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); +}