diff --git a/dCommon/Permissions.cpp b/dCommon/Permissions.cpp index e70778cb7..d994e8062 100644 --- a/dCommon/Permissions.cpp +++ b/dCommon/Permissions.cpp @@ -55,8 +55,9 @@ namespace { { "players_history", "Moderation", "Player movement history", "Replay where players went on the 3D world view (positions kept for position_history_days; every replay is audited)", 5 }, { "moderate_names", "Moderation", "Moderate names", "The moderation queue: approve and reject character names", 3 }, { "moderate_pet_names", "Moderation", "Moderate pet names", "Approve and reject pet names", 5 }, + { "guilds_manage", "Moderation", "Guilds", "See guilds, their members and history; approve, reject and change guild names, remove members and disband guilds", 5 }, { "chat_view", "Moderation", "Read chat", "The chat log: what players said in zones, and what the chat filter stopped", 3 }, - { "chat_private", "Moderation", "Read private chat", "Whispers and team chat in the chat log", 8 }, + { "chat_private", "Moderation", "Read private chat", "Whispers, team and guild chat in the chat log", 8 }, { "chat_filter_manage", "Moderation", "Chat filter", "See and change the words the chat filter allows and stops, and apply them in running worlds", 5 }, { "player_reports_view", "Moderation", "View player reports", "Read what players reported from the game's Report Abuse window", 2 }, { "player_reports_manage", "Moderation", "Handle player reports", "Act on or dismiss player reports (a strike also needs strikes_give)", 3 }, diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 18ddb736e..e76b98859 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -101,6 +101,7 @@ #include "ApiKeyService.h" #include "ApiKeyRoutes.h" #include "GrantRoutes.h" +#include "GuildRoutes.h" #include "PermissionGrantsLoader.h" #include "JWTUtils.h" #include "GeneralUtils.h" @@ -536,6 +537,7 @@ int main(int argc, char** argv) { RegisterLeaderboardRoutes(); ApiKeyRoutes::RegisterRoutes(); GrantRoutes::RegisterRoutes(); + GuildRoutes::RegisterRoutes(); RequireAuthMiddleware::SetApiAccessCheck([](const HTTPContext& context) { return Permissions::Allowed(context.gmLevel, "api_access", nullptr, context.grants.get()); }); RequireAuthMiddleware::SetForbiddenPage([](const HTTPContext& context, HTTPReply& reply) { RouteUtils::RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to open this page."); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index e83d8a0b3..c347e04e1 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -6,6 +6,7 @@ set(DASHBOARDROUTES_SOURCES "AuthRoutes.cpp" "ApiKeyRoutes.cpp" "GrantRoutes.cpp" + "GuildRoutes.cpp" "RouteUtils.cpp" "PlayerActions.cpp" "AccountRoutes.cpp" diff --git a/dDashboardServer/routes/ChatRoutes.cpp b/dDashboardServer/routes/ChatRoutes.cpp index 83c5c94f8..59b24deb5 100644 --- a/dDashboardServer/routes/ChatRoutes.cpp +++ b/dDashboardServer/routes/ChatRoutes.cpp @@ -22,7 +22,7 @@ namespace { constexpr uint32_t MAX_PAGE = 500; uint64_t g_LastPushed = 0; - bool IsPrivate(const std::string& channel) { return channel == "whisper" || channel == "team"; } + bool IsPrivate(const std::string& channel) { return channel == "whisper" || channel == "team" || channel == "guild"; } nlohmann::json MessageJson(const IChatLog::ChatMessage& m) { const auto& zones = ZoneNames(); @@ -37,7 +37,7 @@ namespace { IChatLog::ChatQuery Query(const HTTPContext& context, const std::function& get) { IChatLog::ChatQuery q; q.channel = get("channel"); - // Whispers and team chat need their own permission + // Whispers, team and guild chat need their own permission q.includePrivate = Can(context, "chat_private"); if (IsPrivate(q.channel) && !q.includePrivate) q.channel = "none"; const auto character = get("character"); diff --git a/dDashboardServer/routes/DashboardRoutes.cpp b/dDashboardServer/routes/DashboardRoutes.cpp index cb66967a4..f3a066581 100644 --- a/dDashboardServer/routes/DashboardRoutes.cpp +++ b/dDashboardServer/routes/DashboardRoutes.cpp @@ -433,8 +433,8 @@ void RegisterDashboardRoutes() { // Moderation and tools // Holds three queues; anyone who may work one of them gets in and sees only theirs - Route(eHTTPMethod::GET, "/moderation", 1, "Moderation queue (names, pet names, properties)", [](HTTPReply& reply, const HTTPContext& context) { - if (!Can(context, "moderate_names") && !Can(context, "moderate_pet_names") && !Can(context, "moderate_properties")) { + Route(eHTTPMethod::GET, "/moderation", 1, "Moderation queue (names, pet names, guild names, properties)", [](HTTPReply& reply, const HTTPContext& context) { + if (!Can(context, "moderate_names") && !Can(context, "moderate_pet_names") && !Can(context, "guilds_manage") && !Can(context, "moderate_properties")) { return RenderError(reply, context, eHTTPStatusCode::FORBIDDEN, "You don't have permission to review names or properties."); } RenderPage(reply, context, "moderation.jinja2", "moderation"); diff --git a/dDashboardServer/routes/GuildRoutes.cpp b/dDashboardServer/routes/GuildRoutes.cpp new file mode 100644 index 000000000..1b6d5bafa --- /dev/null +++ b/dDashboardServer/routes/GuildRoutes.cpp @@ -0,0 +1,200 @@ +#include "GuildRoutes.h" + +#include + +#include "Database.h" +#include "eGuildRank.h" +#include "eHTTPMethod.h" +#include "GeneralUtils.h" +#include "GuildNameRules.h" +#include "HTTPContext.h" +#include "master/PlayerAction.h" +#include "PlayerActions.h" +#include "RouteUtils.h" +#include "Web.h" +#include "WSRoutes.h" + +using namespace RouteUtils; + +namespace { + constexpr const char* MANAGE = "guilds_manage"; + constexpr uint32_t HISTORY_LENGTH = 200; + + int64_t Now() { return static_cast(std::time(nullptr)); } + + std::string Actor(const HTTPContext& context) { + return "[dashboard] " + context.authenticatedUser; + } + + const char* StatusName(int32_t status) { + return status == IGuilds::NAME_APPROVED ? "approved" : "pending"; + } + + nlohmann::json GuildJson(const IGuilds::Guild& guild) { + return { {"id", std::to_string(guild.id)}, {"name", guild.name}, {"name_status", StatusName(guild.nameStatus)}, + {"founder_id", std::to_string(guild.founderId)}, {"created_at", guild.createdAt} }; + } + + void AddEvent(const HTTPContext& context, int64_t guildId, const std::string& kind, LWOOBJID characterId, const std::string& characterName, const std::string& detail) { + Database::Get()->InsertGuildEvent({ 0, guildId, Now(), kind, characterId, characterName, Actor(context), detail }); + } + + // The chat server tells the guild's online members (it is the guild authority); the reply carries the request id + nlohmann::json TellChat(int64_t guildId, uint32_t requester) { + PlayerActionRequest request; + request.action = ePlayerAction::GUILD_CHANGED; + request.targetId = guildId; + const auto id = PlayerActions::Request(request, requester, [](const PlayerActionResult& result) -> PlayerActions::Outcome { + return { true, result.affected ? "The guild's online members were told." : "The chat server isn't running; members see the change when they next log in." }; + }); + return { {"requestId", id} }; + } + + std::optional RequireGuild(const HTTPContext& context, HTTPReply& reply) { + const auto guildId = PathId(context.path, 2); + if (!guildId) { + JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid guild ID"); + return std::nullopt; + } + auto guild = Database::Get()->GetGuild(*guildId); + if (!guild) JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such guild"); + return guild; + } + + // A name that is free: "Guild ", then "Guild -2" and so on + std::string PlaceholderName(int64_t guildId) { + const std::string base = "Guild " + std::to_string(guildId); + std::string name = base; + for (int i = 2; Database::Get()->GetGuildByName(name); i++) name = base + "-" + std::to_string(i); + return name; + } + + void Changed(const std::string& guildId) { + BroadcastTableChanged("guilds", guildId); + } +} + +void GuildRoutes::RegisterRoutes() { + Route(eHTTPMethod::GET, "/guilds", Perm(MANAGE), "Guilds: members, history and name moderation", [](HTTPReply& reply, const HTTPContext& context) { + RenderPage(reply, context, "guilds.jinja2", "guilds"); + }); + + ReadRoute(eHTTPMethod::POST, "/api/tables/guilds", Perm(MANAGE), "Guilds (DataTables), newest first. Body adds {pending} (only names waiting for review)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto request = ParseDataTablesRequest(context.body); + const auto body = ParseBody(context); + if (!request || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); + const auto page = Database::Get()->GetGuildPage(request->start, request->length, request->search, body->value("pending", false)); + nlohmann::json data = nlohmann::json::array(); + for (const auto& summary : page.guilds) { + auto row = GuildJson(summary.guild); + row["member_count"] = summary.memberCount; + row["leader_id"] = summary.leaderId ? std::to_string(summary.leaderId) : ""; + row["leader_name"] = summary.leaderName; + data.push_back(std::move(row)); + } + JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", page.total}, {"recordsFiltered", page.filtered}, {"data", data} }); + }); + + Route(eHTTPMethod::GET, "/api/guilds/:id", Perm(MANAGE), "A guild with its members (rank 1 leader, 2 officer, 3 veteran, 4 recruit) and its history, newest first", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + auto result = GuildJson(*guild); + if (const auto founder = Database::Get()->GetCharacterInfo(guild->founderId)) result["founder_name"] = founder->name; + nlohmann::json members = nlohmann::json::array(); + for (const auto& member : Database::Get()->GetGuildMembers(guild->id)) { + members.push_back({ {"id", std::to_string(member.characterId)}, {"name", member.name}, {"rank", member.rank}, {"joined_at", member.joinedAt} }); + } + nlohmann::json events = nlohmann::json::array(); + for (const auto& event : Database::Get()->GetGuildEvents(guild->id, HISTORY_LENGTH)) { + events.push_back({ {"id", event.id}, {"time", event.time}, {"kind", event.kind}, {"character_id", std::to_string(event.characterId)}, + {"character_name", event.characterName}, {"actor", event.actor}, {"detail", event.detail} }); + } + result["members"] = members; + result["events"] = events; + JsonSuccess(reply, { {"guild", result} }); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/approve", Perm(MANAGE), "Approve a guild name that waits for review; other players see it from then on", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + Database::Get()->SetGuildName(guild->id, guild->name, IGuilds::NAME_APPROVED); + AddEvent(context, guild->id, "name_approved", 0, "", guild->name); + Audit(context, "approve_guild_name", "Guild " + std::to_string(guild->id) + ": " + guild->name); + Changed(std::to_string(guild->id)); + JsonSuccess(reply, TellChat(guild->id, context.accountId)); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/reject", Perm(MANAGE), "Reject a guild name: the guild is renamed \"Guild \". Body (optional): {reason}", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + const auto body = ParseBody(context).value_or(nlohmann::json::object()); + const auto reason = body.value("reason", std::string{}); + const auto name = PlaceholderName(guild->id); + Database::Get()->SetGuildName(guild->id, name, IGuilds::NAME_APPROVED); + AddEvent(context, guild->id, "name_rejected", 0, "", guild->name + (reason.empty() ? "" : " (" + reason + ")")); + Audit(context, "reject_guild_name", "Guild " + std::to_string(guild->id) + ": " + guild->name + " -> " + name + (reason.empty() ? "" : " (" + reason + ")")); + Changed(std::to_string(guild->id)); + auto result = TellChat(guild->id, context.accountId); + result["name"] = name; + JsonSuccess(reply, result); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/rename", Perm(MANAGE), "Rename a guild (approved). Body: {name} (3-30 of letters, digits, space ' - .; unique without regard to case)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + const auto body = ParseBody(context); + if (!body || !body->contains("name") || !(*body)["name"].is_string()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Give the new name"); + const auto name = (*body)["name"].get(); + if (!GuildNameRules::IsValid(GeneralUtils::UTF8ToUTF16(name))) { + return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "A guild name is 3 to 30 letters, digits, spaces, ' - and . (no space at either end or twice in a row)"); + } + const auto taken = Database::Get()->GetGuildByName(name); + if (taken && taken->id != guild->id) return JsonError(reply, eHTTPStatusCode::CONFLICT, "Guild " + std::to_string(taken->id) + " has that name"); + Database::Get()->SetGuildName(guild->id, name, IGuilds::NAME_APPROVED); + AddEvent(context, guild->id, "renamed", 0, "", guild->name + " -> " + name); + Audit(context, "rename_guild", "Guild " + std::to_string(guild->id) + ": " + guild->name + " -> " + name); + Changed(std::to_string(guild->id)); + JsonSuccess(reply, TellChat(guild->id, context.accountId)); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/disband", Perm(MANAGE), "Disband a guild: every member leaves and the guild is deleted (its history stays)", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + const auto members = Database::Get()->GetGuildMembers(guild->id); + Database::Get()->DeleteGuild(guild->id); + AddEvent(context, guild->id, "disbanded", 0, "", guild->name + ", " + std::to_string(members.size()) + " member(s)"); + Audit(context, "disband_guild", "Guild " + std::to_string(guild->id) + ": " + guild->name + ", " + std::to_string(members.size()) + " member(s)"); + Changed(std::to_string(guild->id)); + JsonSuccess(reply, TellChat(guild->id, context.accountId)); + }); + + Route(eHTTPMethod::POST, "/api/guilds/:id/members/:character/remove", Perm(MANAGE), + "Remove a member. A leader's guild goes to the highest-ranked, longest-serving member; the last member's guild is deleted", + [](HTTPReply& reply, const HTTPContext& context) { + const auto guild = RequireGuild(context, reply); + if (!guild) return; + const auto characterId = PathId(context.path, 4); + const auto member = characterId ? Database::Get()->GetGuildMember(*characterId) : std::nullopt; + if (!member || member->guildId != guild->id) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "That character isn't in this guild"); + Database::Get()->RemoveGuildMember(member->characterId); + AddEvent(context, guild->id, "kicked", member->characterId, member->name, ""); + const auto remaining = Database::Get()->GetGuildMembers(guild->id); + if (remaining.empty()) { + Database::Get()->DeleteGuild(guild->id); + AddEvent(context, guild->id, "disbanded", 0, "", "the last member was removed"); + } else if (member->rank == static_cast(eGuildRank::LEADER)) { + // Ordered by rank, then who joined first + Database::Get()->SetGuildMemberRank(remaining.front().characterId, static_cast(eGuildRank::LEADER)); + AddEvent(context, guild->id, "leader", remaining.front().characterId, remaining.front().name, "the leader was removed"); + } + Audit(context, "remove_guild_member", "Guild " + std::to_string(guild->id) + " (" + guild->name + "): removed " + member->name, AuditTarget::Character(member->characterId)); + Changed(std::to_string(guild->id)); + JsonSuccess(reply, TellChat(guild->id, context.accountId)); + }); +} diff --git a/dDashboardServer/routes/GuildRoutes.h b/dDashboardServer/routes/GuildRoutes.h new file mode 100644 index 000000000..d7659e2a4 --- /dev/null +++ b/dDashboardServer/routes/GuildRoutes.h @@ -0,0 +1,10 @@ +#pragma once + +/** + * Guilds (docs/Guilds.md): the list, each guild's members and history, and moderation (approve or reject a name that + * waits for review, rename, disband, remove a member). Needs guilds_manage. The dashboard writes the database, audits + * the change, adds it to the guild's history and asks the chat server (through master) to tell the online members. + */ +namespace GuildRoutes { + void RegisterRoutes(); +} diff --git a/dDashboardServer/routes/PrometheusMetrics.cpp b/dDashboardServer/routes/PrometheusMetrics.cpp index 6978f1a02..47a9f73cd 100644 --- a/dDashboardServer/routes/PrometheusMetrics.cpp +++ b/dDashboardServer/routes/PrometheusMetrics.cpp @@ -47,8 +47,8 @@ namespace { // Chat messages counted since the dashboard started, by channel bool g_ChatStarted{}; uint64_t g_ChatSeen{}; - std::map g_ChatMessages{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"web", 0} }; - std::map g_ChatBlocked{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"web", 0} }; + std::map g_ChatMessages{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"guild", 0}, {"web", 0} }; + std::map g_ChatBlocked{ {"zone", 0}, {"whisper", 0}, {"team", 0}, {"guild", 0}, {"web", 0} }; std::string LowerName(std::string_view name) { std::string out(name); diff --git a/dDashboardServer/templates/chat_log.jinja2 b/dDashboardServer/templates/chat_log.jinja2 index 5acd70d97..85c0b1197 100644 --- a/dDashboardServer/templates/chat_log.jinja2 +++ b/dDashboardServer/templates/chat_log.jinja2 @@ -62,7 +62,7 @@
diff --git a/dDashboardServer/templates/guilds.jinja2 b/dDashboardServer/templates/guilds.jinja2 new file mode 100644 index 000000000..031fa9ea2 --- /dev/null +++ b/dDashboardServer/templates/guilds.jinja2 @@ -0,0 +1,150 @@ +{% extends "base.jinja2" %} + +{% block title %}Guilds - DarkflameServer{% endblock %} + +{% block content %} +

Guilds

+
+

Every guild. A name the chat filter doesn't allow waits for review; other players see no guild name until it is approved. Rejecting a name renames the guild “Guild <id>”.

+
+ + +
+
+ +
+
+ + + + + + + + + + + + + +
IDNameStatusMembersLeaderCreatedActions
+
+
+ +
+
+
+
+ + +
+
+
+

+
+
+
Members
+ + + +
CharacterRankJoined
+
+
+
History
+ + + +
WhenWhatWhoByDetail
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/dDashboardServer/templates/header.jinja2 b/dDashboardServer/templates/header.jinja2 index 5c7dd2f08..b21eea79e 100644 --- a/dDashboardServer/templates/header.jinja2 +++ b/dDashboardServer/templates/header.jinja2 @@ -17,14 +17,14 @@
{% set inboxPages = ["moderation", "player_reports", "bug_reports"] %} - {% if can.moderate_names or can.moderate_pet_names or can.moderate_properties or can.player_reports_view or can.bug_reports_view %} + {% if can.moderate_names or can.moderate_pet_names or can.guilds_manage or can.moderate_properties or can.player_reports_view or can.bug_reports_view %} {% endif %} +{% if can.guilds_manage %} +
+
Pending Guild Names
+
+ + + +
IDGuild NameLeaderMembersActions
+
+
+{% endif %} + {% if can.moderate_properties %}
Pending Properties
@@ -85,6 +97,26 @@ $(function () { ], $.extend({ liveTable: 'pet_names' }, small)); } + if (DASH.can('guilds_manage')) { + serverTable('#pendingGuildsTable', '/api/tables/guilds', [ + { data: 'id', orderable: false }, + { data: 'name', orderable: false, render: function (d) { return fmt.link('/guilds', d); } }, + { data: 'leader_id', orderable: false, render: function (d, t, row) { return d ? fmt.character(d, row.leader_name) : ''; } }, + { data: 'member_count', orderable: false }, + { data: 'id', orderable: false, render: function (d, t, row) { + return '' + + ''; + } } + ], $.extend({ liveTable: 'guilds', extra: function () { return { pending: true }; } }, small)); + $('#pendingGuildsTable').on('click', '[data-guild-action]', function () { + var id = this.getAttribute('data-id'); + if (this.getAttribute('data-guild-action') === 'approve') { api.action('/api/guilds/' + id + '/approve', {}, 'Approved').catch(function () {}); return; } + var reason = prompt('Reject the guild name “' + this.getAttribute('data-name') + '”? The guild is renamed “Guild ' + id + '”. Reason (optional, kept in its history):', ''); + if (reason === null) return; + api.action('/api/guilds/' + id + '/reject', { reason: reason }, 'Rejected').catch(function () {}); + }); + } + if (DASH.can('moderate_properties')) { serverTable('#pendingPropsTable', '/api/tables/pending_properties', [ { data: 'id', orderable: false }, diff --git a/dDatabase/GameDatabase/ITables/IChatLog.h b/dDatabase/GameDatabase/ITables/IChatLog.h index e138a5b78..b11f06923 100644 --- a/dDatabase/GameDatabase/ITables/IChatLog.h +++ b/dDatabase/GameDatabase/ITables/IChatLog.h @@ -17,7 +17,7 @@ public: struct ChatMessage { uint64_t id{}; int64_t time{}; - std::string channel; // zone, whisper, team, web + std::string channel; // zone, whisper, team, guild, web LWOOBJID senderId{}; // the character; 0 for messages from the web std::string senderName; uint32_t accountId{};