mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
Adding or removing a grant sends the existing PLAYER_ACTION refresh through master to every world: REFRESH_ACCOUNT for an account's grants, REFRESH_CHARACTER for a character's. A world with that account's sessions (or the loaded character) drops the grants it kept, so the next command reads them again: no relog. The dashboard toasts whether the player was online. The dashboard's own open pages already get the new rights on their next request, and their WebSockets are checked again. Check: with a character in game, grant it /spawn on the dashboard (toast: "Applied in game at once") and use /spawn without relogging; remove the grant and /spawn is refused again at once. With the player offline the toast says it applies when they next play. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
333 lines
18 KiB
C++
333 lines
18 KiB
C++
#include "GrantRoutes.h"
|
|
|
|
#include <ctime>
|
|
#include <map>
|
|
|
|
#include "AccountRules.h"
|
|
#include "Database.h"
|
|
#include "eHTTPMethod.h"
|
|
#include "Game.h"
|
|
#include "GeneralUtils.h"
|
|
#include "master/PlayerAction.h"
|
|
#include "PlayerActions.h"
|
|
#include "HTTPContext.h"
|
|
#include "PermissionGrants.h"
|
|
#include "Permissions.h"
|
|
#include "RouteUtils.h"
|
|
#include "SettingsRoutes.h"
|
|
#include "Web.h"
|
|
#include "WSRoutes.h"
|
|
|
|
using namespace RouteUtils;
|
|
using AccountRules::eAccountAction;
|
|
using PermissionGrants::eKind;
|
|
|
|
namespace {
|
|
constexpr size_t MAX_NOTE = 255;
|
|
constexpr size_t MAX_NAME = 64;
|
|
constexpr uint32_t MAX_LIST = 500;
|
|
constexpr uint32_t HISTORY_LENGTH = 200;
|
|
constexpr const char* MANAGE = "grants_manage";
|
|
|
|
int64_t Now() { return static_cast<int64_t>(std::time(nullptr)); }
|
|
|
|
std::string Trim(std::string text) {
|
|
text.erase(0, text.find_first_not_of(" \t\r\n"));
|
|
text.erase(text.find_last_not_of(" \t\r\n") + 1);
|
|
return text;
|
|
}
|
|
|
|
std::string UtcTime(int64_t time) {
|
|
const auto seconds = static_cast<std::time_t>(time);
|
|
std::tm tm{};
|
|
gmtime_r(&seconds, &tm);
|
|
char text[32];
|
|
std::strftime(text, sizeof(text), "%Y-%m-%d %H:%M UTC", &tm);
|
|
return text;
|
|
}
|
|
|
|
// Who a grant is for
|
|
struct Target {
|
|
std::string type; // PermissionGrants::ACCOUNT or CHARACTER
|
|
int64_t id{}; // account ID or charinfo ID
|
|
uint32_t accountId{}; // the account (the character's owner)
|
|
std::string name;
|
|
|
|
std::string Describe() const {
|
|
return (type == PermissionGrants::ACCOUNT ? "account " : "character ") + name + " (" + std::to_string(id) + ")";
|
|
}
|
|
AuditTarget Audit() const {
|
|
return type == PermissionGrants::ACCOUNT ? AuditTarget::Account(accountId) : AuditTarget{ accountId, id };
|
|
}
|
|
};
|
|
|
|
std::optional<Target> AccountTarget(uint32_t accountId) {
|
|
const auto account = Database::Get()->GetAccountById(accountId);
|
|
if (account.contains("error")) return std::nullopt;
|
|
return Target{ std::string(PermissionGrants::ACCOUNT), accountId, accountId, account.value("name", std::string{}) };
|
|
}
|
|
|
|
std::optional<Target> CharacterTarget(LWOOBJID characterId) {
|
|
const auto info = Database::Get()->GetCharacterInfo(characterId);
|
|
if (!info) return std::nullopt;
|
|
return Target{ std::string(PermissionGrants::CHARACTER), characterId, info->accountId, info->name };
|
|
}
|
|
|
|
// {targetType, target}: an account's ID or name, or a character's ID or name
|
|
std::optional<Target> ResolveTarget(const std::string& type, const nlohmann::json& value) {
|
|
std::string text = value.is_string() ? Trim(value.get<std::string>()) : value.is_number_integer() ? std::to_string(value.get<int64_t>()) : "";
|
|
if (text.empty()) return std::nullopt;
|
|
if (type == PermissionGrants::ACCOUNT) {
|
|
if (const auto id = GeneralUtils::TryParse<uint32_t>(text)) return AccountTarget(*id);
|
|
const auto info = Database::Get()->GetAccountInfo(text);
|
|
return info ? AccountTarget(info->id) : std::nullopt;
|
|
}
|
|
if (type == PermissionGrants::CHARACTER) {
|
|
const auto id = ResolveCharacter(text);
|
|
return id ? CharacterTarget(*id) : std::nullopt;
|
|
}
|
|
return std::nullopt;
|
|
}
|
|
|
|
std::optional<Target> TargetOf(const IPermissionGrants::Grant& grant) {
|
|
if (grant.targetType == PermissionGrants::ACCOUNT) return AccountTarget(static_cast<uint32_t>(grant.targetId));
|
|
return CharacterTarget(grant.targetId);
|
|
}
|
|
|
|
std::optional<PermissionGrants::Command> FindCommand(const std::vector<SlashCommandNow>& commands, const std::string& name) {
|
|
for (const auto& command : commands) if (command.rules.name == name) return command.rules;
|
|
return std::nullopt;
|
|
}
|
|
|
|
// Why the signed-in user may not give or take away this (empty: they may): only what they hold themselves
|
|
std::string Refusal(const HTTPContext& context, eKind kind, const std::string& name, const std::vector<SlashCommandNow>& commands) {
|
|
const auto now = Now();
|
|
return PermissionGrants::Refusal(kind, name, context.gmLevel,
|
|
[&context](const std::string& key) { return Can(context, key); },
|
|
[&commands](const std::string& command) { return FindCommand(commands, command); },
|
|
[&context, now](const PermissionGrants::Command& command) {
|
|
return PermissionGrants::MayUseCommand(context.gmLevel, context.gmLevel, command, context.grants.get(), now);
|
|
});
|
|
}
|
|
|
|
std::string Status(const IPermissionGrants::Grant& grant, int64_t now) {
|
|
if (grant.revokedAt != 0) return "removed";
|
|
if (grant.expiresAt != 0 && grant.expiresAt <= now) return "expired";
|
|
return "active";
|
|
}
|
|
|
|
// Rows as JSON, with who they are for and whether the signed-in user may remove them
|
|
class Lister {
|
|
public:
|
|
Lister(const HTTPContext& context) : context(context), commands(CurrentSlashCommands()), now(Now()) {}
|
|
|
|
nlohmann::json Row(const IPermissionGrants::Grant& grant) {
|
|
const auto kind = PermissionGrants::ParseKind(grant.kind);
|
|
const auto status = Status(grant, now);
|
|
const auto& target = Find(grant);
|
|
bool canRemove = false;
|
|
if (kind && status == "active" && target && Can(context, MANAGE)) {
|
|
canRemove = MayManage(target->accountId) && Refusal(context, *kind, grant.name, commands).empty();
|
|
}
|
|
return {
|
|
{"id", grant.id}, {"targetType", grant.targetType}, {"targetId", std::to_string(grant.targetId)},
|
|
{"targetName", target ? target->name : ""}, {"accountId", target ? target->accountId : 0},
|
|
{"kind", grant.kind}, {"name", grant.name}, {"label", kind ? PermissionGrants::Describe(*kind, grant.name) : grant.kind + " " + grant.name},
|
|
{"deny", grant.deny}, {"expiresAt", grant.expiresAt}, {"note", grant.note}, {"grantedAt", grant.grantedAt}, {"grantedBy", grant.grantedBy},
|
|
{"revokedAt", grant.revokedAt}, {"revokedBy", grant.revokedBy}, {"status", status}, {"canRemove", canRemove}
|
|
};
|
|
}
|
|
|
|
nlohmann::json Rows(const std::vector<IPermissionGrants::Grant>& grants) {
|
|
nlohmann::json rows = nlohmann::json::array();
|
|
for (const auto& grant : grants) rows.push_back(Row(grant));
|
|
return rows;
|
|
}
|
|
|
|
private:
|
|
const std::optional<Target>& Find(const IPermissionGrants::Grant& grant) {
|
|
const auto key = grant.targetType + ":" + std::to_string(grant.targetId);
|
|
auto it = targets.find(key);
|
|
if (it == targets.end()) it = targets.emplace(key, TargetOf(grant)).first;
|
|
return it->second;
|
|
}
|
|
|
|
bool MayManage(uint32_t accountId) {
|
|
auto it = manageable.find(accountId);
|
|
if (it == manageable.end()) {
|
|
const auto account = Database::Get()->GetAccountById(accountId);
|
|
const bool may = !account.contains("error") &&
|
|
CanManageAccount(context, static_cast<uint8_t>(account.value("gm_level", 0)), accountId, eAccountAction::MODERATION);
|
|
it = manageable.emplace(accountId, may).first;
|
|
}
|
|
return it->second;
|
|
}
|
|
|
|
const HTTPContext& context;
|
|
std::vector<SlashCommandNow> commands;
|
|
int64_t now;
|
|
std::map<std::string, std::optional<Target>> targets;
|
|
std::map<uint32_t, bool> manageable;
|
|
};
|
|
|
|
/**
|
|
* Grants apply at once: the dashboard's open pages get their account's rights again, and the world servers read the
|
|
* grants of the account's online sessions (or the character, if it is loaded) again, as for a GM level change.
|
|
* Returns the request ID of the refresh (the page shows its result).
|
|
*/
|
|
uint32_t Applied(const HTTPContext& context, const Target& target) {
|
|
Game::web.RecheckWebSockets(target.accountId);
|
|
BroadcastTableChanged("grants", std::to_string(target.accountId));
|
|
PlayerActionRequest request;
|
|
const bool account = target.type == PermissionGrants::ACCOUNT;
|
|
request.action = account ? ePlayerAction::REFRESH_ACCOUNT : ePlayerAction::REFRESH_CHARACTER;
|
|
request.accountId = target.accountId;
|
|
request.characterId = account ? 0 : target.id;
|
|
return PlayerActions::Request(request, context.accountId, [account](const PlayerActionResult& result) {
|
|
if (result.affected == 0) return PlayerActions::Outcome{ true, account ? "The account isn't online; it applies when they next play" : "The character isn't online; it applies when they next play" };
|
|
return PlayerActions::Outcome{ true, "Applied in game at once" };
|
|
});
|
|
}
|
|
}
|
|
|
|
void GrantRoutes::RegisterRoutes() {
|
|
Route(eHTTPMethod::GET, "/api/grants/catalog", Perm(MANAGE),
|
|
"What can be granted: the permissions, permission groups (categories), slash commands and command groups (GM levels), each with "
|
|
"whether you may grant it ('grantable') and why not ('reason'): only what you hold yourself",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto commands = CurrentSlashCommands();
|
|
nlohmann::json permissions = nlohmann::json::array();
|
|
for (const auto& permission : Permissions::All()) {
|
|
const auto reason = Refusal(context, eKind::PERMISSION, permission.key, commands);
|
|
permissions.push_back({ {"key", permission.key}, {"title", permission.title}, {"category", permission.category},
|
|
{"description", permission.description}, {"level", Permissions::Level(permission.key)}, {"grantable", reason.empty()}, {"reason", reason} });
|
|
}
|
|
nlohmann::json groups = nlohmann::json::array();
|
|
for (const auto& group : PermissionGrants::PermissionGroups()) {
|
|
const auto reason = Refusal(context, eKind::PERMISSION_GROUP, group, commands);
|
|
groups.push_back({ {"name", group}, {"permissions", PermissionGrants::GroupPermissions(group)}, {"grantable", reason.empty()}, {"reason", reason} });
|
|
}
|
|
nlohmann::json commandList = nlohmann::json::array();
|
|
for (const auto& command : commands) {
|
|
if (command.clientHandled) continue;
|
|
const auto reason = Refusal(context, eKind::COMMAND, command.rules.name, commands);
|
|
commandList.push_back({ {"name", command.rules.name}, {"aliases", command.aliases}, {"help", command.help}, {"level", command.rules.level},
|
|
{"minLevel", command.rules.minLevel}, {"permission", command.rules.permission}, {"grantable", reason.empty()}, {"reason", reason} });
|
|
}
|
|
nlohmann::json commandGroups = nlohmann::json::array();
|
|
for (uint8_t level = 1; level <= Permissions::MAX_LEVEL; level++) {
|
|
const auto name = std::to_string(level);
|
|
const auto reason = Refusal(context, eKind::COMMAND_GROUP, name, commands);
|
|
commandGroups.push_back({ {"name", name}, {"grantable", reason.empty()}, {"reason", reason} });
|
|
}
|
|
JsonSuccess(reply, { {"permissions", permissions}, {"permissionGroups", groups}, {"commands", commandList}, {"commandGroups", commandGroups} });
|
|
});
|
|
|
|
Route(eHTTPMethod::GET, "/api/grants", 0,
|
|
"Permission grants. ?account=ID or ?character=ID: every grant of that account or character, removed and expired ones too, newest first "
|
|
"(your own without grants_manage). Neither: {active, history} for every account and character (grants_manage)",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto accountText = QueryValue(context.queryString, "account");
|
|
const auto characterText = QueryValue(context.queryString, "character");
|
|
std::optional<Target> target;
|
|
if (!accountText.empty()) {
|
|
const auto id = GeneralUtils::TryParse<uint32_t>(accountText);
|
|
if (id) target = AccountTarget(*id);
|
|
} else if (!characterText.empty()) {
|
|
const auto id = GeneralUtils::TryParse<LWOOBJID>(characterText);
|
|
if (id) target = CharacterTarget(*id);
|
|
} else {
|
|
if (!Can(context, MANAGE)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions");
|
|
Lister lister(context);
|
|
const auto now = Now();
|
|
return JsonSuccess(reply, { {"active", lister.Rows(Database::Get()->GetRecentPermissionGrants(true, now, MAX_LIST))},
|
|
{"history", lister.Rows(Database::Get()->GetRecentPermissionGrants(false, now, HISTORY_LENGTH))} });
|
|
}
|
|
if (!target) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Account or character not found");
|
|
const bool own = context.accountId != 0 && target->accountId == context.accountId;
|
|
if (!own && !Can(context, MANAGE)) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, "Insufficient permissions");
|
|
Lister lister(context);
|
|
JsonSuccess(reply, { {"target", { {"type", target->type}, {"id", std::to_string(target->id)}, {"accountId", target->accountId}, {"name", target->name} }},
|
|
{"grants", lister.Rows(Database::Get()->GetPermissionGrants(target->type, target->id))}, {"canManage", Can(context, MANAGE)} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/grants", Perm(MANAGE),
|
|
"Grant (or with deny: true, take away) something for one account or character. Body: {targetType: account|character, target: ID or name, "
|
|
"kind: permission|command|permission_group|command_group, name, deny, expiresAt (Unix seconds; 0 or missing: never), note}. Only what you hold "
|
|
"yourself, on accounts you may manage (self_moderation for your own). Online players get it at once",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto body = ParseBody(context);
|
|
if (!body || !body->is_object()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
|
const auto target = ResolveTarget(body->value("targetType", ""), body->contains("target") ? (*body)["target"] : nlohmann::json());
|
|
if (!target) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such account or character");
|
|
const auto kind = PermissionGrants::ParseKind(body->value("kind", ""));
|
|
if (!kind) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "kind must be permission, command, permission_group or command_group");
|
|
const auto name = Trim(body->value("name", ""));
|
|
if (name.empty() || name.size() > MAX_NAME) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick what to grant");
|
|
const bool deny = body->value("deny", false);
|
|
const auto note = Trim(body->value("note", ""));
|
|
if (note.size() > MAX_NOTE) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The note is too long");
|
|
const auto& expiry = body->contains("expiresAt") ? (*body)["expiresAt"] : nlohmann::json();
|
|
if (!expiry.is_null() && !expiry.is_number_integer()) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "expiresAt must be Unix seconds");
|
|
const int64_t expiresAt = expiry.is_null() ? 0 : expiry.get<int64_t>();
|
|
const auto now = Now();
|
|
if (expiresAt < 0 || (expiresAt != 0 && expiresAt <= now)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "The expiry must be in the future");
|
|
|
|
// The rank rules, like every account tool: never a higher GM level, your own only with self_moderation
|
|
if (!AuthorizeAccountAction(context, target->accountId, reply, eAccountAction::MODERATION)) return;
|
|
const auto commands = CurrentSlashCommands();
|
|
const auto refusal = Refusal(context, *kind, name, commands);
|
|
if (!refusal.empty()) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, refusal);
|
|
|
|
for (const auto& existing : Database::Get()->GetPermissionGrants(target->type, target->id)) {
|
|
if (Status(existing, now) == "active" && existing.kind == PermissionGrants::KindName(*kind) && existing.name == name && existing.deny == deny) {
|
|
return JsonError(reply, eHTTPStatusCode::CONFLICT, "This " + target->type + " already has that; remove it first to change it");
|
|
}
|
|
}
|
|
|
|
IPermissionGrants::Grant grant;
|
|
grant.targetType = target->type;
|
|
grant.targetId = target->id;
|
|
grant.kind = std::string(PermissionGrants::KindName(*kind));
|
|
grant.name = name;
|
|
grant.deny = deny;
|
|
grant.expiresAt = expiresAt;
|
|
grant.note = note;
|
|
grant.grantedAt = now;
|
|
grant.grantedById = context.accountId;
|
|
grant.grantedBy = context.authenticatedUser;
|
|
grant.id = Database::Get()->InsertPermissionGrant(grant);
|
|
|
|
const auto what = PermissionGrants::Describe(*kind, name);
|
|
const auto description = std::string(deny ? "Took away " : "Granted ") + what + (deny ? " from " : " to ") + target->Describe() +
|
|
(expiresAt ? " until " + UtcTime(expiresAt) : "") + (note.empty() ? "" : ": " + note) + OwnAccountNote(context.accountId, target->accountId);
|
|
Audit(context, deny ? "deny_permission" : "grant_permission", description, target->Audit());
|
|
const auto requestId = Applied(context, *target);
|
|
JsonSuccess(reply, { {"id", grant.id}, {"requestId", requestId}, {"message", std::string(deny ? "Took away " : "Granted ") + what} });
|
|
});
|
|
|
|
Route(eHTTPMethod::POST, "/api/grants/:id/remove", Perm(MANAGE),
|
|
"Remove a grant (or deny) that is in force: only one for something you hold yourself, on an account you may manage. Online players lose it at once",
|
|
[](HTTPReply& reply, const HTTPContext& context) {
|
|
const auto id = PathId<uint64_t>(context.path, 2);
|
|
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid grant ID");
|
|
const auto grant = Database::Get()->GetPermissionGrant(*id);
|
|
if (!grant) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "Grant not found");
|
|
if (Status(*grant, Now()) != "active") return JsonError(reply, eHTTPStatusCode::CONFLICT, "This grant isn't in force any more");
|
|
const auto kind = PermissionGrants::ParseKind(grant->kind);
|
|
const auto target = TargetOf(*grant);
|
|
if (!kind || !target) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "The grant's account or character no longer exists");
|
|
if (!AuthorizeAccountAction(context, target->accountId, reply, eAccountAction::MODERATION)) return;
|
|
const auto refusal = Refusal(context, *kind, grant->name, CurrentSlashCommands());
|
|
if (!refusal.empty()) return JsonError(reply, eHTTPStatusCode::FORBIDDEN, refusal);
|
|
if (!Database::Get()->RevokePermissionGrant(grant->id, context.authenticatedUser, Now())) {
|
|
return JsonError(reply, eHTTPStatusCode::CONFLICT, "This grant was already removed");
|
|
}
|
|
const auto what = PermissionGrants::Describe(*kind, grant->name);
|
|
Audit(context, "remove_grant", std::string("Removed the ") + (grant->deny ? "deny of " : "grant of ") + what + (grant->deny ? " from " : " to ") +
|
|
target->Describe() + " (given by " + grant->grantedBy + ")" + OwnAccountNote(context.accountId, target->accountId), target->Audit());
|
|
const auto requestId = Applied(context, *target);
|
|
JsonSuccess(reply, { {"requestId", requestId}, {"message", std::string("Removed the ") + (grant->deny ? "deny of " : "grant of ") + what} });
|
|
});
|
|
}
|