mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-16 17:44:08 +00:00
feat: web dashboard and playground work
The NexusDashboard-parity dashboard (dDashboardServer) and everything built on it on the experimental branch: accounts, characters, properties and moderation tools, permissions shared with in-game slash commands, economy reports, World 3D and property 3D views with client scenery, scheduled events (features, vanity changes, live events, announcements, restarts), vanity files and events, the CDClient browser, the message inspector with saved captures, chat filter tools, community challenges, live ops, the AI moderator helper, and the server-side changes they need. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
307
dDashboardServer/notify/Alerts.cpp
Normal file
307
dDashboardServer/notify/Alerts.cpp
Normal file
@@ -0,0 +1,307 @@
|
||||
#include "Alerts.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <condition_variable>
|
||||
#include <ctime>
|
||||
#include <algorithm>
|
||||
#include <deque>
|
||||
#include <mutex>
|
||||
#include <set>
|
||||
#include <thread>
|
||||
|
||||
#include <curl/curl.h>
|
||||
|
||||
#include "Database.h"
|
||||
#include "EmailService.h"
|
||||
#include "Logger.h"
|
||||
|
||||
namespace {
|
||||
constexpr size_t MAX_QUEUE = 200;
|
||||
constexpr int MAX_ATTEMPTS = 3;
|
||||
constexpr long TIMEOUT_SECONDS = 10;
|
||||
|
||||
struct Job {
|
||||
uint32_t webhookId{};
|
||||
std::string url;
|
||||
std::string body;
|
||||
std::vector<std::string> headers;
|
||||
std::function<void(std::optional<std::string>)> done;
|
||||
int attempt{};
|
||||
std::chrono::steady_clock::time_point notBefore{}; // retries wait without holding up other deliveries
|
||||
};
|
||||
|
||||
struct Result {
|
||||
uint32_t webhookId{};
|
||||
int64_t time{};
|
||||
int32_t status{};
|
||||
std::string error;
|
||||
std::function<void(std::optional<std::string>)> done;
|
||||
};
|
||||
|
||||
std::thread g_Worker;
|
||||
std::mutex g_Mutex;
|
||||
std::condition_variable g_Wake;
|
||||
std::deque<Job> g_Jobs;
|
||||
std::deque<Result> g_Results;
|
||||
bool g_Stopping = false;
|
||||
bool g_Started = false;
|
||||
|
||||
uint32_t g_LastBugReportId = 0;
|
||||
std::optional<std::pair<bool, bool>> g_LastServerStatus;
|
||||
|
||||
size_t Discard(char*, size_t size, size_t count, void*) { return size * count; }
|
||||
|
||||
size_t ReadHeader(char* data, size_t size, size_t count, void* userData) {
|
||||
const std::string_view line(data, size * count);
|
||||
auto* retryAfter = static_cast<long*>(userData);
|
||||
if (line.size() > 12 && (line.starts_with("retry-after:") || line.starts_with("Retry-After:"))) {
|
||||
*retryAfter = std::strtol(std::string(line.substr(12)).c_str(), nullptr, 10);
|
||||
}
|
||||
return size * count;
|
||||
}
|
||||
|
||||
// One POST; returns the HTTP status (0 when the request did not complete)
|
||||
int32_t Post(const Job& job, std::string& error, long& retryAfter) {
|
||||
CURL* curl = curl_easy_init();
|
||||
if (!curl) {
|
||||
error = "Could not start an HTTP request";
|
||||
return 0;
|
||||
}
|
||||
curl_slist* headers = curl_slist_append(nullptr, "Content-Type: application/json");
|
||||
for (const auto& header : job.headers) headers = curl_slist_append(headers, header.c_str());
|
||||
|
||||
curl_easy_setopt(curl, CURLOPT_URL, job.url.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, job.body.c_str());
|
||||
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, static_cast<long>(job.body.size()));
|
||||
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
|
||||
curl_easy_setopt(curl, CURLOPT_TIMEOUT, TIMEOUT_SECONDS);
|
||||
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
|
||||
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DarkflameServer-Dashboard");
|
||||
// Only plain web requests, and no redirects: a webhook URL must not be bounced somewhere else
|
||||
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https");
|
||||
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
|
||||
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, Discard);
|
||||
curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, ReadHeader);
|
||||
curl_easy_setopt(curl, CURLOPT_HEADERDATA, &retryAfter);
|
||||
|
||||
const auto code = curl_easy_perform(curl);
|
||||
long status = 0;
|
||||
if (code == CURLE_OK) curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
||||
else error = curl_easy_strerror(code);
|
||||
curl_slist_free_all(headers);
|
||||
curl_easy_cleanup(curl);
|
||||
if (code == CURLE_OK && (status < 200 || status >= 300)) error = "HTTP " + std::to_string(status);
|
||||
return static_cast<int32_t>(status);
|
||||
}
|
||||
|
||||
void WorkerLoop() {
|
||||
while (true) {
|
||||
Job job;
|
||||
{
|
||||
std::unique_lock lock(g_Mutex);
|
||||
while (true) {
|
||||
if (g_Stopping && g_Jobs.empty()) return;
|
||||
// On shutdown, pending retries are given up rather than waited for
|
||||
if (g_Stopping) {
|
||||
for (auto& pending : g_Jobs) g_Results.push_back({ pending.webhookId, static_cast<int64_t>(std::time(nullptr)), 0, "Not sent: shutting down", std::move(pending.done) });
|
||||
g_Jobs.clear();
|
||||
return;
|
||||
}
|
||||
const auto now = std::chrono::steady_clock::now();
|
||||
auto ready = std::ranges::find_if(g_Jobs, [now](const Job& pending) { return pending.notBefore <= now; });
|
||||
if (ready != g_Jobs.end()) {
|
||||
job = std::move(*ready);
|
||||
g_Jobs.erase(ready);
|
||||
break;
|
||||
}
|
||||
if (g_Jobs.empty()) g_Wake.wait(lock);
|
||||
else g_Wake.wait_until(lock, std::ranges::min_element(g_Jobs, {}, &Job::notBefore)->notBefore);
|
||||
}
|
||||
}
|
||||
|
||||
std::string error;
|
||||
long retryAfter = 0;
|
||||
const auto status = Post(job, error, retryAfter);
|
||||
job.attempt++;
|
||||
const bool ok = status >= 200 && status < 300;
|
||||
// Client errors other than rate limiting will not get better by retrying
|
||||
const bool retry = !ok && (status == 0 || status == 429 || status >= 500) && job.attempt < MAX_ATTEMPTS;
|
||||
|
||||
std::lock_guard lock(g_Mutex);
|
||||
if (retry) {
|
||||
const long wait = status == 429 && retryAfter > 0 ? std::min<long>(retryAfter, 30) : (job.attempt == 1 ? 2 : 10);
|
||||
job.notBefore = std::chrono::steady_clock::now() + std::chrono::seconds(wait);
|
||||
g_Jobs.push_back(std::move(job));
|
||||
} else {
|
||||
g_Results.push_back({ job.webhookId, static_cast<int64_t>(std::time(nullptr)), status, error, std::move(job.done) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::string DashboardUrl(const std::string& link) {
|
||||
if (link.empty()) return "";
|
||||
const auto base = EmailService::BaseUrl();
|
||||
return base.empty() ? "" : base + link;
|
||||
}
|
||||
|
||||
void Enqueue(const IDashboardAdmin::Webhook& webhook, const WebhookFormat::Alert& alert, std::function<void(std::optional<std::string>)> done = nullptr) {
|
||||
Job job;
|
||||
job.webhookId = webhook.id;
|
||||
job.url = webhook.url;
|
||||
job.body = WebhookFormat::BuildBody(webhook.format, alert);
|
||||
if (!webhook.secret.empty()) job.headers.push_back("X-DLU-Signature: sha256=" + WebhookFormat::Sign(webhook.secret, job.body));
|
||||
job.headers.push_back("X-DLU-Event: " + alert.event);
|
||||
job.done = std::move(done);
|
||||
|
||||
{
|
||||
std::lock_guard lock(g_Mutex);
|
||||
if (!g_Started || g_Jobs.size() >= MAX_QUEUE) {
|
||||
LOG("Dropping webhook alert '%s' for %s: %s", alert.title.c_str(), webhook.name.c_str(), g_Started ? "too many queued" : "alerts are not running");
|
||||
return;
|
||||
}
|
||||
g_Jobs.push_back(std::move(job));
|
||||
}
|
||||
g_Wake.notify_one();
|
||||
}
|
||||
|
||||
std::string AccountName(uint32_t accountId) {
|
||||
const auto account = Database::Get()->GetAccountById(accountId);
|
||||
return account.is_object() ? account.value("name", "account " + std::to_string(accountId)) : "account " + std::to_string(accountId);
|
||||
}
|
||||
}
|
||||
|
||||
namespace Alerts {
|
||||
void Initialize() {
|
||||
if (g_Started) return;
|
||||
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||
g_Stopping = false;
|
||||
g_Started = true;
|
||||
g_Worker = std::thread(WorkerLoop);
|
||||
try {
|
||||
g_LastBugReportId = Database::Get()->GetMaxBugReportId();
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not read bug reports for alerts: %s", ex.what());
|
||||
}
|
||||
}
|
||||
|
||||
void Shutdown() {
|
||||
if (!g_Started) return;
|
||||
{
|
||||
std::lock_guard lock(g_Mutex);
|
||||
g_Stopping = true;
|
||||
}
|
||||
g_Wake.notify_all();
|
||||
g_Worker.join();
|
||||
Update();
|
||||
g_Started = false;
|
||||
curl_global_cleanup();
|
||||
}
|
||||
|
||||
void Update() {
|
||||
std::deque<Result> results;
|
||||
{
|
||||
std::lock_guard lock(g_Mutex);
|
||||
results.swap(g_Results);
|
||||
}
|
||||
for (auto& result : results) {
|
||||
try {
|
||||
Database::Get()->RecordWebhookResult(result.webhookId, result.time, result.status, result.error);
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not record webhook result: %s", ex.what());
|
||||
}
|
||||
if (!result.error.empty()) LOG("Webhook %u failed: %s", result.webhookId, result.error.c_str());
|
||||
if (result.done) result.done(result.error.empty() ? std::nullopt : std::optional<std::string>(result.error));
|
||||
}
|
||||
}
|
||||
|
||||
void Emit(const std::string& event, const std::string& title, const std::string& description,
|
||||
std::vector<WebhookFormat::Field> fields, const std::string& link) {
|
||||
std::vector<IDashboardAdmin::Webhook> webhooks;
|
||||
try {
|
||||
webhooks = Database::Get()->GetWebhooks();
|
||||
} catch (const std::exception& ex) {
|
||||
LOG("Could not load webhooks: %s", ex.what());
|
||||
return;
|
||||
}
|
||||
const WebhookFormat::Alert alert{ event, title, description, std::move(fields), DashboardUrl(link), static_cast<int64_t>(std::time(nullptr)) };
|
||||
for (const auto& webhook : webhooks) {
|
||||
if (webhook.enabled && WebhookFormat::Matches(webhook.events, event)) Enqueue(webhook, alert);
|
||||
}
|
||||
}
|
||||
|
||||
void SendTest(uint32_t webhookId, std::function<void(std::optional<std::string>)> done) {
|
||||
const auto webhook = Database::Get()->GetWebhook(webhookId);
|
||||
if (!webhook) {
|
||||
done("Webhook not found");
|
||||
return;
|
||||
}
|
||||
const WebhookFormat::Alert alert{ "test", "Test from the DarkflameServer dashboard", "If you can read this, the webhook works.",
|
||||
{ { "Webhook", webhook->name } }, DashboardUrl("/webhooks"), static_cast<int64_t>(std::time(nullptr)) };
|
||||
Enqueue(*webhook, alert, std::move(done));
|
||||
}
|
||||
|
||||
void CheckNewBugReports() {
|
||||
const auto reports = Database::Get()->GetBugReportsAfter(g_LastBugReportId, 10);
|
||||
for (const auto& report : reports) {
|
||||
g_LastBugReportId = std::max(g_LastBugReportId, report["id"].get<uint32_t>());
|
||||
const auto reporter = report.value("reporter_name", "");
|
||||
Emit("bug_report", "Bug report #" + std::to_string(report["id"].get<uint32_t>()), report.value("body", ""),
|
||||
{ { "Reporter", reporter.empty() ? report.value("reporter_id", "") : reporter }, { "Client", report.value("client_version", "") },
|
||||
{ "Selection", report.value("selection", "") } },
|
||||
"/bug_reports/" + std::to_string(report["id"].get<uint32_t>()));
|
||||
}
|
||||
}
|
||||
|
||||
void PendingName(LWOOBJID characterId) {
|
||||
const auto info = Database::Get()->GetCharacterInfo(characterId);
|
||||
if (!info || info->pendingName.empty()) return;
|
||||
Emit("pending_name", "Name waiting for approval", info->name + " wants to be called " + info->pendingName + ".",
|
||||
{ { "Current name", info->name }, { "Requested", info->pendingName } }, "/moderation");
|
||||
}
|
||||
|
||||
void FromAudit(const std::string& actor, const std::string& action, const std::string& description) {
|
||||
static const std::set<std::string> moderation{
|
||||
"ban_account", "unban_account", "lock_account", "unlock_account", "mute_account", "unmute_account",
|
||||
"kick_account", "restrict_character", "broadcast_mail", "delete_account", "give_strike", "revoke_strike",
|
||||
"warn_account", "action_player_report", "chat_filter_allow", "chat_filter_block", "chat_filter_remove"
|
||||
};
|
||||
static const std::set<std::string> security{
|
||||
"set_gm_level", "create_api_token", "reset_password", "create_account", "enable_2fa", "disable_2fa",
|
||||
"reset_2fa", "regenerate_recovery_codes", "manage_webhook", "change_setting"
|
||||
};
|
||||
std::string event;
|
||||
if (moderation.contains(action)) event = "moderation";
|
||||
else if (security.contains(action)) event = "security";
|
||||
else return;
|
||||
|
||||
std::string title = action;
|
||||
std::replace(title.begin(), title.end(), '_', ' ');
|
||||
if (!title.empty()) title[0] = static_cast<char>(std::toupper(static_cast<unsigned char>(title[0])));
|
||||
Emit(event, title, description, { { "By", actor } }, "/audit_log");
|
||||
}
|
||||
|
||||
void InGameAccountAction(const std::string& action, uint32_t accountId) {
|
||||
const auto name = AccountName(accountId);
|
||||
Emit("moderation", action == "account_banned" ? "Account banned in game" : "Account muted in game",
|
||||
name + (action == "account_banned" ? " was banned" : " was muted") + " with a GM command.", { { "Account", name } },
|
||||
"/accounts/" + std::to_string(accountId));
|
||||
}
|
||||
|
||||
void ServerStatus(bool authOnline, bool chatOnline) {
|
||||
const std::pair<bool, bool> current{ authOnline, chatOnline };
|
||||
if (!g_LastServerStatus) {
|
||||
g_LastServerStatus = current;
|
||||
return;
|
||||
}
|
||||
const auto previous = *g_LastServerStatus;
|
||||
g_LastServerStatus = current;
|
||||
if (previous.first != authOnline) {
|
||||
Emit("server", authOnline ? "Auth server is back" : "Auth server went offline",
|
||||
authOnline ? "Players can log in again." : "Players cannot log in until it is back.", {}, "/");
|
||||
}
|
||||
if (previous.second != chatOnline) {
|
||||
Emit("server", chatOnline ? "Chat server is back" : "Chat server went offline",
|
||||
chatOnline ? "Chat, friends and teams work again." : "Chat, friends and teams are unavailable.", {}, "/");
|
||||
}
|
||||
}
|
||||
}
|
||||
49
dDashboardServer/notify/Alerts.h
Normal file
49
dDashboardServer/notify/Alerts.h
Normal file
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "dCommonVars.h"
|
||||
#include "WebhookFormat.h"
|
||||
|
||||
/**
|
||||
* Alerts sent to outgoing webhooks (Discord, Slack or any JSON receiver), configured on the Webhooks page.
|
||||
* Delivery happens on a worker thread with retries; results are written back from the main loop.
|
||||
*/
|
||||
namespace Alerts {
|
||||
void Initialize();
|
||||
void Shutdown();
|
||||
|
||||
// Main loop: record finished deliveries
|
||||
void Update();
|
||||
|
||||
/**
|
||||
* Send an alert to every enabled webhook subscribed to the event.
|
||||
* @param link dashboard path such as "/bug_reports/5"; made absolute with dashboard_url when that is set
|
||||
*/
|
||||
void Emit(const std::string& event, const std::string& title, const std::string& description,
|
||||
std::vector<WebhookFormat::Field> fields = {}, const std::string& link = "");
|
||||
|
||||
// Send a test message to one webhook; done receives an error or nothing on success
|
||||
void SendTest(uint32_t webhookId, std::function<void(std::optional<std::string>)> done);
|
||||
|
||||
// ---- Sources ----
|
||||
|
||||
// New bug reports since the last check (called when the game reports one, and by the periodic check)
|
||||
void CheckNewBugReports();
|
||||
|
||||
// A character asked for a name that needs approval
|
||||
void PendingName(LWOOBJID characterId);
|
||||
|
||||
// Moderation and security actions taken on the dashboard, from the audit log
|
||||
void FromAudit(const std::string& actor, const std::string& action, const std::string& description);
|
||||
|
||||
// Bans and mutes done in game with slash commands
|
||||
void InGameAccountAction(const std::string& action, uint32_t accountId);
|
||||
|
||||
// Auth/chat availability changes (first call only records the state)
|
||||
void ServerStatus(bool authOnline, bool chatOnline);
|
||||
}
|
||||
158
dDashboardServer/notify/WebhookFormat.cpp
Normal file
158
dDashboardServer/notify/WebhookFormat.cpp
Normal file
@@ -0,0 +1,158 @@
|
||||
#include "WebhookFormat.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <ctime>
|
||||
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/hmac.h>
|
||||
|
||||
#include "GeneralUtils.h"
|
||||
|
||||
namespace {
|
||||
// Discord embed limits; longer text is cut
|
||||
constexpr size_t MAX_TITLE = 256;
|
||||
constexpr size_t MAX_DESCRIPTION = 4000;
|
||||
constexpr size_t MAX_FIELD_NAME = 256;
|
||||
constexpr size_t MAX_FIELD_VALUE = 1000;
|
||||
constexpr size_t MAX_FIELDS = 25;
|
||||
|
||||
std::string Cut(const std::string& text, size_t max) {
|
||||
if (text.size() <= max) return text;
|
||||
// Don't cut through a UTF-8 sequence
|
||||
size_t end = max - 3;
|
||||
while (end > 0 && (static_cast<unsigned char>(text[end]) & 0xC0) == 0x80) end--;
|
||||
return text.substr(0, end) + "...";
|
||||
}
|
||||
|
||||
// Colors by event so a channel reads at a glance
|
||||
int Color(const std::string& event) {
|
||||
if (event == "security" || event == "economy_flag") return 0xD03B3B;
|
||||
if (event == "moderation") return 0xEC835A;
|
||||
if (event == "server") return 0xFAB219;
|
||||
if (event == "bug_report") return 0x3987E5;
|
||||
return 0x0CA30C;
|
||||
}
|
||||
|
||||
std::string IsoTime(int64_t time) {
|
||||
const auto t = static_cast<std::time_t>(time);
|
||||
std::tm tm{};
|
||||
#ifdef _WIN32
|
||||
gmtime_s(&tm, &t);
|
||||
#else
|
||||
gmtime_r(&t, &tm);
|
||||
#endif
|
||||
char buffer[32];
|
||||
std::strftime(buffer, sizeof(buffer), "%Y-%m-%dT%H:%M:%SZ", &tm);
|
||||
return buffer;
|
||||
}
|
||||
}
|
||||
|
||||
namespace WebhookFormat {
|
||||
const std::vector<EventInfo>& Events() {
|
||||
static const std::vector<EventInfo> events{
|
||||
{ "bug_report", "A player submitted a bug report" },
|
||||
{ "pending_name", "A character name is waiting for approval" },
|
||||
{ "moderation", "Bans, locks, mutes, kicks and restrictions, from the dashboard or in game" },
|
||||
{ "security", "GM level changes, two-factor login changes, API tokens, recovery code use" },
|
||||
{ "economy_flag", "The nightly checks flagged unusual income, an item spike or a duplicated item" },
|
||||
{ "server", "Auth or chat went offline or came back, restarts scheduled or cancelled" },
|
||||
{ "test", "Test messages sent from the webhooks page" },
|
||||
};
|
||||
return events;
|
||||
}
|
||||
|
||||
bool IsKnownEvent(const std::string& event) {
|
||||
return std::ranges::any_of(Events(), [&](const EventInfo& info) { return event == info.name; });
|
||||
}
|
||||
|
||||
bool Matches(const std::string& events, const std::string& event) {
|
||||
if (event == "test") return true;
|
||||
for (auto name : GeneralUtils::SplitString(events, ',')) {
|
||||
name.erase(0, name.find_first_not_of(' '));
|
||||
name.erase(name.find_last_not_of(' ') + 1);
|
||||
if (name == "*" || name == event) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string NormalizeEvents(const std::string& events) {
|
||||
std::string out;
|
||||
for (auto name : GeneralUtils::SplitString(events, ',')) {
|
||||
name.erase(0, name.find_first_not_of(' '));
|
||||
name.erase(name.find_last_not_of(' ') + 1);
|
||||
if (name == "*") return "*";
|
||||
if (!IsKnownEvent(name) || name == "test") continue;
|
||||
if (!out.empty()) out += ',';
|
||||
out += name;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool IsKnownFormat(const std::string& format) {
|
||||
return format == "discord" || format == "slack" || format == "json";
|
||||
}
|
||||
|
||||
std::string BuildBody(const std::string& format, const Alert& alert) {
|
||||
if (format == "discord") {
|
||||
nlohmann::json fields = nlohmann::json::array();
|
||||
for (const auto& field : alert.fields) {
|
||||
if (fields.size() >= MAX_FIELDS) break;
|
||||
fields.push_back({ {"name", Cut(field.name, MAX_FIELD_NAME)}, {"value", Cut(field.value.empty() ? "-" : field.value, MAX_FIELD_VALUE)}, {"inline", field.value.size() < 40} });
|
||||
}
|
||||
nlohmann::json embed{
|
||||
{"title", Cut(alert.title, MAX_TITLE)}, {"description", Cut(alert.description, MAX_DESCRIPTION)},
|
||||
{"color", Color(alert.event)}, {"fields", fields}, {"timestamp", IsoTime(alert.time)},
|
||||
{"footer", { {"text", "DarkflameServer · " + alert.event} }}
|
||||
};
|
||||
if (!alert.url.empty()) embed["url"] = alert.url;
|
||||
// No mentions from player-written text (bug reports, names)
|
||||
return nlohmann::json{ {"embeds", nlohmann::json::array({ embed })}, {"allowed_mentions", { {"parse", nlohmann::json::array()} }} }.dump();
|
||||
}
|
||||
if (format == "slack") {
|
||||
std::string text = "*" + Cut(alert.title, MAX_TITLE) + "*";
|
||||
if (!alert.description.empty()) text += "\n" + Cut(alert.description, MAX_DESCRIPTION);
|
||||
for (const auto& field : alert.fields) text += "\n• *" + field.name + ":* " + Cut(field.value, MAX_FIELD_VALUE);
|
||||
if (!alert.url.empty()) text += "\n<" + alert.url + "|Open in dashboard>";
|
||||
return nlohmann::json{ {"text", text} }.dump();
|
||||
}
|
||||
nlohmann::json fields = nlohmann::json::object();
|
||||
for (const auto& field : alert.fields) fields[field.name] = field.value;
|
||||
return nlohmann::json{
|
||||
{"event", alert.event}, {"title", alert.title}, {"description", alert.description},
|
||||
{"fields", fields}, {"url", alert.url}, {"time", alert.time}
|
||||
}.dump();
|
||||
}
|
||||
|
||||
std::string Sign(const std::string& secret, const std::string& body) {
|
||||
unsigned char digest[EVP_MAX_MD_SIZE];
|
||||
unsigned int length = 0;
|
||||
HMAC(EVP_sha256(), secret.data(), static_cast<int>(secret.size()), reinterpret_cast<const unsigned char*>(body.data()), body.size(), digest, &length);
|
||||
static constexpr char digits[] = "0123456789abcdef";
|
||||
std::string out;
|
||||
for (unsigned int i = 0; i < length; i++) { out += digits[digest[i] >> 4]; out += digits[digest[i] & 15]; }
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<std::string> ValidateUrl(const std::string& url) {
|
||||
if (url.size() > 2000) return "That URL is too long";
|
||||
const bool https = url.starts_with("https://"), http = url.starts_with("http://");
|
||||
if (!https && !http) return "Use an http:// or https:// URL";
|
||||
const auto hostStart = url.find("://") + 3;
|
||||
const auto hostEnd = std::min(url.find_first_of("/?#", hostStart), url.size());
|
||||
const auto authority = url.substr(hostStart, hostEnd - hostStart);
|
||||
if (authority.empty()) return "The URL has no host";
|
||||
if (authority.find('@') != std::string::npos) return "Put credentials in the secret, not the URL";
|
||||
if (std::ranges::any_of(url, [](char c) { return static_cast<unsigned char>(c) <= ' ' || c == '"' || c == '\\'; })) return "The URL contains spaces or control characters";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::string MaskUrl(const std::string& url) {
|
||||
const auto schemeEnd = url.find("://");
|
||||
if (schemeEnd == std::string::npos) return "…";
|
||||
// Any part of the path can be the secret (Discord and Slack put tokens there), so only the host is shown
|
||||
const auto pathStart = url.find_first_of("/?#", schemeEnd + 3);
|
||||
if (pathStart == std::string::npos) return url;
|
||||
const bool hasPath = url.size() > pathStart + 1;
|
||||
return url.substr(0, pathStart) + (hasPath ? "/…" : "");
|
||||
}
|
||||
}
|
||||
57
dDashboardServer/notify/WebhookFormat.h
Normal file
57
dDashboardServer/notify/WebhookFormat.h
Normal file
@@ -0,0 +1,57 @@
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
/**
|
||||
* Pure parts of outgoing webhooks: which events a webhook wants, the request body for each supported format and
|
||||
* the signature for generic JSON receivers. No network or database, so they can be unit tested.
|
||||
*/
|
||||
namespace WebhookFormat {
|
||||
struct Field {
|
||||
std::string name;
|
||||
std::string value;
|
||||
};
|
||||
|
||||
struct Alert {
|
||||
std::string event; // e.g. "bug_report"
|
||||
std::string title;
|
||||
std::string description;
|
||||
std::vector<Field> fields;
|
||||
std::string url; // absolute link into the dashboard, may be empty
|
||||
int64_t time{}; // unix seconds
|
||||
};
|
||||
|
||||
struct EventInfo {
|
||||
const char* name;
|
||||
const char* description;
|
||||
};
|
||||
|
||||
// Every event a webhook can subscribe to
|
||||
const std::vector<EventInfo>& Events();
|
||||
bool IsKnownEvent(const std::string& event);
|
||||
|
||||
// events is "*" or a comma separated list
|
||||
bool Matches(const std::string& events, const std::string& event);
|
||||
|
||||
// Keep only known event names ("*" stays as is); returns the cleaned list
|
||||
std::string NormalizeEvents(const std::string& events);
|
||||
|
||||
bool IsKnownFormat(const std::string& format);
|
||||
|
||||
// Request body for "discord", "slack" or "json"
|
||||
std::string BuildBody(const std::string& format, const Alert& alert);
|
||||
|
||||
// Hex HMAC-SHA256 of the body, sent as X-DLU-Signature: sha256=<hex> when the webhook has a secret
|
||||
std::string Sign(const std::string& secret, const std::string& body);
|
||||
|
||||
// http(s) URL without credentials; returns an error message or nothing when fine
|
||||
std::optional<std::string> ValidateUrl(const std::string& url);
|
||||
|
||||
// "https://discord.com/…": the host only, since tokens live in the path
|
||||
std::string MaskUrl(const std::string& url);
|
||||
}
|
||||
Reference in New Issue
Block a user