Files
DarkflameServer/dDashboardServer/routes/APIRoutes.cpp
Aaron Kimbrell 931277e76c feat(dashboard): show each pet's icon and kind when moderating pet names
The pet name tables (Pet Names page and the review queue) get the pet's
LOT from its owner's save and its CDClient name, shown with the icon in
a new Pet column. Each owner on the page is read once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:30:55 -05:00

1496 lines
81 KiB
C++

#include "APIRoutes.h"
#include "PropertyAssets.h"
#include "OpenApi.h"
#include "RouteUtils.h"
#include "Permissions.h"
#include "DashboardAuthService.h"
#include "DashboardRoutes.h"
#include "MaintenanceRoutes.h"
#include "ReportRoutes.h"
#include "Strikes.h"
#include "AccountModeration.h"
#include "BehaviorXml.h"
#include "CharacterTools.h"
#include "ClientAssets.h"
#include "CharacterXml.h"
#include "Scenery.h"
#include "Workers.h"
#include "LiveWorld.h"
#include "ServerState.h"
#include "WSRoutes.h"
#include "PlayerActions.h"
#include "AuthTokenHandler.h"
#include "JWTUtils.h"
#include "eServerDisconnectIdentifiers.h"
#include "Web.h"
#include "eHTTPMethod.h"
#include "json.hpp"
#include "Game.h"
#include "Database.h"
#include "Logger.h"
#include "HTTPContext.h"
#include "eGameMasterLevel.h"
#include "ePermissionMap.h"
#include "CDClientDatabase.h"
#include "dConfig.h"
#include "MailInfo.h"
#include "tinyxml2.h"
#include "Sd0.h"
#include <sstream>
#include <map>
#include "Locale.h"
#include <bcrypt/BCrypt.hpp>
#include <algorithm>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <random>
#include <set>
using namespace RouteUtils;
namespace {
// Tell the player in game about a moderation decision. The reply carries the request id, so the dashboard
// shows whether they were online to hear it.
nlohmann::json NotifyModeration(PlayerActionRequest request, uint32_t requester) {
const auto id = PlayerActions::Request(request, requester, [](const PlayerActionResult& result) -> PlayerActions::Outcome {
return { true, result.affected ? "The player was told in game." : "The player isn't online; they'll see the result next time they play." };
});
return { {"requestId", id} };
}
constexpr uint32_t MAX_PLAY_KEYS_PER_REQUEST = 100;
constexpr int16_t MAX_MAIL_ATTACHMENT_COUNT = 999;
using TableFetcher = std::function<std::string(const DataTablesRequest&, const nlohmann::json& body)>;
// A placed model's behaviors (up to 5), parsed for the viewer
nlohmann::json ModelBehaviors(const IPropertyContents::Model& model) {
nlohmann::json list = nlohmann::json::array();
for (const auto behaviorId : model.behaviors) {
if (behaviorId == 0) continue;
auto parsed = BehaviorXml::Parse(Database::Get()->GetBehavior(behaviorId));
if (parsed.is_null()) continue;
parsed["id"] = std::to_string(behaviorId);
list.push_back(std::move(parsed));
}
return list;
}
// Table rows with zone_name next to zone_id
std::string WithZoneNames(const std::string& table) {
auto json = nlohmann::json::parse(table, nullptr, false);
if (json.is_discarded()) return table;
const auto& zones = ZoneNames();
for (auto& row : json["data"]) {
const auto zone = std::to_string(row.value("zone_id", 0));
row["zone_name"] = zones.contains(zone) ? zones[zone].get<std::string>() : "Zone " + zone;
}
return json.dump();
}
/**
* Adds each pet's LOT and kind (its CDClient name) to pet_names rows. pet_names doesn't hold the LOT; the owner's
* save does (<pet><p id l/>), so each owner on the page is read once. Pets whose owner is unknown, or who have
* left the owner's save, get lot 0.
*/
std::string WithPetKinds(const std::string& raw) {
auto json = nlohmann::json::parse(raw, nullptr, false);
if (json.is_discarded() || !json.contains("data") || !json["data"].is_array()) return raw;
std::map<LWOOBJID, std::map<LWOOBJID, LOT>> owners;
for (auto& row : json["data"]) {
const auto owner = GeneralUtils::TryParse<LWOOBJID>(row.value("owner_id", std::string{})).value_or(0);
const auto pet = GeneralUtils::TryParse<LWOOBJID>(row.value("id", std::string{})).value_or(0);
LOT lot = 0;
if (owner && pet) {
auto [it, added] = owners.try_emplace(owner);
if (added) for (const auto& held : CharacterXml::Pets(Database::Get()->GetCharacterXml(owner))) it->second[held.id] = held.lot;
if (const auto found = it->second.find(pet); found != it->second.end()) lot = found->second;
}
row["lot"] = lot;
row["kind"] = lot > 0 ? ClientAssets::ItemName(lot) : "";
}
return json.dump();
}
// Register a DataTables endpoint. The fetcher returns the DB layer's JSON string. Access: a GM level or a Perm.
template<typename Access>
void TableRoute(const std::string& path, const Access& access, const std::string& description, TableFetcher fetcher) {
Route(eHTTPMethod::POST, path, access, description, [fetcher = std::move(fetcher)](HTTPReply& reply, const HTTPContext& context) {
const auto request = ParseDataTablesRequest(context.body);
const auto body = ParseBody(context);
if (!request || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
auto response = nlohmann::json::parse(fetcher(*request, *body), nullptr, false);
if (response.is_discarded()) return JsonError(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "Database error");
response["draw"] = request->draw;
JsonReply(reply, eHTTPStatusCode::OK, response);
});
}
// Parse the numeric ID at the given path segment, replying 400 if it is not a valid number
template<typename T>
std::optional<T> RequireId(const HTTPContext& context, size_t segment, HTTPReply& reply) {
auto id = PathId<T>(context.path, segment);
if (!id) JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid ID");
return id;
}
// Account that owns a character, for applying the account hierarchy to character actions
std::optional<uint32_t> CharacterOwner(LWOOBJID charId, HTTPReply& reply) {
const auto info = Database::Get()->GetCharacterInfo(charId);
if (!info) {
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
return std::nullopt;
}
return info->accountId;
}
// Random play key in the XXXX-XXXX-XXXX-XXXX format used by NexusDashboard
std::string GeneratePlayKey() {
static constexpr std::string_view alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no 0/O/1/I
std::random_device rd;
std::uniform_int_distribution<size_t> dist(0, alphabet.size() - 1);
std::string key;
for (int group = 0; group < 4; group++) {
if (group > 0) key += '-';
for (int i = 0; i < 4; i++) key += alphabet[dist(rd)];
}
return key;
}
std::string Sessions(uint32_t count) {
return std::to_string(count) + (count == 1 ? " online session" : " online sessions");
}
// Moderation history entries and kicks, shared with strike thresholds
using AccountModeration::Note;
using AccountModeration::KickAccount;
std::string Reason(const nlohmann::json& body) {
std::string reason = body.value("reason", "");
reason.erase(0, reason.find_first_not_of(" \t"));
reason.erase(reason.find_last_not_of(" \t") + 1);
return reason.substr(0, 300);
}
// Remembered so the player can see what happened to a name they asked for, and why (optional body {reason})
void Decide(const HTTPContext& context, const std::string& kind, int64_t subjectId, const std::string& subject, bool approved) {
const auto body = ParseBody(context);
Database::Get()->InsertModerationDecision(kind, subjectId, subject.substr(0, 64), approved, body ? Reason(*body).substr(0, 255) : "", static_cast<int64_t>(std::time(nullptr)));
}
// Apply account changes (GM level) to online sessions right away
uint32_t RefreshAccount(uint32_t accountId, uint32_t requester) {
PlayerActionRequest request;
request.action = ePlayerAction::REFRESH_ACCOUNT;
request.accountId = accountId;
return PlayerActions::Request(request, requester, [](const PlayerActionResult& result) {
return PlayerActions::Outcome{ true, result.affected == 0 ? "Account was not online" : "Applied to " + Sessions(result.affected) };
});
}
bool ZoneExists(LWOMAPID zoneId) {
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT zoneID FROM ZoneTable WHERE zoneID = ? LIMIT 1;");
stmt.bind(1, static_cast<int>(zoneId));
auto result = stmt.execQuery();
return !result.eof();
}
// Move an offline character to a zone's spawn point by editing its saved data
bool RescueOffline(LWOOBJID charId, LWOMAPID zoneId, const std::string& spawnPoint) {
const std::string xml = Database::Get()->GetCharacterXml(charId);
tinyxml2::XMLDocument doc;
if (xml.empty() || doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) return false;
auto* charEl = doc.FirstChildElement("obj") ? doc.FirstChildElement("obj")->FirstChildElement("char") : nullptr;
if (!charEl) return false;
// Zeroed position makes the world server use the zone's default spawn point
charEl->SetAttribute("lwid", zoneId);
for (const char* attr : { "lzx", "lzy", "lzz", "lzrx", "lzry", "lzrz" }) charEl->SetAttribute(attr, "0");
charEl->SetAttribute("lzrw", "1");
// The world puts an arriving character on the spawn point its target scene names (Entity::Initialize)
charEl->SetAttribute("tscene", spawnPoint.c_str());
tinyxml2::XMLPrinter printer;
doc.Print(&printer);
Database::Get()->UpdateCharacterXml(charId, printer.CStr());
return true;
}
std::optional<std::string> ReadBinaryFile(const std::filesystem::path& path) {
std::ifstream file(path, std::ios::binary);
if (!file) return std::nullopt;
return std::string((std::istreambuf_iterator<char>(file)), std::istreambuf_iterator<char>());
}
// The PropertyTemplate row a property was claimed from, every column; path ("x y z x y z ...") becomes [[x,y,z], ...]
nlohmann::json PropertyTemplateRow(int32_t templateId) {
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT * FROM PropertyTemplate WHERE id = ? LIMIT 1;");
stmt.bind(1, static_cast<int>(templateId));
auto result = stmt.execQuery();
if (result.eof()) return nullptr;
nlohmann::json row = nlohmann::json::object();
for (int i = 0; i < result.numFields(); i++) {
const std::string column = result.fieldName(i);
switch (result.fieldDataType(i)) {
case SQLITE_INTEGER: row[column] = result.getInt64Field(i); break;
case SQLITE_FLOAT: row[column] = result.getFloatField(i); break;
case SQLITE_NULL: row[column] = nullptr; break;
default: row[column] = result.getStringField(i); break;
}
}
if (row.contains("path") && row["path"].is_string()) {
std::istringstream stream(row["path"].get<std::string>());
nlohmann::json points = nlohmann::json::array();
float x, y, z;
while (stream >> x >> y >> z) points.push_back({ x, y, z });
row["path"] = points;
}
const auto prefix = "PropertyTemplate_" + std::to_string(templateId);
row["localized_name"] = Locale::GetPhrase(prefix + "_name");
row["localized_description"] = Locale::GetPhrase(prefix + "_description");
const auto& zones = ZoneNames();
for (const auto* key : { "mapID", "vendorMapID" }) {
if (!row.contains(key) || !row[key].is_number_integer()) continue;
const auto zone = std::to_string(row[key].get<int64_t>());
row[std::string(key) + "_name"] = zones.contains(zone) && zones[zone].is_string() ? zones[zone].get<std::string>() : "";
}
return row;
}
// Moderators see every property; players only their own, when own_properties allows it
std::optional<IProperty::Info> AuthorizedProperty(const HTTPContext& context, LWOOBJID propertyId, HTTPReply& reply) {
auto info = Database::Get()->GetPropertyInfo(propertyId);
if (!info) {
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Property not found");
return std::nullopt;
}
if (!Can(context, "properties_view")) {
const auto owner = Database::Get()->GetCharacterInfo(info->ownerId);
if (!owner || owner->accountId != context.accountId || !Can(context, "own_properties")) {
JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own properties");
return std::nullopt;
}
}
return info;
}
std::optional<std::filesystem::path> ClientResource(const std::filesystem::path& relative) {
const std::string clientPath = Game::config->GetValue("client_location");
if (clientPath.empty()) return std::nullopt;
const auto path = std::filesystem::path(clientPath) / "res" / relative;
if (!std::filesystem::exists(path)) return std::nullopt;
return path;
}
bool IsPlainFileName(std::string_view name) {
return !name.empty() && name.size() < 128 && name.find("..") == std::string_view::npos &&
std::ranges::all_of(name, [](char c) { return std::isalnum(static_cast<unsigned char>(c)) || c == '_' || c == '-' || c == '.'; });
}
// LXFML for a placed property model: player-built (LOT 14) models come from the ugc table,
// prebuilt ones from the client's res/BrickModels
std::optional<std::pair<std::string, std::string>> ModelLxfml(const IPropertyContents::Model& model, LWOOBJID propertyId) {
constexpr LOT UGC_MODEL_LOT = 14;
if (model.lot == UGC_MODEL_LOT) {
// GetUgcModels joins on properties_contents.ugc_id; GetUgcModel joins on the content id, which only
// matches models whose content and blueprint ids happen to be equal
for (auto& ugc : Database::Get()->GetUgcModels(propertyId)) {
if (ugc.id != model.ugcId) continue;
Sd0 sd0(ugc.lxfmlData);
return std::make_pair(sd0.GetAsStringUncompressed(), "ugc_" + std::to_string(model.ugcId) + ".lxfml");
}
return std::nullopt;
}
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT rc.render_asset FROM ComponentsRegistry cr JOIN RenderComponent rc ON cr.component_id = rc.id "
"WHERE cr.component_type = 2 AND cr.id = ? LIMIT 1;");
stmt.bind(1, static_cast<int>(model.lot));
auto result = stmt.execQuery();
if (result.eof()) return std::nullopt;
std::string asset = result.getStringField("render_asset", "");
std::replace(asset.begin(), asset.end(), '\\', '/');
asset = asset.substr(asset.rfind('/') + 1);
asset = asset.substr(0, asset.find('.'));
std::transform(asset.begin(), asset.end(), asset.begin(), ::tolower);
if (!IsPlainFileName(asset)) return std::nullopt;
const auto path = ClientResource(std::filesystem::path("BrickModels") / (asset + ".lxfml"));
if (!path) return std::nullopt;
const auto data = ReadBinaryFile(*path);
if (!data) return std::nullopt;
return std::make_pair(*data, asset + ".lxfml");
}
// Every LDD geometry file of a brick design (<design>.g, .g1, ...) in one bundle: uint32 count, then per part
// uint32 length + the .g file bytes (little endian). nullopt when the design has none (or lod isn't 0-2).
std::optional<std::string> BrickBundle(uint32_t lod, uint32_t design) {
if (lod > 2) return std::nullopt;
std::vector<std::string> parts;
for (uint32_t index = 0; index < 64; index++) {
const auto name = std::to_string(design) + ".g" + (index == 0 ? "" : std::to_string(index));
const auto path = ClientResource(std::filesystem::path("brickprimitives") / ("lod" + std::to_string(lod)) / name);
const auto data = path ? ReadBinaryFile(*path) : std::nullopt;
if (!data) break;
parts.push_back(*data);
}
if (parts.empty()) return std::nullopt;
const auto writeU32 = [](std::string& out, uint32_t value) {
for (int i = 0; i < 4; i++) out += static_cast<char>((value >> (8 * i)) & 0xff);
};
std::string bundle;
writeU32(bundle, static_cast<uint32_t>(parts.size()));
for (const auto& part : parts) {
writeU32(bundle, static_cast<uint32_t>(part.size()));
bundle += part;
}
return bundle;
}
bool LotExists(LOT lot) {
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT id FROM Objects WHERE id = ? LIMIT 1;");
stmt.bind(1, static_cast<int>(lot));
auto result = stmt.execQuery();
return !result.eof();
}
std::string ItemName(LOT lot) {
return ClientAssets::ObjectName(lot).value_or("");
}
// ---- Registration by area ----
void RegisterStatusRoutes() {
Route(eHTTPMethod::GET, "/api/status", 0, "Server status, world instances and online player count",
[](HTTPReply& reply, const HTTPContext& context) {
// Which property instances run (and who could be on them) is for staff who may see online players
auto response = Can(context, "players_view") ? ServerState::GetServerStateJson() : ServerState::PlayerSafe(ServerState::GetServerStateJson());
response["restart"] = LiveWorld::RestartStatus();
response["stats"]["totalAccounts"] = Database::Get()->GetAccountCount();
response["stats"]["totalCharacters"] = Database::Get()->GetCharacterCount();
JsonReply(reply, eHTTPStatusCode::OK, response);
});
Route(eHTTPMethod::GET, "/api/moderation/counts", Perm("moderate_names"), "Sizes of the moderation queues",
[](HTTPReply& reply, const HTTPContext& context) {
const auto snapshot = Database::Get()->GetDashboardSnapshot();
JsonReply(reply, eHTTPStatusCode::OK, {
{"pendingNames", snapshot.pendingNames},
{"pendingPetNames", snapshot.pendingPetNames},
{"pendingProperties", snapshot.pendingProperties},
{"unresolvedBugReports", snapshot.unresolvedBugReports},
{"openEconomyFlags", snapshot.openEconomyFlags}
});
});
Route(eHTTPMethod::GET, "/api/docs", 0, "List of API endpoints available to the caller",
[](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json routes = nlohmann::json::array();
for (const auto& doc : GetRouteDocs()) {
const int16_t level = doc.permission.empty() ? doc.minGmLevel : Permissions::Level(doc.permission);
if (level > context.gmLevel || !doc.path.starts_with("/api/")) continue;
routes.push_back({ {"method", doc.method}, {"path", doc.path}, {"minGmLevel", level}, {"permission", doc.permission}, {"description", doc.description} });
}
JsonReply(reply, eHTTPStatusCode::OK, {
{"authentication", "Send 'Authorization: Bearer <token>'. Create a token on your account page (POST /api/auth/token). "
"Requests without an Authorization header, including POST /api/auth/login, must send 'X-Requested-With' (any value)."},
{"routes", routes}
});
});
Route(eHTTPMethod::GET, "/api/openapi.json", 0, "The API as an OpenAPI 3 document (what the API page shows in Swagger UI), with the endpoints available to the caller",
[](HTTPReply& reply, const HTTPContext& context) {
std::vector<OpenApi::Route> routes;
for (const auto& doc : GetRouteDocs()) {
const int16_t level = doc.permission.empty() ? doc.minGmLevel : Permissions::Level(doc.permission);
if (level > context.gmLevel || !doc.path.starts_with("/api/")) continue;
routes.push_back({ doc.method, doc.path, doc.description, level, doc.permission });
}
JsonReply(reply, eHTTPStatusCode::OK, OpenApi::Build(routes, "DarkflameServer dashboard"));
});
// Any signed-in account, but each result is only readable by the account that started it
Route(eHTTPMethod::GET, "/api/actions/:id", 0, "Result of an asynchronous action you started (kick, rescue, email, ...) by requestId",
[](HTTPReply& reply, const HTTPContext& context) {
const auto requestId = RequireId<uint32_t>(context, 2, reply);
if (!requestId) return;
JsonReply(reply, eHTTPStatusCode::OK, PlayerActions::GetStatus(*requestId, context.accountId));
});
}
void RegisterAccountRoutes() {
TableRoute("/api/tables/accounts", Perm("accounts_view"), "Accounts (DataTables)", [](const DataTablesRequest& r, const nlohmann::json&) {
return Database::Get()->GetAccountsTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc).dump();
});
Route(eHTTPMethod::GET, "/api/accounts/:id", 0, "Account details. Without accounts_view, only your own",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
if (!accountId) return;
if (!Can(context, "accounts_view") && context.accountId != *accountId) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own account");
auto account = Database::Get()->GetAccountById(*accountId);
if (account.contains("error")) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Account not found");
account["characters"] = Database::Get()->GetAccountCharacters(*accountId);
const auto email = Database::Get()->GetAccountEmail(*accountId);
account["email"] = email ? email->email : "";
account["email_confirmed"] = email && email->confirmed;
JsonReply(reply, eHTTPStatusCode::OK, account);
});
Route(eHTTPMethod::POST, "/api/accounts/create", Perm("accounts_manage"), "Create an account. Body: {username, password, gm_level (optional, default 0), play_key (optional: a key string to attach; "
"GM 0 accounts need one to log in to the game while the auth server uses keys)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const std::string username = body->value("username", "");
const std::string password = body->value("password", "");
if (const auto error = ValidateUsername(username)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
if (const auto error = ValidatePassword(password)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
if (Database::Get()->GetAccountInfo(username)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Account already exists");
const int64_t requestedLevel = body->value("gm_level", 0);
if (requestedLevel < 0 || requestedLevel > 9 || !CanGrantGmLevel(context.gmLevel, static_cast<uint8_t>(requestedLevel))) {
return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You cannot create an account with that GM level");
}
const std::string playKey = body->value("play_key", "");
std::optional<int32_t> keyId;
if (!playKey.empty()) {
keyId = Database::Get()->GetRedeemablePlayKeyId(playKey);
if (!keyId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That play key is invalid, inactive or used up");
}
Database::Get()->InsertNewAccount(username, HashPassword(password), static_cast<eGameMasterLevel>(requestedLevel));
const auto created = Database::Get()->GetAccountInfo(username);
if (created && keyId) {
Database::Get()->SetAccountPlayKey(created->id, *keyId);
BroadcastTableChanged("play_keys");
}
Audit(context, "create_account", "Created account " + username + " with GM level " + std::to_string(requestedLevel) + (keyId ? " and play key ID " + std::to_string(*keyId) : ""), AuditTarget::Account(created ? created->id : 0));
BroadcastTableChanged("accounts");
JsonSuccess(reply);
});
Route(eHTTPMethod::POST, "/api/accounts/:id/ban", Perm("accounts_ban"), "Ban or unban. Body: {banned: bool, reason (shown to the player), days (0 or missing: permanent)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
const auto body = ParseBody(context);
if (!accountId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto targetLevel = AuthorizeAccountAction(context, *accountId, reply, eAccountAction::MODERATION);
if (!targetLevel) return;
const bool banned = body->value("banned", true);
if (banned && RefuseLastOperator(*accountId, *targetLevel, reply, "banned")) return;
const auto reason = Reason(*body);
const int64_t days = std::clamp<int64_t>(body->value("days", 0), 0, 36500);
nlohmann::json response{ {"banned", banned} };
if (banned) {
response["requestId"] = AccountModeration::Ban(context, *accountId, days, reason);
} else {
Database::Get()->SetAccountBan(*accountId, false, 0, reason);
Note(context, *accountId, "unban", "Unbanned" + (reason.empty() ? "" : ": " + reason));
Audit(context, "unban_account", "Account ID " + std::to_string(*accountId) + (reason.empty() ? "" : ": " + reason), AuditTarget::Account(*accountId));
BroadcastTableChanged("accounts", std::to_string(*accountId));
}
JsonSuccess(reply, response);
});
Route(eHTTPMethod::POST, "/api/accounts/:id/lock", Perm("accounts_ban"), "Lock or unlock. Body: {locked: bool}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
const auto body = ParseBody(context);
if (!accountId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto targetLevel = AuthorizeAccountAction(context, *accountId, reply, eAccountAction::MODERATION);
if (!targetLevel) return;
const bool locked = body->value("locked", true);
if (locked && RefuseLastOperator(*accountId, *targetLevel, reply, "locked")) return;
const auto reason = Reason(*body);
Note(context, *accountId, locked ? "lock" : "unlock", std::string(locked ? "Locked" : "Unlocked") + (reason.empty() ? "" : ": " + reason));
// accounts.locked is what the game login, the dashboard login and the account page read.
// Unlocking also ends any failed-login throttle.
Database::Get()->SetAccountLocked(*accountId, locked);
if (!locked) {
Database::Get()->SetLockout(*accountId, 0);
DashboardAuthService::ClearThrottle(*accountId);
}
Audit(context, locked ? "lock_account" : "unlock_account", "Account ID " + std::to_string(*accountId), AuditTarget::Account(*accountId));
BroadcastTableChanged("accounts", std::to_string(*accountId));
nlohmann::json response{ {"locked", locked} };
if (locked) response["requestId"] = KickAccount(context, *accountId, eServerDisconnectIdentifiers::KICK);
JsonSuccess(reply, response);
});
Route(eHTTPMethod::POST, "/api/accounts/:id/kick", Perm("accounts_kick"), "Disconnect every online session of an account",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
if (!accountId) return;
if (!AuthorizeAccountAction(context, *accountId, reply, eAccountAction::TOOLS)) return;
Audit(context, "kick_account", "Account ID " + std::to_string(*accountId), AuditTarget::Account(*accountId));
JsonSuccess(reply, { {"requestId", KickAccount(context, *accountId, eServerDisconnectIdentifiers::KICK)} });
});
Route(eHTTPMethod::GET, "/api/accounts/:id/notes", Perm("accounts_notes"), "An account's moderation history (notes, warnings, bans, mutes, locks), newest first",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
if (!accountId) return;
nlohmann::json notes = nlohmann::json::array();
for (const auto& note : Database::Get()->GetAccountNotes(*accountId)) {
notes.push_back({ {"id", note.id}, {"kind", note.kind}, {"text", note.text}, {"actor", note.actor}, {"created_at", note.createdAt} });
}
JsonSuccess(reply, { {"notes", notes} });
});
Route(eHTTPMethod::POST, "/api/accounts/:id/notes", Perm("accounts_notes"),
"Add to an account's history. Body: {kind: note|warning, text, tell_player: bool (warnings: also show it to the player if online)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
const auto body = ParseBody(context);
if (!accountId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
if (Database::Get()->GetAccountById(*accountId).contains("error")) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Account not found");
const std::string kind = body->value("kind", "note");
std::string text = body->value("text", "");
text.erase(0, text.find_first_not_of(" \t\r\n"));
text.erase(text.find_last_not_of(" \t\r\n") + 1);
if (kind != "note" && kind != "warning") return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "kind must be note or warning");
if (text.empty() || text.size() > PlayerActionRequest::MAX_TEXT) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Write up to 300 characters");
nlohmann::json response{ {"message", kind == "warning" ? "Warning recorded" : "Note added"} };
if (kind == "warning") {
// A warning goes on the player's record (and maybe to them in game): only for accounts you may manage
if (!AuthorizeAccountAction(context, *accountId, reply, eAccountAction::MODERATION)) return;
if (const auto requestId = AccountModeration::Warn(context, *accountId, text, body->value("tell_player", false))) response["requestId"] = requestId;
} else {
Note(context, *accountId, kind, text);
Audit(context, "note_account", "Account ID " + std::to_string(*accountId) + ": " + text, AuditTarget::Account(*accountId));
}
JsonSuccess(reply, response);
});
Route(eHTTPMethod::POST, "/api/account_notes/:id/delete", Perm("accounts_manage"), "Delete an entry from an account's history",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = PathId<uint64_t>(context.path, 2);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid ID");
const auto note = Database::Get()->GetAccountNote(*id);
if (!note) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Note not found");
if (!AuthorizeAccountAction(context, note->accountId, reply, eAccountAction::MODERATION)) return;
Database::Get()->DeleteAccountNote(*id);
Audit(context, "delete_account_note", "Note " + std::to_string(*id) + " (" + note->kind + "): " + note->text, AuditTarget::Account(note->accountId));
BroadcastTableChanged("account_notes", std::to_string(note->accountId));
JsonSuccess(reply, { {"message", "Deleted"} });
});
Route(eHTTPMethod::POST, "/api/accounts/:id/mute", Perm("accounts_mute"), "Mute or unmute. Body: {days: number} or {mute_until: unix time}; 0 unmutes",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
const auto body = ParseBody(context);
if (!accountId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
if (!AuthorizeAccountAction(context, *accountId, reply, eAccountAction::MODERATION)) return;
uint64_t muteUntil = body->value("mute_until", static_cast<uint64_t>(0));
if (body->contains("days")) {
const uint64_t days = std::clamp<int64_t>(body->value("days", 0), 0, 36500);
muteUntil = days == 0 ? 0 : static_cast<uint64_t>(std::time(nullptr)) + days * 24 * 60 * 60;
}
AccountModeration::Mute(context, *accountId, muteUntil, Reason(*body));
JsonSuccess(reply, { {"mute_until", muteUntil} });
});
Route(eHTTPMethod::POST, "/api/accounts/:id/gmlevel", Perm("accounts_gm_level"), "Set GM level. Body: {gm_level: 0-9}. Cannot grant your own level or higher unless operator; the last GM 9 that can sign in keeps GM 9",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
const auto body = ParseBody(context);
if (!accountId) return;
if (!body || !(*body)["gm_level"].is_number_integer()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "gm_level is required");
const int64_t requested = (*body)["gm_level"].get<int64_t>();
if (requested < 0 || requested > static_cast<int64_t>(eGameMasterLevel::OPERATOR)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid GM level");
const auto gmLevel = static_cast<uint8_t>(requested);
// Below GM 9 this also means nobody can ever raise their own level
if (!CanGrantGmLevel(context.gmLevel, gmLevel)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You cannot grant a GM level equal to or above your own");
const auto targetLevel = AuthorizeAccountAction(context, *accountId, reply, eAccountAction::MODERATION);
if (!targetLevel) return;
if (gmLevel < OPERATOR_LEVEL && RefuseLastOperator(*accountId, *targetLevel, reply, "given a lower GM level")) return;
Database::Get()->UpdateAccountGmLevel(*accountId, static_cast<eGameMasterLevel>(gmLevel));
Audit(context, "set_gm_level", "Account ID " + std::to_string(*accountId) + " to level " + std::to_string(gmLevel), AuditTarget::Account(*accountId));
BroadcastTableChanged("accounts", std::to_string(*accountId));
JsonSuccess(reply, { {"gm_level", gmLevel}, {"requestId", RefreshAccount(*accountId, context.accountId)} });
});
Route(eHTTPMethod::POST, "/api/accounts/:id/password", Perm("accounts_manage"), "Reset another account's password. Body: {password}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
const auto body = ParseBody(context);
if (!accountId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const std::string password = body->value("password", "");
if (const auto error = ValidatePassword(password)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
if (!AuthorizeAccountAction(context, *accountId, reply, eAccountAction::MODERATION)) return;
Database::Get()->UpdateAccountPassword(*accountId, HashPassword(password));
// Sign the account out everywhere
Database::Get()->SetSessionsValidAfter(*accountId, std::time(nullptr));
Audit(context, "reset_password", "Account ID " + std::to_string(*accountId), AuditTarget::Account(*accountId));
JsonSuccess(reply);
});
Route(eHTTPMethod::POST, "/api/account/password", 0, "Change your own password. Body: {current_password, new_password}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const std::string current = body->value("current_password", "");
const std::string next = body->value("new_password", "");
if (const auto error = ValidatePassword(next)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, *error);
const auto info = Database::Get()->GetAccountInfo(context.authenticatedUser);
if (!info || current.size() > MAX_PASSWORD_LENGTH || ::bcrypt_checkpw(current.c_str(), info->bcryptPassword.c_str()) != 0) {
return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Current password is incorrect");
}
Database::Get()->UpdateAccountPassword(info->id, HashPassword(next));
// Sign out other sessions, and keep this browser signed in with a new session
Database::Get()->SetSessionsValidAfter(info->id, std::time(nullptr));
const auto token = JWTUtils::GenerateSessionToken(context.accountId, context.authenticatedUser, context.gmLevel, false);
if (!token.empty()) reply.headers.push_back(AuthTokenHandler::BuildSessionCookie(token, false, UseSecureCookies()));
Audit(context, "change_own_password", "Changed their own password on the dashboard; their other sessions were signed out");
JsonSuccess(reply, { {"message", "Password changed. Other sessions were signed out."} });
});
Route(eHTTPMethod::POST, "/api/accounts/:id/delete", Perm("accounts_delete"),
"Permanently delete an account and its characters. Body (your own account only): {confirm: your username}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = RequireId<uint32_t>(context, 2, reply);
if (!accountId) return;
const auto targetLevel = AuthorizeAccountAction(context, *accountId, reply, eAccountAction::MODERATION);
if (!targetLevel || RefuseLastOperator(*accountId, *targetLevel, reply, "deleted")) return;
// Disconnect first so an online session can't save into the deleted rows, then delete
const std::string name = Database::Get()->GetAccountById(*accountId).value("name", std::string{});
if (*accountId == context.accountId) {
// Deleting your own account needs your username typed out, so it can't happen by a stray click
const auto body = ParseBody(context).value_or(nlohmann::json::object());
const auto& confirm = body["confirm"];
if (!confirm.is_string() || confirm.get<std::string>() != name) {
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "To delete your own account, type your username (" + name + ") to confirm");
}
}
PlayerActionRequest request;
request.action = ePlayerAction::KICK_ACCOUNT;
request.accountId = *accountId;
request.disconnectReason = static_cast<uint32_t>(eServerDisconnectIdentifiers::FREE_TRIAL_EXPIRED);
const auto actor = context.authenticatedUser;
const auto actorId = context.accountId;
const auto targetId = *accountId;
const auto requestId = PlayerActions::Request(request, actorId, [targetId, name, actor, actorId](const PlayerActionResult& result) {
Database::Get()->DeleteAccount(targetId);
Database::Get()->InsertAuditLog(actorId, actor, "delete_account", "Deleted account " + name + " (ID " + std::to_string(targetId) + ")" + OwnAccountNote(actorId, targetId), targetId, 0);
BroadcastTableChanged("accounts");
BroadcastTableChanged("characters");
return PlayerActions::Outcome{ true, "Account " + name + " deleted" + (result.affected ? " after disconnecting " + Sessions(result.affected) : "") };
});
JsonSuccess(reply, { {"requestId", requestId}, {"message", "Deleting account"} });
});
}
void RegisterCharacterRoutes() {
TableRoute("/api/tables/characters", Perm("characters_view"), "Characters (DataTables)", [](const DataTablesRequest& r, const nlohmann::json&) {
return Database::Get()->GetCharactersTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc);
});
TableRoute("/api/tables/pending_names", Perm("moderate_names"), "Characters with a pending name (DataTables)", [](const DataTablesRequest& r, const nlohmann::json&) {
return Database::Get()->GetPendingNamesTable(r.start, r.length).dump();
});
Route(eHTTPMethod::GET, "/api/characters/list", Perm("characters_view"), "Id and name of every character",
[](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json result = nlohmann::json::array();
for (const auto& [id, name] : Database::Get()->GetCharacterIdsAndNames()) {
result.push_back({ {"id", std::to_string(id)}, {"name", name} });
}
JsonReply(reply, eHTTPStatusCode::OK, result);
});
Route(eHTTPMethod::GET, "/api/characters/:id", 0, "Character details. Without characters_view, only your own",
[](HTTPReply& reply, const HTTPContext& context) {
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
if (!charId) return;
const auto character = Database::Get()->GetCharacterById(*charId);
if (character.contains("error")) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
if (!CanViewCharacter(context, character.value("account_id", 0u))) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own characters");
JsonReply(reply, eHTTPStatusCode::OK, character);
});
Route(eHTTPMethod::GET, "/api/characters/:id/xml", 0, "Raw character XML. Without characters_view, only your own",
[](HTTPReply& reply, const HTTPContext& context) {
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
if (!charId) return;
const auto info = Database::Get()->GetCharacterInfo(*charId);
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
if (!CanViewCharacter(context, info->accountId)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own characters");
const auto xml = Database::Get()->GetCharacterXml(*charId);
if (xml.empty()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
reply.status = eHTTPStatusCode::OK;
reply.message = xml;
reply.contentType = eContentType::TEXT_PLAIN;
});
Route(eHTTPMethod::POST, "/api/characters/:id/teleport", Perm("characters_rescue"),
"Move an online character to where another online player stands (both in the same world). Body: {to: character name or ID}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
const auto body = ParseBody(context);
if (!charId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto owner = CharacterOwner(*charId, reply);
if (!owner || !AuthorizeAccountAction(context, *owner, reply, eAccountAction::TOOLS)) return;
const auto& to = (*body)["to"];
const auto targetId = ResolveCharacter(to.is_string() ? to.get<std::string>() : to.is_number_integer() ? to.dump() : "");
if (!targetId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "No such character to teleport to");
if (*targetId == *charId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick another player");
PlayerActionRequest request;
request.action = ePlayerAction::TELEPORT_TO_PLAYER;
request.characterId = *charId;
request.targetId = *targetId;
const auto actor = context.authenticatedUser;
const auto actorId = context.accountId;
const auto mover = *charId;
const auto destination = *targetId;
const auto requestId = PlayerActions::Request(request, actorId, [mover, destination, actor, actorId](const PlayerActionResult& result) {
if (result.affected == 0) return PlayerActions::Outcome{ false, "They aren't both online in the same world. Rescue the player to that zone first." };
const auto name = [](LWOOBJID id) { const auto info = Database::Get()->GetCharacterInfo(id); return info ? info->name : std::to_string(id); };
const auto moved = AuditTarget::Character(mover);
Database::Get()->InsertAuditLog(actorId, actor, "teleport_character", "Moved " + name(mover) + " to " + name(destination) + OwnAccountNote(actorId, moved.accountId), moved.accountId, moved.characterId);
return PlayerActions::Outcome{ true, "Moved " + name(mover) + " to " + name(destination) };
});
JsonSuccess(reply, { {"requestId", requestId} });
});
Route(eHTTPMethod::POST, "/api/characters/:id/rescue", Perm("characters_rescue"), "Move a character to a zone, live if online. Body: {zone_id, spawn_point (optional: a name from GET /api/zones/{id}/spawn_points; default the zone's own)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
const auto body = ParseBody(context);
if (!charId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto owner = CharacterOwner(*charId, reply);
if (!owner || !AuthorizeAccountAction(context, *owner, reply, eAccountAction::TOOLS)) return;
const LWOMAPID zoneId = body->value("zone_id", 0u);
if (zoneId == 0 || !ZoneExists(zoneId)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown zone");
const std::string spawnPoint = body->value("spawn_point", "");
if (!spawnPoint.empty()) {
const auto points = ZoneSpawnPointsJson(zoneId);
const bool known = points && std::ranges::any_of(*points, [&](const nlohmann::json& p) { return p.value("name", "") == spawnPoint; });
if (!known) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That zone has no spawn point called " + spawnPoint);
}
// If a world has the character loaded it transfers them itself; editing the saved data then
// would be overwritten by the world's next save. Only offline characters are edited here.
PlayerActionRequest request;
request.action = ePlayerAction::RESCUE_CHARACTER;
request.characterId = *charId;
request.zoneId = zoneId;
request.text = spawnPoint;
const auto actor = context.authenticatedUser;
const auto actorId = context.accountId;
const auto target = *charId;
const auto requestId = PlayerActions::Request(request, actorId, [target, zoneId, spawnPoint, actor, actorId](const PlayerActionResult& result) {
const auto owner = AuditTarget::Character(target).accountId;
const auto where = std::to_string(target) + " to zone " + std::to_string(zoneId) + (spawnPoint.empty() ? "" : " at " + spawnPoint) + OwnAccountNote(actorId, owner);
if (result.affected > 0) {
Database::Get()->InsertAuditLog(actorId, actor, "rescue_character", "Transferred online character " + where, owner, target);
return PlayerActions::Outcome{ true, "Character was online and is being transferred" };
}
if (!RescueOffline(target, zoneId, spawnPoint)) return PlayerActions::Outcome{ false, "Failed to update the character's saved position" };
Database::Get()->InsertAuditLog(actorId, actor, "rescue_character", "Moved offline character " + where, owner, target);
return PlayerActions::Outcome{ true, result.timedOut
? "Saved position updated (some world servers did not respond; if the character was online there, repeat the rescue)"
: "Character was offline; saved position updated" };
});
JsonSuccess(reply, { {"requestId", requestId}, {"message", "Rescue requested"} });
});
Route(eHTTPMethod::POST, "/api/characters/:id/xml", Perm("characters_edit_xml"), "Replace a character's XML (enable_char_xml_upload=1). Disconnects the owner first. Body: {xml}",
[](HTTPReply& reply, const HTTPContext& context) {
if (!ConfigFlag("enable_char_xml_upload", false)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Character XML upload is disabled (enable_char_xml_upload)");
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
const auto body = ParseBody(context);
if (!charId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto owner = CharacterOwner(*charId, reply);
if (!owner || !AuthorizeAccountAction(context, *owner, reply, eAccountAction::ITEMS)) return;
const std::string xml = body->value("xml", "");
tinyxml2::XMLDocument doc;
if (xml.empty() || doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "That is not valid XML");
if (!doc.FirstChildElement("obj") || !doc.FirstChildElement("obj")->FirstChildElement("char")) {
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Character XML must have an <obj> root with a <char> element");
}
// Store compactly like the game does
tinyxml2::XMLPrinter printer(nullptr, true);
doc.Print(&printer);
const std::string compact = printer.CStr();
// Through the safe path: the owner is disconnected first and the current version is kept as a snapshot
const auto requestId = WriteCharacterXml(*charId, *owner, context.authenticatedUser, context.accountId, "XML upload",
[compact](const std::string&, std::string&) { return std::optional<std::string>(compact); });
JsonSuccess(reply, { {"requestId", requestId}, {"message", "Uploading"} });
});
Route(eHTTPMethod::GET, "/api/characters/:id/mail", 0, "A character's mailbox (up to 100 most recent). With characters_mail, or the character's owner",
[](HTTPReply& reply, const HTTPContext& context) {
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
if (!charId) return;
const auto info = Database::Get()->GetCharacterInfo(*charId);
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
if (!Can(context, "characters_mail") && info->accountId != context.accountId) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions");
nlohmann::json mail = nlohmann::json::array();
for (const auto& m : Database::Get()->GetMailForPlayer(*charId, 100)) {
mail.push_back({
{"id", std::to_string(m.id)},
{"sender", m.senderUsername},
{"subject", m.subject},
{"body", m.body},
{"time_sent", m.timeSent},
{"read", m.wasRead},
{"attachment_lot", m.itemLOT},
{"attachment_count", m.itemCount},
{"attachment_name", m.itemLOT > 0 ? ItemName(m.itemLOT) : ""}
});
}
JsonReply(reply, eHTTPStatusCode::OK, mail);
});
Route(eHTTPMethod::POST, "/api/characters/:id/restrict", Perm("characters_restrict"), "Set trade/mail/chat restrictions. Body: {trade, mail, chat: bool}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
const auto body = ParseBody(context);
if (!charId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto owner = CharacterOwner(*charId, reply);
if (!owner || !AuthorizeAccountAction(context, *owner, reply, eAccountAction::MODERATION)) return;
const auto info = Database::Get()->GetCharacterInfo(*charId);
uint64_t map = static_cast<uint64_t>(info->permissionMap);
const auto setBit = [&map, &body](const char* key, ePermissionMap bit) {
if (!body->contains(key)) return;
if (body->value(key, false)) map |= static_cast<uint64_t>(bit);
else map &= ~static_cast<uint64_t>(bit);
};
setBit("trade", ePermissionMap::RestrictedTradeAccess);
setBit("mail", ePermissionMap::RestrictedMailAccess);
setBit("chat", ePermissionMap::RestrictedChatAccess);
Database::Get()->SetCharacterPermissionMap(*charId, map);
Audit(context, "restrict_character", "Character " + std::to_string(*charId) + " permission map " + std::to_string(map), AuditTarget::Character(*charId));
BroadcastTableChanged("characters", std::to_string(*charId));
PlayerActionRequest request;
request.action = ePlayerAction::REFRESH_CHARACTER;
request.characterId = *charId;
const auto requestId = PlayerActions::Request(request, context.accountId, [](const PlayerActionResult& result) {
return PlayerActions::Outcome{ true, result.affected ? "Restrictions applied to the online character" : "Restrictions saved" };
});
JsonSuccess(reply, { {"permission_map", map}, {"requestId", requestId} });
});
Route(eHTTPMethod::POST, "/api/characters/:id/approve_name", Perm("moderate_names"), "Approve a character's pending name",
[](HTTPReply& reply, const HTTPContext& context) {
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
if (!charId) return;
const auto info = Database::Get()->GetCharacterInfo(*charId);
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
if (info->pendingName.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Character has no pending name");
const auto existing = Database::Get()->GetCharacterInfo(info->pendingName);
if (existing && existing->id != *charId) return JsonError(reply, eHTTPStatusCode::CONFLICT, "Another character already uses this name");
Database::Get()->SetCharacterName(*charId, info->pendingName);
Audit(context, "approve_character_name", info->name + " -> " + info->pendingName, AuditTarget::Character(*charId));
Decide(context, "name", *charId, info->pendingName, true);
BroadcastTableChanged("pending_names");
BroadcastTableChanged("characters", std::to_string(*charId));
PlayerActionRequest request;
request.action = ePlayerAction::NAME_MODERATED;
request.characterId = *charId;
request.approved = true;
request.text = info->pendingName;
JsonSuccess(reply, NotifyModeration(request, context.accountId));
});
Route(eHTTPMethod::POST, "/api/characters/:id/reject_name", Perm("moderate_names"), "Reject a character's pending name. Body (optional): {reason (shown to the player on the dashboard), strike (true: also a strike on their account)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto charId = RequireId<LWOOBJID>(context, 2, reply);
if (!charId) return;
const auto info = Database::Get()->GetCharacterInfo(*charId);
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found");
const auto body = ParseBody(context).value_or(nlohmann::json::object());
const auto strike = Strikes::Requested(context, body, reply);
if (!strike) return;
if (*strike && !AuthorizeAccountAction(context, info->accountId, reply, eAccountAction::MODERATION)) return;
Database::Get()->SetPendingCharacterName(*charId, "");
Audit(context, "reject_character_name", info->name + " (rejected " + info->pendingName + ")", AuditTarget::Character(*charId));
Decide(context, "name", *charId, info->pendingName, false);
BroadcastTableChanged("pending_names");
PlayerActionRequest request;
request.action = ePlayerAction::NAME_MODERATED;
request.characterId = *charId;
request.approved = false;
request.text = info->pendingName;
auto result = NotifyModeration(request, context.accountId);
if (*strike) Strikes::Give(context, info->accountId, *charId, eStrikeSource::NAME, info->pendingName, Reason(body)).Into(result);
JsonSuccess(reply, result);
});
}
void RegisterPlayKeyRoutes() {
TableRoute("/api/tables/play_keys", Perm("play_keys_manage"), "Play keys with usage counts (DataTables)", [](const DataTablesRequest& r, const nlohmann::json&) {
return Database::Get()->GetPlayKeysTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc);
});
Route(eHTTPMethod::POST, "/api/play_keys/create", Perm("play_keys_manage"), "Create play keys. Body: {count (1-100), uses, notes, key_string (optional, single key)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const uint32_t count = std::clamp<int64_t>(body->value("count", 1), 1, MAX_PLAY_KEYS_PER_REQUEST);
const uint32_t uses = std::clamp<int64_t>(body->value("uses", 1), 1, 100000);
const std::string notes = body->value("notes", "");
const std::string customKey = body->value("key_string", "");
nlohmann::json created = nlohmann::json::array();
if (!customKey.empty()) {
if (customKey.size() > 64) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Key must be at most 64 characters");
Database::Get()->CreatePlayKey(customKey, uses, notes);
created.push_back(customKey);
} else {
for (uint32_t i = 0; i < count; i++) {
const auto key = GeneratePlayKey();
Database::Get()->CreatePlayKey(key, uses, notes);
created.push_back(key);
}
}
Audit(context, "create_play_key", std::to_string(created.size()) + " key(s) with " + std::to_string(uses) + " use(s)");
BroadcastTableChanged("play_keys");
JsonSuccess(reply, { {"keys", created} });
});
Route(eHTTPMethod::GET, "/api/play_keys/:id", Perm("play_keys_manage"), "Play key details and the accounts that used it",
[](HTTPReply& reply, const HTTPContext& context) {
const auto keyId = RequireId<int32_t>(context, 2, reply);
if (!keyId) return;
const auto key = Database::Get()->GetPlayKey(*keyId);
if (key.contains("error")) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Play key not found");
JsonReply(reply, eHTTPStatusCode::OK, key);
});
Route(eHTTPMethod::POST, "/api/play_keys/:id/update", Perm("play_keys_manage"), "Edit a play key. Body: {uses, notes, active}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto keyId = RequireId<int32_t>(context, 2, reply);
const auto body = ParseBody(context);
if (!keyId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto key = Database::Get()->GetPlayKey(*keyId);
if (key.contains("error")) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Play key not found");
const uint32_t uses = std::clamp<int64_t>(body->value("uses", key.value("key_uses", 1)), 0, 100000);
const std::string notes = body->value("notes", key.value("notes", std::string{}));
const bool active = body->value("active", key.value("active", true));
Database::Get()->UpdatePlayKey(*keyId, uses, notes, active);
Audit(context, "update_play_key", "Key ID " + std::to_string(*keyId));
BroadcastTableChanged("play_keys", std::to_string(*keyId));
JsonSuccess(reply);
});
Route(eHTTPMethod::POST, "/api/play_keys/:id/toggle", Perm("play_keys_manage"), "Activate or deactivate. Body: {active: bool}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto keyId = RequireId<int32_t>(context, 2, reply);
const auto body = ParseBody(context);
if (!keyId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const bool active = body->value("active", true);
Database::Get()->SetPlayKeyActive(*keyId, active);
Audit(context, active ? "activate_play_key" : "deactivate_play_key", "Key ID " + std::to_string(*keyId));
BroadcastTableChanged("play_keys", std::to_string(*keyId));
JsonSuccess(reply, { {"active", active} });
});
Route(eHTTPMethod::POST, "/api/play_keys/:id/delete", Perm("play_keys_manage"), "Delete a play key; accounts that used it are detached",
[](HTTPReply& reply, const HTTPContext& context) {
const auto keyId = RequireId<int32_t>(context, 2, reply);
if (!keyId) return;
Database::Get()->DeletePlayKey(*keyId);
Audit(context, "delete_play_key", "Key ID " + std::to_string(*keyId));
BroadcastTableChanged("play_keys");
JsonSuccess(reply);
});
}
void RegisterPropertyRoutes() {
TableRoute("/api/tables/properties", Perm("properties_view"), "Properties (DataTables). Body may include {pending: true}", [](const DataTablesRequest& r, const nlohmann::json& body) {
return WithZoneNames(Database::Get()->GetPropertiesTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc, body.value("pending", false)));
});
TableRoute("/api/tables/pending_properties", Perm("moderate_properties"), "Public properties awaiting review (DataTables)", [](const DataTablesRequest& r, const nlohmann::json&) {
return WithZoneNames(Database::Get()->GetPropertiesTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc, true));
});
Route(eHTTPMethod::GET, "/api/properties/:id", 0, "Property details and placed models. GM 0 may only view their own",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propId = RequireId<LWOOBJID>(context, 2, reply);
if (!propId) return;
const auto info = AuthorizedProperty(context, *propId, reply);
if (!info) return;
const auto owner = Database::Get()->GetCharacterInfo(info->ownerId);
nlohmann::json models = nlohmann::json::array();
for (const auto& model : Database::Get()->GetPropertyModels(*propId)) {
models.push_back({
{"id", std::to_string(model.id)},
{"lot", model.lot},
{"name", model.lot == 14 ? "Player-built model" : ItemName(model.lot)},
{"ugc_id", std::to_string(model.ugcId)},
{"position", {model.position.x, model.position.y, model.position.z}},
{"rotation", {model.rotation.x, model.rotation.y, model.rotation.z, model.rotation.w}},
{"behaviors", ModelBehaviors(model)}
});
}
// Merge the rest of each model's row (player-given name, behavior ids, blueprint, creator) into its entry
std::map<std::string, nlohmann::json> records;
for (auto& record : Database::Get()->GetPropertyModelRecords(*propId)) {
if (!record["id"].is_string()) continue;
const auto id = record["id"].get<std::string>();
records[id] = std::move(record);
}
for (auto& model : models) {
const auto it = records.find(model["id"].get<std::string>());
if (it == records.end()) continue;
for (const auto& [key, value] : it->second.items()) {
if (!model.contains(key)) model[key] = value;
}
}
auto record = Database::Get()->GetPropertyRecord(*propId);
const int32_t templateId = record.value("template_id", 0);
JsonReply(reply, eHTTPStatusCode::OK, {
{"id", std::to_string(info->id)},
{"name", info->name},
{"template_id", templateId},
{"rent_amount", record.value("rent_amount", 0)},
{"rent_due", record.value("rent_due", int64_t{ 0 })},
{"record", record},
{"template", PropertyTemplateRow(templateId)},
{"description", info->description},
{"owner_id", std::to_string(info->ownerId)},
{"owner_name", owner ? owner->name : ""},
{"clone_id", info->cloneId},
{"privacy_option", info->privacyOption},
{"mod_approved", info->modApproved != 0},
{"rejection_reason", info->rejectionReason},
{"reputation", info->reputation},
{"last_updated", info->lastUpdatedTime},
{"claimed", info->claimedTime},
{"performance_cost", info->performanceCost},
{"zone_id", info->zoneId},
{"zone_name", ZoneNames().contains(std::to_string(info->zoneId)) ? ZoneNames()[std::to_string(info->zoneId)].get<std::string>() : ""},
{"models", models}
});
});
Route(eHTTPMethod::GET, "/api/properties/:id/terrain", 0, "The terrain of the zone a property is in, for the 3D viewer (needs client_location)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propId = RequireId<LWOOBJID>(context, 2, reply);
if (!propId) return;
const auto info = AuthorizedProperty(context, *propId, reply);
if (!info) return;
Workers::Reply(reply, context, ZoneTerrainJsonReady(info->zoneId), [zone = info->zoneId](HTTPReply& out) {
const auto terrain = ZoneTerrainJson(zone);
if (!terrain) return JsonError(out, eHTTPStatusCode::NOT_FOUND, "No terrain for this zone (is client_location set?)");
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_JSON;
out.message = *terrain;
out.headers.push_back("Cache-Control: private, max-age=86400");
});
});
Route(eHTTPMethod::GET, "/api/properties/:id/terrain_chunks", 0,
"The whole terrain of a property's zone as the game draws it: every chunk's heights, its four textures and its color and blend maps (needs client_location)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propId = RequireId<LWOOBJID>(context, 2, reply);
if (!propId) return;
const auto info = AuthorizedProperty(context, *propId, reply);
if (!info) return;
Workers::Reply(reply, context, ZoneTerrainChunksReady(info->zoneId), [zone = info->zoneId](HTTPReply& out) {
const auto terrain = ZoneTerrainChunksJson(zone);
if (!terrain) return JsonError(out, eHTTPStatusCode::NOT_FOUND, "No terrain for this zone (is client_location set?)");
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_JSON;
out.message = *terrain;
out.headers.push_back("Cache-Control: private, max-age=86400");
});
});
Route(eHTTPMethod::GET, "/api/properties/:id/scenery", 0,
"Everything the game draws around a property: its zone's scene objects with their models and the sky, for the 3D view. Models: /api/scenery/:zone/mesh/:asset (needs client_location)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propId = RequireId<LWOOBJID>(context, 2, reply);
if (!propId) return;
const auto info = AuthorizedProperty(context, *propId, reply);
if (!info) return;
Workers::Reply(reply, context, Scenery::ZoneReady(info->zoneId), [zone = info->zoneId](HTTPReply& out) {
const auto scenery = Scenery::ZoneJson(zone);
if (!scenery) return JsonError(out, eHTTPStatusCode::NOT_FOUND, "No scenery for this zone (is client_location set?)");
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_JSON;
out.message = *scenery;
out.headers.push_back("Cache-Control: private, max-age=86400");
});
});
Route(eHTTPMethod::GET, "/api/terrain_textures/:id", 0, "A terrain texture (mapTextureResource ID) as PNG, for the property 3D view",
[](HTTPReply& reply, const HTTPContext& context) {
const auto textureId = RequireId<uint32_t>(context, 2, reply);
if (!textureId) return;
Workers::Reply(reply, context, TerrainTextureReady(*textureId), [id = *textureId](HTTPReply& out) {
const auto png = TerrainTextureFile(id);
if (!png) return JsonError(out, eHTTPStatusCode::NOT_FOUND, "Texture not found");
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::IMAGE_PNG;
out.message = *png;
out.headers.push_back("Cache-Control: private, max-age=604800");
});
});
Route(eHTTPMethod::GET, "/api/properties/:id/boundary", 0, "Where a property's owner may build: the outline from the zone file and the height limit (needs client_location)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propId = RequireId<LWOOBJID>(context, 2, reply);
if (!propId) return;
const auto info = AuthorizedProperty(context, *propId, reply);
if (!info) return;
const auto areas = ZonePropertyAreasJson(info->zoneId);
if (!areas) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Couldn't read this zone's file (is client_location set?)");
JsonSuccess(reply, { {"areas", *areas} });
});
Route(eHTTPMethod::GET, "/api/property_models/:id/lxfml", 0, "Download a placed model's LXFML (openable in LEGO Digital Designer)",
[](HTTPReply& reply, const HTTPContext& context) {
const auto modelId = RequireId<LWOOBJID>(context, 2, reply);
if (!modelId) return;
const auto model = Database::Get()->GetModel(*modelId);
if (!model) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Model not found");
// Authorize through the property that holds the model
const auto owner = Database::Get()->GetModelPropertyId(*modelId);
if (!owner || !AuthorizedProperty(context, *owner, reply)) {
if (!owner) JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Model not found");
return;
}
const auto lxfml = ModelLxfml(*model, *owner);
if (!lxfml) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Model data not available");
reply.status = eHTTPStatusCode::OK;
reply.message = lxfml->first;
reply.contentType = eContentType::APPLICATION_OCTET_STREAM;
reply.headers.push_back("Content-Disposition: attachment; filename=\"" + lxfml->second + "\"");
});
Route(eHTTPMethod::GET, "/api/bricks/:lod/:design", 0, "All LDD geometry files of a brick design, for the model viewer",
[](HTTPReply& reply, const HTTPContext& context) {
// Bundles <design>.g, .g1, .g2, ... so the viewer needs one request per design instead of probing for
// each part. Format: uint32 count, then per part uint32 length + the .g file bytes (little endian).
const auto lod = PathId<uint32_t>(context.path, 2);
const auto design = PathId<uint32_t>(context.path, 3);
reply.status = eHTTPStatusCode::NOT_FOUND;
reply.message = "";
const auto bundle = lod && design ? BrickBundle(*lod, *design) : std::nullopt;
if (!bundle) return;
reply.status = eHTTPStatusCode::OK;
reply.message = *bundle;
reply.contentType = eContentType::APPLICATION_OCTET_STREAM;
reply.headers.push_back("Cache-Control: private, max-age=604800");
});
Route(eHTTPMethod::POST, "/api/properties/:id/approve", Perm("moderate_properties"), "Approve a property",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propId = RequireId<LWOOBJID>(context, 2, reply);
if (!propId) return;
const auto property = Database::Get()->GetPropertyInfo(*propId);
if (!property) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Property not found");
Database::Get()->ApproveProperty(*propId);
Audit(context, "approve_property", "Property ID " + std::to_string(*propId), AuditTarget::Character(property->ownerId));
BroadcastTableChanged("properties", std::to_string(*propId));
PlayerActionRequest request;
request.action = ePlayerAction::PROPERTY_MODERATED;
request.targetId = *propId;
request.characterId = property->ownerId;
request.approved = true;
JsonSuccess(reply, NotifyModeration(request, context.accountId));
});
Route(eHTTPMethod::POST, "/api/properties/:id/reject", Perm("moderate_properties"), "Reject a property and make it private. Body: {reason, strike (true: also a strike on the owner's account)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propId = RequireId<LWOOBJID>(context, 2, reply);
const auto body = ParseBody(context);
if (!propId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
auto info = Database::Get()->GetPropertyInfo(*propId);
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Property not found");
const auto strike = Strikes::Requested(context, *body, reply);
if (!strike) return;
const auto owner = *strike ? Database::Get()->GetCharacterInfo(info->ownerId) : std::nullopt;
if (owner && !AuthorizeAccountAction(context, owner->accountId, reply, eAccountAction::MODERATION)) return;
info->modApproved = 0;
info->rejectionReason = body->value("reason", "Rejected by moderator");
info->privacyOption = 0;
Database::Get()->UpdatePropertyModerationInfo(*info);
Audit(context, "reject_property", "Property ID " + std::to_string(*propId) + " reason: " + info->rejectionReason, AuditTarget::Character(info->ownerId));
BroadcastTableChanged("properties", std::to_string(*propId));
PlayerActionRequest request;
request.action = ePlayerAction::PROPERTY_MODERATED;
request.targetId = *propId;
request.characterId = info->ownerId;
request.approved = false;
request.text = info->rejectionReason;
auto result = NotifyModeration(request, context.accountId);
if (owner) Strikes::Give(context, owner->accountId, info->ownerId, eStrikeSource::PROPERTY, info->name, info->rejectionReason).Into(result);
JsonSuccess(reply, result);
});
}
void RegisterBugReportRoutes() {
TableRoute("/api/tables/bug_reports", Perm("bug_reports_view"), "Bug reports (DataTables). Body may include {resolved: -1 all, 0 unresolved, 1 resolved}", [](const DataTablesRequest& r, const nlohmann::json& body) {
const int8_t resolved = std::clamp<int64_t>(body.value("resolved", -1), -1, 1);
return Database::Get()->GetBugReportsTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc, resolved);
});
Route(eHTTPMethod::GET, "/api/bug_reports/:id", Perm("bug_reports_view"), "Bug report details",
[](HTTPReply& reply, const HTTPContext& context) {
const auto reportId = RequireId<uint32_t>(context, 2, reply);
if (!reportId) return;
const auto report = Database::Get()->GetBugReport(*reportId);
if (report.contains("error")) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Bug report not found");
JsonReply(reply, eHTTPStatusCode::OK, report);
});
Route(eHTTPMethod::POST, "/api/bug_reports/:id/resolve", Perm("bug_reports_manage"), "Resolve a bug report. Body: {resolution}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto reportId = RequireId<uint32_t>(context, 2, reply);
const auto body = ParseBody(context);
if (!reportId) return;
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const std::string resolution = body->value("resolution", "");
if (resolution.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "A resolution is required");
if (Database::Get()->GetBugReport(*reportId).contains("error")) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Bug report not found");
Database::Get()->ResolveBugReport(*reportId, context.accountId, resolution);
Audit(context, "resolve_bug_report", "Report ID " + std::to_string(*reportId));
BroadcastTableChanged("bug_reports", std::to_string(*reportId));
JsonSuccess(reply);
});
Route(eHTTPMethod::POST, "/api/bug_reports/:id/delete", Perm("bug_reports_manage"), "Delete a bug report",
[](HTTPReply& reply, const HTTPContext& context) {
const auto reportId = RequireId<uint32_t>(context, 2, reply);
if (!reportId) return;
Database::Get()->DeleteBugReport(*reportId);
Audit(context, "delete_bug_report", "Report ID " + std::to_string(*reportId));
BroadcastTableChanged("bug_reports");
JsonSuccess(reply);
});
}
void RegisterPetRoutes() {
TableRoute("/api/tables/pet_names", Perm("moderate_pet_names"), "Pet names (DataTables); rows include the pet's lot and kind. Order columns: 0 id, 2 name, 3 status, 4 owner", [](const DataTablesRequest& r, const nlohmann::json& body) {
// The page shows the pet's kind (not sortable) as column 1; the database numbers its columns without it
const uint32_t column = r.orderColumn >= 2 ? r.orderColumn - 1 : 0;
return WithPetKinds(Database::Get()->GetPetNamesTable(r.start, r.length, r.search, column, r.orderAsc, body.value("pending", false)));
});
TableRoute("/api/tables/pending_pet_names", Perm("moderate_pet_names"), "Pet names awaiting review (DataTables); rows include the pet's lot and kind", [](const DataTablesRequest& r, const nlohmann::json&) {
return WithPetKinds(Database::Get()->GetPetNamesTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc, true));
});
Route(eHTTPMethod::POST, "/api/pet_names/:id/approve", Perm("moderate_pet_names"), "Approve a pet name",
[](HTTPReply& reply, const HTTPContext& context) {
const auto petId = RequireId<int64_t>(context, 2, reply);
if (!petId) return;
const auto pet = Database::Get()->GetPetNameInfo(*petId);
Database::Get()->ApprovePetName(*petId);
Audit(context, "approve_pet_name", "Pet ID " + std::to_string(*petId), pet && pet->ownerId ? AuditTarget::Character(pet->ownerId) : AuditTarget{});
Decide(context, "pet_name", *petId, pet ? pet->petName : "", true);
BroadcastTableChanged("pet_names", std::to_string(*petId));
PlayerActionRequest request;
request.action = ePlayerAction::PET_NAME_MODERATED;
request.targetId = *petId;
request.approved = true;
request.text = pet ? pet->petName : "";
JsonSuccess(reply, NotifyModeration(request, context.accountId));
});
Route(eHTTPMethod::POST, "/api/pet_names/:id/reject", Perm("moderate_pet_names"), "Reject (delete) a pet name. Body (optional): {reason (shown to the owner on the dashboard), strike (true: also a strike on the owner's account)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto petId = RequireId<int64_t>(context, 2, reply);
if (!petId) return;
const auto pet = Database::Get()->GetPetNameInfo(*petId);
const auto body = ParseBody(context).value_or(nlohmann::json::object());
const auto strike = Strikes::Requested(context, body, reply);
if (!strike) return;
const auto owner = pet && pet->ownerId ? Database::Get()->GetCharacterInfo(pet->ownerId) : std::nullopt;
if (*strike && !owner) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "This pet's owner isn't known, so there's no account to give a strike to; nothing was done");
if (*strike && !AuthorizeAccountAction(context, owner->accountId, reply, eAccountAction::MODERATION)) return;
Database::Get()->RejectPetName(*petId);
Audit(context, "reject_pet_name", "Pet ID " + std::to_string(*petId), pet && pet->ownerId ? AuditTarget::Character(pet->ownerId) : AuditTarget{});
Decide(context, "pet_name", *petId, pet ? pet->petName : "", false);
BroadcastTableChanged("pet_names");
PlayerActionRequest request;
request.action = ePlayerAction::PET_NAME_MODERATED;
request.targetId = *petId;
request.approved = false;
request.text = pet ? pet->petName : "";
auto result = NotifyModeration(request, context.accountId);
if (*strike) Strikes::Give(context, owner->accountId, owner->id, eStrikeSource::PET_NAME, pet ? pet->petName : "", Reason(body)).Into(result);
JsonSuccess(reply, result);
});
}
void RegisterLogRoutes() {
TableRoute("/api/tables/activity_log", Perm("logs_activity"), "Zone enter/exit activity (DataTables)", [](const DataTablesRequest& r, const nlohmann::json&) {
return Database::Get()->GetActivityLogTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc);
});
TableRoute("/api/tables/command_log", Perm("logs_command"), "Slash command usage (DataTables)", [](const DataTablesRequest& r, const nlohmann::json&) {
return Database::Get()->GetCommandLogTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc);
});
TableRoute("/api/tables/audit_log", Perm("logs_audit"), "Dashboard audit log (DataTables)", [](const DataTablesRequest& r, const nlohmann::json&) {
return Database::Get()->GetAuditLogTable(r.start, r.length, r.search, r.orderColumn, r.orderAsc);
});
}
void RegisterMailRoutes() {
Route(eHTTPMethod::POST, "/api/mail/send", Perm("mail_send"), "Send in-game mail. Body: {recipient_id ('0' = everyone), subject, body, attachment_lot, attachment_count}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const std::string recipient = body->value("recipient_id", "");
const std::string subject = body->value("subject", "");
const std::string mailBody = body->value("body", "");
const LOT lot = body->value("attachment_lot", 0);
const int64_t count = body->value("attachment_count", 0);
if (subject.empty() || mailBody.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Subject and body are required");
if (subject.size() > 50 || mailBody.size() > 400) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Subject is limited to 50 and body to 400 characters");
const bool hasAttachment = lot > 0;
if (hasAttachment && (count < 1 || count > MAX_MAIL_ATTACHMENT_COUNT)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Attachment count must be 1-999");
if (hasAttachment && !LotExists(lot)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown item LOT");
// Mailing items is as strong as /gmadditem, so it needs its own permission
if (hasAttachment && !Can(context, "mail_items")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not attach items to mail");
if (recipient == "0" && hasAttachment && !Can(context, "mail_broadcast_items")) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not mail items to everyone");
const auto send = [&](LWOOBJID charId, const std::string& charName) {
MailInfo mail;
mail.senderId = LWOOBJID_EMPTY;
mail.senderUsername = "[GM] " + context.authenticatedUser;
mail.receiverId = charId;
mail.recipient = charName;
mail.subject = subject;
mail.body = mailBody;
mail.timeSent = static_cast<uint64_t>(std::time(nullptr));
mail.itemID = LWOOBJID_EMPTY;
mail.itemSubkey = LWOOBJID_EMPTY;
mail.itemLOT = hasAttachment ? lot : 0;
mail.itemCount = hasAttachment ? static_cast<int16_t>(count) : 0;
Database::Get()->InsertNewMail(mail);
};
const std::string attachment = hasAttachment ? " with " + std::to_string(count) + "x " + ItemName(lot) + " (" + std::to_string(lot) + ")" : "";
if (recipient == "0") {
auto characters = Database::Get()->GetCharacterIdsAndNames();
// Items to everyone skip the sender's own characters unless they may give themselves items (self_items; GM 9 always)
const bool includeOwn = !hasAttachment || context.gmLevel >= OPERATOR_LEVEL || Can(context, "self_items");
if (!includeOwn) {
const auto own = Database::Get()->GetAccountCharacterIds(context.accountId);
std::erase_if(characters, [&](const auto& entry) { return std::find(own.begin(), own.end(), entry.first) != own.end(); });
}
for (const auto& [id, name] : characters) send(id, name);
Audit(context, "broadcast_mail", "\"" + subject + "\" to " + std::to_string(characters.size()) + " characters" + attachment +
(hasAttachment ? includeOwn ? ", their own characters included" : ", leaving out their own characters" : ""));
return JsonSuccess(reply, { {"message", "Mail sent to " + std::to_string(characters.size()) + " characters"} });
}
const auto charId = GeneralUtils::TryParse<LWOOBJID>(recipient);
const auto info = charId ? Database::Get()->GetCharacterInfo(*charId) : std::nullopt;
if (!info) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Character not found");
// Items only go to accounts the sender may manage; their own characters need self_items
if (hasAttachment && !AuthorizeAccountAction(context, info->accountId, reply, eAccountAction::ITEMS)) return;
send(info->id, info->name);
Audit(context, "send_mail", "\"" + subject + "\" to " + info->name + attachment, AuditTarget::Character(info->id));
JsonSuccess(reply, { {"message", "Mail sent to " + info->name} });
});
Route(eHTTPMethod::GET, "/api/items/search", Perm("mail_send"), "Search items by name or LOT. Query: ?q=",
[](HTTPReply& reply, const HTTPContext& context) {
std::string query;
const auto pos = context.queryString.find("q=");
if (pos != std::string::npos) {
const auto end = context.queryString.find('&', pos);
query = context.queryString.substr(pos + 2, end == std::string::npos ? std::string::npos : end - pos - 2);
std::replace(query.begin(), query.end(), '+', ' ');
}
char decoded[256]{};
mg_url_decode(query.c_str(), query.size(), decoded, sizeof(decoded), 1);
JsonReply(reply, eHTTPStatusCode::OK, SearchItems(decoded));
});
}
}
nlohmann::json SearchItems(const std::string& query) {
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT id, name, displayName FROM Objects WHERE type = 'Loot' AND (name LIKE '%' || ? || '%' OR displayName LIKE '%' || ? || '%' OR id = ?) "
"ORDER BY name LIMIT 50;");
stmt.bind(1, query.c_str());
stmt.bind(2, query.c_str());
stmt.bind(3, GeneralUtils::TryParse<int>(query).value_or(-1));
auto result = stmt.execQuery();
nlohmann::json items = nlohmann::json::array();
while (!result.eof()) {
const std::string displayName = result.getStringField("displayName", "");
items.push_back({ {"lot", result.getIntField("id")}, {"name", displayName.empty() ? result.getStringField("name") : displayName} });
result.nextRow();
}
return items;
}
// For the property showcase (Showcase.cpp), which serves the same model data to people who aren't the owner
namespace PropertyAssets {
std::optional<std::pair<std::string, std::string>> ModelLxfml(const IPropertyContents::Model& model, LWOOBJID propertyId) { return ::ModelLxfml(model, propertyId); }
nlohmann::json ModelBehaviors(const IPropertyContents::Model& model) { return ::ModelBehaviors(model); }
std::optional<std::string> BrickBundle(uint32_t lod, uint32_t design) { return ::BrickBundle(lod, design); }
}
void RegisterAPIRoutes() {
RegisterStatusRoutes();
RegisterAccountRoutes();
RegisterCharacterRoutes();
RegisterPlayKeyRoutes();
RegisterPropertyRoutes();
RegisterBugReportRoutes();
RegisterPetRoutes();
RegisterLogRoutes();
RegisterMailRoutes();
}