2025-08-03 21:53:15 -04:00
|
|
|
#include "RealmController.h"
|
|
|
|
|
#include "../services/DatabaseService.h"
|
|
|
|
|
#include "../services/StatsService.h"
|
|
|
|
|
#include "../services/RedisHelper.h"
|
|
|
|
|
#include "../services/OmeClient.h"
|
2026-01-05 22:54:27 -05:00
|
|
|
#include "../services/CensorService.h"
|
|
|
|
|
#include "../common/HttpHelpers.h"
|
|
|
|
|
#include "../common/AuthHelpers.h"
|
2025-08-03 21:53:15 -04:00
|
|
|
#include <drogon/utils/Utilities.h>
|
|
|
|
|
#include <drogon/Cookie.h>
|
2026-01-05 22:54:27 -05:00
|
|
|
#include <drogon/MultiPart.h>
|
2025-08-03 21:53:15 -04:00
|
|
|
#include <sstream>
|
|
|
|
|
#include <iomanip>
|
|
|
|
|
#include <regex>
|
2026-01-05 22:54:27 -05:00
|
|
|
#include <filesystem>
|
|
|
|
|
#include <cstdio>
|
|
|
|
|
#include <algorithm>
|
|
|
|
|
#include <openssl/rand.h> // SECURITY FIX #4: Use cryptographic RNG
|
2025-08-03 21:53:15 -04:00
|
|
|
|
|
|
|
|
using namespace drogon::orm;
|
|
|
|
|
|
|
|
|
|
namespace {
|
2026-01-05 22:54:27 -05:00
|
|
|
// SECURITY FIX #4: Use cryptographically secure random number generation
|
|
|
|
|
// Replaces mt19937 (Mersenne Twister) which is NOT cryptographically secure
|
2025-08-03 21:53:15 -04:00
|
|
|
std::string generateStreamKey() {
|
2026-01-05 22:54:27 -05:00
|
|
|
unsigned char bytes[32]; // 32 bytes = 64 hex characters
|
|
|
|
|
if (RAND_bytes(bytes, sizeof(bytes)) != 1) {
|
|
|
|
|
LOG_ERROR << "Failed to generate cryptographically secure random bytes";
|
|
|
|
|
throw std::runtime_error("Failed to generate secure stream key");
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
std::stringstream ss;
|
2026-01-05 22:54:27 -05:00
|
|
|
for (size_t i = 0; i < sizeof(bytes); ++i) {
|
|
|
|
|
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(bytes[i]);
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
return ss.str();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
bool validateRealmName(const std::string& name) {
|
|
|
|
|
if (name.length() < 3 || name.length() > 30) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
return std::regex_match(name, std::regex("^[a-z0-9-]+$"));
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
// SECURITY FIX #12: Safe file deletion with path traversal protection
|
|
|
|
|
bool safeDeleteFile(const std::string& urlPath, const std::string& allowedDir) {
|
|
|
|
|
try {
|
|
|
|
|
if (urlPath.empty()) return false;
|
|
|
|
|
|
|
|
|
|
// Construct full path
|
|
|
|
|
std::filesystem::path fullPath = std::filesystem::weakly_canonical("." + urlPath);
|
|
|
|
|
std::filesystem::path basePath = std::filesystem::canonical(allowedDir);
|
|
|
|
|
|
|
|
|
|
// Verify path is within allowed directory
|
|
|
|
|
auto [baseEnd, fullEnd] = std::mismatch(basePath.begin(), basePath.end(), fullPath.begin());
|
|
|
|
|
if (baseEnd != basePath.end()) {
|
|
|
|
|
LOG_WARN << "Path traversal attempt blocked: " << urlPath << " is not within " << allowedDir;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Path is safe, delete the file
|
|
|
|
|
if (std::filesystem::exists(fullPath)) {
|
|
|
|
|
std::filesystem::remove(fullPath);
|
|
|
|
|
LOG_DEBUG << "Safely deleted file: " << fullPath.string();
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
return false;
|
|
|
|
|
} catch (const std::exception& e) {
|
|
|
|
|
LOG_ERROR << "Error in safeDeleteFile: " << e.what();
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-03 21:53:15 -04:00
|
|
|
|
2026-01-05 22:54:27 -05:00
|
|
|
// Sync chat settings to Redis db 1 (chat-service database)
|
|
|
|
|
void syncChatSettingToRedis(const std::string& realmName, const std::string& field, const std::string& value) {
|
|
|
|
|
try {
|
|
|
|
|
sw::redis::ConnectionOptions opts;
|
|
|
|
|
const char* envHost = std::getenv("REDIS_HOST");
|
|
|
|
|
opts.host = envHost ? std::string(envHost) : "redis";
|
|
|
|
|
const char* envPort = std::getenv("REDIS_PORT");
|
|
|
|
|
opts.port = envPort ? std::stoi(envPort) : 6379;
|
|
|
|
|
opts.db = 1; // Chat-service uses db 1
|
|
|
|
|
opts.socket_timeout = std::chrono::milliseconds(1000);
|
|
|
|
|
|
|
|
|
|
auto redis = sw::redis::Redis(opts);
|
|
|
|
|
std::string key = "chat:settings:realm:" + realmName;
|
|
|
|
|
redis.hset(key, field, value);
|
|
|
|
|
LOG_DEBUG << "Synced chat setting " << field << "=" << value << " for realm " << realmName;
|
|
|
|
|
} catch (const sw::redis::Error& e) {
|
|
|
|
|
LOG_ERROR << "Failed to sync chat setting to Redis: " << e.what();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Helper to create a new viewer token
|
|
|
|
|
void createNewViewerToken(std::function<void(const HttpResponsePtr &)> callback, const std::string& streamKey) {
|
|
|
|
|
auto bytes = drogon::utils::genRandomString(32);
|
|
|
|
|
std::string token = drogon::utils::base64Encode(
|
|
|
|
|
(const unsigned char*)bytes.data(), bytes.length()
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
RedisHelper::storeKeyAsync("viewer_token:" + token, streamKey, 300,
|
|
|
|
|
[callback, token](bool stored) {
|
|
|
|
|
if (!stored) {
|
|
|
|
|
auto resp = HttpResponse::newHttpResponse();
|
|
|
|
|
resp->setStatusCode(k500InternalServerError);
|
|
|
|
|
callback(resp);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto resp = HttpResponse::newHttpResponse();
|
|
|
|
|
|
|
|
|
|
Cookie cookie("viewer_token", token);
|
|
|
|
|
cookie.setPath("/");
|
|
|
|
|
cookie.setHttpOnly(true);
|
|
|
|
|
cookie.setSecure(false);
|
|
|
|
|
cookie.setMaxAge(300);
|
|
|
|
|
resp->addCookie(cookie);
|
|
|
|
|
|
|
|
|
|
Json::Value body;
|
|
|
|
|
body["success"] = true;
|
|
|
|
|
body["viewer_token"] = token;
|
|
|
|
|
body["expires_in"] = 300;
|
|
|
|
|
|
|
|
|
|
resp->setContentTypeCode(CT_APPLICATION_JSON);
|
|
|
|
|
resp->setBody(Json::FastWriter().write(body));
|
|
|
|
|
callback(resp);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
void invalidateKeyInRedis(const std::string& oldKey) {
|
|
|
|
|
RedisHelper::addToSet("streams_to_disconnect", oldKey);
|
|
|
|
|
RedisHelper::deleteKey("stream_key:" + oldKey);
|
|
|
|
|
|
|
|
|
|
services::RedisHelper::instance().keysAsync("viewer_token:*",
|
|
|
|
|
[oldKey](const std::vector<std::string>& keys) {
|
|
|
|
|
for (const auto& tokenKey : keys) {
|
|
|
|
|
services::RedisHelper::instance().getAsync(tokenKey,
|
|
|
|
|
[tokenKey, oldKey](sw::redis::OptionalString streamKey) {
|
|
|
|
|
if (streamKey.has_value() && streamKey.value() == oldKey) {
|
|
|
|
|
RedisHelper::deleteKey(tokenKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::getUserRealms(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
auto dbClient = app().getDbClient();
|
2026-01-05 22:54:27 -05:00
|
|
|
*dbClient << "SELECT r.id, r.name, r.description, r.stream_key, r.realm_type, r.is_active, r.is_live, "
|
|
|
|
|
"r.viewer_count, r.chat_enabled, r.chat_guests_allowed, r.chat_slow_mode_seconds, "
|
|
|
|
|
"r.chat_retention_hours, r.offline_image_url, r.title_color, r.created_at, "
|
|
|
|
|
"(SELECT COUNT(*) FROM videos v WHERE v.realm_id = r.id AND v.status = 'ready') as video_count, "
|
|
|
|
|
"(SELECT COUNT(*) FROM audio_files a WHERE a.realm_id = r.id AND a.status = 'ready') as audio_count, "
|
|
|
|
|
"(SELECT COUNT(*) FROM ebooks e WHERE e.realm_id = r.id AND e.status = 'ready') as ebook_count "
|
|
|
|
|
"FROM realms r WHERE r.user_id = $1 ORDER BY r.created_at DESC"
|
2025-08-03 21:53:15 -04:00
|
|
|
<< user.id
|
|
|
|
|
>> [callback](const Result& r) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
Json::Value realms(Json::arrayValue);
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
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>();
|
2026-01-05 22:54:27 -05:00
|
|
|
realm["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
|
|
|
|
realm["type"] = row["realm_type"].isNull() ? "stream" : row["realm_type"].as<std::string>();
|
|
|
|
|
realm["streamKey"] = row["stream_key"].isNull() ? "" : row["stream_key"].as<std::string>();
|
2025-08-03 21:53:15 -04:00
|
|
|
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>());
|
2026-01-05 22:54:27 -05:00
|
|
|
realm["videoCount"] = static_cast<Json::Int64>(row["video_count"].as<int64_t>());
|
|
|
|
|
realm["audioCount"] = static_cast<Json::Int64>(row["audio_count"].as<int64_t>());
|
|
|
|
|
realm["ebookCount"] = static_cast<Json::Int64>(row["ebook_count"].as<int64_t>());
|
|
|
|
|
realm["chatEnabled"] = row["chat_enabled"].as<bool>();
|
|
|
|
|
realm["chatGuestsAllowed"] = row["chat_guests_allowed"].as<bool>();
|
|
|
|
|
realm["chatSlowModeSeconds"] = row["chat_slow_mode_seconds"].as<int>();
|
|
|
|
|
realm["chatRetentionHours"] = row["chat_retention_hours"].as<int>();
|
|
|
|
|
realm["offlineImageUrl"] = row["offline_image_url"].isNull() ? "" : row["offline_image_url"].as<std::string>();
|
|
|
|
|
realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>();
|
2025-08-03 21:53:15 -04:00
|
|
|
realm["createdAt"] = row["created_at"].as<std::string>();
|
|
|
|
|
realms.append(realm);
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
resp["realms"] = realms;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR_MSG(callback, "get realms", "Failed to get realms");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::createRealm(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
auto json = req->getJsonObject();
|
|
|
|
|
if (!json) {
|
|
|
|
|
callback(jsonError("Invalid JSON"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string name = (*json)["name"].asString();
|
|
|
|
|
std::string realmType = (*json).isMember("type") ? (*json)["type"].asString() : "stream";
|
|
|
|
|
|
|
|
|
|
// Check for censored words in realm name
|
|
|
|
|
if (CensorService::getInstance().containsCensoredWords(name)) {
|
|
|
|
|
callback(jsonError("Realm name contains prohibited content"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate realm type
|
|
|
|
|
if (realmType != "stream" && realmType != "video" && realmType != "audio" && realmType != "ebook" && realmType != "watch") {
|
|
|
|
|
callback(jsonError("Invalid realm type. Must be 'stream', 'video', 'audio', 'ebook', or 'watch'"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!validateRealmName(name)) {
|
|
|
|
|
callback(jsonError("Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check permission based on realm type
|
2025-08-03 21:53:15 -04:00
|
|
|
auto dbClient = app().getDbClient();
|
2026-01-05 22:54:27 -05:00
|
|
|
*dbClient << "SELECT is_streamer, is_uploader, is_watch_creator FROM users WHERE id = $1"
|
2025-08-03 21:53:15 -04:00
|
|
|
<< user.id
|
2026-01-05 22:54:27 -05:00
|
|
|
>> [req, callback, user, dbClient, name, realmType](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("User not found", k404NotFound));
|
2025-08-03 21:53:15 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
bool isStreamer = r[0]["is_streamer"].as<bool>();
|
|
|
|
|
bool isUploader = r[0]["is_uploader"].as<bool>();
|
|
|
|
|
bool isWatchCreator = r[0]["is_watch_creator"].isNull() ? false : r[0]["is_watch_creator"].as<bool>();
|
|
|
|
|
|
|
|
|
|
// Check permission based on realm type
|
|
|
|
|
if (realmType == "stream" && !isStreamer) {
|
|
|
|
|
callback(jsonError("You must be a streamer to create stream realms", k403Forbidden));
|
2025-08-03 21:53:15 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
if (realmType == "video" && !isUploader) {
|
|
|
|
|
callback(jsonError("You must be an uploader to create video realms", k403Forbidden));
|
2025-08-03 21:53:15 -04:00
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
if (realmType == "audio" && !isUploader) {
|
|
|
|
|
callback(jsonError("You must be an uploader to create audio realms", k403Forbidden));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (realmType == "ebook" && !isUploader) {
|
|
|
|
|
callback(jsonError("You must be an uploader to create ebook realms", k403Forbidden));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (realmType == "watch" && !isWatchCreator) {
|
|
|
|
|
callback(jsonError("You must be a watch creator to create watch rooms", k403Forbidden));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
// Check if realm name already exists
|
|
|
|
|
*dbClient << "SELECT id FROM realms WHERE name = $1"
|
|
|
|
|
<< name
|
2026-01-05 22:54:27 -05:00
|
|
|
>> [dbClient, user, name, realmType, callback](const Result& r2) {
|
2025-08-03 21:53:15 -04:00
|
|
|
if (!r2.empty()) {
|
|
|
|
|
callback(jsonError("Realm name already taken"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
// Check user's realm limit (e.g., 5 realms per user)
|
|
|
|
|
*dbClient << "SELECT COUNT(*) as count FROM realms WHERE user_id = $1"
|
|
|
|
|
<< user.id
|
2026-01-05 22:54:27 -05:00
|
|
|
>> [dbClient, user, name, realmType, callback](const Result& r3) {
|
2025-08-03 21:53:15 -04:00
|
|
|
if (!r3.empty() && r3[0]["count"].as<int64_t>() >= 5) {
|
|
|
|
|
callback(jsonError("You have reached the maximum number of realms (5)"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
if (realmType == "stream") {
|
|
|
|
|
// Stream realm - generate stream key
|
|
|
|
|
std::string streamKey = generateStreamKey();
|
|
|
|
|
|
|
|
|
|
*dbClient << "INSERT INTO realms (user_id, name, stream_key, realm_type) "
|
|
|
|
|
"VALUES ($1, $2, $3, 'stream') RETURNING id"
|
|
|
|
|
<< user.id << name << streamKey
|
|
|
|
|
>> [callback, name, streamKey](const Result& r4) {
|
|
|
|
|
if (r4.empty()) {
|
|
|
|
|
callback(jsonError("Failed to create realm"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store stream key in Redis
|
|
|
|
|
RedisHelper::storeKey("stream_key:" + streamKey, "1", 86400);
|
|
|
|
|
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["realm"]["id"] = static_cast<Json::Int64>(r4[0]["id"].as<int64_t>());
|
|
|
|
|
resp["realm"]["name"] = name;
|
|
|
|
|
resp["realm"]["streamKey"] = streamKey;
|
|
|
|
|
resp["realm"]["type"] = "stream";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "create realm", "Failed to create realm");
|
|
|
|
|
} else if (realmType == "watch") {
|
|
|
|
|
// Watch room - no stream key, initialize watch_room_state
|
|
|
|
|
*dbClient << "INSERT INTO realms (user_id, name, realm_type) "
|
|
|
|
|
"VALUES ($1, $2, 'watch') RETURNING id"
|
|
|
|
|
<< user.id << name
|
|
|
|
|
>> [callback, dbClient, name](const Result& r4) {
|
|
|
|
|
if (r4.empty()) {
|
|
|
|
|
callback(jsonError("Failed to create realm"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t realmId = r4[0]["id"].as<int64_t>();
|
|
|
|
|
|
|
|
|
|
// Initialize watch_room_state
|
|
|
|
|
*dbClient << "INSERT INTO watch_room_state (realm_id) VALUES ($1)"
|
|
|
|
|
<< realmId
|
|
|
|
|
>> [callback, name, realmId](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["realm"]["id"] = static_cast<Json::Int64>(realmId);
|
|
|
|
|
resp["realm"]["name"] = name;
|
|
|
|
|
resp["realm"]["type"] = "watch";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "init watch state", "Failed to create watch room");
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "create realm", "Failed to create realm");
|
|
|
|
|
} else {
|
|
|
|
|
// Video, Audio, or Ebook realm - no stream key needed
|
|
|
|
|
*dbClient << "INSERT INTO realms (user_id, name, realm_type) "
|
|
|
|
|
"VALUES ($1, $2, $3) RETURNING id"
|
|
|
|
|
<< user.id << name << realmType
|
|
|
|
|
>> [callback, name, realmType](const Result& r4) {
|
|
|
|
|
if (r4.empty()) {
|
|
|
|
|
callback(jsonError("Failed to create realm"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["realm"]["id"] = static_cast<Json::Int64>(r4[0]["id"].as<int64_t>());
|
|
|
|
|
resp["realm"]["name"] = name;
|
|
|
|
|
resp["realm"]["type"] = realmType;
|
|
|
|
|
callback(jsonResp(resp));
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR_MSG(callback, "create realm", "Failed to create realm");
|
|
|
|
|
}
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "count realms");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "check realm name");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "check user permissions");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
2026-01-05 22:54:27 -05:00
|
|
|
void RealmController::issueRealmViewerToken(const HttpRequestPtr &req,
|
2025-08-03 21:53:15 -04:00
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
int64_t id = std::stoll(realmId);
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
// Check for existing viewer token to avoid creating duplicates on page refresh
|
|
|
|
|
auto existingToken = req->getCookie("viewer_token");
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
|
|
|
|
|
<< id
|
2026-01-05 22:54:27 -05:00
|
|
|
>> [callback, existingToken](const Result& r) {
|
2025-08-03 21:53:15 -04:00
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonResp({}, k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
// If user has existing token, check if it's still valid for this stream
|
|
|
|
|
if (!existingToken.empty()) {
|
|
|
|
|
RedisHelper::getKeyAsync("viewer_token:" + existingToken,
|
|
|
|
|
[callback, existingToken, streamKey](const std::string& storedKey) {
|
|
|
|
|
if (storedKey == streamKey) {
|
|
|
|
|
// Token is still valid for this stream - just refresh TTL and return it
|
|
|
|
|
RedisHelper::storeKeyAsync("viewer_token:" + existingToken, streamKey, 300,
|
|
|
|
|
[callback, existingToken](bool stored) {
|
|
|
|
|
auto resp = HttpResponse::newHttpResponse();
|
|
|
|
|
|
|
|
|
|
// Refresh cookie
|
|
|
|
|
Cookie cookie("viewer_token", existingToken);
|
|
|
|
|
cookie.setPath("/");
|
|
|
|
|
cookie.setHttpOnly(true);
|
|
|
|
|
cookie.setSecure(false);
|
|
|
|
|
cookie.setMaxAge(300);
|
|
|
|
|
resp->addCookie(cookie);
|
|
|
|
|
|
|
|
|
|
Json::Value body;
|
|
|
|
|
body["success"] = true;
|
|
|
|
|
body["viewer_token"] = existingToken;
|
|
|
|
|
body["expires_in"] = 300;
|
|
|
|
|
body["reused"] = true; // Indicate token was reused
|
|
|
|
|
|
|
|
|
|
resp->setContentTypeCode(CT_APPLICATION_JSON);
|
|
|
|
|
resp->setBody(Json::FastWriter().write(body));
|
|
|
|
|
callback(resp);
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// Token invalid or for different stream - delete old and create new
|
|
|
|
|
if (!storedKey.empty()) {
|
|
|
|
|
RedisHelper::deleteKeyAsync("viewer_token:" + existingToken, [](bool){});
|
|
|
|
|
}
|
|
|
|
|
createNewViewerToken(callback, streamKey);
|
|
|
|
|
}
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// No existing token, create new one
|
|
|
|
|
createNewViewerToken(callback, streamKey);
|
|
|
|
|
}
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
2026-01-05 22:54:27 -05:00
|
|
|
LOG_ERROR << "Failed to issue viewer token: " << e.base().what();
|
2025-08-03 21:53:15 -04:00
|
|
|
callback(jsonResp({}, k500InternalServerError));
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::getRealmStreamKey(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
// Check for viewer token
|
|
|
|
|
auto token = req->getCookie("viewer_token");
|
|
|
|
|
if (token.empty()) {
|
|
|
|
|
callback(jsonError("No viewer token", k403Forbidden));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t id = std::stoll(realmId);
|
|
|
|
|
|
|
|
|
|
// First get the stream key for this realm
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
*dbClient << "SELECT stream_key FROM realms WHERE id = $1 AND is_active = true"
|
|
|
|
|
<< id
|
|
|
|
|
>> [token, callback](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
|
|
|
|
|
|
|
|
|
// Verify the token is valid for this stream
|
|
|
|
|
RedisHelper::getKeyAsync("viewer_token:" + token,
|
|
|
|
|
[callback, streamKey](const std::string& storedStreamKey) {
|
|
|
|
|
if (storedStreamKey != streamKey) {
|
|
|
|
|
callback(jsonError("Invalid token for this realm", k403Forbidden));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Token is valid, return the stream key
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["streamKey"] = streamKey;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "get realm stream key");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
2025-08-10 07:55:39 -04:00
|
|
|
void RealmController::getRealm(const HttpRequestPtr &, // Remove parameter name since it's unused
|
2025-08-03 21:53:15 -04:00
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
2025-08-09 13:51:36 -04:00
|
|
|
// Remove authentication requirement for public viewing
|
2025-08-03 21:53:15 -04:00
|
|
|
int64_t id = std::stoll(realmId);
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
*dbClient << "SELECT r.*, u.username FROM realms r "
|
|
|
|
|
"JOIN users u ON r.user_id = u.id "
|
2025-08-09 13:51:36 -04:00
|
|
|
"WHERE r.id = $1 AND r.is_active = true"
|
|
|
|
|
<< id
|
2025-08-03 21:53:15 -04:00
|
|
|
>> [callback](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
auto& realm = resp["realm"];
|
|
|
|
|
realm["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
|
|
|
|
realm["name"] = r[0]["name"].as<std::string>();
|
2026-01-05 22:54:27 -05:00
|
|
|
realm["type"] = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as<std::string>();
|
2025-08-09 13:51:36 -04:00
|
|
|
// Don't expose stream key in public endpoint
|
|
|
|
|
// realm["streamKey"] = r[0]["stream_key"].as<std::string>();
|
2025-08-03 21:53:15 -04:00
|
|
|
realm["isActive"] = r[0]["is_active"].as<bool>();
|
|
|
|
|
realm["isLive"] = r[0]["is_live"].as<bool>();
|
|
|
|
|
realm["viewerCount"] = static_cast<Json::Int64>(r[0]["viewer_count"].as<int64_t>());
|
|
|
|
|
realm["createdAt"] = r[0]["created_at"].as<std::string>();
|
|
|
|
|
realm["username"] = r[0]["username"].as<std::string>();
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "get realm");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::updateRealm(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 07:55:39 -04:00
|
|
|
// Parse realm ID
|
|
|
|
|
int64_t id;
|
|
|
|
|
try {
|
|
|
|
|
id = std::stoll(realmId);
|
|
|
|
|
} catch (...) {
|
|
|
|
|
callback(jsonError("Invalid realm ID", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 22:54:27 -05:00
|
|
|
// Parse JSON body
|
|
|
|
|
auto json = req->getJsonObject();
|
|
|
|
|
if (!json) {
|
|
|
|
|
callback(jsonError("Invalid JSON"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-10 07:55:39 -04:00
|
|
|
// Verify the realm exists and belongs to the user
|
|
|
|
|
auto dbClient = app().getDbClient();
|
2026-01-05 22:54:27 -05:00
|
|
|
*dbClient << "SELECT id, name FROM realms WHERE id = $1 AND user_id = $2"
|
2025-08-10 07:55:39 -04:00
|
|
|
<< id << user.id
|
2026-01-05 22:54:27 -05:00
|
|
|
>> [callback, json, dbClient, id, user](const Result& r) {
|
2025-08-10 07:55:39 -04:00
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found or access denied", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
std::string realmName = r[0]["name"].as<std::string>();
|
|
|
|
|
|
|
|
|
|
// Update chat_enabled if provided
|
|
|
|
|
if (json->isMember("chatEnabled")) {
|
|
|
|
|
bool chatEnabled = (*json)["chatEnabled"].asBool();
|
|
|
|
|
*dbClient << "UPDATE realms SET chat_enabled = $1 WHERE id = $2 AND user_id = $3"
|
|
|
|
|
<< chatEnabled << id << user.id
|
|
|
|
|
>> [callback](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = "Realm updated successfully";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "update realm", "Failed to update realm");
|
|
|
|
|
} else if (json->isMember("chatGuestsAllowed")) {
|
|
|
|
|
// Update chat_guests_allowed if provided
|
|
|
|
|
bool chatGuestsAllowed = (*json)["chatGuestsAllowed"].asBool();
|
|
|
|
|
*dbClient << "UPDATE realms SET chat_guests_allowed = $1 WHERE id = $2 AND user_id = $3"
|
|
|
|
|
<< chatGuestsAllowed << id << user.id
|
|
|
|
|
>> [callback, realmName, chatGuestsAllowed](const Result&) {
|
|
|
|
|
// Sync to Redis (chat-service database)
|
|
|
|
|
syncChatSettingToRedis(realmName, "chatGuestsAllowed", chatGuestsAllowed ? "1" : "0");
|
|
|
|
|
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = "Realm updated successfully";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "update realm", "Failed to update realm");
|
|
|
|
|
} else if (json->isMember("chatSlowModeSeconds")) {
|
|
|
|
|
// Update chat slow mode seconds
|
|
|
|
|
int slowModeSeconds = (*json)["chatSlowModeSeconds"].asInt();
|
|
|
|
|
// Clamp to valid range (0-300)
|
|
|
|
|
if (slowModeSeconds < 0) slowModeSeconds = 0;
|
|
|
|
|
if (slowModeSeconds > 300) slowModeSeconds = 300;
|
|
|
|
|
*dbClient << "UPDATE realms SET chat_slow_mode_seconds = $1 WHERE id = $2 AND user_id = $3"
|
|
|
|
|
<< slowModeSeconds << id << user.id
|
|
|
|
|
>> [callback](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = "Slow mode updated successfully";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "update slow mode", "Failed to update slow mode");
|
|
|
|
|
} else if (json->isMember("chatRetentionHours")) {
|
|
|
|
|
// Update chat retention hours
|
|
|
|
|
int retentionHours = (*json)["chatRetentionHours"].asInt();
|
|
|
|
|
// Clamp to valid range (1-168)
|
|
|
|
|
if (retentionHours < 1) retentionHours = 1;
|
|
|
|
|
if (retentionHours > 168) retentionHours = 168;
|
|
|
|
|
*dbClient << "UPDATE realms SET chat_retention_hours = $1 WHERE id = $2 AND user_id = $3"
|
|
|
|
|
<< retentionHours << id << user.id
|
|
|
|
|
>> [callback](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = "Message retention updated successfully";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "update retention", "Failed to update retention");
|
|
|
|
|
} else if (json->isMember("name")) {
|
|
|
|
|
// Update realm name
|
|
|
|
|
std::string newName = (*json)["name"].asString();
|
|
|
|
|
|
|
|
|
|
// Validate name format
|
|
|
|
|
if (!validateRealmName(newName)) {
|
|
|
|
|
callback(jsonError("Invalid realm name. Use 3-30 lowercase letters, numbers, and hyphens only."));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if name is already taken by another realm
|
|
|
|
|
*dbClient << "SELECT id FROM realms WHERE name = $1 AND id != $2"
|
|
|
|
|
<< newName << id
|
|
|
|
|
>> [callback, dbClient, id, user, newName](const Result& nameCheck) {
|
|
|
|
|
if (!nameCheck.empty()) {
|
|
|
|
|
callback(jsonError("Realm name is already taken"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*dbClient << "UPDATE realms SET name = $1 WHERE id = $2 AND user_id = $3"
|
|
|
|
|
<< newName << id << user.id
|
|
|
|
|
>> [callback, newName](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = "Realm renamed successfully";
|
|
|
|
|
resp["name"] = newName;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "rename realm", "Failed to rename realm");
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "check realm name");
|
|
|
|
|
} else if (json->isMember("description")) {
|
|
|
|
|
// Update realm description
|
|
|
|
|
std::string description = (*json)["description"].asString();
|
|
|
|
|
|
|
|
|
|
// Limit description length
|
|
|
|
|
if (description.length() > 500) {
|
|
|
|
|
description = description.substr(0, 500);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
*dbClient << "UPDATE realms SET description = $1 WHERE id = $2 AND user_id = $3"
|
|
|
|
|
<< description << id << user.id
|
|
|
|
|
>> [callback](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = "Description updated successfully";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "update description", "Failed to update description");
|
|
|
|
|
} else {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = "No changes to apply";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
2025-08-10 07:55:39 -04:00
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "verify realm ownership");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::deleteRealm(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
int64_t id = std::stoll(realmId);
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
auto dbClient = app().getDbClient();
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
// First get the stream key to invalidate it (if it's a stream realm)
|
|
|
|
|
*dbClient << "SELECT stream_key, realm_type FROM realms WHERE id = $1 AND user_id = $2"
|
2025-08-03 21:53:15 -04:00
|
|
|
<< id << user.id
|
|
|
|
|
>> [dbClient, id, user, callback](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
// Only invalidate stream key for stream realms
|
|
|
|
|
if (!r[0]["stream_key"].isNull()) {
|
|
|
|
|
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
|
|
|
|
invalidateKeyInRedis(streamKey);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete the realm (videos will be cascade deleted due to FK)
|
2025-08-03 21:53:15 -04:00
|
|
|
*dbClient << "DELETE FROM realms WHERE id = $1 AND user_id = $2"
|
|
|
|
|
<< id << user.id
|
|
|
|
|
>> [callback](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR_MSG(callback, "delete realm", "Failed to delete realm");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "get realm for delete");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::regenerateRealmKey(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
int64_t id = std::stoll(realmId);
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
auto dbClient = app().getDbClient();
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
// Get old key
|
2026-01-05 22:54:27 -05:00
|
|
|
*dbClient << "SELECT stream_key, realm_type FROM realms WHERE id = $1 AND user_id = $2"
|
2025-08-03 21:53:15 -04:00
|
|
|
<< id << user.id
|
|
|
|
|
>> [dbClient, id, user, callback](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
// Check if this is a stream realm
|
|
|
|
|
std::string realmType = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as<std::string>();
|
|
|
|
|
if (realmType != "stream") {
|
|
|
|
|
callback(jsonError("Cannot regenerate key for video realms", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
std::string oldKey = r[0]["stream_key"].as<std::string>();
|
|
|
|
|
invalidateKeyInRedis(oldKey);
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
std::string newKey = generateStreamKey();
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
*dbClient << "UPDATE realms SET stream_key = $1 WHERE id = $2 AND user_id = $3"
|
|
|
|
|
<< newKey << id << user.id
|
|
|
|
|
>> [callback, newKey](const Result&) {
|
|
|
|
|
// Store new key in Redis
|
|
|
|
|
RedisHelper::storeKey("stream_key:" + newKey, "1", 86400);
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["streamKey"] = newKey;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR_MSG(callback, "update stream key", "Failed to regenerate key");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "get realm for key regen");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::getRealmByName(const HttpRequestPtr &,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmName) {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
2026-01-05 22:54:27 -05:00
|
|
|
*dbClient << "SELECT r.id, r.name, r.realm_type, r.is_live, r.viewer_count, r.viewer_multiplier, "
|
|
|
|
|
"r.chat_enabled, r.chat_guests_allowed, "
|
|
|
|
|
"r.chat_slow_mode_seconds, r.chat_retention_hours, "
|
|
|
|
|
"r.offline_image_url, r.description, r.user_id, r.playlist_control_mode, r.playlist_whitelist, r.title_color, "
|
2026-01-08 22:57:43 -05:00
|
|
|
"r.live_started_at, "
|
2026-01-05 22:54:27 -05:00
|
|
|
"u.username, u.avatar_url, u.user_color FROM realms r "
|
2025-08-03 21:53:15 -04:00
|
|
|
"JOIN users u ON r.user_id = u.id "
|
|
|
|
|
"WHERE r.name = $1 AND r.is_active = true"
|
|
|
|
|
<< realmName
|
|
|
|
|
>> [callback](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
auto& realm = resp["realm"];
|
|
|
|
|
realm["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
|
|
|
|
realm["name"] = r[0]["name"].as<std::string>();
|
2026-01-05 22:54:27 -05:00
|
|
|
realm["type"] = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as<std::string>();
|
2025-08-03 21:53:15 -04:00
|
|
|
realm["isLive"] = r[0]["is_live"].as<bool>();
|
2026-01-05 22:54:27 -05:00
|
|
|
// Apply viewer multiplier for visual "viewbotting" effect
|
|
|
|
|
int64_t viewerCount = r[0]["viewer_count"].as<int64_t>();
|
|
|
|
|
int multiplier = r[0]["viewer_multiplier"].isNull() ? 1 : r[0]["viewer_multiplier"].as<int>();
|
|
|
|
|
int64_t displayCount = viewerCount * multiplier;
|
|
|
|
|
// Add random 0-99 bonus when multiplier is active
|
|
|
|
|
if (multiplier > 1) {
|
|
|
|
|
static thread_local std::mt19937 rng(std::random_device{}());
|
|
|
|
|
std::uniform_int_distribution<int> dist(0, 99);
|
|
|
|
|
displayCount += dist(rng);
|
|
|
|
|
}
|
|
|
|
|
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
|
|
|
|
realm["chatEnabled"] = r[0]["chat_enabled"].as<bool>();
|
|
|
|
|
realm["chatGuestsAllowed"] = r[0]["chat_guests_allowed"].as<bool>();
|
|
|
|
|
realm["chatSlowModeSeconds"] = r[0]["chat_slow_mode_seconds"].as<int>();
|
|
|
|
|
realm["chatRetentionHours"] = r[0]["chat_retention_hours"].as<int>();
|
|
|
|
|
realm["offlineImageUrl"] = r[0]["offline_image_url"].isNull() ? "" : r[0]["offline_image_url"].as<std::string>();
|
|
|
|
|
realm["description"] = r[0]["description"].isNull() ? "" : r[0]["description"].as<std::string>();
|
|
|
|
|
realm["ownerId"] = static_cast<Json::Int64>(r[0]["user_id"].as<int64_t>());
|
|
|
|
|
realm["playlistControlMode"] = r[0]["playlist_control_mode"].isNull() ? "owner" : r[0]["playlist_control_mode"].as<std::string>();
|
|
|
|
|
realm["playlistWhitelist"] = r[0]["playlist_whitelist"].isNull() ? "[]" : r[0]["playlist_whitelist"].as<std::string>();
|
2025-08-03 21:53:15 -04:00
|
|
|
realm["username"] = r[0]["username"].as<std::string>();
|
|
|
|
|
realm["avatarUrl"] = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
2026-01-05 22:54:27 -05:00
|
|
|
realm["colorCode"] = r[0]["user_color"].isNull() ? "" : r[0]["user_color"].as<std::string>();
|
|
|
|
|
realm["titleColor"] = r[0]["title_color"].isNull() ? "#ffffff" : r[0]["title_color"].as<std::string>();
|
2026-01-08 22:57:43 -05:00
|
|
|
// Include live_started_at for stream duration display
|
|
|
|
|
if (!r[0]["live_started_at"].isNull()) {
|
|
|
|
|
realm["liveStartedAt"] = r[0]["live_started_at"].as<std::string>();
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "get realm by name");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::getLiveRealms(const HttpRequestPtr &,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
2026-01-05 22:54:27 -05:00
|
|
|
// SECURITY: Do NOT expose stream_key in public API - it allows stream hijacking
|
2026-01-08 19:42:22 -05:00
|
|
|
*dbClient << "SELECT r.name, r.viewer_count, r.viewer_multiplier, r.offline_image_url, r.title_color, "
|
2026-01-09 01:56:05 -05:00
|
|
|
"u.username, u.avatar_url, u.user_color "
|
2025-08-03 21:53:15 -04:00
|
|
|
"FROM realms r JOIN users u ON r.user_id = u.id "
|
|
|
|
|
"WHERE r.is_live = true AND r.is_active = true "
|
2026-01-05 22:54:27 -05:00
|
|
|
"ORDER BY (r.viewer_count * COALESCE(r.viewer_multiplier, 1)) DESC"
|
2025-08-03 21:53:15 -04:00
|
|
|
>> [callback](const Result& r) {
|
|
|
|
|
Json::Value resp(Json::arrayValue);
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
for (const auto& row : r) {
|
|
|
|
|
Json::Value realm;
|
|
|
|
|
realm["name"] = row["name"].as<std::string>();
|
2026-01-05 22:54:27 -05:00
|
|
|
// stream_key intentionally omitted - security fix
|
|
|
|
|
// Apply viewer multiplier for visual "viewbotting" effect
|
|
|
|
|
int64_t viewerCount = row["viewer_count"].as<int64_t>();
|
|
|
|
|
int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
|
|
|
|
int64_t displayCount = viewerCount * multiplier;
|
|
|
|
|
// Add random 0-99 bonus when multiplier is active
|
|
|
|
|
if (multiplier > 1) {
|
|
|
|
|
static thread_local std::mt19937 rng(std::random_device{}());
|
|
|
|
|
std::uniform_int_distribution<int> dist(0, 99);
|
|
|
|
|
displayCount += dist(rng);
|
|
|
|
|
}
|
|
|
|
|
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
2025-08-03 21:53:15 -04:00
|
|
|
realm["username"] = row["username"].as<std::string>();
|
|
|
|
|
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
2026-01-08 19:42:22 -05:00
|
|
|
realm["offlineImageUrl"] = row["offline_image_url"].isNull() ? "" : row["offline_image_url"].as<std::string>();
|
|
|
|
|
realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>();
|
2026-01-09 01:56:05 -05:00
|
|
|
realm["colorCode"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
|
2025-08-03 21:53:15 -04:00
|
|
|
resp.append(realm);
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "get live realms");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::getAllRealms(const HttpRequestPtr &,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
// Only show stream realms in the public list (video realms are accessed via /videos)
|
|
|
|
|
// SECURITY: Do NOT expose stream_key in public API - it allows stream hijacking
|
|
|
|
|
*dbClient << "SELECT r.id, r.name, r.realm_type, r.is_live, r.viewer_count, r.viewer_multiplier, "
|
|
|
|
|
"r.chat_enabled, r.chat_guests_allowed, "
|
|
|
|
|
"r.offline_image_url, r.title_color, u.username, u.avatar_url, u.user_color "
|
|
|
|
|
"FROM realms r JOIN users u ON r.user_id = u.id "
|
|
|
|
|
"WHERE r.is_active = true AND (r.realm_type = 'stream' OR r.realm_type IS NULL) "
|
|
|
|
|
"ORDER BY r.is_live DESC, (r.viewer_count * COALESCE(r.viewer_multiplier, 1)) DESC, r.name ASC"
|
|
|
|
|
>> [callback](const Result& r) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
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["type"] = row["realm_type"].isNull() ? "stream" : row["realm_type"].as<std::string>();
|
|
|
|
|
// stream_key intentionally omitted - security fix
|
|
|
|
|
realm["isLive"] = row["is_live"].as<bool>();
|
|
|
|
|
// Apply viewer multiplier for visual "viewbotting" effect
|
|
|
|
|
int64_t viewerCount = row["viewer_count"].as<int64_t>();
|
|
|
|
|
int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
|
|
|
|
int64_t displayCount = viewerCount * multiplier;
|
|
|
|
|
// Add random 0-99 bonus when multiplier is active
|
|
|
|
|
if (multiplier > 1) {
|
|
|
|
|
static thread_local std::mt19937 rng(std::random_device{}());
|
|
|
|
|
std::uniform_int_distribution<int> dist(0, 99);
|
|
|
|
|
displayCount += dist(rng);
|
|
|
|
|
}
|
|
|
|
|
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
|
|
|
|
realm["chatEnabled"] = row["chat_enabled"].as<bool>();
|
|
|
|
|
realm["chatGuestsAllowed"] = row["chat_guests_allowed"].as<bool>();
|
|
|
|
|
realm["offlineImageUrl"] = row["offline_image_url"].isNull() ? "" : row["offline_image_url"].as<std::string>();
|
|
|
|
|
realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>();
|
|
|
|
|
realm["username"] = row["username"].as<std::string>();
|
|
|
|
|
realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
|
|
|
|
realm["colorCode"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
|
|
|
|
|
realms.append(realm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["realms"] = realms;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "get all realms");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::validateRealmKey(const HttpRequestPtr &,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &key) {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
*dbClient << "SELECT id FROM realms WHERE stream_key = $1 AND is_active = true"
|
|
|
|
|
<< key
|
|
|
|
|
>> [callback, key](const Result& r) {
|
|
|
|
|
bool valid = !r.empty();
|
|
|
|
|
if (valid) {
|
|
|
|
|
// Store in Redis
|
|
|
|
|
RedisHelper::storeKey("stream_key:" + key, "1", 86400);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["valid"] = valid;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> [callback](const DrogonDbException& e) {
|
|
|
|
|
LOG_ERROR << "Database error: " << e.base().what();
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["valid"] = false;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::getRealmStats(const HttpRequestPtr &,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
// Public endpoint - no authentication required
|
|
|
|
|
int64_t id = std::stoll(realmId);
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
auto dbClient = app().getDbClient();
|
2026-01-05 22:54:27 -05:00
|
|
|
*dbClient << "SELECT stream_key, viewer_multiplier FROM realms WHERE id = $1"
|
2025-08-03 21:53:15 -04:00
|
|
|
<< id
|
|
|
|
|
>> [callback](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
2026-01-05 22:54:27 -05:00
|
|
|
int multiplier = r[0]["viewer_multiplier"].isNull() ? 1 : r[0]["viewer_multiplier"].as<int>();
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
StatsService::getInstance().getStreamStats(streamKey,
|
2026-01-05 22:54:27 -05:00
|
|
|
[callback, multiplier](bool success, const StreamStats& stats) {
|
2025-08-03 21:53:15 -04:00
|
|
|
if (success) {
|
|
|
|
|
Json::Value json;
|
|
|
|
|
json["success"] = true;
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
// Calculate random bonus when multiplier is active
|
|
|
|
|
int randomBonus = 0;
|
|
|
|
|
if (multiplier > 1) {
|
|
|
|
|
static thread_local std::mt19937 rng(std::random_device{}());
|
|
|
|
|
std::uniform_int_distribution<int> dist(0, 99);
|
|
|
|
|
randomBonus = dist(rng);
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
auto& s = json["stats"];
|
2026-01-05 22:54:27 -05:00
|
|
|
// Apply viewer multiplier for visual "viewbotting" effect
|
|
|
|
|
s["connections"] = static_cast<Json::Int64>(stats.uniqueViewers * multiplier + randomBonus);
|
|
|
|
|
s["total_connections"] = static_cast<Json::Int64>(stats.totalConnections * multiplier + randomBonus);
|
2025-08-03 21:53:15 -04:00
|
|
|
s["bytes_in"] = static_cast<Json::Int64>(stats.totalBytesIn);
|
|
|
|
|
s["bytes_out"] = static_cast<Json::Int64>(stats.totalBytesOut);
|
|
|
|
|
s["bitrate"] = stats.bitrate;
|
|
|
|
|
s["codec"] = stats.codec;
|
|
|
|
|
s["resolution"] = stats.resolution;
|
|
|
|
|
s["fps"] = stats.fps;
|
|
|
|
|
s["is_live"] = stats.isLive;
|
2026-01-05 22:54:27 -05:00
|
|
|
|
|
|
|
|
// Protocol breakdown (also multiplied for consistency)
|
2025-08-03 21:53:15 -04:00
|
|
|
auto& pc = s["protocol_connections"];
|
2026-01-05 22:54:27 -05:00
|
|
|
pc["webrtc"] = static_cast<Json::Int64>(stats.protocolConnections.webrtc * multiplier);
|
|
|
|
|
pc["hls"] = static_cast<Json::Int64>(stats.protocolConnections.hls * multiplier);
|
|
|
|
|
pc["llhls"] = static_cast<Json::Int64>(stats.protocolConnections.llhls * multiplier);
|
|
|
|
|
pc["dash"] = static_cast<Json::Int64>(stats.protocolConnections.dash * multiplier);
|
|
|
|
|
|
2025-08-03 21:53:15 -04:00
|
|
|
callback(jsonResp(json));
|
|
|
|
|
} else {
|
|
|
|
|
callback(jsonError("Failed to retrieve stats"));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-01-05 22:54:27 -05:00
|
|
|
>> DB_ERROR(callback, "get realm stats");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::getPublicUserRealms(const HttpRequestPtr &,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &username) {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
*dbClient << "SELECT r.id, r.name, r.realm_type, r.is_live, r.viewer_count, r.viewer_multiplier, "
|
|
|
|
|
"r.title_color, r.created_at, "
|
|
|
|
|
"(SELECT COUNT(*) FROM videos v WHERE v.realm_id = r.id AND v.status = 'ready') as video_count "
|
|
|
|
|
"FROM realms r "
|
|
|
|
|
"JOIN users u ON r.user_id = u.id "
|
|
|
|
|
"WHERE u.username = $1 AND r.is_active = true "
|
|
|
|
|
"ORDER BY r.realm_type ASC, r.is_live DESC, r.created_at DESC"
|
|
|
|
|
<< username
|
|
|
|
|
>> [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["type"] = row["realm_type"].isNull() ? "stream" : row["realm_type"].as<std::string>();
|
|
|
|
|
realm["isLive"] = row["is_live"].as<bool>();
|
|
|
|
|
// Apply viewer multiplier for visual "viewbotting" effect
|
|
|
|
|
int64_t viewerCount = row["viewer_count"].as<int64_t>();
|
|
|
|
|
int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as<int>();
|
|
|
|
|
int64_t displayCount = viewerCount * multiplier;
|
|
|
|
|
// Add random 0-99 bonus when multiplier is active
|
|
|
|
|
if (multiplier > 1) {
|
|
|
|
|
static thread_local std::mt19937 rng(std::random_device{}());
|
|
|
|
|
std::uniform_int_distribution<int> dist(0, 99);
|
|
|
|
|
displayCount += dist(rng);
|
|
|
|
|
}
|
|
|
|
|
realm["viewerCount"] = static_cast<Json::Int64>(displayCount);
|
|
|
|
|
realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as<std::string>();
|
|
|
|
|
realm["videoCount"] = static_cast<Json::Int64>(row["video_count"].as<int64_t>());
|
|
|
|
|
realm["createdAt"] = row["created_at"].as<std::string>();
|
|
|
|
|
realms.append(realm);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp["realms"] = realms;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "get user realms");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::uploadOfflineImage(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t id;
|
|
|
|
|
try {
|
|
|
|
|
id = std::stoll(realmId);
|
|
|
|
|
} catch (...) {
|
|
|
|
|
callback(jsonError("Invalid realm ID", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get file from multipart form
|
|
|
|
|
MultiPartParser fileParser;
|
|
|
|
|
if (fileParser.parse(req) != 0 || fileParser.getFiles().empty()) {
|
|
|
|
|
callback(jsonError("No file uploaded"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto& file = fileParser.getFiles()[0];
|
|
|
|
|
|
|
|
|
|
// Check file extension for allowed formats (supports animated GIF/WebP)
|
|
|
|
|
std::string filename = file.getFileName();
|
|
|
|
|
std::string ext;
|
|
|
|
|
auto dotPos = filename.find_last_of('.');
|
|
|
|
|
if (dotPos != std::string::npos) {
|
|
|
|
|
ext = filename.substr(dotPos);
|
|
|
|
|
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
|
|
|
|
|
}
|
|
|
|
|
if (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") {
|
|
|
|
|
callback(jsonError("Invalid file type. Only JPEG, PNG, GIF, and WebP are allowed."));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check file size (max 5MB)
|
|
|
|
|
if (file.fileLength() > 5 * 1024 * 1024) {
|
|
|
|
|
callback(jsonError("File too large. Maximum size is 5MB."));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
|
|
|
|
|
// Verify realm ownership and get old image path
|
|
|
|
|
*dbClient << "SELECT offline_image_url, realm_type FROM realms WHERE id = $1 AND user_id = $2"
|
|
|
|
|
<< id << user.id
|
|
|
|
|
>> [callback, dbClient, id, user, file = std::move(file)](const Result& r) mutable {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found or access denied", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Only allow for stream and watch realms
|
|
|
|
|
std::string realmType = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as<std::string>();
|
|
|
|
|
if (realmType != "stream" && realmType != "watch") {
|
|
|
|
|
callback(jsonError("Offline images are only supported for stream and watch realms", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete old image if exists (SECURITY FIX #12: Use safe delete with path validation)
|
|
|
|
|
if (!r[0]["offline_image_url"].isNull()) {
|
|
|
|
|
std::string oldPath = r[0]["offline_image_url"].as<std::string>();
|
|
|
|
|
safeDeleteFile(oldPath, "./uploads/offline");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Save new file
|
|
|
|
|
std::string uploadDir = "./uploads/offline";
|
|
|
|
|
std::filesystem::create_directories(uploadDir);
|
|
|
|
|
|
|
|
|
|
// Generate unique filename
|
|
|
|
|
std::string ext = file.getFileName().substr(file.getFileName().find_last_of("."));
|
|
|
|
|
std::string filename = "realm_" + std::to_string(id) + "_" +
|
|
|
|
|
std::to_string(std::chrono::system_clock::now().time_since_epoch().count()) + ext;
|
|
|
|
|
std::string savePath = uploadDir + "/" + filename;
|
|
|
|
|
std::string urlPath = "/uploads/offline/" + filename;
|
|
|
|
|
|
|
|
|
|
// Save file
|
|
|
|
|
file.saveAs(savePath);
|
|
|
|
|
|
|
|
|
|
// Update database
|
|
|
|
|
*dbClient << "UPDATE realms SET offline_image_url = $1 WHERE id = $2 AND user_id = $3"
|
|
|
|
|
<< urlPath << id << user.id
|
|
|
|
|
>> [callback, urlPath](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["offlineImageUrl"] = urlPath;
|
|
|
|
|
resp["message"] = "Offline image uploaded successfully";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "update offline image", "Failed to update offline image");
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "verify realm ownership");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::deleteOfflineImage(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t id;
|
|
|
|
|
try {
|
|
|
|
|
id = std::stoll(realmId);
|
|
|
|
|
} catch (...) {
|
|
|
|
|
callback(jsonError("Invalid realm ID", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
|
|
|
|
|
// Get old image path and verify ownership
|
|
|
|
|
*dbClient << "SELECT offline_image_url FROM realms WHERE id = $1 AND user_id = $2"
|
|
|
|
|
<< id << user.id
|
|
|
|
|
>> [callback, dbClient, id, user](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found or access denied", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Delete file if exists (SECURITY FIX #12: Use safe delete with path validation)
|
|
|
|
|
if (!r[0]["offline_image_url"].isNull()) {
|
|
|
|
|
std::string oldPath = r[0]["offline_image_url"].as<std::string>();
|
|
|
|
|
safeDeleteFile(oldPath, "./uploads/offline");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear database field
|
|
|
|
|
*dbClient << "UPDATE realms SET offline_image_url = NULL WHERE id = $1 AND user_id = $2"
|
|
|
|
|
<< id << user.id
|
|
|
|
|
>> [callback](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = "Offline image deleted successfully";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "delete offline image", "Failed to delete offline image");
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "get realm for delete image");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::getRealmModerators(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t id;
|
|
|
|
|
try {
|
|
|
|
|
id = std::stoll(realmId);
|
|
|
|
|
} catch (...) {
|
|
|
|
|
callback(jsonError("Invalid realm ID", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
|
|
|
|
|
// Verify user is realm owner or admin
|
|
|
|
|
*dbClient << "SELECT user_id FROM realms WHERE id = $1"
|
|
|
|
|
<< id
|
|
|
|
|
>> [callback, dbClient, id, user](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t ownerId = r[0]["user_id"].as<int64_t>();
|
|
|
|
|
if (ownerId != user.id && !user.isAdmin) {
|
|
|
|
|
callback(jsonError("Access denied", k403Forbidden));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get moderators for this realm
|
|
|
|
|
*dbClient << "SELECT u.id, u.username, u.avatar_url, u.user_color, cm.granted_at "
|
|
|
|
|
"FROM chat_moderators cm "
|
|
|
|
|
"JOIN users u ON cm.user_id = u.id "
|
|
|
|
|
"WHERE cm.realm_id = $1 "
|
|
|
|
|
"ORDER BY cm.granted_at DESC"
|
|
|
|
|
<< id
|
|
|
|
|
>> [callback](const Result& r2) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
Json::Value moderators(Json::arrayValue);
|
|
|
|
|
|
|
|
|
|
for (const auto& row : r2) {
|
|
|
|
|
Json::Value mod;
|
|
|
|
|
mod["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
|
|
|
|
mod["username"] = row["username"].as<std::string>();
|
|
|
|
|
mod["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
|
|
|
|
mod["colorCode"] = row["user_color"].isNull() ? "#561D5E" : row["user_color"].as<std::string>();
|
|
|
|
|
mod["grantedAt"] = row["granted_at"].as<std::string>();
|
|
|
|
|
moderators.append(mod);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resp["moderators"] = moderators;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "get moderators", "Failed to get moderators");
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "check realm ownership");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::addRealmModerator(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t id;
|
|
|
|
|
try {
|
|
|
|
|
id = std::stoll(realmId);
|
|
|
|
|
} catch (...) {
|
|
|
|
|
callback(jsonError("Invalid realm ID", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto json = req->getJsonObject();
|
|
|
|
|
if (!json || !(*json)["userId"].isInt64()) {
|
|
|
|
|
callback(jsonError("userId is required", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t targetUserId = (*json)["userId"].asInt64();
|
|
|
|
|
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
|
|
|
|
|
// Verify user is realm owner or admin
|
|
|
|
|
*dbClient << "SELECT user_id FROM realms WHERE id = $1"
|
|
|
|
|
<< id
|
|
|
|
|
>> [callback, dbClient, id, user, targetUserId](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t ownerId = r[0]["user_id"].as<int64_t>();
|
|
|
|
|
if (ownerId != user.id && !user.isAdmin) {
|
|
|
|
|
callback(jsonError("Only realm owner can add moderators", k403Forbidden));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check if target user exists
|
|
|
|
|
*dbClient << "SELECT id, username FROM users WHERE id = $1"
|
|
|
|
|
<< targetUserId
|
|
|
|
|
>> [callback, dbClient, id, targetUserId](const Result& r2) {
|
|
|
|
|
if (r2.empty()) {
|
|
|
|
|
callback(jsonError("User not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string username = r2[0]["username"].as<std::string>();
|
|
|
|
|
|
|
|
|
|
// Insert moderator (ignore if already exists)
|
|
|
|
|
*dbClient << "INSERT INTO chat_moderators (realm_id, user_id) VALUES ($1, $2) "
|
|
|
|
|
"ON CONFLICT (realm_id, user_id) DO NOTHING"
|
|
|
|
|
<< id << targetUserId
|
|
|
|
|
>> [callback, username](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = username + " added as moderator";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "add moderator", "Failed to add moderator");
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "check user exists");
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "check realm ownership");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::removeRealmModerator(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId,
|
|
|
|
|
const std::string &moderatorId) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t id, modId;
|
|
|
|
|
try {
|
|
|
|
|
id = std::stoll(realmId);
|
|
|
|
|
modId = std::stoll(moderatorId);
|
|
|
|
|
} catch (...) {
|
|
|
|
|
callback(jsonError("Invalid ID", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
|
|
|
|
|
// Verify user is realm owner or admin
|
|
|
|
|
*dbClient << "SELECT user_id FROM realms WHERE id = $1"
|
|
|
|
|
<< id
|
|
|
|
|
>> [callback, dbClient, id, modId, user](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t ownerId = r[0]["user_id"].as<int64_t>();
|
|
|
|
|
if (ownerId != user.id && !user.isAdmin) {
|
|
|
|
|
callback(jsonError("Only realm owner can remove moderators", k403Forbidden));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Remove moderator
|
|
|
|
|
*dbClient << "DELETE FROM chat_moderators WHERE realm_id = $1 AND user_id = $2"
|
|
|
|
|
<< id << modId
|
|
|
|
|
>> [callback](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["message"] = "Moderator removed";
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR_MSG(callback, "remove moderator", "Failed to remove moderator");
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "check realm ownership");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void RealmController::updateTitleColor(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmId) {
|
|
|
|
|
UserInfo user = getUserFromRequest(req);
|
|
|
|
|
if (user.id == 0) {
|
|
|
|
|
callback(jsonError("Unauthorized", k401Unauthorized));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t id;
|
|
|
|
|
try {
|
|
|
|
|
id = std::stoll(realmId);
|
|
|
|
|
} catch (...) {
|
|
|
|
|
callback(jsonError("Invalid realm ID", k400BadRequest));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto json = req->getJsonObject();
|
|
|
|
|
if (!json) {
|
|
|
|
|
callback(jsonError("Invalid JSON"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!(*json).isMember("titleColor")) {
|
|
|
|
|
callback(jsonError("Title color is required"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
std::string titleColor = (*json)["titleColor"].asString();
|
|
|
|
|
|
|
|
|
|
// Validate title color format (hex color)
|
|
|
|
|
if (titleColor.length() != 7 || titleColor[0] != '#') {
|
|
|
|
|
callback(jsonError("Invalid color format. Use hex format like #ffffff"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate hex characters
|
|
|
|
|
for (size_t i = 1; i < titleColor.length(); ++i) {
|
|
|
|
|
char c = titleColor[i];
|
|
|
|
|
if (!((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'))) {
|
|
|
|
|
callback(jsonError("Invalid color format. Use hex format like #ffffff"));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
|
|
|
|
|
// Verify realm ownership
|
|
|
|
|
*dbClient << "SELECT user_id FROM realms WHERE id = $1"
|
|
|
|
|
<< id
|
|
|
|
|
>> [callback, dbClient, id, user, titleColor](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int64_t ownerId = r[0]["user_id"].as<int64_t>();
|
|
|
|
|
if (ownerId != user.id && !user.isAdmin) {
|
|
|
|
|
callback(jsonError("You don't have permission to modify this realm", k403Forbidden));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update title color
|
|
|
|
|
*dbClient << "UPDATE realms SET title_color = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2"
|
|
|
|
|
<< titleColor << id
|
|
|
|
|
>> [callback, titleColor](const Result&) {
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["success"] = true;
|
|
|
|
|
resp["titleColor"] = titleColor;
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "update title color");
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "check realm ownership");
|
2026-01-08 19:42:22 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Internal endpoint for Lua thumbnail generator to lookup stream key by realm name
|
|
|
|
|
void RealmController::getStreamKeyByRealmName(const HttpRequestPtr &req,
|
|
|
|
|
std::function<void(const HttpResponsePtr &)> &&callback,
|
|
|
|
|
const std::string &realmName) {
|
|
|
|
|
auto dbClient = app().getDbClient();
|
|
|
|
|
|
|
|
|
|
*dbClient << "SELECT stream_key FROM realms WHERE name = $1 AND is_live = true AND is_active = true"
|
|
|
|
|
<< realmName
|
|
|
|
|
>> [callback](const Result& r) {
|
|
|
|
|
if (r.empty()) {
|
|
|
|
|
callback(jsonError("Realm not found or not live", k404NotFound));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Json::Value resp;
|
|
|
|
|
resp["streamKey"] = r[0]["stream_key"].as<std::string>();
|
|
|
|
|
callback(jsonResp(resp));
|
|
|
|
|
}
|
|
|
|
|
>> DB_ERROR(callback, "get stream key by realm name");
|
2025-08-03 21:53:15 -04:00
|
|
|
}
|