From 3c9240e7d8e650a489489d8cebd12424a626180c Mon Sep 17 00:00:00 2001 From: James Osborne Date: Tue, 9 Jun 2026 20:52:47 -0400 Subject: [PATCH] Add account admin mutation endpoints --- src/HTTPServer.cc | 288 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 286 insertions(+), 2 deletions(-) diff --git a/src/HTTPServer.cc b/src/HTTPServer.cc index d5450847..7a44148c 100644 --- a/src/HTTPServer.cc +++ b/src/HTTPServer.cc @@ -1,7 +1,9 @@ #include "HTTPServer.hh" +#include #include #include +#include #include #include @@ -30,6 +32,94 @@ HTTPServer::HTTPServer(std::shared_ptr state) }); }; + auto parse_u32_string = [](const std::string& value_str, int base, const char* field_name) -> uint32_t { + if (value_str.empty()) { + throw HTTPError(400, std::format("{} is required", field_name)); + } + + size_t conversion_end = 0; + uint64_t value = 0; + try { + value = std::stoull(value_str, &conversion_end, base); + } catch (const std::exception&) { + throw HTTPError(400, std::format("Invalid {}", field_name)); + } + + if (conversion_end != value_str.size()) { + throw HTTPError(400, std::format("Invalid {}", field_name)); + } + if (value > 0xFFFFFFFFULL) { + throw HTTPError(400, std::format("{} out of range", field_name)); + } + return value; + }; + + auto parse_u32_decimal_or_0x = [parse_u32_string](const std::string& value_str, const char* field_name) -> uint32_t { + int base = 10; + if ((value_str.size() >= 2) && + (value_str[0] == '0') && + ((value_str[1] == 'x') || (value_str[1] == 'X'))) { + base = 16; + } + return parse_u32_string(value_str, base, field_name); + }; + + auto parse_account_id = [parse_u32_decimal_or_0x](const std::string& account_id_str) -> uint32_t { + return parse_u32_decimal_or_0x(account_id_str, "account ID"); + }; + + auto require_nonzero_u32 = [](uint32_t value, const char* field_name) -> uint32_t { + if (value == 0) { + throw HTTPError(400, std::format("{} is zero", field_name)); + } + return value; + }; + + auto require_string_length = [](const std::string& value, const char* field_name, size_t min_size, size_t max_size) -> void { + if (value.size() < min_size) { + throw HTTPError(400, std::format("{} is too short", field_name)); + } + if (value.size() > max_size) { + throw HTTPError(400, std::format("{} is too long", field_name)); + } + }; + + auto require_exact_string_length = [](const std::string& value, const char* field_name, size_t expected_size) -> void { + if (value.size() != expected_size) { + throw HTTPError(400, std::format("{} length is incorrect", field_name)); + } + }; + + auto normalize_license_type = [](std::string type_str) -> std::string { + for (char& ch : type_str) { + ch = static_cast(toupper(static_cast(ch))); + } + return type_str; + }; + + auto require_account_admin_secret = [this](const HTTPRequest& req) -> void { + const auto& account_sync_config = this->state->config_json->get("AccountSync", phosg::JSON::dict()); + std::string shared_secret = account_sync_config.get_string("SharedSecret", ""); + if (shared_secret.empty()) { + throw HTTPError(403, "Account admin mutations are disabled"); + } + + const std::string* header_secret = req.get_header("x-psopeeps-admin-secret"); + if (!header_secret || (*header_secret != shared_secret)) { + throw HTTPError(403, "Forbidden"); + } + }; + + auto account_mutation_response = [](const char* action, std::shared_ptr account) -> std::shared_ptr { + return std::make_shared(phosg::JSON::dict({ + {"OK", true}, + {"Action", action}, + {"AccountID", account->account_id}, + {"AccountIDDecimal", std::format("{:010}", account->account_id)}, + {"AccountIDHex", std::format("{:08X}", account->account_id)}, + })); + }; + this->router.add(HTTPRequest::Method::GET, "/", [generate_server_version_json](ArgsT&&) -> RetT { co_return std::make_shared(generate_server_version_json()); }); @@ -661,8 +751,8 @@ HTTPServer::HTTPServer(std::shared_ptr state) co_return res; }); - this->router.add(HTTPRequest::Method::GET, "/y/account/:account_id", [this](ArgsT&& args) -> RetT { - uint32_t account_id = args.get_param("account_id"); + this->router.add(HTTPRequest::Method::GET, "/y/account/:account_id", [this, parse_account_id](ArgsT&& args) -> RetT { + uint32_t account_id = parse_account_id(args.params.at("account_id")); try { co_return std::make_shared(this->state->account_index->from_account_id(account_id)->json()); } catch (const AccountIndex::missing_account&) { @@ -670,6 +760,200 @@ HTTPServer::HTTPServer(std::shared_ptr state) } }); + this->router.add(HTTPRequest::Method::POST, "/y/account/:account_id/ensure", + [this, parse_account_id, parse_u32_decimal_or_0x, require_string_length, require_account_admin_secret, account_mutation_response](ArgsT&& args) -> RetT { + require_account_admin_secret(args.req); + uint32_t account_id = parse_account_id(args.params.at("account_id")); + + try { + std::shared_ptr account; + try { + account = this->state->account_index->from_account_id(account_id); + } catch (const AccountIndex::missing_account&) { + account = std::make_shared(); + account->account_id = account_id; + account->flags = parse_u32_decimal_or_0x(args.post_data.get_string("flags", "0"), "flags"); + account->user_flags = parse_u32_decimal_or_0x(args.post_data.get_string("user_flags", "0"), "user_flags"); + this->state->account_index->add(account); + } + + std::string bb_username = args.post_data.get_string("bb_username", ""); + if (!bb_username.empty()) { + auto license = std::make_shared(); + license->username = std::move(bb_username); + license->password = args.post_data.get_string("bb_password", ""); + require_string_length(license->username, "bb_username", 1, 16); + require_string_length(license->password, "bb_password", 1, 16); + + auto existing_it = account->bb_licenses.find(license->username); + if (existing_it != account->bb_licenses.end()) { + if (existing_it->second->password != license->password) { + throw HTTPError(409, "BB license already exists on account with different password"); + } + } else { + this->state->account_index->add_bb_license(account, license); + } + } + + account->save(); + co_return account_mutation_response("ensure-account", account); + + } catch (const HTTPError&) { + throw; + } catch (const std::runtime_error& e) { + if (!strcmp(e.what(), "username already registered")) { + throw HTTPError(409, e.what()); + } + throw HTTPError(400, e.what()); + } catch (const std::exception& e) { + throw HTTPError(400, e.what()); + } + }); + + this->router.add(HTTPRequest::Method::POST, "/y/account/:account_id/add-license", + [this, parse_account_id, parse_u32_string, require_nonzero_u32, require_string_length, require_exact_string_length, normalize_license_type, require_account_admin_secret, account_mutation_response](ArgsT&& args) -> RetT { + require_account_admin_secret(args.req); + uint32_t account_id = parse_account_id(args.params.at("account_id")); + + try { + auto account = this->state->account_index->from_account_id(account_id); + std::string type_str = normalize_license_type(args.post_data.get_string("type")); + + if (type_str == "DC") { + auto license = std::make_shared(); + license->serial_number = require_nonzero_u32(parse_u32_string(args.post_data.get_string("serial_number"), 16, "serial_number"), "serial_number"); + license->access_key = args.post_data.get_string("access_key"); + require_exact_string_length(license->access_key, "access_key", 8); + auto existing_it = account->dc_licenses.find(license->serial_number); + if (existing_it != account->dc_licenses.end()) { + if (existing_it->second->access_key != license->access_key) { + throw HTTPError(409, "DC license already exists on account with different access key"); + } + co_return account_mutation_response("add-license", account); + } + this->state->account_index->add_dc_license(account, license); + + } else if (type_str == "PC") { + auto license = std::make_shared(); + license->serial_number = require_nonzero_u32(parse_u32_string(args.post_data.get_string("serial_number"), 16, "serial_number"), "serial_number"); + license->access_key = args.post_data.get_string("access_key"); + require_exact_string_length(license->access_key, "access_key", 8); + auto existing_it = account->pc_licenses.find(license->serial_number); + if (existing_it != account->pc_licenses.end()) { + if (existing_it->second->access_key != license->access_key) { + throw HTTPError(409, "PC license already exists on account with different access key"); + } + co_return account_mutation_response("add-license", account); + } + this->state->account_index->add_pc_license(account, license); + + } else if (type_str == "GC") { + auto license = std::make_shared(); + license->serial_number = require_nonzero_u32(parse_u32_string(args.post_data.get_string("serial_number"), 10, "serial_number"), "serial_number"); + license->access_key = args.post_data.get_string("access_key"); + license->password = args.post_data.get_string("password"); + require_exact_string_length(license->access_key, "access_key", 12); + require_string_length(license->password, "password", 1, 8); + auto existing_it = account->gc_licenses.find(license->serial_number); + if (existing_it != account->gc_licenses.end()) { + if ((existing_it->second->access_key != license->access_key) || + (existing_it->second->password != license->password)) { + throw HTTPError(409, "GC license already exists on account with different credentials"); + } + co_return account_mutation_response("add-license", account); + } + this->state->account_index->add_gc_license(account, license); + + } else if (type_str == "BB") { + auto license = std::make_shared(); + license->username = args.post_data.get_string("username"); + license->password = args.post_data.get_string("password", ""); + require_string_length(license->username, "username", 1, 16); + require_string_length(license->password, "password", 1, 16); + auto existing_it = account->bb_licenses.find(license->username); + if (existing_it != account->bb_licenses.end()) { + if (existing_it->second->password != license->password) { + throw HTTPError(409, "BB license already exists on account with different password"); + } + co_return account_mutation_response("add-license", account); + } + this->state->account_index->add_bb_license(account, license); + + } else { + throw HTTPError(400, "Invalid license type"); + } + + account->save(); + co_return account_mutation_response("add-license", account); + + } catch (const AccountIndex::missing_account&) { + throw HTTPError(404, "Account does not exist"); + } catch (const HTTPError&) { + throw; + } catch (const std::runtime_error& e) { + if (!strcmp(e.what(), "serial number already registered") || + !strcmp(e.what(), "username already registered")) { + throw HTTPError(409, e.what()); + } + throw HTTPError(400, e.what()); + } catch (const std::exception& e) { + throw HTTPError(400, e.what()); + } + }); + + this->router.add(HTTPRequest::Method::POST, "/y/account/:account_id/delete-license", + [this, parse_account_id, parse_u32_string, normalize_license_type, require_account_admin_secret, account_mutation_response](ArgsT&& args) -> RetT { + require_account_admin_secret(args.req); + uint32_t account_id = parse_account_id(args.params.at("account_id")); + + try { + auto account = this->state->account_index->from_account_id(account_id); + std::string type_str = normalize_license_type(args.post_data.get_string("type")); + + if (type_str == "DC") { + uint32_t serial_number = parse_u32_string(args.post_data.get_string("serial_number"), 16, "serial_number"); + if (account->dc_licenses.find(serial_number) == account->dc_licenses.end()) { + co_return account_mutation_response("delete-license", account); + } + this->state->account_index->remove_dc_license(account, serial_number); + + } else if (type_str == "PC") { + uint32_t serial_number = parse_u32_string(args.post_data.get_string("serial_number"), 16, "serial_number"); + if (account->pc_licenses.find(serial_number) == account->pc_licenses.end()) { + co_return account_mutation_response("delete-license", account); + } + this->state->account_index->remove_pc_license(account, serial_number); + + } else if (type_str == "GC") { + uint32_t serial_number = parse_u32_string(args.post_data.get_string("serial_number"), 10, "serial_number"); + if (account->gc_licenses.find(serial_number) == account->gc_licenses.end()) { + co_return account_mutation_response("delete-license", account); + } + this->state->account_index->remove_gc_license(account, serial_number); + + } else if (type_str == "BB") { + std::string username = args.post_data.get_string("username"); + if (account->bb_licenses.find(username) == account->bb_licenses.end()) { + co_return account_mutation_response("delete-license", account); + } + this->state->account_index->remove_bb_license(account, username); + + } else { + throw HTTPError(400, "Invalid license type"); + } + + account->save(); + co_return account_mutation_response("delete-license", account); + + } catch (const AccountIndex::missing_account&) { + throw HTTPError(404, "Account does not exist"); + } catch (const HTTPError&) { + throw; + } catch (const std::exception& e) { + throw HTTPError(400, e.what()); + } + }); + this->router.add(HTTPRequest::Method::GET, "/y/teams", [this](ArgsT&&) -> RetT { auto res = std::make_shared(phosg::JSON::dict()); for (const auto& it : this->state->team_index->all()) {