mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
Online state with the time it came up, down/up alerts, a UGC column in the health history, /api/servers (every server with its process memory and CPU) and /api/servers/ugc, and UGC gauges for Prometheus. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
159 lines
6.4 KiB
C++
159 lines
6.4 KiB
C++
#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, chat or the UGC server 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 ? "/…" : "");
|
|
}
|
|
}
|