Files
DarkflameServer/dDashboardServer/routes/UgcRoutes.cpp
Aaron Kimbrell 90777e5c9d feat(ugc): when each creation was made and how long it took, on the UGC page
The UGC server records how long each successful make took (process_ms on
ugc and ugc_modular_build; migrations sqlite 73, mysql 90). The UGC page
shows it with the time it was made: in the models list's Made column
(sortable, slowest first), in the tiles' tooltips, in the item preview
and in an assembly's References. The gallery can sort by slowest to
make too. Makes from before this are shown without a duration.

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

651 lines
38 KiB
C++

#include "UgcRoutes.h"
#include <algorithm>
#include <memory>
#include <set>
#include "CDClientDatabase.h"
#include "Database.h"
#include "UgcIconParams.h"
#include "NifFile.h"
#include "SettingsCatalog.h"
#include "SettingsHistory.h"
#include "UgcAssemblies.h"
#include "UgcFetch.h"
#include "UgcLinks.h"
#include "UgcLookup.h"
#include "UgcKeys.h"
#include "Workers.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "RouteUtils.h"
#include "Sd0.h"
#include "WSRoutes.h"
#include "dConfig.h"
#include "eHTTPMethod.h"
using namespace RouteUtils;
using namespace UgcFetch;
namespace {
constexpr uint32_t PAGE_SIZE = 50;
std::string StateName(IUgc::eProcessState state) { return IUgc::ProcessStateName(state); }
std::optional<IUgc::eProcessState> ParseState(const std::string& text) { return IUgc::ParseProcessState(text); }
// Web thread: the key the UGC server's /admin routes want
std::string AdminKey() {
const auto master = Database::Get()->GetMasterInfo();
return master ? master->password : std::string();
}
// Sends an admin request from a worker and answers with the UGC server's JSON (or why it couldn't)
void ProxyAdmin(HTTPReply& reply, const HTTPContext& context, const std::string& route, const nlohmann::json& body) {
const auto url = InternalUrl() + route;
Workers::Reply(reply, context, false, [url, key = AdminKey(), text = body.dump()](HTTPReply& out) {
const auto fetched = AdminPost(url, key, text);
if (fetched->status == 0) return JsonError(out, eHTTPStatusCode::BAD_GATEWAY, "The UGC server doesn't answer at " + url + " (" + fetched->error + ")");
out.status = static_cast<eHTTPStatusCode>(fetched->status);
const bool png = fetched->body.starts_with("\x89PNG");
out.contentType = png ? eContentType::IMAGE_PNG : eContentType::APPLICATION_JSON;
out.message = fetched->body;
if (!png && fetched->status != 200 && !fetched->body.starts_with("{")) {
JsonError(out, static_cast<eHTTPStatusCode>(fetched->status), fetched->body.empty() ? "The UGC server answered " + std::to_string(fetched->status) : fetched->body);
}
out.headers.push_back("Cache-Control: no-store");
}, WorkerPool::ePriority::URGENT);
}
// Web thread: a ugcconfig.ini setting's value as the UGC server sees it (dashboard value, file value, default)
std::optional<std::string> UgcSetting(const std::string& name) {
if (const auto current = SettingsHistory::Current("ugcconfig.ini", name)) {
if (current->webValue && (current->webWins || !current->fileValue)) return current->webValue;
if (current->fileValue) return current->fileValue;
}
if (const auto* info = SettingsCatalog::Find("ugcconfig.ini", name)) return info->defaultValue;
return std::nullopt;
}
// Web thread: the icon kinds: player models, and each car or rocket build type in the client's data
// (ModularBuildComponent), named after the object its Assembly LOT is
nlohmann::json IconKinds() {
nlohmann::json kinds = nlohmann::json::array({ { { "kind", UgcIconParams::ModelKind() }, { "label", "Player models" } } });
auto result = CDClientDatabase::ExecuteQuery("SELECT buildType, xml FROM ModularBuildComponent GROUP BY buildType ORDER BY buildType;");
while (!result.eof()) {
const auto buildType = result.getIntField("buildType", 0);
std::string label = "Build type " + std::to_string(buildType);
const std::string xml = result.getStringField("xml", "");
if (const auto at = xml.find("LOT=\""); at != std::string::npos) {
const auto lot = GeneralUtils::TryParse<int32_t>(xml.substr(at + 5, xml.find('"', at + 5) - at - 5));
if (lot) {
auto name = CDClientDatabase::CreatePreppedStmt("SELECT displayName FROM Objects WHERE id = ? LIMIT 1;");
name.bind(1, *lot);
auto row = name.execQuery();
if (!row.eof() && row.getStringField("displayName", "")[0] != '\0') label = row.getStringField("displayName", "");
}
}
kinds.push_back({ { "kind", UgcIconParams::BuildKind(buildType) }, { "label", label }, { "buildType", buildType } });
result.nextRow();
}
return kinds;
}
// Web thread: a car or rocket's kind, from its first module's build type (ModuleComponent)
std::optional<std::string> ModularKind(std::string modules) {
std::replace(modules.begin(), modules.end(), '-', '+'); // a combination's key too
const auto lots = UgcModularKey::Lots(modules);
if (lots.empty()) return std::nullopt;
auto query = CDClientDatabase::CreatePreppedStmt("SELECT m.buildType FROM ComponentsRegistry cr JOIN ModuleComponent m ON m.id = cr.component_id "
"WHERE cr.id = ? AND cr.component_type = 28 LIMIT 1;");
query.bind(1, static_cast<int32_t>(lots.front()));
auto row = query.execQuery();
if (row.eof()) return std::nullopt;
return UgcIconParams::BuildKind(row.getIntField("buildType", 0));
}
// Web thread: each kind with something to edit its preset on: the newest made player model, the most used combination of each build type
nlohmann::json WithSamples(nlohmann::json kinds) {
std::map<std::string, std::pair<std::string, uint64_t>> best; // kind -> (combination, builds)
std::map<std::string, uint64_t> uses;
for (const auto& [ldf, count] : Database::Get()->GetModularBuildConfigCounts()) uses[UgcModularKey::Normalize(ldf)] += count;
for (const auto& [key, count] : uses) {
if (key.empty()) continue;
std::string modules = key;
std::replace(modules.begin(), modules.end(), '-', '+');
const auto kind = ModularKind(modules);
if (kind && count > best[*kind].second) best[*kind] = { key, count };
}
for (auto& entry : kinds) {
const auto kind = entry.value("kind", std::string());
if (kind == UgcIconParams::ModelKind()) {
const auto models = Database::Get()->GetUgcProcessList(IUgc::eProcessState::DONE, "", 0, 1);
entry["sample"] = models.empty() ? nlohmann::json(nullptr) : nlohmann::json(std::to_string(models.front().id));
} else if (const auto it = best.find(kind); it != best.end()) {
entry["sample"] = it->second.first;
entry["sampleBuilds"] = it->second.second;
} else {
entry["sample"] = nullptr;
}
}
return kinds;
}
// Web thread: every module in the client's data (ModuleComponent) with its build type and name, read once
const std::map<uint32_t, UgcAssemblies::ModuleInfo>& Modules() {
static std::map<uint32_t, UgcAssemblies::ModuleInfo> modules;
static bool loaded = false;
if (loaded) return modules;
loaded = true;
auto result = CDClientDatabase::ExecuteQuery("SELECT cr.id AS lot, m.buildType AS buildType, o.name AS name, o.displayName AS displayName FROM ComponentsRegistry cr "
"JOIN ModuleComponent m ON m.id = cr.component_id LEFT JOIN Objects o ON o.id = cr.id WHERE cr.component_type = 28;");
while (!result.eof()) {
std::string name = result.getStringField("displayName", "");
if (name.empty()) name = result.getStringField("name", "");
modules[static_cast<uint32_t>(result.getIntField("lot", 0))] = { result.getIntField("buildType", -1), name };
result.nextRow();
}
return modules;
}
// The /ugc search box: "state:" and "kind:" (or "type:") words are filters of their own, the rest is a UGC search
// (UgcLookup::ParseQuery: "owner:", "account:", "property:", "name:", "lot:"/"module:", "id:", or plain text)
struct ListSearch {
IUgcLookup::UgcSearch search;
std::optional<IUgc::eProcessState> state;
std::string kind;
std::string text; // what went to ParseQuery
};
ListSearch ParseListSearch(const std::string& input) {
ListSearch out;
std::string rest;
size_t start = 0;
while (start < input.size()) {
auto end = input.find(' ', start);
if (end == std::string::npos) end = input.size();
const auto word = input.substr(start, end - start);
const auto lower = UgcAssemblies::Lower(word);
if (lower.starts_with("state:") && IUgc::ParseProcessState(lower.substr(6))) out.state = IUgc::ParseProcessState(lower.substr(6));
else if (lower.starts_with("kind:") && lower.size() > 5) out.kind = lower.substr(5);
else if (lower.starts_with("type:") && lower.size() > 5) out.kind = lower.substr(5);
else if (!word.empty()) rest += (rest.empty() ? "" : " ") + word;
start = end + 1;
}
out.text = rest;
out.search = UgcLookup::ParseQuery(rest);
return out;
}
nlohmann::json ModulesJson(const std::vector<uint32_t>& lots) {
nlohmann::json out = nlohmann::json::array();
const auto& modules = Modules();
for (const auto lot : lots) {
const auto it = modules.find(lot);
out.push_back({ { "lot", lot }, { "name", it != modules.end() ? it->second.name : std::string() }, { "icon", "/api/icon/" + std::to_string(lot) } });
}
return out;
}
nlohmann::json EntryJson(const IUgcLookup::UgcEntry& entry) {
return { { "id", std::to_string(entry.id) }, { "characterId", std::to_string(entry.characterId) }, { "characterName", entry.characterName },
{ "accountId", entry.accountId }, { "accountName", entry.accountName }, { "state", IUgc::ProcessStateName(entry.state) }, { "attempts", entry.attempts },
{ "processedAt", entry.processedAt }, { "error", entry.error }, { "bakeAo", entry.bakeAo }, { "processAfter", entry.processAfter },
{ "detail", entry.detail }, { "bricks", entry.bricks }, { "triangles", entry.triangles }, { "processMs", entry.processMs } };
}
// Web thread: every car and rocket build (the assemblies are made from them)
std::vector<IUgcLookup::UgcEntry> AllBuilds(const IUgcLookup::UgcSearch& search = {}) {
IUgcLookup::UgcListQuery query;
query.search = search;
query.limit = 1000000;
return Database::Get()->ListUgc(IUgcLookup::eUgcKind::MODULAR, query).first;
}
// Web thread: the values stored for a target, or null
nlohmann::json StoredValues(const std::string& target) {
const auto stored = Database::Get()->GetUgcIconSettings(target);
if (!stored) return nullptr;
return nlohmann::json::parse(UgcIconParams::ToJson(UgcIconParams::Parse(*stored)));
}
const std::set<std::string> FILES = { "icon.png", "model.nif", "model.noao.nif", "stats.json", "combo.json",
"previous.icon.png", "previous.model.nif", "previous.model.noao.nif", "previous.stats.json" };
std::optional<std::string> KindOf(const std::string& text) {
if (text == "model" || text == "modular") return text;
return std::nullopt;
}
// An id from a request body, as a string or a number; nullopt when it's missing or not one
std::optional<LWOOBJID> BodyId(const nlohmann::json& body) {
const auto it = body.find("id");
if (it == body.end()) return std::nullopt;
return GeneralUtils::TryParse<LWOOBJID>(it->is_string() ? it->get<std::string>() : it->dump());
}
nlohmann::json Counts(const std::vector<std::pair<IUgc::eProcessState, uint64_t>>& counts) {
nlohmann::json out = nlohmann::json::object();
for (const auto state : magic_enum::enum_values<IUgc::eProcessState>()) out[IUgc::ProcessStateName(state)] = 0;
for (const auto& [state, count] : counts) out[StateName(state)] = count;
return out;
}
}
namespace UgcRoutes {
void RegisterRoutes() {
Route(eHTTPMethod::GET, "/ugc", Perm("properties_view"), "What the UGC server made of players' models",
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "ugc.jinja2", "ugc"); });
Route(eHTTPMethod::GET, "/api/ugc", Perm("properties_view"),
"A page of player models (kind=model) or of car and rocket assemblies (kind=modular: one per combination of modules, however many builds use "
"it). Query: q= (\"state:\", \"kind:\"/\"type:\" (a build type, e.g. build6), \"owner:\", \"account:\", \"property:\", \"name:\", "
"\"lot:\"/\"module:\" (a LOT or a module's name), \"id:\", or plain text across names, owners and ids), state=, type=, sort=newest|oldest|owner|name|"
"bricks|triangles|slowest (models) or newest|oldest|references|name (assemblies), reverse=1 (the sort's other direction), page= (from 0), size= (1-200). {items, total, page, size, counts, "
"kinds, ugcPublicUrl, canManage}",
[](HTTPReply& reply, const HTTPContext& context) {
const bool modular = QueryValue(context.queryString, "kind") == "modular";
auto parsed = ParseListSearch(QueryValue(context.queryString, "q").substr(0, 100));
if (const auto state = IUgc::ParseProcessState(QueryValue(context.queryString, "state"))) parsed.state = state;
if (const auto type = QueryValue(context.queryString, "type"); !type.empty()) parsed.kind = type;
const auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
const auto size = std::clamp(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(PAGE_SIZE), 1u, 200u);
const auto sortText = QueryValue(context.queryString, "sort");
const bool reverse = QueryValue(context.queryString, "reverse") == "1";
nlohmann::json items = nlohmann::json::array();
uint64_t total = 0;
const auto kinds = IconKinds();
if (!modular) {
IUgcLookup::UgcListQuery query;
query.search = parsed.search;
query.state = parsed.state;
static const std::map<std::string, IUgcLookup::eSort> SORTS = { { "newest", IUgcLookup::eSort::NEWEST }, { "oldest", IUgcLookup::eSort::OLDEST },
{ "owner", IUgcLookup::eSort::OWNER }, { "name", IUgcLookup::eSort::NAME }, { "bricks", IUgcLookup::eSort::BRICKS }, { "triangles", IUgcLookup::eSort::TRIANGLES }, { "slowest", IUgcLookup::eSort::SLOWEST } };
if (const auto it = SORTS.find(sortText); it != SORTS.end()) query.sort = it->second;
query.reverse = reverse;
query.offset = page * size;
query.limit = size;
const auto [entries, count] = Database::Get()->ListUgc(IUgcLookup::eUgcKind::MODEL, query);
total = count;
for (const auto& entry : entries) items.push_back(EntryJson(entry));
} else {
UgcAssemblies::Filter filter;
filter.state = parsed.state;
if (!parsed.kind.empty()) {
for (const auto& k : kinds) {
if (k.contains("buildType") && (k["kind"] == parsed.kind || UgcAssemblies::Lower(k["label"].get<std::string>()).find(parsed.kind) != std::string::npos)) {
filter.buildType = k["buildType"].get<int32_t>();
break;
}
}
if (!filter.buildType) filter.buildType = -2; // no such type: nothing
}
const auto& modules = Modules();
if (!parsed.search.text.empty() || parsed.search.number) {
using eField = IUgcLookup::UgcSearch::eField;
const auto field = parsed.search.field;
if (field != eField::LOT) {
std::set<LWOOBJID> matched;
for (const auto& build : AllBuilds(parsed.search)) matched.insert(build.id);
filter.builds = matched;
}
if (field == eField::ANY || field == eField::LOT) {
filter.moduleText = parsed.search.text;
if (parsed.search.number) filter.moduleLot = static_cast<uint32_t>(*parsed.search.number);
}
}
auto assemblies = UgcAssemblies::Group(AllBuilds(), modules);
std::erase_if(assemblies, [&](const auto& a) { return !UgcAssemblies::Matches(a, filter, modules); });
UgcAssemblies::Sort(assemblies, UgcAssemblies::ParseSort(sortText).value_or(UgcAssemblies::eSort::NEWEST), modules, reverse);
total = assemblies.size();
for (size_t i = static_cast<size_t>(page) * size; i < assemblies.size() && i < static_cast<size_t>(page + 1) * size; i++) {
const auto& a = assemblies[i];
std::string label, kind;
for (const auto& k : kinds) {
if (k.contains("buildType") && k["buildType"] == a.buildType) {
label = k["label"];
kind = k["kind"];
}
}
std::string ldf = a.key;
std::replace(ldf.begin(), ldf.end(), '-', '+');
items.push_back({ { "id", a.key }, { "key", a.key }, { "modules", ldf }, { "moduleList", ModulesJson(a.lots) }, { "buildType", a.buildType },
{ "kind", kind }, { "kindLabel", label }, { "state", IUgc::ProcessStateName(a.state) }, { "error", a.error }, { "uses", a.builds.size() },
{ "owners", a.owners.size() }, { "iconBuild", std::to_string(a.iconBuild) }, { "newestBuild", std::to_string(a.builds.front()) },
{ "storageId", std::to_string(UgcModularKey::StorageId(a.key)) } });
}
}
JsonSuccess(reply, { { "counts", { { "model", Counts(Database::Get()->GetUgcProcessCounts()) }, { "modular", Counts(Database::Get()->GetModularBuildProcessCounts()) } } },
{ "items", items }, { "total", total }, { "page", page }, { "size", size }, { "more", static_cast<uint64_t>(page + 1) * size < total }, { "kinds", kinds },
{ "ugcPublicUrl", Game::config->GetValue("ugc_public_url") }, { "canManage", Can(context, "ugc_manage") } });
});
Route(eHTTPMethod::GET, "/api/ugc/assembly/builds", Perm("properties_view"),
"The builds (ugc_modular_build rows) that use a combination of modules: {items: [{id, characterId, characterName, accountId, accountName, state, "
"attempts, processedAt, error, where: [{type: property|mail|inventory, ...}]}], total, page, size}. Query: modules= (the combination), q= (owner, "
"account, property or id, as the list's search), sort=id|owner|account|state (default id, the newest first; owner and account A to Z, state "
"by state then newest), reverse=1 (the other direction), page=, size=, build= (a build to show: the page holding it is given)",
[](HTTPReply& reply, const HTTPContext& context) {
auto asked = QueryValue(context.queryString, "modules");
std::replace(asked.begin(), asked.end(), '-', '+');
const auto key = UgcModularKey::Normalize(asked);
if (key.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "No modules");
const auto parsed = ParseListSearch(QueryValue(context.queryString, "q").substr(0, 100));
auto builds = AllBuilds(parsed.search);
std::erase_if(builds, [&](const auto& b) { return UgcModularKey::Normalize(b.detail) != key || (parsed.state && b.state != *parsed.state); });
const auto sort = QueryValue(context.queryString, "sort");
const auto lowerName = [](const std::string& name) { return UgcAssemblies::Lower(name); };
std::stable_sort(builds.begin(), builds.end(), [&](const auto& a, const auto& b) {
if (sort == "owner" && lowerName(a.characterName) != lowerName(b.characterName)) return lowerName(a.characterName) < lowerName(b.characterName);
if (sort == "account" && lowerName(a.accountName) != lowerName(b.accountName)) return lowerName(a.accountName) < lowerName(b.accountName);
if (sort == "state" && a.state != b.state) return a.state < b.state;
return a.id > b.id;
});
if (QueryValue(context.queryString, "reverse") == "1") std::reverse(builds.begin(), builds.end());
const auto size = std::clamp(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(25), 1u, 200u);
auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
if (const auto wanted = GeneralUtils::TryParse<LWOOBJID>(QueryValue(context.queryString, "build"))) {
const auto it = std::find_if(builds.begin(), builds.end(), [&](const auto& b) { return b.id == *wanted; });
if (it != builds.end()) page = static_cast<uint32_t>((it - builds.begin()) / size);
}
std::vector<IUgcLookup::UgcEntry> shown;
for (size_t i = static_cast<size_t>(page) * size; i < builds.size() && i < static_cast<size_t>(page + 1) * size; i++) shown.push_back(builds[i]);
const auto where = UgcLinks::Whereabouts(shown);
nlohmann::json items = nlohmann::json::array();
for (const auto& build : shown) {
auto item = EntryJson(build);
const auto found = where.find(build.id);
item["where"] = found == where.end() ? nlohmann::json::array() : found->second;
items.push_back(std::move(item));
}
JsonSuccess(reply, { { "items", items }, { "total", builds.size() }, { "page", page }, { "size", size }, { "key", key } });
});
Route(eHTTPMethod::GET, "/api/ugc/assembly/of/:id", Perm("properties_view"),
"The assembly (combination of modules) a car or rocket build uses: {key, modules}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = PathId<LWOOBJID>(context.path, 4);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
const auto info = Database::Get()->GetModularBuildProcessInfo(*id);
if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such build");
const auto key = UgcModularKey::Normalize(info->details);
std::string ldf = key;
std::replace(ldf.begin(), ldf.end(), '-', '+');
JsonSuccess(reply, { { "key", key }, { "modules", ldf }, { "moduleList", ModulesJson(UgcModularKey::Lots(ldf)) } });
});
Route(eHTTPMethod::GET, "/api/ugc/:id/lxfml", Perm("properties_view"), "A player model's LXFML",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = PathId<LWOOBJID>(context.path, 2);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
auto model = Database::Get()->GetUgcModel(*id);
if (!model) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such model");
Sd0 sd0(model->lxfmlData);
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::TEXT_PLAIN;
reply.message = sd0.GetAsStringUncompressed();
});
Route(eHTTPMethod::GET, "/api/ugc/server/status", Perm("properties_view"),
"The UGC server's live status (its /status, fetched by the dashboard from ugc_internal_url): queue, workers, CPU and memory use, "
"limits and throttling. {success: false, error, url} when it doesn't answer",
[](HTTPReply& reply, const HTTPContext& context) {
const auto base = InternalUrl();
Workers::Reply(reply, context, false, [base](HTTPReply& out) {
const auto fetched = CachedGet(base + "/status", eCache::STATUS);
if (fetched->status != 200) {
JsonReply(out, eHTTPStatusCode::OK, { { "success", false }, { "url", base }, { "error", fetched->status == 0 ? fetched->error : "answered " + std::to_string(fetched->status) } });
return;
}
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_JSON;
out.message = R"({"success":true,"status":)" + fetched->body + "}";
out.headers.push_back("Cache-Control: no-store");
}, WorkerPool::ePriority::URGENT);
});
Route(eHTTPMethod::GET, "/api/ugc/files/:kind/:id/:file", Perm("properties_view"),
"A file the UGC server made (fetched from ugc_internal_url): icon.png, model.nif, model.noao.nif (before the lighting bake), "
"stats.json, or previous.<one of those> (the version before it was made again). 408 while it's being made",
[](HTTPReply& reply, const HTTPContext& context) {
const auto kind = KindOf(std::string(PathSegment(context.path, 3)));
const auto id = PathId<LWOOBJID>(context.path, 4);
const auto file = std::string(PathSegment(context.path, 5));
if (!kind || !id || !FILES.contains(file)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid kind, id or file");
const auto url = InternalUrl() + "/files/" + *kind + "/" + std::to_string(*id) + "/" + file;
Workers::Reply(reply, context, false, [url, file](HTTPReply& out) {
const auto fetched = CachedGet(url);
if (fetched->status != 200) return ReplyError(out, *fetched, url);
out.status = eHTTPStatusCode::OK;
out.contentType = file.ends_with(".png") ? eContentType::IMAGE_PNG : file.ends_with(".json") ? eContentType::APPLICATION_JSON : eContentType::APPLICATION_OCTET_STREAM;
out.message = fetched->body;
out.headers.push_back("Cache-Control: private, no-cache");
}, file.ends_with(".png") ? WorkerPool::ePriority::URGENT : WorkerPool::ePriority::NORMAL);
});
Route(eHTTPMethod::GET, "/api/ugc/mesh/:id", Perm("properties_view"),
"A player model's generated .nif converted for the 3D view (NifFile::Encode, as the scenery meshes). Query: ?lod=0 (most detailed) "
"to 3, &version=current|previous, &ao=0 for the mesh before the lighting bake. The header adds triangles and vertices",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = PathId<LWOOBJID>(context.path, 3);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
const auto lod = std::min(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "lod")).value_or(0), 3u);
const bool previous = QueryValue(context.queryString, "version") == "previous";
const bool baked = QueryValue(context.queryString, "ao") != "0";
const std::string file = std::string(previous ? "previous." : "") + (baked ? "model.nif" : "model.noao.nif");
const auto url = InternalUrl() + "/files/model/" + std::to_string(*id) + "/" + file;
Workers::Reply(reply, context, false, [url, lod](HTTPReply& out) {
const auto fetched = CachedGet(url);
if (fetched->status != 200) return ReplyError(out, *fetched, url);
std::string error;
const auto model = NifFile::Parse(fetched->body, lod, error);
if (!model) return JsonError(out, eHTTPStatusCode::UNPROCESSABLE_ENTITY, "The .nif can't be read: " + error);
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_OCTET_STREAM;
out.message = NifFile::Encode(*model, std::vector<std::string>(model->meshes.size()));
out.headers.push_back("Cache-Control: private, no-cache");
});
});
Route(eHTTPMethod::POST, "/api/ugc/cache/delete", Perm("ugc_manage"),
"Delete one item's generated files on the UGC server. Body: {kind: model|modular, id, after: on_demand|now|gone} (made again when asked "
"for, queued now, or left deleted). A car or rocket's files are shared by every build of the same modules",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto kind = body->value("kind", std::string("model")) == "modular" ? std::string("modular") : std::string("model");
const auto id = BodyId(*body);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
const auto after = body->value("after", std::string("now"));
Audit(context, "ugc_delete_files", "Deleted the UGC files of " + kind + " " + std::to_string(*id) + " (" + after + ")");
ProxyAdmin(reply, context, "/admin/delete", { { "kind", kind }, { "ids", { std::to_string(*id) } }, { "after", after } });
});
Route(eHTTPMethod::POST, "/api/ugc/cache/purge", Perm("ugc_manage"),
"Delete generated files by filter. Body: {kind, state?, owner? (name), olderThanDays?, unusedDays?, all?, confirm (\"PURGE ALL\" with all and no "
"other filter), after: on_demand|now|gone}. State and owner pick the rows here; the ages are the files' own",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const bool modular = body->value("kind", std::string("model")) == "modular";
const auto state = ParseState(body->value("state", std::string()));
const auto owner = body->value("owner", std::string()).substr(0, 64);
const auto older = body->value("olderThanDays", int64_t{ 0 });
const auto unused = body->value("unusedDays", int64_t{ 0 });
nlohmann::json request = { { "kind", modular ? "modular" : "model" }, { "olderThanDays", older }, { "unusedDays", unused },
{ "after", body->value("after", std::string("on_demand")) } };
std::string what = std::string(modular ? "cars and rockets" : "models");
if (state || !owner.empty()) {
// The rows the filter picks, from the database
nlohmann::json ids = nlohmann::json::array();
for (uint32_t page = 0;; page++) {
const auto list = modular ? Database::Get()->GetModularBuildProcessList(state, owner, page * 1000, 1000) : Database::Get()->GetUgcProcessList(state, owner, page * 1000, 1000);
for (const auto& info : list) ids.push_back(std::to_string(info.id));
if (list.size() < 1000 || ids.size() >= 100000) break;
}
if (ids.empty()) return JsonSuccess(reply, { { "deleted", 0 }, { "bytes", 0 }, { "notes", { "Nothing matches the filter." } } });
request["ids"] = ids;
what += (state ? " " + body->value("state", std::string()) : "") + (owner.empty() ? "" : " of " + owner);
} else {
if (body->value("all", false) && older == 0 && unused == 0 && body->value("confirm", std::string()) != "PURGE ALL") {
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Type PURGE ALL to delete every generated file");
}
if (!body->value("all", false) && older == 0 && unused == 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick a filter, or all");
request["all"] = true;
}
if (older > 0) what += ", made more than " + std::to_string(older) + " days ago";
if (unused > 0) what += ", not asked for in " + std::to_string(unused) + " days";
Audit(context, "ugc_purge", "Purged the UGC files of " + what + " (" + request["after"].get<std::string>() + ")");
ProxyAdmin(reply, context, "/admin/delete", request);
});
Route(eHTTPMethod::GET, "/api/ugc/icon/params", Perm("properties_view"),
"What an icon's framing and light can be set to: {params: [{key, group, setting, label, unit, min, max, step, default, description}], kinds: [{kind, "
"label, buildType, sample}]} (player models, and each car or rocket build type in the client's data; sample: a model id or the most used module "
"combination of that kind, to edit its preset on)",
[](HTTPReply& reply, const HTTPContext&) {
nlohmann::json params = nlohmann::json::array();
for (const auto& param : UgcIconParams::List()) {
params.push_back({ { "key", param.key }, { "group", param.group }, { "setting", param.setting }, { "label", param.label }, { "unit", param.unit }, { "min", param.min },
{ "max", param.max }, { "step", param.step }, { "default", param.defaultValue }, { "description", param.description } });
}
JsonSuccess(reply, { { "params", params }, { "kinds", WithSamples(IconKinds()) } });
});
Route(eHTTPMethod::GET, "/api/ugc/icon/settings", Perm("properties_view"),
"An item's icon values in their layers: {kind, target, settings (icon_* as the UGC server reads them), preset (the kind's, or null), own (the "
"item's or combination's, or null)}. Query: ?kind=model&id= or ?kind=modular&modules=, or ?preset=<kind> for a kind alone",
[](HTTPReply& reply, const HTTPContext& context) {
std::string kind, target;
if (const auto preset = QueryValue(context.queryString, "preset"); !preset.empty()) {
kind = preset;
} else if (QueryValue(context.queryString, "kind") == "modular") {
const auto modules = QueryValue(context.queryString, "modules");
const auto found = ModularKind(modules);
if (!found) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Those aren't modules");
kind = *found;
target = UgcIconParams::CombinationTarget(UgcModularKey::Normalize(modules));
} else {
const auto id = GeneralUtils::TryParse<LWOOBJID>(QueryValue(context.queryString, "id"));
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
kind = UgcIconParams::ModelKind();
target = UgcIconParams::ModelTarget(*id);
}
nlohmann::json settings = nlohmann::json::object();
for (const auto& param : UgcIconParams::List()) {
const auto value = UgcSetting(param.setting);
settings[param.key] = std::clamp(value ? GeneralUtils::TryParse<float>(*value).value_or(param.defaultValue) : param.defaultValue, param.min, param.max);
}
JsonSuccess(reply, { { "kind", kind }, { "target", target }, { "settings", settings }, { "preset", StoredValues(UgcIconParams::KindTarget(kind)) },
{ "own", target.empty() ? nlohmann::json(nullptr) : StoredValues(target) } });
});
Route(eHTTPMethod::GET, "/api/ugc/assembly", Perm("properties_view"),
"A car or rocket's modules put together as the icon renderer does (turned by its build type's AdditionalModelRotation), converted for the 3D "
"view (NifFile::Encode). Made by the UGC server on a worker and cached per combination. Query: ?modules=4713-4714-4715 (or an ldf_config)",
[](HTTPReply& reply, const HTTPContext& context) {
auto asked = QueryValue(context.queryString, "modules");
std::replace(asked.begin(), asked.end(), '-', '+');
const auto key = UgcModularKey::Normalize(asked);
if (key.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "No modules");
const auto url = InternalUrl() + "/admin/assembly";
Workers::Reply(reply, context, false, [url, key, admin = AdminKey()](HTTPReply& out) {
std::string modules = key;
std::replace(modules.begin(), modules.end(), '-', '+');
const auto fetched = AdminPost(url, admin, nlohmann::json{ { "modules", modules } }.dump());
if (fetched->status == 0) return JsonError(out, eHTTPStatusCode::BAD_GATEWAY, "The UGC server doesn't answer at " + url + " (" + fetched->error + ")");
if (fetched->status != 200) {
const auto error = nlohmann::json::parse(fetched->body, nullptr, false);
return JsonError(out, eHTTPStatusCode::UNPROCESSABLE_ENTITY, error.is_object() ? error.value("error", fetched->body) : fetched->body);
}
std::string error;
const auto model = NifFile::Parse(fetched->body, 0, error);
if (!model) return JsonError(out, eHTTPStatusCode::UNPROCESSABLE_ENTITY, "The .nif can't be read: " + error);
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_OCTET_STREAM;
out.message = NifFile::Encode(*model, std::vector<std::string>(model->meshes.size()));
out.headers.push_back("Cache-Control: private, max-age=60");
});
});
Route(eHTTPMethod::POST, "/api/ugc/icon/preview", Perm("properties_view"),
"An icon drawn by the UGC server with the given values, not stored (PNG). Body: {kind: model, id, values} or {kind: modular, modules, values}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const bool model = body->value("kind", std::string()) == "model";
ProxyAdmin(reply, context, "/admin/preview", { { "kind", model ? "model" : "modular" }, { "id", std::to_string(BodyId(*body).value_or(0)) },
{ "modules", body->value("modules", std::string()) }, { "values", nlohmann::json::parse(UgcIconParams::ToJson(UgcIconParams::Parse(body->value("values", nlohmann::json::object()).dump()))) } });
});
Route(eHTTPMethod::POST, "/api/ugc/icon/save", Perm("ugc_manage"),
"Save icon values as a kind's preset ({scope: kind, kind, values}) or as one item's own ({scope: item, kind: model, id, values} or {scope: item, "
"kind: modular, modules, values}); values null removes it. The icons already made keep theirs until they are drawn again",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
std::string target, what;
if (body->value("scope", std::string()) == "kind") {
const auto kind = body->value("kind", std::string());
bool known = false;
for (const auto& entry : IconKinds()) known = known || entry["kind"] == kind;
if (!known) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown kind");
target = UgcIconParams::KindTarget(kind);
what = "the " + kind + " icon preset";
} else if (body->value("kind", std::string()) == "modular") {
const auto key = UgcModularKey::Normalize(body->value("modules", std::string()));
if (key.empty()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "No modules");
target = UgcIconParams::CombinationTarget(key);
what = "the icon of the module combination " + key;
} else {
const auto id = BodyId(*body);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
target = UgcIconParams::ModelTarget(*id);
what = "the icon of model " + std::to_string(*id);
}
const auto values = body->value("values", nlohmann::json());
if (values.is_null()) {
Database::Get()->DeleteUgcIconSettings(target);
Audit(context, "ugc_icon_settings", "Removed the settings of " + what);
return JsonSuccess(reply, { { "message", "Removed; draw the icons again to see it" } });
}
const auto clean = UgcIconParams::ToJson(UgcIconParams::Parse(values.dump()));
Database::Get()->SetUgcIconSettings(target, clean);
Audit(context, "ugc_icon_settings", "Set " + what + " to " + clean);
JsonSuccess(reply, { { "message", "Saved; draw the icons again to see it in game" } });
});
Route(eHTTPMethod::POST, "/api/ugc/icon/regenerate", Perm("ugc_manage"),
"Have the UGC server draw every stored icon of a kind again (only icons: player models' from their stored .nif). Body: {kind}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
const auto kind = body ? body->value("kind", std::string()) : std::string();
bool known = false;
for (const auto& entry : IconKinds()) known = known || entry["kind"] == kind;
if (!known) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Unknown kind");
Audit(context, "ugc_regenerate_icons", "Queued every " + kind + " icon to be drawn again");
ProxyAdmin(reply, context, "/admin/regenerate-icons", { { "kind", kind } });
});
Route(eHTTPMethod::POST, "/api/ugc/reprocess", Perm("ugc_manage"),
"Have the UGC server make items again. Body: {kind: model|modular, id} for one, {kind, failedOnly: true} for the failed ones, {kind} for all",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const bool modular = body->value("kind", "model") == "modular";
std::optional<LWOOBJID> id;
if (body->contains("id")) {
id = BodyId(*body);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
}
const bool failedOnly = body->value("failedOnly", false);
const auto changed = modular ? Database::Get()->ResetModularBuildProcessing(id, failedOnly) : Database::Get()->ResetUgcModelProcessing(id, failedOnly);
const std::string what = modular ? "modular build" : "model";
Audit(context, "ugc_reprocess", "Queued " + std::to_string(changed) + " " + what + "(s) to be made again" +
(id ? " (" + std::to_string(*id) + ")" : failedOnly ? " (the failed ones)" : " (all)"));
BroadcastTableChanged("ugc");
JsonSuccess(reply, { { "message", std::to_string(changed) + " " + what + (changed == 1 ? "" : "s") + " will be made again" } });
});
}
}