From dce1c7833670c9b7424130ce9503e23eeff0af45 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 10:23:35 -0500 Subject: [PATCH] feat(dashboard): UGC search, and UGC icons on property and character pages UGC Search (/ugc_search) finds creations by name, id or LOT with where each one is. Property pages and character inventories show the icon the UGC server made of each creation, its state and a link to /ugc?item=&kind=, for whoever may view the page. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/DashboardServer.cpp | 2 + dDashboardServer/routes/CMakeLists.txt | 1 + dDashboardServer/routes/UgcLinks.cpp | 332 ++++++++++++++++++ dDashboardServer/routes/UgcLinks.h | 7 + dDashboardServer/routes/UgcLookup.h | 181 ++++++++++ dDashboardServer/static/js/ugc-links.js | 60 ++++ dDashboardServer/static/js/ugc-search.js | 66 ++++ .../templates/character-view.jinja2 | 19 +- dDashboardServer/templates/header.jinja2 | 1 + .../templates/property-view.jinja2 | 21 +- dDashboardServer/templates/ugc-search.jinja2 | 35 ++ tests/dWebTests/CMakeLists.txt | 1 + tests/dWebTests/UgcLookupTests.cpp | 164 +++++++++ 13 files changed, 884 insertions(+), 6 deletions(-) create mode 100644 dDashboardServer/routes/UgcLinks.cpp create mode 100644 dDashboardServer/routes/UgcLinks.h create mode 100644 dDashboardServer/routes/UgcLookup.h create mode 100644 dDashboardServer/static/js/ugc-links.js create mode 100644 dDashboardServer/static/js/ugc-search.js create mode 100644 dDashboardServer/templates/ugc-search.jinja2 create mode 100644 tests/dWebTests/UgcLookupTests.cpp diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 236808788..00d929c99 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -74,6 +74,7 @@ #include "Showcase.h" #include "ContrabandRoutes.h" #include "UgcRoutes.h" +#include "UgcLinks.h" #include "PropertyRentRoutes.h" #include "FeaturedProperties.h" #include "PasswordRecovery.h" @@ -546,6 +547,7 @@ int main(int argc, char** argv) { FeaturedProperties::RegisterRoutes(); ContrabandRoutes::RegisterRoutes(); UgcRoutes::RegisterRoutes(); + UgcLinks::RegisterRoutes(); PropertyRentRoutes::RegisterRoutes(); RegisterPasswordRecoveryRoutes(); RegisterWSRoutes(); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index 77e810a4d..ee3183e89 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -57,6 +57,7 @@ set(DASHBOARDROUTES_SOURCES "FeaturedProperties.cpp" "ContrabandRoutes.cpp" "UgcRoutes.cpp" + "UgcLinks.cpp" "PropertyRentRoutes.cpp" "PasswordRecovery.cpp" "PrometheusMetrics.cpp" diff --git a/dDashboardServer/routes/UgcLinks.cpp b/dDashboardServer/routes/UgcLinks.cpp new file mode 100644 index 000000000..9cd82b1af --- /dev/null +++ b/dDashboardServer/routes/UgcLinks.cpp @@ -0,0 +1,332 @@ +#include "UgcLinks.h" + +#include +#include +#include +#include + +#include + +#include "Database.h" +#include "dConfig.h" +#include "eHTTPMethod.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "NifFile.h" +#include "RouteUtils.h" +#include "TtlCache.h" +#include "UgcLookup.h" +#include "Workers.h" + +using namespace RouteUtils; +using namespace UgcLookup; + +namespace { + constexpr uint32_t SEARCH_LIMIT = 50; + // Creators whose inventories a search looks in for creations that are not placed or mailed + constexpr size_t MAX_INVENTORIES = 25; + + // ---- The UGC server's files, fetched by the dashboard (as the /ugc page's routes do) ---- + + struct Fetched { + long status{}; // HTTP status, 0 when the UGC server didn't answer + std::string body; + std::string error; + }; + + std::mutex g_CacheMutex; + TtlCache> g_Cache(std::chrono::seconds(15), 32 * 1024 * 1024); + constexpr size_t MAX_FETCH_BYTES = 128 * 1024 * 1024; + + size_t Collect(char* data, size_t size, size_t count, void* userData) { + auto* out = static_cast(userData); + if (out->size() + size * count > MAX_FETCH_BYTES) return 0; + out->append(data, size * count); + return size * count; + } + + // Web thread + std::string InternalUrl() { + auto url = Game::config->GetValue("ugc_internal_url"); + if (url.empty()) url = "http://127.0.0.1:2008"; + while (url.ends_with('/')) url.pop_back(); + return url; + } + + // Any thread + std::shared_ptr Fetch(const std::string& url) { + { + std::lock_guard lock(g_CacheMutex); + if (auto hit = g_Cache.Get(url)) return *hit; + } + auto out = std::make_shared(); + CURL* curl = curl_easy_init(); + if (!curl) { + out->error = "could not start a request"; + return out; + } + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https"); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "DarkflameServer-Dashboard"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, Collect); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out->body); + const auto code = curl_easy_perform(curl); + if (code == CURLE_OK) curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &out->status); + else out->error = curl_easy_strerror(code); + curl_easy_cleanup(curl); + if (out->status == 200 || out->status == 404) { + std::lock_guard lock(g_CacheMutex); + g_Cache.Put(url, out, out->body.size() + 64); + } + return out; + } + + void FetchError(HTTPReply& reply, const Fetched& fetched) { + if (fetched.status == 408) return JsonError(reply, eHTTPStatusCode::REQUEST_TIMEOUT, "The UGC server is making it; try again in a moment"); + if (fetched.status == 404) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "The UGC server has not made it"); + if (fetched.status == 0) return JsonError(reply, eHTTPStatusCode::BAD_GATEWAY, "The UGC server doesn't answer (" + fetched.error + ")"); + JsonError(reply, eHTTPStatusCode::BAD_GATEWAY, "The UGC server answered " + std::to_string(fetched.status)); + } + + // ---- Who may see a creation ---- + + // The property page's rule (as /api/properties/:id): properties_view, or the owner with own_properties + bool MayViewProperty(const HTTPContext& context, LWOOBJID propertyId) { + if (Can(context, "properties_view")) return true; + const auto info = Database::Get()->GetPropertyInfo(propertyId); + if (!info || !context.isAuthenticated) return false; + const auto owner = Database::Get()->GetCharacterInfo(info->ownerId); + return owner && owner->accountId == context.accountId && Can(context, "own_properties"); + } + + // The creations in a character's saved inventories + std::vector CharacterCreations(LWOOBJID characterId) { + const auto refs = InventoryRefs(Database::Get()->GetCharacterXml(characterId)); + if (refs.empty()) return {}; + std::set models, modular; + for (const auto& entry : Database::Get()->GetUgcEntries(Candidates(refs))) (entry.kind == eUgcKind::MODEL ? models : modular).insert(entry.id); + return LinkItems(refs, models, modular); + } + + /** + * Whether the viewer may see what the UGC server made of a creation: with properties_view (as the /ugc page), when + * one of their own characters made it, or through a page they may see that shows it: ?property= (placed there) or + * ?character= (in that character's inventories). + */ + bool MaySee(const HTTPContext& context, eUgcKind kind, LWOOBJID id) { + if (Can(context, "properties_view")) return true; + if (!context.isAuthenticated) return false; + const auto entries = Database::Get()->GetUgcEntries({ id }); + const auto entry = std::find_if(entries.begin(), entries.end(), [kind](const auto& e) { return e.kind == kind; }); + if (entry == entries.end()) return false; + if (entry->accountId == context.accountId) return true; + if (const auto property = GeneralUtils::TryParse(QueryValue(context.queryString, "property"))) { + if (!MayViewProperty(context, *property)) return false; + for (const auto& placement : Database::Get()->GetUgcPlacements({ id })) { + if (placement.propertyId == *property) return true; + } + } + if (const auto character = GeneralUtils::TryParse(QueryValue(context.queryString, "character"))) { + const auto info = Database::Get()->GetCharacterInfo(*character); + if (!info || !CanViewCharacter(context, info->accountId)) return false; + for (const auto& link : CharacterCreations(*character)) { + if (link.kind == kind && link.ugcId == id) return true; + } + } + return false; + } + + // The icon's address; `via` is the page's ?property= or ?character= that lets its viewer see it + std::string IconUrl(eUgcKind kind, LWOOBJID id, const std::string& via) { + return std::string("/api/ugc_links/icon/") + KindName(kind) + "/" + std::to_string(id) + (via.empty() ? "" : "?" + via); + } + + nlohmann::json EntryJson(const IUgcLookup::UgcEntry& entry, const std::string& via, bool viewer) { + return { + { "kind", KindName(entry.kind) }, { "ugcId", std::to_string(entry.id) }, { "state", StateName(entry.state) }, { "error", entry.error }, + { "icon", IconUrl(entry.kind, entry.id, via) }, { "link", viewer ? nlohmann::json(ViewerLink(entry.kind, entry.id)) : nlohmann::json() } + }; + } +} + +namespace UgcLinks { + void RegisterRoutes() { + Route(eHTTPMethod::GET, "/ugc_search", Perm("properties_view"), "Find players' models, cars and rockets and where they are", + [](HTTPReply& reply, const HTTPContext& context) { + RenderPage(reply, context, "ugc-search.jinja2", "ugc_search", { { "query", QueryValue(context.queryString, "q").substr(0, 100) } }); + }); + + Route(eHTTPMethod::GET, "/api/ugc_links/search", Perm("properties_view"), + "Players' creations matching ?q=: a number matches the ugc / blueprint id, a placed model's object id, the property, creator " + "character or account id, or a LOT (a modular build's modules); text matches the creator's character or account name, a " + "property name, the name or description given to a placed model, or the upload's file name. \"field: text\" searches one of " + "id, owner, property, model, lot. {items: [{kind, ugcId, state, error, detail, characterId, characterName, accountId, accountName, " + "icon, link, where: [{type: property|mail|inventory, ...}]}]}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto search = ParseQuery(QueryValue(context.queryString, "q")); + if (search.text.empty() && !search.number) return JsonSuccess(reply, { { "items", nlohmann::json::array() } }); + const auto limit = std::clamp(GeneralUtils::TryParse(QueryValue(context.queryString, "limit")).value_or(SEARCH_LIMIT), 1u, SEARCH_LIMIT); + const auto entries = Database::Get()->SearchUgc(search, limit); + + std::vector ids, modularIds; + std::set models, modular; + for (const auto& entry : entries) { + ids.push_back(entry.id); + if (entry.kind == eUgcKind::MODEL) { + models.insert(entry.id); + } else { + modular.insert(entry.id); + modularIds.push_back(entry.id); + } + } + std::map, nlohmann::json> where; + for (const auto& p : Database::Get()->GetUgcPlacements(ids)) { + const auto kind = models.contains(p.ugcId) ? eUgcKind::MODEL : eUgcKind::MODULAR; + where[{ kind, p.ugcId }].push_back({ { "type", "property" }, { "propertyId", std::to_string(p.propertyId) }, { "propertyName", p.propertyName }, + { "ownerId", std::to_string(p.ownerId) }, { "ownerName", p.ownerName }, { "zoneId", p.zoneId }, { "modelId", std::to_string(p.modelId) }, + { "lot", p.lot }, { "modelName", p.modelName }, { "modelDescription", p.modelDescription } }); + } + for (const auto& mail : Database::Get()->GetUgcMail(modularIds, MODEL_ITEM_LOT)) { + if (const auto creation = MailCreation(mail, models, modular)) { + where[*creation].push_back({ { "type", "mail" }, { "mailId", std::to_string(mail.id) }, { "characterId", std::to_string(mail.receiverId) }, + { "characterName", mail.receiverName } }); + } + } + // Not placed or mailed: most are still with whoever made them + std::set creators; + for (const auto& entry : entries) { + if (!where.contains({ entry.kind, entry.id }) && entry.characterId != LWOOBJID_EMPTY && creators.size() < MAX_INVENTORIES) creators.insert(entry.characterId); + } + for (const auto creator : creators) { + for (const auto& link : CharacterCreations(creator)) { + const std::pair key{ link.kind, link.ugcId }; + const bool wanted = link.kind == eUgcKind::MODEL ? models.contains(link.ugcId) : modular.contains(link.ugcId); + if (!wanted) continue; + where[key].push_back({ { "type", "inventory" }, { "characterId", std::to_string(creator) }, { "inventory", link.item.inventory }, + { "itemId", std::to_string(link.item.itemId) }, { "lot", link.item.lot } }); + } + } + + nlohmann::json items = nlohmann::json::array(); + for (const auto& entry : entries) { + auto item = EntryJson(entry, "", true); + item["detail"] = entry.detail; + item["characterId"] = std::to_string(entry.characterId); + item["characterName"] = entry.characterName; + item["accountId"] = entry.accountId; + item["accountName"] = entry.accountName; + const auto found = where.find({ entry.kind, entry.id }); + item["where"] = found == where.end() ? nlohmann::json::array() : found->second; + items.push_back(std::move(item)); + } + // The newest of both kinds first (ids are handed out in order) + std::stable_sort(items.begin(), items.end(), [](const auto& a, const auto& b) { + return GeneralUtils::TryParse(a["ugcId"].template get()).value_or(0) > GeneralUtils::TryParse(b["ugcId"].template get()).value_or(0); + }); + JsonSuccess(reply, { { "items", items } }); + }); + + Route(eHTTPMethod::GET, "/api/ugc_links/property/:id", 0, + "The player-built models placed on a property, with what the UGC server made of them: {items: [{modelId, kind, ugcId, state, error, " + "icon, mesh, link}]}. Whoever may view the property (properties_view, or its owner with own_properties)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto propertyId = PathId(context.path, 3); + if (!propertyId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid property id"); + if (!Database::Get()->GetPropertyInfo(*propertyId)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Property not found"); + if (!MayViewProperty(context, *propertyId)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own properties"); + std::map> placed; // ugc id -> placed models + for (const auto& model : Database::Get()->GetPropertyModels(*propertyId)) { + if (model.ugcId != 0) placed[model.ugcId].push_back(model.id); + } + std::vector ids; + for (const auto& [id, _] : placed) ids.push_back(id); + const auto via = "property=" + std::to_string(*propertyId); + const bool viewer = Can(context, "properties_view"); + nlohmann::json items = nlohmann::json::array(); + for (const auto& entry : Database::Get()->GetUgcEntries(ids)) { + for (const auto modelId : placed[entry.id]) { + auto item = EntryJson(entry, via, viewer); + item["modelId"] = std::to_string(modelId); + if (entry.kind == eUgcKind::MODEL && entry.state == IUgc::eProcessState::DONE) { + item["mesh"] = "/api/ugc_links/mesh/" + std::to_string(entry.id) + "?" + via; + } + items.push_back(std::move(item)); + } + } + JsonSuccess(reply, { { "items", items } }); + }); + + Route(eHTTPMethod::GET, "/api/ugc_links/character/:id", 0, + "The creations (models, cars, rockets) in a character's saved inventories: {items: [{itemId, lot, inventory, kind, ugcId, state, error, icon, link}]}. " + "Whoever may view the character", + [](HTTPReply& reply, const HTTPContext& context) { + const auto characterId = PathId(context.path, 3); + const auto info = characterId ? Database::Get()->GetCharacterInfo(*characterId) : std::nullopt; + if (!info) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Character not found"); + if (!CanViewCharacter(context, info->accountId)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may only view your own characters"); + const auto links = CharacterCreations(*characterId); + std::vector ids; + for (const auto& link : links) ids.push_back(link.ugcId); + std::map, IUgcLookup::UgcEntry> entries; + for (auto& entry : Database::Get()->GetUgcEntries(ids)) entries[{ entry.kind, entry.id }] = std::move(entry); + const auto via = "character=" + std::to_string(*characterId); + const bool viewer = Can(context, "properties_view"); + nlohmann::json items = nlohmann::json::array(); + for (const auto& link : links) { + const auto entry = entries.find({ link.kind, link.ugcId }); + if (entry == entries.end()) continue; + auto item = EntryJson(entry->second, via, viewer); + item["itemId"] = std::to_string(link.item.itemId); + item["lot"] = link.item.lot; + item["inventory"] = link.item.inventory; + items.push_back(std::move(item)); + } + JsonSuccess(reply, { { "items", items } }); + }); + + Route(eHTTPMethod::GET, "/api/ugc_links/icon/:kind/:id", 0, + "The icon the UGC server made of a creation (kind model or modular), fetched from ugc_internal_url. With properties_view, for your own " + "characters' creations, or with ?property= / ?character= naming a page you may view that shows it. 404 until it is made", + [](HTTPReply& reply, const HTTPContext& context) { + const auto kind = ParseKind(PathSegment(context.path, 3)); + const auto id = PathId(context.path, 4); + if (!kind || !id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid kind or id"); + if (!MaySee(context, *kind, *id)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not view this creation"); + const auto url = InternalUrl() + "/files/" + KindName(*kind) + "/" + std::to_string(*id) + "/icon.png"; + Workers::Reply(reply, context, false, [url](HTTPReply& out) { + const auto fetched = Fetch(url); + if (fetched->status != 200) return FetchError(out, *fetched); + out.status = eHTTPStatusCode::OK; + out.contentType = eContentType::IMAGE_PNG; + out.message = fetched->body; + out.headers.push_back("Cache-Control: private, max-age=60"); + }, WorkerPool::ePriority::URGENT); + }); + + Route(eHTTPMethod::GET, "/api/ugc_links/mesh/:id", 0, + "The .nif the UGC server made of a player-built model, converted for the 3D view (as /api/ugc/mesh/:id). ?lod=0 (most detailed) to 3. " + "Who may see it as /api/ugc_links/icon", + [](HTTPReply& reply, const HTTPContext& context) { + const auto id = PathId(context.path, 3); + if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id"); + if (!MaySee(context, eUgcKind::MODEL, *id)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "You may not view this creation"); + const auto lod = std::min(GeneralUtils::TryParse(QueryValue(context.queryString, "lod")).value_or(0), 3u); + const auto url = InternalUrl() + "/files/model/" + std::to_string(*id) + "/model.nif"; + Workers::Reply(reply, context, false, [url, lod](HTTPReply& out) { + const auto fetched = Fetch(url); + if (fetched->status != 200) return FetchError(out, *fetched); + 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(model->meshes.size())); + out.headers.push_back("Cache-Control: private, max-age=60"); + }); + }); + } +} diff --git a/dDashboardServer/routes/UgcLinks.h b/dDashboardServer/routes/UgcLinks.h new file mode 100644 index 000000000..44c9e7026 --- /dev/null +++ b/dDashboardServer/routes/UgcLinks.h @@ -0,0 +1,7 @@ +#pragma once + +// Finding players' creations and showing them where they are: the UGC search, and the UGC server's icons and models on +// the property and character pages (for whoever may view those pages) +namespace UgcLinks { + void RegisterRoutes(); +} diff --git a/dDashboardServer/routes/UgcLookup.h b/dDashboardServer/routes/UgcLookup.h new file mode 100644 index 000000000..a56edc9a4 --- /dev/null +++ b/dDashboardServer/routes/UgcLookup.h @@ -0,0 +1,181 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tinyxml2.h" +#include "dCommonVars.h" +#include "GeneralUtils.h" +#include "IUgcLookup.h" +#include "LDFFormat.h" + +/** + * Finding players' creations (brick built models and modular cars and rockets) for the dashboard: reading a search, + * finding which of a character's items are creations, and in which mail one is. Pure (no database), so it can be + * unit tested; routes/UgcLinks.cpp runs it. + * + * A creation shows up as: + * - a placed model (properties_contents.ugc_id): LOT 14 for a brick built model, its ugc_id the blueprint id + * - a brick built model item (LOT 6662): its blueprint id in the item's config (blueprintid, saved as x@bp) + * - a car or rocket item (any LOT): its subkey is the ugc_modular_build id + * - a mail attachment: the same, with the config as LDF lines (attachment_config) and the subkey in attachment_subkey + */ +namespace UgcLookup { + using eUgcKind = IUgcLookup::eUgcKind; + using UgcSearch = IUgcLookup::UgcSearch; + + constexpr LOT MODEL_OBJECT_LOT = 14; + constexpr LOT MODEL_ITEM_LOT = 6662; + + inline const char* KindName(eUgcKind kind) { return kind == eUgcKind::MODULAR ? "modular" : "model"; } + + inline std::optional ParseKind(std::string_view text) { + if (text == "model") return eUgcKind::MODEL; + if (text == "modular") return eUgcKind::MODULAR; + return std::nullopt; + } + + inline const char* StateName(IUgc::eProcessState state) { + switch (state) { + case IUgc::eProcessState::PENDING: return "pending"; + case IUgc::eProcessState::DONE: return "done"; + case IUgc::eProcessState::FAILED: return "failed"; + } + return "unknown"; + } + + // Where the /ugc page shows a creation: /ugc?item=&kind=model|modular (the page may read these to open it) + inline std::string ViewerLink(eUgcKind kind, LWOOBJID id) { + return "/ugc?item=" + std::to_string(id) + "&kind=" + KindName(kind); + } + + inline std::string_view Trim(std::string_view text) { + while (!text.empty() && std::isspace(static_cast(text.front()))) text.remove_prefix(1); + while (!text.empty() && std::isspace(static_cast(text.back()))) text.remove_suffix(1); + return text; + } + + /** + * A search box's text: a number is matched against ids (and LOTs), anything else against names. "field: text" + * searches one field: id, owner (or character, account), property, model (or name), lot. + */ + inline UgcSearch ParseQuery(std::string_view input) { + UgcSearch search; + auto text = std::string(Trim(input)); + const auto colon = text.find(':'); + if (colon != std::string::npos) { + auto key = text.substr(0, colon); + std::transform(key.begin(), key.end(), key.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + static const std::map FIELDS = { + { "id", UgcSearch::eField::ID }, { "owner", UgcSearch::eField::OWNER }, { "character", UgcSearch::eField::OWNER }, + { "account", UgcSearch::eField::OWNER }, { "property", UgcSearch::eField::PROPERTY }, { "model", UgcSearch::eField::MODEL }, + { "name", UgcSearch::eField::MODEL }, { "lot", UgcSearch::eField::LOT }, + }; + const auto field = FIELDS.find(key); + if (field != FIELDS.end()) { + search.field = field->second; + text = std::string(Trim(std::string_view(text).substr(colon + 1))); + } + } + search.number = GeneralUtils::TryParse(text); + if (search.number && *search.number < 0) search.number.reset(); + // Ids and LOTs are only numbers (anything else is searched as a name); a name may be all digits, so elsewhere a + // number is also matched as text + const bool numeric = search.field == UgcSearch::eField::ID || search.field == UgcSearch::eField::LOT; + if (numeric && !search.number) search.field = UgcSearch::eField::ANY; + if (!numeric || !search.number) search.text = text.substr(0, 64); + return search; + } + + // An object id kept in an item's saved config: "9:" (LDF type and value) or just the number + inline LWOOBJID ConfigId(const char* value) { + if (!value) return LWOOBJID_EMPTY; + std::string_view text(value); + const auto colon = text.rfind(':'); + if (colon != std::string_view::npos) text = text.substr(colon + 1); + return GeneralUtils::TryParse(text).value_or(LWOOBJID_EMPTY); + } + + // An item in a character's saved inventories that may be a creation + struct ItemRef { + LWOOBJID itemId{}; + LOT lot{}; + uint32_t inventory{}; + LWOOBJID subkey{}; // a modular build's id + LWOOBJID blueprint{}; // a brick built model's blueprint (ugc) id + }; + + // Every item of a character's saved XML with a subkey or a blueprint + inline std::vector InventoryRefs(const std::string& xml) { + std::vector refs; + tinyxml2::XMLDocument doc; + if (xml.empty() || doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) return refs; + const auto* obj = doc.FirstChildElement("obj"); + const auto* inv = obj ? obj->FirstChildElement("inv") : nullptr; + const auto* items = inv ? inv->FirstChildElement("items") : nullptr; + for (const auto* in = items ? items->FirstChildElement("in") : nullptr; in; in = in->NextSiblingElement("in")) { + for (const auto* i = in->FirstChildElement("i"); i; i = i->NextSiblingElement("i")) { + ItemRef ref; + ref.itemId = i->Int64Attribute("id", 0); + ref.lot = i->IntAttribute("l", 0); + ref.inventory = static_cast(in->IntAttribute("t", 0)); + ref.subkey = i->Int64Attribute("sk", 0); + if (const auto* x = i->FirstChildElement("x")) { + ref.blueprint = ConfigId(x->Attribute("bp")); + if (ref.blueprint == LWOOBJID_EMPTY) ref.blueprint = ConfigId(x->Attribute("b")); + } + if (ref.subkey != LWOOBJID_EMPTY || ref.blueprint != LWOOBJID_EMPTY) refs.push_back(ref); + } + } + return refs; + } + + // The ids that InventoryRefs' items may be creations by + inline std::vector Candidates(const std::vector& refs) { + std::set ids; + for (const auto& ref : refs) { + if (ref.blueprint != LWOOBJID_EMPTY) ids.insert(ref.blueprint); + if (ref.subkey != LWOOBJID_EMPTY) ids.insert(ref.subkey); + } + return { ids.begin(), ids.end() }; + } + + struct ItemLink { + ItemRef item; + eUgcKind kind{}; + LWOOBJID ugcId{}; + }; + + // Which items are creations, given the ids that are models and those that are modular builds. A model item goes by + // its blueprint (its subkey is the model's own id); anything else by its subkey. + inline std::vector LinkItems(const std::vector& refs, const std::set& models, const std::set& modular) { + std::vector links; + for (const auto& ref : refs) { + if (ref.blueprint != LWOOBJID_EMPTY && models.contains(ref.blueprint)) links.push_back({ ref, eUgcKind::MODEL, ref.blueprint }); + else if (ref.subkey != LWOOBJID_EMPTY && modular.contains(ref.subkey)) links.push_back({ ref, eUgcKind::MODULAR, ref.subkey }); + } + return links; + } + + // The creation a mail attachment is: a model item by the blueprint in its config (LDF lines), else by its subkey + inline std::optional> MailCreation(const IUgcLookup::UgcMail& mail, const std::set& models, const std::set& modular) { + if (mail.lot == MODEL_ITEM_LOT && !mail.config.empty()) { + LwoNameValue config; + config.InsertLines(mail.config); + const auto it = config.find(u"blueprintid"); + if (it != config.end() && it->second) { + const auto blueprint = GeneralUtils::TryParse(it->second->GetValueAsString()).value_or(LWOOBJID_EMPTY); + if (models.contains(blueprint)) return std::make_pair(eUgcKind::MODEL, blueprint); + } + } + if (mail.subkey != LWOOBJID_EMPTY && modular.contains(mail.subkey)) return std::make_pair(eUgcKind::MODULAR, mail.subkey); + return std::nullopt; + } +} diff --git a/dDashboardServer/static/js/ugc-links.js b/dDashboardServer/static/js/ugc-links.js new file mode 100644 index 000000000..e47b8803f --- /dev/null +++ b/dDashboardServer/static/js/ugc-links.js @@ -0,0 +1,60 @@ +/** + * Players' creations on the property and character pages: the icon the UGC server made of a model, car or rocket in + * place of the item's own icon, its state and a link to it on the /ugc page (for those who may open it). The routes + * (/api/ugc_links/...) let whoever may view the page see the icons of the creations on it. + */ +(function () { + var STATES = { done: ['Made', 'success'], pending: ['Waiting', 'secondary'], failed: ['Failed', 'danger'] }; + + window.UgcLinks = { + // A badge for the UGC server's state; a failure's reason as its tooltip + state: function (item) { + var s = STATES[item.state] || [item.state, 'secondary']; + return '' + esc(s[0]) + ''; + }, + // An of the creation's icon that falls back to the item's own icon until the UGC server made one + icon: function (item, lot, size) { + size = size || 40; + return ''; + }, + // {modelId: item} for the player-built models on a property + forProperty: function (propertyId) { + return api.get('/api/ugc_links/property/' + propertyId).then(function (d) { + var map = {}; + ((d && d.items) || []).forEach(function (item) { map[item.modelId] = item; }); + return map; + }).catch(function () { return {}; }); + }, + // Shows the creations among a character page's items (.inv-item[data-id]) with their own icon and a link + decorateInventory: function (characterId, root) { + return api.get('/api/ugc_links/character/' + characterId).then(function (d) { + ((d && d.items) || []).forEach(function (item) { + (root || document).querySelectorAll('.inv-item[data-id="' + item.itemId + '"]').forEach(function (el) { + var img = el.querySelector('img'); + if (img) { + var own = img.src; + img.onerror = function () { img.onerror = null; img.src = own; }; + img.src = item.icon; + } + el.classList.add('inv-ugc'); + el.title = (el.title ? el.title + ' - ' : '') + 'Player-built ' + (item.kind === 'modular' ? 'car or rocket' : 'model') + ' (UGC ' + item.ugcId + ', ' + item.state + ')'; + if (item.link && !el.querySelector('.inv-ugc-link')) { + // Items may sit inside a link already (the item trace), so this is a button, not a nested link + var a = document.createElement('span'); + a.className = 'inv-ugc-link'; + a.setAttribute('role', 'link'); + a.tabIndex = 0; + a.textContent = 'UGC'; + a.title = 'Open in the UGC viewer'; + var open = function (e) { e.preventDefault(); e.stopPropagation(); window.location.href = item.link; }; + a.addEventListener('click', open); + a.addEventListener('keydown', function (e) { if (e.key === 'Enter') open(e); }); + el.appendChild(a); + } + }); + }); + }).catch(function () {}); + } + }; +})(); diff --git a/dDashboardServer/static/js/ugc-search.js b/dDashboardServer/static/js/ugc-search.js new file mode 100644 index 000000000..0a36f8ce2 --- /dev/null +++ b/dDashboardServer/static/js/ugc-search.js @@ -0,0 +1,66 @@ +/** + * The UGC search (/ugc_search): players' creations by name, id or LOT and where each one is (/api/ugc_links/search). + * ?q= fills the search in; the address keeps the last search so it can be shared. + */ +(function () { + var input = document.getElementById('ugcSearchInput'); + var rows = document.getElementById('ugcSearchRows'); + var status = document.getElementById('ugcSearchStatus'); + var latest = 0, timer = null; + var INVENTORIES = { 5: 'Models', 12: 'Vault models', 14: 'Brick-building models' }; + + function inventoryName(type) { return (window.Labels && Labels.name('inventories', type)) || INVENTORIES[type] || 'Inventory ' + type; } + + function where(item) { + if (!item.where.length) { + return 'Not found'; + } + return item.where.map(function (w) { + if (w.type === 'property') { + // A model the player didn't name has the client's placeholder (Objects__name) + var given = w.modelName && !/^Objects_\d+_name$/.test(w.modelName); + var name = given ? ' as ' + esc(w.modelName) + '' : ''; + return '
On ' + fmt.link('/properties/' + w.propertyId, w.propertyName || 'Property ' + w.propertyId) + name + + ' (' + fmt.character(w.ownerId, w.ownerName) + ', ' + esc(w.zoneId) + ')' + + ' 3D
'; + } + if (w.type === 'mail') return '
In the mail to ' + fmt.character(w.characterId, w.characterName) + '
'; + return '
In ' + fmt.character(w.characterId, item.characterName) + '\'s ' + esc(inventoryName(w.inventory)) + '
'; + }).join(''); + } + + function render(items) { + rows.innerHTML = items.length ? items.map(function (item) { + var lot = item.kind === 'modular' ? 6416 : 6662; + var label = item.kind === 'modular' ? 'Car or rocket' : 'Model'; + var title = item.link ? '' + label + ' ' + esc(item.ugcId) + '' : label + ' ' + esc(item.ugcId); + var detail = item.detail ? '
' + (item.kind === 'modular' ? 'Modules ' + esc(item.detail) + '' : esc(item.detail)) + '
' : ''; + var account = item.accountName ? '
' + (document.getElementById('ugcSearchPage').dataset.canAccounts ? fmt.link('/accounts/' + item.accountId, item.accountName) : esc(item.accountName)) + '
' : ''; + return '' + UgcLinks.icon(item, lot, 48) + '' + title + detail + '' + + '' + fmt.character(item.characterId, item.characterName) + account + '' + + '' + where(item) + '' + UgcLinks.state(item) + ''; + }).join('') : 'Nothing matches.'; + } + + function search() { + var q = input.value.trim(); + var request = ++latest; + var url = new URL(window.location.href); + if (q) url.searchParams.set('q', q); else url.searchParams.delete('q'); + history.replaceState(null, '', url); + if (!q) { rows.innerHTML = 'Type something to search.'; status.textContent = ''; return; } + status.textContent = 'Searching…'; + api.get('/api/ugc_links/search?q=' + encodeURIComponent(q)).then(function (d) { + if (request !== latest) return; + var items = (d && d.items) || []; + status.textContent = items.length + ' found' + (items.length >= 50 ? ' (the newest 50 of each kind; narrow the search for more)' : ''); + render(items); + }).catch(function () { if (request === latest) status.textContent = 'The search failed.'; }); + } + + input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(search, 300); }); + document.getElementById('ugcSearchForm').addEventListener('submit', function (e) { e.preventDefault(); clearTimeout(timer); search(); }); + input.value = document.getElementById('ugcSearchPage').dataset.query || ''; + if (input.value) search(); + input.focus(); +})(); diff --git a/dDashboardServer/templates/character-view.jinja2 b/dDashboardServer/templates/character-view.jinja2 index e68b943f3..a825406aa 100644 --- a/dDashboardServer/templates/character-view.jinja2 +++ b/dDashboardServer/templates/character-view.jinja2 @@ -38,6 +38,20 @@ font-size: 10px; opacity: 0.7; } +/* A player's creation (ugc-links.js): the UGC server's icon, and a link to it in the UGC viewer */ +.inv-item.inv-ugc { border-color: var(--bs-info-border-subtle); } +.inv-ugc-link { + position: absolute; + top: 1px; + right: 2px; + font-size: 9px; + font-weight: bold; + padding: 0 2px; + border-radius: 3px; + color: var(--bs-info-text-emphasis); + background: var(--bs-info-bg-subtle); +} +.inv-ugc-link:hover { text-decoration: underline; } {% endblock %} @@ -259,7 +273,7 @@
{% for item in inv.items %} {% if can.reports_view and item.id != "" %}{% endif %} -
+ {% set inboxPages = ["moderation", "player_reports", "bug_reports"] %} diff --git a/dDashboardServer/templates/property-view.jinja2 b/dDashboardServer/templates/property-view.jinja2 index 061c91b65..03cf8bb53 100644 --- a/dDashboardServer/templates/property-view.jinja2 +++ b/dDashboardServer/templates/property-view.jinja2 @@ -108,6 +108,7 @@ {% endblock %} {% block scripts %} + + +{% endblock %} diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index 1bf9a1734..b09ad2389 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -39,6 +39,7 @@ set(DWEBTESTS_SOURCES "DeferredReplyTests.cpp" "WorkerPoolTests.cpp" "OnceCacheTests.cpp" + "UgcLookupTests.cpp" "${PROJECT_SOURCE_DIR}/dDashboardServer/routes/WorkerPool.cpp" "BackupFilesTests.cpp" "LogBundleTests.cpp" diff --git a/tests/dWebTests/UgcLookupTests.cpp b/tests/dWebTests/UgcLookupTests.cpp new file mode 100644 index 000000000..7e7119727 --- /dev/null +++ b/tests/dWebTests/UgcLookupTests.cpp @@ -0,0 +1,164 @@ +#include + +#include + +#include + +#include "UgcLookup.h" +#include "UgcLookupSql.h" + +using namespace UgcLookup; +using eField = UgcSearch::eField; + +TEST(UgcLookupTests, ParsesSearches) { + auto s = ParseQuery(" 1152921504606954530 "); + EXPECT_EQ(s.field, eField::ANY); + EXPECT_EQ(s.number, 1152921504606954530); + EXPECT_EQ(s.text, "1152921504606954530"); // a name may be all digits + + s = ParseQuery("Bob"); + EXPECT_EQ(s.field, eField::ANY); + EXPECT_FALSE(s.number); + EXPECT_EQ(s.text, "Bob"); + + s = ParseQuery("Owner: Bob"); + EXPECT_EQ(s.field, eField::OWNER); + EXPECT_EQ(s.text, "Bob"); + + s = ParseQuery("lot:8129"); + EXPECT_EQ(s.field, eField::LOT); + EXPECT_EQ(s.number, 8129); + EXPECT_TRUE(s.text.empty()); + + // An id field with no number is searched as text; an unknown field is part of the text + s = ParseQuery("id: abc"); + EXPECT_EQ(s.field, eField::ANY); + EXPECT_EQ(s.text, "abc"); + s = ParseQuery("my: house"); + EXPECT_EQ(s.field, eField::ANY); + EXPECT_EQ(s.text, "my: house"); + + EXPECT_FALSE(ParseQuery("-5").number); + EXPECT_TRUE(ParseQuery(" ").text.empty()); +} + +TEST(UgcLookupTests, LinksToTheViewer) { + EXPECT_EQ(ViewerLink(eUgcKind::MODEL, 42), "/ugc?item=42&kind=model"); + EXPECT_EQ(ViewerLink(eUgcKind::MODULAR, 7), "/ugc?item=7&kind=modular"); + EXPECT_EQ(ParseKind("modular"), eUgcKind::MODULAR); + EXPECT_FALSE(ParseKind("other")); +} + +TEST(UgcLookupTests, FindsCreationsInAnInventory) { + const std::string xml = + "" + "" + "" // a model: its blueprint, its subkey is its UGID + "" // a rocket: its subkey + "" // an old car without a subkey + "" + "" // a pet; a plain item + ""; + const auto refs = InventoryRefs(xml); + ASSERT_EQ(refs.size(), 3u); + EXPECT_EQ(refs[0].blueprint, 1000); + EXPECT_EQ(refs[0].inventory, 5u); + EXPECT_EQ(refs[1].subkey, 2000); + + const auto candidates = Candidates(refs); + EXPECT_EQ(std::set(candidates.begin(), candidates.end()), (std::set{ 555, 1000, 2000, 3000 })); + + // The pet's subkey (3000) is no creation; the model goes by its blueprint, not its UGID + const auto links = LinkItems(refs, { 1000, 555 }, { 2000 }); + ASSERT_EQ(links.size(), 2u); + EXPECT_EQ(links[0].item.itemId, 100); + EXPECT_EQ(links[0].kind, eUgcKind::MODEL); + EXPECT_EQ(links[0].ugcId, 1000); + EXPECT_EQ(links[1].item.itemId, 101); + EXPECT_EQ(links[1].kind, eUgcKind::MODULAR); + EXPECT_EQ(links[1].ugcId, 2000); + + EXPECT_TRUE(InventoryRefs("").empty()); + EXPECT_TRUE(InventoryRefs("").empty()); +} + +TEST(UgcLookupTests, FindsCreationsInTheMail) { + IUgcLookup::UgcMail model{ .id = 1, .lot = MODEL_ITEM_LOT, .subkey = 555, .config = "blueprintid=9:1000\nuserModelID=9:555" }; + EXPECT_EQ(MailCreation(model, { 1000 }, {}), std::make_pair(eUgcKind::MODEL, LWOOBJID{ 1000 })); + EXPECT_FALSE(MailCreation(model, { 999 }, {})); + + IUgcLookup::UgcMail rocket{ .id = 2, .lot = 8092, .subkey = 2000, .config = "assemblyPartLOTs=0:1:8129" }; + EXPECT_EQ(MailCreation(rocket, {}, { 2000 }), std::make_pair(eUgcKind::MODULAR, LWOOBJID{ 2000 })); + EXPECT_FALSE(MailCreation(rocket, { 2000 }, {})); +} + +namespace { + // The lookup's SQL against a small database with the game's tables (the columns it reads) + class UgcLookupSqlTests : public ::testing::Test { + protected: + sqlite3* db = nullptr; + + void SetUp() override { + ASSERT_EQ(sqlite3_open(":memory:", &db), SQLITE_OK); + Exec("CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT);" + "CREATE TABLE charinfo (id BIGINT PRIMARY KEY, account_id INTEGER, name TEXT);" + "CREATE TABLE properties (id BIGINT PRIMARY KEY, owner_id BIGINT, name TEXT, zone_id INTEGER);" + "CREATE TABLE ugc (id INTEGER PRIMARY KEY, account_id INTEGER, character_id BIGINT, is_optimized INTEGER, filename TEXT, process_error TEXT DEFAULT '');" + "CREATE TABLE ugc_modular_build (ugc_id BIGINT PRIMARY KEY, character_id BIGINT, ldf_config TEXT, is_optimized INTEGER DEFAULT 0, process_error TEXT DEFAULT '');" + "CREATE TABLE properties_contents (id BIGINT PRIMARY KEY, property_id BIGINT, ugc_id BIGINT, lot INTEGER, model_name TEXT DEFAULT '', model_description TEXT DEFAULT '');" + "INSERT INTO accounts VALUES (1, 'builder'), (2, 'racer');" + "INSERT INTO charinfo VALUES (10, 1, 'Bricky'), (20, 2, 'Speedy');" + "INSERT INTO properties VALUES (500, 10, 'Castle Hill', 1150);" + "INSERT INTO ugc VALUES (1000, 1, 10, 1, 'tower.lxfml', ''), (1001, 1, 10, 0, 'boat.lxfml', '');" + "INSERT INTO ugc_modular_build VALUES (2000, 20, '1:8129+1:8130+1:9330', 1, '');" + "INSERT INTO properties_contents VALUES (7000, 500, 1000, 14, 'Big tower', 'the tallest');"); + } + + void TearDown() override { sqlite3_close(db); } + + void Exec(const char* sql) { ASSERT_EQ(sqlite3_exec(db, sql, nullptr, nullptr, nullptr), SQLITE_OK) << sqlite3_errmsg(db); } + + // The ids a search finds, of models and modular builds + std::set Find(const std::string& query, bool modular) { + const auto search = ParseQuery(query); + const auto sql = UgcLookupSql::Select(modular) + UgcLookupSql::Where(search, modular) + "LIMIT 50;"; + sqlite3_stmt* stmt = nullptr; + EXPECT_EQ(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr), SQLITE_OK) << sqlite3_errmsg(db) << "\n" << sql; + EXPECT_EQ(sqlite3_bind_parameter_count(stmt), UgcLookupSql::TEXT_BINDS); + const auto pattern = "%" + search.text + "%"; + for (int i = 1; i <= UgcLookupSql::TEXT_BINDS; i++) sqlite3_bind_text(stmt, i, pattern.c_str(), -1, SQLITE_TRANSIENT); + std::set ids; + while (sqlite3_step(stmt) == SQLITE_ROW) ids.insert(sqlite3_column_int64(stmt, 0)); + sqlite3_finalize(stmt); + return ids; + } + }; +} + +TEST_F(UgcLookupSqlTests, FindsByIds) { + EXPECT_EQ(Find("1000", false), (std::set{ 1000 })); // the blueprint id + EXPECT_EQ(Find("7000", false), (std::set{ 1000 })); // the placed model's object id + EXPECT_EQ(Find("500", false), (std::set{ 1000 })); // the property + EXPECT_EQ(Find("10", false), (std::set{ 1000, 1001 })); // the creator + EXPECT_EQ(Find("id: 2", true), (std::set{ 2000 })); // the creator's account + EXPECT_EQ(Find("2000", true), (std::set{ 2000 })); + EXPECT_TRUE(Find("2000", false).empty()); +} + +TEST_F(UgcLookupSqlTests, FindsByNames) { + EXPECT_EQ(Find("brick", false), (std::set{ 1000, 1001 })); // the character's name + EXPECT_EQ(Find("owner: racer", true), (std::set{ 2000 })); // the account's name + EXPECT_TRUE(Find("owner: racer", false).empty()); + EXPECT_EQ(Find("castle", false), (std::set{ 1000 })); // a property it is placed on + EXPECT_EQ(Find("property: 500", false), (std::set{ 1000 })); + EXPECT_EQ(Find("tallest", false), (std::set{ 1000 })); // the description given to it + EXPECT_EQ(Find("model: boat", false), (std::set{ 1001 })); // the upload's file name + EXPECT_TRUE(Find("property: tower", false).empty()); // a model name is not a property name +} + +TEST_F(UgcLookupSqlTests, FindsByLot) { + EXPECT_EQ(Find("lot: 8130", true), (std::set{ 2000 })); // a module + EXPECT_EQ(Find("lot: 9330", true), (std::set{ 2000 })); // the last module + EXPECT_TRUE(Find("lot: 813", true).empty()); // not part of a module's LOT + EXPECT_EQ(Find("lot: 14", false), (std::set{ 1000 })); // placed as LOT 14 +}