#include "AdminController.h" #include "../services/OmeClient.h" #include "../services/RedisHelper.h" #include "../services/CensorService.h" #include "../common/HttpHelpers.h" #include "../common/AuthHelpers.h" #include "../common/FileValidation.h" #include #include #include #include #include #include #include #include #include #include using namespace drogon::orm; namespace { // Notify chat-service to refresh its sticker cache void notifyChatServiceStickerUpdate() { auto client = HttpClient::newHttpClient("http://chat-service:8081"); auto req = HttpRequest::newHttpRequest(); req->setMethod(Post); req->setPath("/api/chat/admin/stickers/refresh"); client->sendRequest(req, [](ReqResult result, const HttpResponsePtr& resp) { if (result != ReqResult::Ok) { LOG_WARN << "Failed to notify chat-service of sticker update: " << (int)result; } else { LOG_INFO << "Chat-service sticker cache refresh triggered"; } }); } // Notify chat-service to refresh its censored words cache void notifyChatServiceCensoredWordsUpdate() { auto client = HttpClient::newHttpClient("http://chat-service:8081"); auto req = HttpRequest::newHttpRequest(); req->setMethod(Post); req->setPath("/api/chat/admin/censored-words/refresh"); client->sendRequest(req, [](ReqResult result, const HttpResponsePtr& resp) { if (result != ReqResult::Ok) { LOG_WARN << "Failed to notify chat-service of censored words update: " << (int)result; } else { LOG_INFO << "Chat-service censored words cache refresh triggered"; } }); } // Rate limiter for censored words updates (10 per minute per admin) struct CensoredWordsRateLimiter { std::mutex mutex; std::unordered_map> requestTimes; static constexpr int MAX_REQUESTS = 10; static constexpr int WINDOW_SECONDS = 60; bool isAllowed(int userId) { std::lock_guard lock(mutex); auto now = std::chrono::steady_clock::now(); auto cutoff = now - std::chrono::seconds(WINDOW_SECONDS); auto& times = requestTimes[userId]; // Remove old entries times.erase( std::remove_if(times.begin(), times.end(), [cutoff](const auto& t) { return t < cutoff; }), times.end() ); if (times.size() >= MAX_REQUESTS) { return false; } times.push_back(now); return true; } }; CensoredWordsRateLimiter censoredWordsRateLimiter; // SECURITY FIX #26: Rate limiter for admin role operations (30 per minute per admin) struct AdminRoleRateLimiter { std::mutex mutex; std::unordered_map> requestTimes; static constexpr int MAX_REQUESTS = 30; static constexpr int WINDOW_SECONDS = 60; bool isAllowed(int userId) { std::lock_guard lock(mutex); auto now = std::chrono::steady_clock::now(); auto cutoff = now - std::chrono::seconds(WINDOW_SECONDS); auto& times = requestTimes[userId]; // Remove old entries times.erase( std::remove_if(times.begin(), times.end(), [cutoff](const auto& t) { return t < cutoff; }), times.end() ); if (times.size() >= MAX_REQUESTS) { return false; } times.push_back(now); return true; } }; AdminRoleRateLimiter adminRoleRateLimiter; // SECURITY FIX #27: HTML entity escaping to prevent XSS in site title std::string htmlEscape(const std::string& input) { std::string output; output.reserve(input.size() * 1.1); // Reserve a bit more space for entities for (char c : input) { switch (c) { case '&': output += "&"; break; case '<': output += "<"; break; case '>': output += ">"; break; case '"': output += """; break; case '\'': output += "'"; break; default: output += c; break; } } return output; } } // Update getUsers in AdminController.cpp: void AdminController::getUsers(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); LOG_DEBUG << "[getUsers] Auth check - user.id: " << user.id << ", isAdmin: " << user.isAdmin; if (user.id == 0 || !user.isAdmin) { LOG_WARN << "[getUsers] Unauthorized access attempt"; callback(jsonError("Unauthorized", k403Forbidden)); return; } LOG_DEBUG << "[getUsers] Executing database query for users..."; auto dbClient = app().getDbClient(); *dbClient << "SELECT u.id, u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, u.is_bot, u.is_sticker_creator, u.is_uploader, u.is_texter, u.is_watch_creator, u.is_disabled, u.pending_uberban, u.created_at, u.user_color, " "(SELECT COUNT(*) FROM realms WHERE user_id = u.id) as realm_count, " "(SELECT COUNT(*) FROM referral_codes WHERE owner_id = u.id) as referral_code_count " "FROM users u ORDER BY u.created_at DESC" >> [callback](const Result& r) { LOG_DEBUG << "[getUsers] Query successful, returned " << r.size() << " users"; Json::Value resp; resp["success"] = true; Json::Value users(Json::arrayValue); for (const auto& row : r) { Json::Value user; user["id"] = static_cast(row["id"].as()); user["username"] = row["username"].as(); user["isAdmin"] = row["is_admin"].as(); user["isModerator"] = row["is_moderator"].isNull() ? false : row["is_moderator"].as(); user["isStreamer"] = row["is_streamer"].as(); user["isRestreamer"] = row["is_restreamer"].isNull() ? false : row["is_restreamer"].as(); user["isBot"] = row["is_bot"].isNull() ? false : row["is_bot"].as(); user["isStickerCreator"] = row["is_sticker_creator"].isNull() ? false : row["is_sticker_creator"].as(); user["isUploader"] = row["is_uploader"].isNull() ? false : row["is_uploader"].as(); user["isTexter"] = row["is_texter"].isNull() ? false : row["is_texter"].as(); user["isWatchCreator"] = row["is_watch_creator"].isNull() ? false : row["is_watch_creator"].as(); user["isDisabled"] = row["is_disabled"].isNull() ? false : row["is_disabled"].as(); user["pendingUberban"] = row["pending_uberban"].isNull() ? false : row["pending_uberban"].as(); user["createdAt"] = row["created_at"].as(); user["colorCode"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as(); user["realmCount"] = static_cast(row["realm_count"].as()); user["referralCodeCount"] = static_cast(row["referral_code_count"].as()); users.append(user); LOG_DEBUG << "[getUsers] Added user: " << user["username"].asString(); } resp["users"] = users; LOG_DEBUG << "[getUsers] Sending response with " << users.size() << " users"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get users", "Failed to get users"); } void AdminController::getActiveStreams(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // Get live realms from database auto dbClient = app().getDbClient(); *dbClient << "SELECT r.id, r.name, r.stream_key, r.viewer_count, " "u.username FROM realms r " "JOIN users u ON r.user_id = u.id " "WHERE r.is_live = true" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value streams(Json::arrayValue); for (const auto& row : r) { Json::Value stream; stream["id"] = static_cast(row["id"].as()); stream["name"] = row["name"].as(); stream["streamKey"] = row["stream_key"].as(); stream["viewerCount"] = static_cast(row["viewer_count"].as()); stream["username"] = row["username"].as(); streams.append(stream); } resp["streams"] = streams; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get active streams", "Failed to get active streams"); } void AdminController::disconnectStream(const HttpRequestPtr &req, std::function &&callback, const std::string &streamKey) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // Add to Redis set for OpenResty to disconnect RedisHelper::addToSet("streams_to_disconnect", streamKey); // Also try direct disconnect OmeClient::getInstance().disconnectStream(streamKey, [callback](bool) { Json::Value resp; resp["success"] = true; resp["message"] = "Stream disconnect initiated"; callback(jsonResp(resp)); }); } void AdminController::promoteToStreamer(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_streamer = true WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User promoted to streamer"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "promote user", "Failed to promote user"); } void AdminController::demoteFromStreamer(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_streamer = false WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User demoted from streamer"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "demote user", "Failed to demote user"); } void AdminController::promoteToRestreamer(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_restreamer = true WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User promoted to restreamer"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "promote user to restreamer", "Failed to promote user to restreamer"); } void AdminController::demoteFromRestreamer(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_restreamer = false WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User demoted from restreamer"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "demote user from restreamer", "Failed to demote user from restreamer"); } void AdminController::promoteToBot(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_bot = true WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User granted bot role"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "grant bot role", "Failed to grant bot role"); } void AdminController::demoteFromBot(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_bot = false WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User bot role revoked"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "revoke bot role", "Failed to revoke bot role"); } void AdminController::getAllBotApiKeys(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT b.id, b.user_id, b.name, b.scopes, b.is_active, b.last_used_at, b.expires_at, b.created_at, " "u.username FROM bot_api_keys b " "JOIN users u ON b.user_id = u.id " "ORDER BY b.created_at DESC" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value keys(Json::arrayValue); for (const auto& row : r) { Json::Value key; key["id"] = static_cast(row["id"].as()); key["userId"] = static_cast(row["user_id"].as()); key["username"] = row["username"].as(); key["name"] = row["name"].as(); key["scopes"] = row["scopes"].isNull() ? "chat:write" : row["scopes"].as(); key["isActive"] = row["is_active"].as(); key["lastUsedAt"] = row["last_used_at"].isNull() ? "" : row["last_used_at"].as(); key["expiresAt"] = row["expires_at"].isNull() ? "" : row["expires_at"].as(); key["createdAt"] = row["created_at"].as(); keys.append(key); } resp["keys"] = keys; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get all bot API keys", "Failed to get API keys"); } void AdminController::deleteBotApiKey(const HttpRequestPtr &req, std::function &&callback, const std::string &keyId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t keyIdInt; try { keyIdInt = std::stoll(keyId); } catch (...) { callback(jsonError("Invalid key ID")); return; } auto dbClient = app().getDbClient(); // Hard delete the key (admin action) *dbClient << "DELETE FROM bot_api_keys WHERE id = $1" << keyIdInt >> [callback](const Result& r) { if (r.affectedRows() == 0) { callback(jsonError("API key not found", k404NotFound)); return; } Json::Value resp; resp["success"] = true; resp["message"] = "API key deleted"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "delete bot API key", "Failed to delete API key"); } void AdminController::uploadStickers(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } MultiPartParser fileUpload; if (fileUpload.parse(req) != 0 || fileUpload.getFiles().empty()) { callback(jsonError("No files uploaded")); return; } auto files = fileUpload.getFiles(); auto dbClient = app().getDbClient(); // Validate file sizes (4MB limit) const size_t maxFileSize = 4 * 1024 * 1024; // 4MB for (const auto& file : files) { if (file.fileLength() > maxFileSize) { callback(jsonError("File too large: " + file.getFileName() + " (max 4MB)")); return; } } Json::Value resp; resp["success"] = true; Json::Value uploaded(Json::arrayValue); // Ensure directory exists std::filesystem::create_directories("./uploads/stickers"); // Process each uploaded file int validCount = 0; for (const auto& file : files) { // Get the sticker name from the parameter (format: sticker_name[filename]) std::string paramName = file.getFileName(); std::string stickerName = fileUpload.getParameter("name_" + paramName); if (stickerName.empty()) { // Use filename without extension as default name stickerName = paramName.substr(0, paramName.find_last_of('.')); } // Validate file content using magic bytes (not just extension) auto validation = validateImageMagicBytes(file.fileData(), file.fileLength(), true); if (!validation.valid) { LOG_WARN << "Sticker upload rejected for " << paramName << ": invalid image magic bytes"; continue; // Skip invalid file types } // Use the extension from magic byte validation (ignores client-provided extension) std::string ext = validation.extension; std::string uniqueName = drogon::utils::getUuid() + ext; std::string filePath = "/uploads/stickers/" + uniqueName; std::string fullPath = "./uploads/stickers/" + uniqueName; // Save file file.saveAs(fullPath); validCount++; // Insert into database *dbClient << "INSERT INTO stickers (name, file_path) VALUES ($1, $2) RETURNING id" << stickerName << filePath >> [uploaded, stickerName, filePath](const Result& r) mutable { if (!r.empty()) { Json::Value sticker; sticker["id"] = static_cast(r[0]["id"].as()); sticker["name"] = stickerName; sticker["filePath"] = filePath; uploaded.append(sticker); } } >> [](const DrogonDbException& e) { LOG_ERROR << "Failed to insert sticker: " << e.base().what(); }; } if (validCount == 0) { callback(jsonError("No valid image files uploaded. Only JPEG, PNG, GIF, and WebP are allowed.")); return; } resp["stickers"] = uploaded; resp["count"] = validCount; // Notify chat-service to refresh sticker cache notifyChatServiceStickerUpdate(); callback(jsonResp(resp)); } void AdminController::getStickers(const HttpRequestPtr &req, std::function &&callback) { auto dbClient = app().getDbClient(); *dbClient << "SELECT id, name, file_path, created_at FROM stickers WHERE is_active = true ORDER BY name ASC" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value stickers(Json::arrayValue); for (const auto& row : r) { Json::Value sticker; sticker["id"] = static_cast(row["id"].as()); sticker["name"] = row["name"].as(); sticker["filePath"] = row["file_path"].as(); sticker["createdAt"] = row["created_at"].as(); stickers.append(sticker); } resp["stickers"] = stickers; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get stickers", "Failed to get stickers"); } void AdminController::deleteSticker(const HttpRequestPtr &req, std::function &&callback, const std::string &stickerId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t id = std::stoll(stickerId); auto dbClient = app().getDbClient(); // First get the file path before deleting *dbClient << "SELECT file_path FROM stickers WHERE id = $1" << id >> [callback, dbClient, id](const Result& r) { if (r.empty()) { callback(jsonError("Sticker not found", k404NotFound)); return; } std::string filePath = r[0]["file_path"].as(); // Hard delete from database *dbClient << "DELETE FROM stickers WHERE id = $1" << id >> [callback, filePath](const Result&) { // Delete file from disk try { // Convert web path to filesystem path // file_path is like "/uploads/stickers/uuid.ext" std::string fullPath = "." + filePath; if (std::filesystem::exists(fullPath)) { std::filesystem::remove(fullPath); LOG_INFO << "Deleted sticker file: " << fullPath; } else { LOG_WARN << "Sticker file not found for deletion: " << fullPath; } } catch (const std::exception& e) { LOG_WARN << "Failed to delete sticker file " << filePath << ": " << e.what(); // Continue anyway - DB record is already deleted } // Notify chat-service to refresh sticker cache notifyChatServiceStickerUpdate(); Json::Value resp; resp["success"] = true; resp["message"] = "Sticker permanently deleted"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "delete sticker", "Failed to delete sticker"); } >> DB_ERROR_MSG(callback, "get sticker", "Failed to get sticker"); } void AdminController::renameSticker(const HttpRequestPtr &req, std::function &&callback, const std::string &stickerId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto jsonPtr = req->getJsonObject(); if (!jsonPtr) { callback(jsonError("Invalid JSON")); return; } std::string newName = (*jsonPtr).get("name", "").asString(); if (newName.empty()) { callback(jsonError("Name is required")); return; } // Validate name (alphanumeric and underscores only, 1-50 chars) if (newName.length() > 50) { callback(jsonError("Name must be 50 characters or less")); return; } int64_t id = std::stoll(stickerId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE stickers SET name = $1 WHERE id = $2 RETURNING id, name, file_path" << newName << id >> [callback](const Result& r) { if (r.empty()) { callback(jsonError("Sticker not found", k404NotFound)); return; } // Notify chat-service to refresh sticker cache notifyChatServiceStickerUpdate(); Json::Value resp; resp["success"] = true; resp["sticker"]["id"] = static_cast(r[0]["id"].as()); resp["sticker"]["name"] = r[0]["name"].as(); resp["sticker"]["filePath"] = r[0]["file_path"].as(); callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to rename sticker: " << e.base().what(); if (std::string(e.base().what()).find("duplicate") != std::string::npos) { callback(jsonError("A sticker with this name already exists")); } else { callback(jsonError("Failed to rename sticker")); } }; } void AdminController::promoteToStickerCreator(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_sticker_creator = true WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User granted sticker creator role"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "grant sticker creator role", "Failed to grant sticker creator role"); } void AdminController::demoteFromStickerCreator(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_sticker_creator = false WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User sticker creator role revoked"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "revoke sticker creator role", "Failed to revoke sticker creator role"); } void AdminController::promoteToUploader(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_uploader = true WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User granted uploader role"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "grant uploader role", "Failed to grant uploader role"); } void AdminController::demoteFromUploader(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_uploader = false WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User uploader role revoked"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "revoke uploader role", "Failed to revoke uploader role"); } void AdminController::promoteToTexter(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_texter = true WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User granted texter role"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "grant texter role", "Failed to grant texter role"); } void AdminController::demoteFromTexter(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_texter = false WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User texter role revoked"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "revoke texter role", "Failed to revoke texter role"); } void AdminController::promoteToWatchCreator(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_watch_creator = true WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User granted watch creator role"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "grant watch creator role", "Failed to grant watch creator role"); } void AdminController::demoteFromWatchCreator(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_watch_creator = false WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User watch creator role revoked"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "revoke watch creator role", "Failed to revoke watch creator role"); } void AdminController::promoteToModerator(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_moderator = true WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User granted site-wide moderator role"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "grant moderator role", "Failed to grant moderator role"); } void AdminController::demoteFromModerator(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // SECURITY FIX #26: Rate limit role operations if (!adminRoleRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 30 role changes per minute.", k429TooManyRequests)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); *dbClient << "UPDATE users SET is_moderator = false WHERE id = $1" << targetUserId >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User site-wide moderator role revoked"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "revoke moderator role", "Failed to revoke moderator role"); } void AdminController::getStickerSubmissions(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT s.id, s.name, s.file_path, s.status, s.denial_reason, s.created_at, " "u.id as submitter_id, u.username as submitter_username " "FROM sticker_submissions s " "JOIN users u ON s.submitted_by = u.id " "WHERE s.status = 'pending' " "ORDER BY s.created_at ASC" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value submissions(Json::arrayValue); for (const auto& row : r) { Json::Value sub; sub["id"] = static_cast(row["id"].as()); sub["name"] = row["name"].as(); sub["filePath"] = row["file_path"].as(); sub["status"] = row["status"].as(); sub["createdAt"] = row["created_at"].as(); sub["submitterId"] = static_cast(row["submitter_id"].as()); sub["submitterUsername"] = row["submitter_username"].as(); submissions.append(sub); } resp["submissions"] = submissions; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get sticker submissions", "Failed to get sticker submissions"); } void AdminController::approveStickerSubmission(const HttpRequestPtr &req, std::function &&callback, const std::string &submissionId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto jsonPtr = req->getJsonObject(); std::string newName = ""; if (jsonPtr) { newName = (*jsonPtr).get("name", "").asString(); } int64_t id = std::stoll(submissionId); int64_t reviewerId = user.id; auto dbClient = app().getDbClient(); // First get the submission details *dbClient << "SELECT id, name, file_path FROM sticker_submissions WHERE id = $1 AND status = 'pending'" << id >> [callback, dbClient, id, reviewerId, newName](const Result& r) { if (r.empty()) { callback(jsonError("Submission not found or already processed", k404NotFound)); return; } std::string stickerName = newName.empty() ? r[0]["name"].as() : newName; std::string filePath = r[0]["file_path"].as(); // Insert into stickers table *dbClient << "INSERT INTO stickers (name, file_path) VALUES ($1, $2) RETURNING id" << stickerName << filePath >> [callback, dbClient, id, reviewerId, stickerName](const Result& insertResult) { if (insertResult.empty()) { callback(jsonError("Failed to create sticker")); return; } int64_t newStickerId = insertResult[0]["id"].as(); // Update submission status *dbClient << "UPDATE sticker_submissions SET status = 'approved', " "reviewed_by = $1, reviewed_at = CURRENT_TIMESTAMP WHERE id = $2" << reviewerId << id >> [callback, newStickerId, stickerName](const Result&) { // Notify chat-service to refresh sticker cache notifyChatServiceStickerUpdate(); Json::Value resp; resp["success"] = true; resp["message"] = "Sticker approved and added"; resp["sticker"]["id"] = static_cast(newStickerId); resp["sticker"]["name"] = stickerName; callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to update submission status: " << e.base().what(); callback(jsonError("Failed to update submission status")); }; } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to create sticker: " << e.base().what(); if (std::string(e.base().what()).find("duplicate") != std::string::npos) { callback(jsonError("A sticker with this name already exists")); } else { callback(jsonError("Failed to create sticker")); } }; } >> DB_ERROR_MSG(callback, "get submission", "Failed to get submission"); } void AdminController::denyStickerSubmission(const HttpRequestPtr &req, std::function &&callback, const std::string &submissionId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto jsonPtr = req->getJsonObject(); std::string reason = ""; if (jsonPtr) { reason = (*jsonPtr).get("reason", "").asString(); } int64_t id = std::stoll(submissionId); int64_t reviewerId = user.id; auto dbClient = app().getDbClient(); *dbClient << "UPDATE sticker_submissions SET status = 'denied', " "reviewed_by = $1, reviewed_at = CURRENT_TIMESTAMP, denial_reason = $2 " "WHERE id = $3 AND status = 'pending'" << reviewerId << reason << id >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; resp["message"] = "Sticker submission denied"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "deny submission", "Failed to deny submission"); } void AdminController::uploadHonkSound(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } MultiPartParser fileUpload; if (fileUpload.parse(req) != 0 || fileUpload.getFiles().empty()) { callback(jsonError("No file uploaded")); return; } auto files = fileUpload.getFiles(); if (files.size() != 1) { callback(jsonError("Please upload exactly one file")); return; } auto& file = files[0]; std::string paramName = file.getFileName(); std::string honkName = fileUpload.getParameter("name"); if (honkName.empty()) { // Use filename without extension as default name honkName = paramName.substr(0, paramName.find_last_of('.')); } // Validate file extension std::string ext = paramName.substr(paramName.find_last_of('.')); std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); if (ext != ".mp3" && ext != ".wav" && ext != ".ogg") { callback(jsonError("Only MP3, WAV, and OGG files are allowed")); return; } // Generate unique filename std::string uniqueName = drogon::utils::getUuid() + ext; std::string filePath = "/uploads/honks/" + uniqueName; std::string fullPath = "./uploads/honks/" + uniqueName; // Ensure directory exists std::filesystem::create_directories("./uploads/honks"); // Save file file.saveAs(fullPath); auto dbClient = app().getDbClient(); *dbClient << "INSERT INTO honk_sounds (name, file_path) VALUES ($1, $2) RETURNING id, name, file_path, is_active, created_at" << honkName << filePath >> [callback](const Result& r) { if (!r.empty()) { const auto& row = r[0]; Json::Value resp; resp["success"] = true; resp["honk"]["id"] = static_cast(row["id"].as()); resp["honk"]["name"] = row["name"].as(); resp["honk"]["filePath"] = row["file_path"].as(); resp["honk"]["isActive"] = row["is_active"].as(); resp["honk"]["createdAt"] = row["created_at"].as(); callback(jsonResp(resp)); } else { callback(jsonError("Failed to save honk sound")); } } >> DB_ERROR_MSG(callback, "insert honk sound", "Failed to upload honk sound"); } void AdminController::getHonkSounds(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT id, name, file_path, is_active, created_at FROM honk_sounds ORDER BY created_at DESC" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value honks(Json::arrayValue); for (const auto& row : r) { Json::Value honk; honk["id"] = static_cast(row["id"].as()); honk["name"] = row["name"].as(); honk["filePath"] = row["file_path"].as(); honk["isActive"] = row["is_active"].as(); honk["createdAt"] = row["created_at"].as(); honks.append(honk); } resp["honks"] = honks; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get honk sounds", "Failed to get honk sounds"); } void AdminController::deleteHonkSound(const HttpRequestPtr &req, std::function &&callback, const std::string &honkId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t id = std::stoll(honkId); auto dbClient = app().getDbClient(); *dbClient << "DELETE FROM honk_sounds WHERE id = $1" << id >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "Honk sound deleted"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "delete honk sound", "Failed to delete honk sound"); } void AdminController::setActiveHonkSound(const HttpRequestPtr &req, std::function &&callback, const std::string &honkId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t id = std::stoll(honkId); auto dbClient = app().getDbClient(); // The trigger will automatically deactivate other honks *dbClient << "UPDATE honk_sounds SET is_active = true WHERE id = $1" << id >> [callback](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "Honk sound activated"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "activate honk sound", "Failed to activate honk sound"); } void AdminController::getActiveHonkSound(const HttpRequestPtr &req, std::function &&callback) { // This endpoint is public - no auth required auto dbClient = app().getDbClient(); *dbClient << "SELECT id, name, file_path FROM honk_sounds WHERE is_active = true LIMIT 1" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; if (!r.empty()) { const auto& row = r[0]; resp["honk"]["id"] = static_cast(row["id"].as()); resp["honk"]["name"] = row["name"].as(); resp["honk"]["filePath"] = row["file_path"].as(); } else { resp["honk"] = Json::nullValue; } callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get active honk sound", "Failed to get active honk sound"); } void AdminController::getChatSettings(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (!user.isAdmin) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT guest_prefix, default_retention_hours, " "guests_allowed_site_wide, registration_enabled, referral_system_enabled FROM chat_settings WHERE id = 1" >> [callback](const Result& r) { if (r.empty()) { Json::Value settings; settings["guestPrefix"] = "Guest"; settings["defaultRetentionHours"] = 24; settings["guestsAllowedSiteWide"] = true; settings["registrationEnabled"] = true; settings["referralSystemEnabled"] = false; callback(jsonResp(settings)); return; } auto row = r[0]; Json::Value settings; settings["guestPrefix"] = row["guest_prefix"].as(); settings["defaultRetentionHours"] = row["default_retention_hours"].as(); settings["guestsAllowedSiteWide"] = row["guests_allowed_site_wide"].as(); settings["registrationEnabled"] = row["registration_enabled"].as(); settings["referralSystemEnabled"] = row["referral_system_enabled"].isNull() ? false : row["referral_system_enabled"].as(); callback(jsonResp(settings)); } >> DB_ERROR_MSG(callback, "get chat settings", "Failed to get chat settings"); } void AdminController::updateChatSettings(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (!user.isAdmin) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } auto jsonPtr = req->getJsonObject(); if (!jsonPtr) { callback(jsonError("Invalid JSON")); return; } const auto& json = *jsonPtr; std::string guestPrefix = json.get("guestPrefix", "Guest").asString(); int defaultRetentionHours = json.get("defaultRetentionHours", 24).asInt(); bool guestsAllowedSiteWide = json.get("guestsAllowedSiteWide", true).asBool(); bool registrationEnabled = json.get("registrationEnabled", true).asBool(); bool referralSystemEnabled = json.get("referralSystemEnabled", false).asBool(); // Validate: referral system can only be enabled if registration is disabled if (referralSystemEnabled && registrationEnabled) { callback(jsonError("Referral system can only be enabled when registration is disabled")); return; } // SECURITY FIX #33: Sync-first transactional pattern // Sync to chat-service FIRST, then update PostgreSQL only on success // This prevents configuration drift where database is updated but chat-service is not auto client = HttpClient::newHttpClient("http://chat-service:8081"); // Build request body with settings Json::Value chatServiceBody; chatServiceBody["guestPrefix"] = guestPrefix; chatServiceBody["defaultRetentionHours"] = defaultRetentionHours; chatServiceBody["guestsAllowedSiteWide"] = guestsAllowedSiteWide; chatServiceBody["registrationEnabled"] = registrationEnabled; chatServiceBody["referralSystemEnabled"] = referralSystemEnabled; auto chatReq = HttpRequest::newHttpJsonRequest(chatServiceBody); chatReq->setMethod(Put); chatReq->setPath("/api/chat/admin/settings"); // Get auth token from original request std::string token = req->getCookie("auth_token"); if (token.empty()) { std::string auth = req->getHeader("Authorization"); if (!auth.empty() && auth.substr(0, 7) == "Bearer ") { token = auth.substr(7); } } chatReq->addHeader("Authorization", "Bearer " + token); // First sync to chat-service client->sendRequest(chatReq, [callback, guestPrefix, defaultRetentionHours, guestsAllowedSiteWide, registrationEnabled, referralSystemEnabled] (ReqResult result, const HttpResponsePtr& chatResp) { Json::Value resp; // SECURITY FIX #28/#33: If chat-service sync fails, abort without updating PostgreSQL if (result != ReqResult::Ok) { LOG_ERROR << "Failed to connect to chat-service: " << (int)result; resp["success"] = false; resp["error"] = "Chat service is unavailable. Settings not updated."; callback(jsonResp(resp)); return; } // Check HTTP response status if (chatResp && chatResp->getStatusCode() != k200OK) { LOG_ERROR << "Chat-service rejected settings sync: " << chatResp->getStatusCode(); resp["success"] = false; resp["error"] = "Chat service rejected the update. Settings not updated."; callback(jsonResp(resp)); return; } // Chat-service sync successful, now update PostgreSQL LOG_INFO << "Settings synced to chat-service Redis, now updating PostgreSQL"; auto dbClient = app().getDbClient(); *dbClient << "UPDATE chat_settings SET guest_prefix = $1, " "default_retention_hours = $2, guests_allowed_site_wide = $3, " "registration_enabled = $4, referral_system_enabled = $5, " "updated_at = CURRENT_TIMESTAMP WHERE id = 1" << guestPrefix << defaultRetentionHours << guestsAllowedSiteWide << registrationEnabled << referralSystemEnabled >> [callback, guestPrefix, defaultRetentionHours, guestsAllowedSiteWide, registrationEnabled, referralSystemEnabled](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "Chat settings updated"; resp["settings"]["guestPrefix"] = guestPrefix; resp["settings"]["defaultRetentionHours"] = defaultRetentionHours; resp["settings"]["guestsAllowedSiteWide"] = guestsAllowedSiteWide; resp["settings"]["registrationEnabled"] = registrationEnabled; resp["settings"]["referralSystemEnabled"] = referralSystemEnabled; callback(jsonResp(resp)); LOG_INFO << "Chat settings updated: guestPrefix=" << guestPrefix << ", guestsAllowed=" << guestsAllowedSiteWide << ", regEnabled=" << registrationEnabled << ", referralEnabled=" << referralSystemEnabled; } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to update PostgreSQL after chat-service sync: " << e.base().what(); // SECURITY FIX #33: Warn about inconsistent state Json::Value resp; resp["success"] = false; resp["error"] = "Chat service updated but database update failed. Please retry or contact support."; callback(jsonResp(resp)); }; }); } void AdminController::getRealms(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT r.id, r.name, r.description, r.stream_key, r.is_active, r.is_live, " "r.viewer_count, r.viewer_multiplier, r.realm_type, r.created_at, r.original_creator_username, " "u.username, u.id as user_id " "FROM realms r " "JOIN users u ON r.user_id = u.id " "ORDER BY r.created_at DESC" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value realms(Json::arrayValue); for (const auto& row : r) { Json::Value realm; realm["id"] = static_cast(row["id"].as()); realm["name"] = row["name"].as(); realm["description"] = row["description"].isNull() ? "" : row["description"].as(); realm["streamKey"] = row["stream_key"].isNull() ? "" : row["stream_key"].as(); realm["isActive"] = row["is_active"].as(); realm["isLive"] = row["is_live"].as(); realm["viewerCount"] = static_cast(row["viewer_count"].as()); realm["viewerMultiplier"] = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as(); realm["realmType"] = row["realm_type"].isNull() ? "stream" : row["realm_type"].as(); realm["createdAt"] = row["created_at"].as(); realm["username"] = row["username"].as(); realm["userId"] = static_cast(row["user_id"].as()); realm["originalCreatorUsername"] = row["original_creator_username"].isNull() ? "" : row["original_creator_username"].as(); realms.append(realm); } resp["realms"] = realms; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get realms", "Failed to get realms"); } void AdminController::setViewerMultiplier(const HttpRequestPtr &req, std::function &&callback, const std::string &realmId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto jsonPtr = req->getJsonObject(); if (!jsonPtr) { callback(jsonError("Invalid JSON body")); return; } int64_t id = std::stoll(realmId); int multiplier = (*jsonPtr).get("multiplier", 1).asInt(); // Clamp multiplier to reasonable range (1-1000) if (multiplier < 1) multiplier = 1; if (multiplier > 1000) multiplier = 1000; auto dbClient = app().getDbClient(); *dbClient << "UPDATE realms SET viewer_multiplier = $1 WHERE id = $2 AND realm_type = 'stream'" << multiplier << id >> [callback, multiplier](const Result&) { Json::Value resp; resp["success"] = true; resp["multiplier"] = multiplier; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "set viewer multiplier", "Failed to update viewer multiplier"); } void AdminController::deleteRealm(const HttpRequestPtr &req, std::function &&callback, const std::string &realmId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t id = std::stoll(realmId); auto dbClient = app().getDbClient(); // First, collect all file paths from videos, audio_files, and ebooks for this realm // Then delete files from disk before deleting from database // Get video files *dbClient << "SELECT file_path, thumbnail_path, preview_path FROM videos WHERE realm_id = $1" << id >> [callback, dbClient, id](const Result& videos) { // Collect video file paths std::vector filesToDelete; for (const auto& row : videos) { if (!row["file_path"].isNull()) { filesToDelete.push_back("/app" + row["file_path"].as()); } if (!row["thumbnail_path"].isNull()) { filesToDelete.push_back("/app" + row["thumbnail_path"].as()); } if (!row["preview_path"].isNull()) { filesToDelete.push_back("/app" + row["preview_path"].as()); } } // Get audio files *dbClient << "SELECT file_path, thumbnail_path FROM audio_files WHERE realm_id = $1" << id >> [callback, dbClient, id, filesToDelete](const Result& audio) mutable { // Collect audio file paths for (const auto& row : audio) { if (!row["file_path"].isNull()) { filesToDelete.push_back("/app" + row["file_path"].as()); } if (!row["thumbnail_path"].isNull()) { filesToDelete.push_back("/app" + row["thumbnail_path"].as()); } } // Get ebook files *dbClient << "SELECT file_path, cover_path FROM ebooks WHERE realm_id = $1" << id >> [callback, dbClient, id, filesToDelete](const Result& ebooks) mutable { // Collect ebook file paths for (const auto& row : ebooks) { if (!row["file_path"].isNull()) { filesToDelete.push_back("/app" + row["file_path"].as()); } if (!row["cover_path"].isNull()) { filesToDelete.push_back("/app" + row["cover_path"].as()); } } // Delete all collected files from disk int filesDeleted = 0; for (const auto& filePath : filesToDelete) { try { if (std::filesystem::exists(filePath)) { std::filesystem::remove(filePath); filesDeleted++; } } catch (const std::exception& e) { LOG_WARN << "Failed to delete file " << filePath << ": " << e.what(); } } LOG_INFO << "Deleted " << filesDeleted << " files for realm " << id; // Now delete the realm (CASCADE will handle DB records) *dbClient << "DELETE FROM realms WHERE id = $1" << id >> [callback, filesDeleted](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "Realm deleted successfully"; resp["filesDeleted"] = filesDeleted; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "delete realm", "Failed to delete realm"); } >> DB_ERROR_MSG(callback, "get ebooks for realm", "Failed to get ebooks for realm"); } >> DB_ERROR_MSG(callback, "get audio files for realm", "Failed to get audio files for realm"); } >> DB_ERROR_MSG(callback, "get videos for realm", "Failed to get videos for realm"); } void AdminController::deleteUser(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t targetUserId = std::stoll(userId); // Prevent admin from deleting themselves if (targetUserId == user.id) { callback(jsonError("Cannot delete your own account")); return; } auto dbClient = app().getDbClient(); // First check if user exists and is not an admin *dbClient << "SELECT id, username, is_admin FROM users WHERE id = $1" << targetUserId >> [callback, dbClient, targetUserId](const Result& r) { if (r.empty()) { callback(jsonError("User not found", k404NotFound)); return; } if (r[0]["is_admin"].as()) { callback(jsonError("Cannot delete admin users")); return; } std::string deletedUsername = r[0]["username"].as(); // Get an admin user ID to transfer content to *dbClient << "SELECT id FROM users WHERE is_admin = true LIMIT 1" >> [callback, dbClient, targetUserId, deletedUsername](const Result& adminResult) { if (adminResult.empty()) { callback(jsonError("No admin user found to transfer content")); return; } int64_t adminId = adminResult[0]["id"].as(); // Transfer realms to admin, preserving original creator username *dbClient << "UPDATE realms SET user_id = $1, original_creator_username = $2 WHERE user_id = $3" << adminId << deletedUsername << targetUserId >> [callback, dbClient, targetUserId, deletedUsername, adminId](const Result&) { // Transfer videos to admin *dbClient << "UPDATE videos SET user_id = $1 WHERE user_id = $2" << adminId << targetUserId >> [callback, dbClient, targetUserId, deletedUsername, adminId](const Result&) { // Transfer audio_files to admin *dbClient << "UPDATE audio_files SET user_id = $1 WHERE user_id = $2" << adminId << targetUserId >> [callback, dbClient, targetUserId, deletedUsername, adminId](const Result&) { // Transfer ebooks to admin *dbClient << "UPDATE ebooks SET user_id = $1 WHERE user_id = $2" << adminId << targetUserId >> [callback, dbClient, targetUserId, deletedUsername, adminId](const Result&) { // Transfer forums to admin *dbClient << "UPDATE forums SET user_id = $1 WHERE user_id = $2" << adminId << targetUserId >> [callback, dbClient, targetUserId, deletedUsername](const Result&) { // Now safe to delete user (all content transferred) *dbClient << "DELETE FROM users WHERE id = $1" << targetUserId >> [callback, deletedUsername](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User '" + deletedUsername + "' deleted. All content transferred to admin."; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "delete user", "Failed to delete user"); } >> DB_ERROR_MSG(callback, "transfer forums", "Failed to transfer forums"); } >> DB_ERROR_MSG(callback, "transfer ebooks", "Failed to transfer ebooks"); } >> DB_ERROR_MSG(callback, "transfer audio files", "Failed to transfer audio files"); } >> DB_ERROR_MSG(callback, "transfer videos", "Failed to transfer videos"); } >> DB_ERROR_MSG(callback, "transfer realms", "Failed to transfer realms"); } >> DB_ERROR_MSG(callback, "get admin user", "Failed to get admin user"); } >> DB_ERROR_MSG(callback, "get user", "Failed to get user"); } void AdminController::disableUser(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t targetUserId = std::stoll(userId); // Prevent admin from disabling themselves if (targetUserId == user.id) { callback(jsonError("Cannot disable your own account")); return; } auto dbClient = app().getDbClient(); // Check if user exists and is not an admin *dbClient << "SELECT id, username, is_admin FROM users WHERE id = $1" << targetUserId >> [callback, dbClient, targetUserId](const Result& r) { if (r.empty()) { callback(jsonError("User not found", k404NotFound)); return; } bool isAdmin = r[0]["is_admin"].as(); std::string username = r[0]["username"].as(); if (isAdmin) { callback(jsonError("Cannot disable admin users")); return; } // Disable the user *dbClient << "UPDATE users SET is_disabled = true WHERE id = $1" << targetUserId >> [callback, username](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User '" + username + "' has been disabled"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "disable user", "Failed to disable user"); } >> DB_ERROR_MSG(callback, "get user", "Failed to get user"); } void AdminController::enableUser(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t targetUserId = std::stoll(userId); auto dbClient = app().getDbClient(); // Check if user exists *dbClient << "SELECT id, username FROM users WHERE id = $1" << targetUserId >> [callback, dbClient, targetUserId](const Result& r) { if (r.empty()) { callback(jsonError("User not found", k404NotFound)); return; } std::string username = r[0]["username"].as(); // Enable the user *dbClient << "UPDATE users SET is_disabled = false WHERE id = $1" << targetUserId >> [callback, username](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "User '" + username + "' has been enabled"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "enable user", "Failed to enable user"); } >> DB_ERROR_MSG(callback, "get user", "Failed to get user"); } void AdminController::uberbanUser(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t targetUserId = std::stoll(userId); // Prevent admin from uberbanning themselves if (targetUserId == user.id) { callback(jsonError("Cannot uberban yourself", k400BadRequest)); return; } auto dbClient = app().getDbClient(); // Check if user exists and is not an admin *dbClient << "SELECT id, username, is_admin FROM users WHERE id = $1" << targetUserId >> [callback, dbClient, targetUserId](const Result& r) { if (r.empty()) { callback(jsonError("User not found", k404NotFound)); return; } bool isAdmin = r[0]["is_admin"].as(); if (isAdmin) { callback(jsonError("Cannot uberban an admin user", k400BadRequest)); return; } std::string username = r[0]["username"].as(); // Try to get fingerprint from chat-service if user is connected auto httpClient = HttpClient::newHttpClient("http://chat-service:8081"); auto chatReq = HttpRequest::newHttpRequest(); chatReq->setPath("/api/internal/user/" + std::to_string(targetUserId) + "/uberban"); chatReq->setMethod(drogon::Post); httpClient->sendRequest(chatReq, [callback, dbClient, targetUserId, username](ReqResult result, const HttpResponsePtr& resp) { // Option C: All uberbans are deferred - fingerprint captured on reconnect // Check if user was connected and disconnected by chat-service bool userWasDisconnected = false; if (result == ReqResult::Ok && resp && resp->getStatusCode() == k200OK) { auto json = resp->getJsonObject(); if (json && (*json)["disconnected"].asBool()) { userWasDisconnected = true; } } // Set pending uberban in database and Redis // If user was disconnected, chat-service already set Redis key (no TTL) // We set it here with TTL as backup / consistency *dbClient << "UPDATE users SET pending_uberban = true, pending_uberban_at = NOW() WHERE id = $1" << targetUserId >> [callback, username, targetUserId, userWasDisconnected](const Result&) { // Set Redis key for fast lookup by chat-service // TTL: 30 days (in case user never comes back) services::RedisHelper::instance().setexAsync( "pending_uberban:" + std::to_string(targetUserId), "1", 30 * 24 * 60 * 60, // 30 days [](bool) {} // Fire and forget ); Json::Value resp; resp["success"] = true; if (userWasDisconnected) { resp["message"] = "User '" + username + "' has been kicked. Fingerprint will be captured on reconnect."; } else { resp["message"] = "User '" + username + "' marked for uberban (will apply on next activity)"; } resp["immediate"] = false; resp["disconnected"] = userWasDisconnected; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "set pending uberban", "Failed to set pending uberban"); }); } >> DB_ERROR_MSG(callback, "get user", "Failed to get user"); } void AdminController::incrementReferrals(const HttpRequestPtr &req, std::function &&callback, const std::string &userId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t targetId; try { targetId = std::stoll(userId); } catch (...) { callback(jsonError("Invalid user ID", k400BadRequest)); return; } // Generate a random 12-character code static const char charset[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; std::string code; code.reserve(12); std::random_device rd; std::mt19937 gen(rd()); std::uniform_int_distribution<> dist(0, sizeof(charset) - 2); for (int i = 0; i < 12; ++i) { code += charset[dist(gen)]; } auto dbClient = app().getDbClient(); // Insert a new referral code for the user (this increments their count) *dbClient << "INSERT INTO referral_codes (owner_id, code) VALUES ($1, $2)" << targetId << code >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; resp["message"] = "Referral code added"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "increment referrals", "Failed to add referral code"); } void AdminController::getVideos(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, " "v.duration_seconds, v.file_size_bytes, v.view_count, v.is_public, v.status, v.created_at, " "u.id as user_id, u.username " "FROM videos v " "LEFT JOIN users u ON v.user_id = u.id " "WHERE v.status != 'deleted' " "ORDER BY v.created_at DESC" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value videos(Json::arrayValue); for (const auto& row : r) { Json::Value video; video["id"] = static_cast(row["id"].as()); video["title"] = row["title"].as(); video["description"] = row["description"].isNull() ? "" : row["description"].as(); video["filePath"] = row["file_path"].as(); video["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as(); video["durationSeconds"] = row["duration_seconds"].as(); video["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); video["viewCount"] = row["view_count"].as(); video["isPublic"] = row["is_public"].as(); video["status"] = row["status"].as(); video["createdAt"] = row["created_at"].as(); video["userId"] = row["user_id"].isNull() ? Json::nullValue : Json::Value(static_cast(row["user_id"].as())); video["username"] = row["username"].isNull() ? "[deleted]" : row["username"].as(); videos.append(video); } resp["videos"] = videos; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get videos", "Failed to get videos"); } void AdminController::deleteVideo(const HttpRequestPtr &req, std::function &&callback, const std::string &videoId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t id = std::stoll(videoId); auto dbClient = app().getDbClient(); // Get file paths first *dbClient << "SELECT file_path, thumbnail_path FROM videos WHERE id = $1" << id >> [callback, dbClient, id](const Result& r) { if (r.empty()) { callback(jsonError("Video not found", k404NotFound)); return; } std::string filePath = r[0]["file_path"].as(); std::string thumbnailPath = r[0]["thumbnail_path"].isNull() ? "" : r[0]["thumbnail_path"].as(); // Soft delete by setting status to 'deleted' *dbClient << "UPDATE videos SET status = 'deleted' WHERE id = $1" << id >> [callback, filePath, thumbnailPath](const Result&) { // Delete files from disk try { std::string fullVideoPath = "/app" + filePath; if (std::filesystem::exists(fullVideoPath)) { std::filesystem::remove(fullVideoPath); } if (!thumbnailPath.empty()) { std::string fullThumbPath = "/app" + thumbnailPath; if (std::filesystem::exists(fullThumbPath)) { std::filesystem::remove(fullThumbPath); } } } catch (const std::exception& e) { LOG_WARN << "Failed to delete video files: " << e.what(); } Json::Value resp; resp["success"] = true; resp["message"] = "Video deleted successfully"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "delete video", "Failed to delete video"); } >> DB_ERROR_MSG(callback, "get video", "Failed to get video"); } void AdminController::getAudios(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, " "a.duration_seconds, a.file_size_bytes, a.play_count, a.is_public, a.status, a.created_at, " "u.id as user_id, u.username, r.name as realm_name " "FROM audio_files a " "LEFT JOIN users u ON a.user_id = u.id " "LEFT JOIN realms r ON a.realm_id = r.id " "WHERE a.status != 'deleted' " "ORDER BY a.created_at DESC" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value audios(Json::arrayValue); for (const auto& row : r) { Json::Value audio; audio["id"] = static_cast(row["id"].as()); audio["title"] = row["title"].as(); audio["description"] = row["description"].isNull() ? "" : row["description"].as(); audio["filePath"] = row["file_path"].as(); audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as(); audio["durationSeconds"] = row["duration_seconds"].as(); audio["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); audio["playCount"] = row["play_count"].as(); audio["isPublic"] = row["is_public"].as(); audio["status"] = row["status"].as(); audio["createdAt"] = row["created_at"].as(); audio["userId"] = row["user_id"].isNull() ? Json::nullValue : Json::Value(static_cast(row["user_id"].as())); audio["username"] = row["username"].isNull() ? "[deleted]" : row["username"].as(); audio["realmName"] = row["realm_name"].isNull() ? "" : row["realm_name"].as(); audios.append(audio); } resp["audios"] = audios; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get audios", "Failed to get audios"); } void AdminController::deleteAudio(const HttpRequestPtr &req, std::function &&callback, const std::string &audioId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t id = std::stoll(audioId); auto dbClient = app().getDbClient(); // Get file paths first *dbClient << "SELECT file_path, thumbnail_path FROM audio_files WHERE id = $1" << id >> [callback, dbClient, id](const Result& r) { if (r.empty()) { callback(jsonError("Audio not found", k404NotFound)); return; } std::string filePath = r[0]["file_path"].as(); std::string thumbnailPath = r[0]["thumbnail_path"].isNull() ? "" : r[0]["thumbnail_path"].as(); // Soft delete by setting status to 'deleted' *dbClient << "UPDATE audio_files SET status = 'deleted' WHERE id = $1" << id >> [callback, filePath, thumbnailPath](const Result&) { // Delete files from disk try { std::string fullAudioPath = "/app" + filePath; if (std::filesystem::exists(fullAudioPath)) { std::filesystem::remove(fullAudioPath); } if (!thumbnailPath.empty()) { std::string fullThumbPath = "/app" + thumbnailPath; if (std::filesystem::exists(fullThumbPath)) { std::filesystem::remove(fullThumbPath); } } } catch (const std::exception& e) { LOG_WARN << "Failed to delete audio files: " << e.what(); } Json::Value resp; resp["success"] = true; resp["message"] = "Audio deleted successfully"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "delete audio", "Failed to delete audio"); } >> DB_ERROR_MSG(callback, "get audio", "Failed to get audio"); } void AdminController::getEbooks(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, " "e.file_size_bytes, e.chapter_count, e.read_count, e.is_public, e.status, e.created_at, " "u.id as user_id, u.username, r.name as realm_name " "FROM ebooks e " "LEFT JOIN users u ON e.user_id = u.id " "LEFT JOIN realms r ON e.realm_id = r.id " "WHERE e.status != 'deleted' " "ORDER BY e.created_at DESC" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; Json::Value ebooks(Json::arrayValue); for (const auto& row : r) { Json::Value ebook; ebook["id"] = static_cast(row["id"].as()); ebook["title"] = row["title"].as(); ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); ebook["filePath"] = row["file_path"].as(); ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); ebook["readCount"] = row["read_count"].as(); ebook["isPublic"] = row["is_public"].as(); ebook["status"] = row["status"].as(); ebook["createdAt"] = row["created_at"].as(); ebook["userId"] = row["user_id"].isNull() ? Json::nullValue : Json::Value(static_cast(row["user_id"].as())); ebook["username"] = row["username"].isNull() ? "[deleted]" : row["username"].as(); ebook["realmName"] = row["realm_name"].isNull() ? "" : row["realm_name"].as(); ebooks.append(ebook); } resp["ebooks"] = ebooks; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get ebooks", "Failed to get ebooks"); } void AdminController::deleteEbook(const HttpRequestPtr &req, std::function &&callback, const std::string &ebookId) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } int64_t id = std::stoll(ebookId); auto dbClient = app().getDbClient(); // Get file paths first *dbClient << "SELECT file_path, cover_path FROM ebooks WHERE id = $1" << id >> [callback, dbClient, id](const Result& r) { if (r.empty()) { callback(jsonError("Ebook not found", k404NotFound)); return; } std::string filePath = r[0]["file_path"].as(); std::string coverPath = r[0]["cover_path"].isNull() ? "" : r[0]["cover_path"].as(); // Soft delete by setting status to 'deleted' *dbClient << "UPDATE ebooks SET status = 'deleted' WHERE id = $1" << id >> [callback, filePath, coverPath](const Result&) { // Delete files from disk try { std::string fullEbookPath = "/app" + filePath; if (std::filesystem::exists(fullEbookPath)) { std::filesystem::remove(fullEbookPath); } if (!coverPath.empty()) { std::string fullCoverPath = "/app" + coverPath; if (std::filesystem::exists(fullCoverPath)) { std::filesystem::remove(fullCoverPath); } } } catch (const std::exception& e) { LOG_WARN << "Failed to delete ebook files: " << e.what(); } Json::Value resp; resp["success"] = true; resp["message"] = "Ebook deleted successfully"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "delete ebook", "Failed to delete ebook"); } >> DB_ERROR_MSG(callback, "get ebook", "Failed to get ebook"); } void AdminController::getSiteSettings(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (!user.isAdmin) { callback(jsonError("Admin access required", k403Forbidden)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT setting_key, setting_value FROM site_settings" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; for (const auto& row : r) { std::string key = row["setting_key"].as(); std::string value = row["setting_value"].isNull() ? "" : row["setting_value"].as(); resp["settings"][key] = value; } callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get site settings", "Failed to get site settings"); } void AdminController::updateSiteSettings(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (!user.isAdmin) { callback(jsonError("Admin access required", k403Forbidden)); return; } auto json = req->getJsonObject(); if (!json) { callback(jsonError("Invalid JSON")); return; } auto dbClient = app().getDbClient(); // Update site_title if provided (use UPSERT in case row doesn't exist) if (json->isMember("site_title")) { std::string title = (*json)["site_title"].asString(); if (title.length() > 100) { title = title.substr(0, 100); } // SECURITY FIX #27: Sanitize site title to prevent XSS attacks title = htmlEscape(title); *dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('site_title', $1) " "ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP" << title >> [](const Result&) { LOG_INFO << "Site title updated successfully"; } >> [](const DrogonDbException& e) { LOG_ERROR << "Failed to update site_title: " << e.base().what(); }; } // Update logo_display_mode if provided (use UPSERT in case row doesn't exist) if (json->isMember("logo_display_mode")) { std::string mode = (*json)["logo_display_mode"].asString(); if (mode != "text" && mode != "image" && mode != "both") { callback(jsonError("Invalid logo_display_mode. Must be 'text', 'image', or 'both'")); return; } // SECURITY FIX #29: Add proper error handling for async DB operations *dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('logo_display_mode', $1) " "ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP" << mode >> [](const Result&) { LOG_INFO << "Logo display mode updated successfully"; } >> [](const DrogonDbException& e) { LOG_ERROR << "Failed to update logo_display_mode: " << e.base().what(); }; } // Update censored_words if provided (comma-separated list) if (json->isMember("censored_words")) { // Rate limit: 10 updates per minute per admin if (!censoredWordsRateLimiter.isAllowed(user.id)) { callback(jsonError("Rate limit exceeded. Maximum 10 censored words updates per minute.", k429TooManyRequests)); return; } std::string words = (*json)["censored_words"].asString(); // Limit to 10KB to prevent abuse if (words.length() > 10240) { callback(jsonError("Censored words list exceeds maximum size of 10KB")); return; } *dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('censored_words', $1) " "ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP" << words >> [](const Result&) { // Reload censored words in the CensorService CensorService::getInstance().loadCensoredWords(); // Notify chat service to refresh its cache notifyChatServiceCensoredWordsUpdate(); } >> [](const DrogonDbException& e) { LOG_ERROR << "Failed to update censored_words: " << e.base().what(); }; } Json::Value resp; resp["success"] = true; resp["message"] = "Site settings updated"; callback(jsonResp(resp)); } void AdminController::uploadSiteLogo(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (!user.isAdmin) { callback(jsonError("Admin access required", k403Forbidden)); return; } MultiPartParser parser; parser.parse(req); if (parser.getFiles().empty()) { callback(jsonError("No file uploaded")); return; } const auto& file = parser.getFiles()[0]; // Validate file size (max 2MB for logo) const size_t maxSize = 2 * 1024 * 1024; if (file.fileLength() > maxSize) { callback(jsonError("File too large (max 2MB)")); return; } // Validate image magic bytes auto validation = validateImageMagicBytes(file.fileData(), file.fileLength(), true); if (!validation.valid) { callback(jsonError("Invalid image file. Only JPEG, PNG, GIF, WebP, and SVG are allowed.")); return; } // Create logo directory if it doesn't exist const std::string logoDir = "/app/uploads/logo"; try { std::filesystem::create_directories(logoDir); } catch (const std::exception& e) { LOG_ERROR << "Failed to create logo directory: " << e.what(); callback(jsonError("Failed to save logo")); return; } // Delete old logo if exists try { for (const auto& entry : std::filesystem::directory_iterator(logoDir)) { std::filesystem::remove(entry.path()); } } catch (...) { // Ignore errors when deleting old logo } // Save new logo with timestamp to bust cache std::string filename = "site_logo_" + std::to_string(std::time(nullptr)) + validation.extension; std::string fullPath = logoDir + "/" + filename; std::string webPath = "/uploads/logo/" + filename; try { std::ofstream ofs(fullPath, std::ios::binary); if (!ofs) { callback(jsonError("Failed to save logo file")); return; } ofs.write(file.fileData(), file.fileLength()); ofs.close(); } catch (const std::exception& e) { LOG_ERROR << "Failed to write logo file: " << e.what(); callback(jsonError("Failed to save logo")); return; } // Update database with logo path (use UPSERT in case row doesn't exist) auto dbClient = app().getDbClient(); *dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('logo_path', $1) " "ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP" << webPath >> [callback, webPath](const Result&) { Json::Value resp; resp["success"] = true; resp["logoPath"] = webPath; resp["message"] = "Logo uploaded successfully"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "update logo path", "Failed to save logo"); } void AdminController::getPublicSiteSettings(const HttpRequestPtr &, std::function &&callback) { // Public endpoint - no auth required // SECURITY FIX #30: Only expose safe public settings, filter sensitive ones (e.g., hide censored_words) auto dbClient = app().getDbClient(); *dbClient << "SELECT setting_key, setting_value FROM site_settings" >> [callback](const Result& r) { // Whitelist of publicly-safe settings static const std::unordered_set publicKeys = { "site_title", "logo_path", "logo_display_mode" }; Json::Value resp; resp["success"] = true; for (const auto& row : r) { std::string key = row["setting_key"].as(); // Only include whitelisted public keys if (publicKeys.find(key) != publicKeys.end()) { std::string value = row["setting_value"].isNull() ? "" : row["setting_value"].as(); resp["settings"][key] = value; } } callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to get site settings: " << e.base().what(); // Return defaults on error Json::Value resp; resp["success"] = true; resp["settings"]["site_title"] = "Stream"; resp["settings"]["logo_path"] = ""; resp["settings"]["logo_display_mode"] = "text"; callback(jsonResp(resp)); }; } void AdminController::getCensoredWords(const HttpRequestPtr &, std::function &&callback) { // Internal endpoint for chat service - no auth required (Docker internal network) auto dbClient = app().getDbClient(); *dbClient << "SELECT setting_value FROM site_settings WHERE setting_key = 'censored_words'" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; if (!r.empty() && !r[0]["setting_value"].isNull()) { resp["censored_words"] = r[0]["setting_value"].as(); } else { resp["censored_words"] = ""; } callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to get censored words: " << e.base().what(); callback(jsonError("Failed to retrieve censored words", k500InternalServerError)); }; } void AdminController::uploadDefaultAvatars(const HttpRequestPtr &req, std::function &&callback) { auto user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } MultiPartParser parser; if (parser.parse(req) != 0) { callback(jsonError("Invalid multipart form data")); return; } auto files = parser.getFiles(); if (files.empty()) { callback(jsonError("No files uploaded")); return; } // Create avatars directory if it doesn't exist std::string avatarsDir = "/app/uploads/avatars"; std::filesystem::create_directories(avatarsDir); Json::Value uploaded(Json::arrayValue); int successCount = 0; auto dbClient = app().getDbClient(); for (auto& file : files) { const char* fileData = file.fileData(); size_t fileLen = file.fileLength(); // Check file size limit (500KB max for default avatars) if (fileLen > 500 * 1024) { LOG_WARN << "Rejected oversized file: " << file.getFileName() << " (" << fileLen << " bytes)"; continue; } // Validate magic bytes (SVG disabled for security - prevents XSS) auto validation = validateImageMagicBytes(fileData, fileLen, false); if (!validation.valid) { LOG_WARN << "Rejected file with invalid magic bytes: " << file.getFileName(); continue; } // Generate unique filename std::string timestamp = std::to_string(std::time(nullptr)); std::string randomStr = std::to_string(rand() % 100000); std::string filename = "avatar_" + timestamp + "_" + randomStr + validation.extension; std::string fullPath = avatarsDir + "/" + filename; // Write file std::ofstream outFile(fullPath, std::ios::binary); if (!outFile) { LOG_ERROR << "Failed to create file: " << fullPath; continue; } outFile.write(fileData, fileLen); outFile.close(); std::string dbPath = "/uploads/avatars/" + filename; // Insert into database synchronously (blocking for simplicity in loop) try { auto result = dbClient->execSqlSync( "INSERT INTO default_avatars (file_path, is_active) VALUES ($1, true) RETURNING id", dbPath ); if (!result.empty()) { Json::Value avatarInfo; avatarInfo["id"] = result[0]["id"].as(); avatarInfo["filePath"] = dbPath; uploaded.append(avatarInfo); successCount++; } } catch (const DrogonDbException& e) { LOG_ERROR << "Failed to insert default avatar: " << e.base().what(); // Clean up file on db error std::filesystem::remove(fullPath); } } Json::Value resp; resp["success"] = successCount > 0; resp["uploaded"] = uploaded; resp["count"] = successCount; callback(jsonResp(resp)); } void AdminController::getDefaultAvatars(const HttpRequestPtr &req, std::function &&callback) { auto user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT id, file_path, is_active, created_at FROM default_avatars ORDER BY created_at DESC" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; resp["avatars"] = Json::arrayValue; for (const auto& row : r) { Json::Value avatar; avatar["id"] = row["id"].as(); avatar["filePath"] = row["file_path"].as(); avatar["isActive"] = row["is_active"].as(); avatar["createdAt"] = row["created_at"].as(); resp["avatars"].append(avatar); } callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "get default avatars", "Failed to load default avatars"); } void AdminController::deleteDefaultAvatar(const HttpRequestPtr &req, std::function &&callback, const std::string &avatarId) { auto user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k401Unauthorized)); return; } int64_t id; try { id = std::stoll(avatarId); } catch (...) { callback(jsonError("Invalid avatar ID")); return; } auto dbClient = app().getDbClient(); // First get the file path *dbClient << "SELECT file_path FROM default_avatars WHERE id = $1" << id >> [callback, id, dbClient](const Result& r) { if (r.empty()) { callback(jsonError("Avatar not found", k404NotFound)); return; } std::string filePath = r[0]["file_path"].as(); // Delete from database *dbClient << "DELETE FROM default_avatars WHERE id = $1" << id >> [callback, filePath](const Result&) { // Delete file from filesystem std::string fullPath = "/app" + filePath; std::filesystem::remove(fullPath); Json::Value resp; resp["success"] = true; resp["message"] = "Avatar deleted"; callback(jsonResp(resp)); } >> DB_ERROR_MSG(callback, "delete default avatar", "Failed to delete avatar"); } >> DB_ERROR_MSG(callback, "find default avatar", "Failed to find avatar"); } void AdminController::getRandomDefaultAvatar(const HttpRequestPtr &, std::function &&callback) { // Public endpoint - returns a random active default avatar for guests auto dbClient = app().getDbClient(); *dbClient << "SELECT file_path FROM default_avatars WHERE is_active = true ORDER BY RANDOM() LIMIT 1" >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; if (!r.empty()) { resp["avatarUrl"] = r[0]["file_path"].as(); } else { resp["avatarUrl"] = Json::nullValue; } callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to get random default avatar: " << e.base().what(); Json::Value resp; resp["success"] = true; resp["avatarUrl"] = Json::nullValue; callback(jsonResp(resp)); }; } void AdminController::trackStickerUsage(const HttpRequestPtr &req, std::function &&callback) { // Internal endpoint for chat-service - no auth required (Docker internal network) auto jsonPtr = req->getJsonObject(); if (!jsonPtr || !(*jsonPtr).isMember("stickers")) { callback(jsonError("Invalid request: missing stickers array")); return; } const auto& stickers = (*jsonPtr)["stickers"]; if (!stickers.isArray() || stickers.empty()) { Json::Value resp; resp["success"] = true; resp["updated"] = 0; callback(jsonResp(resp)); return; } auto dbClient = app().getDbClient(); int updateCount = 0; static constexpr int MAX_STICKERS_PER_REQUEST = 50; // Increment usage_count for each sticker name (limited to prevent abuse) for (const auto& stickerName : stickers) { if (updateCount >= MAX_STICKERS_PER_REQUEST) break; if (!stickerName.isString()) continue; std::string name = stickerName.asString(); updateCount++; *dbClient << "UPDATE stickers SET usage_count = COALESCE(usage_count, 0) + 1 " "WHERE LOWER(name) = LOWER($1) AND is_active = true" << name >> [name](const Result&) { LOG_DEBUG << "Incremented usage count for sticker: " << name; } >> [name](const DrogonDbException& e) { LOG_WARN << "Failed to increment sticker usage for " << name << ": " << e.base().what(); }; } Json::Value resp; resp["success"] = true; resp["updated"] = updateCount; callback(jsonResp(resp)); } void AdminController::getStickerStats(const HttpRequestPtr &req, std::function &&callback) { // Public endpoint - returns top stickers by usage count std::string limitParam = req->getParameter("limit"); int limit = 50; if (!limitParam.empty()) { try { limit = std::min(100, std::max(1, std::stoi(limitParam))); } catch (...) { limit = 50; } } auto dbClient = app().getDbClient(); // Build query with limit embedded (already validated to be 1-100) std::string query = "SELECT id, name, file_path, COALESCE(usage_count, 0) as usage_count " "FROM stickers WHERE is_active = true AND COALESCE(usage_count, 0) > 0 " "ORDER BY usage_count DESC LIMIT " + std::to_string(limit); *dbClient << query >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; resp["stickers"] = Json::arrayValue; int64_t totalUsage = 0; for (const auto& row : r) { Json::Value sticker; sticker["id"] = static_cast(row["id"].as()); sticker["name"] = row["name"].as(); sticker["filePath"] = row["file_path"].as(); int64_t count = row["usage_count"].as(); sticker["usageCount"] = static_cast(count); totalUsage += count; resp["stickers"].append(sticker); } resp["totalUsage"] = static_cast(totalUsage); callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to get sticker stats: " << e.base().what(); callback(jsonError("Failed to retrieve sticker statistics", k500InternalServerError)); }; } void AdminController::downloadAllStickers(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); // Get all active stickers with their names and file paths *dbClient << "SELECT id, name, file_path FROM stickers WHERE is_active = true ORDER BY name ASC" >> [callback](const Result& r) { if (r.empty()) { callback(jsonError("No stickers to download", k404NotFound)); return; } // Generate unique temp filename for ZIP std::string tempZipPath = "/tmp/stickers_" + drogon::utils::getUuid() + ".zip"; int zipError = 0; zip_t* archive = zip_open(tempZipPath.c_str(), ZIP_CREATE | ZIP_TRUNCATE, &zipError); if (!archive) { LOG_ERROR << "Failed to create ZIP archive: error code " << zipError; callback(jsonError("Failed to create archive")); return; } int addedCount = 0; std::unordered_set usedNames; // Track names to handle duplicates for (const auto& row : r) { std::string stickerName = row["name"].as(); std::string filePath = row["file_path"].as(); // Convert web path to filesystem path // file_path is like "/uploads/stickers/uuid.ext" std::string fullPath = "." + filePath; // becomes "./uploads/stickers/uuid.ext" // Check if file exists if (!std::filesystem::exists(fullPath)) { LOG_WARN << "Sticker file not found: " << fullPath; continue; } // Get file extension from original file std::string ext = ""; size_t dotPos = filePath.rfind('.'); if (dotPos != std::string::npos) { ext = filePath.substr(dotPos); // includes the dot } // Create sanitized filename: name + extension // Sanitize name to be filesystem-safe std::string safeName = stickerName; for (char& c : safeName) { if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') { c = '_'; } } std::string zipFilename = safeName + ext; // Handle duplicate names by appending a number if (usedNames.count(zipFilename)) { int counter = 2; while (usedNames.count(safeName + "_" + std::to_string(counter) + ext)) { counter++; } zipFilename = safeName + "_" + std::to_string(counter) + ext; } usedNames.insert(zipFilename); // Add file to archive zip_source_t* source = zip_source_file(archive, fullPath.c_str(), 0, -1); if (!source) { LOG_WARN << "Failed to create source for: " << fullPath; continue; } if (zip_file_add(archive, zipFilename.c_str(), source, ZIP_FL_OVERWRITE) < 0) { LOG_WARN << "Failed to add file to ZIP: " << zipFilename; zip_source_free(source); continue; } addedCount++; } // Close and finalize the archive if (zip_close(archive) < 0) { LOG_ERROR << "Failed to close ZIP archive: " << zip_strerror(archive); std::filesystem::remove(tempZipPath); callback(jsonError("Failed to finalize archive")); return; } if (addedCount == 0) { std::filesystem::remove(tempZipPath); callback(jsonError("No sticker files could be added to archive")); return; } // Generate timestamp for filename auto now = std::time(nullptr); char timestamp[20]; std::strftime(timestamp, sizeof(timestamp), "%Y%m%d_%H%M%S", std::localtime(&now)); // Read the file into memory std::ifstream file(tempZipPath, std::ios::binary); if (!file) { std::filesystem::remove(tempZipPath); callback(jsonError("Failed to read archive")); return; } std::vector buffer((std::istreambuf_iterator(file)), std::istreambuf_iterator()); file.close(); // Delete temp file now that we have it in memory std::filesystem::remove(tempZipPath); // Create response with binary data auto resp = HttpResponse::newHttpResponse(); resp->setStatusCode(k200OK); resp->setContentTypeCode(CT_CUSTOM); resp->addHeader("Content-Type", "application/zip"); resp->addHeader("Content-Disposition", "attachment; filename=\"stickers_" + std::string(timestamp) + ".zip\""); resp->setBody(std::string(buffer.begin(), buffer.end())); LOG_INFO << "Stickers ZIP download: " << addedCount << " stickers"; callback(resp); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to get stickers for download: " << e.base().what(); callback(jsonError("Failed to get stickers", k500InternalServerError)); }; } // SSL Certificate Management void AdminController::getSSLSettings(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); *dbClient << "SELECT domain, acme_email, certificate_status, certificate_expiry, " "last_renewal_attempt, last_renewal_error, auto_renewal_enabled, updated_at " "FROM ssl_settings WHERE id = 1" >> [callback, dbClient](const Result& r) { Json::Value resp; resp["success"] = true; std::string dbStatus = "none"; std::string domain = ""; if (r.empty()) { // Return defaults if no settings exist resp["domain"] = ""; resp["acmeEmail"] = ""; resp["certificateStatus"] = "none"; resp["certificateExpiry"] = Json::nullValue; resp["lastRenewalAttempt"] = Json::nullValue; resp["lastRenewalError"] = Json::nullValue; resp["autoRenewalEnabled"] = true; } else { const auto& row = r[0]; domain = row["domain"].isNull() ? "" : row["domain"].as(); dbStatus = row["certificate_status"].as(); resp["domain"] = domain; resp["acmeEmail"] = row["acme_email"].isNull() ? "" : row["acme_email"].as(); resp["certificateStatus"] = dbStatus; if (!row["certificate_expiry"].isNull()) { resp["certificateExpiry"] = row["certificate_expiry"].as(); } else { resp["certificateExpiry"] = Json::nullValue; } if (!row["last_renewal_attempt"].isNull()) { resp["lastRenewalAttempt"] = row["last_renewal_attempt"].as(); } else { resp["lastRenewalAttempt"] = Json::nullValue; } if (!row["last_renewal_error"].isNull()) { resp["lastRenewalError"] = row["last_renewal_error"].as(); } else { resp["lastRenewalError"] = Json::nullValue; } resp["autoRenewalEnabled"] = row["auto_renewal_enabled"].as(); } // Check for existing certificates on disk if DB shows "none" // This handles the case where certbot ran during cloud-init if (dbStatus == "none") { try { // Check /etc/letsencrypt/live directory for any certificates std::string letsencryptDir = "/etc/letsencrypt/live"; if (std::filesystem::exists(letsencryptDir) && std::filesystem::is_directory(letsencryptDir)) { for (const auto& entry : std::filesystem::directory_iterator(letsencryptDir)) { if (entry.is_directory()) { std::string certPath = entry.path().string() + "/fullchain.pem"; if (std::filesystem::exists(certPath)) { std::string detectedDomain = entry.path().filename().string(); LOG_INFO << "Detected existing SSL certificate for: " << detectedDomain; // Update response to show active certificate resp["certificateStatus"] = "active"; if (resp["domain"].asString().empty()) { resp["domain"] = detectedDomain; } // Update database to reflect the existing certificate *dbClient << "INSERT INTO ssl_settings (id, domain, certificate_status, updated_at) " "VALUES (1, $1, 'active', CURRENT_TIMESTAMP) " "ON CONFLICT (id) DO UPDATE SET " "certificate_status = 'active', " "domain = CASE WHEN ssl_settings.domain = '' THEN $1 ELSE ssl_settings.domain END, " "updated_at = CURRENT_TIMESTAMP" << detectedDomain >> [](const Result&) { LOG_INFO << "Updated ssl_settings with detected certificate"; } >> [](const DrogonDbException& e) { LOG_ERROR << "Failed to update ssl_settings: " << e.base().what(); }; break; } } } } } catch (const std::exception& e) { LOG_DEBUG << "Error checking for certificates: " << e.what(); } } callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to get SSL settings: " << e.base().what(); callback(jsonError("Failed to get SSL settings", k500InternalServerError)); }; } void AdminController::updateSSLSettings(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto jsonPtr = req->getJsonObject(); if (!jsonPtr) { callback(jsonError("Invalid JSON")); return; } std::string domain = (*jsonPtr).get("domain", "").asString(); std::string acmeEmail = (*jsonPtr).get("acmeEmail", "").asString(); bool autoRenewalEnabled = (*jsonPtr).get("autoRenewalEnabled", true).asBool(); // Basic domain validation if (!domain.empty()) { // Check for valid domain format (simple check) if (domain.find(' ') != std::string::npos || domain.find('/') != std::string::npos) { callback(jsonError("Invalid domain format")); return; } } // Basic email validation if (!acmeEmail.empty()) { if (acmeEmail.find('@') == std::string::npos) { callback(jsonError("Invalid email format")); return; } } auto dbClient = app().getDbClient(); // Upsert SSL settings *dbClient << "INSERT INTO ssl_settings (id, domain, acme_email, auto_renewal_enabled, updated_at) " "VALUES (1, $1, $2, $3, CURRENT_TIMESTAMP) " "ON CONFLICT (id) DO UPDATE SET " "domain = EXCLUDED.domain, " "acme_email = EXCLUDED.acme_email, " "auto_renewal_enabled = EXCLUDED.auto_renewal_enabled, " "updated_at = CURRENT_TIMESTAMP" << domain << acmeEmail << autoRenewalEnabled >> [callback, domain, acmeEmail, autoRenewalEnabled](const Result&) { Json::Value resp; resp["success"] = true; resp["domain"] = domain; resp["acmeEmail"] = acmeEmail; resp["autoRenewalEnabled"] = autoRenewalEnabled; callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to update SSL settings: " << e.base().what(); callback(jsonError("Failed to update SSL settings", k500InternalServerError)); }; } void AdminController::requestCertificate(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } auto dbClient = app().getDbClient(); // First get the current settings *dbClient << "SELECT domain, acme_email, certificate_status FROM ssl_settings WHERE id = 1" >> [callback, dbClient](const Result& r) { if (r.empty()) { callback(jsonError("SSL settings not configured. Please set domain and email first.")); return; } const auto& row = r[0]; std::string domain = row["domain"].isNull() ? "" : row["domain"].as(); std::string email = row["acme_email"].isNull() ? "" : row["acme_email"].as(); std::string status = row["certificate_status"].as(); if (domain.empty()) { callback(jsonError("Domain not configured")); return; } if (email.empty()) { callback(jsonError("ACME email not configured")); return; } if (status == "pending") { callback(jsonError("Certificate request already in progress")); return; } // Write trigger files for certbot try { std::filesystem::create_directories("/etc/letsencrypt"); std::ofstream domainFile("/etc/letsencrypt/domain"); domainFile << domain; domainFile.close(); std::ofstream emailFile("/etc/letsencrypt/email"); emailFile << email; emailFile.close(); // Create trigger file std::ofstream triggerFile("/etc/letsencrypt/request_cert"); triggerFile << "1"; triggerFile.close(); LOG_INFO << "Certificate request triggered for domain: " << domain; } catch (const std::exception& e) { LOG_ERROR << "Failed to write certbot trigger files: " << e.what(); callback(jsonError("Failed to initiate certificate request")); return; } // Update status to pending *dbClient << "UPDATE ssl_settings SET certificate_status = 'pending', " "last_renewal_attempt = CURRENT_TIMESTAMP, " "last_renewal_error = NULL, " "updated_at = CURRENT_TIMESTAMP WHERE id = 1" >> [callback, domain](const Result&) { Json::Value resp; resp["success"] = true; resp["message"] = "Certificate request initiated for " + domain; resp["status"] = "pending"; callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to update SSL status: " << e.base().what(); callback(jsonError("Certificate request initiated but status update failed")); }; } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to get SSL settings: " << e.base().what(); callback(jsonError("Failed to get SSL settings", k500InternalServerError)); }; } void AdminController::getSSLStatus(const HttpRequestPtr &req, std::function &&callback) { UserInfo user = getUserFromRequest(req); if (user.id == 0 || !user.isAdmin) { callback(jsonError("Unauthorized", k403Forbidden)); return; } // Check status file from certbot container std::string certStatus = "unknown"; try { std::ifstream statusFile("/etc/letsencrypt/status"); if (statusFile) { std::getline(statusFile, certStatus); statusFile.close(); } } catch (...) { // Ignore file read errors } auto dbClient = app().getDbClient(); // Update database status if certbot has completed if (certStatus == "active" || certStatus == "error") { std::string newStatus = certStatus; std::string errorMsg = ""; if (certStatus == "error") { try { std::ifstream errorFile("/etc/letsencrypt/error"); if (errorFile) { std::getline(errorFile, errorMsg); errorFile.close(); } } catch (...) {} } // Check certificate expiry if active std::string expiry = ""; if (certStatus == "active") { // Try to read certificate expiry from OpenSSL // For now, we'll set a placeholder - actual expiry can be read from cert // In production, use: openssl x509 -enddate -noout -in /etc/letsencrypt/live/domain/cert.pem } *dbClient << "UPDATE ssl_settings SET certificate_status = $1, " "last_renewal_error = CASE WHEN $2 = '' THEN NULL ELSE $2 END, " "updated_at = CURRENT_TIMESTAMP WHERE id = 1" << newStatus << errorMsg >> [](const Result&) {} >> [](const DrogonDbException& e) { LOG_ERROR << "Failed to update SSL status in DB: " << e.base().what(); }; } // Return current status from database *dbClient << "SELECT domain, certificate_status, certificate_expiry, " "last_renewal_attempt, last_renewal_error FROM ssl_settings WHERE id = 1" >> [callback, certStatus](const Result& r) { Json::Value resp; resp["success"] = true; resp["certbotStatus"] = certStatus; if (r.empty()) { resp["configured"] = false; } else { resp["configured"] = true; const auto& row = r[0]; resp["domain"] = row["domain"].isNull() ? "" : row["domain"].as(); resp["status"] = row["certificate_status"].as(); if (!row["certificate_expiry"].isNull()) { resp["expiry"] = row["certificate_expiry"].as(); } if (!row["last_renewal_attempt"].isNull()) { resp["lastAttempt"] = row["last_renewal_attempt"].as(); } if (!row["last_renewal_error"].isNull()) { resp["lastError"] = row["last_renewal_error"].as(); } } callback(jsonResp(resp)); } >> [callback](const DrogonDbException& e) { LOG_ERROR << "Failed to get SSL status: " << e.base().what(); callback(jsonError("Failed to get SSL status", k500InternalServerError)); }; }