Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
|
|
@ -9,6 +9,8 @@
|
|||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <cstdlib>
|
||||
#include <gpgme.h>
|
||||
#include <filesystem>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
|
@ -32,153 +34,140 @@ bool AuthService::validatePassword(const std::string& password, std::string& err
|
|||
return true;
|
||||
}
|
||||
|
||||
// Helper function to execute GPG commands
|
||||
std::string executeGpgCommand(const std::string& command) {
|
||||
std::array<char, 128> buffer;
|
||||
std::string result;
|
||||
|
||||
FILE* pipe = popen(command.c_str(), "r");
|
||||
if (!pipe) {
|
||||
LOG_ERROR << "Failed to execute GPG command: " << command;
|
||||
return "";
|
||||
}
|
||||
|
||||
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
|
||||
result += buffer.data();
|
||||
}
|
||||
|
||||
int exitCode = pclose(pipe);
|
||||
|
||||
// Exit code is returned as status << 8, so we need to extract the actual exit code
|
||||
int actualExitCode = WEXITSTATUS(exitCode);
|
||||
|
||||
if (actualExitCode != 0) {
|
||||
LOG_ERROR << "GPG command failed with exit code: " << actualExitCode
|
||||
<< " for command: " << command
|
||||
<< " output: " << result;
|
||||
// Don't return empty string immediately - sometimes GPG returns non-zero but still works
|
||||
}
|
||||
|
||||
return result;
|
||||
// GPGME-based PGP signature verification (no shell commands for security)
|
||||
namespace {
|
||||
// RAII wrapper for gpgme_data_t (named to avoid conflict with deprecated GpgmeData typedef)
|
||||
class GpgmeDataWrapper {
|
||||
public:
|
||||
GpgmeDataWrapper() : data_(nullptr) {}
|
||||
~GpgmeDataWrapper() { if (data_) gpgme_data_release(data_); }
|
||||
gpgme_data_t* ptr() { return &data_; }
|
||||
gpgme_data_t get() { return data_; }
|
||||
bool valid() const { return data_ != nullptr; }
|
||||
private:
|
||||
gpgme_data_t data_;
|
||||
};
|
||||
|
||||
// RAII wrapper for gpgme_ctx_t
|
||||
class GpgmeContextWrapper {
|
||||
public:
|
||||
GpgmeContextWrapper() : ctx_(nullptr) {}
|
||||
~GpgmeContextWrapper() { if (ctx_) gpgme_release(ctx_); }
|
||||
gpgme_ctx_t* ptr() { return &ctx_; }
|
||||
gpgme_ctx_t get() { return ctx_; }
|
||||
bool valid() const { return ctx_ != nullptr; }
|
||||
private:
|
||||
gpgme_ctx_t ctx_;
|
||||
};
|
||||
}
|
||||
|
||||
// Server-side PGP signature verification
|
||||
bool verifyPgpSignature(const std::string& message, const std::string& signature, const std::string& publicKey) {
|
||||
try {
|
||||
// Create temporary directory for GPG operations
|
||||
std::string tmpDir = "/tmp/pgp_verify_" + drogon::utils::genRandomString(8);
|
||||
std::string mkdirCmd = "mkdir -p " + tmpDir;
|
||||
if (system(mkdirCmd.c_str()) != 0) {
|
||||
LOG_ERROR << "Failed to create temporary directory: " << tmpDir;
|
||||
// Initialize GPGME
|
||||
gpgme_check_version(nullptr);
|
||||
|
||||
// Create temporary directory for isolated keyring using filesystem
|
||||
std::string tmpDir = "/tmp/pgp_verify_" + drogon::utils::genRandomString(16);
|
||||
std::filesystem::create_directories(tmpDir);
|
||||
std::filesystem::permissions(tmpDir, std::filesystem::perms::owner_all);
|
||||
|
||||
// Set GNUPGHOME environment for this context
|
||||
std::string gnupgHome = tmpDir;
|
||||
|
||||
// Create GPGME context
|
||||
GpgmeContextWrapper ctx;
|
||||
gpgme_error_t err = gpgme_new(ctx.ptr());
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create GPGME context: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create GPG home directory
|
||||
std::string keyringDir = tmpDir + "/gnupg";
|
||||
std::string mkdirGpgCmd = "mkdir -p " + keyringDir + " && chmod 700 " + keyringDir;
|
||||
if (system(mkdirGpgCmd.c_str()) != 0) {
|
||||
LOG_ERROR << "Failed to create GPG home directory: " << keyringDir;
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
|
||||
// Set the engine info to use our temporary directory
|
||||
err = gpgme_ctx_set_engine_info(ctx.get(), GPGME_PROTOCOL_OpenPGP, nullptr, gnupgHome.c_str());
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to set GPGME engine info: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write files
|
||||
std::string messageFile = tmpDir + "/message.txt";
|
||||
std::string sigFile = tmpDir + "/signature.asc";
|
||||
std::string pubkeyFile = tmpDir + "/pubkey.asc";
|
||||
|
||||
// Write message file
|
||||
std::ofstream msgOut(messageFile);
|
||||
if (!msgOut) {
|
||||
LOG_ERROR << "Failed to create message file: " << messageFile;
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
|
||||
// Set protocol
|
||||
gpgme_set_protocol(ctx.get(), GPGME_PROTOCOL_OpenPGP);
|
||||
|
||||
// Import the public key
|
||||
GpgmeDataWrapper keyData;
|
||||
err = gpgme_data_new_from_mem(keyData.ptr(), publicKey.c_str(), publicKey.size(), 1);
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create key data: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
msgOut << message;
|
||||
msgOut.close();
|
||||
|
||||
// Write signature file
|
||||
std::ofstream sigOut(sigFile);
|
||||
if (!sigOut) {
|
||||
LOG_ERROR << "Failed to create signature file: " << sigFile;
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
|
||||
err = gpgme_op_import(ctx.get(), keyData.get());
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to import public key: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
sigOut << signature;
|
||||
sigOut.close();
|
||||
|
||||
// Write public key file
|
||||
std::ofstream keyOut(pubkeyFile);
|
||||
if (!keyOut) {
|
||||
LOG_ERROR << "Failed to create public key file: " << pubkeyFile;
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
|
||||
gpgme_import_result_t importResult = gpgme_op_import_result(ctx.get());
|
||||
if (!importResult || (importResult->imported == 0 && importResult->unchanged == 0)) {
|
||||
LOG_ERROR << "No keys were imported";
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
keyOut << publicKey;
|
||||
keyOut.close();
|
||||
|
||||
// Initialize GPG (create trustdb if needed)
|
||||
std::string initCmd = "GNUPGHOME=" + keyringDir + " gpg --batch --yes --list-keys 2>&1";
|
||||
executeGpgCommand(initCmd); // This will create the trustdb if it doesn't exist
|
||||
|
||||
// Import the public key to the temporary keyring
|
||||
// Use --trust-model always to avoid trust issues
|
||||
std::string importCmd = "GNUPGHOME=" + keyringDir +
|
||||
" gpg --batch --yes --trust-model always --import " + pubkeyFile + " 2>&1";
|
||||
std::string importResult = executeGpgCommand(importCmd);
|
||||
|
||||
LOG_DEBUG << "GPG import result: " << importResult;
|
||||
|
||||
// Check if import was successful (be more lenient with the check)
|
||||
bool importSuccess = (importResult.find("imported") != std::string::npos) ||
|
||||
(importResult.find("unchanged") != std::string::npos) ||
|
||||
(importResult.find("processed: 1") != std::string::npos) ||
|
||||
(importResult.find("public key") != std::string::npos);
|
||||
|
||||
if (!importSuccess) {
|
||||
LOG_ERROR << "Failed to import public key. Import output: " << importResult;
|
||||
// Try to get more information about what went wrong
|
||||
std::string debugCmd = "GNUPGHOME=" + keyringDir + " gpg --list-keys 2>&1";
|
||||
std::string debugResult = executeGpgCommand(debugCmd);
|
||||
LOG_ERROR << "GPG keyring state: " << debugResult;
|
||||
|
||||
// Cleanup
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
|
||||
LOG_DEBUG << "GPGME imported " << importResult->imported << " keys, "
|
||||
<< importResult->unchanged << " unchanged";
|
||||
|
||||
// Create data objects for signature and message
|
||||
GpgmeDataWrapper sigData;
|
||||
err = gpgme_data_new_from_mem(sigData.ptr(), signature.c_str(), signature.size(), 1);
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create signature data: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
GpgmeDataWrapper msgData;
|
||||
err = gpgme_data_new_from_mem(msgData.ptr(), message.c_str(), message.size(), 1);
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create message data: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
// Use --trust-model always to avoid trust issues
|
||||
std::string verifyCmd = "GNUPGHOME=" + keyringDir +
|
||||
" gpg --batch --yes --trust-model always --verify " +
|
||||
sigFile + " " + messageFile + " 2>&1";
|
||||
std::string verifyResult = executeGpgCommand(verifyCmd);
|
||||
|
||||
LOG_DEBUG << "GPG verify result: " << verifyResult;
|
||||
|
||||
// Check if verification succeeded (check both English and potential localized messages)
|
||||
bool verified = (verifyResult.find("Good signature") != std::string::npos) ||
|
||||
(verifyResult.find("gpg: Good signature") != std::string::npos) ||
|
||||
(verifyResult.find("Signature made") != std::string::npos &&
|
||||
verifyResult.find("BAD signature") == std::string::npos);
|
||||
|
||||
if (!verified) {
|
||||
LOG_WARN << "Signature verification failed. Verify output: " << verifyResult;
|
||||
} else {
|
||||
LOG_INFO << "Signature verification successful for challenge";
|
||||
err = gpgme_op_verify(ctx.get(), sigData.get(), msgData.get(), nullptr);
|
||||
if (err) {
|
||||
LOG_WARN << "Signature verification failed: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cleanup temporary files
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
|
||||
|
||||
// Check verification result
|
||||
gpgme_verify_result_t verifyResult = gpgme_op_verify_result(ctx.get());
|
||||
if (!verifyResult || !verifyResult->signatures) {
|
||||
LOG_WARN << "No signatures found in verification result";
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if signature is valid
|
||||
gpgme_signature_t sig = verifyResult->signatures;
|
||||
bool verified = (sig->status == GPG_ERR_NO_ERROR);
|
||||
|
||||
if (verified) {
|
||||
LOG_INFO << "Signature verification successful for challenge";
|
||||
} else {
|
||||
LOG_WARN << "Signature verification failed: " << gpgme_strerror(sig->status);
|
||||
}
|
||||
|
||||
// Cleanup temporary directory
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
|
||||
return verified;
|
||||
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception during signature verification: " << e.what();
|
||||
return false;
|
||||
|
|
@ -314,8 +303,8 @@ void AuthService::registerUser(const std::string& username, const std::string& p
|
|||
return;
|
||||
}
|
||||
|
||||
if (!std::regex_match(username, std::regex("^[a-zA-Z0-9_]+$"))) {
|
||||
callback(false, "Username can only contain letters, numbers, and underscores", 0);
|
||||
if (!std::regex_match(username, std::regex("^[a-zA-Z][a-zA-Z0-9_]*$"))) {
|
||||
callback(false, "Username must start with a letter and contain only letters, numbers, and underscores", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -454,7 +443,7 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
||||
*dbClient << "SELECT id, username, password_hash, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, is_disabled, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color "
|
||||
"FROM users WHERE username = $1 LIMIT 1"
|
||||
<< username
|
||||
>> [password, callback, this](const Result& r) {
|
||||
|
|
@ -463,18 +452,25 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
|||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if account is disabled
|
||||
bool isDisabled = r[0]["is_disabled"].isNull() ? false : r[0]["is_disabled"].as<bool>();
|
||||
if (isDisabled) {
|
||||
callback(false, "Account disabled", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if PGP-only is enabled BEFORE password validation
|
||||
bool isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
|
||||
|
||||
if (isPgpOnly) {
|
||||
// Return a specific error for PGP-only accounts
|
||||
callback(false, "PGP-only login enabled for this account", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
std::string hash = r[0]["password_hash"].as<std::string>();
|
||||
|
||||
|
||||
bool valid = false;
|
||||
try {
|
||||
valid = BCrypt::validatePassword(password, hash);
|
||||
|
|
@ -483,23 +479,32 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
|||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!valid) {
|
||||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
UserInfo user;
|
||||
user.id = r[0]["id"].as<int64_t>();
|
||||
user.username = r[0]["username"].as<std::string>();
|
||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||
user.isModerator = r[0]["is_moderator"].isNull() ? false : r[0]["is_moderator"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isRestreamer = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
|
||||
user.isBot = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
|
||||
user.isTexter = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].as<bool>();
|
||||
user.isPgpOnly = isPgpOnly;
|
||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
user.bannerUrl = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
|
||||
user.bannerPosition = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
|
||||
user.bannerZoom = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
|
||||
user.bannerPositionX = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
|
||||
user.graffitiUrl = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_url"].as<std::string>();
|
||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||
|
||||
|
||||
std::string token = generateToken(user);
|
||||
callback(true, token, user);
|
||||
} catch (const std::exception& e) {
|
||||
|
|
@ -593,8 +598,8 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_streamer, "
|
||||
"u.is_pgp_only, u.bio, u.avatar_url, u.pgp_only_enabled_at, u.user_color "
|
||||
*dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, u.is_bot, u.is_texter, "
|
||||
"u.is_pgp_only, u.is_disabled, u.bio, u.avatar_url, u.banner_url, u.banner_position, u.banner_zoom, u.banner_position_x, u.graffiti_url, u.pgp_only_enabled_at, u.user_color "
|
||||
"FROM pgp_keys pk JOIN users u ON pk.user_id = u.id "
|
||||
"WHERE u.username = $1 ORDER BY pk.created_at DESC LIMIT 1"
|
||||
<< username
|
||||
|
|
@ -605,31 +610,47 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
callback(false, "", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if account is disabled
|
||||
bool isDisabled = r[0]["is_disabled"].isNull() ? false : r[0]["is_disabled"].as<bool>();
|
||||
if (isDisabled) {
|
||||
callback(false, "Account disabled", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
std::string publicKey = r[0]["public_key"].as<std::string>();
|
||||
|
||||
|
||||
// CRITICAL: Server-side signature verification
|
||||
bool signatureValid = verifyPgpSignature(challenge, signature, publicKey);
|
||||
|
||||
|
||||
if (!signatureValid) {
|
||||
LOG_WARN << "Invalid PGP signature for user";
|
||||
callback(false, "Invalid signature", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
LOG_INFO << "PGP signature verified successfully for user";
|
||||
|
||||
|
||||
UserInfo user;
|
||||
user.id = r[0]["id"].as<int64_t>();
|
||||
user.username = r[0]["username"].as<std::string>();
|
||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||
user.isModerator = r[0]["is_moderator"].isNull() ? false : r[0]["is_moderator"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isRestreamer = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
|
||||
user.isBot = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
|
||||
user.isTexter = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].as<bool>();
|
||||
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
user.bannerUrl = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
|
||||
user.bannerPosition = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
|
||||
user.bannerZoom = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
|
||||
user.bannerPositionX = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
|
||||
user.graffitiUrl = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_url"].as<std::string>();
|
||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||
|
||||
|
||||
std::string token = generateToken(user);
|
||||
callback(true, token, user);
|
||||
} catch (const std::exception& e) {
|
||||
|
|
@ -653,25 +674,70 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
}
|
||||
}
|
||||
|
||||
// SECURITY FIX #5: Validate JWT secret has minimum length and entropy
|
||||
void AuthService::validateAndLoadJwtSecret() {
|
||||
if (!jwtSecret_.empty()) {
|
||||
return; // Already loaded and validated
|
||||
}
|
||||
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
if (!envSecret || strlen(envSecret) == 0) {
|
||||
throw std::runtime_error("JWT_SECRET environment variable is not set");
|
||||
}
|
||||
|
||||
size_t secretLen = strlen(envSecret);
|
||||
|
||||
// Require at least 32 characters (256 bits) for HS256
|
||||
if (secretLen < 32) {
|
||||
throw std::runtime_error("JWT_SECRET must be at least 32 characters (256 bits) for security");
|
||||
}
|
||||
|
||||
// Basic entropy check - ensure not all same character
|
||||
bool hasVariety = false;
|
||||
for (size_t i = 1; i < secretLen && !hasVariety; ++i) {
|
||||
if (envSecret[i] != envSecret[0]) {
|
||||
hasVariety = true;
|
||||
}
|
||||
}
|
||||
if (!hasVariety) {
|
||||
throw std::runtime_error("JWT_SECRET has insufficient entropy - all characters are the same");
|
||||
}
|
||||
|
||||
// Check for common weak secrets
|
||||
std::string secretLower = envSecret;
|
||||
std::transform(secretLower.begin(), secretLower.end(), secretLower.begin(), ::tolower);
|
||||
if (secretLower.find("secret") != std::string::npos ||
|
||||
secretLower.find("password") != std::string::npos ||
|
||||
secretLower.find("123456") != std::string::npos) {
|
||||
LOG_WARN << "JWT_SECRET appears to contain common weak patterns - consider using a stronger secret";
|
||||
}
|
||||
|
||||
jwtSecret_ = std::string(envSecret);
|
||||
LOG_INFO << "JWT secret loaded and validated (" << secretLen << " characters)";
|
||||
}
|
||||
|
||||
std::string AuthService::generateToken(const UserInfo& user) {
|
||||
try {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
}
|
||||
|
||||
validateAndLoadJwtSecret();
|
||||
|
||||
// SECURITY FIX: Reduced JWT expiry from 24h to 1h to limit token exposure window
|
||||
auto token = jwt::create()
|
||||
.set_issuer("streaming-app")
|
||||
.set_type("JWS")
|
||||
.set_type("JWT")
|
||||
.set_issued_at(std::chrono::system_clock::now())
|
||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24))
|
||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(1))
|
||||
.set_payload_claim("user_id", jwt::claim(std::to_string(user.id)))
|
||||
.set_payload_claim("username", jwt::claim(user.username))
|
||||
.set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin)))
|
||||
.set_payload_claim("is_moderator", jwt::claim(std::to_string(user.isModerator)))
|
||||
.set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer)))
|
||||
.set_payload_claim("is_restreamer", jwt::claim(std::to_string(user.isRestreamer)))
|
||||
.set_payload_claim("is_disabled", jwt::claim(std::to_string(user.isDisabled))) // SECURITY FIX #26
|
||||
.set_payload_claim("token_version", jwt::claim(std::to_string(user.tokenVersion))) // SECURITY FIX #10
|
||||
.set_payload_claim("color_code", jwt::claim(
|
||||
user.colorCode.empty() ? "#561D5E" : user.colorCode
|
||||
)) // Ensure color is never empty
|
||||
.set_payload_claim("avatar_url", jwt::claim(user.avatarUrl))
|
||||
.sign(jwt::algorithm::hs256{jwtSecret_});
|
||||
|
||||
return token;
|
||||
|
|
@ -683,11 +749,8 @@ std::string AuthService::generateToken(const UserInfo& user) {
|
|||
|
||||
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
||||
try {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
}
|
||||
|
||||
validateAndLoadJwtSecret();
|
||||
|
||||
auto decoded = jwt::decode(token);
|
||||
|
||||
auto verifier = jwt::verify()
|
||||
|
|
@ -699,9 +762,21 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
userInfo.id = std::stoll(decoded.get_payload_claim("user_id").as_string());
|
||||
userInfo.username = decoded.get_payload_claim("username").as_string();
|
||||
userInfo.isAdmin = decoded.get_payload_claim("is_admin").as_string() == "1";
|
||||
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
|
||||
userInfo.isModerator = decoded.has_payload_claim("is_moderator") ?
|
||||
decoded.get_payload_claim("is_moderator").as_string() == "1" : false;
|
||||
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
|
||||
decoded.get_payload_claim("is_streamer").as_string() == "1" : false;
|
||||
|
||||
userInfo.isRestreamer = decoded.has_payload_claim("is_restreamer") ?
|
||||
decoded.get_payload_claim("is_restreamer").as_string() == "1" : false;
|
||||
|
||||
// SECURITY FIX #26: Extract disabled status
|
||||
userInfo.isDisabled = decoded.has_payload_claim("is_disabled") ?
|
||||
decoded.get_payload_claim("is_disabled").as_string() == "1" : false;
|
||||
|
||||
// SECURITY FIX #10: Extract token version for revocation check
|
||||
userInfo.tokenVersion = decoded.has_payload_claim("token_version") ?
|
||||
std::stoi(decoded.get_payload_claim("token_version").as_string()) : 1;
|
||||
|
||||
// Get color from token if available, otherwise will need to fetch from DB
|
||||
if (decoded.has_payload_claim("color_code")) {
|
||||
userInfo.colorCode = decoded.get_payload_claim("color_code").as_string();
|
||||
|
|
@ -709,7 +784,13 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
// For older tokens without color, default value
|
||||
userInfo.colorCode = "#561D5E";
|
||||
}
|
||||
|
||||
|
||||
// SECURITY FIX #26: Reject tokens from disabled accounts
|
||||
if (userInfo.isDisabled) {
|
||||
LOG_DEBUG << "Token rejected - user account is disabled: " << userInfo.username;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_DEBUG << "Token validation failed: " << e.what();
|
||||
|
|
@ -717,6 +798,23 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
}
|
||||
}
|
||||
|
||||
// Chat service compatibility method
|
||||
std::optional<UserClaims> AuthService::verifyToken(const std::string& token) {
|
||||
UserInfo userInfo;
|
||||
if (validateToken(token, userInfo)) {
|
||||
UserClaims claims;
|
||||
claims.userId = std::to_string(userInfo.id);
|
||||
claims.username = userInfo.username;
|
||||
claims.userColor = userInfo.colorCode;
|
||||
claims.isAdmin = userInfo.isAdmin;
|
||||
claims.isModerator = userInfo.isModerator;
|
||||
claims.isStreamer = userInfo.isStreamer;
|
||||
claims.isRestreamer = userInfo.isRestreamer;
|
||||
return claims;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
||||
const std::string& newPassword,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
|
|
@ -771,9 +869,11 @@ void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2"
|
||||
// SECURITY FIX #10: Increment token_version to invalidate all existing tokens
|
||||
*dbClient << "UPDATE users SET password_hash = $1, token_version = COALESCE(token_version, 0) + 1 WHERE id = $2"
|
||||
<< newHash << userId
|
||||
>> [callback](const Result&) {
|
||||
>> [callback, userId](const Result&) {
|
||||
LOG_INFO << "Password updated and token_version incremented for user " << userId;
|
||||
callback(true, "");
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
|
|
@ -804,7 +904,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
||||
*dbClient << "SELECT id, username, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color "
|
||||
"FROM users WHERE id = $1 LIMIT 1"
|
||||
<< userId
|
||||
>> [callback](const Result& r) {
|
||||
|
|
@ -813,18 +913,27 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
|||
callback(false, UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
UserInfo user;
|
||||
user.id = r[0]["id"].as<int64_t>();
|
||||
user.username = r[0]["username"].as<std::string>();
|
||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||
user.isModerator = r[0]["is_moderator"].isNull() ? false : r[0]["is_moderator"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isRestreamer = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
|
||||
user.isBot = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
|
||||
user.isTexter = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].as<bool>();
|
||||
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
user.bannerUrl = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
|
||||
user.bannerPosition = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
|
||||
user.bannerZoom = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
|
||||
user.bannerPositionX = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
|
||||
user.graffitiUrl = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_url"].as<std::string>();
|
||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||
|
||||
|
||||
callback(true, user);
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception processing user data: " << e.what();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <jwt-cpp/jwt.h>
|
||||
#include <bcrypt/BCrypt.hpp>
|
||||
|
||||
|
|
@ -9,12 +10,38 @@ struct UserInfo {
|
|||
int64_t id = 0;
|
||||
std::string username;
|
||||
bool isAdmin = false;
|
||||
bool isModerator = false; // Site-wide moderator role
|
||||
bool isStreamer = false;
|
||||
bool isRestreamer = false;
|
||||
bool isBot = false;
|
||||
bool isTexter = false;
|
||||
bool isPgpOnly = false;
|
||||
bool isDisabled = false; // SECURITY FIX #26: Track disabled status
|
||||
std::string bio;
|
||||
std::string avatarUrl;
|
||||
std::string bannerUrl;
|
||||
int bannerPosition = 50; // Y position percentage (0-100) for object-position
|
||||
int bannerZoom = 100; // Zoom percentage (100-200)
|
||||
int bannerPositionX = 50; // X position percentage (0-100) for object-position
|
||||
std::string graffitiUrl;
|
||||
std::string pgpOnlyEnabledAt;
|
||||
std::string colorCode;
|
||||
double ubercoinBalance = 0.0; // Übercoin balance (3 decimal places)
|
||||
std::string createdAt; // Account creation date (for burn rate calculation)
|
||||
int tokenVersion = 1; // SECURITY FIX #10: Token version for revocation
|
||||
};
|
||||
|
||||
// Chat service compatibility struct
|
||||
struct UserClaims {
|
||||
std::string userId;
|
||||
std::string username;
|
||||
std::string userColor;
|
||||
bool isAdmin;
|
||||
bool isModerator; // Site-wide moderator role
|
||||
bool isStreamer;
|
||||
bool isRestreamer;
|
||||
|
||||
UserClaims() : isAdmin(false), isModerator(false), isStreamer(false), isRestreamer(false) {}
|
||||
};
|
||||
|
||||
class AuthService {
|
||||
|
|
@ -40,7 +67,10 @@ public:
|
|||
|
||||
std::string generateToken(const UserInfo& user);
|
||||
bool validateToken(const std::string& token, UserInfo& userInfo);
|
||||
|
||||
|
||||
// Chat service compatibility method
|
||||
std::optional<UserClaims> verifyToken(const std::string& token);
|
||||
|
||||
// New method to fetch complete user info including color
|
||||
void fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback);
|
||||
|
||||
|
|
@ -56,6 +86,7 @@ public:
|
|||
private:
|
||||
AuthService() = default;
|
||||
std::string jwtSecret_;
|
||||
|
||||
|
||||
bool validatePassword(const std::string& password, std::string& error);
|
||||
void validateAndLoadJwtSecret(); // SECURITY FIX #5
|
||||
};
|
||||
164
backend/src/services/CensorService.cpp
Normal file
164
backend/src/services/CensorService.cpp
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
#include "CensorService.h"
|
||||
#include <drogon/drogon.h>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
||||
// Maximum length for a single censored word (ReDoS prevention)
|
||||
static constexpr size_t MAX_WORD_LENGTH = 100;
|
||||
// Maximum number of censored words
|
||||
static constexpr size_t MAX_WORD_COUNT = 500;
|
||||
|
||||
void CensorService::loadCensoredWords(std::function<void(bool)> callback) {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
*dbClient << "SELECT setting_value FROM site_settings WHERE setting_key = 'censored_words'"
|
||||
>> [this, callback](const Result& r) {
|
||||
// Build new patterns in temporary variables
|
||||
std::vector<std::string> newWords;
|
||||
std::optional<std::regex> newPattern;
|
||||
|
||||
if (!r.empty() && !r[0]["setting_value"].isNull()) {
|
||||
std::string wordsStr = r[0]["setting_value"].as<std::string>();
|
||||
|
||||
// Parse comma-separated words
|
||||
std::stringstream ss(wordsStr);
|
||||
std::string word;
|
||||
while (std::getline(ss, word, ',') && newWords.size() < MAX_WORD_COUNT) {
|
||||
// Trim whitespace
|
||||
size_t start = word.find_first_not_of(" \t\r\n");
|
||||
size_t end = word.find_last_not_of(" \t\r\n");
|
||||
if (start != std::string::npos && end != std::string::npos) {
|
||||
word = word.substr(start, end - start + 1);
|
||||
// Skip empty words and words exceeding max length (ReDoS prevention)
|
||||
if (!word.empty() && word.length() <= MAX_WORD_LENGTH) {
|
||||
newWords.push_back(word);
|
||||
} else if (word.length() > MAX_WORD_LENGTH) {
|
||||
LOG_WARN << "Skipping censored word exceeding " << MAX_WORD_LENGTH << " chars";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build combined pattern
|
||||
newPattern = buildCombinedPattern(newWords);
|
||||
}
|
||||
|
||||
// Atomic swap under lock
|
||||
{
|
||||
std::unique_lock<std::shared_mutex> lock(mutex_);
|
||||
censoredWords_ = std::move(newWords);
|
||||
combinedPattern_ = std::move(newPattern);
|
||||
}
|
||||
|
||||
LOG_INFO << "Loaded " << censoredWords_.size() << " censored words";
|
||||
|
||||
if (callback) {
|
||||
callback(true);
|
||||
}
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to load censored words: " << e.base().what();
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
std::optional<std::regex> CensorService::buildCombinedPattern(const std::vector<std::string>& words) {
|
||||
if (words.empty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
try {
|
||||
// Build combined pattern: \b(word1|word2|word3)\b
|
||||
std::string pattern = "\\b(";
|
||||
bool first = true;
|
||||
|
||||
for (const auto& word : words) {
|
||||
if (!first) {
|
||||
pattern += "|";
|
||||
}
|
||||
first = false;
|
||||
|
||||
// Escape special regex characters
|
||||
for (char c : word) {
|
||||
if (c == '.' || c == '^' || c == '$' || c == '*' || c == '+' ||
|
||||
c == '?' || c == '(' || c == ')' || c == '[' || c == ']' ||
|
||||
c == '{' || c == '}' || c == '|' || c == '\\') {
|
||||
pattern += '\\';
|
||||
}
|
||||
pattern += c;
|
||||
}
|
||||
}
|
||||
|
||||
pattern += ")\\b";
|
||||
|
||||
return std::regex(pattern, std::regex_constants::icase);
|
||||
} catch (const std::regex_error& e) {
|
||||
LOG_ERROR << "Failed to build combined censored pattern: " << e.what();
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
std::string CensorService::censor(const std::string& text) const {
|
||||
if (text.empty()) {
|
||||
return text;
|
||||
}
|
||||
|
||||
std::shared_lock<std::shared_mutex> lock(mutex_);
|
||||
|
||||
if (!combinedPattern_) {
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string result;
|
||||
try {
|
||||
// Replace censored words with asterisks
|
||||
std::sregex_iterator begin(text.begin(), text.end(), *combinedPattern_);
|
||||
std::sregex_iterator end;
|
||||
|
||||
size_t lastPos = 0;
|
||||
for (std::sregex_iterator it = begin; it != end; ++it) {
|
||||
const std::smatch& match = *it;
|
||||
// Append text before match
|
||||
result += text.substr(lastPos, match.position() - lastPos);
|
||||
// Replace match with asterisks of same length
|
||||
result += std::string(match.length(), '*');
|
||||
lastPos = match.position() + match.length();
|
||||
}
|
||||
// Append remaining text
|
||||
result += text.substr(lastPos);
|
||||
} catch (const std::regex_error& e) {
|
||||
LOG_ERROR << "Regex replace error: " << e.what();
|
||||
return text;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
bool CensorService::containsCensoredWords(const std::string& text) const {
|
||||
if (text.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::shared_lock<std::shared_mutex> lock(mutex_);
|
||||
|
||||
if (!combinedPattern_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return std::regex_search(text, *combinedPattern_);
|
||||
} catch (const std::regex_error& e) {
|
||||
LOG_ERROR << "Regex search error: " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> CensorService::getCensoredWords() const {
|
||||
std::shared_lock<std::shared_mutex> lock(mutex_);
|
||||
return censoredWords_;
|
||||
}
|
||||
37
backend/src/services/CensorService.h
Normal file
37
backend/src/services/CensorService.h
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <shared_mutex>
|
||||
#include <regex>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
class CensorService {
|
||||
public:
|
||||
static CensorService& getInstance() {
|
||||
static CensorService instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Load censored words from database
|
||||
void loadCensoredWords(std::function<void(bool)> callback = nullptr);
|
||||
|
||||
// Censor text by replacing censored words with asterisks (case-insensitive)
|
||||
std::string censor(const std::string& text) const;
|
||||
|
||||
// Check if text contains any censored words
|
||||
bool containsCensoredWords(const std::string& text) const;
|
||||
|
||||
// Get the list of censored words (for debugging/admin)
|
||||
std::vector<std::string> getCensoredWords() const;
|
||||
|
||||
private:
|
||||
CensorService() = default;
|
||||
|
||||
mutable std::shared_mutex mutex_;
|
||||
std::vector<std::string> censoredWords_;
|
||||
std::optional<std::regex> combinedPattern_; // Single combined pattern for efficiency
|
||||
|
||||
// Build a single combined regex pattern from all words
|
||||
std::optional<std::regex> buildCombinedPattern(const std::vector<std::string>& words);
|
||||
};
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
namespace middleware {
|
||||
|
||||
class CorsMiddleware {
|
||||
public:
|
||||
struct Config {
|
||||
std::vector<std::string> allowOrigins = {"*"};
|
||||
std::vector<std::string> allowMethods = {"GET", "POST", "PUT", "DELETE", "OPTIONS"};
|
||||
std::vector<std::string> allowHeaders = {"Content-Type", "Authorization"};
|
||||
bool allowCredentials = true;
|
||||
int maxAge = 86400;
|
||||
};
|
||||
|
||||
static void enable(const Config& config = {}) {
|
||||
using namespace drogon;
|
||||
|
||||
auto cfg = std::make_shared<Config>(config);
|
||||
|
||||
auto addHeaders = [cfg](const HttpResponsePtr &resp, const HttpRequestPtr &req) {
|
||||
std::string origin = req->getHeader("Origin");
|
||||
|
||||
// Check if origin is allowed
|
||||
bool allowed = false;
|
||||
for (const auto& allowedOrigin : cfg->allowOrigins) {
|
||||
if (allowedOrigin == "*" || allowedOrigin == origin) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
resp->addHeader("Access-Control-Allow-Origin", origin.empty() ? "*" : origin);
|
||||
resp->addHeader("Access-Control-Allow-Methods", joinStrings(cfg->allowMethods, ", "));
|
||||
resp->addHeader("Access-Control-Allow-Headers", joinStrings(cfg->allowHeaders, ", "));
|
||||
if (cfg->allowCredentials) {
|
||||
resp->addHeader("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle preflight requests
|
||||
app().registerPreRoutingAdvice([cfg, addHeaders](const HttpRequestPtr &req,
|
||||
AdviceCallback &&acb,
|
||||
AdviceChainCallback &&accb) {
|
||||
if (req->getMethod() == Options) {
|
||||
auto resp = HttpResponse::newHttpResponse();
|
||||
resp->setStatusCode(k204NoContent);
|
||||
addHeaders(resp, req);
|
||||
resp->addHeader("Access-Control-Max-Age", std::to_string(cfg->maxAge));
|
||||
acb(resp);
|
||||
return;
|
||||
}
|
||||
accb();
|
||||
});
|
||||
|
||||
// Add CORS headers to all responses
|
||||
app().registerPostHandlingAdvice([addHeaders](const HttpRequestPtr &req,
|
||||
const HttpResponsePtr &resp) {
|
||||
addHeaders(resp, req);
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
static std::string joinStrings(const std::vector<std::string>& strings, const std::string& delimiter) {
|
||||
std::string result;
|
||||
for (size_t i = 0; i < strings.size(); ++i) {
|
||||
result += strings[i];
|
||||
if (i < strings.size() - 1) result += delimiter;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace middleware
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
#include "DatabaseService.h"
|
||||
#include "../services/RedisHelper.h"
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
|
@ -12,22 +12,25 @@ namespace {
|
|||
void storeKeyInRedis(const std::string& streamKey) {
|
||||
// Store the stream key in Redis for validation (24 hour TTL)
|
||||
bool stored = RedisHelper::storeKey("stream_key:" + streamKey, "1", 86400);
|
||||
|
||||
|
||||
if (stored) {
|
||||
LOG_INFO << "Stored stream key in Redis: " << streamKey;
|
||||
} else {
|
||||
LOG_ERROR << "Failed to store key in Redis: " << streamKey;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// SECURITY FIX: Use cryptographically secure random bytes instead of mt19937
|
||||
std::string generateStreamKey() {
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
std::uniform_int_distribution<> dis(0, 255);
|
||||
|
||||
unsigned char bytes[32]; // 32 bytes = 64 hex characters
|
||||
if (RAND_bytes(bytes, sizeof(bytes)) != 1) {
|
||||
LOG_ERROR << "Failed to generate cryptographically secure random bytes";
|
||||
throw std::runtime_error("Failed to generate secure stream key");
|
||||
}
|
||||
|
||||
std::stringstream ss;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
ss << std::hex << std::setw(2) << std::setfill('0') << dis(gen);
|
||||
for (size_t i = 0; i < sizeof(bytes); ++i) {
|
||||
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(bytes[i]);
|
||||
}
|
||||
return ss.str();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,16 +28,17 @@ void RedisHelper::ensureConnected() {
|
|||
sw::redis::ConnectionOptions opts;
|
||||
opts.host = getRedisHost();
|
||||
opts.port = getRedisPort();
|
||||
|
||||
opts.db = getRedisDb();
|
||||
|
||||
const char* envPass = std::getenv("REDIS_PASS");
|
||||
if (envPass && strlen(envPass) > 0) {
|
||||
opts.password = envPass;
|
||||
}
|
||||
|
||||
|
||||
opts.socket_timeout = std::chrono::milliseconds(1000);
|
||||
opts.connect_timeout = std::chrono::milliseconds(1000);
|
||||
|
||||
LOG_INFO << "Connecting to Redis at " << opts.host << ":" << opts.port;
|
||||
LOG_INFO << "Connecting to Redis at " << opts.host << ":" << opts.port << " db=" << opts.db;
|
||||
|
||||
_redis = std::make_unique<sw::redis::Redis>(opts);
|
||||
_redis->ping();
|
||||
|
|
@ -71,17 +72,35 @@ int RedisHelper::getRedisPort() const {
|
|||
return std::stoi(envPort);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const auto& config = drogon::app().getCustomConfig();
|
||||
if (config.isMember("redis") && config["redis"].isMember("port")) {
|
||||
return config["redis"]["port"].asInt();
|
||||
}
|
||||
} catch (...) {}
|
||||
|
||||
|
||||
return 6379;
|
||||
}
|
||||
|
||||
int RedisHelper::getRedisDb() const {
|
||||
const char* envDb = std::getenv("REDIS_DB");
|
||||
if (envDb) {
|
||||
try {
|
||||
return std::stoi(envDb);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
try {
|
||||
const auto& config = drogon::app().getCustomConfig();
|
||||
if (config.isMember("redis") && config["redis"].isMember("db")) {
|
||||
return config["redis"]["db"].asInt();
|
||||
}
|
||||
} catch (...) {}
|
||||
|
||||
return 0; // Default to db 0
|
||||
}
|
||||
|
||||
void RedisHelper::executeInThreadPool(std::function<void()> task) {
|
||||
auto loop = drogon::app().getLoop();
|
||||
if (!loop) {
|
||||
|
|
@ -212,19 +231,20 @@ void RedisHelper::expireAsync(const std::string &key,
|
|||
// Sync versions for compatibility
|
||||
std::unique_ptr<sw::redis::Redis> RedisHelper::getConnection() {
|
||||
ensureConnected();
|
||||
|
||||
|
||||
sw::redis::ConnectionOptions opts;
|
||||
opts.host = getRedisHost();
|
||||
opts.port = getRedisPort();
|
||||
|
||||
opts.db = getRedisDb();
|
||||
|
||||
const char* envPass = std::getenv("REDIS_PASS");
|
||||
if (envPass && strlen(envPass) > 0) {
|
||||
opts.password = envPass;
|
||||
}
|
||||
|
||||
|
||||
opts.socket_timeout = std::chrono::milliseconds(200);
|
||||
opts.connect_timeout = std::chrono::milliseconds(200);
|
||||
|
||||
|
||||
return std::make_unique<sw::redis::Redis>(opts);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ private:
|
|||
void executeInThreadPool(std::function<void()> task);
|
||||
std::string getRedisHost() const;
|
||||
int getRedisPort() const;
|
||||
int getRedisDb() const;
|
||||
|
||||
std::unique_ptr<sw::redis::Redis> _redis;
|
||||
bool _initialized;
|
||||
|
|
|
|||
397
backend/src/services/RestreamService.cpp
Normal file
397
backend/src/services/RestreamService.cpp
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
#include "RestreamService.h"
|
||||
#include <drogon/drogon.h>
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <memory>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
||||
// execCurl removed - using Drogon HttpClient instead for security
|
||||
|
||||
std::string RestreamService::getBaseUrl() {
|
||||
const char* envUrl = std::getenv("OME_API_URL");
|
||||
if (envUrl) {
|
||||
return std::string(envUrl);
|
||||
}
|
||||
return "http://ovenmediaengine:8081";
|
||||
}
|
||||
|
||||
std::string RestreamService::getApiToken() {
|
||||
const char* envToken = std::getenv("OME_API_TOKEN");
|
||||
if (!envToken || strlen(envToken) == 0) {
|
||||
throw std::runtime_error("OME_API_TOKEN environment variable is not set");
|
||||
}
|
||||
return std::string(envToken);
|
||||
}
|
||||
|
||||
HttpClientPtr RestreamService::getClient() {
|
||||
return HttpClient::newHttpClient(getBaseUrl());
|
||||
}
|
||||
|
||||
HttpRequestPtr RestreamService::createRequest(HttpMethod method, const std::string& path) {
|
||||
auto request = HttpRequest::newHttpRequest();
|
||||
request->setMethod(method);
|
||||
request->setPath(path);
|
||||
|
||||
const auto token = getApiToken();
|
||||
const auto b64 = drogon::utils::base64Encode(token);
|
||||
request->addHeader("Authorization", std::string("Basic ") + b64);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
HttpRequestPtr RestreamService::createJsonRequest(HttpMethod method, const std::string& path,
|
||||
const Json::Value& body) {
|
||||
auto request = HttpRequest::newHttpJsonRequest(body);
|
||||
request->setMethod(method);
|
||||
request->setPath(path);
|
||||
|
||||
const auto token = getApiToken();
|
||||
const auto b64 = drogon::utils::base64Encode(token);
|
||||
request->addHeader("Authorization", std::string("Basic ") + b64);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
std::string RestreamService::generatePushId(const std::string& streamKey, int64_t destinationId) {
|
||||
return "restream_" + streamKey + "_" + std::to_string(destinationId);
|
||||
}
|
||||
|
||||
void RestreamService::updateDestinationStatus(int64_t destinationId, bool isConnected, const std::string& error) {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
if (isConnected) {
|
||||
*dbClient << "UPDATE restream_destinations SET is_connected = true, last_error = NULL, "
|
||||
"last_connected_at = CURRENT_TIMESTAMP WHERE id = $1"
|
||||
<< destinationId
|
||||
>> [destinationId](const Result&) {
|
||||
LOG_INFO << "Restream destination " << destinationId << " connected";
|
||||
}
|
||||
>> [destinationId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update restream destination " << destinationId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
} else {
|
||||
*dbClient << "UPDATE restream_destinations SET is_connected = false, last_error = $1 WHERE id = $2"
|
||||
<< error << destinationId
|
||||
>> [destinationId](const Result&) {
|
||||
LOG_INFO << "Restream destination " << destinationId << " disconnected";
|
||||
}
|
||||
>> [destinationId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update restream destination " << destinationId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void RestreamService::startPush(const std::string& sourceStreamKey, const RestreamDestination& dest,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
// Build the full destination URL with stream key
|
||||
std::string fullUrl = dest.rtmpUrl;
|
||||
if (!fullUrl.empty() && fullUrl.back() != '/') {
|
||||
fullUrl += '/';
|
||||
}
|
||||
fullUrl += dest.streamKey;
|
||||
|
||||
std::string pushId = generatePushId(sourceStreamKey, dest.id);
|
||||
auto destId = dest.id;
|
||||
|
||||
LOG_INFO << "Starting RTMP push for stream " << sourceStreamKey
|
||||
<< " to " << dest.name << " (" << dest.rtmpUrl << ")";
|
||||
|
||||
// Build JSON body
|
||||
Json::Value body;
|
||||
body["id"] = pushId;
|
||||
body["stream"]["name"] = sourceStreamKey;
|
||||
body["protocol"] = "rtmp";
|
||||
body["url"] = fullUrl;
|
||||
|
||||
// Use Drogon HttpClient instead of curl for security
|
||||
auto request = createJsonRequest(drogon::Post, "/v1/vhosts/default/apps/app:startPush", body);
|
||||
|
||||
LOG_INFO << "Sending HTTP request for push start";
|
||||
|
||||
getClient()->sendRequest(request,
|
||||
[this, callback, pushId, sourceStreamKey, destId](ReqResult result, const HttpResponsePtr& response) {
|
||||
if (result != ReqResult::Ok || !response) {
|
||||
std::string error = "Failed to connect to OME API";
|
||||
updateDestinationStatus(destId, false, error);
|
||||
callback(false, error);
|
||||
LOG_ERROR << "Failed to start RTMP push: " << error;
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = response->getJsonObject();
|
||||
if (json) {
|
||||
int statusCode = (*json).get("statusCode", 0).asInt();
|
||||
std::string message = (*json).get("message", "").asString();
|
||||
|
||||
// 200 = success, 400 with "Duplicate ID" = already running (treat as success)
|
||||
bool isSuccess = (statusCode == 200);
|
||||
bool isDuplicate = (statusCode == 400 && message.find("Duplicate") != std::string::npos);
|
||||
|
||||
if (isSuccess || isDuplicate) {
|
||||
// Track the active push
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
activePushes_[sourceStreamKey][destId] = pushId;
|
||||
}
|
||||
updateDestinationStatus(destId, true, "");
|
||||
callback(true, "");
|
||||
if (isDuplicate) {
|
||||
LOG_INFO << "RTMP push already active (duplicate ID): " << pushId;
|
||||
} else {
|
||||
LOG_INFO << "RTMP push started successfully: " << pushId;
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
std::string error = (*json).get("message", "Unknown error").asString();
|
||||
updateDestinationStatus(destId, false, error);
|
||||
callback(false, error);
|
||||
LOG_ERROR << "Failed to start RTMP push: " << error;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::string error = "Invalid response from OME API";
|
||||
updateDestinationStatus(destId, false, error);
|
||||
callback(false, error);
|
||||
LOG_ERROR << "Failed to start RTMP push: " << error;
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamService::stopPush(const std::string& sourceStreamKey, int64_t destinationId,
|
||||
std::function<void(bool)> callback) {
|
||||
std::string pushId;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
auto streamIt = activePushes_.find(sourceStreamKey);
|
||||
if (streamIt != activePushes_.end()) {
|
||||
auto destIt = streamIt->second.find(destinationId);
|
||||
if (destIt != streamIt->second.end()) {
|
||||
pushId = destIt->second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not tracked in memory, generate the push ID anyway and try to stop it
|
||||
// This handles cases where server restarted but push is still active on OME
|
||||
if (pushId.empty()) {
|
||||
pushId = generatePushId(sourceStreamKey, destinationId);
|
||||
}
|
||||
|
||||
LOG_INFO << "Stopping RTMP push: " << pushId;
|
||||
|
||||
// Build JSON body
|
||||
Json::Value body;
|
||||
body["id"] = pushId;
|
||||
|
||||
// Use Drogon HttpClient instead of curl for security
|
||||
auto request = createJsonRequest(drogon::Post, "/v1/vhosts/default/apps/app:stopPush", body);
|
||||
|
||||
LOG_INFO << "Sending HTTP request for push stop";
|
||||
|
||||
getClient()->sendRequest(request,
|
||||
[this, callback, pushId, sourceStreamKey, destinationId](ReqResult result, const HttpResponsePtr& response) {
|
||||
// Remove from tracking regardless of result
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
auto streamIt = activePushes_.find(sourceStreamKey);
|
||||
if (streamIt != activePushes_.end()) {
|
||||
streamIt->second.erase(destinationId);
|
||||
if (streamIt->second.empty()) {
|
||||
activePushes_.erase(streamIt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateDestinationStatus(destinationId, false, "");
|
||||
|
||||
if (result == ReqResult::Ok && response) {
|
||||
auto json = response->getJsonObject();
|
||||
if (json) {
|
||||
int statusCode = (*json).get("statusCode", 0).asInt();
|
||||
if (statusCode == 200 || statusCode == 404) {
|
||||
callback(true);
|
||||
LOG_INFO << "RTMP push stopped: " << pushId;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Even if API call failed, we've removed from tracking
|
||||
callback(true);
|
||||
LOG_WARN << "RTMP push stop may have failed, but removed from tracking: " << pushId;
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamService::stopAllPushes(const std::string& sourceStreamKey,
|
||||
std::function<void(bool)> callback) {
|
||||
std::vector<int64_t> destinationIds;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
auto streamIt = activePushes_.find(sourceStreamKey);
|
||||
if (streamIt != activePushes_.end()) {
|
||||
for (const auto& [destId, pushId] : streamIt->second) {
|
||||
destinationIds.push_back(destId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (destinationIds.empty()) {
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop each push
|
||||
auto remaining = std::make_shared<std::atomic<int>>(destinationIds.size());
|
||||
auto allSuccess = std::make_shared<std::atomic<bool>>(true);
|
||||
|
||||
for (int64_t destId : destinationIds) {
|
||||
stopPush(sourceStreamKey, destId, [remaining, allSuccess, callback](bool success) {
|
||||
if (!success) {
|
||||
allSuccess->store(false);
|
||||
}
|
||||
if (--(*remaining) == 0) {
|
||||
callback(allSuccess->load());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void RestreamService::getPushStatus(const std::string& sourceStreamKey, int64_t destinationId,
|
||||
std::function<void(bool, bool isConnected, const std::string& error)> callback) {
|
||||
std::string pushId;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
auto streamIt = activePushes_.find(sourceStreamKey);
|
||||
if (streamIt != activePushes_.end()) {
|
||||
auto destIt = streamIt->second.find(destinationId);
|
||||
if (destIt != streamIt->second.end()) {
|
||||
pushId = destIt->second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pushId.empty()) {
|
||||
callback(true, false, "Not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
// OME API: GET /v1/vhosts/{vhost}/apps/{app}/push
|
||||
std::string path = "/v1/vhosts/default/apps/app/push";
|
||||
auto request = createRequest(Get, path);
|
||||
|
||||
getClient()->sendRequest(request,
|
||||
[callback, pushId](ReqResult result, const HttpResponsePtr& response) {
|
||||
if (result == ReqResult::Ok && response && response->getStatusCode() == k200OK) {
|
||||
try {
|
||||
auto json = *response->getJsonObject();
|
||||
// Look for our push in the response
|
||||
if (json.isMember("response") && json["response"].isArray()) {
|
||||
for (const auto& push : json["response"]) {
|
||||
if (push["id"].asString() == pushId) {
|
||||
std::string state = push.get("state", "unknown").asString();
|
||||
bool connected = (state == "started" || state == "connected");
|
||||
std::string error = push.get("error", "").asString();
|
||||
callback(true, connected, error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
callback(true, false, "Push not found");
|
||||
} catch (const std::exception& e) {
|
||||
callback(false, false, e.what());
|
||||
}
|
||||
} else {
|
||||
callback(false, false, "Failed to get push status");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamService::startAllDestinations(const std::string& streamKey, int64_t realmId) {
|
||||
LOG_INFO << "Starting all restream destinations for realm " << realmId;
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id, realm_id, name, rtmp_url, stream_key, enabled "
|
||||
"FROM restream_destinations WHERE realm_id = $1 AND enabled = true"
|
||||
<< realmId
|
||||
>> [this, streamKey](const Result& r) {
|
||||
for (const auto& row : r) {
|
||||
RestreamDestination dest;
|
||||
dest.id = row["id"].as<int64_t>();
|
||||
dest.realmId = row["realm_id"].as<int64_t>();
|
||||
dest.name = row["name"].as<std::string>();
|
||||
dest.rtmpUrl = row["rtmp_url"].as<std::string>();
|
||||
dest.streamKey = row["stream_key"].as<std::string>();
|
||||
dest.enabled = row["enabled"].as<bool>();
|
||||
|
||||
startPush(streamKey, dest, [dest](bool success, const std::string& error) {
|
||||
if (!success) {
|
||||
LOG_ERROR << "Failed to start restream to " << dest.name << ": " << error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
>> [realmId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to fetch restream destinations for realm " << realmId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void RestreamService::stopAllDestinations(const std::string& streamKey, int64_t realmId) {
|
||||
LOG_INFO << "Stopping all restream destinations for realm " << realmId;
|
||||
|
||||
stopAllPushes(streamKey, [realmId](bool success) {
|
||||
if (!success) {
|
||||
LOG_WARN << "Some restream pushes may not have stopped cleanly for realm " << realmId;
|
||||
}
|
||||
});
|
||||
|
||||
// Also update all destinations in DB as disconnected
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE restream_destinations SET is_connected = false WHERE realm_id = $1"
|
||||
<< realmId
|
||||
>> [](const Result&) {}
|
||||
>> [realmId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update restream destinations for realm " << realmId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void RestreamService::attemptReconnections(const std::string& streamKey, int64_t realmId) {
|
||||
// Get all enabled but disconnected destinations and try to reconnect
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id, realm_id, name, rtmp_url, stream_key, enabled, is_connected "
|
||||
"FROM restream_destinations "
|
||||
"WHERE realm_id = $1 AND enabled = true AND is_connected = false"
|
||||
<< realmId
|
||||
>> [this, streamKey](const Result& r) {
|
||||
for (const auto& row : r) {
|
||||
RestreamDestination dest;
|
||||
dest.id = row["id"].as<int64_t>();
|
||||
dest.realmId = row["realm_id"].as<int64_t>();
|
||||
dest.name = row["name"].as<std::string>();
|
||||
dest.rtmpUrl = row["rtmp_url"].as<std::string>();
|
||||
dest.streamKey = row["stream_key"].as<std::string>();
|
||||
dest.enabled = row["enabled"].as<bool>();
|
||||
|
||||
LOG_INFO << "Attempting to reconnect restream destination: " << dest.name;
|
||||
|
||||
startPush(streamKey, dest, [dest](bool success, const std::string& error) {
|
||||
if (success) {
|
||||
LOG_INFO << "Reconnected restream to " << dest.name;
|
||||
} else {
|
||||
LOG_WARN << "Reconnection failed for " << dest.name << ": " << error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
>> [realmId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to fetch disconnected restream destinations for realm " << realmId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
}
|
||||
75
backend/src/services/RestreamService.h
Normal file
75
backend/src/services/RestreamService.h
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpClient.h>
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <mutex>
|
||||
#include <memory>
|
||||
|
||||
struct RestreamDestination {
|
||||
int64_t id;
|
||||
int64_t realmId;
|
||||
std::string name;
|
||||
std::string rtmpUrl;
|
||||
std::string streamKey;
|
||||
bool enabled;
|
||||
bool isConnected;
|
||||
std::string lastError;
|
||||
};
|
||||
|
||||
class RestreamService {
|
||||
public:
|
||||
static RestreamService& getInstance() {
|
||||
static RestreamService instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Start pushing stream to a destination
|
||||
void startPush(const std::string& sourceStreamKey, const RestreamDestination& dest,
|
||||
std::function<void(bool, const std::string&)> callback);
|
||||
|
||||
// Stop pushing stream to a destination
|
||||
void stopPush(const std::string& sourceStreamKey, int64_t destinationId,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Stop all pushes for a stream
|
||||
void stopAllPushes(const std::string& sourceStreamKey,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Get push status for a destination
|
||||
void getPushStatus(const std::string& sourceStreamKey, int64_t destinationId,
|
||||
std::function<void(bool, bool isConnected, const std::string& error)> callback);
|
||||
|
||||
// Start all enabled destinations for a realm when stream goes live
|
||||
void startAllDestinations(const std::string& streamKey, int64_t realmId);
|
||||
|
||||
// Stop all destinations for a realm when stream goes offline
|
||||
void stopAllDestinations(const std::string& streamKey, int64_t realmId);
|
||||
|
||||
// Attempt reconnection for failed destinations (called periodically)
|
||||
void attemptReconnections(const std::string& streamKey, int64_t realmId);
|
||||
|
||||
private:
|
||||
RestreamService() = default;
|
||||
~RestreamService() = default;
|
||||
RestreamService(const RestreamService&) = delete;
|
||||
RestreamService& operator=(const RestreamService&) = delete;
|
||||
|
||||
std::string getBaseUrl();
|
||||
std::string getApiToken();
|
||||
drogon::HttpClientPtr getClient();
|
||||
drogon::HttpRequestPtr createRequest(drogon::HttpMethod method, const std::string& path);
|
||||
drogon::HttpRequestPtr createJsonRequest(drogon::HttpMethod method, const std::string& path,
|
||||
const Json::Value& body);
|
||||
|
||||
// Generate a unique push ID for tracking
|
||||
std::string generatePushId(const std::string& streamKey, int64_t destinationId);
|
||||
|
||||
// Update destination status in database
|
||||
void updateDestinationStatus(int64_t destinationId, bool isConnected, const std::string& error);
|
||||
|
||||
// Track active pushes: streamKey -> (destinationId -> pushId)
|
||||
std::unordered_map<std::string, std::unordered_map<int64_t, std::string>> activePushes_;
|
||||
std::mutex pushMutex_;
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
#include "../controllers/StreamController.h"
|
||||
#include "../services/RedisHelper.h"
|
||||
#include "../services/OmeClient.h"
|
||||
#include "../services/RestreamService.h"
|
||||
#include <drogon/HttpClient.h>
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <set>
|
||||
|
|
@ -116,19 +117,25 @@ void StatsService::pollOmeStats() {
|
|||
// Update each active stream
|
||||
for (const auto& streamKey : activeStreamKeys) {
|
||||
LOG_INFO << "Processing active stream: " << streamKey;
|
||||
|
||||
// IMMEDIATELY update database to mark as live
|
||||
|
||||
// IMMEDIATELY update database to mark as live and get realm ID
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE realms SET is_live = true, viewer_count = 0, "
|
||||
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1"
|
||||
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1 RETURNING id"
|
||||
<< streamKey
|
||||
>> [streamKey](const orm::Result&) {
|
||||
>> [streamKey](const orm::Result& r) {
|
||||
LOG_INFO << "Successfully marked realm as live: " << streamKey;
|
||||
|
||||
// Attempt reconnection for any disconnected restream destinations
|
||||
if (!r.empty()) {
|
||||
int64_t realmId = r[0]["id"].as<int64_t>();
|
||||
RestreamService::getInstance().attemptReconnections(streamKey, realmId);
|
||||
}
|
||||
}
|
||||
>> [streamKey](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update realm live status: " << e.base().what();
|
||||
};
|
||||
|
||||
|
||||
// Then update detailed stats
|
||||
updateStreamStats(streamKey);
|
||||
}
|
||||
|
|
@ -167,10 +174,12 @@ void StatsService::updateStreamStats(const std::string& streamKey) {
|
|||
fetchStatsFromOme(streamKey, [this, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
|
||||
// Only count viewer tokens when stream is actually live
|
||||
// Offline streams should show 0 viewers (tokens may linger for 5 min after disconnect)
|
||||
updatedStats.uniqueViewers = stats.isLive ? getUniqueViewerCount(streamKey) : 0;
|
||||
|
||||
storeStatsInRedis(streamKey, updatedStats);
|
||||
|
||||
|
||||
// Update realm in database
|
||||
updateRealmLiveStatus(streamKey, updatedStats);
|
||||
|
||||
|
|
@ -267,31 +276,31 @@ void StatsService::fetchStatsFromOme(const std::string& streamKey,
|
|||
hasInput = true;
|
||||
const auto& input = data["input"];
|
||||
|
||||
// Get bitrate from input tracks
|
||||
// Get bitrate from input tracks (OME returns bytes/sec, convert to bits/sec)
|
||||
if (input.isMember("tracks") && input["tracks"].isArray()) {
|
||||
for (const auto& track : input["tracks"]) {
|
||||
if (track["type"].asString() == "video" && track.isMember("bitrate")) {
|
||||
stats.bitrate = track["bitrate"].asDouble();
|
||||
stats.bitrate = track["bitrate"].asDouble() * 8; // Convert bytes/sec to bits/sec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative: Check lastThroughputIn
|
||||
// Alternative: Check lastThroughputIn (OME returns bytes/sec, convert to bits/sec)
|
||||
if (!hasInput && data.isMember("lastThroughputIn")) {
|
||||
double throughput = data["lastThroughputIn"].asDouble();
|
||||
if (throughput > 0) {
|
||||
hasInput = true;
|
||||
stats.bitrate = throughput;
|
||||
stats.bitrate = throughput * 8; // Convert bytes/sec to bits/sec
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative: Check avgThroughputIn
|
||||
|
||||
// Alternative: Check avgThroughputIn (OME returns bytes/sec, convert to bits/sec)
|
||||
if (!hasInput && data.isMember("avgThroughputIn")) {
|
||||
double avgThroughput = data["avgThroughputIn"].asDouble();
|
||||
if (avgThroughput > 0) {
|
||||
hasInput = true;
|
||||
stats.bitrate = avgThroughput;
|
||||
stats.bitrate = avgThroughput * 8; // Convert bytes/sec to bits/sec
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -479,8 +488,8 @@ void StatsService::getStreamStats(const std::string& streamKey,
|
|||
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
// Set uniqueViewers on cache miss
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
// Only count viewer tokens when stream is actually live
|
||||
updatedStats.uniqueViewers = stats.isLive ? getUniqueViewerCount(streamKey) : 0;
|
||||
callback(true, updatedStats);
|
||||
} else {
|
||||
callback(false, stats);
|
||||
|
|
@ -504,7 +513,7 @@ void StatsService::getStreamStats(const std::string& streamKey,
|
|||
stats.resolution = json["resolution"].asString();
|
||||
stats.fps = json["fps"].asDouble();
|
||||
stats.isLive = json["is_live"].asBool();
|
||||
|
||||
|
||||
// Parse protocol connections
|
||||
if (json.isMember("protocol_connections")) {
|
||||
const auto& pc = json["protocol_connections"];
|
||||
|
|
@ -513,19 +522,42 @@ void StatsService::getStreamStats(const std::string& streamKey,
|
|||
stats.protocolConnections.llhls = pc["llhls"].asInt64();
|
||||
stats.protocolConnections.dash = pc["dash"].asInt64();
|
||||
}
|
||||
|
||||
|
||||
stats.lastUpdated = std::chrono::system_clock::time_point(
|
||||
std::chrono::seconds(json["last_updated"].asInt64())
|
||||
);
|
||||
|
||||
callback(true, stats);
|
||||
|
||||
// Verify is_live from database (source of truth from webhooks)
|
||||
// This prevents stale cache from overriding the webhook-updated DB state
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT is_live FROM realms WHERE stream_key = $1"
|
||||
<< streamKey
|
||||
>> [callback, stats](const orm::Result& r) mutable {
|
||||
if (!r.empty()) {
|
||||
bool dbIsLive = r[0]["is_live"].as<bool>();
|
||||
// If database says live but cache says offline, trust database
|
||||
// (webhooks update DB immediately, cache may be stale)
|
||||
if (dbIsLive && !stats.isLive) {
|
||||
LOG_DEBUG << "Overriding stale cache: DB says live, cache says offline";
|
||||
stats.isLive = true;
|
||||
}
|
||||
}
|
||||
callback(true, stats);
|
||||
}
|
||||
>> [callback, stats](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to verify is_live from DB: " << e.base().what();
|
||||
// Fall back to cached value on DB error
|
||||
callback(true, stats);
|
||||
};
|
||||
LOG_DEBUG << "Retrieved cached stats for " << streamKey;
|
||||
return; // Callback handled async
|
||||
} else {
|
||||
// Fallback to fresh fetch if cached data is corrupted
|
||||
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
// Only count viewer tokens when stream is actually live
|
||||
updatedStats.uniqueViewers = stats.isLive ? getUniqueViewerCount(streamKey) : 0;
|
||||
callback(true, updatedStats);
|
||||
} else {
|
||||
callback(false, stats);
|
||||
|
|
@ -538,7 +570,8 @@ void StatsService::getStreamStats(const std::string& streamKey,
|
|||
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
// Only count viewer tokens when stream is actually live
|
||||
updatedStats.uniqueViewers = stats.isLive ? getUniqueViewerCount(streamKey) : 0;
|
||||
callback(true, updatedStats);
|
||||
} else {
|
||||
callback(false, stats);
|
||||
|
|
|
|||
|
|
@ -69,5 +69,7 @@ private:
|
|||
|
||||
std::atomic<bool> running_{false};
|
||||
std::optional<trantor::TimerId> timerId_;
|
||||
std::chrono::seconds pollInterval_{2}; // Poll every 2 seconds
|
||||
// Poll every 5 seconds for near-instant stats updates
|
||||
// Real-time updates also come via OME webhooks (see StreamController::handleOmeWebhook)
|
||||
std::chrono::seconds pollInterval_{5};
|
||||
};
|
||||
281
backend/src/services/TreasuryService.cpp
Normal file
281
backend/src/services/TreasuryService.cpp
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
#include "TreasuryService.h"
|
||||
#include <ctime>
|
||||
#include <cmath>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
TreasuryService::~TreasuryService() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
void TreasuryService::initialize() {
|
||||
LOG_INFO << "Initializing Treasury Service...";
|
||||
running_ = true;
|
||||
}
|
||||
|
||||
void TreasuryService::startScheduler() {
|
||||
if (!running_) {
|
||||
LOG_WARN << "Treasury service not initialized, cannot start scheduler";
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO << "Starting treasury scheduler...";
|
||||
|
||||
if (auto loop = drogon::app().getLoop()) {
|
||||
try {
|
||||
// Do an immediate check on startup (catches up on missed tasks)
|
||||
checkAndRunTasks();
|
||||
|
||||
// Then set up the hourly timer
|
||||
timerId_ = loop->runEvery(
|
||||
checkInterval_.count(),
|
||||
[this]() {
|
||||
if (!running_) return;
|
||||
try {
|
||||
checkAndRunTasks();
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Error in treasury scheduler: " << e.what();
|
||||
}
|
||||
}
|
||||
);
|
||||
LOG_INFO << "Treasury scheduler started with " << checkInterval_.count() << "s interval";
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to create treasury timer: " << e.what();
|
||||
}
|
||||
} else {
|
||||
LOG_ERROR << "Event loop not available for treasury scheduler";
|
||||
}
|
||||
}
|
||||
|
||||
void TreasuryService::shutdown() {
|
||||
LOG_INFO << "Shutting down Treasury Service...";
|
||||
running_ = false;
|
||||
|
||||
if (timerId_.has_value()) {
|
||||
if (auto loop = drogon::app().getLoop()) {
|
||||
loop->invalidateTimer(timerId_.value());
|
||||
}
|
||||
timerId_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void TreasuryService::checkAndRunTasks() {
|
||||
LOG_INFO << "Treasury scheduler: checking for pending tasks...";
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get current time info
|
||||
std::time_t now = std::time(nullptr);
|
||||
std::tm* localTime = std::localtime(&now);
|
||||
int dayOfWeek = localTime->tm_wday; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||
|
||||
// Get treasury timestamps
|
||||
*dbClient << "SELECT last_growth_at, last_distribution_at FROM ubercoin_treasury WHERE id = 1"
|
||||
>> [this, dayOfWeek, localTime](const orm::Result& r) {
|
||||
if (r.empty()) {
|
||||
LOG_WARN << "Treasury record not found";
|
||||
return;
|
||||
}
|
||||
|
||||
bool needsGrowth = false;
|
||||
bool needsDistribution = false;
|
||||
|
||||
std::time_t now = std::time(nullptr);
|
||||
std::tm* todayStart = std::localtime(&now);
|
||||
todayStart->tm_hour = 0;
|
||||
todayStart->tm_min = 0;
|
||||
todayStart->tm_sec = 0;
|
||||
std::time_t todayStartTime = std::mktime(todayStart);
|
||||
|
||||
// Check if growth is needed (Mon-Sat, once per day)
|
||||
if (dayOfWeek >= 1 && dayOfWeek <= 6) { // Monday to Saturday
|
||||
if (r[0]["last_growth_at"].isNull()) {
|
||||
needsGrowth = true;
|
||||
} else {
|
||||
std::string lastGrowthStr = r[0]["last_growth_at"].as<std::string>();
|
||||
std::tm lastGrowthTm = {};
|
||||
std::istringstream ss(lastGrowthStr);
|
||||
ss >> std::get_time(&lastGrowthTm, "%Y-%m-%d %H:%M:%S");
|
||||
std::time_t lastGrowthTime = std::mktime(&lastGrowthTm);
|
||||
|
||||
// If last growth was before today, we need to apply growth
|
||||
if (lastGrowthTime < todayStartTime) {
|
||||
needsGrowth = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if distribution is needed (Sunday, once per week)
|
||||
if (dayOfWeek == 0) { // Sunday
|
||||
if (r[0]["last_distribution_at"].isNull()) {
|
||||
needsDistribution = true;
|
||||
} else {
|
||||
std::string lastDistStr = r[0]["last_distribution_at"].as<std::string>();
|
||||
std::tm lastDistTm = {};
|
||||
std::istringstream ss(lastDistStr);
|
||||
ss >> std::get_time(&lastDistTm, "%Y-%m-%d %H:%M:%S");
|
||||
std::time_t lastDistTime = std::mktime(&lastDistTm);
|
||||
|
||||
// If last distribution was before today, we need to distribute
|
||||
if (lastDistTime < todayStartTime) {
|
||||
needsDistribution = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsGrowth) {
|
||||
LOG_INFO << "Treasury scheduler: applying daily growth";
|
||||
this->applyDailyGrowth();
|
||||
}
|
||||
|
||||
if (needsDistribution) {
|
||||
LOG_INFO << "Treasury scheduler: distributing to users";
|
||||
this->distributeToUsers();
|
||||
}
|
||||
|
||||
if (!needsGrowth && !needsDistribution) {
|
||||
LOG_INFO << "Treasury scheduler: no tasks needed at this time";
|
||||
}
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Treasury scheduler: failed to check timestamps: " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void TreasuryService::applyDailyGrowth() {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Apply 3.3% growth to treasury balance
|
||||
*dbClient << "UPDATE ubercoin_treasury SET balance = balance * 1.033, last_growth_at = NOW() WHERE id = 1 RETURNING balance"
|
||||
>> [](const orm::Result& r) {
|
||||
double newBalance = 0.0;
|
||||
if (!r.empty()) {
|
||||
newBalance = r[0]["balance"].as<double>();
|
||||
}
|
||||
LOG_INFO << "Treasury growth applied (3.3%). New balance: " << newBalance;
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to apply treasury growth: " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void TreasuryService::distributeToUsers() {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get treasury balance
|
||||
*dbClient << "SELECT balance FROM ubercoin_treasury WHERE id = 1"
|
||||
>> [this, dbClient](const orm::Result& r) {
|
||||
if (r.empty()) {
|
||||
LOG_ERROR << "Treasury not found for distribution";
|
||||
return;
|
||||
}
|
||||
|
||||
double treasuryBalance = r[0]["balance"].as<double>();
|
||||
|
||||
if (treasuryBalance <= 0) {
|
||||
LOG_INFO << "Treasury empty, nothing to distribute";
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all users with their created_at for burn rate calculation
|
||||
*dbClient << "SELECT id, created_at, ubercoin_balance FROM users"
|
||||
>> [this, dbClient, treasuryBalance](const orm::Result& users) {
|
||||
if (users.empty()) {
|
||||
LOG_INFO << "No users to distribute to";
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t userCount = users.size();
|
||||
double sharePerUser = treasuryBalance / static_cast<double>(userCount);
|
||||
|
||||
double totalDistributed = 0.0;
|
||||
double totalDestroyed = 0.0;
|
||||
|
||||
// Calculate distributions for each user
|
||||
for (const auto& row : users) {
|
||||
int64_t userId = row["id"].as<int64_t>();
|
||||
std::string createdAt = row["created_at"].as<std::string>();
|
||||
double currentBalance = row["ubercoin_balance"].isNull() ? 0.0 : row["ubercoin_balance"].as<double>();
|
||||
|
||||
int accountAgeDays = this->calculateAccountAgeDays(createdAt);
|
||||
double burnRate = this->calculateBurnRate(accountAgeDays);
|
||||
|
||||
// Calculate received amount (after burn) - ceiling for user benefit
|
||||
double receivedAmount = sharePerUser * (100.0 - burnRate) / 100.0;
|
||||
receivedAmount = std::ceil(receivedAmount * 1000.0) / 1000.0;
|
||||
|
||||
double destroyedAmount = sharePerUser - receivedAmount;
|
||||
|
||||
double newBalance = currentBalance + receivedAmount;
|
||||
|
||||
// Update user balance
|
||||
*dbClient << "UPDATE users SET ubercoin_balance = $1 WHERE id = $2"
|
||||
<< newBalance << userId
|
||||
>> [](const orm::Result&) {}
|
||||
>> [userId](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update user " << userId << " balance in distribution: " << e.base().what();
|
||||
};
|
||||
|
||||
totalDistributed += receivedAmount;
|
||||
totalDestroyed += destroyedAmount;
|
||||
}
|
||||
|
||||
// Reset treasury balance to 0 and update total_destroyed
|
||||
*dbClient << "UPDATE ubercoin_treasury SET balance = 0, total_destroyed = total_destroyed + $1, last_distribution_at = NOW() WHERE id = 1"
|
||||
<< totalDestroyed
|
||||
>> [totalDistributed, totalDestroyed, userCount](const orm::Result&) {
|
||||
LOG_INFO << "Treasury distributed successfully. Users: " << userCount
|
||||
<< ", Distributed: " << totalDistributed
|
||||
<< ", Destroyed: " << totalDestroyed;
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to reset treasury: " << e.base().what();
|
||||
};
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get users for distribution: " << e.base().what();
|
||||
};
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get treasury balance: " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
double TreasuryService::calculateBurnRate(int accountAgeDays) {
|
||||
double burnRate = 99.0 * std::exp(-static_cast<double>(accountAgeDays) / 180.0);
|
||||
return std::max(1.0, burnRate);
|
||||
}
|
||||
|
||||
int TreasuryService::calculateAccountAgeDays(const std::string& createdAt) {
|
||||
try {
|
||||
// Parse ISO 8601 timestamp (e.g., "2025-01-15T10:30:00+00:00" or "2025-01-15 10:30:00")
|
||||
std::tm tm = {};
|
||||
std::istringstream ss(createdAt);
|
||||
|
||||
// Try ISO 8601 format first
|
||||
ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
|
||||
if (ss.fail()) {
|
||||
// Try space-separated format
|
||||
ss.clear();
|
||||
ss.str(createdAt);
|
||||
ss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S");
|
||||
}
|
||||
|
||||
if (ss.fail()) {
|
||||
LOG_WARN << "Failed to parse created_at timestamp: " << createdAt;
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::time_t createdTime = std::mktime(&tm);
|
||||
std::time_t now = std::time(nullptr);
|
||||
|
||||
// Calculate difference in days
|
||||
double diffSeconds = std::difftime(now, createdTime);
|
||||
return static_cast<int>(diffSeconds / (60 * 60 * 24));
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Error calculating account age: " << e.what();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
40
backend/src/services/TreasuryService.h
Normal file
40
backend/src/services/TreasuryService.h
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
#include <trantor/net/EventLoop.h>
|
||||
#include <atomic>
|
||||
#include <optional>
|
||||
#include <chrono>
|
||||
|
||||
class TreasuryService {
|
||||
public:
|
||||
static TreasuryService& getInstance() {
|
||||
static TreasuryService instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void initialize();
|
||||
void startScheduler();
|
||||
void shutdown();
|
||||
|
||||
// Manual triggers (for testing/admin)
|
||||
void applyDailyGrowth();
|
||||
void distributeToUsers();
|
||||
|
||||
private:
|
||||
TreasuryService() = default;
|
||||
~TreasuryService();
|
||||
TreasuryService(const TreasuryService&) = delete;
|
||||
TreasuryService& operator=(const TreasuryService&) = delete;
|
||||
|
||||
void checkAndRunTasks();
|
||||
|
||||
// Burn rate calculation helpers
|
||||
double calculateBurnRate(int accountAgeDays);
|
||||
int calculateAccountAgeDays(const std::string& createdAt);
|
||||
|
||||
std::atomic<bool> running_{false};
|
||||
std::optional<trantor::TimerId> timerId_;
|
||||
|
||||
// Check every hour (3600 seconds)
|
||||
std::chrono::seconds checkInterval_{3600};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue