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:
Aaron Kimbrell
2026-09-26 17:52:10 -05:00
parent 854d02f509
commit e213a7aeff
681 changed files with 88310 additions and 1321 deletions

View File

@@ -0,0 +1,268 @@
#include "EmailService.h"
#include <condition_variable>
#include <deque>
#include <mutex>
#include <thread>
#include "Game.h"
#include "Logger.h"
#include "dConfig.h"
#include "GeneralUtils.h"
#include "BinaryPathFinder.h"
#include "OAuth2.h"
namespace {
using Task = std::function<std::optional<std::string>()>;
struct Job {
Task task;
EmailService::Completion onComplete;
};
struct Finished {
EmailService::Completion onComplete;
std::optional<std::string> error;
};
// Written in Initialize before the worker starts, read-only afterwards
Smtp::Config g_Config;
std::string g_BaseUrl;
bool g_Configured = false;
bool g_UseOAuth2 = false;
std::string g_OAuth2ConfigError;
OAuth2::TokenManager g_Tokens; // token operations run on the worker only
std::thread g_Worker;
std::mutex g_Mutex;
std::condition_variable g_Wake;
std::deque<Job> g_Jobs;
std::deque<Finished> g_Finished;
bool g_Stopping = false;
void WorkerLoop() {
while (true) {
Job job;
{
std::unique_lock lock(g_Mutex);
g_Wake.wait(lock, [] { return g_Stopping || !g_Jobs.empty(); });
if (g_Jobs.empty()) return; // stopping and drained
job = std::move(g_Jobs.front());
g_Jobs.pop_front();
}
std::optional<std::string> error;
try {
error = job.task();
} catch (const std::exception& ex) {
error = std::string("Unexpected error: ") + ex.what();
}
std::lock_guard lock(g_Mutex);
g_Finished.push_back({ std::move(job.onComplete), std::move(error) });
}
}
void Enqueue(Task task, EmailService::Completion onComplete) {
{
std::lock_guard lock(g_Mutex);
g_Jobs.push_back({ std::move(task), std::move(onComplete) });
}
g_Wake.notify_one();
}
// Runs on the worker thread
std::optional<std::string> SendNow(const Smtp::Message& message) {
if (!g_UseOAuth2) return Smtp::Send(g_Config, message);
// Try with the cached token first; if the server rejects it, fetch a fresh one and retry once
for (int attempt = 0; attempt < 2; attempt++) {
std::string tokenError;
const auto token = g_Tokens.GetAccessToken(attempt > 0, tokenError);
if (!token) return tokenError;
auto config = g_Config;
config.oauth2Token = *token;
bool authFailed = false;
auto error = Smtp::Send(config, message, &authFailed);
if (!error || !authFailed) return error;
if (attempt == 1) return *error + " (check that the connected account is allowed to send as " + g_Config.fromAddress + ")";
}
return std::nullopt;
}
std::string Setting(const std::string& key, const std::string& fallback = "") {
const auto value = Game::config->GetValue(key);
return value.empty() ? fallback : value;
}
void ConfigureOAuth2() {
OAuth2::Config oauth;
oauth.provider = Setting("smtp_oauth2_provider", "custom");
oauth.grant = Setting("smtp_oauth2_grant") == "client_credentials" ? OAuth2::eGrant::CLIENT_CREDENTIALS : OAuth2::eGrant::AUTHORIZATION_CODE;
oauth.clientId = Setting("smtp_oauth2_client_id");
oauth.clientSecret = Setting("smtp_oauth2_client_secret");
oauth.authorizeUrl = Setting("smtp_oauth2_authorize_url");
oauth.tokenUrl = Setting("smtp_oauth2_token_url");
oauth.scope = Setting("smtp_oauth2_scope");
oauth.refreshToken = Setting("smtp_oauth2_refresh_token");
oauth.tokenFile = (BinaryPathFinder::GetBinaryDir() / "dashboard_oauth2_token.json").string();
oauth.timeoutSeconds = g_Config.timeoutSeconds;
if (const auto error = OAuth2::ApplyProviderDefaults(oauth, Setting("smtp_oauth2_tenant"))) {
g_OAuth2ConfigError = *error;
return;
}
g_Tokens.Configure(std::move(oauth));
}
}
namespace EmailService {
void Initialize() {
g_UseOAuth2 = Setting("smtp_auth", "password") == "oauth2";
const auto provider = Setting("smtp_oauth2_provider");
// Known providers have fixed SMTP servers
std::string defaultHost;
if (g_UseOAuth2 && provider == "microsoft") defaultHost = "smtp.office365.com";
else if (g_UseOAuth2 && provider == "google") defaultHost = "smtp.gmail.com";
g_Config.host = Setting("smtp_host", defaultHost);
g_Config.port = GeneralUtils::TryParse<uint16_t>(Setting("smtp_port")).value_or(587);
const auto security = Setting("smtp_security", "starttls");
g_Config.security = security == "tls" ? Smtp::eSecurity::TLS : security == "none" ? Smtp::eSecurity::NONE : Smtp::eSecurity::STARTTLS;
g_Config.password = Setting("smtp_password");
g_Config.fromAddress = Setting("smtp_from_address");
// With OAuth2 the login is the mailbox address, which is normally the sender
g_Config.username = Setting("smtp_username", g_UseOAuth2 ? g_Config.fromAddress : "");
g_Config.fromName = Setting("smtp_from_name", "DarkflameServer");
g_Config.caFile = Setting("smtp_ca_file");
g_Config.verifyCertificate = Setting("smtp_verify_certificate", "1") != "0";
g_Config.timeoutSeconds = GeneralUtils::TryParse<uint32_t>(Setting("smtp_timeout")).value_or(20);
g_BaseUrl = Setting("dashboard_url");
while (!g_BaseUrl.empty() && g_BaseUrl.back() == '/') g_BaseUrl.pop_back();
g_Configured = !g_Config.host.empty() && !g_Config.fromAddress.empty() && !g_BaseUrl.empty();
if (!g_Configured) {
LOG("Email is disabled; set smtp_host, smtp_from_address and dashboard_url in dashboardconfig.ini to enable it");
return;
}
if (!Smtp::IsValidAddress(g_Config.fromAddress)) {
LOG("smtp_from_address '%s' is not a valid address; email is disabled", g_Config.fromAddress.c_str());
g_Configured = false;
return;
}
if (g_UseOAuth2) {
ConfigureOAuth2();
if (!g_OAuth2ConfigError.empty()) {
LOG("Email OAuth2 settings are incomplete: %s; email is disabled", g_OAuth2ConfigError.c_str());
g_Configured = false;
return;
}
}
if (!g_Config.verifyCertificate) LOG("WARNING: SMTP certificate verification is disabled (smtp_verify_certificate=0)");
if (g_Config.security == Smtp::eSecurity::NONE) LOG("WARNING: SMTP is configured without TLS (smtp_security=none)");
Smtp::GlobalInit();
g_Stopping = false;
g_Worker = std::thread(WorkerLoop);
LOG("Email enabled via %s:%u%s", g_Config.host.c_str(), g_Config.port, g_UseOAuth2 ? " using OAuth2" : "");
}
void Shutdown() {
if (!g_Worker.joinable()) return;
{
std::lock_guard lock(g_Mutex);
g_Stopping = true;
}
g_Wake.notify_all();
g_Worker.join();
Update();
Smtp::GlobalCleanup();
}
bool IsConfigured() {
return g_Configured;
}
std::string BaseUrl() {
return g_BaseUrl;
}
void Send(Smtp::Message message, Completion onComplete) {
if (!g_Configured) {
if (onComplete) onComplete("Email is not configured on this server");
return;
}
Enqueue([message = std::move(message)]() { return SendNow(message); }, std::move(onComplete));
}
void RunOnWorker(std::function<std::optional<std::string>()> task, Completion onComplete) {
if (!g_Configured) {
if (onComplete) onComplete("Email is not configured on this server");
return;
}
Enqueue(std::move(task), std::move(onComplete));
}
void Update() {
std::deque<Finished> finished;
{
std::lock_guard lock(g_Mutex);
finished.swap(g_Finished);
}
for (auto& item : finished) {
if (item.error) LOG("Email task failed: %s", item.error->c_str());
if (item.onComplete) item.onComplete(item.error);
}
}
bool UsesOAuth2() {
return g_UseOAuth2;
}
nlohmann::json OAuth2Status() {
nlohmann::json status{
{"configured", g_Configured},
{"auth", g_UseOAuth2 ? "oauth2" : "password"},
{"host", g_Config.host},
{"from", g_Config.fromAddress}
};
if (!g_UseOAuth2) return status;
if (!g_OAuth2ConfigError.empty()) {
status["error"] = g_OAuth2ConfigError;
return status;
}
const auto& oauth = g_Tokens.GetConfig();
status["provider"] = oauth.provider;
status["grant"] = oauth.grant == OAuth2::eGrant::CLIENT_CREDENTIALS ? "client_credentials" : "authorization_code";
status["account"] = g_Config.username;
status["connected"] = g_Tokens.HasCredentials();
status["canConnect"] = oauth.grant == OAuth2::eGrant::AUTHORIZATION_CODE && !oauth.authorizeUrl.empty();
status["redirectUri"] = OAuth2RedirectUri();
const auto lastError = g_Tokens.LastError();
if (!lastError.empty()) status["error"] = lastError;
return status;
}
std::string OAuth2RedirectUri() {
return g_BaseUrl + "/oauth2/callback";
}
std::string OAuth2AuthorizeUrl(const std::string& state, const std::string& codeChallenge) {
if (!g_Configured || !g_UseOAuth2) return "";
const auto& oauth = g_Tokens.GetConfig();
if (oauth.grant != OAuth2::eGrant::AUTHORIZATION_CODE || oauth.authorizeUrl.empty()) return "";
return OAuth2::BuildAuthorizeUrl(oauth, OAuth2RedirectUri(), state, codeChallenge);
}
void OAuth2Connect(const std::string& code, const std::string& codeVerifier, Completion onComplete) {
RunOnWorker([code, codeVerifier]() { return g_Tokens.ExchangeCode(code, codeVerifier, OAuth2RedirectUri()); }, std::move(onComplete));
}
void OAuth2Disconnect(Completion onComplete) {
RunOnWorker([]() -> std::optional<std::string> { g_Tokens.Disconnect(); return std::nullopt; }, std::move(onComplete));
}
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include <functional>
#include <optional>
#include <string>
#include "SmtpClient.h"
#include "json.hpp"
/**
* Sends dashboard emails (password resets, address verification) in the background.
* SMTP runs on a worker thread; completion callbacks run on the main thread from Update().
*/
namespace EmailService {
// Called with nullopt on success or the error message
using Completion = std::function<void(const std::optional<std::string>& error)>;
// Read SMTP settings from the dashboard config and start the worker. Safe to call when unconfigured.
void Initialize();
void Shutdown();
// Whether smtp_host, smtp_from_address and dashboard_url are set
bool IsConfigured();
// Public base URL used for links in emails (dashboard_url), without a trailing slash
std::string BaseUrl();
// Queue a message. The completion runs on the main thread.
void Send(Smtp::Message message, Completion onComplete = nullptr);
// Run blocking work (e.g. talking to an OAuth2 provider) on the email worker thread
void RunOnWorker(std::function<std::optional<std::string>()> task, Completion onComplete);
// ---- OAuth2 (smtp_auth=oauth2) ----
bool UsesOAuth2();
// Status for the operator UI: provider, grant, whether an account is connected, last error
nlohmann::json OAuth2Status();
// Where the provider sends the operator back after signing in
std::string OAuth2RedirectUri();
// Authorization URL for connecting a mailbox. Empty if OAuth2 isn't set up for the connect flow.
std::string OAuth2AuthorizeUrl(const std::string& state, const std::string& codeChallenge);
// Exchange the code from the provider on the worker thread
void OAuth2Connect(const std::string& code, const std::string& codeVerifier, Completion onComplete);
void OAuth2Disconnect(Completion onComplete);
// Run completions for finished sends. Call once per tick from the main loop.
void Update();
}

View File

@@ -0,0 +1,307 @@
#include "OAuth2.h"
#include <chrono>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <curl/curl.h>
#include <openssl/sha.h>
#include "json.hpp"
namespace {
int64_t Now() {
return static_cast<int64_t>(std::time(nullptr));
}
size_t WriteBody(char* data, size_t size, size_t count, void* userData) {
static_cast<std::string*>(userData)->append(data, size * count);
return size * count;
}
}
namespace OAuth2 {
std::optional<std::string> ApplyProviderDefaults(Config& config, const std::string& tenant) {
const bool appOnly = config.grant == eGrant::CLIENT_CREDENTIALS;
if (config.provider == "microsoft") {
const std::string base = "https://login.microsoftonline.com/" + (tenant.empty() ? std::string("common") : tenant) + "/oauth2/v2.0/";
if (config.authorizeUrl.empty()) config.authorizeUrl = base + "authorize";
if (config.tokenUrl.empty()) config.tokenUrl = base + "token";
if (config.scope.empty()) {
config.scope = appOnly ? "https://outlook.office365.com/.default" : "https://outlook.office.com/SMTP.Send offline_access";
}
if (appOnly && (tenant.empty() || tenant == "common" || tenant == "organizations")) {
return "Microsoft app-only access needs smtp_oauth2_tenant set to your tenant ID or domain";
}
} else if (config.provider == "google") {
if (appOnly) return "Google does not support client_credentials for SMTP; use authorization_code";
if (config.authorizeUrl.empty()) config.authorizeUrl = "https://accounts.google.com/o/oauth2/v2/auth";
if (config.tokenUrl.empty()) config.tokenUrl = "https://oauth2.googleapis.com/token";
if (config.scope.empty()) config.scope = "https://mail.google.com/";
} else if (config.provider != "custom") {
return "Unknown smtp_oauth2_provider '" + config.provider + "' (use microsoft, google or custom)";
}
if (config.clientId.empty()) return "smtp_oauth2_client_id is required";
if (config.tokenUrl.empty()) return "smtp_oauth2_token_url is required";
if (!appOnly && config.authorizeUrl.empty() && config.refreshToken.empty()) {
return "smtp_oauth2_authorize_url is required to connect an account";
}
return std::nullopt;
}
std::string UrlEncode(std::string_view value) {
static constexpr char hex[] = "0123456789ABCDEF";
std::string out;
for (const char c : value) {
const auto u = static_cast<unsigned char>(c);
if (std::isalnum(u) || c == '-' || c == '_' || c == '.' || c == '~') {
out += c;
} else {
out += '%';
out += hex[u >> 4];
out += hex[u & 0xf];
}
}
return out;
}
std::string FormEncode(const std::map<std::string, std::string>& fields) {
std::string out;
for (const auto& [key, value] : fields) {
if (!out.empty()) out += '&';
out += UrlEncode(key) + "=" + UrlEncode(value);
}
return out;
}
std::string Base64Url(std::string_view data) {
static constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
std::string out;
size_t i = 0;
for (; i + 2 < data.size(); i += 3) {
const uint32_t triple = (static_cast<unsigned char>(data[i]) << 16) | (static_cast<unsigned char>(data[i + 1]) << 8) | static_cast<unsigned char>(data[i + 2]);
out += alphabet[(triple >> 18) & 0x3f];
out += alphabet[(triple >> 12) & 0x3f];
out += alphabet[(triple >> 6) & 0x3f];
out += alphabet[triple & 0x3f];
}
if (i < data.size()) {
const uint32_t b0 = static_cast<unsigned char>(data[i]);
const uint32_t b1 = i + 1 < data.size() ? static_cast<unsigned char>(data[i + 1]) : 0;
const uint32_t triple = (b0 << 16) | (b1 << 8);
out += alphabet[(triple >> 18) & 0x3f];
out += alphabet[(triple >> 12) & 0x3f];
if (i + 1 < data.size()) out += alphabet[(triple >> 6) & 0x3f];
}
return out;
}
std::string PkceChallenge(const std::string& verifier) {
unsigned char digest[SHA256_DIGEST_LENGTH];
SHA256(reinterpret_cast<const unsigned char*>(verifier.data()), verifier.size(), digest);
return Base64Url(std::string_view(reinterpret_cast<const char*>(digest), sizeof(digest)));
}
std::string BuildAuthorizeUrl(const Config& config, const std::string& redirectUri, const std::string& state, const std::string& codeChallenge) {
std::map<std::string, std::string> fields{
{"response_type", "code"},
{"client_id", config.clientId},
{"redirect_uri", redirectUri},
{"scope", config.scope},
{"state", state},
{"code_challenge", codeChallenge},
{"code_challenge_method", "S256"}
};
if (config.provider == "google") {
// Google only issues a refresh token for offline access, and only on consent
fields["access_type"] = "offline";
fields["prompt"] = "consent";
} else if (config.provider == "microsoft") {
fields["prompt"] = "select_account";
}
const char separator = config.authorizeUrl.find('?') == std::string::npos ? '?' : '&';
return config.authorizeUrl + separator + FormEncode(fields);
}
std::optional<TokenResponse> ParseTokenResponse(const std::string& body, std::string& error) {
const auto json = nlohmann::json::parse(body, nullptr, false);
if (json.is_discarded() || !json.is_object()) {
error = "Token endpoint returned an unreadable response";
return std::nullopt;
}
if (json.contains("error")) {
error = json.value("error_description", json["error"].is_string() ? json["error"].get<std::string>() : "unknown error");
return std::nullopt;
}
if (!json.contains("access_token") || !json["access_token"].is_string()) {
error = "Token endpoint response has no access_token";
return std::nullopt;
}
TokenResponse response;
response.accessToken = json["access_token"].get<std::string>();
if (json.contains("refresh_token") && json["refresh_token"].is_string()) response.refreshToken = json["refresh_token"].get<std::string>();
if (json.contains("expires_in")) {
const auto& expires = json["expires_in"];
if (expires.is_number_integer()) response.expiresIn = expires.get<int64_t>();
else if (expires.is_string()) response.expiresIn = std::strtoll(expires.get<std::string>().c_str(), nullptr, 10);
}
return response;
}
void TokenManager::Configure(Config config) {
m_Config = std::move(config);
m_RefreshToken = LoadStoredRefreshToken();
if (m_RefreshToken.empty()) m_RefreshToken = m_Config.refreshToken;
m_AccessToken.clear();
m_AccessTokenExpires = 0;
std::lock_guard lock(m_StatusMutex);
m_HasRefreshToken = m_Config.grant == eGrant::CLIENT_CREDENTIALS || !m_RefreshToken.empty();
}
bool TokenManager::HasCredentials() {
std::lock_guard lock(m_StatusMutex);
return m_HasRefreshToken;
}
std::string TokenManager::LastError() {
std::lock_guard lock(m_StatusMutex);
return m_LastError;
}
void TokenManager::SetError(const std::string& error) {
std::lock_guard lock(m_StatusMutex);
m_LastError = error;
}
std::optional<std::string> TokenManager::GetAccessToken(bool forceRefresh, std::string& error) {
// Refresh a minute early so a token never expires mid-send
if (!forceRefresh && !m_AccessToken.empty() && Now() < m_AccessTokenExpires - 60) return m_AccessToken;
std::map<std::string, std::string> fields{ {"client_id", m_Config.clientId} };
if (!m_Config.clientSecret.empty()) fields["client_secret"] = m_Config.clientSecret;
if (m_Config.grant == eGrant::CLIENT_CREDENTIALS) {
fields["grant_type"] = "client_credentials";
fields["scope"] = m_Config.scope;
} else {
if (m_RefreshToken.empty()) {
error = "No mail account is connected; an operator must connect one on their account page";
SetError(error);
return std::nullopt;
}
fields["grant_type"] = "refresh_token";
fields["refresh_token"] = m_RefreshToken;
}
TokenResponse response;
if (auto requestError = RequestToken(fields, response)) {
error = "Could not get an access token: " + *requestError;
SetError(error);
return std::nullopt;
}
m_AccessToken = response.accessToken;
m_AccessTokenExpires = Now() + response.expiresIn;
// Some providers (Microsoft) rotate refresh tokens; keep the newest one
if (!response.refreshToken.empty() && response.refreshToken != m_RefreshToken) StoreRefreshToken(response.refreshToken);
SetError("");
return m_AccessToken;
}
std::optional<std::string> TokenManager::ExchangeCode(const std::string& code, const std::string& codeVerifier, const std::string& redirectUri) {
std::map<std::string, std::string> fields{
{"grant_type", "authorization_code"},
{"client_id", m_Config.clientId},
{"code", code},
{"code_verifier", codeVerifier},
{"redirect_uri", redirectUri}
};
if (!m_Config.clientSecret.empty()) fields["client_secret"] = m_Config.clientSecret;
TokenResponse response;
if (auto error = RequestToken(fields, response)) {
SetError(*error);
return error;
}
if (response.refreshToken.empty()) {
const std::string error = "The provider did not return a refresh token (for Google, remove the app's access at myaccount.google.com/permissions and connect again)";
SetError(error);
return error;
}
StoreRefreshToken(response.refreshToken);
m_AccessToken = response.accessToken;
m_AccessTokenExpires = Now() + response.expiresIn;
SetError("");
return std::nullopt;
}
void TokenManager::Disconnect() {
m_RefreshToken = m_Config.refreshToken;
{
std::lock_guard lock(m_StatusMutex);
m_HasRefreshToken = m_Config.grant == eGrant::CLIENT_CREDENTIALS || !m_RefreshToken.empty();
}
m_AccessToken.clear();
m_AccessTokenExpires = 0;
std::error_code ec;
std::filesystem::remove(m_Config.tokenFile, ec);
}
std::optional<std::string> TokenManager::RequestToken(const std::map<std::string, std::string>& fields, TokenResponse& response) {
CURL* curl = curl_easy_init();
if (!curl) return "Could not initialise libcurl";
const std::string body = FormEncode(fields);
std::string responseBody;
char errorBuffer[CURL_ERROR_SIZE]{};
curl_slist* headers = curl_slist_append(nullptr, "Accept: application/json");
curl_easy_setopt(curl, CURLOPT_URL, m_Config.tokenUrl.c_str());
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body.c_str());
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteBody);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseBody);
curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errorBuffer);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, static_cast<long>(m_Config.timeoutSeconds));
// Client secrets and tokens must only travel over HTTPS, except to a local test server
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "https,http");
const CURLcode result = curl_easy_perform(curl);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
if (result != CURLE_OK) return std::string(errorBuffer[0] ? errorBuffer : curl_easy_strerror(result));
std::string error;
auto parsed = ParseTokenResponse(responseBody, error);
if (!parsed) return error;
response = *parsed;
return std::nullopt;
}
void TokenManager::StoreRefreshToken(const std::string& refreshToken) {
m_RefreshToken = refreshToken;
{
std::lock_guard lock(m_StatusMutex);
m_HasRefreshToken = true;
}
if (m_Config.tokenFile.empty()) return;
const nlohmann::json data{ {"provider", m_Config.provider}, {"client_id", m_Config.clientId}, {"refresh_token", refreshToken} };
{
std::ofstream out(m_Config.tokenFile, std::ios::trunc);
out << data.dump();
}
std::error_code ec;
std::filesystem::permissions(m_Config.tokenFile, std::filesystem::perms::owner_read | std::filesystem::perms::owner_write, std::filesystem::perm_options::replace, ec);
}
std::string TokenManager::LoadStoredRefreshToken() {
std::ifstream in(m_Config.tokenFile);
if (!in) return "";
const auto data = nlohmann::json::parse(in, nullptr, false);
if (data.is_discarded() || !data.is_object()) return "";
// A token issued to a different app registration is useless; ignore it
if (data.value("client_id", "") != m_Config.clientId || data.value("provider", "") != m_Config.provider) return "";
return data.value("refresh_token", "");
}
}

View File

@@ -0,0 +1,101 @@
#pragma once
#include <cstdint>
#include <map>
#include <mutex>
#include <optional>
#include <string>
#include <string_view>
/**
* OAuth2 for SMTP (XOAUTH2), so the dashboard can send through Microsoft 365 or Google accounts
* without a mailbox password.
*
* Two ways to get tokens:
* - authorization_code: an operator connects the mailbox once from the dashboard; the refresh token is kept
* in a file next to the binary and used to get short-lived access tokens.
* - client_credentials: app-only access (Microsoft 365 "SMTP.SendAsApp"); no sign-in needed.
*/
namespace OAuth2 {
enum class eGrant : uint8_t {
AUTHORIZATION_CODE,
CLIENT_CREDENTIALS
};
struct Config {
std::string provider; // microsoft, google or custom
eGrant grant{ eGrant::AUTHORIZATION_CODE };
std::string clientId;
std::string clientSecret;
std::string authorizeUrl;
std::string tokenUrl;
std::string scope;
std::string refreshToken; // optional, from config; a connected account takes precedence
std::string tokenFile; // where a connected account's refresh token is stored
uint32_t timeoutSeconds{ 20 };
};
// Fill in URLs and scopes for known providers. Returns an error for unknown providers or missing settings.
std::optional<std::string> ApplyProviderDefaults(Config& config, const std::string& tenant);
// ---- Pure helpers, exposed for testing ----
std::string UrlEncode(std::string_view value);
std::string FormEncode(const std::map<std::string, std::string>& fields);
// Base64url without padding (RFC 4648 section 5)
std::string Base64Url(std::string_view data);
// PKCE S256 challenge for a verifier
std::string PkceChallenge(const std::string& verifier);
std::string BuildAuthorizeUrl(const Config& config, const std::string& redirectUri, const std::string& state, const std::string& codeChallenge);
struct TokenResponse {
std::string accessToken;
std::string refreshToken; // empty if the provider didn't issue a new one
int64_t expiresIn{ 3600 };
};
// Parse a token endpoint response body; returns the provider's error description on failure
std::optional<TokenResponse> ParseTokenResponse(const std::string& body, std::string& error);
/**
* Access token cache. Token operations (Get/Exchange/Disconnect) block on HTTPS and must only run on the
* email worker thread; HasCredentials and LastError are safe from any thread.
*/
class TokenManager {
public:
void Configure(Config config);
// A valid access token, refreshing if needed. Blocking (HTTPS request).
std::optional<std::string> GetAccessToken(bool forceRefresh, std::string& error);
// Exchange an authorization code from the connect flow and store the refresh token. Blocking.
std::optional<std::string> ExchangeCode(const std::string& code, const std::string& codeVerifier, const std::string& redirectUri);
// Forget the connected account
void Disconnect();
bool HasCredentials();
std::string LastError();
const Config& GetConfig() const { return m_Config; }
private:
std::optional<std::string> RequestToken(const std::map<std::string, std::string>& fields, TokenResponse& response);
void StoreRefreshToken(const std::string& refreshToken);
std::string LoadStoredRefreshToken();
void SetError(const std::string& error);
// Worker thread only
Config m_Config;
std::string m_RefreshToken;
std::string m_AccessToken;
int64_t m_AccessTokenExpires{ 0 };
// Shared with the main thread
std::mutex m_StatusMutex;
bool m_HasRefreshToken{ false };
std::string m_LastError;
};
}

View File

@@ -0,0 +1,184 @@
#include "SmtpClient.h"
#include <algorithm>
#include <ctime>
#include <random>
#include <curl/curl.h>
namespace {
std::string Domain(const std::string& address) {
const auto at = address.rfind('@');
return at == std::string::npos ? "localhost" : address.substr(at + 1);
}
std::string RfcDate() {
char date[64];
const auto now = std::time(nullptr);
std::tm utc{};
#ifdef _WIN32
gmtime_s(&utc, &now);
#else
gmtime_r(&now, &utc);
#endif
std::strftime(date, sizeof(date), "%a, %d %b %Y %H:%M:%S +0000", &utc);
return date;
}
struct Upload {
const std::string& data;
size_t offset{ 0 };
};
size_t ReadUpload(char* buffer, size_t size, size_t count, void* userData) {
auto* upload = static_cast<Upload*>(userData);
const size_t length = std::min(size * count, upload->data.size() - upload->offset);
std::copy_n(upload->data.data() + upload->offset, length, buffer);
upload->offset += length;
return length;
}
}
namespace Smtp {
bool IsValidAddress(std::string_view address) {
if (address.size() < 3 || address.size() > 254) return false;
const auto at = address.find('@');
if (at == std::string_view::npos || at == 0 || at != address.rfind('@') || at == address.size() - 1) return false;
if (address.substr(at + 1).find('.') == std::string_view::npos) return false;
return std::ranges::none_of(address, [](char c) {
const auto u = static_cast<unsigned char>(c);
return u <= 0x20 || u == 0x7f || c == '<' || c == '>' || c == '(' || c == ')' || c == ',' || c == ';' || c == ':' || c == '"' || c == '\\' || c == '[' || c == ']';
});
}
std::string Base64(std::string_view data, bool wrapLines) {
static constexpr char alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string out;
size_t lineLength = 0;
for (size_t i = 0; i < data.size(); i += 3) {
const uint32_t b0 = static_cast<unsigned char>(data[i]);
const uint32_t b1 = i + 1 < data.size() ? static_cast<unsigned char>(data[i + 1]) : 0;
const uint32_t b2 = i + 2 < data.size() ? static_cast<unsigned char>(data[i + 2]) : 0;
const uint32_t triple = (b0 << 16) | (b1 << 8) | b2;
out += alphabet[(triple >> 18) & 0x3f];
out += alphabet[(triple >> 12) & 0x3f];
out += i + 1 < data.size() ? alphabet[(triple >> 6) & 0x3f] : '=';
out += i + 2 < data.size() ? alphabet[triple & 0x3f] : '=';
lineLength += 4;
if (wrapLines && lineLength >= 76 && i + 3 < data.size()) {
out += "\r\n";
lineLength = 0;
}
}
return out;
}
std::string EncodeHeader(std::string_view value) {
const bool plain = std::ranges::all_of(value, [](char c) {
const auto u = static_cast<unsigned char>(c);
return u >= 0x20 && u < 0x7f;
});
if (plain) return std::string(value);
return "=?UTF-8?B?" + Base64(value, false) + "?=";
}
std::string BuildMessage(const Config& config, const Message& message, std::string_view date, std::string_view messageId) {
// Header values never contain CR/LF: addresses are validated and text is encoded or stripped
const auto clean = [](std::string_view s) {
std::string out;
for (const char c : s) if (c != '\r' && c != '\n') out += c;
return out;
};
const std::string fromName = clean(config.fromName);
std::string data;
data += "From: " + (fromName.empty() ? "" : "\"" + EncodeHeader(fromName) + "\" ") + "<" + config.fromAddress + ">\r\n";
data += "To: <" + message.to + ">\r\n";
data += "Subject: " + EncodeHeader(clean(message.subject)) + "\r\n";
data += "Date: " + std::string(date) + "\r\n";
data += "Message-ID: <" + std::string(messageId) + ">\r\n";
data += "MIME-Version: 1.0\r\n";
data += "Content-Type: text/plain; charset=UTF-8\r\n";
data += "Content-Transfer-Encoding: base64\r\n";
data += "\r\n";
// Normalise line endings to CRLF before encoding
std::string body;
for (size_t i = 0; i < message.body.size(); i++) {
if (message.body[i] == '\n' && (i == 0 || message.body[i - 1] != '\r')) body += '\r';
body += message.body[i];
}
data += Base64(body, true) + "\r\n";
// No dot-stuffing here: libcurl escapes lines starting with '.' when uploading SMTP data
return data;
}
void GlobalInit() {
curl_global_init(CURL_GLOBAL_DEFAULT);
}
void GlobalCleanup() {
curl_global_cleanup();
}
std::optional<std::string> Send(const Config& config, const Message& message, bool* authFailed) {
if (authFailed) *authFailed = false;
if (config.host.empty()) return "SMTP is not configured (smtp_host)";
if (!IsValidAddress(config.fromAddress)) return "Invalid sender address (smtp_from_address)";
if (!IsValidAddress(message.to)) return "Invalid recipient address";
// Never send credentials in plain text
if ((!config.username.empty() || !config.oauth2Token.empty()) && config.security == eSecurity::NONE) {
return "Refusing to send SMTP credentials without TLS; set smtp_security to starttls or tls";
}
CURL* curl = curl_easy_init();
if (!curl) return "Could not initialise libcurl";
std::random_device rd;
const std::string messageId = std::to_string(std::time(nullptr)) + "." + std::to_string(rd()) + std::to_string(rd()) + "@" + Domain(config.fromAddress);
const std::string payload = BuildMessage(config, message, RfcDate(), messageId);
Upload upload{ payload };
const std::string scheme = config.security == eSecurity::TLS ? "smtps://" : "smtp://";
const std::string url = scheme + config.host + ":" + std::to_string(config.port) + "/" + Domain(config.fromAddress);
const std::string from = "<" + config.fromAddress + ">";
curl_slist* recipients = curl_slist_append(nullptr, ("<" + message.to + ">").c_str());
char errorBuffer[CURL_ERROR_SIZE]{};
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errorBuffer);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); // we run on a worker thread
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, static_cast<long>(config.timeoutSeconds));
curl_easy_setopt(curl, CURLOPT_TIMEOUT, static_cast<long>(config.timeoutSeconds) * 3);
if (config.security == eSecurity::STARTTLS) curl_easy_setopt(curl, CURLOPT_USE_SSL, static_cast<long>(CURLUSESSL_ALL));
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, config.verifyCertificate ? 1L : 0L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, config.verifyCertificate ? 2L : 0L);
if (!config.caFile.empty()) curl_easy_setopt(curl, CURLOPT_CAINFO, config.caFile.c_str());
if (!config.oauth2Token.empty()) {
// libcurl picks OAUTHBEARER or XOAUTH2 from what the server offers
curl_easy_setopt(curl, CURLOPT_USERNAME, config.username.c_str());
curl_easy_setopt(curl, CURLOPT_XOAUTH2_BEARER, config.oauth2Token.c_str());
} else if (!config.username.empty()) {
curl_easy_setopt(curl, CURLOPT_USERNAME, config.username.c_str());
curl_easy_setopt(curl, CURLOPT_PASSWORD, config.password.c_str());
}
curl_easy_setopt(curl, CURLOPT_MAIL_FROM, from.c_str());
curl_easy_setopt(curl, CURLOPT_MAIL_RCPT, recipients);
curl_easy_setopt(curl, CURLOPT_READFUNCTION, ReadUpload);
curl_easy_setopt(curl, CURLOPT_READDATA, &upload);
curl_easy_setopt(curl, CURLOPT_UPLOAD, 1L);
const CURLcode result = curl_easy_perform(curl);
curl_slist_free_all(recipients);
curl_easy_cleanup(curl);
if (result == CURLE_OK) return std::nullopt;
std::string error = errorBuffer[0] ? errorBuffer : curl_easy_strerror(result);
if (result == CURLE_LOGIN_DENIED) {
if (authFailed) *authFailed = true;
error = "SMTP login failed: " + error;
}
else if (result == CURLE_PEER_FAILED_VERIFICATION) error = "Certificate verification failed: " + error;
return error;
}
}

View File

@@ -0,0 +1,62 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
/**
* SMTP sending for the dashboard's account emails, built on libcurl.
* Supports implicit TLS, STARTTLS and plain connections with the authentication methods the server offers,
* and verifies the server certificate and host name (OS certificate store unless a CA file is given).
*/
namespace Smtp {
enum class eSecurity : uint8_t {
NONE, // Plain text; only for a relay on the same machine or private network
STARTTLS, // Upgrade with STARTTLS (usually port 587)
TLS // TLS from the first byte (usually port 465)
};
struct Config {
std::string host;
uint16_t port{ 587 };
eSecurity security{ eSecurity::STARTTLS };
std::string username;
std::string password;
std::string fromAddress;
std::string fromName;
// PEM bundle to verify the server against. Empty uses the system trust store.
std::string caFile;
bool verifyCertificate{ true };
uint32_t timeoutSeconds{ 20 };
// OAuth2 access token; when set, authenticates with XOAUTH2 as `username` instead of a password
std::string oauth2Token;
};
struct Message {
std::string to;
std::string subject;
std::string body; // Plain text, UTF-8
};
// libcurl process-wide setup; call once before any Send and before starting threads
void GlobalInit();
void GlobalCleanup();
// Send a message. Blocks until done; returns an error description on failure.
// authFailed is set when the server rejected the credentials (e.g. an expired OAuth2 token).
std::optional<std::string> Send(const Config& config, const Message& message, bool* authFailed = nullptr);
// ---- Message formatting, exposed for testing ----
// Loose address check: one '@', no whitespace/control characters or characters that could break headers
bool IsValidAddress(std::string_view address);
// Encode a header value as an RFC 2047 encoded word if it is not plain ASCII
std::string EncodeHeader(std::string_view value);
std::string Base64(std::string_view data, bool wrapLines);
// Full RFC 5322 message (headers + base64 body) for upload; libcurl performs dot-stuffing
std::string BuildMessage(const Config& config, const Message& message, std::string_view date, std::string_view messageId);
}