mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-05 04:13:44 +00:00
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:
@@ -86,6 +86,7 @@ namespace {
|
||||
{ "reports_review_flags", "Economy and map", "Review flags", "Mark economy flags dismissed or actioned", 3 },
|
||||
{ "items_restore", "Economy and map", "Give items back", "Mail a traced item back to a player, or what a character lost since one of its snapshots (with original IDs when they no longer exist)", 8 },
|
||||
{ "reports_run_checks", "Economy and map", "Run economy checks", "Run the anomaly checks by hand", 8 },
|
||||
{ "contraband_manage", "Economy and map", "Contraband list", "Add, change and remove contraband items (flagged, or removed from players, when a character has one)", 8 },
|
||||
|
||||
{ "play_keys_manage", "Server", "Play keys", "Create, edit and delete play keys, and see the key an account used", 8 },
|
||||
{ "client_files", "Server", "Client files", "Browse and download the game client's files", 8 },
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
#include "master/MessageCapture.h"
|
||||
#include "PublicRoutes.h"
|
||||
#include "Showcase.h"
|
||||
#include "ContrabandRoutes.h"
|
||||
#include "FeaturedProperties.h"
|
||||
#include "PasswordRecovery.h"
|
||||
#include "SettingsRoutes.h"
|
||||
@@ -524,6 +525,7 @@ int main(int argc, char** argv) {
|
||||
RegisterPublicRoutes();
|
||||
RegisterShowcaseRoutes();
|
||||
FeaturedProperties::RegisterRoutes();
|
||||
ContrabandRoutes::RegisterRoutes();
|
||||
RegisterPasswordRecoveryRoutes();
|
||||
RegisterWSRoutes();
|
||||
RegisterDashboardRoutes(); // Must be last - catches all unmatched routes
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -52,6 +52,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"PublicRoutes.cpp"
|
||||
"Showcase.cpp"
|
||||
"FeaturedProperties.cpp"
|
||||
"ContrabandRoutes.cpp"
|
||||
"PasswordRecovery.cpp"
|
||||
"PrometheusMetrics.cpp"
|
||||
"../email/SmtpClient.cpp"
|
||||
|
||||
111
dDashboardServer/routes/ContrabandRoutes.cpp
Normal file
111
dDashboardServer/routes/ContrabandRoutes.cpp
Normal 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)} });
|
||||
});
|
||||
}
|
||||
}
|
||||
11
dDashboardServer/routes/ContrabandRoutes.h
Normal file
11
dDashboardServer/routes/ContrabandRoutes.h
Normal 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();
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
76
dDashboardServer/static/js/contraband.js
Normal file
76
dDashboardServer/static/js/contraband.js
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* The Contraband page: the list of items players shouldn't have, and (with contraband_manage) adding, changing and
|
||||
* removing entries. Running worlds reload the list after every change.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var canManage = false;
|
||||
var lotInput = document.getElementById('lotInput');
|
||||
|
||||
function row(i) {
|
||||
var action = i.action === 'remove' ? fmt.badge('Flag and remove', 'danger') : fmt.badge('Flag', 'warning');
|
||||
var buttons = canManage ? '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-edit="' + esc(i.lot) + '">Edit</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-danger" data-remove="' + esc(i.lot) + '">Remove</button>' : '';
|
||||
return '<tr><td><strong>' + esc(i.name) + '</strong> <span class="text-body-secondary small">' + esc(i.lot) + '</span></td>' +
|
||||
'<td>' + esc(i.reason) + '</td><td>' + action + '</td><td class="small">' + esc(i.added_by) + ', ' + esc(fmt.unix(i.added_at)) + '</td>' +
|
||||
'<td class="text-end text-nowrap">' + buttons + '</td></tr>';
|
||||
}
|
||||
|
||||
var items = [];
|
||||
function load() {
|
||||
api.get('/api/contraband').then(function (d) {
|
||||
if (!d.success) return;
|
||||
items = d.items;
|
||||
canManage = d.canManage;
|
||||
document.getElementById('addCard').classList.toggle('d-none', !canManage);
|
||||
document.getElementById('rows').innerHTML = items.map(row).join('') ||
|
||||
'<tr><td colspan="5" class="text-body-secondary">Nothing is contraband.</td></tr>';
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
var timer = null;
|
||||
lotInput.addEventListener('input', function () {
|
||||
clearTimeout(timer);
|
||||
var q = lotInput.value.trim();
|
||||
if (q.length < 2 || /^\d+$/.test(q)) return;
|
||||
timer = setTimeout(function () {
|
||||
api.get('/api/contraband/items?q=' + encodeURIComponent(q)).then(function (found) {
|
||||
document.getElementById('lotSuggestions').innerHTML = (Array.isArray(found) ? found : []).map(function (i) {
|
||||
return '<option value="' + esc(i.lot) + '">' + esc(i.name) + '</option>';
|
||||
}).join('');
|
||||
});
|
||||
}, 250);
|
||||
});
|
||||
|
||||
function done(d) {
|
||||
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
|
||||
toast(d.message, 'success');
|
||||
load();
|
||||
}
|
||||
|
||||
document.getElementById('addForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var lot = parseInt(lotInput.value, 10);
|
||||
if (!(lot > 0)) { toast('Pick an item from the list or enter its LOT', 'warning'); return; }
|
||||
api.post('/api/contraband', { lot: lot, reason: document.getElementById('reasonInput').value, action: document.getElementById('actionInput').value })
|
||||
.then(function (d) { if (d.success) { lotInput.value = ''; document.getElementById('reasonInput').value = ''; } done(d); });
|
||||
});
|
||||
|
||||
document.getElementById('rows').addEventListener('click', function (e) {
|
||||
var edit = e.target.closest('[data-edit]'), remove = e.target.closest('[data-remove]');
|
||||
if (edit) {
|
||||
var item = items.filter(function (i) { return String(i.lot) === edit.dataset.edit; })[0];
|
||||
if (!item) return;
|
||||
lotInput.value = item.lot;
|
||||
document.getElementById('reasonInput').value = item.reason;
|
||||
document.getElementById('actionInput').value = item.action;
|
||||
lotInput.focus();
|
||||
} else if (remove) {
|
||||
if (!confirm('Take this item off the contraband list?')) return;
|
||||
api.post('/api/contraband/delete', { lot: parseInt(remove.dataset.remove, 10) }).then(done);
|
||||
}
|
||||
});
|
||||
|
||||
load();
|
||||
})();
|
||||
@@ -11,7 +11,7 @@
|
||||
// Names come from the server's enums (Labels); only the colours are kept here
|
||||
var METHOD_COLOURS = { 1: 'info', 2: 'secondary', 3: 'success', 4: 'light' };
|
||||
function methodBadge(value) { return fmt.badge(Labels.name('transferMethods', value) || '?', METHOD_COLOURS[value] || 'secondary'); }
|
||||
var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger' };
|
||||
var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger', 4: 'warning', 5: 'danger' };
|
||||
var FLAG_STATUS_COLOURS = { 0: 'danger', 1: 'secondary', 2: 'success' };
|
||||
function flagKindBadge(value) { return fmt.badge(Labels.name('flagKinds', value) || '?', FLAG_KIND_COLOURS[value] || 'secondary'); }
|
||||
function flagStatusBadge(value) { return fmt.badge(Labels.name('flagStatus', value) || '?', FLAG_STATUS_COLOURS[value] || 'secondary'); }
|
||||
|
||||
@@ -1588,7 +1588,7 @@
|
||||
|
||||
// ---- Flags ----
|
||||
|
||||
var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger', 4: 'warning' };
|
||||
var FLAG_KIND_COLOURS = { 1: 'warning', 2: 'info', 3: 'danger', 4: 'warning', 5: 'danger' };
|
||||
var FLAG_STATUS_COLOURS = { 0: 'danger', 1: 'secondary', 2: 'success' };
|
||||
function flagKindBadge(value) { return fmt.badge(Labels.name('flagKinds', value) || '?', FLAG_KIND_COLOURS[value] || 'secondary'); }
|
||||
function flagStatusBadge(value) { return fmt.badge(Labels.name('flagStatus', value) || '?', FLAG_STATUS_COLOURS[value] || 'secondary'); }
|
||||
|
||||
42
dDashboardServer/templates/contraband.jinja2
Normal file
42
dDashboardServer/templates/contraband.jinja2
Normal file
@@ -0,0 +1,42 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}Contraband - DarkflameServer{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="contrabandPage">
|
||||
<div class="mb-3">
|
||||
<h2 class="mb-1">Contraband</h2>
|
||||
<p class="text-body-secondary mb-0">Items players shouldn't have. World servers check every inventory (vault included) when a character loads,
|
||||
and every item a player receives. <strong>Flag</strong> adds an economy flag for staff to review; <strong>Remove</strong> flags it and takes the
|
||||
item away (a snapshot of the character is kept first, so it can be given back from the character page). Staff accounts aren't checked unless
|
||||
<code>contraband_ignore_staff</code> is off. Running worlds pick up changes straight away.
|
||||
Flags found: <a href="/reports#flags">Economy & Map, Flags</a>.</p>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3 d-none" id="addCard">
|
||||
<div class="card-body">
|
||||
<form class="row g-2 align-items-end" id="addForm">
|
||||
<div class="col-md-4"><label class="form-label small mb-1" for="lotInput">Item</label>
|
||||
<input class="form-control form-control-sm" id="lotInput" list="lotSuggestions" required autocomplete="off" placeholder="Name or LOT">
|
||||
<datalist id="lotSuggestions"></datalist></div>
|
||||
<div class="col-md-4"><label class="form-label small mb-1" for="reasonInput">Reason</label>
|
||||
<input class="form-control form-control-sm" id="reasonInput" maxlength="300" placeholder="Shown to staff, and to the player if it's removed"></div>
|
||||
<div class="col-md-2"><label class="form-label small mb-1" for="actionInput">When found</label>
|
||||
<select class="form-select form-select-sm" id="actionInput"><option value="flag">Flag</option><option value="remove">Flag and remove</option></select></div>
|
||||
<div class="col-md-2"><button type="submit" class="btn btn-sm btn-primary w-100">Save</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<table class="table table-sm align-middle mb-0 table-stack">
|
||||
<thead><tr><th>Item</th><th>Reason</th><th>When found</th><th>Added</th><th></th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/contraband.js"></script>
|
||||
{% endblock %}
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set worldPages = ["world3d", "reports"] %}
|
||||
{% set worldPages = ["world3d", "reports", "contraband"] %}
|
||||
{% if can.players_view or can.reports_view %}
|
||||
<div class="nav-group">
|
||||
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-world" aria-expanded="{% if current_page in worldPages %}true{% else %}false{% endif %}" aria-controls="nav-world">
|
||||
@@ -58,6 +58,7 @@
|
||||
<div class="list-group list-group-flush">
|
||||
{% if can.players_view %}<a href="/world3d" class="list-group-item list-group-item-action{% if current_page == "world3d" %} active{% endif %}">World 3D</a>{% endif %}
|
||||
{% if can.reports_view %}<a href="/reports" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center{% if current_page == "reports" %} active{% endif %}">Economy & Map <span class="badge rounded-pill text-bg-danger d-none" data-badge="reports" title="Open economy flags"></span></a>{% endif %}
|
||||
{% if can.reports_view %}<a href="/contraband" class="list-group-item list-group-item-action{% if current_page == "contraband" %} active{% endif %}">Contraband</a>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
#include "ILiveOps.h"
|
||||
#include "IFeaturedProperties.h"
|
||||
#include "IMessageCaptures.h"
|
||||
#include "IContraband.h"
|
||||
|
||||
#ifdef _DEBUG
|
||||
# define DLU_SQL_TRY_CATCH_RETHROW(x) do { try { x; } catch (std::exception& ex) { LOG("SQL Error: %s", ex.what()); throw; } } while(0)
|
||||
@@ -60,7 +61,7 @@ class GameDatabase :
|
||||
public IPropertyContents, public IProperty, public IPetNames, public ICharXml,
|
||||
public IMigrationHistory, public IUgc, public IFriends, public ICharInfo,
|
||||
public IAccounts, public IActivityLog, public IAccountsRewardCodes, public IIgnoreList,
|
||||
public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures {
|
||||
public IBehaviors, public IUgcModularBuild, public IAuditLog, public IDashboardStats, public IAccountEmails, public IDashboardMaintenance, public IEconomyLedger, public IDashboardAdmin, public IServerConfig, public IScheduledTasks, public ICharacterSnapshots, public IAccountNotes, public IServerHealth, public IRelatedData, public IChatLog, public IAccountStrikes, public ISlashCommands, public IModeration, public IServerOperations, public IPlayerPositions, public IAiSuggestions, public ILiveOps, public IFeaturedProperties, public IMessageCaptures, public IContraband {
|
||||
public:
|
||||
virtual ~GameDatabase() = default;
|
||||
// TODO: These should be made private.
|
||||
|
||||
40
dDatabase/GameDatabase/ITables/IContraband.h
Normal file
40
dDatabase/GameDatabase/ITables/IContraband.h
Normal file
@@ -0,0 +1,40 @@
|
||||
#ifndef __ICONTRABAND__H__
|
||||
#define __ICONTRABAND__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
|
||||
/**
|
||||
* Contraband: items staff don't want players to have (issue #1563), edited on the dashboard. World servers check a
|
||||
* character's items against the list when it loads and when items are added, and flag (an economy flag of kind
|
||||
* CONTRABAND) or also remove them.
|
||||
*/
|
||||
class IContraband {
|
||||
public:
|
||||
enum class eContrabandAction : uint8_t {
|
||||
FLAG = 0, // only flag the character
|
||||
REMOVE = 1 // flag the character and remove the item
|
||||
};
|
||||
|
||||
struct ContrabandItem {
|
||||
LOT lot{};
|
||||
std::string reason;
|
||||
eContrabandAction action{};
|
||||
std::string addedBy;
|
||||
int64_t addedAt{};
|
||||
};
|
||||
|
||||
// Every listed item, by LOT
|
||||
virtual std::vector<ContrabandItem> GetContrabandItems() = 0;
|
||||
|
||||
// Insert or replace the row of item.lot
|
||||
virtual void SetContrabandItem(const ContrabandItem& item) = 0;
|
||||
|
||||
// Whether there was a row to delete
|
||||
virtual bool DeleteContrabandItem(LOT lot) = 0;
|
||||
};
|
||||
|
||||
#endif //!__ICONTRABAND__H__
|
||||
@@ -58,7 +58,10 @@ public:
|
||||
DUPLICATE = 3, // an object id exists in more than one place (day 0, so each item is flagged once)
|
||||
// different items share an object id and the login migration will not separate them: the characters holding
|
||||
// them already migrated, or a copy is in mail (day 0)
|
||||
ID_COLLISION = 4
|
||||
ID_COLLISION = 4,
|
||||
// a character has an item on the contraband list (value: how many, baseline: 1 when it was removed). Found when
|
||||
// the character loaded (day 0, one flag per item) or when the item was added (that day, item id 0)
|
||||
CONTRABAND = 5
|
||||
};
|
||||
|
||||
enum class eFlagStatus : uint8_t { OPEN = 0, DISMISSED = 1, ACTIONED = 2 };
|
||||
|
||||
@@ -215,6 +215,11 @@ public:
|
||||
std::vector<ZoneLimit> GetZoneLimits() override;
|
||||
void SetZoneLimit(const ZoneLimit& limit) override;
|
||||
void DeleteZoneLimit(uint32_t zoneId) override;
|
||||
// IContraband
|
||||
std::vector<ContrabandItem> GetContrabandItems() override;
|
||||
void SetContrabandItem(const ContrabandItem& item) override;
|
||||
bool DeleteContrabandItem(LOT lot) override;
|
||||
|
||||
// IFeaturedProperties
|
||||
std::vector<FeaturedSlot> GetFeaturedPropertySlots() override;
|
||||
void SetFeaturedPropertySlot(const FeaturedSlot& slot) override;
|
||||
|
||||
@@ -18,6 +18,7 @@ set(DDATABASES_DATABASES_MYSQL_TABLES_SOURCES
|
||||
"ServerOperations.cpp"
|
||||
"LiveOps.cpp"
|
||||
"FeaturedProperties.cpp"
|
||||
"Contraband.cpp"
|
||||
"MessageCaptures.cpp"
|
||||
"ChatLog.cpp"
|
||||
"RelatedData.cpp"
|
||||
|
||||
22
dDatabase/GameDatabase/MySQL/Tables/Contraband.cpp
Normal file
22
dDatabase/GameDatabase/MySQL/Tables/Contraband.cpp
Normal file
@@ -0,0 +1,22 @@
|
||||
#include "MySQLDatabase.h"
|
||||
|
||||
std::vector<IContraband::ContrabandItem> MySQLDatabase::GetContrabandItems() {
|
||||
std::vector<ContrabandItem> items;
|
||||
auto result = ExecuteSelect("SELECT * FROM contraband_items ORDER BY lot;");
|
||||
while (result->next()) {
|
||||
items.push_back({ result->getInt("lot"), result->getString("reason").c_str(), static_cast<eContrabandAction>(result->getUInt("action")),
|
||||
result->getString("added_by").c_str(), result->getInt64("added_at") });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
void MySQLDatabase::SetContrabandItem(const ContrabandItem& item) {
|
||||
ExecuteInsert(
|
||||
"INSERT INTO contraband_items (lot, reason, action, added_by, added_at) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON DUPLICATE KEY UPDATE reason = VALUES(reason), action = VALUES(action), added_by = VALUES(added_by), added_at = VALUES(added_at);",
|
||||
item.lot, item.reason, static_cast<uint32_t>(item.action), item.addedBy, item.addedAt);
|
||||
}
|
||||
|
||||
bool MySQLDatabase::DeleteContrabandItem(LOT lot) {
|
||||
return ExecuteUpdate("DELETE FROM contraband_items WHERE lot = ?;", lot) > 0;
|
||||
}
|
||||
@@ -199,6 +199,11 @@ public:
|
||||
std::vector<ZoneLimit> GetZoneLimits() override;
|
||||
void SetZoneLimit(const ZoneLimit& limit) override;
|
||||
void DeleteZoneLimit(uint32_t zoneId) override;
|
||||
// IContraband
|
||||
std::vector<ContrabandItem> GetContrabandItems() override;
|
||||
void SetContrabandItem(const ContrabandItem& item) override;
|
||||
bool DeleteContrabandItem(LOT lot) override;
|
||||
|
||||
// IFeaturedProperties
|
||||
std::vector<FeaturedSlot> GetFeaturedPropertySlots() override;
|
||||
void SetFeaturedPropertySlot(const FeaturedSlot& slot) override;
|
||||
|
||||
@@ -18,6 +18,7 @@ set(DDATABASES_DATABASES_SQLITE_TABLES_SOURCES
|
||||
"ServerOperations.cpp"
|
||||
"LiveOps.cpp"
|
||||
"FeaturedProperties.cpp"
|
||||
"Contraband.cpp"
|
||||
"MessageCaptures.cpp"
|
||||
"ChatLog.cpp"
|
||||
"RelatedData.cpp"
|
||||
|
||||
22
dDatabase/GameDatabase/SQLite/Tables/Contraband.cpp
Normal file
22
dDatabase/GameDatabase/SQLite/Tables/Contraband.cpp
Normal file
@@ -0,0 +1,22 @@
|
||||
#include "SQLiteDatabase.h"
|
||||
|
||||
std::vector<IContraband::ContrabandItem> SQLiteDatabase::GetContrabandItems() {
|
||||
std::vector<ContrabandItem> items;
|
||||
auto [_, result] = ExecuteSelect("SELECT * FROM contraband_items ORDER BY lot;");
|
||||
for (; !result.eof(); result.nextRow()) {
|
||||
items.push_back({ result.getIntField("lot"), result.getStringField("reason"), static_cast<eContrabandAction>(result.getIntField("action")),
|
||||
result.getStringField("added_by"), result.getInt64Field("added_at") });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
void SQLiteDatabase::SetContrabandItem(const ContrabandItem& item) {
|
||||
ExecuteInsert(
|
||||
"INSERT INTO contraband_items (lot, reason, action, added_by, added_at) VALUES (?, ?, ?, ?, ?) "
|
||||
"ON CONFLICT(lot) DO UPDATE SET reason = excluded.reason, action = excluded.action, added_by = excluded.added_by, added_at = excluded.added_at;",
|
||||
item.lot, item.reason, static_cast<uint32_t>(item.action), item.addedBy, item.addedAt);
|
||||
}
|
||||
|
||||
bool SQLiteDatabase::DeleteContrabandItem(LOT lot) {
|
||||
return ExecuteUpdate("DELETE FROM contraband_items WHERE lot = ?;", lot) > 0;
|
||||
}
|
||||
@@ -245,6 +245,11 @@ class TestSQLDatabase : public GameDatabase {
|
||||
std::vector<ZoneLimit> GetZoneLimits() override { return {}; }
|
||||
void SetZoneLimit(const ZoneLimit& limit) override {}
|
||||
void DeleteZoneLimit(uint32_t zoneId) override {}
|
||||
// IContraband
|
||||
std::vector<ContrabandItem> GetContrabandItems() override { return {}; }
|
||||
void SetContrabandItem(const ContrabandItem& item) override {}
|
||||
bool DeleteContrabandItem(LOT lot) override { return false; }
|
||||
|
||||
// IFeaturedProperties
|
||||
std::vector<FeaturedSlot> GetFeaturedPropertySlots() override { return {}; }
|
||||
void SetFeaturedPropertySlot(const FeaturedSlot& slot) override {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "InventoryComponent.h"
|
||||
#include "Contraband.h"
|
||||
#include "EconomyLedger.h"
|
||||
|
||||
#include <sstream>
|
||||
@@ -204,6 +205,8 @@ void InventoryComponent::AddItem(
|
||||
inventoryType = Inventory::FindInventoryTypeForLot(lot);
|
||||
}
|
||||
|
||||
Contraband::OnItemAdded(m_Parent, lot, count, lootSourceType, inventorySourceType);
|
||||
|
||||
auto* missions = static_cast<MissionComponent*>(this->m_Parent->GetComponent(eReplicaComponentType::MISSION));
|
||||
|
||||
auto* inventory = GetInventory(inventoryType);
|
||||
@@ -336,6 +339,7 @@ ReceivedItem InventoryComponent::ReceiveItem(const LWOOBJID id, const LOT lot, c
|
||||
addItem.showFlyingLoot = options.showFlyingLoot && !options.equip;
|
||||
addItem.SendToClient(m_Parent->GetSystemAddress());
|
||||
EconomyLedger::RecordItems(m_Parent, lot, count, static_cast<uint32_t>(lootSourceType));
|
||||
Contraband::OnItemAdded(m_Parent, lot, count, lootSourceType, options.sourceInventory);
|
||||
if (options.equip) {
|
||||
item->Equip();
|
||||
Game::entityManager->SerializeEntity(m_Parent);
|
||||
|
||||
@@ -6,6 +6,7 @@ set(DGAME_DUTILITIES_SOURCES "BrickDatabase.cpp"
|
||||
"DashboardNotify.cpp"
|
||||
"GUID.cpp"
|
||||
"LiveEvents.cpp"
|
||||
"Contraband.cpp"
|
||||
"Loot.cpp"
|
||||
"Mail.cpp"
|
||||
"MessageInspector.cpp"
|
||||
|
||||
199
dGame/dUtilities/Contraband.cpp
Normal file
199
dGame/dUtilities/Contraband.cpp
Normal file
@@ -0,0 +1,199 @@
|
||||
#include "Contraband.h"
|
||||
|
||||
#include <ctime>
|
||||
#include <optional>
|
||||
|
||||
#include "magic_enum.hpp"
|
||||
|
||||
#include "Character.h"
|
||||
#include "ChatPackets.h"
|
||||
#include "Database.h"
|
||||
#include "dConfig.h"
|
||||
#include "dServer.h"
|
||||
#include "eGameMasterLevel.h"
|
||||
#include "EconomyLedger.h"
|
||||
#include "Entity.h"
|
||||
#include "Game.h"
|
||||
#include "GeneralUtils.h"
|
||||
#include "Inventory.h"
|
||||
#include "InventoryComponent.h"
|
||||
#include "Item.h"
|
||||
#include "Logger.h"
|
||||
#include "Mail.h"
|
||||
#include "User.h"
|
||||
#include "ZCompression.h"
|
||||
|
||||
namespace {
|
||||
std::optional<Contraband::List> g_List;
|
||||
|
||||
constexpr const char* ACTOR = "World server";
|
||||
|
||||
bool IgnoreStaff() {
|
||||
return !Game::config || Game::config->GetValue("contraband_ignore_staff") != "0";
|
||||
}
|
||||
|
||||
bool NotifyPlayers() {
|
||||
return !Game::config || Game::config->GetValue("contraband_notify_players") != "0";
|
||||
}
|
||||
|
||||
eGameMasterLevel AccountLevel(const Entity* player) {
|
||||
const auto* character = player ? player->GetCharacter() : nullptr;
|
||||
const auto* user = character ? character->GetParentUser() : nullptr;
|
||||
return user ? user->GetMaxGMLevel() : eGameMasterLevel::CIVILIAN;
|
||||
}
|
||||
|
||||
std::string Compress(const std::string& data) {
|
||||
std::string out(ZCompression::GetMaxCompressedLength(static_cast<uint32_t>(data.size())), '\0');
|
||||
const auto size = ZCompression::Compress(reinterpret_cast<const uint8_t*>(data.data()), static_cast<uint32_t>(data.size()),
|
||||
reinterpret_cast<uint8_t*>(out.data()), static_cast<uint32_t>(out.size()));
|
||||
out.resize(size > 0 ? static_cast<size_t>(size) : 0);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string Where() {
|
||||
if (!Game::server) return "";
|
||||
return " (zone " + std::to_string(Game::server->GetZoneID()) + " instance " + std::to_string(Game::server->GetInstanceID()) + ")";
|
||||
}
|
||||
|
||||
// Every item a player has (not the proxy items of sets), across every inventory
|
||||
std::vector<Contraband::HeldItem> HeldItems(InventoryComponent& inventory) {
|
||||
std::vector<Contraband::HeldItem> items;
|
||||
for (const auto& [type, inv] : inventory.GetInventories()) {
|
||||
if (!inv) continue;
|
||||
for (const auto& [id, item] : inv->GetItems()) {
|
||||
if (!item || item->GetParent() != LWOOBJID_EMPTY) continue;
|
||||
items.push_back({ id, item->GetLot(), item->GetCount(), type });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// Remove every item of a LOT (all inventories); how many were removed
|
||||
uint32_t RemoveLot(InventoryComponent& inventory, LOT lot, bool silent) {
|
||||
uint32_t removed = 0;
|
||||
for (const auto& [type, inv] : inventory.GetInventories()) {
|
||||
if (!inv) continue;
|
||||
std::vector<Item*> items;
|
||||
for (const auto& [id, item] : inv->GetItems()) if (item && item->GetLot() == lot && item->GetParent() == LWOOBJID_EMPTY) items.push_back(item);
|
||||
for (auto* item : items) {
|
||||
removed += item->GetCount();
|
||||
item->SetCount(0, silent, false, false, eLootSourceType::MODERATION);
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
namespace Contraband {
|
||||
std::vector<Finding> Find(const std::vector<HeldItem>& items, const List& list) {
|
||||
std::vector<Finding> found;
|
||||
if (list.empty()) return found;
|
||||
for (const auto& item : items) {
|
||||
const auto it = list.find(item.lot);
|
||||
if (it != list.end() && item.count > 0) found.push_back({ item, it->second });
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff) {
|
||||
return !ignoreStaff || accountLevel <= eGameMasterLevel::CIVILIAN;
|
||||
}
|
||||
|
||||
bool CountsAsAdded(eLootSourceType source, eInventoryType sourceInventory) {
|
||||
return sourceInventory == eInventoryType::INVALID && source != eLootSourceType::RELOCATE && source != eLootSourceType::INVENTORY;
|
||||
}
|
||||
|
||||
uint32_t Reload() {
|
||||
List list;
|
||||
for (const auto& item : Database::Get()->GetContrabandItems()) list[item.lot] = { item.reason, item.action };
|
||||
LOG("Loaded %zu contraband item(s)", list.size());
|
||||
g_List = std::move(list);
|
||||
return 1;
|
||||
}
|
||||
|
||||
const List& Get() {
|
||||
if (!g_List) Reload();
|
||||
return *g_List;
|
||||
}
|
||||
|
||||
void CheckOnLoad(Entity* player) {
|
||||
auto* character = player ? player->GetCharacter() : nullptr;
|
||||
auto* inventory = player ? player->GetComponent<InventoryComponent>() : nullptr;
|
||||
if (!character || !inventory || Get().empty() || !Applies(AccountLevel(player), IgnoreStaff())) return;
|
||||
|
||||
const auto findings = Find(HeldItems(*inventory), Get());
|
||||
if (findings.empty()) return;
|
||||
|
||||
const auto characterId = character->GetID();
|
||||
const auto accountId = character->GetParentUser() ? character->GetParentUser()->GetAccountID() : 0;
|
||||
bool snapshotTaken = false;
|
||||
std::vector<std::string> removedNames;
|
||||
for (const auto& finding : findings) {
|
||||
const bool remove = finding.entry.action == IContraband::eContrabandAction::REMOVE;
|
||||
const auto where = std::string(magic_enum::enum_name(finding.item.inventory));
|
||||
Database::Get()->InsertEconomyFlag({ 0, IDashboardAdmin::eFlagKind::CONTRABAND, characterId, finding.item.lot, finding.item.id,
|
||||
finding.item.count, remove ? 1 : 0, (remove ? "Removed at login from " : "Found at login in ") + where + ": " + finding.entry.reason });
|
||||
if (!remove) continue;
|
||||
|
||||
// Keep the character as it was, so staff can give the items back from the character page
|
||||
if (!snapshotTaken) {
|
||||
const auto& xml = character->GetXMLData();
|
||||
Database::Get()->InsertCharacterSnapshot({ 0, characterId, static_cast<int64_t>(std::time(nullptr)), "before contraband removal", ACTOR,
|
||||
static_cast<uint32_t>(xml.size()), "", Compress(xml) });
|
||||
snapshotTaken = true;
|
||||
}
|
||||
auto* item = inventory->FindItemById(finding.item.id);
|
||||
if (!item) continue;
|
||||
// Silent: the character hasn't been sent to the client yet
|
||||
item->SetCount(0, true, false, false, eLootSourceType::MODERATION);
|
||||
removedNames.push_back(std::to_string(finding.item.count) + "x " + std::to_string(finding.item.lot));
|
||||
Database::Get()->InsertAuditLog(0, ACTOR, "contraband_removed", character->GetName() + ": removed " + std::to_string(finding.item.count) +
|
||||
" of item " + std::to_string(finding.item.lot) + " (id " + std::to_string(finding.item.id) + ", " + where + ") at login" + Where() + ": " + finding.entry.reason,
|
||||
accountId, characterId);
|
||||
}
|
||||
LOG("Character %llu:%s has %zu contraband item stack(s), %zu removed", characterId, character->GetName().c_str(), findings.size(), removedNames.size());
|
||||
|
||||
if (!removedNames.empty() && NotifyPlayers()) {
|
||||
std::string body = "Items that are not allowed on this server were removed from your character:";
|
||||
for (const auto& finding : findings) {
|
||||
if (finding.entry.action != IContraband::eContrabandAction::REMOVE) continue;
|
||||
body += "\n- " + std::to_string(finding.item.count) + " x item " + std::to_string(finding.item.lot) + (finding.entry.reason.empty() ? "" : ": " + finding.entry.reason);
|
||||
}
|
||||
body += "\nIf you think this is a mistake, contact the server's staff.";
|
||||
Mail::SendMail(player, "Items removed", body, LOT_NULL, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void OnItemAdded(Entity* owner, LOT lot, uint32_t count, eLootSourceType source, eInventoryType sourceInventory) {
|
||||
if (!owner || !owner->IsPlayer() || count == 0) return;
|
||||
const auto& list = Get();
|
||||
const auto it = list.find(lot);
|
||||
if (it == list.end() || !CountsAsAdded(source, sourceInventory) || !Applies(AccountLevel(owner), IgnoreStaff())) return;
|
||||
|
||||
auto* character = owner->GetCharacter();
|
||||
if (!character) return;
|
||||
const auto entry = it->second;
|
||||
const bool remove = entry.action == IContraband::eContrabandAction::REMOVE;
|
||||
const auto sourceName = std::string(magic_enum::enum_name(source));
|
||||
// One flag per character, item and day for items that arrive (the load check flags each item on its own)
|
||||
Database::Get()->InsertEconomyFlag({ EconomyLedger::Today(), IDashboardAdmin::eFlagKind::CONTRABAND, character->GetID(), lot, 0,
|
||||
count, remove ? 1 : 0, std::string(remove ? "Removed when received" : "Received") + " (" + sourceName + ")" + Where() + ": " + entry.reason });
|
||||
if (!remove) return;
|
||||
|
||||
// Take it away once the add (and whatever trade, mail or loot code called it) has finished
|
||||
owner->AddCallbackTimer(0.0f, [owner, lot, entry]() {
|
||||
auto* inventory = owner->GetComponent<InventoryComponent>();
|
||||
auto* character = owner->GetCharacter();
|
||||
if (!inventory || !character) return;
|
||||
const auto removed = RemoveLot(*inventory, lot, false);
|
||||
if (removed == 0) return;
|
||||
const auto accountId = character->GetParentUser() ? character->GetParentUser()->GetAccountID() : 0;
|
||||
Database::Get()->InsertAuditLog(0, ACTOR, "contraband_removed", character->GetName() + ": removed " + std::to_string(removed) + " of item " +
|
||||
std::to_string(lot) + " right after receiving it" + Where() + ": " + entry.reason, accountId, character->GetID());
|
||||
if (NotifyPlayers()) {
|
||||
ChatPackets::SendSystemMessage(owner->GetSystemAddress(), "An item you received (" + std::to_string(lot) + ") is not allowed on this server and was removed" +
|
||||
(entry.reason.empty() ? "." : ": " + entry.reason));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
77
dGame/dUtilities/Contraband.h
Normal file
77
dGame/dUtilities/Contraband.h
Normal file
@@ -0,0 +1,77 @@
|
||||
#ifndef __CONTRABAND__H__
|
||||
#define __CONTRABAND__H__
|
||||
|
||||
#include <cstdint>
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
#include "eInventoryType.h"
|
||||
#include "eLootSourceType.h"
|
||||
#include "IContraband.h"
|
||||
|
||||
class Entity;
|
||||
enum class eGameMasterLevel : uint8_t;
|
||||
|
||||
/**
|
||||
* Contraband (issue #1563): items staff don't want players to have, listed on the dashboard's Contraband page
|
||||
* (IContraband). This world loads the list when it is first needed and again when the dashboard changes it
|
||||
* (ePlayerAction::RELOAD_CONTRABAND).
|
||||
*
|
||||
* - When a character loads (before it is sent to the client), every inventory (vault included) is checked. Each
|
||||
* listed item is flagged (an economy flag of kind CONTRABAND, once per item). Items whose entry says so are removed
|
||||
* too: a snapshot of the character is kept first (so the dashboard's "Give back lost items" can return them), the
|
||||
* removal is audited and the player gets a mail saying why.
|
||||
* - When a listed item is added (loot, trade, mail, vendors, ...), the character is flagged for that item for the
|
||||
* day, and an item to remove is removed right after it arrives, with a chat message saying why.
|
||||
*
|
||||
* Staff accounts (GM level above civilian) are not checked unless contraband_ignore_staff is 0.
|
||||
*/
|
||||
namespace Contraband {
|
||||
struct Entry {
|
||||
std::string reason;
|
||||
IContraband::eContrabandAction action{};
|
||||
};
|
||||
|
||||
using List = std::map<LOT, Entry>;
|
||||
|
||||
struct HeldItem {
|
||||
LWOOBJID id{};
|
||||
LOT lot{};
|
||||
uint32_t count{};
|
||||
eInventoryType inventory{};
|
||||
};
|
||||
|
||||
struct Finding {
|
||||
HeldItem item;
|
||||
Entry entry;
|
||||
};
|
||||
|
||||
// ---- Pure rules, unit tested ----
|
||||
|
||||
// The held items that are on the list, in the order given
|
||||
std::vector<Finding> Find(const std::vector<HeldItem>& items, const List& list);
|
||||
|
||||
// Whether an account at this GM level is checked
|
||||
bool Applies(eGameMasterLevel accountLevel, bool ignoreStaff);
|
||||
|
||||
// Whether an added item should be checked: moves between a player's own inventories are not new items
|
||||
bool CountsAsAdded(eLootSourceType source, eInventoryType sourceInventory);
|
||||
|
||||
// ---- This world ----
|
||||
|
||||
// Load the list again from the database. Returns 1 (one world reloaded).
|
||||
uint32_t Reload();
|
||||
|
||||
// The list as loaded (loads it the first time)
|
||||
const List& Get();
|
||||
|
||||
// Check a player's inventories when their character loads, before it is sent to the client
|
||||
void CheckOnLoad(Entity* player);
|
||||
|
||||
// A listed item was added to a player's inventory (called for every add; cheap when the LOT isn't listed)
|
||||
void OnItemAdded(Entity* owner, LOT lot, uint32_t count, eLootSourceType source, eInventoryType sourceInventory);
|
||||
}
|
||||
|
||||
#endif //!__CONTRABAND__H__
|
||||
@@ -29,6 +29,7 @@
|
||||
#include "dChatFilter.h"
|
||||
#include "MissionComponent.h"
|
||||
#include "Mission.h"
|
||||
#include "Contraband.h"
|
||||
#include "LiveEvents.h"
|
||||
|
||||
namespace {
|
||||
@@ -293,6 +294,8 @@ uint32_t DashboardActions::Apply(const PlayerActionRequest& request) {
|
||||
return ChangeMission(request.action, request.characterId, static_cast<uint32_t>(request.targetId), request.approved, request.text);
|
||||
case ePlayerAction::RELOAD_LIVE_OPS:
|
||||
return LiveEvents::Reload();
|
||||
case ePlayerAction::RELOAD_CONTRABAND:
|
||||
return Contraband::Reload();
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ enum class ePlayerAction : uint8_t {
|
||||
MISSION_RESET, // as /resetmission
|
||||
MISSION_ACCEPT, // as /addmission (prerequisites are skipped)
|
||||
RELOAD_LIVE_OPS, // load the running live events and open challenges again (every world, answering 1 each; see LiveEvents.h)
|
||||
RELOAD_CONTRABAND, // load the contraband list again (every world, answering 1 each; see Contraband.h)
|
||||
};
|
||||
|
||||
struct PlayerActionRequest : public LUBitStream {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "master/PlayerAction.h"
|
||||
#include "master/MessageCapture.h"
|
||||
#include "MessageInspector.h"
|
||||
#include "Contraband.h"
|
||||
#include "LiveEvents.h"
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
@@ -952,6 +953,9 @@ void LoadPlayer(const SystemAddress& sysAddr) {
|
||||
}
|
||||
}
|
||||
|
||||
// Contraband is flagged (and removed if its entry says so) before the character is saved and sent
|
||||
Contraband::CheckOnLoad(player);
|
||||
|
||||
// Update the characters xml to ensure the update above is not only saved, but so the client picks up on the changes.
|
||||
c->SaveXMLToDatabase();
|
||||
|
||||
|
||||
@@ -1015,6 +1015,25 @@ only for collisions that logging in will not fix (both characters already migrat
|
||||
duplicate (the same item twice) is still flagged even when one copy is on an old save: the login gives it a new ID but
|
||||
keeps both copies, so check it before then.
|
||||
|
||||
### Contraband
|
||||
|
||||
The **Contraband** page (World & Economy; viewing needs `reports_view`, changing the list `contraband_manage`, GM 8)
|
||||
lists items players shouldn't have. Pick an item with the item search (or type its LOT), give a reason and choose
|
||||
what happens when a character has one:
|
||||
|
||||
- **Flag**: an economy flag of kind *Contraband* is added (Economy & Map, Flags; and the character's related data),
|
||||
to review like any other flag.
|
||||
- **Flag and remove**: the same flag, and the item is taken away.
|
||||
|
||||
World servers check every inventory, the vault included, when a character loads (one flag per item), and every
|
||||
item a player receives from loot, trades, mail, vendors and so on (one flag per character, item and day). Moving an
|
||||
item between a player's own inventories doesn't count. Before removing items at login the world keeps a snapshot of
|
||||
the character (reason "before contraband removal"), so **Give back lost items** on the character page can return
|
||||
them if an item was listed by mistake; every removal is in the audit log as `contraband_removed`. Players get a mail
|
||||
(at login) or a chat message (when received) saying what was removed and why, unless `contraband_notify_players` is
|
||||
off. Staff accounts (GM level above 0) aren't checked unless `contraband_ignore_staff` is off. Every change to the
|
||||
list is audited and running worlds load it again straight away.
|
||||
|
||||
### Saved views and report emails
|
||||
|
||||
On the Economy page, **Views** saves the current tab, range, staff toggle and item filter under a name, so you can go
|
||||
|
||||
9
migrations/dlu/mysql/77_contraband.sql
Normal file
9
migrations/dlu/mysql/77_contraband.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
/* Contraband: items staff don't want players to have, edited on the dashboard's Contraband page. action: 0 = flag
|
||||
(an economy flag of kind 5 when a character has one), 1 = flag and remove the item. */
|
||||
CREATE TABLE IF NOT EXISTS contraband_items (
|
||||
lot INT NOT NULL PRIMARY KEY,
|
||||
reason TEXT NOT NULL,
|
||||
action TINYINT NOT NULL DEFAULT 0,
|
||||
added_by VARCHAR(64) NOT NULL DEFAULT '',
|
||||
added_at BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
8
migrations/dlu/sqlite/60_contraband.sql
Normal file
8
migrations/dlu/sqlite/60_contraband.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
/* contraband_items: see the MySQL migration. */
|
||||
CREATE TABLE IF NOT EXISTS contraband_items (
|
||||
lot INTEGER NOT NULL PRIMARY KEY,
|
||||
reason TEXT NOT NULL,
|
||||
action INTEGER NOT NULL DEFAULT 0,
|
||||
added_by TEXT NOT NULL DEFAULT '',
|
||||
added_at BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -99,6 +99,7 @@ NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::Challenge, id, title, description,
|
||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::ChallengeTotal, total, contributors);
|
||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(ILiveOps::Reward, challengeId, characterId, amount, coins, rewardedAt, claimedAt);
|
||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IFeaturedProperties::FeaturedSlot, templateId, mode, propertyId, updatedAt, updatedBy, zoneId);
|
||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IContraband::ContrabandItem, lot, reason, action, addedBy, addedAt);
|
||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IFeaturedProperties::FeaturedSettings, fullAuto, updatedAt, updatedBy);
|
||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IMessageCaptures::MessageCaptureSession, id, characterId, characterName, accountId, accountName, startedById, startedBy, startedAt, endsAt, endedAt, endReason, toServer, toClient, onlyMessages, skipMessages, zoneId, instanceId, cloneId, zones, messageCount, byteCount, dropped);
|
||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(IMessageCaptures::MessageCaptureRecord, sessionId, seq, timeMs, direction, messageId, objectId, bits, droppedBefore, zoneId, instanceId, cloneId, payload, decoded);
|
||||
@@ -1395,6 +1396,22 @@ TEST_F(ParitySeeded, FeaturedProperties) {
|
||||
});
|
||||
}
|
||||
|
||||
TEST_F(ParitySeeded, Contraband) {
|
||||
Both("GetContrabandItems empty", [](GameDatabase& db) { return db.GetContrabandItems(); });
|
||||
Both("SetContrabandItem", [](GameDatabase& db) {
|
||||
db.SetContrabandItem({ 14128, "Atlantis Squid Helm", IContraband::eContrabandAction::REMOVE, "admin", 1700000000 });
|
||||
db.SetContrabandItem({ 6655, "Stig's Helmet", IContraband::eContrabandAction::FLAG, "admin", 1700000001 });
|
||||
db.SetContrabandItem({ 6655, "changed", IContraband::eContrabandAction::REMOVE, "mod", 1700000002 });
|
||||
return db.GetContrabandItems();
|
||||
});
|
||||
Both("DeleteContrabandItem", [](GameDatabase& db) { return json{ db.DeleteContrabandItem(14128), db.DeleteContrabandItem(14128), db.GetContrabandItems() }; });
|
||||
Both("Contraband economy flag", [](GameDatabase& db) {
|
||||
const bool first = db.InsertEconomyFlag({ 0, IDashboardAdmin::eFlagKind::CONTRABAND, CHAR_BOB, 6655, 1152921510000300001LL, 1, 1, "Removed at login" });
|
||||
const bool again = db.InsertEconomyFlag({ 0, IDashboardAdmin::eFlagKind::CONTRABAND, CHAR_BOB, 6655, 1152921510000300001LL, 1, 1, "Removed at login" });
|
||||
return json{ first, again, db.GetEconomyFlagsFor(CHAR_BOB, 5).size() };
|
||||
});
|
||||
}
|
||||
|
||||
TEST_F(ParitySeeded, MessageCaptures) {
|
||||
using Query = IMessageCaptures::SessionQuery;
|
||||
using eOrder = IMessageCaptures::eSessionOrder;
|
||||
|
||||
@@ -6,6 +6,7 @@ set(DGAMETEST_SOURCES
|
||||
"PlayerReportsLimitTests.cpp"
|
||||
"SlashCommandPermissionTests.cpp"
|
||||
"StaleSaveGuardTests.cpp"
|
||||
"ContrabandTests.cpp"
|
||||
)
|
||||
|
||||
add_subdirectory(dComponentsTests)
|
||||
|
||||
38
tests/dGameTests/ContrabandTests.cpp
Normal file
38
tests/dGameTests/ContrabandTests.cpp
Normal file
@@ -0,0 +1,38 @@
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include "Contraband.h"
|
||||
#include "eGameMasterLevel.h"
|
||||
|
||||
using Contraband::HeldItem;
|
||||
using eAction = IContraband::eContrabandAction;
|
||||
|
||||
TEST(ContrabandTests, FindsListedItemsOnly) {
|
||||
Contraband::List list{ { 14128, { "Atlantis Squid Helm", eAction::REMOVE } }, { 6655, { "Stig's Helmet", eAction::FLAG } } };
|
||||
const std::vector<HeldItem> items{
|
||||
{ 1, 6086, 1, eInventoryType::ITEMS },
|
||||
{ 2, 14128, 1, eInventoryType::VAULT_ITEMS },
|
||||
{ 3, 6655, 2, eInventoryType::ITEMS },
|
||||
{ 4, 6655, 0, eInventoryType::ITEMS }, // an empty stack is nothing
|
||||
};
|
||||
const auto found = Contraband::Find(items, list);
|
||||
ASSERT_EQ(found.size(), 2u);
|
||||
EXPECT_EQ(found[0].item.id, 2);
|
||||
EXPECT_EQ(found[0].entry.action, eAction::REMOVE);
|
||||
EXPECT_EQ(found[1].item.id, 3);
|
||||
EXPECT_EQ(found[1].entry.reason, "Stig's Helmet");
|
||||
EXPECT_TRUE(Contraband::Find(items, {}).empty());
|
||||
}
|
||||
|
||||
TEST(ContrabandTests, StaffAreSkippedUnlessConfigured) {
|
||||
EXPECT_TRUE(Contraband::Applies(eGameMasterLevel::CIVILIAN, true));
|
||||
EXPECT_FALSE(Contraband::Applies(eGameMasterLevel::MODERATOR, true));
|
||||
EXPECT_TRUE(Contraband::Applies(eGameMasterLevel::MODERATOR, false));
|
||||
}
|
||||
|
||||
TEST(ContrabandTests, OwnInventoryMovesAreNotNewItems) {
|
||||
EXPECT_TRUE(Contraband::CountsAsAdded(eLootSourceType::TRADE, eInventoryType::INVALID));
|
||||
EXPECT_TRUE(Contraband::CountsAsAdded(eLootSourceType::NONE, eInventoryType::INVALID));
|
||||
EXPECT_FALSE(Contraband::CountsAsAdded(eLootSourceType::NONE, eInventoryType::VAULT_ITEMS));
|
||||
EXPECT_FALSE(Contraband::CountsAsAdded(eLootSourceType::RELOCATE, eInventoryType::INVALID));
|
||||
EXPECT_FALSE(Contraband::CountsAsAdded(eLootSourceType::INVENTORY, eInventoryType::INVALID));
|
||||
}
|
||||
Reference in New Issue
Block a user