mirror of
https://github.com/InstanceBrick/nightcap.git
synced 2026-10-25 10:00:59 +01:00
645 lines
22 KiB
C++
645 lines
22 KiB
C++
#include "runtime/roblox_launch_uri.h"
|
|
|
|
#include <curl/curl.h>
|
|
|
|
#include <algorithm>
|
|
#include <array>
|
|
#include <cctype>
|
|
#include <charconv>
|
|
#include <cstdint>
|
|
#include <limits>
|
|
#include <nlohmann/json.hpp>
|
|
#include <set>
|
|
#include <string>
|
|
#include <string_view>
|
|
#include <utility>
|
|
#include <vector>
|
|
|
|
namespace mocktail {
|
|
namespace runtime {
|
|
namespace {
|
|
|
|
constexpr std::size_t kMaximumParameterCount = 64;
|
|
constexpr std::size_t kMaximumParameterKeyBytes = 128;
|
|
constexpr std::size_t kMaximumParameterValueBytes = 16 * 1024;
|
|
constexpr std::string_view kModernExperiencePrefix = "//experiences/start?";
|
|
constexpr std::string_view kModernDirectPrefix = "//placeid=";
|
|
constexpr std::string_view kPlaceLauncherPath = "/game/placelauncher.ashx";
|
|
|
|
Status Invalid(std::string message) {
|
|
return Status::Error(StatusCode::kInvalidArgument, std::move(message));
|
|
}
|
|
|
|
class ScopedCurlUrl final {
|
|
public:
|
|
ScopedCurlUrl() : handle_(curl_url()) {}
|
|
~ScopedCurlUrl() {
|
|
if (handle_ != nullptr) {
|
|
curl_url_cleanup(handle_);
|
|
}
|
|
}
|
|
|
|
ScopedCurlUrl(const ScopedCurlUrl&) = delete;
|
|
ScopedCurlUrl& operator=(const ScopedCurlUrl&) = delete;
|
|
|
|
CURLU* get() const { return handle_; }
|
|
|
|
private:
|
|
CURLU* handle_ = nullptr;
|
|
};
|
|
|
|
class ScopedCurlString final {
|
|
public:
|
|
ScopedCurlString() = default;
|
|
~ScopedCurlString() { curl_free(value_); }
|
|
|
|
ScopedCurlString(const ScopedCurlString&) = delete;
|
|
ScopedCurlString& operator=(const ScopedCurlString&) = delete;
|
|
|
|
char** output() { return &value_; }
|
|
const char* get() const { return value_; }
|
|
|
|
private:
|
|
char* value_ = nullptr;
|
|
};
|
|
|
|
struct LaunchFields {
|
|
int64_t place_id = 0;
|
|
int64_t user_id = 0;
|
|
int64_t conversation_id = 0;
|
|
int64_t referred_by_player_id = 0;
|
|
std::string request_type;
|
|
std::string game_instance_id;
|
|
std::string reserved_server_access_code;
|
|
std::string call_id;
|
|
std::string referral_page;
|
|
std::string access_code;
|
|
std::string link_code;
|
|
std::string launch_data;
|
|
std::string event_id;
|
|
std::string game_join_context;
|
|
std::string join_attempt_id;
|
|
std::string join_attempt_origin;
|
|
std::string iso_context;
|
|
std::set<std::string> seen_fields;
|
|
};
|
|
|
|
std::string AsciiLower(std::string_view value) {
|
|
std::string normalized(value);
|
|
std::transform(normalized.begin(), normalized.end(), normalized.begin(),
|
|
[](unsigned char character) {
|
|
return static_cast<char>(std::tolower(character));
|
|
});
|
|
return normalized;
|
|
}
|
|
|
|
bool ContainsControlBytes(std::string_view value) {
|
|
return std::any_of(value.begin(), value.end(), [](unsigned char character) {
|
|
return character < 0x20 || character == 0x7f;
|
|
});
|
|
}
|
|
|
|
bool IsValidUtf8(std::string_view value) {
|
|
for (std::size_t index = 0; index < value.size();) {
|
|
const unsigned char first = static_cast<unsigned char>(value[index]);
|
|
if (first <= 0x7f) {
|
|
++index;
|
|
continue;
|
|
}
|
|
std::size_t continuation_count = 0;
|
|
uint32_t code_point = 0;
|
|
if (first >= 0xc2 && first <= 0xdf) {
|
|
continuation_count = 1;
|
|
code_point = first & 0x1f;
|
|
} else if (first >= 0xe0 && first <= 0xef) {
|
|
continuation_count = 2;
|
|
code_point = first & 0x0f;
|
|
} else if (first >= 0xf0 && first <= 0xf4) {
|
|
continuation_count = 3;
|
|
code_point = first & 0x07;
|
|
} else {
|
|
return false;
|
|
}
|
|
if (index + continuation_count >= value.size()) {
|
|
return false;
|
|
}
|
|
for (std::size_t offset = 1; offset <= continuation_count; ++offset) {
|
|
const unsigned char continuation =
|
|
static_cast<unsigned char>(value[index + offset]);
|
|
if ((continuation & 0xc0) != 0x80) {
|
|
return false;
|
|
}
|
|
code_point = (code_point << 6) | (continuation & 0x3f);
|
|
}
|
|
if ((continuation_count == 2 && code_point < 0x800) ||
|
|
(continuation_count == 3 && code_point < 0x10000) ||
|
|
(code_point >= 0xd800 && code_point <= 0xdfff) ||
|
|
code_point > 0x10ffff) {
|
|
return false;
|
|
}
|
|
index += continuation_count + 1;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
int HexDigitValue(char character) {
|
|
if (character >= '0' && character <= '9') {
|
|
return character - '0';
|
|
}
|
|
if (character >= 'a' && character <= 'f') {
|
|
return character - 'a' + 10;
|
|
}
|
|
if (character >= 'A' && character <= 'F') {
|
|
return character - 'A' + 10;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
Status PercentDecode(std::string_view encoded, bool plus_as_space,
|
|
std::string* decoded) {
|
|
if (decoded == nullptr || encoded.size() > kMaximumParameterValueBytes) {
|
|
return Invalid("Roblox launch parameter size is invalid");
|
|
}
|
|
decoded->clear();
|
|
decoded->reserve(encoded.size());
|
|
for (std::size_t index = 0; index < encoded.size(); ++index) {
|
|
unsigned char value = static_cast<unsigned char>(encoded[index]);
|
|
if (encoded[index] == '%') {
|
|
if (index + 2 >= encoded.size()) {
|
|
return Invalid("Roblox launch URI contains invalid percent encoding");
|
|
}
|
|
const int high = HexDigitValue(encoded[index + 1]);
|
|
const int low = HexDigitValue(encoded[index + 2]);
|
|
if (high < 0 || low < 0) {
|
|
return Invalid("Roblox launch URI contains invalid percent encoding");
|
|
}
|
|
value = static_cast<unsigned char>((high << 4) | low);
|
|
index += 2;
|
|
} else if (encoded[index] == '+' && plus_as_space) {
|
|
value = ' ';
|
|
}
|
|
if (value < 0x20 || value == 0x7f) {
|
|
return Invalid("Roblox launch URI contains a control byte");
|
|
}
|
|
decoded->push_back(static_cast<char>(value));
|
|
}
|
|
if (decoded->size() > kMaximumParameterValueBytes || !IsValidUtf8(*decoded)) {
|
|
return Invalid("Roblox launch parameter is not bounded UTF-8");
|
|
}
|
|
return Status::Ok();
|
|
}
|
|
|
|
Status ReadDecimalInteger(std::string_view value, const char* field_name,
|
|
bool allow_zero, int64_t* output) {
|
|
if (output == nullptr || value.empty() || value.front() == '+' ||
|
|
value.front() == '-') {
|
|
return Invalid(std::string("Roblox launch integer is invalid: ") +
|
|
field_name);
|
|
}
|
|
int64_t parsed = 0;
|
|
const auto result =
|
|
std::from_chars(value.data(), value.data() + value.size(), parsed);
|
|
if (result.ec != std::errc() || result.ptr != value.data() + value.size() ||
|
|
parsed < 0 || (!allow_zero && parsed == 0)) {
|
|
return Invalid(std::string("Roblox launch integer is invalid: ") +
|
|
field_name);
|
|
}
|
|
*output = parsed;
|
|
return Status::Ok();
|
|
}
|
|
|
|
Status RememberField(std::string canonical_name, LaunchFields* fields) {
|
|
if (!fields->seen_fields.insert(std::move(canonical_name)).second) {
|
|
return Invalid("Roblox launch URI repeats a singleton field");
|
|
}
|
|
return Status::Ok();
|
|
}
|
|
|
|
Status AssignField(std::string_view raw_key, std::string value,
|
|
bool decode_modern_launch_data, LaunchFields* fields) {
|
|
if (raw_key.empty() || raw_key.size() > kMaximumParameterKeyBytes ||
|
|
fields == nullptr) {
|
|
return Invalid("Roblox launch parameter key is invalid");
|
|
}
|
|
const std::string key = AsciiLower(raw_key);
|
|
std::string canonical_key;
|
|
if (key == "request") {
|
|
canonical_key = "request";
|
|
} else if (key == "placeid") {
|
|
canonical_key = "placeId";
|
|
} else if (key == "userid") {
|
|
canonical_key = "userId";
|
|
} else if (key == "conversationid") {
|
|
canonical_key = "conversationId";
|
|
} else if (key == "referredbyplayerid") {
|
|
canonical_key = "referredByPlayerId";
|
|
} else if (key == "gameid" || key == "gameinstanceid" ||
|
|
key == "instanceid") {
|
|
canonical_key = "gameInstanceId";
|
|
} else if (key == "reservedserveraccesscode") {
|
|
canonical_key = "reservedServerAccessCode";
|
|
} else if (key == "callid") {
|
|
canonical_key = "callId";
|
|
} else if (key == "referralpage") {
|
|
canonical_key = "referralPage";
|
|
} else if (key == "accesscode") {
|
|
canonical_key = "accessCode";
|
|
} else if (key == "linkcode" || key == "privateserverlinkcode") {
|
|
canonical_key = "linkCode";
|
|
} else if (key == "launchdata") {
|
|
canonical_key = "launchData";
|
|
} else if (key == "eventid") {
|
|
canonical_key = "eventId";
|
|
} else if (key == "gamejoincontext") {
|
|
canonical_key = "gameJoinContext";
|
|
} else if (key == "joinattemptid") {
|
|
canonical_key = "joinAttemptId";
|
|
} else if (key == "joinattemptorigin") {
|
|
canonical_key = "joinAttemptOrigin";
|
|
} else if (key == "isocontext") {
|
|
canonical_key = "isoContext";
|
|
} else {
|
|
return Status::Ok();
|
|
}
|
|
|
|
Status status = RememberField(canonical_key, fields);
|
|
if (!status.ok()) {
|
|
return status;
|
|
}
|
|
if (canonical_key == "launchData" && decode_modern_launch_data &&
|
|
value.find('%') != std::string::npos) {
|
|
std::string decoded;
|
|
status = PercentDecode(value, false, &decoded);
|
|
if (!status.ok()) {
|
|
return status;
|
|
}
|
|
value = std::move(decoded);
|
|
}
|
|
if (canonical_key == "request") {
|
|
fields->request_type = std::move(value);
|
|
} else if (canonical_key == "placeId") {
|
|
status = ReadDecimalInteger(value, "placeId", false, &fields->place_id);
|
|
} else if (canonical_key == "userId") {
|
|
status = ReadDecimalInteger(value, "userId", true, &fields->user_id);
|
|
} else if (canonical_key == "conversationId") {
|
|
status = ReadDecimalInteger(value, "conversationId", true,
|
|
&fields->conversation_id);
|
|
} else if (canonical_key == "referredByPlayerId") {
|
|
status = ReadDecimalInteger(value, "referredByPlayerId", true,
|
|
&fields->referred_by_player_id);
|
|
} else if (canonical_key == "gameInstanceId") {
|
|
fields->game_instance_id = std::move(value);
|
|
} else if (canonical_key == "reservedServerAccessCode") {
|
|
fields->reserved_server_access_code = std::move(value);
|
|
} else if (canonical_key == "callId") {
|
|
fields->call_id = std::move(value);
|
|
} else if (canonical_key == "referralPage") {
|
|
fields->referral_page = std::move(value);
|
|
} else if (canonical_key == "accessCode") {
|
|
fields->access_code = std::move(value);
|
|
} else if (canonical_key == "linkCode") {
|
|
fields->link_code = std::move(value);
|
|
} else if (canonical_key == "launchData") {
|
|
fields->launch_data = std::move(value);
|
|
} else if (canonical_key == "eventId") {
|
|
fields->event_id = std::move(value);
|
|
} else if (canonical_key == "gameJoinContext") {
|
|
fields->game_join_context = std::move(value);
|
|
} else if (canonical_key == "joinAttemptId") {
|
|
fields->join_attempt_id = std::move(value);
|
|
} else if (canonical_key == "joinAttemptOrigin") {
|
|
fields->join_attempt_origin = std::move(value);
|
|
} else if (canonical_key == "isoContext") {
|
|
fields->iso_context = std::move(value);
|
|
}
|
|
return status;
|
|
}
|
|
|
|
Status ParseQuery(std::string_view query, bool decode_modern_launch_data,
|
|
LaunchFields* fields) {
|
|
if (query.empty() || fields == nullptr) {
|
|
return Invalid("Roblox launch URI query is missing");
|
|
}
|
|
std::size_t count = 0;
|
|
std::size_t begin = 0;
|
|
while (begin <= query.size()) {
|
|
if (++count > kMaximumParameterCount) {
|
|
return Invalid("Roblox launch URI has too many parameters");
|
|
}
|
|
const std::size_t end = query.find('&', begin);
|
|
const std::string_view pair =
|
|
query.substr(begin, end == std::string_view::npos ? query.size() - begin
|
|
: end - begin);
|
|
if (pair.empty()) {
|
|
return Invalid("Roblox launch URI contains an empty parameter");
|
|
}
|
|
const std::size_t equals = pair.find('=');
|
|
if (equals == std::string_view::npos || equals == 0) {
|
|
return Invalid("Roblox launch URI parameter has no value");
|
|
}
|
|
std::string key;
|
|
std::string value;
|
|
Status status = PercentDecode(pair.substr(0, equals), true, &key);
|
|
if (status.ok()) {
|
|
status = PercentDecode(pair.substr(equals + 1), true, &value);
|
|
}
|
|
if (status.ok()) {
|
|
status =
|
|
AssignField(key, std::move(value), decode_modern_launch_data, fields);
|
|
}
|
|
if (!status.ok()) {
|
|
return status;
|
|
}
|
|
if (end == std::string_view::npos) {
|
|
break;
|
|
}
|
|
begin = end + 1;
|
|
}
|
|
return Status::Ok();
|
|
}
|
|
|
|
Status GetRequiredUrlPart(CURLU* url, CURLUPart part, const char* label,
|
|
std::string* output) {
|
|
ScopedCurlString value;
|
|
if (url == nullptr || output == nullptr ||
|
|
curl_url_get(url, part, value.output(), 0) != CURLUE_OK ||
|
|
value.get() == nullptr) {
|
|
return Invalid(std::string("Roblox PlaceLauncher URL has no ") + label);
|
|
}
|
|
*output = value.get();
|
|
return Status::Ok();
|
|
}
|
|
|
|
bool HasUrlPart(CURLU* url, CURLUPart part) {
|
|
ScopedCurlString value;
|
|
return url != nullptr &&
|
|
curl_url_get(url, part, value.output(), 0) == CURLUE_OK &&
|
|
value.get() != nullptr && value.get()[0] != '\0';
|
|
}
|
|
|
|
Status ParsePlaceLauncherUrl(std::string_view launcher_url,
|
|
LaunchFields* fields) {
|
|
if (launcher_url.empty() ||
|
|
launcher_url.size() > kMaximumRobloxLaunchUriBytes ||
|
|
ContainsControlBytes(launcher_url) || !IsValidUtf8(launcher_url)) {
|
|
return Invalid("Roblox PlaceLauncher URL is invalid");
|
|
}
|
|
const ScopedCurlUrl parsed;
|
|
const std::string owned_url(launcher_url);
|
|
if (parsed.get() == nullptr ||
|
|
curl_url_set(parsed.get(), CURLUPART_URL, owned_url.c_str(),
|
|
CURLU_NON_SUPPORT_SCHEME | CURLU_PATH_AS_IS) != CURLUE_OK) {
|
|
return Invalid("Roblox PlaceLauncher URL cannot be parsed");
|
|
}
|
|
|
|
std::string scheme;
|
|
std::string host;
|
|
std::string path;
|
|
std::string query;
|
|
Status status =
|
|
GetRequiredUrlPart(parsed.get(), CURLUPART_SCHEME, "scheme", &scheme);
|
|
if (status.ok()) {
|
|
status = GetRequiredUrlPart(parsed.get(), CURLUPART_HOST, "host", &host);
|
|
}
|
|
if (status.ok()) {
|
|
status = GetRequiredUrlPart(parsed.get(), CURLUPART_PATH, "path", &path);
|
|
}
|
|
if (status.ok()) {
|
|
status = GetRequiredUrlPart(parsed.get(), CURLUPART_QUERY, "query", &query);
|
|
}
|
|
if (!status.ok()) {
|
|
return status;
|
|
}
|
|
const std::string normalized_host = AsciiLower(host);
|
|
if (AsciiLower(scheme) != "https" ||
|
|
(normalized_host != "www.roblox.com" && normalized_host != "roblox.com" &&
|
|
normalized_host != "assetgame.roblox.com") ||
|
|
AsciiLower(path) != kPlaceLauncherPath ||
|
|
HasUrlPart(parsed.get(), CURLUPART_USER) ||
|
|
HasUrlPart(parsed.get(), CURLUPART_PASSWORD) ||
|
|
HasUrlPart(parsed.get(), CURLUPART_FRAGMENT)) {
|
|
return Invalid("Roblox PlaceLauncher URL origin or path is not allowed");
|
|
}
|
|
ScopedCurlString port;
|
|
const CURLUcode port_status =
|
|
curl_url_get(parsed.get(), CURLUPART_PORT, port.output(), 0);
|
|
if (port_status == CURLUE_OK && port.get() != nullptr &&
|
|
std::string_view(port.get()) != "443") {
|
|
return Invalid("Roblox PlaceLauncher URL port is not allowed");
|
|
}
|
|
return ParseQuery(query, false, fields);
|
|
}
|
|
|
|
Status ParseClassic(std::string_view payload, LaunchFields* fields) {
|
|
if (payload.empty() || fields == nullptr) {
|
|
return Invalid("Roblox player launch payload is missing");
|
|
}
|
|
std::size_t begin = 0;
|
|
std::size_t count = 0;
|
|
bool version_seen = false;
|
|
bool launch_mode_seen = false;
|
|
bool launcher_url_seen = false;
|
|
std::string launcher_url;
|
|
while (begin <= payload.size()) {
|
|
if (++count > kMaximumParameterCount) {
|
|
return Invalid("Roblox player launch payload has too many fields");
|
|
}
|
|
const std::size_t end = payload.find('+', begin);
|
|
const std::string_view field = payload.substr(
|
|
begin,
|
|
end == std::string_view::npos ? payload.size() - begin : end - begin);
|
|
if (field.size() >
|
|
kMaximumParameterKeyBytes + 1 + kMaximumParameterValueBytes) {
|
|
return Invalid("Roblox player launch field is too large");
|
|
}
|
|
if (!version_seen) {
|
|
if (field != "1") {
|
|
return Invalid("Roblox player launch version is unsupported");
|
|
}
|
|
version_seen = true;
|
|
} else {
|
|
const std::size_t colon = field.find(':');
|
|
if (colon == std::string_view::npos || colon == 0) {
|
|
return Invalid("Roblox player launch field is malformed");
|
|
}
|
|
const std::string key = AsciiLower(field.substr(0, colon));
|
|
const std::string_view encoded_value = field.substr(colon + 1);
|
|
if (key == "launchmode") {
|
|
if (launch_mode_seen) {
|
|
return Invalid("Roblox player launch repeats launchmode");
|
|
}
|
|
launch_mode_seen = true;
|
|
std::string launch_mode;
|
|
Status status = PercentDecode(encoded_value, false, &launch_mode);
|
|
if (!status.ok()) {
|
|
return status;
|
|
}
|
|
if (AsciiLower(launch_mode) != "play") {
|
|
return Invalid("Roblox player launch mode is unsupported");
|
|
}
|
|
} else if (key == "placelauncherurl") {
|
|
if (launcher_url_seen) {
|
|
return Invalid("Roblox player launch repeats PlaceLauncher URL");
|
|
}
|
|
launcher_url_seen = true;
|
|
Status status = PercentDecode(encoded_value, false, &launcher_url);
|
|
if (!status.ok()) {
|
|
return status;
|
|
}
|
|
}
|
|
// gameinfo is an authentication ticket. Unknown outer metadata,
|
|
// including gameinfo and browsertrackerid, is never decoded or retained.
|
|
}
|
|
if (end == std::string_view::npos) {
|
|
break;
|
|
}
|
|
begin = end + 1;
|
|
}
|
|
if (!version_seen || !launch_mode_seen || !launcher_url_seen) {
|
|
return Invalid("Roblox player launch contract is incomplete");
|
|
}
|
|
Status status = ParsePlaceLauncherUrl(launcher_url, fields);
|
|
if (status.ok() && fields->request_type.empty()) {
|
|
status = Invalid("Roblox PlaceLauncher request type is missing");
|
|
}
|
|
return status;
|
|
}
|
|
|
|
Status ValidateSelector(const LaunchFields& fields) {
|
|
const bool private_join =
|
|
!fields.access_code.empty() || !fields.link_code.empty();
|
|
const std::array<bool, 5> selectors = {
|
|
fields.user_id > 0, fields.conversation_id > 0, private_join,
|
|
!fields.game_instance_id.empty(),
|
|
!fields.reserved_server_access_code.empty()};
|
|
const std::size_t selector_count = static_cast<std::size_t>(
|
|
std::count(selectors.begin(), selectors.end(), true));
|
|
if (selector_count > 1) {
|
|
return Invalid("Roblox launch URI contains conflicting join selectors");
|
|
}
|
|
if (fields.request_type.empty()) {
|
|
return Status::Ok();
|
|
}
|
|
const std::string request = AsciiLower(fields.request_type);
|
|
if (request == "requestgame") {
|
|
return selector_count == 0
|
|
? Status::Ok()
|
|
: Invalid("RequestGame contains a non-public join selector");
|
|
}
|
|
if (request == "requestgamejob") {
|
|
return !fields.game_instance_id.empty()
|
|
? Status::Ok()
|
|
: Invalid("RequestGameJob requires a game instance");
|
|
}
|
|
if (request == "requestprivategame") {
|
|
return private_join || !fields.reserved_server_access_code.empty()
|
|
? Status::Ok()
|
|
: Invalid("RequestPrivateGame requires an access code");
|
|
}
|
|
if (request == "requestplaytogethergame") {
|
|
return fields.conversation_id > 0
|
|
? Status::Ok()
|
|
: Invalid("RequestPlayTogetherGame requires a conversation");
|
|
}
|
|
return Invalid("Roblox PlaceLauncher request type is unsupported");
|
|
}
|
|
|
|
Status MakeRequest(const LaunchFields& fields,
|
|
RobloxExperienceLaunchRequest* request) {
|
|
if (fields.place_id <= 0) {
|
|
return Invalid("Roblox launch URI requires a positive placeId");
|
|
}
|
|
Status status = ValidateSelector(fields);
|
|
if (!status.ok()) {
|
|
return status;
|
|
}
|
|
nlohmann::json document = {{"placeId", fields.place_id}};
|
|
const auto add_integer = [&document](const char* name, int64_t value) {
|
|
if (value > 0) {
|
|
document[name] = value;
|
|
}
|
|
};
|
|
const auto add_string = [&document](const char* name,
|
|
const std::string& value) {
|
|
if (!value.empty()) {
|
|
document[name] = value;
|
|
}
|
|
};
|
|
add_integer("userId", fields.user_id);
|
|
add_integer("conversationId", fields.conversation_id);
|
|
add_integer("referredByPlayerId", fields.referred_by_player_id);
|
|
add_string("gameInstanceId", fields.game_instance_id);
|
|
add_string("reservedServerAccessCode", fields.reserved_server_access_code);
|
|
add_string("callId", fields.call_id);
|
|
add_string("referralPage", fields.referral_page);
|
|
add_string("accessCode", fields.access_code);
|
|
add_string("linkCode", fields.link_code);
|
|
add_string("launchData", fields.launch_data);
|
|
add_string("eventId", fields.event_id);
|
|
add_string("gameJoinContext", fields.game_join_context);
|
|
add_string("joinAttemptId", fields.join_attempt_id);
|
|
add_string("joinAttemptOrigin", fields.join_attempt_origin);
|
|
add_string("isoContext", fields.iso_context);
|
|
return ParseRobloxExperienceLaunchJson(document.dump(), request);
|
|
}
|
|
|
|
Status ParseModern(std::string_view payload, LaunchFields* fields) {
|
|
std::string_view query;
|
|
bool decode_launch_data_twice = false;
|
|
if (payload.size() > kModernExperiencePrefix.size() &&
|
|
AsciiLower(payload.substr(0, kModernExperiencePrefix.size())) ==
|
|
kModernExperiencePrefix) {
|
|
query = payload.substr(kModernExperiencePrefix.size());
|
|
decode_launch_data_twice = true;
|
|
} else if (payload.size() >= kModernDirectPrefix.size() &&
|
|
AsciiLower(payload.substr(0, kModernDirectPrefix.size())) ==
|
|
kModernDirectPrefix) {
|
|
// Roblox's documented direct-app form is
|
|
// roblox://placeId=<id>&..., without a '?' separator.
|
|
query = payload.substr(2);
|
|
} else {
|
|
return Invalid("Roblox launch path is unsupported");
|
|
}
|
|
if (query.find('#') != std::string_view::npos ||
|
|
query.find('/') != std::string_view::npos) {
|
|
return Invalid("Roblox launch URI contains an unsupported fragment");
|
|
}
|
|
return ParseQuery(query, decode_launch_data_twice, fields);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
Status ParseRobloxLaunchUri(std::string_view uri,
|
|
RobloxExperienceLaunchRequest* request) {
|
|
if (request == nullptr) {
|
|
return Invalid("Roblox launch output is null");
|
|
}
|
|
*request = {};
|
|
if (uri.empty() || uri.size() > kMaximumRobloxLaunchUriBytes ||
|
|
ContainsControlBytes(uri) || !IsValidUtf8(uri)) {
|
|
return Invalid("Roblox launch URI size or encoding is invalid");
|
|
}
|
|
const std::size_t colon = uri.find(':');
|
|
if (colon == std::string_view::npos || colon == 0) {
|
|
return Invalid("Roblox launch URI has no supported scheme");
|
|
}
|
|
const std::string scheme = AsciiLower(uri.substr(0, colon));
|
|
const std::string_view payload = uri.substr(colon + 1);
|
|
LaunchFields fields;
|
|
Status status;
|
|
if (scheme == "roblox-player") {
|
|
status = ParseClassic(payload, &fields);
|
|
} else if (scheme == "roblox") {
|
|
status = ParseModern(payload, &fields);
|
|
} else {
|
|
return Invalid("Roblox launch URI scheme is unsupported");
|
|
}
|
|
if (!status.ok()) {
|
|
return status;
|
|
}
|
|
return MakeRequest(fields, request);
|
|
}
|
|
|
|
} // namespace runtime
|
|
} // namespace mocktail
|