beeta/backend/src/controllers/AdminController.cpp
2026-01-07 03:38:34 -05:00

3329 lines
No EOL
152 KiB
C++

#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 <filesystem>
#include <algorithm>
#include <fstream>
#include <ctime>
#include <chrono>
#include <unordered_map>
#include <unordered_set>
#include <mutex>
#include <random>
#include <zip.h>
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<int, std::vector<std::chrono::steady_clock::time_point>> requestTimes;
static constexpr int MAX_REQUESTS = 10;
static constexpr int WINDOW_SECONDS = 60;
bool isAllowed(int userId) {
std::lock_guard<std::mutex> 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<int, std::vector<std::chrono::steady_clock::time_point>> requestTimes;
static constexpr int MAX_REQUESTS = 30;
static constexpr int WINDOW_SECONDS = 60;
bool isAllowed(int userId) {
std::lock_guard<std::mutex> 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 += "&amp;"; break;
case '<': output += "&lt;"; break;
case '>': output += "&gt;"; break;
case '"': output += "&quot;"; break;
case '\'': output += "&#39;"; break;
default: output += c; break;
}
}
return output;
}
}
// Update getUsers in AdminController.cpp:
void AdminController::getUsers(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
user["username"] = row["username"].as<std::string>();
user["isAdmin"] = row["is_admin"].as<bool>();
user["isModerator"] = row["is_moderator"].isNull() ? false : row["is_moderator"].as<bool>();
user["isStreamer"] = row["is_streamer"].as<bool>();
user["isRestreamer"] = row["is_restreamer"].isNull() ? false : row["is_restreamer"].as<bool>();
user["isBot"] = row["is_bot"].isNull() ? false : row["is_bot"].as<bool>();
user["isStickerCreator"] = row["is_sticker_creator"].isNull() ? false : row["is_sticker_creator"].as<bool>();
user["isUploader"] = row["is_uploader"].isNull() ? false : row["is_uploader"].as<bool>();
user["isTexter"] = row["is_texter"].isNull() ? false : row["is_texter"].as<bool>();
user["isWatchCreator"] = row["is_watch_creator"].isNull() ? false : row["is_watch_creator"].as<bool>();
user["isDisabled"] = row["is_disabled"].isNull() ? false : row["is_disabled"].as<bool>();
user["pendingUberban"] = row["pending_uberban"].isNull() ? false : row["pending_uberban"].as<bool>();
user["createdAt"] = row["created_at"].as<std::string>();
user["colorCode"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
user["realmCount"] = static_cast<Json::Int64>(row["realm_count"].as<int64_t>());
user["referralCodeCount"] = static_cast<Json::Int64>(row["referral_code_count"].as<int64_t>());
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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
stream["name"] = row["name"].as<std::string>();
stream["streamKey"] = row["stream_key"].as<std::string>();
stream["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
stream["username"] = row["username"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
key["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
key["username"] = row["username"].as<std::string>();
key["name"] = row["name"].as<std::string>();
key["scopes"] = row["scopes"].isNull() ? "chat:write" : row["scopes"].as<std::string>();
key["isActive"] = row["is_active"].as<bool>();
key["lastUsedAt"] = row["last_used_at"].isNull() ? "" : row["last_used_at"].as<std::string>();
key["expiresAt"] = row["expires_at"].isNull() ? "" : row["expires_at"].as<std::string>();
key["createdAt"] = row["created_at"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<std::string>("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<Json::Int64>(r[0]["id"].as<int64_t>());
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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
sticker["name"] = row["name"].as<std::string>();
sticker["filePath"] = row["file_path"].as<std::string>();
sticker["createdAt"] = row["created_at"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<std::string>();
// 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<void(const HttpResponsePtr &)> &&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<Json::Int64>(r[0]["id"].as<int64_t>());
resp["sticker"]["name"] = r[0]["name"].as<std::string>();
resp["sticker"]["filePath"] = r[0]["file_path"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
sub["name"] = row["name"].as<std::string>();
sub["filePath"] = row["file_path"].as<std::string>();
sub["status"] = row["status"].as<std::string>();
sub["createdAt"] = row["created_at"].as<std::string>();
sub["submitterId"] = static_cast<Json::Int64>(row["submitter_id"].as<int64_t>());
sub["submitterUsername"] = row["submitter_username"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<std::string>() : newName;
std::string filePath = r[0]["file_path"].as<std::string>();
// Insert into stickers table
*dbClient << "INSERT INTO stickers (name, file_path) VALUES ($1, $2) RETURNING id"
<< stickerName << filePath
>> [callback, dbClient, id, reviewerId, stickerName, filePath](const Result& insertResult) {
if (insertResult.empty()) {
callback(jsonError("Failed to create sticker"));
return;
}
int64_t newStickerId = insertResult[0]["id"].as<int64_t>();
// 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, filePath](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<Json::Int64>(newStickerId);
resp["sticker"]["name"] = stickerName;
resp["sticker"]["filePath"] = filePath;
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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<std::string>("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<Json::Int64>(row["id"].as<int64_t>());
resp["honk"]["name"] = row["name"].as<std::string>();
resp["honk"]["filePath"] = row["file_path"].as<std::string>();
resp["honk"]["isActive"] = row["is_active"].as<bool>();
resp["honk"]["createdAt"] = row["created_at"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
honk["name"] = row["name"].as<std::string>();
honk["filePath"] = row["file_path"].as<std::string>();
honk["isActive"] = row["is_active"].as<bool>();
honk["createdAt"] = row["created_at"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
resp["honk"]["name"] = row["name"].as<std::string>();
resp["honk"]["filePath"] = row["file_path"].as<std::string>();
} 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<void(const HttpResponsePtr &)> &&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<std::string>();
settings["defaultRetentionHours"] = row["default_retention_hours"].as<int>();
settings["guestsAllowedSiteWide"] = row["guests_allowed_site_wide"].as<bool>();
settings["registrationEnabled"] = row["registration_enabled"].as<bool>();
settings["referralSystemEnabled"] = row["referral_system_enabled"].isNull() ? false : row["referral_system_enabled"].as<bool>();
callback(jsonResp(settings));
}
>> DB_ERROR_MSG(callback, "get chat settings", "Failed to get chat settings");
}
void AdminController::updateChatSettings(const HttpRequestPtr &req,
std::function<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
realm["name"] = row["name"].as<std::string>();
realm["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
realm["streamKey"] = row["stream_key"].isNull() ? "" : row["stream_key"].as<std::string>();
realm["isActive"] = row["is_active"].as<bool>();
realm["isLive"] = row["is_live"].as<bool>();
realm["viewerCount"] = static_cast<Json::Int64>(row["viewer_count"].as<int64_t>());
realm["viewerMultiplier"] = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
realm["realmType"] = row["realm_type"].isNull() ? "stream" : row["realm_type"].as<std::string>();
realm["createdAt"] = row["created_at"].as<std::string>();
realm["username"] = row["username"].as<std::string>();
realm["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
realm["originalCreatorUsername"] = row["original_creator_username"].isNull() ? "" : row["original_creator_username"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<std::string> filesToDelete;
for (const auto& row : videos) {
if (!row["file_path"].isNull()) {
filesToDelete.push_back("/app" + row["file_path"].as<std::string>());
}
if (!row["thumbnail_path"].isNull()) {
filesToDelete.push_back("/app" + row["thumbnail_path"].as<std::string>());
}
if (!row["preview_path"].isNull()) {
filesToDelete.push_back("/app" + row["preview_path"].as<std::string>());
}
}
// 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<std::string>());
}
if (!row["thumbnail_path"].isNull()) {
filesToDelete.push_back("/app" + row["thumbnail_path"].as<std::string>());
}
}
// 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<std::string>());
}
if (!row["cover_path"].isNull()) {
filesToDelete.push_back("/app" + row["cover_path"].as<std::string>());
}
}
// 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<void(const HttpResponsePtr &)> &&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<bool>()) {
callback(jsonError("Cannot delete admin users"));
return;
}
std::string deletedUsername = r[0]["username"].as<std::string>();
// 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<int64_t>();
// 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<void(const HttpResponsePtr &)> &&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<bool>();
std::string username = r[0]["username"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<std::string>();
// 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<void(const HttpResponsePtr &)> &&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<bool>();
if (isAdmin) {
callback(jsonError("Cannot uberban an admin user", k400BadRequest));
return;
}
std::string username = r[0]["username"].as<std::string>();
// 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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
video["title"] = row["title"].as<std::string>();
video["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
video["filePath"] = row["file_path"].as<std::string>();
video["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
video["durationSeconds"] = row["duration_seconds"].as<int>();
video["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
video["viewCount"] = row["view_count"].as<int>();
video["isPublic"] = row["is_public"].as<bool>();
video["status"] = row["status"].as<std::string>();
video["createdAt"] = row["created_at"].as<std::string>();
video["userId"] = row["user_id"].isNull() ? Json::nullValue : Json::Value(static_cast<Json::Int64>(row["user_id"].as<int64_t>()));
video["username"] = row["username"].isNull() ? "[deleted]" : row["username"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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>();
std::string thumbnailPath = r[0]["thumbnail_path"].isNull() ? "" : r[0]["thumbnail_path"].as<std::string>();
// 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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
audio["title"] = row["title"].as<std::string>();
audio["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
audio["filePath"] = row["file_path"].as<std::string>();
audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
audio["durationSeconds"] = row["duration_seconds"].as<int>();
audio["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
audio["playCount"] = row["play_count"].as<int>();
audio["isPublic"] = row["is_public"].as<bool>();
audio["status"] = row["status"].as<std::string>();
audio["createdAt"] = row["created_at"].as<std::string>();
audio["userId"] = row["user_id"].isNull() ? Json::nullValue : Json::Value(static_cast<Json::Int64>(row["user_id"].as<int64_t>()));
audio["username"] = row["username"].isNull() ? "[deleted]" : row["username"].as<std::string>();
audio["realmName"] = row["realm_name"].isNull() ? "" : row["realm_name"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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>();
std::string thumbnailPath = r[0]["thumbnail_path"].isNull() ? "" : r[0]["thumbnail_path"].as<std::string>();
// 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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
ebook["title"] = row["title"].as<std::string>();
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
ebook["filePath"] = row["file_path"].as<std::string>();
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
ebook["readCount"] = row["read_count"].as<int>();
ebook["isPublic"] = row["is_public"].as<bool>();
ebook["status"] = row["status"].as<std::string>();
ebook["createdAt"] = row["created_at"].as<std::string>();
ebook["userId"] = row["user_id"].isNull() ? Json::nullValue : Json::Value(static_cast<Json::Int64>(row["user_id"].as<int64_t>()));
ebook["username"] = row["username"].isNull() ? "[deleted]" : row["username"].as<std::string>();
ebook["realmName"] = row["realm_name"].isNull() ? "" : row["realm_name"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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>();
std::string coverPath = r[0]["cover_path"].isNull() ? "" : r[0]["cover_path"].as<std::string>();
// 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<void(const HttpResponsePtr &)> &&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>();
std::string value = row["setting_value"].isNull() ? "" : row["setting_value"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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 announcement_enabled if provided
if (json->isMember("announcement_enabled")) {
bool enabled = (*json)["announcement_enabled"].asBool();
std::string value = enabled ? "true" : "false";
*dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('announcement_enabled', $1) "
"ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP"
<< value
>> [](const Result&) {
LOG_INFO << "Announcement enabled setting updated successfully";
}
>> [](const DrogonDbException& e) {
LOG_ERROR << "Failed to update announcement_enabled: " << e.base().what();
};
}
// Update announcement_text if provided
if (json->isMember("announcement_text")) {
std::string text = (*json)["announcement_text"].asString();
// Limit to 500 characters
if (text.length() > 500) {
text = text.substr(0, 500);
}
// Sanitize to prevent XSS
text = htmlEscape(text);
*dbClient << "INSERT INTO site_settings (setting_key, setting_value) VALUES ('announcement_text', $1) "
"ON CONFLICT (setting_key) DO UPDATE SET setting_value = $1, updated_at = CURRENT_TIMESTAMP"
<< text
>> [](const Result&) {
LOG_INFO << "Announcement text updated successfully";
}
>> [](const DrogonDbException& e) {
LOG_ERROR << "Failed to update announcement_text: " << 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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<std::string> publicKeys = {
"site_title", "logo_path", "logo_display_mode",
"announcement_enabled", "announcement_text"
};
Json::Value resp;
resp["success"] = true;
for (const auto& row : r) {
std::string key = row["setting_key"].as<std::string>();
// Only include whitelisted public keys
if (publicKeys.find(key) != publicKeys.end()) {
std::string value = row["setting_value"].isNull() ? "" : row["setting_value"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<std::string>();
} 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<void(const HttpResponsePtr &)> &&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<int64_t>();
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<void(const HttpResponsePtr &)> &&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<int64_t>();
avatar["filePath"] = row["file_path"].as<std::string>();
avatar["isActive"] = row["is_active"].as<bool>();
avatar["createdAt"] = row["created_at"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<std::string>();
// 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<void(const HttpResponsePtr &)> &&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<std::string>();
} 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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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<Json::Int64>(row["id"].as<int64_t>());
sticker["name"] = row["name"].as<std::string>();
sticker["filePath"] = row["file_path"].as<std::string>();
int64_t count = row["usage_count"].as<int64_t>();
sticker["usageCount"] = static_cast<Json::Int64>(count);
totalUsage += count;
resp["stickers"].append(sticker);
}
resp["totalUsage"] = static_cast<Json::Int64>(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<void(const HttpResponsePtr &)> &&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<std::string> usedNames; // Track names to handle duplicates
for (const auto& row : r) {
std::string stickerName = row["name"].as<std::string>();
std::string filePath = row["file_path"].as<std::string>();
// 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<char> buffer((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
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<void(const HttpResponsePtr &)> &&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<std::string>();
dbStatus = row["certificate_status"].as<std::string>();
resp["domain"] = domain;
resp["acmeEmail"] = row["acme_email"].isNull() ? "" : row["acme_email"].as<std::string>();
resp["certificateStatus"] = dbStatus;
if (!row["certificate_expiry"].isNull()) {
resp["certificateExpiry"] = row["certificate_expiry"].as<std::string>();
} else {
resp["certificateExpiry"] = Json::nullValue;
}
if (!row["last_renewal_attempt"].isNull()) {
resp["lastRenewalAttempt"] = row["last_renewal_attempt"].as<std::string>();
} else {
resp["lastRenewalAttempt"] = Json::nullValue;
}
if (!row["last_renewal_error"].isNull()) {
resp["lastRenewalError"] = row["last_renewal_error"].as<std::string>();
} else {
resp["lastRenewalError"] = Json::nullValue;
}
resp["autoRenewalEnabled"] = row["auto_renewal_enabled"].as<bool>();
}
// 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<void(const HttpResponsePtr &)> &&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<void(const HttpResponsePtr &)> &&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>();
std::string email = row["acme_email"].isNull() ? "" : row["acme_email"].as<std::string>();
std::string status = row["certificate_status"].as<std::string>();
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<void(const HttpResponsePtr &)> &&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<std::string>();
resp["status"] = row["certificate_status"].as<std::string>();
if (!row["certificate_expiry"].isNull()) {
resp["expiry"] = row["certificate_expiry"].as<std::string>();
}
if (!row["last_renewal_attempt"].isNull()) {
resp["lastAttempt"] = row["last_renewal_attempt"].as<std::string>();
}
if (!row["last_renewal_error"].isNull()) {
resp["lastError"] = row["last_renewal_error"].as<std::string>();
}
}
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));
};
}