From a4c4c24a5c7209b2c44a310bb76f7c9271678457 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 10 Nov 2026 18:37:08 -0600 Subject: [PATCH] feat(dashboard): the zone file API, with zone edit locks /api/v1/zones/{zoneId}: send a zone's edited files (multipart), follow the reload, list, restore and clear the zone's sets, and take, release and look at its lock. Every request needs an API key that names the zones_publish permission, an owner at zone_publish_gm_level, and HTTPS or the server's own machine; refusals are counted per address and every use is in the audit log. A publish is checked at its headers before any of its body is taken, the files are checked and written on a worker thread, and the zone's instances are replaced through the world reload, held while players are there unless told to move them. The Worlds page lists each zone's editors and lock holder for an admin. Co-Authored-By: Claude Fable 5.2 --- dCommon/ApiKeyScope.h | 5 + dCommon/Permissions.cpp | 2 + dDashboardServer/DashboardServer.cpp | 9 +- dDashboardServer/auth/ApiKeyService.cpp | 2 +- dDashboardServer/auth/ZonePublishAuth.h | 158 +++ dDashboardServer/routes/ApiKeyRoutes.cpp | 8 +- dDashboardServer/routes/CMakeLists.txt | 1 + dDashboardServer/routes/OpenApi.h | 16 +- dDashboardServer/routes/RouteUtils.cpp | 4 + dDashboardServer/routes/RouteUtils.h | 4 + dDashboardServer/routes/ZonePublishRoutes.cpp | 1064 +++++++++++++++++ dDashboardServer/routes/ZonePublishRoutes.h | 64 + dDashboardServer/templates/instances.jinja2 | 75 ++ docs/ZonePublish.md | 122 ++ tests/dWebTests/CMakeLists.txt | 2 + tests/dWebTests/OpenApiTests.cpp | 15 + tests/dWebTests/UploadRouteTests.cpp | 175 +++ tests/dWebTests/ZonePublishApiTests.cpp | 272 +++++ 18 files changed, 1993 insertions(+), 5 deletions(-) create mode 100644 dDashboardServer/auth/ZonePublishAuth.h create mode 100644 dDashboardServer/routes/ZonePublishRoutes.cpp create mode 100644 dDashboardServer/routes/ZonePublishRoutes.h create mode 100644 docs/ZonePublish.md create mode 100644 tests/dWebTests/UploadRouteTests.cpp create mode 100644 tests/dWebTests/ZonePublishApiTests.cpp diff --git a/dCommon/ApiKeyScope.h b/dCommon/ApiKeyScope.h index c4c149709..40c8ccfda 100644 --- a/dCommon/ApiKeyScope.h +++ b/dCommon/ApiKeyScope.h @@ -28,6 +28,11 @@ namespace ApiKeys { bool Has(const std::string& permission) const { return allPermissions || permissions.contains(permission); } }; + // Whether a key can still be used: not revoked, and not past its expiry (0: none). Times are unix seconds. + inline bool InUse(int64_t revokedAt, int64_t expiresAt, int64_t now) { + return revokedAt == 0 && (expiresAt == 0 || expiresAt > now); + } + // "*" or a comma-separated list, as stored in the database inline std::string JoinPermissions(bool all, const std::set& permissions) { if (all) return std::string(ALL_PERMISSIONS); diff --git a/dCommon/Permissions.cpp b/dCommon/Permissions.cpp index d3f6197c8..255f51fbd 100644 --- a/dCommon/Permissions.cpp +++ b/dCommon/Permissions.cpp @@ -112,6 +112,8 @@ namespace { { "events_manage", "Server", "Scheduled events: features", "Scheduled events that switch game features (event_1..event_8) on and off", 8 }, { "cdclient_reload", "Server", "Reload CDClient", "Make every server read the client's cdclient.fdb again after it changed (master also does this by itself)", 9 }, { "world_reload", "Server", "Reload worlds", "See the zone files each running world loaded and whether they changed on disk, and replace a zone's instances with ones on the files on disk now (players are moved over; master also does this by itself when a file changes)", 9 }, + { "zones_publish", "Server", "Zone files (API keys)", "Make an API key that can send edited zone files through the zone file API (/api/v1/zones). Using one also takes the GM level in zone_publish_gm_level and the zone's lock", 9 }, + { "zone_editors_manage", "Server", "Zone editors and locks", "Assign each zone's editors, see who holds its lock and take the lock away (also needs the GM level in zone_lock_admin_gm_level)", 9 }, { "server_live_update", "Server", "Live updates", "Move every server and world instance onto a new build without a restart (players see a short loading screen), and cancel one", 9 }, { "instances_manage", "Server", "Instance limits", "Change players per instance and spare instances per zone (the master server applies them)", 9 }, { "backups", "Server", "Database backups", "Make, download and delete database backups (downloads also need your password)", 9 }, diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 38032023f..e591b8664 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -67,6 +67,7 @@ #include "LiveWorld.h" #include "LiveUpdateRoutes.h" #include "WorldReloadRoutes.h" +#include "ZonePublishRoutes.h" #include "master/WorldFiles.h" #include "Scenery.h" #include "Workers.h" @@ -377,7 +378,10 @@ namespace { handlers.On(Master::SERVER_TRAFFIC, [](const ServerTraffic& report, const SystemAddress&) { Traffic::Ingest(report); }); handlers.On(Master::PROFILE_RESULT, [](const ProfileResult& result, const SystemAddress&) { Performance::IngestProfile(result); }); handlers.On(Master::LIVE_UPDATE_STATUS, [](const LiveUpdateStatus& status, const SystemAddress&) { LiveUpdateRoutes::HandleStatus(status); }); - handlers.On(Master::WORLD_FILES_STATUS, [](const WorldFilesStatus& status, const SystemAddress&) { WorldReloadRoutes::HandleStatus(status); }); + handlers.On(Master::WORLD_FILES_STATUS, [](const WorldFilesStatus& status, const SystemAddress&) { + WorldReloadRoutes::HandleStatus(status); + ZonePublishRoutes::HandleStatus(status); + }); return handlers; }(); return handlers; @@ -594,6 +598,7 @@ int main(int argc, char** argv) { LiveWorld::RegisterRoutes(); LiveUpdateRoutes::RegisterRoutes(); WorldReloadRoutes::RegisterRoutes(); + ZonePublishRoutes::RegisterRoutes(); Inspector::RegisterRoutes(); CaptureReplay::RegisterRoutes(); RegisterCDClientBrowserRoutes(); @@ -683,6 +688,7 @@ int main(int argc, char** argv) { { Profiler::Scope scope("Traffic::Update"); Traffic::Update(); } { Profiler::Scope scope("Performance::Update"); Performance::Update(); } { Profiler::Scope scope("ApiKeyService::Update"); ApiKeyService::Update(); } + { Profiler::Scope scope("ZonePublishRoutes::Update"); ZonePublishRoutes::Update(); } // Broadcast dashboard updates periodically if (elapsedSinceBroadcast >= broadcastInterval) { @@ -697,6 +703,7 @@ int main(int argc, char** argv) { // Cleanup: the worker threads first (they answer deferred requests), then the web server's connections Workers::Stop(); + ZonePublishRoutes::Shutdown(); Game::web.Shutdown(); ApiKeyService::Flush(); Inspector::Shutdown(); diff --git a/dDashboardServer/auth/ApiKeyService.cpp b/dDashboardServer/auth/ApiKeyService.cpp index e7268b30d..db12c6bf7 100644 --- a/dDashboardServer/auth/ApiKeyService.cpp +++ b/dDashboardServer/auth/ApiKeyService.cpp @@ -82,7 +82,7 @@ namespace { // The key is in use and its owner may still sign in; the owner's GM level as of now std::optional CheckKeyAndOwner(const IApiKeys::ApiKey& key) { const auto now = static_cast(std::time(nullptr)); - if (key.revokedAt != 0 || (key.expiresAt != 0 && key.expiresAt <= now)) return std::nullopt; + if (!ApiKeys::InUse(key.revokedAt, key.expiresAt, now)) return std::nullopt; const auto account = Database::Get()->GetAccountById(key.accountId); if (account.contains("error") || account.value("banned", false) || account.value("locked", false)) return std::nullopt; const auto gmLevel = static_cast(account.value("gm_level", 0)); diff --git a/dDashboardServer/auth/ZonePublishAuth.h b/dDashboardServer/auth/ZonePublishAuth.h new file mode 100644 index 000000000..85321e02d --- /dev/null +++ b/dDashboardServer/auth/ZonePublishAuth.h @@ -0,0 +1,158 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include "ApiKeyScope.h" +#include "eHTTPStatusCode.h" + +/** + * Who may use the zone file API (/api/v1/zones/..., docs/ZonePublish.md), checked on every request before any of + * its body is taken. Pure, so every refusal is unit tested without a web server or a database: the caller looks up + * the key and its owner (ApiKeyService does, on every request) and the request's addresses. + */ +namespace ZonePublishAuth { + // The permission an API key needs in its own list (the zones.publish scope; permission names have no dots, so it + // is kept as zones_publish and either spelling makes a key with it). A key with "all of the owner's permissions" + // does not count, so a key can only write zone files when it was made for that. + constexpr const char* SCOPE = "zones_publish"; + constexpr const char* SCOPE_ALIAS = "zones.publish"; + + inline bool IsLoopback(std::string_view address) { + return address == "::1" || address == "localhost" || address.starts_with("127.") || address == "::ffff:127.0.0.1"; + } + + /** + * Whether a request's key travelled safely: over HTTPS, or from this machine. The dashboard itself speaks plain + * HTTP; HTTPS is a reverse proxy's in front of it (behind_proxy=1), which says so in X-Forwarded-Proto. + * @param peer The address the connection came from + * @param forwardedFor The last X-Forwarded-For entry (what the proxy added), empty for none + */ + inline bool TransportAllowed(bool behindProxy, std::string_view peer, std::string_view forwardedFor, std::string_view forwardedProto) { + // Without a proxy nothing is encrypted: this machine only. X-Forwarded-* from anyone is not believed. + if (!behindProxy) return IsLoopback(peer); + // Not through the proxy at all: a local caller + if (forwardedFor.empty() && forwardedProto.empty()) return IsLoopback(peer); + if (forwardedProto == "https") return true; + // Through the proxy over plain HTTP: only from the proxy's own machine + return IsLoopback(forwardedFor) && IsLoopback(peer); + } + + enum class eDenial : uint8_t { + NONE, + RATE_LIMITED, // too many refused requests from this address + PLAIN_HTTP, // a key sent over plain HTTP from another machine + NO_KEY, // no key, or one that is unknown, revoked, expired or whose owner can't sign in + NOT_A_KEY, // signed in some other way (a session): this API takes API keys only + SCOPE, // the key's permissions don't name zones.publish + READ_ONLY, // a read-only key changing something + GM_LEVEL, // the owner's GM level is below zone_publish_gm_level now + }; + + struct Request { + bool blocked{}; // FailureLimiter::Blocked for the address + bool transportAllowed{}; + bool authenticated{}; // a credential was accepted (HTTPContext::isAuthenticated) + const ApiKeys::Scope* key{}; // the API key that authenticated it, if one did + uint8_t gmLevel{}; // the key owner's GM level, read from the database for this request + uint8_t requiredLevel{}; // zone_publish_gm_level + bool changes{}; // anything but a GET + }; + + inline eDenial Check(const Request& request) { + if (request.blocked) return eDenial::RATE_LIMITED; + if (!request.transportAllowed) return eDenial::PLAIN_HTTP; + if (!request.authenticated) return eDenial::NO_KEY; + if (!request.key) return eDenial::NOT_A_KEY; + if (!request.key->permissions.contains(SCOPE)) return eDenial::SCOPE; + if (request.key->readOnly && request.changes) return eDenial::READ_ONLY; + if (request.gmLevel < request.requiredLevel) return eDenial::GM_LEVEL; + return eDenial::NONE; + } + + inline eHTTPStatusCode Status(eDenial denial) { + switch (denial) { + case eDenial::NONE: return eHTTPStatusCode::OK; + case eDenial::RATE_LIMITED: return eHTTPStatusCode::TOO_MANY_REQUESTS; + case eDenial::NO_KEY: + case eDenial::NOT_A_KEY: return eHTTPStatusCode::UNAUTHORIZED; + default: return eHTTPStatusCode::FORBIDDEN; + } + } + + // The reply's "error" + inline const char* Code(eDenial denial) { + switch (denial) { + case eDenial::NONE: return ""; + case eDenial::RATE_LIMITED: return "rate_limited"; + case eDenial::PLAIN_HTTP: return "https_required"; + case eDenial::NO_KEY: return "unauthorized"; + case eDenial::NOT_A_KEY: return "api_key_required"; + case eDenial::SCOPE: return "scope_required"; + case eDenial::READ_ONLY: return "read_only_key"; + case eDenial::GM_LEVEL: return "gm_level"; + } + return ""; + } + + inline const char* Message(eDenial denial) { + switch (denial) { + case eDenial::NONE: return ""; + case eDenial::RATE_LIMITED: return "Too many refused requests from this address; try again later"; + case eDenial::PLAIN_HTTP: return "API keys are only taken over HTTPS, or from the server's own machine"; + case eDenial::NO_KEY: return "Send a valid API key as 'Authorization: Bearer '"; + case eDenial::NOT_A_KEY: return "This API takes API keys only"; + case eDenial::SCOPE: return "This API key doesn't have the zones.publish permission"; + case eDenial::READ_ONLY: return "This API key is read-only"; + case eDenial::GM_LEVEL: return "Your account's GM level is too low for zone files"; + } + return ""; + } + + /** + * Refused requests per address, on top of each key's own rate limit: after maxFailures inside the window, the + * address is refused outright until its failures age out. Times are unix seconds, passed in. Web thread only. + */ + class FailureLimiter { + public: + FailureLimiter(uint32_t maxFailures, int64_t windowSeconds) : m_Max(maxFailures), m_Window(windowSeconds) {} + + bool Blocked(const std::string& address, int64_t now) { + const auto it = m_Failures.find(address); + if (it == m_Failures.end()) return false; + Age(it->second, now); + if (it->second.empty()) { + m_Failures.erase(it); + return false; + } + return it->second.size() >= m_Max; + } + + // Returns true when this failure is the one that blocks the address + bool RecordFailure(const std::string& address, int64_t now) { + if (m_Failures.size() > 10000) Prune(now); + auto& failures = m_Failures[address]; + Age(failures, now); + if (failures.size() >= m_Max) return false; + failures.push_back(now); + return failures.size() == m_Max; + } + + size_t Size() const { return m_Failures.size(); } + + private: + void Age(std::deque& failures, int64_t now) const { + while (!failures.empty() && failures.front() <= now - m_Window) failures.pop_front(); + } + void Prune(int64_t now) { + std::erase_if(m_Failures, [&](auto& entry) { Age(entry.second, now); return entry.second.empty(); }); + } + + uint32_t m_Max; + int64_t m_Window; + std::map> m_Failures; + }; +} diff --git a/dDashboardServer/routes/ApiKeyRoutes.cpp b/dDashboardServer/routes/ApiKeyRoutes.cpp index 9aceb2fc0..eab6b81fd 100644 --- a/dDashboardServer/routes/ApiKeyRoutes.cpp +++ b/dDashboardServer/routes/ApiKeyRoutes.cpp @@ -14,6 +14,7 @@ #include "PermissionGrantsLoader.h" #include "RequireAuthMiddleware.h" #include "RouteUtils.h" +#include "ZonePublishAuth.h" using namespace RouteUtils; using AccountRules::eAccountAction; @@ -213,7 +214,12 @@ namespace ApiKeyRoutes { key.permissions = std::string(ApiKeys::ALL_PERMISSIONS); } else if (requested.is_array()) { std::set permissions; - for (const auto& permission : requested) if (permission.is_string()) permissions.insert(permission.get()); + for (const auto& permission : requested) { + if (!permission.is_string()) continue; + // The zone file API's scope is written zones.publish; permission names have no dots + auto name = permission.get(); + permissions.insert(name == ZonePublishAuth::SCOPE_ALIAS ? std::string(ZonePublishAuth::SCOPE) : name); + } if (permissions.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick at least one permission"); // Staff can't hand a key more than they have const auto refused = Permissions::NotGrantable(context.gmLevel, permissions, context.grants.get()); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index 80b4a4d0d..694eb53ba 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -21,6 +21,7 @@ set(DASHBOARDROUTES_SOURCES "LiveWorld.cpp" "LiveUpdateRoutes.cpp" "WorldReloadRoutes.cpp" + "ZonePublishRoutes.cpp" "SettingsRoutes.cpp" "Background.cpp" "DatabaseUsage.cpp" diff --git a/dDashboardServer/routes/OpenApi.h b/dDashboardServer/routes/OpenApi.h index bbb45d210..503de8c04 100644 --- a/dDashboardServer/routes/OpenApi.h +++ b/dDashboardServer/routes/OpenApi.h @@ -14,7 +14,8 @@ * description in the house style become query parameters and a request body: * "Query: ?range=24h|7d|30d, &files= (newest per server)" or "Query: after (the last id), limit (max 500)" * "Body: {name, enabled, parent (default root.xml)}" (also "Body (optional): {...}", "Body adds {...}") - * Routes described as "(DataTables)" take the DataTables paging body. Pure (routes in, JSON out) so it can be tested. + * Routes described as "(DataTables)" take the DataTables paging body; ones described as "(multipart)" take + * multipart/form-data: the fields of their "Body: {...}" and file parts. Pure (routes in, JSON out) so it can be tested. */ namespace OpenApi { struct Route { @@ -203,7 +204,18 @@ namespace OpenApi { properties[param.name] = schema; example[param.name] = param.isList ? nlohmann::json::array() : nlohmann::json(param.choices.empty() ? "" : param.choices.front()); } - operation["requestBody"] = { {"required", false}, {"content", { {"application/json", { {"schema", { {"type", "object"}, {"properties", properties} }}, {"example", example} }} }} }; + if (route.description.find("(multipart)") != std::string::npos) { + // File uploads: the fields from "Body: {...}", and any number of file parts + properties["files"] = { {"type", "array"}, {"items", { {"type", "string"}, {"format", "binary"} }}, + {"description", "One part per file; each part's filename is the file's path"} }; + operation["requestBody"] = { {"required", true}, {"content", { {"multipart/form-data", { {"schema", { {"type", "object"}, {"properties", properties} }} }} }} }; + operation["responses"]["409"] = { {"description", "Conflict: {error, message}"} }; + operation["responses"]["413"] = { {"description", "Too large"} }; + operation["responses"]["422"] = { {"description", "Refused: {error: validation_failed, files: [{path, ok, errors: [{code, message, offset}]}]}"} }; + operation["responses"]["429"] = { {"description", "Too many requests"} }; + } else { + operation["requestBody"] = { {"required", false}, {"content", { {"application/json", { {"schema", { {"type", "object"}, {"properties", properties} }}, {"example", example} }} }} }; + } } paths[path][method] = operation; } diff --git a/dDashboardServer/routes/RouteUtils.cpp b/dDashboardServer/routes/RouteUtils.cpp index 31bf0e0f7..42f2b91c7 100644 --- a/dDashboardServer/routes/RouteUtils.cpp +++ b/dDashboardServer/routes/RouteUtils.cpp @@ -92,6 +92,10 @@ namespace RouteUtils { Register(method, path, std::move(middleware), std::move(handler)); } + void Document(eHTTPMethod method, const std::string& path, const std::string& permission, const std::string& description) { + g_RouteDocs.push_back({ std::string(magic_enum::enum_name(method)), path, Permissions::Level(permission), description, permission, method == eHTTPMethod::GET }); + } + bool Can(const HTTPContext& context, const std::string& permission) { return context.isAuthenticated && Permissions::Allowed(context.gmLevel, permission, context.apiKey.get(), context.grants.get()); } diff --git a/dDashboardServer/routes/RouteUtils.h b/dDashboardServer/routes/RouteUtils.h index f0d242e09..d1a39909b 100644 --- a/dDashboardServer/routes/RouteUtils.h +++ b/dDashboardServer/routes/RouteUtils.h @@ -260,6 +260,10 @@ namespace RouteUtils { void Route(eHTTPMethod method, const std::string& path, int16_t minGmLevel, const std::string& description, Handler handler); void Route(eHTTPMethod method, const std::string& path, const Perm& permission, const std::string& description, Handler handler); + // List a route that was registered with its own middleware (not through Route) in the API documentation; + // permission: the permission its callers need + void Document(eHTTPMethod method, const std::string& path, const std::string& permission, const std::string& description); + // Routes registered while one of these lives only read, even POSTs (lookups that send their query as a body), so // read-only API keys may use them. POST /api/tables/... (DataTables) always counts as a read. struct ReadRoutes { diff --git a/dDashboardServer/routes/ZonePublishRoutes.cpp b/dDashboardServer/routes/ZonePublishRoutes.cpp new file mode 100644 index 000000000..1db2b279f --- /dev/null +++ b/dDashboardServer/routes/ZonePublishRoutes.cpp @@ -0,0 +1,1064 @@ +#include "ZonePublishRoutes.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "ClientAssets.h" +#include "Database.h" +#include "Game.h" +#include "GameText.h" +#include "GeneralUtils.h" +#include "IHTTPMiddleware.h" +#include "Logger.h" +#include "MasterPackets.h" +#include "MultipartFile.h" +#include "Permissions.h" +#include "ReportRoutes.h" +#include "RouteUtils.h" +#include "ServerState.h" +#include "Web.h" +#include "ZoneEditLock.h" +#include "ZoneOverrideStore.h" +#include "ZonePaths.h" +#include "ZonePublish.h" +#include "ZonePublishAuth.h" +#include "dConfig.h" +#include "dServer.h" +#include "eHTTPMethod.h" +#include "master/WorldFiles.h" + +using namespace RouteUtils; +using ZoneEditLock::eReload; +using ZoneEditLock::eResult; + +namespace { + constexpr const char* BASE = "/api/v1/zones/:zoneid"; + constexpr const char* AUDIT_ACTION = "zone_publish"; + constexpr const char* ADMIN_PERMISSION = "zone_editors_manage"; + constexpr size_t ZONE_SEGMENT = 3; // /api/v1/zones/ + constexpr size_t MAX_NOTE = 500; + constexpr size_t MAX_RECORDS = 500; + constexpr uint64_t MIB = 1024 * 1024; + // A multipart body is its files plus a little framing per part + constexpr uint64_t FRAMING_BYTES = 1 * MIB; + constexpr auto RELOAD_TIMEOUT = std::chrono::minutes(10); + // Refused requests per address before the address is refused outright for a while + constexpr uint32_t MAX_FAILURES = 10; + constexpr int64_t FAILURE_WINDOW = 600; + constexpr int64_t READ_AUDIT_SECONDS = 60; + + int64_t Now() { return static_cast(std::time(nullptr)); } + + // ---- Settings (main thread; the worker gets copies) ---- + + uint32_t ConfigNumber(const char* key, uint32_t fallback) { + if (!Game::config) return fallback; + return GeneralUtils::TryParse(Game::config->GetValue(key)).value_or(fallback); + } + + std::filesystem::path Root() { + return ZoneOverrideStore::RootFromSetting(Game::config ? Game::config->GetValue("zone_override_dir") : std::string()); + } + + ZoneEditLock::Levels Levels() { + ZoneEditLock::Levels levels; + levels.publish = static_cast(std::clamp(ConfigNumber("zone_publish_gm_level", ZoneEditLock::DEFAULT_PUBLISH_LEVEL), 1, 9)); + levels.admin = static_cast(std::clamp(ConfigNumber("zone_lock_admin_gm_level", ZoneEditLock::DEFAULT_ADMIN_LEVEL), 1, 9)); + return levels; + } + + ZonePublish::Limits Limits() { + ZonePublish::Limits limits; + limits.maxFileBytes = static_cast(std::max(ConfigNumber("zone_publish_max_file_mb", 64), 1)) * MIB; + limits.maxTotalBytes = static_cast(std::max(ConfigNumber("zone_publish_max_total_mb", 256), 1)) * MIB; + limits.maxFiles = std::max(ConfigNumber("zone_publish_max_files", 128), 1); + limits.maxObjects = std::max(ConfigNumber("zone_publish_max_objects", 100000), 1); + return limits; + } + + uint32_t KeepBackups() { return ConfigNumber("zone_publish_max_backups", 20); } + + // ---- Replies: {"error": code, "message": text} ---- + + void Error(HTTPReply& reply, eHTTPStatusCode status, const std::string& code, const std::string& message, nlohmann::json extra = nlohmann::json::object()) { + extra["error"] = code; + extra["message"] = message; + JsonReply(reply, status, extra); + } + + // A name as it was sent, with bytes that are not text replaced (a reply has to be valid UTF-8) + std::string SafeText(const std::string& text) { + return nlohmann::json::parse(nlohmann::json(text).dump(-1, ' ', false, nlohmann::json::error_handler_t::replace)).get(); + } + + std::string NewId() { + unsigned char bytes[8]; + if (RAND_bytes(bytes, sizeof(bytes)) != 1) throw std::runtime_error("RAND_bytes failed"); + static constexpr char digits[] = "0123456789abcdef"; + std::string id; + for (const auto byte : bytes) { + id += digits[byte >> 4]; + id += digits[byte & 15]; + } + return id; + } + + std::string AccountName(uint32_t accountId) { + const auto account = Database::Get()->GetAccountById(accountId); + return account.contains("error") ? "account " + std::to_string(accountId) : account.value("name", std::string()); + } + + // ---- Audit: every use of a key, and every refusal ---- + + std::map g_ReadAudited; // key id, zone and action -> when last written + uint64_t g_Audited = 0; // entries written (or, for reads, counted as written), to tell a request that left none + + // `read`: something that only looks (asked for again and again while a reload runs), written at most once a minute + void AuditUse(const HTTPContext& context, uint32_t zoneId, const std::string& action, const std::string& result, bool read = false) { + const auto keyId = context.apiKey ? context.apiKey->keyId : 0; + const auto text = "key " + std::to_string(keyId) + ", zone " + std::to_string(zoneId) + ", " + action + ": " + result; + g_Audited++; + if (read) { + const auto now = Now(); + if (g_ReadAudited.size() > 5000) g_ReadAudited.clear(); + auto& last = g_ReadAudited[std::to_string(keyId) + "/" + std::to_string(zoneId) + "/" + action]; + if (now - last < READ_AUDIT_SECONDS) { + LOG_DEBUG("[zone api] %s: %s", context.authenticatedUser.c_str(), text.c_str()); + return; + } + last = now; + } + Audit(context, AUDIT_ACTION, text); + } + + // ---- The gate every route goes through first ---- + + ZonePublishAuth::FailureLimiter g_Failures(MAX_FAILURES, FAILURE_WINDOW); + + std::string LastForwardedFor(const HTTPContext& context) { + const auto& forwarded = context.GetHeader("X-Forwarded-For"); + const auto comma = forwarded.rfind(','); + auto last = comma == std::string::npos ? forwarded : forwarded.substr(comma + 1); + last.erase(0, last.find_first_not_of(' ')); + last.erase(last.find_last_not_of(' ') + 1); + return last; + } + + std::string ActionOf(const HTTPContext& context) { + std::string action = context.method; + for (size_t i = ZONE_SEGMENT + 1; ; ++i) { + const auto segment = PathSegment(context.path, i); + if (segment.empty()) break; + action += " " + std::string(segment); + } + return action; + } + + // A request that was refused without anything saying so in the audit log gets its entry here: no refusal goes unwritten + void AuditIfRefused(const HTTPContext& context, const HTTPReply& reply, uint64_t auditedBefore) { + if (g_Audited != auditedBefore || reply.deferred || static_cast(reply.status) < 400) return; + const auto body = nlohmann::json::parse(reply.message, nullptr, false); + const auto code = body.is_object() ? body.value("error", std::string("error")) : std::string("error"); + AuditUse(context, PathId(context.path, ZONE_SEGMENT).value_or(0), ActionOf(context), + "refused (" + code + ", " + std::to_string(static_cast(reply.status)) + ")"); + } + + class ZoneApiGate : public IHTTPMiddleware { + public: + bool Process(HTTPContext& context, HTTPReply& reply) override { + const auto address = ClientAddress(context); + const auto now = Now(); + ZonePublishAuth::Request request; + request.blocked = g_Failures.Blocked(address, now); + request.transportAllowed = ZonePublishAuth::TransportAllowed(ConfigFlag("behind_proxy", false), context.clientIP, + LastForwardedFor(context), context.GetHeader("X-Forwarded-Proto")); + request.authenticated = context.isAuthenticated; + request.key = context.apiKey.get(); + request.gmLevel = context.gmLevel; // read from the database for this request (ApiKeyService) + request.requiredLevel = Levels().publish; + request.changes = context.method != "GET"; + const auto denial = ZonePublishAuth::Check(request); + if (denial == ZonePublishAuth::eDenial::NONE) return true; + + Error(reply, ZonePublishAuth::Status(denial), ZonePublishAuth::Code(denial), ZonePublishAuth::Message(denial)); + // Already refused outright: said once, when the address was blocked + if (denial == ZonePublishAuth::eDenial::RATE_LIMITED) return false; + const bool nowBlocked = g_Failures.RecordFailure(address, now); + const auto zoneId = PathId(context.path, ZONE_SEGMENT).value_or(0); + auto who = context; + if (!who.isAuthenticated) who.authenticatedUser = "[no key] " + address.substr(0, 45); + std::string result = std::string("refused (") + ZonePublishAuth::Code(denial) + ") from " + address; + if (nowBlocked) result += "; the address is refused for the next " + std::to_string(FAILURE_WINDOW / 60) + " minutes"; + AuditUse(who, zoneId, ActionOf(context), result); + return false; + } + std::string GetName() const override { return "ZoneApiGate"; } + }; + + // ---- Zones ---- + + struct ZoneRef { + uint32_t zoneId{}; + std::string luzPath; // relative to res/maps/, lowercase, forward slashes + std::string folder; + }; + + std::optional ZoneOf(uint32_t zoneId) { + auto luz = ZoneLuzPath(zoneId); + if (!luz) return std::nullopt; + std::replace(luz->begin(), luz->end(), '\\', '/'); + ZoneRef zone{ zoneId, ZonePublish::Canonical(*luz), {} }; + zone.folder = ZonePublish::FolderOf(zone.luzPath); + // A zone whose files are not in a folder of their own under maps/ can't be given a set + if (ZonePublish::NameProblem(zone.luzPath) || zone.folder.empty()) return std::nullopt; + return zone; + } + + // The zone a request names and the override folder, or the error reply + std::optional ZoneFor(const HTTPContext& context, HTTPReply& reply, std::filesystem::path* root = nullptr) { + const auto zoneId = PathId(context.path, ZONE_SEGMENT); + const auto zone = zoneId ? ZoneOf(*zoneId) : std::nullopt; + if (!zone) { + Error(reply, eHTTPStatusCode::NOT_FOUND, "unknown_zone", "There is no such zone"); + return std::nullopt; + } + if (root) { + *root = Root(); + if (root->empty()) { + Error(reply, eHTTPStatusCode::SERVICE_UNAVAILABLE, "disabled", "Edited zone files are turned off on this server (zone_override_dir is empty)"); + return std::nullopt; + } + } + return zone; + } + + uint32_t PlayersIn(uint32_t zoneId, uint32_t* instances = nullptr) { + std::lock_guard lock(ServerState::g_StatusMutex); + uint32_t players = 0; + for (const auto& world : ServerState::g_WorldInstances) { + if (world.mapID != zoneId) continue; + players += world.players; + if (instances) (*instances)++; + } + return players; + } + + // ---- Locks ---- + + ZoneEditLock::Actor ActorOf(const HTTPContext& context, uint32_t zoneId) { + return { context.accountId, context.gmLevel, Database::Get()->IsZoneEditor(zoneId, context.accountId) }; + } + + std::optional HolderOf(const std::optional& lock) { + return lock ? std::optional(lock->accountId) : std::nullopt; + } + + // Whether the caller holds the zone's lock; writes the 403 otherwise + bool RequireLock(const HTTPContext& context, uint32_t zoneId, HTTPReply& reply, const std::string& action) { + const auto lock = Database::Get()->GetZoneLock(zoneId); + const auto result = ZoneEditLock::Change(ActorOf(context, zoneId), HolderOf(lock), Levels()); + if (result == eResult::OK) return true; + if (result == eResult::LEVEL_TOO_LOW) { + Error(reply, eHTTPStatusCode::FORBIDDEN, "gm_level", "Your account's GM level is too low for zone files"); + } else { + nlohmann::json extra{ {"holder", lock ? nlohmann::json(AccountName(lock->accountId)) : nlohmann::json(nullptr)} }; + if (lock) extra["since"] = lock->lockedAt; + extra["holder_is_you"] = false; + Error(reply, eHTTPStatusCode::FORBIDDEN, "lock_required", lock ? "Another account holds this zone's lock" : "Take this zone's lock first", extra); + } + AuditUse(context, zoneId, action, "refused (" + reply.message.substr(0, 120) + ")"); + return false; + } + + nlohmann::json LockJson(uint32_t zoneId, uint32_t callerAccountId) { + const auto lock = Database::Get()->GetZoneLock(zoneId); + nlohmann::json editors = nlohmann::json::array(); + for (const auto& editor : Database::Get()->GetZoneEditors(zoneId)) editors.push_back(AccountName(editor.accountId)); + return { {"holder", lock ? nlohmann::json(AccountName(lock->accountId)) : nlohmann::json(nullptr)}, + {"since", lock ? nlohmann::json(lock->lockedAt) : nlohmann::json(nullptr)}, + {"holder_is_you", lock && lock->accountId == callerAccountId}, {"editors", editors} }; + } + + // ---- The worker: file work only, its result handed back to the main thread ---- + + using Completion = std::function; + std::thread g_Worker; + std::mutex g_Mutex; + std::condition_variable g_Wake; + std::deque> g_Jobs; + std::deque g_Done; + bool g_Stopping = false; + + void PostMain(Completion done) { + std::lock_guard lock(g_Mutex); + g_Done.push_back(std::move(done)); + } + + void WorkerLoop() { + while (true) { + std::function job; + { + std::unique_lock lock(g_Mutex); + g_Wake.wait(lock, [] { return g_Stopping || !g_Jobs.empty(); }); + if (g_Stopping) return; + job = std::move(g_Jobs.front()); + g_Jobs.pop_front(); + } + auto done = job(); + if (done) PostMain(std::move(done)); + } + } + + void Submit(std::function job) { + { + std::lock_guard lock(g_Mutex); + if (!g_Worker.joinable()) { + g_Stopping = false; + g_Worker = std::thread(WorkerLoop); + } + g_Jobs.push_back(std::move(job)); + } + g_Wake.notify_one(); + } + + // ---- What was asked for, by id ---- + + struct Record { + std::string id; + uint32_t zoneId{}; + std::string action; // publish, restore, clear + std::string state = "validating"; + uint32_t accountId{}; + std::string user; + bool reloadWanted = true; + bool movePlayers{}; + uint32_t instances{}; + uint32_t playersMoved{}; + std::vector errors; + std::string error, message; // why it failed + int64_t finishedAt{}; + ZonePublishRoutes::ReloadCounters before; + std::chrono::steady_clock::time_point reloadAt; + int64_t createdAt{}; + }; + + std::map g_Records; + std::map g_Active; // zone -> the change being checked or written + std::set g_Waiting; // zones whose files changed and whose reload hasn't been asked for + std::map g_Counters; // master's last status, per zone + std::map g_ZoneMessages; + + bool Finished(const Record& record) { return record.state == "done" || record.state == "failed"; } + + Record& NewRecord(const std::string& id, uint32_t zoneId, const std::string& action, const HTTPContext& context) { + if (g_Records.size() >= MAX_RECORDS) { + // The oldest finished ones go + std::vector> finished; + for (const auto& [key, record] : g_Records) if (Finished(record)) finished.emplace_back(record.createdAt, key); + std::ranges::sort(finished); + for (size_t i = 0; i < finished.size() && g_Records.size() >= MAX_RECORDS; ++i) g_Records.erase(finished[i].second); + } + auto& record = g_Records[id]; + record.id = id; + record.zoneId = zoneId; + record.action = action; + record.accountId = context.accountId; + record.user = context.authenticatedUser; + record.createdAt = Now(); + return record; + } + + void Finish(Record& record, bool ok) { + record.state = ok ? "done" : "failed"; + record.finishedAt = Now(); + } + + void Fail(Record& record, const std::string& error, const std::string& message) { + record.error = error; + record.message = message; + Finish(record, false); + } + + nlohmann::json RecordJson(const Record& record) { + nlohmann::json json{ {"publishId", record.id}, {"zoneId", record.zoneId}, {"action", record.action}, {"state", record.state}, + {"players", PlayersIn(record.zoneId)}, + {"reload", { {"instances", record.instances}, {"playersMoved", record.playersMoved}, {"errors", record.errors} }}, + {"finishedAt", record.finishedAt ? nlohmann::json(record.finishedAt) : nlohmann::json(nullptr)} }; + if (record.state == "failed") { + json["error"] = record.error.empty() ? "failed" : record.error; + json["message"] = record.message; + } + return json; + } + + bool MasterConnected() { return Game::server && Game::server->GetIsConnectedToMaster(); } + + void SendHold(uint32_t zoneId, bool hold) { + if (!MasterConnected()) return; + WorldReloadHold message; + message.zoneId = zoneId; + message.hold = hold; + MasterPackets::SendToMaster(message); + } + + // Ask master to replace the zone's instances now (the existing world reload) + void StartReload(Record& record) { + g_Waiting.erase(record.zoneId); + uint32_t instances = 0; + PlayersIn(record.zoneId, &instances); + if (instances == 0) { + // Nothing runs: the next instance reads the new files as it starts + SendHold(record.zoneId, false); + return Finish(record, true); + } + const auto counters = g_Counters.find(record.zoneId); + record.before = counters == g_Counters.end() ? ZonePublishRoutes::ReloadCounters{} : counters->second; + WorldReloadRequest request; + request.zoneId = record.zoneId; + request.requestedBy = ("zone files, " + record.user).substr(0, WorldFiles::MAX_BY); + MasterPackets::SendToMaster(request); + record.state = "reloading"; + record.reloadAt = std::chrono::steady_clock::now(); + } + + // After a zone's files changed: reload now, hold while players are there, or leave it + eReload DecideReload(Record& record) { + const auto players = PlayersIn(record.zoneId); + const auto plan = ZoneEditLock::PlanReload(record.reloadWanted, players, record.movePlayers); + if (plan == eReload::STARTED) { + StartReload(record); + } else { + // Master goes on leaving the zone's instances alone until a reload is asked for + g_Waiting.insert(record.zoneId); + if (plan == eReload::HELD) record.state = "held"; + else Finish(record, true); + } + return plan; + } + + // Nothing was changed after all: master watches the zone again, unless an earlier set still waits + void ReleaseHold(uint32_t zoneId) { + if (!g_Waiting.contains(zoneId)) SendHold(zoneId, false); + } + + nlohmann::json FilesJson(const std::vector& files) { + nlohmann::json out = nlohmann::json::array(); + for (const auto& file : files) out.push_back({ {"path", file.path}, {"bytes", file.bytes}, {"sha256", file.sha256}, {"version", file.version} }); + return out; + } + + // Reads "reload" and "ifPlayersPresent" as a form field or JSON member gave them; false for a value that is neither + bool ReadFlag(const std::string& text, bool fallback, bool& out) { + if (text.empty()) { out = fallback; return true; } + if (text == "true" || text == "1") { out = true; return true; } + if (text == "false" || text == "0") { out = false; return true; } + return false; + } + + bool ReadPlayersRule(const std::string& text, bool& move) { + if (text.empty() || text == "hold") { move = false; return true; } + if (text == "move") { move = true; return true; } + return false; + } + + std::string JsonText(const nlohmann::json& body, const char* key) { + const auto it = body.find(key); + if (it == body.end() || it->is_null()) return ""; + if (it->is_string()) return it->get(); + if (it->is_boolean()) return it->get() ? "true" : "false"; + return "?"; + } + + // What the worker did with a change + struct Outcome { + eHTTPStatusCode status = eHTTPStatusCode::OK; + std::string error, message; // set for anything but a set that was checked + bool validated{}; + ZonePublish::Result validation; + bool dryRun{}; + bool applied{}; + ZoneOverrideStore::Applied result; + bool reloadWanted = true; + bool movePlayers{}; + std::string note; + }; + + // Main thread: answer the request the worker finished, and start (or hold) the reload + void Complete(const std::string& id, const DeferredReply& deferred, const HTTPContext& context, const Outcome& outcome, const std::filesystem::path& root) { + ZoneOverrideStore::RemoveStaging(root, id); + const auto it = g_Records.find(id); + if (it == g_Records.end()) return; + auto& record = it->second; + if (const auto active = g_Active.find(record.zoneId); active != g_Active.end() && active->second == id) g_Active.erase(active); + const auto zoneId = record.zoneId; + const auto action = record.action; + HTTPReply reply; + + if (!outcome.error.empty()) { + Error(reply, outcome.status, outcome.error, outcome.message); + AuditUse(context, zoneId, action, "failed (" + outcome.error + ": " + outcome.message + ")"); + ReleaseHold(zoneId); + // A request still waiting gets the error as its answer; one that was given its id finds it in the status + if (!deferred) return Fail(record, outcome.error, outcome.message); + g_Records.erase(it); + return deferred.Send(std::move(reply)); + } + if (outcome.validated && !outcome.validation.ok) { + nlohmann::json files = nlohmann::json::array(); + std::string codes; + for (const auto& file : outcome.validation.files) { + if (file.ok) continue; + nlohmann::json errors = nlohmann::json::array(); + for (const auto& error : file.errors) { + nlohmann::json entry{ {"code", error.code}, {"message", error.message} }; + if (error.offset) entry["offset"] = *error.offset; + errors.push_back(std::move(entry)); + if (codes.find(error.code) == std::string::npos) codes += (codes.empty() ? "" : ", ") + error.code; + } + files.push_back({ {"path", SafeText(file.path)}, {"ok", false}, {"errors", errors} }); + } + JsonReply(reply, eHTTPStatusCode::UNPROCESSABLE_ENTITY, { {"error", "validation_failed"}, {"files", files} }); + AuditUse(context, zoneId, action, "refused (validation_failed: " + codes + "); nothing was written"); + ReleaseHold(zoneId); + g_Records.erase(it); + return deferred.Send(std::move(reply)); + } + if (outcome.dryRun) { + nlohmann::json files = nlohmann::json::array(); + for (const auto& file : outcome.validation.files) files.push_back({ {"path", file.path}, {"bytes", file.bytes}, {"sha256", file.sha256}, {"version", file.version} }); + JsonReply(reply, eHTTPStatusCode::OK, { {"zoneId", zoneId}, {"dryRun", true}, {"files", files}, {"reload", { {"state", "skipped"} }} }); + AuditUse(context, zoneId, action, "dry run passed (" + std::to_string(files.size()) + " files); nothing was written"); + ReleaseHold(zoneId); + g_Records.erase(it); + return deferred.Send(std::move(reply)); + } + + record.reloadWanted = outcome.reloadWanted; + record.movePlayers = outcome.movePlayers; + const auto plan = DecideReload(record); + JsonReply(reply, eHTTPStatusCode::OK, { {"publishId", id}, {"zoneId", zoneId}, + {"backupId", outcome.result.backupId ? nlohmann::json(*outcome.result.backupId) : nlohmann::json(nullptr)}, + {"files", FilesJson(outcome.result.files)}, {"reload", { {"state", ZoneEditLock::ReloadName(plan)} }} }); + AuditUse(context, zoneId, action, "applied as " + id + " (" + std::to_string(outcome.result.files.size()) + " files" + + (outcome.result.backupId ? ", the set before is backup " + *outcome.result.backupId : "") + "), reload " + ZoneEditLock::ReloadName(plan) + + (outcome.note.empty() ? "" : "; note: " + outcome.note.substr(0, 200))); + if (deferred) deferred.Send(std::move(reply)); + } + + // The checks every change starts with, on the main thread; the change's id, or nothing with the reply written + std::optional BeginChange(const HTTPContext& context, HTTPReply& reply, const std::string& action, std::filesystem::path& root) { + const auto zone = ZoneFor(context, reply, &root); + if (!zone) return std::nullopt; + if (!RequireLock(context, zone->zoneId, reply, action)) return std::nullopt; + if (g_Active.contains(zone->zoneId)) { + Error(reply, eHTTPStatusCode::CONFLICT, "publish_in_progress", "This zone's files are being changed right now; try again when that is done"); + AuditUse(context, zone->zoneId, action, "refused (publish_in_progress)"); + return std::nullopt; + } + if (!MasterConnected()) { + Error(reply, eHTTPStatusCode::SERVICE_UNAVAILABLE, "master_unavailable", "The dashboard is not connected to the master server"); + return std::nullopt; + } + return zone; + } + + // ---- POST publish: the precheck at the headers, then the body ---- + + // Runs before any of the body is taken: the zone, the lock, and one change at a time + class PublishPrecheck : public IHTTPMiddleware { + public: + bool Process(HTTPContext& context, HTTPReply& reply) override { + std::filesystem::path root; + const auto audited = g_Audited; + if (!BeginChange(context, reply, "publish", root)) { + AuditIfRefused(context, reply, audited); + return false; + } + if (!MultipartFile::Boundary(context.GetHeader("Content-Type"))) { + Error(reply, eHTTPStatusCode::BAD_REQUEST, "malformed", "Send the files as multipart/form-data"); + AuditIfRefused(context, reply, audited); + return false; + } + context.userData["publish_id"] = NewId(); + return true; + } + std::string GetName() const override { return "ZonePublishPrecheck"; } + }; + + std::string SpoolFile(const HTTPContext& context) { + const auto id = context.userData.find("publish_id"); + if (id == context.userData.end()) return ""; + try { + return (ZoneOverrideStore::MakeUploadDir(Root(), id->second) / "body").string(); + } catch (const std::exception& ex) { + LOG("Zone files: no room for an upload: %s", ex.what()); + return ""; + } + } + + void HandlePublish(HTTPReply& reply, const HTTPContext& context) { + const auto id = context.userData.at("publish_id"); + std::filesystem::path root; + // The upload took a while: the zone's lock and its one change at a time are looked at again + const auto zone = BeginChange(context, reply, "publish", root); + const auto boundary = MultipartFile::Boundary(context.GetHeader("Content-Type")); + if (!zone || !boundary) { + ZoneOverrideStore::RemoveStaging(Root(), id); + return; + } + NewRecord(id, zone->zoneId, "publish", context); + g_Active[zone->zoneId] = id; + // Master leaves the zone's instances alone from before the files change until the reload is decided + SendHold(zone->zoneId, true); + + ZonePublish::Zone target; + target.zoneId = zone->zoneId; + target.luzPath = zone->luzPath; + // The scenes the zone has in the client's files: a set can't add any + if (const auto luz = ClientAssets::ReadResFile("maps/" + zone->luzPath)) { + for (const auto& scene : ZonePaths::ReadSceneFiles(*luz)) target.clientScenes.insert(zone->folder + ZonePublish::Canonical(scene)); + } + const auto clientMaps = ClientAssets::ResFolder() / "maps"; + target.hasFile = [clientMaps](const std::string& path) { + std::error_code code; + return std::filesystem::is_regular_file(clientMaps / path, code); + }; + const auto limits = Limits(); + const auto keep = KeepBackups(); + const auto who = context.authenticatedUser; + const auto now = Now(); + const auto body = std::filesystem::path(context.bodyFile); + const auto deferred = Web::Defer(reply, context); + + Submit([=]() -> Completion { + Outcome outcome; + const auto fail = [&](eHTTPStatusCode status, const char* error, const std::string& message) { + outcome.status = status; + outcome.error = error; + outcome.message = message; + }; + try { + std::vector parts; + MultipartFile::Limits split; + split.maxParts = limits.maxFiles + 16; + split.maxFileBytes = limits.maxFileBytes; + const auto error = MultipartFile::Split(body, *boundary, body.parent_path(), split, parts); + std::error_code code; + std::filesystem::remove(body, code); + std::string reload, dryRun, players; + std::vector uploads; + bool strayFile = false; + for (const auto& part : parts) { + if (part.isFile && part.name != "file") strayFile = true; + else if (part.isFile) uploads.push_back({ part.filename, part.file, part.bytes }); + else if (part.name == "reload") reload = part.value; + else if (part.name == "dryRun") dryRun = part.value; + else if (part.name == "ifPlayersPresent") players = part.value; + else if (part.name == "note") outcome.note = part.value.substr(0, MAX_NOTE); + } + if (error == MultipartFile::eError::TOO_MANY_PARTS) fail(eHTTPStatusCode::PAYLOAD_TOO_LARGE, "too_large", "The set has more files than this server takes"); + else if (error == MultipartFile::eError::IO) fail(eHTTPStatusCode::INTERNAL_SERVER_ERROR, "storage", "The upload could not be read"); + else if (error != MultipartFile::eError::NONE) fail(eHTTPStatusCode::BAD_REQUEST, "malformed", "The body is not well formed multipart/form-data"); + else if (strayFile) fail(eHTTPStatusCode::BAD_REQUEST, "malformed", "Every file part's form field name is \"file\""); + else if (!ReadFlag(reload, true, outcome.reloadWanted) || !ReadFlag(dryRun, false, outcome.dryRun) || !ReadPlayersRule(players, outcome.movePlayers)) { + fail(eHTTPStatusCode::BAD_REQUEST, "malformed", "reload and dryRun are true or false; ifPlayersPresent is hold or move"); + } + if (!outcome.error.empty()) return [=] { Complete(id, deferred, context, outcome, root); }; + + outcome.validation = ZonePublish::Validate(target, uploads, limits); + outcome.validated = true; + if (outcome.validation.ok && !outcome.dryRun) { + PostMain([id] { if (const auto it = g_Records.find(id); it != g_Records.end()) it->second.state = "applying"; }); + std::vector files; + for (const auto& file : outcome.validation.files) files.push_back({ file.path, file.source, file.bytes, file.sha256, file.version }); + outcome.result = ZoneOverrideStore::Apply(root, { target.zoneId, ZonePublish::FolderOf(target.luzPath), id, who, outcome.note, now, keep }, files); + outcome.applied = true; + } + } catch (const ZoneOverrideStore::StoreError& ex) { + fail(eHTTPStatusCode::INTERNAL_SERVER_ERROR, "storage", ex.what()); + } catch (const std::exception&) { + fail(eHTTPStatusCode::INTERNAL_SERVER_ERROR, "internal", "The set could not be checked"); + } + return [=] { Complete(id, deferred, context, outcome, root); }; + }); + } + + // Restore a backup or clear the zone's set: the same swap, and the same reload + void HandleStoreChange(HTTPReply& reply, const HTTPContext& context, const std::string& action) { + std::filesystem::path root; + const auto zone = BeginChange(context, reply, action, root); + if (!zone) return; + const auto body = ParseBody(context); + Outcome request; + if (!body || !ReadFlag(JsonText(*body, "reload"), true, request.reloadWanted) || !ReadPlayersRule(JsonText(*body, "ifPlayersPresent"), request.movePlayers)) { + return Error(reply, eHTTPStatusCode::BAD_REQUEST, "malformed", "Send JSON; reload is true or false, ifPlayersPresent is hold or move"); + } + std::string backupId; + if (action == "restore") { + backupId = JsonText(*body, "backupId"); + if (!ZoneOverrideStore::ValidId(backupId)) return Error(reply, eHTTPStatusCode::BAD_REQUEST, "malformed", "Send the backupId to restore"); + const auto backups = ZoneOverrideStore::Backups(root, zone->zoneId); + if (std::ranges::none_of(backups, [&](const auto& backup) { return backup.id == backupId; })) { + AuditUse(context, zone->zoneId, action, "refused (no backup " + backupId + ")"); + return Error(reply, eHTTPStatusCode::NOT_FOUND, "unknown_backup", "This zone has no such backup"); + } + } + const auto id = NewId(); + NewRecord(id, zone->zoneId, action, context).state = "applying"; + g_Active[zone->zoneId] = id; + SendHold(zone->zoneId, true); + const ZoneOverrideStore::Change change{ zone->zoneId, zone->folder, id, context.authenticatedUser, + action == "restore" ? "restored " + backupId : "", Now(), KeepBackups() }; + // Answered with its id now; how it goes is the status' to say + const DeferredReply deferred; + JsonReply(reply, eHTTPStatusCode::OK, { {"publishId", id}, {"zoneId", zone->zoneId} }); + Submit([=]() -> Completion { + Outcome outcome = request; + try { + outcome.result = action == "restore" ? ZoneOverrideStore::Restore(root, change, backupId) : ZoneOverrideStore::Clear(root, change); + outcome.applied = true; + if (action == "restore") outcome.note = "backup " + backupId; + } catch (const std::exception& ex) { + outcome.status = eHTTPStatusCode::INTERNAL_SERVER_ERROR; + outcome.error = "storage"; + outcome.message = ex.what(); + } + return [=] { Complete(id, deferred, context, outcome, root); }; + }); + } + + // The record a request names, when it is this zone's; writes the 404 otherwise + Record* RecordFor(const HTTPContext& context, HTTPReply& reply, uint32_t zoneId) { + const auto id = std::string(PathSegment(context.path, ZONE_SEGMENT + 2)); + const auto it = g_Records.find(id); + if (it == g_Records.end() || it->second.zoneId != zoneId) { + Error(reply, eHTTPStatusCode::NOT_FOUND, "unknown_publish", "This zone has no such publish (they are forgotten when the dashboard restarts)"); + return nullptr; + } + return &it->second; + } + + void Register(eHTTPMethod method, const std::string& path, const std::string& description, Handler handler) { + Game::web.RegisterHTTPRoute({ .path = path, .method = method, .middleware = { std::make_shared() }, + .handle = [handler = std::move(handler)](HTTPReply& reply, const HTTPContext& context) { + const auto audited = g_Audited; + try { + handler(reply, context); + } catch (const std::exception& ex) { + LOG("Error handling %s %s: %s", context.method.c_str(), context.path.c_str(), ex.what()); + Error(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "internal", "Internal server error"); + } + AuditIfRefused(context, reply, audited); + } }); + Document(method, path, ZonePublishAuth::SCOPE, description); + } + + // ---- The dashboard's own page: editors and locks (a signed-in admin, not the API) ---- + + bool RequireAdmin(const HTTPContext& context, HTTPReply& reply) { + if (context.gmLevel >= Levels().admin) return true; + JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Managing zone editors and locks needs GM level " + std::to_string(Levels().admin) + " (zone_lock_admin_gm_level)"); + return false; + } + + nlohmann::json AdminJson() { + std::map zones; + const auto zoneRow = [&zones](uint32_t zoneId) -> nlohmann::json& { + auto [it, added] = zones.try_emplace(zoneId); + if (added) it->second = { {"zone", zoneId}, {"zoneName", GameText::ZoneName(zoneId)}, {"holder", nullptr}, {"holderId", 0}, {"since", nullptr}, + {"editors", nlohmann::json::array()}, {"state", ""} }; + return it->second; + }; + for (const auto& editor : Database::Get()->GetZoneEditors(0)) { + zoneRow(editor.zoneId)["editors"].push_back({ {"accountId", editor.accountId}, {"name", AccountName(editor.accountId)}, + {"assignedBy", editor.assignedBy}, {"assignedAt", editor.assignedAt} }); + } + for (const auto& lock : Database::Get()->GetZoneLocks()) { + auto& row = zoneRow(lock.zoneId); + row["holder"] = AccountName(lock.accountId); + row["holderId"] = lock.accountId; + row["since"] = lock.lockedAt; + } + for (const auto zoneId : g_Waiting) zoneRow(zoneId)["state"] = "waiting for its reload"; + for (const auto& [zoneId, id] : g_Active) zoneRow(zoneId)["state"] = "being changed"; + nlohmann::json rows = nlohmann::json::array(); + for (auto& [_, row] : zones) rows.push_back(std::move(row)); + const auto levels = Levels(); + return { {"enabled", !Root().empty()}, {"zones", rows}, {"maxEditors", ZoneEditLock::MAX_EDITORS}, + {"publishLevel", levels.publish}, {"adminLevel", levels.admin} }; + } +} + +namespace ZonePublishRoutes { + void HandleStatus(const WorldFilesStatus& status) { + g_Counters.clear(); + g_ZoneMessages.clear(); + for (const auto& zone : status.zones) { + g_Counters[zone.zoneId] = { true, zone.requests, zone.acted, zone.playersMoved, zone.failed, zone.pending }; + g_ZoneMessages[zone.zoneId] = zone.message; + } + for (auto& [id, record] : g_Records) { + if (record.state != "reloading") continue; + const auto counters = g_Counters.find(record.zoneId); + const auto progress = ProgressOf(record.before, counters == g_Counters.end() ? ReloadCounters{} : counters->second); + record.instances = std::max(record.instances, progress.instances); + record.playersMoved = std::max(record.playersMoved, progress.playersMoved); + if (progress.failed > 0) { + const auto message = std::to_string(progress.failed) + " instance(s) could not be replaced: " + g_ZoneMessages[record.zoneId]; + if (record.errors.empty()) record.errors.push_back(message); + else record.errors.back() = message; + } + if (!progress.done) continue; + // Nothing was replaced and something could not be: the new files are not loaded + if (progress.failed > 0 && progress.instances == 0) Fail(record, "reload_failed", record.errors.back()); + else Finish(record, true); + } + } + + void Update() { + std::deque done; + { + std::lock_guard lock(g_Mutex); + done.swap(g_Done); + } + for (auto& completion : done) { + try { + completion(); + } catch (const std::exception& ex) { + LOG("Zone files: finishing a change failed: %s", ex.what()); + } + } + const auto now = std::chrono::steady_clock::now(); + for (auto& [id, record] : g_Records) { + if (record.state != "reloading" || now - record.reloadAt < RELOAD_TIMEOUT) continue; + record.errors.push_back("The master server did not say the reload finished"); + Fail(record, "reload_failed", record.errors.back()); + } + } + + void Shutdown() { + { + std::lock_guard lock(g_Mutex); + g_Stopping = true; + } + g_Wake.notify_all(); + if (g_Worker.joinable()) g_Worker.join(); + } + + void RegisterRoutes() { + // What a stopped dashboard left half done was never swapped in + ZoneOverrideStore::RemoveAllStaging(Root()); + const std::string base = BASE; + + Game::web.RegisterUploadRoute({ .path = base + "/publish", .method = eHTTPMethod::POST, + .middleware = { std::make_shared(), std::make_shared() }, + .maxBytes = [] { return Limits().maxTotalBytes + FRAMING_BYTES; }, + .spoolFile = SpoolFile, + .handle = [](HTTPReply& reply, const HTTPContext& context) { + const auto audited = g_Audited; + try { + HandlePublish(reply, context); + } catch (const std::exception& ex) { + LOG("Error handling a zone publish: %s", ex.what()); + Error(reply, eHTTPStatusCode::INTERNAL_SERVER_ERROR, "internal", "Internal server error"); + } + AuditIfRefused(context, reply, audited); + } }); + Document(eHTTPMethod::POST, base + "/publish", ZonePublishAuth::SCOPE, + "Send a zone's edited files (multipart). One file part per file, its filename the path relative to res/maps/ as the zone names it: the zone's " + ".luz, and any of its scenes' .lvl and .lutriggers files and its .raw. Every file is checked first and nothing is kept unless all pass (422 " + "lists what is wrong, file by file). The set replaces the zone's whole earlier set, which becomes a backup. Needs the zone's lock. " + "Body: {reload=true|false (default true: replace the zone's instances), dryRun=true|false (only check), note, ifPlayersPresent=hold|move " + "(default hold: with players there the reload waits for .../reload)}. Returns {publishId, zoneId, backupId, files: [{path, bytes, sha256, version}], " + "reload: {state: started|held|skipped}}"); + + Register(eHTTPMethod::GET, base + "/publish/:publishid", + "How a publish, restore or clear is going: {state: validating|applying|held|reloading|done|failed, players (in the zone now), " + "reload: {instances, playersMoved, errors}, finishedAt}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto zone = ZoneFor(context, reply); + if (!zone) return; + const auto* record = RecordFor(context, reply, zone->zoneId); + if (!record) return; + AuditUse(context, zone->zoneId, "status " + record->id, record->state, true); + JsonReply(reply, eHTTPStatusCode::OK, RecordJson(*record)); + }); + + Register(eHTTPMethod::POST, base + "/publish/:publishid/reload", + "Go on with a reload that is held because players are in the zone. Needs the zone's lock. Body: {ifPlayersPresent=move|hold (hold only " + "reloads when nobody is there any more)}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto zone = ZoneFor(context, reply); + if (!zone) return; + auto* record = RecordFor(context, reply, zone->zoneId); + if (!record) return; + if (!RequireLock(context, zone->zoneId, reply, "reload " + record->id)) return; + const auto body = ParseBody(context); + bool move = false; + if (!body || !ReadPlayersRule(JsonText(*body, "ifPlayersPresent"), move)) return Error(reply, eHTTPStatusCode::BAD_REQUEST, "malformed", "ifPlayersPresent is hold or move"); + if (record->state != "held") return Error(reply, eHTTPStatusCode::CONFLICT, "not_held", "This publish is not waiting for its reload"); + if (!MasterConnected()) return Error(reply, eHTTPStatusCode::SERVICE_UNAVAILABLE, "master_unavailable", "The dashboard is not connected to the master server"); + if (move || PlayersIn(zone->zoneId) == 0) StartReload(*record); + AuditUse(context, zone->zoneId, "reload " + record->id, record->state == "held" ? "still held (players are there)" : "reload started" + std::string(move ? " (players are moved)" : "")); + JsonReply(reply, eHTTPStatusCode::OK, RecordJson(*record)); + }); + + Register(eHTTPMethod::GET, base + "/overrides", + "The zone's edited files in use ({current: {files: [{path, bytes, sha256, publishedBy, publishedAt}]}, null for none) and the sets it had before " + "({backups: [{backupId, createdAt, createdBy, note}]}, newest first)", + [](HTTPReply& reply, const HTTPContext& context) { + std::filesystem::path root; + const auto zone = ZoneFor(context, reply, &root); + if (!zone) return; + nlohmann::json files = nlohmann::json::array(), backups = nlohmann::json::array(); + for (const auto& file : ZoneOverrideStore::Current(root, zone->zoneId, zone->folder)) { + files.push_back({ {"path", file.path}, {"bytes", file.bytes}, {"sha256", file.sha256}, {"publishedBy", file.who}, {"publishedAt", file.when} }); + } + for (const auto& backup : ZoneOverrideStore::Backups(root, zone->zoneId)) { + backups.push_back({ {"backupId", backup.id}, {"createdAt", backup.time}, {"createdBy", backup.who}, {"note", backup.note} }); + } + AuditUse(context, zone->zoneId, "overrides", "listed", true); + JsonReply(reply, eHTTPStatusCode::OK, { {"current", files.empty() ? nlohmann::json(nullptr) : nlohmann::json{ {"files", files} }}, {"backups", backups} }); + }); + + Register(eHTTPMethod::POST, base + "/overrides/restore", + "Put one of the zone's earlier sets back (the set in use becomes a backup), then reload as a publish does. Returns {publishId} to ask the status with. Needs the zone's lock. " + "Body: {backupId, reload=true|false, ifPlayersPresent=hold|move}", + [](HTTPReply& reply, const HTTPContext& context) { HandleStoreChange(reply, context, "restore"); }); + + Register(eHTTPMethod::DELETE, base + "/overrides", + "Take the zone's edited files away, so the client's are read again (the set becomes a backup), then reload as a publish does. Returns " + "{publishId} to ask the status with. Needs the zone's lock. Body (optional): {reload=true|false, ifPlayersPresent=hold|move}", + [](HTTPReply& reply, const HTTPContext& context) { HandleStoreChange(reply, context, "clear"); }); + + Register(eHTTPMethod::GET, base + "/lock", "Who holds the zone's lock and who its editors are: {holder (null: nobody), since, holder_is_you, editors: [names]}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto zone = ZoneFor(context, reply); + if (!zone) return; + AuditUse(context, zone->zoneId, "lock", "looked at", true); + JsonReply(reply, eHTTPStatusCode::OK, LockJson(zone->zoneId, context.accountId)); + }); + + Register(eHTTPMethod::POST, base + "/lock", + "Take the zone's lock: one of its editors, or an admin (zone_lock_admin_gm_level). 409 {error: zone_locked, holder, since} when another account holds it. " + "A lock is kept until it is released", + [](HTTPReply& reply, const HTTPContext& context) { + const auto zone = ZoneFor(context, reply); + if (!zone) return; + const auto time = Now(); + auto lock = Database::Get()->GetZoneLock(zone->zoneId); + auto result = ZoneEditLock::Take(ActorOf(context, zone->zoneId), HolderOf(lock), Levels()); + // Someone else got there between the look and the take + if (result == eResult::OK && !Database::Get()->TakeZoneLock(zone->zoneId, context.accountId, time)) { + lock = Database::Get()->GetZoneLock(zone->zoneId); + result = lock && lock->accountId == context.accountId ? eResult::NO_CHANGE : eResult::LOCKED_BY_OTHER; + } + switch (result) { + case eResult::OK: + case eResult::NO_CHANGE: { + AuditUse(context, zone->zoneId, "lock", result == eResult::OK ? "taken" : "already held"); + auto json = LockJson(zone->zoneId, context.accountId); + json["result"] = static_cast(result); + return JsonReply(reply, eHTTPStatusCode::OK, json); + } + case eResult::LEVEL_TOO_LOW: + Error(reply, eHTTPStatusCode::FORBIDDEN, "gm_level", "Your account's GM level is too low for zone files", { {"result", 1} }); + break; + case eResult::NOT_ASSIGNED: + Error(reply, eHTTPStatusCode::FORBIDDEN, "not_an_editor", "Your account is not one of this zone's editors", { {"result", 2} }); + break; + case eResult::LOCKED_BY_OTHER: + Error(reply, eHTTPStatusCode::CONFLICT, "zone_locked", "Another account holds this zone's lock", + { {"holder", lock ? AccountName(lock->accountId) : std::string()}, {"since", lock ? lock->lockedAt : 0}, {"holder_is_you", false}, {"result", 3} }); + break; + } + AuditUse(context, zone->zoneId, "lock", "refused (" + nlohmann::json::parse(reply.message).value("error", std::string()) + ")"); + }); + + Register(eHTTPMethod::DELETE, base + "/lock", "Release the zone's lock: its holder, or an admin (zone_lock_admin_gm_level) taking it away from whoever holds it", + [](HTTPReply& reply, const HTTPContext& context) { + const auto zone = ZoneFor(context, reply); + if (!zone) return; + const auto lock = Database::Get()->GetZoneLock(zone->zoneId); + const auto result = ZoneEditLock::Release(ActorOf(context, zone->zoneId), HolderOf(lock), Levels()); + if (result == eResult::OK) { + Database::Get()->ReleaseZoneLock(zone->zoneId); + const bool forced = lock->accountId != context.accountId; + AuditUse(context, zone->zoneId, "unlock", forced ? "taken away from " + AccountName(lock->accountId) : "released"); + } else if (result == eResult::LEVEL_TOO_LOW) { + AuditUse(context, zone->zoneId, "unlock", "refused (gm_level)"); + return Error(reply, eHTTPStatusCode::FORBIDDEN, "gm_level", "Your account's GM level is too low for zone files", { {"result", 1} }); + } else if (result == eResult::LOCKED_BY_OTHER) { + AuditUse(context, zone->zoneId, "unlock", "refused (held by " + AccountName(lock->accountId) + ")"); + return Error(reply, eHTTPStatusCode::FORBIDDEN, "lock_required", "Another account holds this zone's lock", + { {"holder", AccountName(lock->accountId)}, {"since", lock->lockedAt}, {"holder_is_you", false}, {"result", 3} }); + } else { + AuditUse(context, zone->zoneId, "unlock", "nothing was locked"); + } + auto json = LockJson(zone->zoneId, context.accountId); + json["result"] = static_cast(result); + JsonReply(reply, eHTTPStatusCode::OK, json); + }); + + // The Worlds page's card + Route(eHTTPMethod::GET, "/api/zone-editing", Perm(ADMIN_PERMISSION), + "Every zone that has editors or a held lock: its editors, who holds its lock since when, and whether its files wait for a reload", + [](HTTPReply& reply, const HTTPContext&) { JsonSuccess(reply, AdminJson()); }); + + Route(eHTTPMethod::POST, "/api/zone-editing/:zone/editors", Perm(ADMIN_PERMISSION), + "Make an account one of a zone's editors (up to 10 per zone). Body: {account (its name or ID)}", + [](HTTPReply& reply, const HTTPContext& context) { + if (!RequireAdmin(context, reply)) return; + const auto zoneId = PathId(context.path, 2); + if (!zoneId || !ZoneOf(*zoneId)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "There is no such zone"); + const auto body = ParseBody(context); + if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); + std::string text = (*body).contains("account") && (*body)["account"].is_number_unsigned() ? std::to_string((*body)["account"].get()) : body->value("account", std::string()); + uint32_t accountId = 0; + if (const auto asId = GeneralUtils::TryParse(text); asId && !Database::Get()->GetAccountById(*asId).contains("error")) accountId = *asId; + else if (const auto info = Database::Get()->GetAccountInfo(text)) accountId = info->id; + if (accountId == 0) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Account not found"); + if (Database::Get()->IsZoneEditor(*zoneId, accountId)) return JsonSuccess(reply, { {"message", "Already one of its editors"} }); + if (Database::Get()->GetZoneEditors(*zoneId).size() >= ZoneEditLock::MAX_EDITORS) { + return JsonError(reply, eHTTPStatusCode::CONFLICT, "A zone has at most " + std::to_string(ZoneEditLock::MAX_EDITORS) + " editors"); + } + Database::Get()->AddZoneEditor({ *zoneId, accountId, context.authenticatedUser.substr(0, 64), Now() }); + Audit(context, "zone_editor_add", "Made " + AccountName(accountId) + " an editor of zone " + std::to_string(*zoneId), AuditTarget::Account(accountId)); + JsonSuccess(reply, { {"message", "Editor added"} }); + }); + + Route(eHTTPMethod::POST, "/api/zone-editing/:zone/editors/remove", Perm(ADMIN_PERMISSION), + "Take an account off a zone's editors (a lock it holds stays until it is released). Body: {accountId}", + [](HTTPReply& reply, const HTTPContext& context) { + if (!RequireAdmin(context, reply)) return; + const auto zoneId = PathId(context.path, 2); + const auto body = ParseBody(context); + if (!zoneId || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid request"); + const auto accountId = body->value("accountId", 0u); + if (!Database::Get()->RemoveZoneEditor(*zoneId, accountId)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Not one of its editors"); + Audit(context, "zone_editor_remove", "Took " + AccountName(accountId) + " off the editors of zone " + std::to_string(*zoneId), AuditTarget::Account(accountId)); + JsonSuccess(reply, { {"message", "Editor removed"} }); + }); + + Route(eHTTPMethod::POST, "/api/zone-editing/:zone/release", Perm(ADMIN_PERMISSION), + "Take a zone's lock away from whoever holds it", + [](HTTPReply& reply, const HTTPContext& context) { + if (!RequireAdmin(context, reply)) return; + const auto zoneId = PathId(context.path, 2); + if (!zoneId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid zone"); + const auto lock = Database::Get()->GetZoneLock(*zoneId); + if (!lock || !Database::Get()->ReleaseZoneLock(*zoneId)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Nobody holds this zone's lock"); + Audit(context, "zone_lock_release", "Took the lock of zone " + std::to_string(*zoneId) + " away from " + AccountName(lock->accountId), AuditTarget::Account(lock->accountId)); + JsonSuccess(reply, { {"message", "Lock released"} }); + }); + } +} diff --git a/dDashboardServer/routes/ZonePublishRoutes.h b/dDashboardServer/routes/ZonePublishRoutes.h new file mode 100644 index 000000000..3458cdb85 --- /dev/null +++ b/dDashboardServer/routes/ZonePublishRoutes.h @@ -0,0 +1,64 @@ +#pragma once + +#include + +#include "json.hpp" + +struct WorldFilesStatus; + +/** + * The zone file API (/api/v1/zones/..., docs/ZonePublish.md): an external level editor sends a zone's edited files, + * which are checked, kept in the override folder and loaded by replacing the zone's instances. A thin layer: who + * may is ZonePublishAuth (the key) and ZoneEditLock (the zone's lock), checking a set is ZonePublish, keeping it is + * ZoneOverrideStore, and the reload is the world reload master already does (WorldReloader). + * + * Routes and their database use run on the main thread; reading and writing the files runs on one worker thread, + * which touches nothing else and hands its result back to the main thread (Update). + */ +namespace ZonePublishRoutes { + void RegisterRoutes(); + + // Master's world file status (WORLD_FILES_STATUS): how far the reloads asked for here have come + void HandleStatus(const WorldFilesStatus& status); + + // Main loop: finish what the worker did + void Update(); + + // Stops the worker (a set being written finishes first) + void Shutdown(); + + // What master counts per zone (WorldFilesStatus::Zone); present: the zone has instances that reported files + struct ReloadCounters { + bool present{}; + uint32_t requests{}; + uint32_t acted{}; + uint32_t playersMoved{}; + uint32_t failed{}; + uint16_t pending{}; + }; + + struct ReloadProgress { + bool done{}; + uint32_t instances{}; + uint32_t playersMoved{}; + uint32_t failed{}; + }; + + /** + * What a reload asked for when the counters were `before` has done by `now`: done once master took the request + * and nothing of the zone is being replaced any more, or the zone has no instance left. Pure. + */ + inline ReloadProgress ProgressOf(const ReloadCounters& before, const ReloadCounters& now) { + ReloadProgress progress; + // No instance left (they were all stopped), or the counts started over: nothing more will come + if (!now.present || now.requests < before.requests || now.acted < before.acted) { + progress.done = true; + return progress; + } + progress.instances = now.acted - before.acted; + progress.playersMoved = now.playersMoved >= before.playersMoved ? now.playersMoved - before.playersMoved : 0; + progress.failed = now.failed >= before.failed ? now.failed - before.failed : 0; + progress.done = now.requests > before.requests && now.pending == 0; + return progress; + } +} diff --git a/dDashboardServer/templates/instances.jinja2 b/dDashboardServer/templates/instances.jinja2 index 8bd13c330..e06b8b739 100644 --- a/dDashboardServer/templates/instances.jinja2 +++ b/dDashboardServer/templates/instances.jinja2 @@ -97,6 +97,27 @@ {% endif %} +{% if can.zone_editors_manage %} +
+
+
Zone editors and locks
+ +
+
+

Who may send a zone's edited files through the zone file API (/api/v1/zones). A zone has up to + 10 editors; one account at a time holds its lock, and only the holder can send, restore or clear its files. + A lock is kept until its holder releases it or it is taken away here.

+
+
+
+
+
+
+
+

Loading…

+
+
+{% endif %} {% endblock %} {% block scripts %} @@ -289,4 +310,58 @@ })(); {% endif %} +{% if can.zone_editors_manage %} + +{% endif %} {% endblock %} diff --git a/docs/ZonePublish.md b/docs/ZonePublish.md new file mode 100644 index 000000000..eb0dd45da --- /dev/null +++ b/docs/ZonePublish.md @@ -0,0 +1,122 @@ +# Edited zone files + +Taking a zone's edited files from an external level editor, keeping them apart from the client's files, and loading +them by replacing the zone's instances ([WorldHotReload.md](WorldHotReload.md)). The server's client files are never +written. + +Code: `dCommon/ZonePublish` (checking a set), `dCommon/ZoneOverrideStore` (the override folder: apply, backups, +restore, clear), `dCommon/ZoneEditLock.h` (who may: lock and reload rules), `dCommon/dClient/AssetManager` (reading +overrides), `dDatabase/.../IZoneEditing.h` (locks and editors), `dWeb/MultipartFile` and `Web::RegisterUploadRoute` +(large uploads), `dDashboardServer/auth/ZonePublishAuth.h` (the API's gate), +`dDashboardServer/routes/ZonePublishRoutes` (the API). The first four know nothing of HTTP, so another way in uses the +same checks, store, locks and reload. + +## Override folder + +`zone_override_dir` (sharedconfig.ini; empty: off; relative to the servers' folder unless absolute). + +| Path | Holds | +| --- | --- | +| `maps//...` | the files in use | +| `maps//.zone-.json` | which files are zone ``'s set, who sent it, when, its note | +| `staging//` | a set while it is checked and built | +| `backups//-/` | the sets a zone had before (`files/`, `manifest.json`) | + +For a name under `maps/` the asset manager reads `/maps/...` when the file is there, else the +client's. Names are lowercase with forward slashes. Master watches the same file a world would read: an override that +appears, changes or goes away makes the instances that read another version stale. + +A zone's set is replaced whole. The folder's new contents are built in `staging`, flushed to disk and swapped in with +one rename (an exchange where the system has one). The set replaced is copied to `backups` first; the newest +`zone_publish_max_backups` are kept. Restore and clear are the same swap. Files of another zone in the same folder +stay. + +## Who may + +- An API key (`Authorization: Bearer`) whose own permission list has `zones_publish` (also written `zones.publish` + when a key is made). A key with all of its owner's permissions does not count. Keys are stored as a SHA-256 hash. +- The key's owner at GM level `zone_publish_gm_level` or higher, read from the database on every request. +- HTTPS, or the server's own machine. The dashboard speaks plain HTTP: with `behind_proxy=1` the proxy's + `X-Forwarded-Proto: https` counts; without it only loopback does. Anything else is refused and audited. +- Ten refused requests from one address in ten minutes refuse the address for the rest of that time, on top of the + key's own rate limit. +- Every use and every refusal is in the audit log (action `zone_publish`: key ID, zone, action, result; the account is + the entry's actor). Reads are written at most once a minute per key, zone and action. + +Locks: a zone has up to 10 assigned editors and one lock holder. Taking the lock needs an assigned editor, or GM level +`zone_lock_admin_gm_level`. Releasing: the holder, or an admin. Sending, restoring and clearing need the caller to +hold the lock. Locks do not expire. Results where a number is given: 0 ok, 1 level too low, 2 not assigned, 3 locked +by another account, 4 no change. + +## Checks + +All files are checked before any is written; one failure refuses the set. + +| Code | Meaning | +| --- | --- | +| `name_not_allowed` | Not a path in the zone's folder the `.luz` lists: `..`, absolute, backslash, NUL, non-ASCII, another zone's folder, an unlisted file | +| `duplicate` | The same path twice (compared in lowercase) | +| `missing_luz` | The set lacks the zone's `.luz` (its path comes from the ZoneTable) | +| `too_large` | A file over `zone_publish_max_file_mb`, the set over `zone_publish_max_total_mb` or `zone_publish_max_files` | +| `parse_failed` | The server's own reader does not read the file to its end (`offset`: where it stopped) | +| `unsupported_version` | `.luz` outside 30 to 41, `.lvl` over 48 | +| `scene_mismatch` | The `.luz` lists a scene the zone does not have in the client files, or one that is in neither the set nor the client | +| `terrain_mismatch` | The `.raw` sent is not the one the `.luz` names, or the one named is nowhere | +| `trigger_missing_target` | A trigger command without a `target` | +| `unknown_trigger_event`, `unknown_trigger_command` | An ID the server does not know | +| `bad_object_config` | An object setting that does not parse | +| `too_many_objects` | Over `zone_publish_max_objects` objects in the set's scenes | + +Allowed names: the `.luz`, each listed scene's `.lvl` and `.lutriggers`, the `.raw` the `.luz` names. A set cannot add +scenes. Before a file is written its path is resolved (`std::filesystem::weakly_canonical`) and must be inside the +override folder. + +## API + +Base `/api/v1`. Errors are `{"error", "message"}`. `zoneId` is the ZoneTable ID. + +| Route | Does | +| --- | --- | +| `POST /zones/{zoneId}/publish` | multipart/form-data: one file part per file (filename: its path relative to `res/maps/`), fields `reload` (default true), `dryRun`, `note`, `ifPlayersPresent` (`hold` default, `move`) | +| `GET /zones/{zoneId}/publish/{publishId}` | `{publishId, zoneId, action, state, players, reload: {instances, playersMoved, errors}, finishedAt}`; states `validating`, `applying`, `held`, `reloading`, `done`, `failed` | +| `POST /zones/{zoneId}/publish/{publishId}/reload` | `{"ifPlayersPresent": "move"}` goes on with a held reload | +| `GET /zones/{zoneId}/overrides` | `{current: {files: [{path, bytes, sha256, publishedBy, publishedAt}]} or null, backups: [{backupId, createdAt, createdBy, note}]}` | +| `POST /zones/{zoneId}/overrides/restore` | `{backupId, reload, ifPlayersPresent}`; answers `{publishId, zoneId}` at once | +| `DELETE /zones/{zoneId}/overrides` | optional `{reload, ifPlayersPresent}`; answers `{publishId, zoneId}` at once | +| `GET /zones/{zoneId}/lock` | `{holder, since, holder_is_you, editors}` | +| `POST /zones/{zoneId}/lock` | take (200 again for the holder, `holder_is_you: true`); 409 `{error: zone_locked, holder, since, holder_is_you: false}` | +| `DELETE /zones/{zoneId}/lock` | release (holder, or an admin) | + +A publish answers `{publishId, zoneId, backupId, files: [{path, bytes, sha256, version}], reload: {state}}` with +`state` `started`, `held` or `skipped`. A dry run answers `{zoneId, dryRun: true, files, reload}` and keeps nothing. +Restore and clear answer with their `publishId` and are followed with the status route like a publish. A `failed` +status has `error` and `message` beside its state; reload problems are also in `reload.errors`. Every file part's form +field name is `file`. Names are compared without regard to case and kept lowercase, as the servers read them. Times +are unix seconds. + +| Status | When | +| --- | --- | +| 400 | `malformed` | +| 401 | `unauthorized`, `api_key_required` | +| 403 | `https_required`, `scope_required`, `read_only_key`, `gm_level`, `lock_required`, `not_an_editor` | +| 404 | `unknown_zone`, `unknown_publish`, `unknown_backup` | +| 409 | `publish_in_progress`, `zone_locked`, `not_held` | +| 411 | `length_required` (no Content-Length) | +| 413 | `too_large` | +| 422 | `validation_failed` with `files: [{path, ok: false, errors: [{code, message, offset?}]}]` | +| 429 | `rate_limited`, or the key's own limit | +| 503 | `disabled` (no `zone_override_dir`), `master_unavailable` | + +The publish request's headers are checked (key, level, transport, zone, lock, size) before any of its body is taken. +The body is written to `staging` as it arrives, not held in memory. + +## Reload + +Only the zone's instances, through master's world reload. With players in the zone and `ifPlayersPresent=hold` the +state is `held` until `.../reload` says to move them, or nobody is left when it is asked again with `hold`. From +before the files change until a reload of the zone is asked for, the dashboard tells master to leave the zone's +instances alone (`WORLD_RELOAD_HOLD`), so the file watch does not reload a held zone by itself. `reload=false` leaves +the instances as they are; the files are read when an instance next starts, or on a later reload. + +The dashboard's Worlds page lists each zone's editors and lock holder (permission `zone_editors_manage` and GM level +`zone_lock_admin_gm_level`): add and remove editors, take a lock away. diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 695448118..3778c87a3 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -53,6 +53,8 @@ set(DWEBTESTS_SOURCES "LogBundleTests.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/routes/LogBundle.cpp" "SecurityFixesTests.cpp" + "ZonePublishApiTests.cpp" + "UploadRouteTests.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/routes/BackupFiles.cpp" "MetricsFormatTests.cpp" "TrafficHistoryTests.cpp" diff --git a/tests/dWebTests/OpenApiTests.cpp b/tests/dWebTests/OpenApiTests.cpp index 8f79d7434..6fbf21d0d 100644 --- a/tests/dWebTests/OpenApiTests.cpp +++ b/tests/dWebTests/OpenApiTests.cpp @@ -58,3 +58,18 @@ TEST(OpenApiTests, BuildsPathsWithParameters) { EXPECT_TRUE(table["schema"]["properties"].contains("channel")); EXPECT_EQ(table["example"]["length"], 25); } + +TEST(OpenApiTests, MultipartRoutesTakeFilesAndFields) { + const auto doc = OpenApi::Build({ + { "POST", "/api/v1/zones/:zoneid/publish", "Send a zone's edited files (multipart). Body: {reload=true|false (default true), dryRun=true|false, note, ifPlayersPresent=hold|move}. Returns {publishId}", 9, "zones_publish" }, + }, "Test"); + const auto& publish = doc["paths"]["/api/v1/zones/{zoneid}/publish"]["post"]; + EXPECT_EQ(publish["x-permission"], "zones_publish"); + EXPECT_EQ(publish["parameters"][0]["name"], "zoneid"); + ASSERT_TRUE(publish["requestBody"]["content"].contains("multipart/form-data")); + const auto& properties = publish["requestBody"]["content"]["multipart/form-data"]["schema"]["properties"]; + EXPECT_EQ(properties["files"]["items"]["format"], "binary"); + EXPECT_EQ(properties["ifPlayersPresent"]["enum"], (std::vector{ "hold", "move" })); + EXPECT_TRUE(properties.contains("dryRun")); + EXPECT_TRUE(publish["responses"].contains("422")); +} diff --git a/tests/dWebTests/UploadRouteTests.cpp b/tests/dWebTests/UploadRouteTests.cpp new file mode 100644 index 000000000..2d43ad86f --- /dev/null +++ b/tests/dWebTests/UploadRouteTests.cpp @@ -0,0 +1,175 @@ +#include + +#include +#include +#include +#include + +#include +#include + +#include "Game.h" +#include "Web.h" +#include "eHTTPMethod.h" + +/** + * Upload routes (Web::RegisterUploadRoute) over a real connection: the test starts the web server on a loopback + * port of its own, and a second thread sends requests to it with libcurl while this one polls the server, as a + * server's main loop does. Skipped when no port can be had. + */ +namespace { + // ctest runs each test in a process of its own, several at once: each takes a port and a folder of its own + uint32_t g_Port = 0; + constexpr uint64_t MAX_BYTES = 8 * 1024 * 1024; + + struct Seen { + std::atomic middlewareCalls{}; + std::atomic handlerCalls{}; + std::string bodyFile; + uint64_t bodyBytes{}; + std::string body; + std::string fieldHeader; + }; + Seen g_Seen; + std::filesystem::path g_Dir; + bool g_Started = false; + + // Refuses a request that says so, before its body + class DenyOnHeader : public IHTTPMiddleware { + public: + bool Process(HTTPContext& context, HTTPReply& reply) override { + g_Seen.middlewareCalls++; + if (context.GetHeader("X-Deny").empty()) return true; + reply.status = eHTTPStatusCode::FORBIDDEN; + reply.message = "{\"error\":\"denied\"}"; + return false; + } + std::string GetName() const override { return "DenyOnHeader"; } + }; + + struct Response { + long status{}; + std::string body; + CURLcode code{}; + }; + + size_t Collect(char* data, size_t size, size_t count, void* out) { + static_cast(out)->append(data, size * count); + return size * count; + } + + // Sends the request on another thread while this one runs the server + Response Post(const std::string& body, const std::vector& headers, bool chunked = false) { + Response response; + std::atomic done{}; + std::thread client([&] { + CURL* curl = curl_easy_init(); + curl_slist* list = nullptr; + for (const auto& header : headers) list = curl_slist_append(list, header.c_str()); + list = curl_slist_append(list, "Expect:"); // send the body at once + if (chunked) list = curl_slist_append(list, "Transfer-Encoding: chunked"); + curl_easy_setopt(curl, CURLOPT_URL, ("http://127.0.0.1:" + std::to_string(g_Port) + "/upload/7").c_str()); + curl_easy_setopt(curl, CURLOPT_HTTPHEADER, list); + curl_easy_setopt(curl, CURLOPT_POST, 1L); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.data()); + if (!chunked) curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE_LARGE, static_cast(body.size())); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, Collect); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L); + curl_easy_setopt(curl, CURLOPT_NOPROXY, "*"); + response.code = curl_easy_perform(curl); + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &response.status); + curl_slist_free_all(list); + curl_easy_cleanup(curl); + done = true; + }); + while (!done) Game::web.ReceiveRequests(5); + client.join(); + return response; + } + + class UploadRouteTests : public ::testing::Test { + protected: + static void SetUpTestSuite() { + g_Dir = std::filesystem::temp_directory_path() / ("dlu-upload-route-tests-" + std::to_string(getpid())); + std::filesystem::remove_all(g_Dir); + std::filesystem::create_directories(g_Dir); + for (uint32_t attempt = 0; attempt < 20 && !g_Started; ++attempt) { + g_Port = 38000 + (static_cast(getpid()) * 7 + attempt * 131) % 2000; + g_Started = Game::web.Startup("127.0.0.1", g_Port); + } + if (!g_Started) return; + Game::web.RegisterUploadRoute({ .path = "/upload/:id", .method = eHTTPMethod::POST, + .middleware = { std::make_shared() }, + .maxBytes = [] { return MAX_BYTES; }, + .spoolFile = [](const HTTPContext&) { return (g_Dir / "body").string(); }, + .handle = [](HTTPReply& reply, const HTTPContext& context) { + g_Seen.handlerCalls++; + g_Seen.bodyFile = context.bodyFile; + g_Seen.bodyBytes = context.bodyBytes; + g_Seen.fieldHeader = context.GetHeader("X-Field"); + std::ifstream in(context.bodyFile, std::ios::binary); + g_Seen.body.assign(std::istreambuf_iterator(in), {}); + reply.status = eHTTPStatusCode::OK; + reply.message = "{\"bytes\":" + std::to_string(context.bodyBytes) + ",\"inMemory\":" + std::to_string(context.body.size()) + "}"; + } }); + } + static void TearDownTestSuite() { + if (g_Started) Game::web.Shutdown(); + std::filesystem::remove_all(g_Dir); + } + void SetUp() override { + if (!g_Started) GTEST_SKIP() << "No loopback port could be had"; + g_Seen.middlewareCalls = 0; + g_Seen.handlerCalls = 0; + g_Seen.body.clear(); + } + }; +} + +// Larger than the web server's in-memory limit for a request: it arrives whole, in the file +TEST_F(UploadRouteTests, ALargeBodyArrivesInAFile) { + std::string body(5 * 1024 * 1024, '\0'); + for (size_t i = 0; i < body.size(); ++i) body[i] = static_cast((i * 31 + i / 977) & 0xFF); + const auto response = Post(body, { "X-Field: yes", "Content-Type: application/octet-stream" }); + ASSERT_EQ(response.code, CURLE_OK); + EXPECT_EQ(response.status, 200); + EXPECT_EQ(response.body, "{\"bytes\":" + std::to_string(body.size()) + ",\"inMemory\":0}"); + EXPECT_EQ(g_Seen.handlerCalls, 1); + EXPECT_EQ(g_Seen.middlewareCalls, 1); // once, however many reads the body took + EXPECT_EQ(g_Seen.bodyBytes, body.size()); + EXPECT_EQ(g_Seen.fieldHeader, "yes"); + EXPECT_TRUE(g_Seen.body == body); +} + +TEST_F(UploadRouteTests, AnEmptyBodyIsAnUpload) { + const auto response = Post("", {}); + ASSERT_EQ(response.code, CURLE_OK); + EXPECT_EQ(response.status, 200); + EXPECT_EQ(g_Seen.handlerCalls, 1); + EXPECT_EQ(g_Seen.bodyBytes, 0u); +} + +// What the middleware refuses is answered before the body: the handler never runs and no file is kept +TEST_F(UploadRouteTests, RefusedBeforeTheBodyIsTaken) { + std::filesystem::remove(g_Dir / "body"); + const auto response = Post(std::string(2 * 1024 * 1024, 'x'), { "X-Deny: 1" }); + EXPECT_EQ(response.status, 403); + EXPECT_EQ(response.body, "{\"error\":\"denied\"}"); + EXPECT_EQ(g_Seen.handlerCalls, 0); + EXPECT_FALSE(std::filesystem::exists(g_Dir / "body")); +} + +TEST_F(UploadRouteTests, TooLargeIsRefusedByItsLength) { + std::filesystem::remove(g_Dir / "body"); + const auto response = Post(std::string(MAX_BYTES + 1, 'x'), {}); + EXPECT_EQ(response.status, 413); + EXPECT_EQ(g_Seen.handlerCalls, 0); + EXPECT_FALSE(std::filesystem::exists(g_Dir / "body")); +} + +TEST_F(UploadRouteTests, ALengthIsRequired) { + const auto response = Post("hello", {}, true); + EXPECT_EQ(response.status, 411); + EXPECT_EQ(g_Seen.handlerCalls, 0); +} diff --git a/tests/dWebTests/ZonePublishApiTests.cpp b/tests/dWebTests/ZonePublishApiTests.cpp new file mode 100644 index 000000000..ef63f537b --- /dev/null +++ b/tests/dWebTests/ZonePublishApiTests.cpp @@ -0,0 +1,272 @@ +#include + +#include +#include + +#include "ApiKeyScope.h" +#include "MultipartFile.h" +#include "Permissions.h" +#include "ZonePublishAuth.h" +#include "ZonePublishRoutes.h" + +using namespace ZonePublishAuth; + +namespace { + ApiKeys::Scope Key(std::set permissions, bool readOnly = false, bool all = false) { + ApiKeys::Scope scope; + scope.keyId = 7; + scope.name = "editor"; + scope.permissions = std::move(permissions); + scope.readOnly = readOnly; + scope.allPermissions = all; + return scope; + } + + // A request that passes; each test breaks one thing + Request Good(const ApiKeys::Scope& key) { + Request request; + request.transportAllowed = true; + request.authenticated = true; + request.key = &key; + request.gmLevel = 9; + request.requiredLevel = 9; + request.changes = true; + return request; + } +} + +TEST(ZonePublishAuthTests, AValidKeyWithTheScopeAtTheLevelPasses) { + const auto key = Key({ SCOPE }); + EXPECT_EQ(Check(Good(key)), eDenial::NONE); +} + +TEST(ZonePublishAuthTests, NoKeyIsUnauthorized) { + const auto key = Key({ SCOPE }); + auto request = Good(key); + request.authenticated = false; + request.key = nullptr; + EXPECT_EQ(Check(request), eDenial::NO_KEY); + EXPECT_EQ(Status(eDenial::NO_KEY), eHTTPStatusCode::UNAUTHORIZED); +} + +// A revoked or expired key never authenticates (ApiKeyService asks InUse on every request), so it is "no key" +TEST(ZonePublishAuthTests, RevokedAndExpiredKeysAreNotInUse) { + EXPECT_TRUE(ApiKeys::InUse(0, 0, 1000)); + EXPECT_FALSE(ApiKeys::InUse(900, 0, 1000)); // revoked + EXPECT_FALSE(ApiKeys::InUse(0, 1000, 1000)); // expired + EXPECT_TRUE(ApiKeys::InUse(0, 1001, 1000)); + const auto key = Key({ SCOPE }); + auto request = Good(key); + request.authenticated = false; // what a revoked key leaves behind + EXPECT_EQ(Check(request), eDenial::NO_KEY); +} + +TEST(ZonePublishAuthTests, ASessionIsNotAKey) { + const auto key = Key({ SCOPE }); + auto request = Good(key); + request.key = nullptr; + EXPECT_EQ(Check(request), eDenial::NOT_A_KEY); + EXPECT_EQ(Status(eDenial::NOT_A_KEY), eHTTPStatusCode::UNAUTHORIZED); +} + +TEST(ZonePublishAuthTests, WrongScopeIsForbidden) { + const auto other = Key({ "world_reload", "client_files" }); + EXPECT_EQ(Check(Good(other)), eDenial::SCOPE); + EXPECT_EQ(Status(eDenial::SCOPE), eHTTPStatusCode::FORBIDDEN); + // "All of the owner's permissions" is not the scope either: a key has to be made for zone files + const auto all = Key({}, false, true); + EXPECT_EQ(Check(Good(all)), eDenial::SCOPE); +} + +TEST(ZonePublishAuthTests, GmLevelTooLowIsForbidden) { + const auto key = Key({ SCOPE }); + auto request = Good(key); + request.gmLevel = 8; + EXPECT_EQ(Check(request), eDenial::GM_LEVEL); + EXPECT_EQ(Status(eDenial::GM_LEVEL), eHTTPStatusCode::FORBIDDEN); + request.requiredLevel = 8; + EXPECT_EQ(Check(request), eDenial::NONE); +} + +TEST(ZonePublishAuthTests, ReadOnlyKeysOnlyRead) { + const auto key = Key({ SCOPE }, true); + auto request = Good(key); + EXPECT_EQ(Check(request), eDenial::READ_ONLY); + request.changes = false; + EXPECT_EQ(Check(request), eDenial::NONE); +} + +TEST(ZonePublishAuthTests, PlainHttpFromAnotherMachineIsRefusedFirst) { + const auto key = Key({ SCOPE }); + auto request = Good(key); + request.transportAllowed = false; + EXPECT_EQ(Check(request), eDenial::PLAIN_HTTP); + EXPECT_EQ(Status(eDenial::PLAIN_HTTP), eHTTPStatusCode::FORBIDDEN); + EXPECT_STREQ(Code(eDenial::PLAIN_HTTP), "https_required"); +} + +TEST(ZonePublishAuthTests, TransportIsHttpsOrLocal) { + // No proxy: the dashboard speaks plain HTTP, so only this machine + EXPECT_TRUE(TransportAllowed(false, "127.0.0.1", "", "")); + EXPECT_TRUE(TransportAllowed(false, "::1", "", "")); + EXPECT_FALSE(TransportAllowed(false, "192.168.1.20", "", "")); + EXPECT_FALSE(TransportAllowed(false, "203.0.113.9", "", "")); + // Headers anyone can send are not believed without a proxy + EXPECT_FALSE(TransportAllowed(false, "203.0.113.9", "127.0.0.1", "https")); + // Behind a proxy: what the proxy says about the request + EXPECT_TRUE(TransportAllowed(true, "127.0.0.1", "203.0.113.9", "https")); + EXPECT_FALSE(TransportAllowed(true, "127.0.0.1", "203.0.113.9", "http")); + EXPECT_FALSE(TransportAllowed(true, "127.0.0.1", "203.0.113.9", "")); + EXPECT_FALSE(TransportAllowed(true, "127.0.0.1", "203.0.113.9", "HTTPS, http")); + // Not through the proxy: a caller on this machine, or nobody + EXPECT_TRUE(TransportAllowed(true, "127.0.0.1", "", "")); + EXPECT_FALSE(TransportAllowed(true, "10.0.0.5", "", "")); + // Through a local proxy from this machine over plain HTTP + EXPECT_TRUE(TransportAllowed(true, "127.0.0.1", "127.0.0.1", "http")); + EXPECT_FALSE(TransportAllowed(true, "10.0.0.5", "127.0.0.1", "http")); +} + +TEST(ZonePublishAuthTests, RefusalsAreLimitedPerAddress) { + FailureLimiter limiter(3, 600); + EXPECT_FALSE(limiter.Blocked("203.0.113.9", 1000)); + EXPECT_FALSE(limiter.RecordFailure("203.0.113.9", 1000)); + EXPECT_FALSE(limiter.RecordFailure("203.0.113.9", 1001)); + EXPECT_FALSE(limiter.Blocked("203.0.113.9", 1002)); + EXPECT_TRUE(limiter.RecordFailure("203.0.113.9", 1002)); // the one that blocks it + EXPECT_TRUE(limiter.Blocked("203.0.113.9", 1003)); + EXPECT_FALSE(limiter.Blocked("198.51.100.1", 1003)); // another address is not + // Refusals while blocked don't keep it blocked longer + EXPECT_FALSE(limiter.RecordFailure("203.0.113.9", 1500)); + EXPECT_TRUE(limiter.Blocked("203.0.113.9", 1599)); + EXPECT_FALSE(limiter.Blocked("203.0.113.9", 1603)); + + const auto key = Key({ SCOPE }); + auto request = Good(key); + request.blocked = true; + EXPECT_EQ(Check(request), eDenial::RATE_LIMITED); + EXPECT_EQ(Status(eDenial::RATE_LIMITED), eHTTPStatusCode::TOO_MANY_REQUESTS); +} + +TEST(ZonePublishAuthTests, TheScopeIsAPermissionKeysCanBeGiven) { + ASSERT_NE(Permissions::Find(SCOPE), nullptr); + EXPECT_EQ(Permissions::Find(SCOPE)->defaultLevel, 9); + // Only an account that has it can make a key with it + EXPECT_TRUE(Permissions::NotGrantable(9, { SCOPE }).empty()); + EXPECT_FALSE(Permissions::NotGrantable(8, { SCOPE }).empty()); +} + +TEST(ZonePublishProgressTests, AReloadIsDoneOnceMasterTookItAndNothingIsPending) { + using namespace ZonePublishRoutes; + const ReloadCounters before{ true, 2, 5, 10, 0, 0 }; + // Master has not handled the request yet + EXPECT_FALSE(ProgressOf(before, before).done); + // Taken: two instances, one still moving its players + auto progress = ProgressOf(before, { true, 3, 7, 10, 0, 1 }); + EXPECT_FALSE(progress.done); + EXPECT_EQ(progress.instances, 2u); + progress = ProgressOf(before, { true, 3, 7, 14, 1, 0 }); + EXPECT_TRUE(progress.done); + EXPECT_EQ(progress.playersMoved, 4u); + EXPECT_EQ(progress.failed, 1u); + // Every instance stopped: the zone is gone from master's status + EXPECT_TRUE(ProgressOf(before, {}).done); + // The zone went away and came back: its counts started over + EXPECT_TRUE(ProgressOf(before, { true, 1, 1, 0, 0, 0 }).done); +} + +namespace { + struct MultipartFixture : ::testing::Test { + std::filesystem::path dir; + void SetUp() override { + dir = std::filesystem::temp_directory_path() / ("dlu-multipart-" + std::string(::testing::UnitTest::GetInstance()->current_test_info()->name())); + std::filesystem::remove_all(dir); + std::filesystem::create_directories(dir); + } + void TearDown() override { std::filesystem::remove_all(dir); } + MultipartFile::eError Split(const std::string& body, std::vector& parts, MultipartFile::Limits limits = {}) { + std::ofstream(dir / "body", std::ios::binary) << body; + return MultipartFile::Split(dir / "body", "XYZ", dir, limits, parts); + } + static std::string Slurp(const std::filesystem::path& path) { + std::ifstream in(path, std::ios::binary); + return std::string(std::istreambuf_iterator(in), {}); + } + static std::string FilePart(const std::string& filename, const std::string& data) { + return "--XYZ\r\nContent-Disposition: form-data; name=\"file\"; filename=\"" + filename + "\"\r\nContent-Type: application/octet-stream\r\n\r\n" + data + "\r\n"; + } + static std::string Field(const std::string& name, const std::string& value) { + return "--XYZ\r\nContent-Disposition: form-data; name=\"" + name + "\"\r\n\r\n" + value + "\r\n"; + } + }; + using MultipartFileTests = MultipartFixture; +} + +TEST(MultipartBoundaryTests, ReadsTheBoundary) { + EXPECT_EQ(MultipartFile::Boundary("multipart/form-data; boundary=----abc123"), "----abc123"); + EXPECT_EQ(MultipartFile::Boundary("Multipart/Form-Data; charset=x; boundary=\"a b\""), "a b"); + EXPECT_FALSE(MultipartFile::Boundary("application/json")); + EXPECT_FALSE(MultipartFile::Boundary("multipart/form-data")); + EXPECT_FALSE(MultipartFile::Boundary("multipart/form-data; boundary=" + std::string(71, 'a'))); +} + +TEST_F(MultipartFileTests, SplitsFilesAndFields) { + // A file bigger than the reader's buffer, with bytes that look like the start of a boundary inside it + std::string big(700 * 1024, 'x'); + big.replace(1000, 6, "\r\n--XY"); + big.replace(262139, 7, "\r\n--XY!"); // across the edge of what is read at once + const std::string binary("a\0b\r\n--\r\n", 9); + std::vector parts; + const auto error = Split(FilePart("zone/a.luz", big) + Field("reload", "false") + FilePart("zone/a.lvl", binary) + Field("note", "") + "--XYZ--\r\n", parts); + ASSERT_EQ(error, MultipartFile::eError::NONE); + ASSERT_EQ(parts.size(), 4u); + EXPECT_TRUE(parts[0].isFile); + EXPECT_EQ(parts[0].filename, "zone/a.luz"); + EXPECT_EQ(parts[0].bytes, big.size()); + EXPECT_EQ(Slurp(parts[0].file), big); + EXPECT_FALSE(parts[1].isFile); + EXPECT_EQ(parts[1].name, "reload"); + EXPECT_EQ(parts[1].value, "false"); + EXPECT_EQ(Slurp(parts[2].file), binary); + EXPECT_EQ(parts[3].value, ""); +} + +// The name a sender gives a file is only text handed back: it is never where the bytes are written +TEST_F(MultipartFileTests, FileNamesAreNeverUsedAsPaths) { + std::vector parts; + ASSERT_EQ(Split(FilePart("../../escape", "x") + FilePart("/etc/passwd", "y") + FilePart("a\\b", "z") + "--XYZ--", parts), MultipartFile::eError::NONE); + ASSERT_EQ(parts.size(), 3u); + EXPECT_EQ(parts[0].filename, "../../escape"); + EXPECT_EQ(parts[2].filename, "a\\b"); + for (const auto& part : parts) EXPECT_EQ(part.file.parent_path(), dir); + EXPECT_FALSE(std::filesystem::exists(dir.parent_path().parent_path() / "escape")); +} + +TEST_F(MultipartFileTests, LimitsAndDamage) { + std::vector parts; + MultipartFile::Limits limits; + limits.maxFileBytes = 10; + ASSERT_EQ(Split(FilePart("a", std::string(500, 'x')) + FilePart("b", "ok") + "--XYZ--", parts, limits), MultipartFile::eError::NONE); + EXPECT_TRUE(parts[0].tooLarge); + EXPECT_EQ(parts[0].bytes, 500u); + EXPECT_LE(std::filesystem::file_size(parts[0].file), 10u); + EXPECT_FALSE(parts[1].tooLarge); + + parts.clear(); + limits = {}; + limits.maxParts = 2; + EXPECT_EQ(Split(FilePart("a", "1") + FilePart("b", "2") + FilePart("c", "3") + "--XYZ--", parts, limits), MultipartFile::eError::TOO_MANY_PARTS); + + parts.clear(); + limits = {}; + limits.maxFieldBytes = 4; + EXPECT_EQ(Split(Field("note", "longer than four") + "--XYZ--", parts, limits), MultipartFile::eError::FIELD_TOO_LARGE); + + parts.clear(); + EXPECT_EQ(Split(FilePart("a", "cut off"), parts), MultipartFile::eError::MALFORMED); // no closing boundary + parts.clear(); + EXPECT_EQ(Split("not multipart at all", parts), MultipartFile::eError::MALFORMED); + parts.clear(); + EXPECT_EQ(Split("--XYZ\r\nContent-Type: text/plain\r\n\r\nno disposition\r\n--XYZ--", parts), MultipartFile::eError::MALFORMED); + parts.clear(); + EXPECT_EQ(Split("", parts), MultipartFile::eError::MALFORMED); +}