mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
The routes' own zone name helpers and the CDClient's English zone and leaderboard header copies give way to GameText, so every name comes from the locale in the viewer's language; the Network page's world labels, mail, missions, leaderboards, item info (cached per language) and the settings/vanity help strings too. Reward code 4 and the plaque text name their zone from the locale. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
367 lines
18 KiB
C++
367 lines
18 KiB
C++
#include "Showcase.h"
|
|
#include "GameText.h"
|
|
#include "Workers.h"
|
|
|
|
#include <chrono>
|
|
#include <map>
|
|
#include <memory>
|
|
|
|
#include "RouteUtils.h"
|
|
#include "Permissions.h"
|
|
#include "PublicRoutes.h"
|
|
#include "PropertyAssets.h"
|
|
#include "ReportRoutes.h"
|
|
#include "DashboardRoutes.h"
|
|
#include "ClientAssets.h"
|
|
#include "Scenery.h"
|
|
#include "BehaviorXml.h"
|
|
#include "RequireAuthMiddleware.h"
|
|
#include "TtlCache.h"
|
|
#include "CDClientDatabase.h"
|
|
#include "Database.h"
|
|
#include "ePropertyPrivacyOption.h"
|
|
#include "GeneralUtils.h"
|
|
#include "Sd0.h"
|
|
#include "eHTTPMethod.h"
|
|
|
|
using namespace RouteUtils;
|
|
|
|
namespace {
|
|
using namespace std::chrono_literals;
|
|
constexpr uint32_t PAGE_SIZE = 24;
|
|
constexpr size_t MAX_SEARCH = 64;
|
|
constexpr LOT UGC_MODEL_LOT = 14;
|
|
constexpr size_t MEGABYTE = 1024 * 1024;
|
|
|
|
// Only for visitors who aren't signed in (showcase_public=1). The 3D view asks for every model and brick design
|
|
// on its own, so assets get a much larger allowance than the lists.
|
|
RateLimiter g_DataLimiter(240, std::chrono::seconds(60));
|
|
RateLimiter g_AssetLimiter(6000, std::chrono::seconds(60));
|
|
|
|
// Whether a property may be shown is checked again every few seconds, so a property a moderator rejects or its
|
|
// owner makes private drops out quickly; what it holds is kept longer
|
|
TtlCache<LWOOBJID, IProperty::Info> g_Shown(10s, 1024);
|
|
TtlCache<LWOOBJID, std::shared_ptr<const std::string>> g_Details(60s, 32 * MEGABYTE);
|
|
TtlCache<LWOOBJID, std::shared_ptr<const std::map<LWOOBJID, IPropertyContents::Model>>> g_Models(60s, 256);
|
|
TtlCache<LWOOBJID, std::shared_ptr<const std::map<LWOOBJID, std::string>>> g_UgcLxfml(10min, 64 * MEGABYTE); // by property: ugc id -> LXFML
|
|
TtlCache<std::string, std::shared_ptr<const std::string>> g_Lists(10s, 256);
|
|
|
|
enum class Kind { PAGE, DATA, ASSET };
|
|
|
|
/**
|
|
* Who may see the showcase: anyone with showcase_public=1 (rate limited when not signed in), otherwise the same
|
|
* checks as any route guarded by showcase_view (signed in, two-factor set up where required, API access for
|
|
* tokens). Writes the reply and returns false when refused.
|
|
*/
|
|
bool Allowed(const HTTPContext& context, HTTPReply& reply, Kind kind) {
|
|
if (ConfigFlag("showcase_public", false)) {
|
|
if (context.isAuthenticated || kind == Kind::PAGE) return true;
|
|
auto& limiter = kind == Kind::ASSET ? g_AssetLimiter : g_DataLimiter;
|
|
if (limiter.Allow(ClientAddress(context))) return true;
|
|
JsonError(reply, eHTTPStatusCode::TOO_MANY_REQUESTS, "Too many requests, try again in a minute");
|
|
return false;
|
|
}
|
|
// Signed in (and the checks every route makes), then the permission: its level or a grant
|
|
static RequireAuthMiddleware gate(0);
|
|
auto copy = context;
|
|
if (!gate.Process(copy, reply)) return false;
|
|
if (Permissions::Allowed(context.gmLevel, "showcase_view", nullptr, context.grants.get())) return true;
|
|
if (context.path.starts_with("/api/")) JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions");
|
|
else RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to open this page.");
|
|
return false;
|
|
}
|
|
|
|
bool Showable(const IProperty::Info& info) {
|
|
return info.modApproved == 1 && info.privacyOption == static_cast<int32_t>(PropertyPrivacyOption::Public);
|
|
}
|
|
|
|
// The property if it is public and approved; a 404 otherwise, the same as for one that doesn't exist
|
|
std::optional<IProperty::Info> ShownProperty(LWOOBJID id, HTTPReply& reply) {
|
|
auto info = g_Shown.Get(id);
|
|
if (!info) {
|
|
info = Database::Get()->GetPropertyInfo(id);
|
|
if (info && Showable(*info)) g_Shown.Put(id, *info);
|
|
}
|
|
if (!info || !Showable(*info)) {
|
|
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Property not found");
|
|
return std::nullopt;
|
|
}
|
|
return info;
|
|
}
|
|
|
|
|
|
// The zones properties can be claimed in (PropertyTemplate), for the zone filter
|
|
nlohmann::json PropertyZones() {
|
|
static const std::vector<uint32_t> ids = [] {
|
|
std::vector<uint32_t> ids;
|
|
auto result = CDClientDatabase::ExecuteQuery("SELECT DISTINCT mapID FROM PropertyTemplate ORDER BY mapID;");
|
|
for (; !result.eof(); result.nextRow()) ids.push_back(static_cast<uint32_t>(result.getIntField(0)));
|
|
return ids;
|
|
}();
|
|
// Named in the viewer's language each time
|
|
nlohmann::json list = nlohmann::json::array();
|
|
for (const auto id : ids) list.push_back({ {"id", id}, {"name", GameText::ZoneName(id)} });
|
|
return list;
|
|
}
|
|
|
|
std::shared_ptr<const std::map<LWOOBJID, IPropertyContents::Model>> Models(LWOOBJID propertyId) {
|
|
if (auto cached = g_Models.Get(propertyId)) return *cached;
|
|
auto models = std::make_shared<std::map<LWOOBJID, IPropertyContents::Model>>();
|
|
for (auto& model : Database::Get()->GetPropertyModels(propertyId)) models->emplace(model.id, model);
|
|
g_Models.Put(propertyId, models);
|
|
return models;
|
|
}
|
|
|
|
// What the viewer needs about a property and its models. Owner by character name only; no account, no rent,
|
|
// no moderation details.
|
|
std::shared_ptr<const std::string> Details(const IProperty::Info& info) {
|
|
if (auto cached = g_Details.Get(info.id)) return *cached;
|
|
const auto owner = Database::Get()->GetCharacterInfo(info.ownerId);
|
|
nlohmann::json models = nlohmann::json::array();
|
|
for (const auto& [id, model] : *Models(info.id)) {
|
|
models.push_back({
|
|
{"id", std::to_string(model.id)},
|
|
{"lot", model.lot},
|
|
{"name", model.lot == UGC_MODEL_LOT ? "Player-built model" : ClientAssets::ItemName(model.lot)},
|
|
{"position", {model.position.x, model.position.y, model.position.z}},
|
|
{"rotation", {model.rotation.x, model.rotation.y, model.rotation.z, model.rotation.w}},
|
|
{"behaviors", PropertyAssets::ModelBehaviors(model)}
|
|
});
|
|
}
|
|
auto body = std::make_shared<const std::string>(nlohmann::json{
|
|
{"success", true},
|
|
{"id", std::to_string(info.id)},
|
|
{"name", info.name},
|
|
{"description", info.description},
|
|
{"owner_name", owner ? owner->name : ""},
|
|
{"zone_id", info.zoneId},
|
|
{"zone_name", GameText::ZoneName(info.zoneId)},
|
|
{"reputation", info.reputation},
|
|
{"last_updated", info.lastUpdatedTime},
|
|
{"models", models}
|
|
}.dump());
|
|
g_Details.Put(info.id, body, body->size());
|
|
return body;
|
|
}
|
|
|
|
// Every player-built model's LXFML on a property, read in one query (a big property has hundreds)
|
|
std::shared_ptr<const std::map<LWOOBJID, std::string>> UgcLxfml(LWOOBJID propertyId) {
|
|
if (auto cached = g_UgcLxfml.Get(propertyId)) return *cached;
|
|
auto lxfml = std::make_shared<std::map<LWOOBJID, std::string>>();
|
|
size_t bytes = 0;
|
|
for (auto& ugc : Database::Get()->GetUgcModels(propertyId)) {
|
|
Sd0 sd0(ugc.lxfmlData);
|
|
auto& text = (*lxfml)[ugc.id] = sd0.GetAsStringUncompressed();
|
|
bytes += text.size();
|
|
}
|
|
g_UgcLxfml.Put(propertyId, lxfml, std::max<size_t>(bytes, 1));
|
|
return lxfml;
|
|
}
|
|
|
|
// cacheControl empty: the default (no-store), for anything that stops being shown when a property is rejected
|
|
void RawReply(HTTPReply& reply, eContentType type, std::string body, const std::string& cacheControl) {
|
|
reply.status = eHTTPStatusCode::OK;
|
|
reply.contentType = type;
|
|
reply.message = std::move(body);
|
|
if (!cacheControl.empty()) reply.headers.push_back("Cache-Control: " + cacheControl);
|
|
}
|
|
|
|
/**
|
|
* Terrain and the build area come from the client's files and are the same for every property in a zone.
|
|
* `ready` says whether what `fetch` needs is built; when it isn't, fetch runs on a worker thread (Workers::Reply),
|
|
* so it must be thread safe. The property is looked up here, on the web thread.
|
|
*/
|
|
template<typename Fetch>
|
|
void ZoneFileRoute(const std::string& path, const std::string& description, Fetch fetch, std::function<bool(uint32_t zone)> ready = nullptr) {
|
|
Route(eHTTPMethod::GET, path, PUBLIC, description, [fetch, ready](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!Allowed(context, reply, Kind::DATA)) return;
|
|
const auto id = PathId<LWOOBJID>(context.path, 2);
|
|
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid ID");
|
|
const auto info = ShownProperty(*id, reply);
|
|
if (!info) return;
|
|
if (!ready) return fetch(reply, *info);
|
|
Workers::Reply(reply, context, ready(info->zoneId), [fetch, info = *info](HTTPReply& out) { fetch(out, info); });
|
|
});
|
|
}
|
|
}
|
|
|
|
void RegisterShowcaseRoutes() {
|
|
Route(eHTTPMethod::GET, "/showcase", PUBLIC, "Browse approved public properties (showcase_view, or everyone with showcase_public=1)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!Allowed(context, reply, Kind::PAGE)) return;
|
|
RenderPage(reply, context, "showcase.jinja2", "showcase", { {"public_page", PublicPageJson()} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/showcase/:id", PUBLIC, "3D view of an approved public property, read only (showcase_view, or everyone with showcase_public=1)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!Allowed(context, reply, Kind::PAGE)) return;
|
|
const auto id = PathId<LWOOBJID>(context.path, 1);
|
|
if (!id) return RenderError(reply, context, eHTTPStatusCode::NOT_FOUND, "Invalid property ID");
|
|
static const auto behaviorRules = BehaviorXml::Rules(ClientAssets::ItemName).dump();
|
|
RenderPage(reply, context, "property-3d.jinja2", "showcase", {
|
|
{"property_id", std::to_string(*id)}, {"behavior_rules", behaviorRules}, {"showcase", true}, {"public_page", PublicPageJson()}
|
|
});
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/showcase", PUBLIC,
|
|
"Approved public properties, 24 per page. Query: ?search= (name, description or owner), &zone= (zone ID), &sort=reputation|newest|name, &page= (from 1). "
|
|
"Needs showcase_view unless showcase_public=1",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!Allowed(context, reply, Kind::DATA)) return;
|
|
IProperty::ShowcaseQuery query;
|
|
query.search = QueryValue(context.queryString, "search").substr(0, MAX_SEARCH);
|
|
query.zoneId = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "zone")).value_or(0);
|
|
const auto sort = QueryValue(context.queryString, "sort");
|
|
query.sort = sort == "newest" ? IProperty::ShowcaseSort::NEWEST : sort == "name" ? IProperty::ShowcaseSort::NAME : IProperty::ShowcaseSort::REPUTATION;
|
|
const auto page = std::clamp<uint32_t>(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(1), 1, 100000);
|
|
query.start = (page - 1) * PAGE_SIZE;
|
|
query.length = PAGE_SIZE;
|
|
|
|
const auto key = query.search + '\n' + std::to_string(query.zoneId) + '\n' + std::to_string(static_cast<int>(query.sort)) + '\n' + std::to_string(page);
|
|
auto body = g_Lists.Get(key);
|
|
if (!body) {
|
|
const auto result = Database::Get()->GetShowcaseProperties(query);
|
|
nlohmann::json properties = nlohmann::json::array();
|
|
for (const auto& entry : result.entries) {
|
|
properties.push_back({
|
|
{"id", std::to_string(entry.info.id)},
|
|
{"name", entry.info.name},
|
|
{"description", entry.info.description},
|
|
{"owner_name", entry.ownerName},
|
|
{"zone_id", entry.info.zoneId},
|
|
{"zone_name", GameText::ZoneName(entry.info.zoneId)},
|
|
{"models", entry.modelCount},
|
|
{"reputation", entry.info.reputation},
|
|
{"last_updated", entry.info.lastUpdatedTime}
|
|
});
|
|
}
|
|
body = std::make_shared<const std::string>(nlohmann::json{
|
|
{"success", true}, {"total", result.total}, {"page", page}, {"page_size", PAGE_SIZE}, {"properties", properties}, {"zones", PropertyZones()}
|
|
}.dump());
|
|
g_Lists.Put(key, *body);
|
|
}
|
|
RawReply(reply, eContentType::APPLICATION_JSON, **body, "");
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/showcase/:id", PUBLIC, "An approved public property and its placed models, for the 3D view. Needs showcase_view unless showcase_public=1",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!Allowed(context, reply, Kind::DATA)) return;
|
|
const auto id = PathId<LWOOBJID>(context.path, 2);
|
|
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid ID");
|
|
const auto info = ShownProperty(*id, reply);
|
|
if (!info) return;
|
|
RawReply(reply, eContentType::APPLICATION_JSON, *Details(*info), "");
|
|
});
|
|
|
|
ZoneFileRoute("/api/showcase/:id/terrain_chunks", "The terrain of a showcased property's zone, as the game draws it (needs client_location)",
|
|
[](HTTPReply& reply, const IProperty::Info& info) {
|
|
const auto terrain = ZoneTerrainChunksJson(info.zoneId);
|
|
if (!terrain) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No terrain for this zone");
|
|
RawReply(reply, eContentType::APPLICATION_JSON, *terrain, "private, max-age=86400");
|
|
}, ZoneTerrainChunksReady);
|
|
|
|
ZoneFileRoute("/api/showcase/:id/terrain", "A height grid of a showcased property's zone (needs client_location)",
|
|
[](HTTPReply& reply, const IProperty::Info& info) {
|
|
const auto terrain = ZoneTerrainJson(info.zoneId);
|
|
if (!terrain) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No terrain for this zone");
|
|
RawReply(reply, eContentType::APPLICATION_JSON, *terrain, "private, max-age=86400");
|
|
}, ZoneTerrainJsonReady);
|
|
|
|
ZoneFileRoute("/api/showcase/:id/boundary", "Where a showcased property's owner may build (needs client_location)",
|
|
[](HTTPReply& reply, const IProperty::Info& info) {
|
|
const auto areas = ZonePropertyAreasJson(info.zoneId);
|
|
if (!areas) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Couldn't read this zone's file");
|
|
JsonSuccess(reply, { {"areas", *areas} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/showcase/:id/models/:model/lxfml", PUBLIC, "A model on a showcased property as LXFML, for the 3D view",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!Allowed(context, reply, Kind::ASSET)) return;
|
|
const auto id = PathId<LWOOBJID>(context.path, 2);
|
|
const auto modelId = PathId<LWOOBJID>(context.path, 4);
|
|
if (!id || !modelId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid ID");
|
|
const auto info = ShownProperty(*id, reply);
|
|
if (!info) return;
|
|
// Only models placed on this property
|
|
const auto models = Models(*id);
|
|
const auto model = models->find(*modelId);
|
|
if (model == models->end()) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Model not found");
|
|
|
|
std::optional<std::string> lxfml;
|
|
if (model->second.lot == UGC_MODEL_LOT) {
|
|
const auto all = UgcLxfml(*id);
|
|
if (const auto it = all->find(model->second.ugcId); it != all->end()) lxfml = it->second;
|
|
} else if (const auto file = PropertyAssets::ModelLxfml(model->second, *id)) {
|
|
lxfml = file->first;
|
|
}
|
|
if (!lxfml) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Model data not available");
|
|
RawReply(reply, eContentType::APPLICATION_OCTET_STREAM, std::move(*lxfml), "");
|
|
});
|
|
|
|
// Client files, the same for every property
|
|
Route(eHTTPMethod::GET, "/api/showcase/bricks/:lod/:design", PUBLIC, "Brick geometry for the showcase's 3D view (see /api/bricks/:lod/:design)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!Allowed(context, reply, Kind::ASSET)) return;
|
|
const auto lod = PathId<uint32_t>(context.path, 3);
|
|
const auto design = PathId<uint32_t>(context.path, 4);
|
|
const auto bundle = lod && design ? PropertyAssets::BrickBundle(*lod, *design) : std::nullopt;
|
|
if (!bundle) {
|
|
reply.status = eHTTPStatusCode::NOT_FOUND;
|
|
reply.message = "";
|
|
return;
|
|
}
|
|
RawReply(reply, eContentType::APPLICATION_OCTET_STREAM, *bundle, "public, max-age=604800");
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/showcase/terrain_textures/:id", PUBLIC, "A terrain texture as PNG for the showcase's 3D view",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
if (!Allowed(context, reply, Kind::ASSET)) return;
|
|
const auto textureId = PathId<uint32_t>(context.path, 3);
|
|
if (!textureId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid ID");
|
|
Workers::Reply(reply, context, TerrainTextureReady(*textureId), [id = *textureId](HTTPReply& out) {
|
|
const auto png = TerrainTextureFile(id);
|
|
if (!png) return JsonError(out, eHTTPStatusCode::NOT_FOUND, "Texture not found");
|
|
RawReply(out, eContentType::IMAGE_PNG, *png, "public, max-age=604800");
|
|
});
|
|
});
|
|
|
|
ZoneFileRoute("/api/showcase/:id/scenery", "Everything the game draws around a showcased property: its zone's scene objects with their models and the sky (needs client_location)",
|
|
[](HTTPReply& reply, const IProperty::Info& info) {
|
|
const auto scenery = Scenery::ZoneJson(info.zoneId);
|
|
if (!scenery) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No scenery for this zone");
|
|
RawReply(reply, eContentType::APPLICATION_JSON, *scenery, "private, max-age=86400");
|
|
}, Scenery::ZoneReady);
|
|
|
|
// Scenery models and textures of the zones properties are in (the only ones the showcase shows)
|
|
const auto propertyZone = [](HTTPReply& reply, const HTTPContext& context) -> std::optional<uint32_t> {
|
|
if (!Allowed(context, reply, Kind::ASSET)) return std::nullopt;
|
|
const auto zone = PathId<uint32_t>(context.path, 3);
|
|
const auto& zones = PropertyZones();
|
|
if (!zone || std::none_of(zones.begin(), zones.end(), [&zone](const nlohmann::json& z) { return z["id"] == *zone; })) {
|
|
JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Not a property zone");
|
|
return std::nullopt;
|
|
}
|
|
return zone;
|
|
};
|
|
const auto lodOf = [](const HTTPContext& context) { return GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "lod")).value_or(0); };
|
|
|
|
Route(eHTTPMethod::GET, "/api/showcase/scenery/:zone/mesh/:asset", PUBLIC, "A scenery model of a property zone for the showcase's 3D view (see /api/scenery/:zone/mesh/:asset)",
|
|
[propertyZone, lodOf](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto zone = propertyZone(reply, context);
|
|
if (!zone) return;
|
|
const auto asset = PathId<uint32_t>(context.path, 5);
|
|
if (!asset) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid model");
|
|
Scenery::ReplyMesh(reply, context, *zone, *asset, lodOf(context));
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/showcase/scenery/:zone/texture/:asset/:slot", PUBLIC, "A scenery texture of a property zone as DDS for the showcase's 3D view",
|
|
[propertyZone, lodOf](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto zone = propertyZone(reply, context);
|
|
if (!zone) return;
|
|
const auto asset = PathId<uint32_t>(context.path, 5);
|
|
const auto slot = PathId<uint32_t>(context.path, 6);
|
|
if (!asset || !slot) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid model or texture");
|
|
Scenery::ReplyTexture(reply, context, *zone, *asset, *slot, lodOf(context));
|
|
});
|
|
}
|