feat: contraband list with flagging and optional removal

Staff list contraband items on a new dashboard page (item search, reason, flag or flag and remove;
contraband_manage to edit, reports_view to see). World servers check every inventory when a
character loads and every item a player receives: each find is an economy flag of the new kind
Contraband, shown with the other flags and in the character's related data. Items marked for
removal are taken away, with a character snapshot kept first so they can be given back, an audit
entry and a mail or chat message telling the player why. Staff are skipped unless
contraband_ignore_staff is off. Worlds reload the list when it changes (RELOAD_CONTRABAND, added
at the end of ePlayerAction).

Fixes #1563

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 23:20:16 -05:00
parent 341ce89a6a
commit b2403b09ea
36 changed files with 766 additions and 22 deletions

View File

@@ -1425,27 +1425,29 @@ namespace {
}
char decoded[256]{};
mg_url_decode(query.c_str(), query.size(), decoded, sizeof(decoded), 1);
query = decoded;
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT id, name, displayName FROM Objects WHERE type = 'Loot' AND (name LIKE '%' || ? || '%' OR displayName LIKE '%' || ? || '%' OR id = ?) "
"ORDER BY name LIMIT 50;");
stmt.bind(1, query.c_str());
stmt.bind(2, query.c_str());
stmt.bind(3, GeneralUtils::TryParse<int>(query).value_or(-1));
auto result = stmt.execQuery();
nlohmann::json items = nlohmann::json::array();
while (!result.eof()) {
const std::string displayName = result.getStringField("displayName", "");
items.push_back({ {"lot", result.getIntField("id")}, {"name", displayName.empty() ? result.getStringField("name") : displayName} });
result.nextRow();
}
JsonReply(reply, eHTTPStatusCode::OK, items);
JsonReply(reply, eHTTPStatusCode::OK, SearchItems(decoded));
});
}
}
nlohmann::json SearchItems(const std::string& query) {
auto stmt = CDClientDatabase::CreatePreppedStmt(
"SELECT id, name, displayName FROM Objects WHERE type = 'Loot' AND (name LIKE '%' || ? || '%' OR displayName LIKE '%' || ? || '%' OR id = ?) "
"ORDER BY name LIMIT 50;");
stmt.bind(1, query.c_str());
stmt.bind(2, query.c_str());
stmt.bind(3, GeneralUtils::TryParse<int>(query).value_or(-1));
auto result = stmt.execQuery();
nlohmann::json items = nlohmann::json::array();
while (!result.eof()) {
const std::string displayName = result.getStringField("displayName", "");
items.push_back({ {"lot", result.getIntField("id")}, {"name", displayName.empty() ? result.getStringField("name") : displayName} });
result.nextRow();
}
return items;
}
// For the property showcase (Showcase.cpp), which serves the same model data to people who aren't the owner
namespace PropertyAssets {
std::optional<std::pair<std::string, std::string>> ModelLxfml(const IPropertyContents::Model& model, LWOOBJID propertyId) { return ::ModelLxfml(model, propertyId); }

View File

@@ -1,3 +1,10 @@
#pragma once
#include <string>
#include "json.hpp"
void RegisterAPIRoutes();
// Items (Objects of type Loot) whose name or display name contains the text, or whose LOT it is: [{lot, name}], at most 50
nlohmann::json SearchItems(const std::string& query);

View File

@@ -52,6 +52,7 @@ set(DASHBOARDROUTES_SOURCES
"PublicRoutes.cpp"
"Showcase.cpp"
"FeaturedProperties.cpp"
"ContrabandRoutes.cpp"
"PasswordRecovery.cpp"
"PrometheusMetrics.cpp"
"../email/SmtpClient.cpp"

View File

@@ -0,0 +1,111 @@
#include "ContrabandRoutes.h"
#include <ctime>
#include "APIRoutes.h"
#include "CDClientDatabase.h"
#include "ClientAssets.h"
#include "Database.h"
#include "DashboardRoutes.h"
#include "GeneralUtils.h"
#include "master/PlayerAction.h"
#include "PlayerActions.h"
#include "RouteUtils.h"
#include "WSRoutes.h"
#include "eHTTPMethod.h"
using namespace RouteUtils;
namespace {
using eContrabandAction = IContraband::eContrabandAction;
constexpr size_t MAX_REASON = 300;
const char* ActionName(eContrabandAction action) {
return action == eContrabandAction::REMOVE ? "remove" : "flag";
}
std::optional<eContrabandAction> ParseAction(const std::string& text) {
if (text == "flag") return eContrabandAction::FLAG;
if (text == "remove") return eContrabandAction::REMOVE;
return std::nullopt;
}
bool IsItem(LOT lot) {
auto stmt = CDClientDatabase::CreatePreppedStmt("SELECT 1 FROM Objects WHERE id = ? LIMIT 1;");
stmt.bind(1, static_cast<int32_t>(lot));
return !stmt.execQuery().eof();
}
std::string Trimmed(std::string text, size_t max) {
text.erase(0, text.find_first_not_of(" \t\r\n"));
text.erase(text.find_last_not_of(" \t\r\n") + 1);
return text.substr(0, max);
}
// Tell every running world to load the list again
uint32_t ReloadWorlds(uint32_t requester) {
PlayerActionRequest request;
request.action = ePlayerAction::RELOAD_CONTRABAND;
return PlayerActions::Request(request, requester, [](const PlayerActionResult& result) {
return PlayerActions::Outcome{ true, result.affected ? "The list was updated in " + std::to_string(result.affected) + " world(s)"
: "No world is running; they'll use the list when they start" };
});
}
}
namespace ContrabandRoutes {
void RegisterRoutes() {
Route(eHTTPMethod::GET, "/contraband", Perm("reports_view"), "Items players aren't allowed to have",
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "contraband.jinja2", "contraband"); });
Route(eHTTPMethod::GET, "/api/contraband", Perm("reports_view"),
"The contraband list: {items: [{lot, name, reason, action: flag|remove, added_by, added_at}], canManage}",
[](HTTPReply& reply, const HTTPContext& context) {
nlohmann::json items = nlohmann::json::array();
for (const auto& item : Database::Get()->GetContrabandItems()) {
items.push_back({ {"lot", item.lot}, {"name", ClientAssets::ItemName(item.lot)}, {"reason", item.reason}, {"action", ActionName(item.action)},
{"added_by", item.addedBy}, {"added_at", item.addedAt} });
}
JsonSuccess(reply, { {"items", items}, {"canManage", Can(context, "contraband_manage")} });
});
Route(eHTTPMethod::GET, "/api/contraband/items", Perm("contraband_manage"), "Search items to add by name or LOT: [{lot, name}]. Query: ?q=",
[](HTTPReply& reply, const HTTPContext& context) {
JsonReply(reply, eHTTPStatusCode::OK, SearchItems(Trimmed(QueryValue(context.queryString, "q"), 64)));
});
Route(eHTTPMethod::POST, "/api/contraband", Perm("contraband_manage"),
"Add an item to the list or change its entry; running worlds pick it up at once. Body: {lot, reason, action: flag|remove}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const auto lot = body->contains("lot") && (*body)["lot"].is_number_integer() ? std::optional<LOT>((*body)["lot"].get<LOT>())
: GeneralUtils::TryParse<LOT>(body->value("lot", ""));
if (!lot || *lot <= 0 || !IsItem(*lot)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick an item from the list or enter its LOT");
const auto action = ParseAction(body->value("action", "flag"));
if (!action) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "action must be flag or remove");
const auto reason = Trimmed(body->value("reason", ""), MAX_REASON);
bool existed = false;
for (const auto& item : Database::Get()->GetContrabandItems()) existed = existed || item.lot == *lot;
Database::Get()->SetContrabandItem({ *lot, reason, *action, context.authenticatedUser, static_cast<int64_t>(std::time(nullptr)) });
const auto name = ClientAssets::ItemName(*lot);
Audit(context, existed ? "contraband_change" : "contraband_add", std::string(existed ? "Changed" : "Added") + " contraband item " + name + " (" +
std::to_string(*lot) + "): " + (*action == eContrabandAction::REMOVE ? "flag and remove" : "flag only") + (reason.empty() ? "" : ", \"" + reason + "\""));
BroadcastTableChanged("contraband");
JsonSuccess(reply, { {"message", name + (existed ? " updated" : " added")}, {"requestId", ReloadWorlds(context.accountId)} });
});
Route(eHTTPMethod::POST, "/api/contraband/delete", Perm("contraband_manage"), "Take an item off the list. Body: {lot}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
const auto lot = body && body->contains("lot") && (*body)["lot"].is_number_integer() ? std::optional<LOT>((*body)["lot"].get<LOT>()) : std::nullopt;
if (!lot) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid lot");
if (!Database::Get()->DeleteContrabandItem(*lot)) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That item isn't on the list");
const auto name = ClientAssets::ItemName(*lot);
Audit(context, "contraband_remove", "Took " + name + " (" + std::to_string(*lot) + ") off the contraband list");
BroadcastTableChanged("contraband");
JsonSuccess(reply, { {"message", name + " is no longer contraband"}, {"requestId", ReloadWorlds(context.accountId)} });
});
}
}

View File

@@ -0,0 +1,11 @@
#pragma once
/**
* The Contraband page: items staff don't want players to have (IContraband). Each has a reason and says whether a
* character holding one is only flagged or also has it removed (see dGame/dUtilities/Contraband.h). Viewing needs
* reports_view, changing the list contraband_manage; every change is audited and running worlds reload the list.
* What was found shows up as economy flags of kind Contraband (Economy page, and the character's related data).
*/
namespace ContrabandRoutes {
void RegisterRoutes();
}

View File

@@ -342,6 +342,10 @@ namespace {
c.Add(Unit(Int(DASHBOARD, "anomaly_item_minimum", "Item spike: at least", "...and more than this many.", "200", 0, std::nullopt), "items"));
c.Add(Bool(DASHBOARD, "economy_duplicate_scan", "Nightly duplicate scan", "Reads every character's inventory.", true));
c.AddSection("Contraband", "Items on the Contraband page are flagged, or removed, when a character loads or receives one. The list itself is edited on that page.");
c.Add(Bool(WORLD, "contraband_ignore_staff", "Don't check staff", "Accounts with a GM level keep listed items and aren't flagged.", true));
c.Add(Bool(WORLD, "contraband_notify_players", "Tell players", "A mail (at login) or a chat message (when received) says which item was removed and why.", true));
c.AddSection("Backups");
c.Add(Format(Text(DASHBOARD, "backup_folder", "Backup folder", "Relative to the server binaries unless absolute.", "backups"), eFormat::PATH));
c.Add(Unit(Int(DASHBOARD, "backup_keep", "Backups to keep", "Older backups are deleted after each new one. 0 keeps them all.", "7", 0, 1000), "backups"));