diff --git a/backend/src/controllers/AdminController.cpp b/backend/src/controllers/AdminController.cpp index 0c1fa02..b0f40ba 100644 --- a/backend/src/controllers/AdminController.cpp +++ b/backend/src/controllers/AdminController.cpp @@ -549,21 +549,21 @@ void AdminController::uploadStickers(const HttpRequestPtr &req, file.saveAs(fullPath); validCount++; - // Insert into database - *dbClient << "INSERT INTO stickers (name, file_path) VALUES ($1, $2) RETURNING id" - << stickerName << filePath - >> [uploaded, stickerName, filePath](const Result& r) mutable { - if (!r.empty()) { - Json::Value sticker; - sticker["id"] = static_cast(r[0]["id"].as()); - sticker["name"] = stickerName; - sticker["filePath"] = filePath; - uploaded.append(sticker); - } - } - >> [](const DrogonDbException& e) { - LOG_ERROR << "Failed to insert sticker: " << e.base().what(); - }; + // Insert into database (synchronous to ensure completion before response) + try { + auto result = dbClient->execSqlSync( + "INSERT INTO stickers (name, file_path) VALUES ($1, $2) RETURNING id", + stickerName, filePath); + if (!result.empty()) { + Json::Value sticker; + sticker["id"] = static_cast(result[0]["id"].as()); + sticker["name"] = stickerName; + sticker["filePath"] = filePath; + uploaded.append(sticker); + } + } catch (const DrogonDbException& e) { + LOG_ERROR << "Failed to insert sticker: " << e.base().what(); + } } if (validCount == 0) { diff --git a/backend/src/controllers/AudioController.cpp b/backend/src/controllers/AudioController.cpp index c78cfe6..5a69289 100644 --- a/backend/src/controllers/AudioController.cpp +++ b/backend/src/controllers/AudioController.cpp @@ -79,6 +79,33 @@ namespace { }; } + // Build minimal audio JSON (for realm listings without user info) + Json::Value buildAudioJsonMinimal(const drogon::orm::Row& row) { + Json::Value audio; + audio["id"] = static_cast(row["id"].as()); + audio["title"] = row["title"].as(); + audio["description"] = row["description"].isNull() ? "" : row["description"].as(); + audio["filePath"] = row["file_path"].as(); + audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as(); + audio["durationSeconds"] = row["duration_seconds"].as(); + audio["format"] = row["format"].isNull() ? "" : row["format"].as(); + audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as(); + audio["playCount"] = row["play_count"].as(); + audio["createdAt"] = row["created_at"].as(); + return audio; + } + + // Build audio JSON with user info (for public listings) + Json::Value buildAudioJsonWithUser(const drogon::orm::Row& row) { + Json::Value audio = buildAudioJsonMinimal(row); + audio["userId"] = static_cast(row["user_id"].as()); + audio["username"] = row["username"].as(); + audio["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); + audio["realmId"] = static_cast(row["realm_id"].as()); + audio["realmName"] = row["realm_name"].as(); + return audio; + } + // Process audio metadata asynchronously void processAudioMetadata(int64_t audioId, const std::string& audioFullPath, const std::string& format) { std::thread([audioId, audioFullPath, format]() { @@ -131,27 +158,9 @@ void AudioController::getAllAudio(const HttpRequestPtr &req, Json::Value resp; resp["success"] = true; Json::Value audioFiles(Json::arrayValue); - for (const auto& row : r) { - Json::Value audio; - audio["id"] = static_cast(row["id"].as()); - audio["title"] = row["title"].as(); - audio["description"] = row["description"].isNull() ? "" : row["description"].as(); - audio["filePath"] = row["file_path"].as(); - audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as(); - audio["durationSeconds"] = row["duration_seconds"].as(); - audio["format"] = row["format"].isNull() ? "" : row["format"].as(); - audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as(); - audio["playCount"] = row["play_count"].as(); - audio["createdAt"] = row["created_at"].as(); - audio["userId"] = static_cast(row["user_id"].as()); - audio["username"] = row["username"].as(); - audio["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); - audio["realmId"] = static_cast(row["realm_id"].as()); - audio["realmName"] = row["realm_name"].as(); - audioFiles.append(audio); + audioFiles.append(buildAudioJsonWithUser(row)); } - resp["audio"] = audioFiles; callback(jsonResp(resp)); } @@ -175,27 +184,9 @@ void AudioController::getLatestAudio(const HttpRequestPtr &, Json::Value resp; resp["success"] = true; Json::Value audioFiles(Json::arrayValue); - for (const auto& row : r) { - Json::Value audio; - audio["id"] = static_cast(row["id"].as()); - audio["title"] = row["title"].as(); - audio["description"] = row["description"].isNull() ? "" : row["description"].as(); - audio["filePath"] = row["file_path"].as(); - audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as(); - audio["durationSeconds"] = row["duration_seconds"].as(); - audio["format"] = row["format"].isNull() ? "" : row["format"].as(); - audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as(); - audio["playCount"] = row["play_count"].as(); - audio["createdAt"] = row["created_at"].as(); - audio["userId"] = static_cast(row["user_id"].as()); - audio["username"] = row["username"].as(); - audio["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); - audio["realmId"] = static_cast(row["realm_id"].as()); - audio["realmName"] = row["realm_name"].as(); - audioFiles.append(audio); + audioFiles.append(buildAudioJsonWithUser(row)); } - resp["audio"] = audioFiles; callback(jsonResp(resp)); } @@ -312,20 +303,8 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req, Json::Value audioFiles(Json::arrayValue); for (const auto& row : r) { - Json::Value audio; - audio["id"] = static_cast(row["id"].as()); - audio["title"] = row["title"].as(); - audio["description"] = row["description"].isNull() ? "" : row["description"].as(); - audio["filePath"] = row["file_path"].as(); - audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as(); - audio["durationSeconds"] = row["duration_seconds"].as(); - audio["format"] = row["format"].isNull() ? "" : row["format"].as(); - audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as(); - audio["playCount"] = row["play_count"].as(); - audio["createdAt"] = row["created_at"].as(); - audioFiles.append(audio); + audioFiles.append(buildAudioJsonMinimal(row)); } - resp["audio"] = audioFiles; callback(jsonResp(resp)); } @@ -383,20 +362,8 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req, Json::Value audioFiles(Json::arrayValue); for (const auto& row : r) { - Json::Value audio; - audio["id"] = static_cast(row["id"].as()); - audio["title"] = row["title"].as(); - audio["description"] = row["description"].isNull() ? "" : row["description"].as(); - audio["filePath"] = row["file_path"].as(); - audio["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as(); - audio["durationSeconds"] = row["duration_seconds"].as(); - audio["format"] = row["format"].isNull() ? "" : row["format"].as(); - audio["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as(); - audio["playCount"] = row["play_count"].as(); - audio["createdAt"] = row["created_at"].as(); - audioFiles.append(audio); + audioFiles.append(buildAudioJsonMinimal(row)); } - resp["audio"] = audioFiles; callback(jsonResp(resp)); } @@ -537,18 +504,12 @@ void AudioController::uploadAudio(const HttpRequestPtr &req, const auto& file = parser.getFiles()[0]; - std::string title = parser.getParameter("title"); + std::string title = sanitizeUserInput(parser.getParameter("title"), 255); if (title.empty()) { title = "Untitled Audio"; } - if (title.length() > 255) { - title = title.substr(0, 255); - } - std::string description = parser.getParameter("description"); - if (description.length() > 5000) { - description = description.substr(0, 5000); - } + std::string description = sanitizeUserInput(parser.getParameter("description"), 5000); // Validate file size (500MB max) const size_t maxSize = 500 * 1024 * 1024; @@ -721,12 +682,10 @@ void AudioController::updateAudio(const HttpRequestPtr &req, std::string title, description; if (json->isMember("title")) { - title = (*json)["title"].asString(); - if (title.length() > 255) title = title.substr(0, 255); + title = sanitizeUserInput((*json)["title"].asString(), 255); } if (json->isMember("description")) { - description = (*json)["description"].asString(); - if (description.length() > 5000) description = description.substr(0, 5000); + description = sanitizeUserInput((*json)["description"].asString(), 5000); } if (json->isMember("title") && json->isMember("description")) { diff --git a/backend/src/controllers/EbookController.cpp b/backend/src/controllers/EbookController.cpp index b15b4c0..b951515 100644 --- a/backend/src/controllers/EbookController.cpp +++ b/backend/src/controllers/EbookController.cpp @@ -20,6 +20,34 @@ static constexpr size_t MAX_COVER_SIZE = 5 * 1024 * 1024; // 5MB using namespace drogon::orm; +namespace { + // Build minimal ebook JSON (for realm listings without user info) + Json::Value buildEbookJsonMinimal(const drogon::orm::Row& row) { + Json::Value ebook; + ebook["id"] = static_cast(row["id"].as()); + ebook["title"] = row["title"].as(); + ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); + ebook["filePath"] = row["file_path"].as(); + ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); + ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); + ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); + ebook["readCount"] = row["read_count"].as(); + ebook["createdAt"] = row["created_at"].as(); + return ebook; + } + + // Build ebook JSON with user info (for public listings) + Json::Value buildEbookJsonWithUser(const drogon::orm::Row& row) { + Json::Value ebook = buildEbookJsonMinimal(row); + ebook["userId"] = static_cast(row["user_id"].as()); + ebook["username"] = row["username"].as(); + ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); + ebook["realmId"] = static_cast(row["realm_id"].as()); + ebook["realmName"] = row["realm_name"].as(); + return ebook; + } +} + void EbookController::getAllEbooks(const HttpRequestPtr &req, std::function &&callback) { auto pagination = parsePagination(req, 20, 50); @@ -40,26 +68,9 @@ void EbookController::getAllEbooks(const HttpRequestPtr &req, Json::Value resp; resp["success"] = true; Json::Value ebooks(Json::arrayValue); - for (const auto& row : r) { - Json::Value ebook; - ebook["id"] = static_cast(row["id"].as()); - ebook["title"] = row["title"].as(); - ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); - ebook["filePath"] = row["file_path"].as(); - ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); - ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); - ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); - ebook["readCount"] = row["read_count"].as(); - ebook["createdAt"] = row["created_at"].as(); - ebook["userId"] = static_cast(row["user_id"].as()); - ebook["username"] = row["username"].as(); - ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); - ebook["realmId"] = static_cast(row["realm_id"].as()); - ebook["realmName"] = row["realm_name"].as(); - ebooks.append(ebook); + ebooks.append(buildEbookJsonWithUser(row)); } - resp["ebooks"] = ebooks; callback(jsonResp(resp)); } @@ -83,26 +94,9 @@ void EbookController::getLatestEbooks(const HttpRequestPtr &, Json::Value resp; resp["success"] = true; Json::Value ebooks(Json::arrayValue); - for (const auto& row : r) { - Json::Value ebook; - ebook["id"] = static_cast(row["id"].as()); - ebook["title"] = row["title"].as(); - ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); - ebook["filePath"] = row["file_path"].as(); - ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); - ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); - ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); - ebook["readCount"] = row["read_count"].as(); - ebook["createdAt"] = row["created_at"].as(); - ebook["userId"] = static_cast(row["user_id"].as()); - ebook["username"] = row["username"].as(); - ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); - ebook["realmId"] = static_cast(row["realm_id"].as()); - ebook["realmName"] = row["realm_name"].as(); - ebooks.append(ebook); + ebooks.append(buildEbookJsonWithUser(row)); } - resp["ebooks"] = ebooks; callback(jsonResp(resp)); } @@ -138,7 +132,6 @@ void EbookController::getEbook(const HttpRequestPtr &, } const auto& row = r[0]; - if (!row["is_public"].as()) { callback(jsonError("Ebook not found", k404NotFound)); return; @@ -146,22 +139,7 @@ void EbookController::getEbook(const HttpRequestPtr &, Json::Value resp; resp["success"] = true; - auto& ebook = resp["ebook"]; - ebook["id"] = static_cast(row["id"].as()); - ebook["title"] = row["title"].as(); - ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); - ebook["filePath"] = row["file_path"].as(); - ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); - ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); - ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); - ebook["readCount"] = row["read_count"].as(); - ebook["createdAt"] = row["created_at"].as(); - ebook["userId"] = static_cast(row["user_id"].as()); - ebook["username"] = row["username"].as(); - ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); - ebook["realmId"] = static_cast(row["realm_id"].as()); - ebook["realmName"] = row["realm_name"].as(); - + resp["ebook"] = buildEbookJsonWithUser(row); callback(jsonResp(resp)); } >> DB_ERROR(callback, "get ebook"); @@ -186,23 +164,12 @@ void EbookController::getUserEbooks(const HttpRequestPtr &, resp["success"] = true; resp["username"] = username; Json::Value ebooks(Json::arrayValue); - for (const auto& row : r) { - Json::Value ebook; - ebook["id"] = static_cast(row["id"].as()); - ebook["title"] = row["title"].as(); - ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); - ebook["filePath"] = row["file_path"].as(); - ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); - ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); - ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); - ebook["readCount"] = row["read_count"].as(); - ebook["createdAt"] = row["created_at"].as(); + Json::Value ebook = buildEbookJsonMinimal(row); ebook["realmId"] = static_cast(row["realm_id"].as()); ebook["realmName"] = row["realm_name"].as(); ebooks.append(ebook); } - resp["ebooks"] = ebooks; callback(jsonResp(resp)); } @@ -259,19 +226,8 @@ void EbookController::getRealmEbooks(const HttpRequestPtr &, // Ebooks Json::Value ebooks(Json::arrayValue); for (const auto& row : r) { - Json::Value ebook; - ebook["id"] = static_cast(row["id"].as()); - ebook["title"] = row["title"].as(); - ebook["description"] = row["description"].isNull() ? "" : row["description"].as(); - ebook["filePath"] = row["file_path"].as(); - ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as(); - ebook["fileSizeBytes"] = static_cast(row["file_size_bytes"].as()); - ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as(); - ebook["readCount"] = row["read_count"].as(); - ebook["createdAt"] = row["created_at"].as(); - ebooks.append(ebook); + ebooks.append(buildEbookJsonMinimal(row)); } - resp["ebooks"] = ebooks; callback(jsonResp(resp)); } diff --git a/backend/src/controllers/RealmController.cpp b/backend/src/controllers/RealmController.cpp index 0b44914..475db56 100644 --- a/backend/src/controllers/RealmController.cpp +++ b/backend/src/controllers/RealmController.cpp @@ -7,6 +7,7 @@ #include "../common/HttpHelpers.h" #include "../common/AuthHelpers.h" #include "../common/CryptoUtils.h" +#include "../common/FileUtils.h" #include #include #include @@ -32,11 +33,25 @@ namespace { return crypto_utils::bytesToHex(bytes, sizeof(bytes)); } - bool validateRealmName(const std::string& name) { + bool validateRealmSlug(const std::string& slug) { + if (slug.length() < 3 || slug.length() > 30) { + return false; + } + return std::regex_match(slug, std::regex("^[a-z0-9-]+$")); + } + + bool validateRealmDisplayName(const std::string& name) { if (name.length() < 3 || name.length() > 30) { return false; } - return std::regex_match(name, std::regex("^[a-z0-9-]+$")); + // Allow uppercase letters, lowercase letters, numbers, and hyphens + return std::regex_match(name, std::regex("^[a-zA-Z0-9-]+$")); + } + + std::string toSlug(const std::string& displayName) { + std::string slug = displayName; + std::transform(slug.begin(), slug.end(), slug.begin(), ::tolower); + return slug; } // SECURITY FIX #12: Safe file deletion with path traversal protection @@ -154,7 +169,7 @@ void RealmController::getUserRealms(const HttpRequestPtr &req, } auto dbClient = app().getDbClient(); - *dbClient << "SELECT r.id, r.name, r.description, r.stream_key, r.realm_type, r.is_active, r.is_live, " + *dbClient << "SELECT r.id, r.name, r.display_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, " @@ -171,6 +186,8 @@ void RealmController::getUserRealms(const HttpRequestPtr &req, Json::Value realm; realm["id"] = static_cast(row["id"].as()); realm["name"] = row["name"].as(); + // Use display_name if available, otherwise fall back to name + realm["displayName"] = row["display_name"].isNull() ? row["name"].as() : row["display_name"].as(); realm["description"] = row["description"].isNull() ? "" : row["description"].as(); realm["type"] = row["realm_type"].isNull() ? "stream" : row["realm_type"].as(); realm["streamKey"] = row["stream_key"].isNull() ? "" : row["stream_key"].as(); @@ -210,11 +227,11 @@ void RealmController::createRealm(const HttpRequestPtr &req, return; } - std::string name = (*json)["name"].asString(); + std::string displayName = (*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)) { + if (CensorService::getInstance().containsCensoredWords(displayName)) { callback(jsonError("Realm name contains prohibited content")); return; } @@ -225,16 +242,20 @@ void RealmController::createRealm(const HttpRequestPtr &req, return; } - if (!validateRealmName(name)) { - callback(jsonError("Realm name must be 3-30 characters, lowercase letters, numbers, and hyphens only")); + // Validate display name format (allows uppercase) + if (!validateRealmDisplayName(displayName)) { + callback(jsonError("Realm name must be 3-30 characters, letters, numbers, and hyphens only")); return; } + // Generate lowercase slug for URL + std::string name = toSlug(displayName); + // Check permission based on realm type auto dbClient = app().getDbClient(); *dbClient << "SELECT is_streamer, is_uploader, is_watch_creator FROM users WHERE id = $1" << user.id - >> [req, callback, user, dbClient, name, realmType](const Result& r) { + >> [req, callback, user, dbClient, name, displayName, realmType](const Result& r) { if (r.empty()) { callback(jsonError("User not found", k404NotFound)); return; @@ -269,7 +290,7 @@ void RealmController::createRealm(const HttpRequestPtr &req, // Check if realm name already exists *dbClient << "SELECT id FROM realms WHERE name = $1" << name - >> [dbClient, user, name, realmType, callback](const Result& r2) { + >> [dbClient, user, name, displayName, realmType, callback](const Result& r2) { if (!r2.empty()) { callback(jsonError("Realm name already taken")); return; @@ -278,7 +299,7 @@ void RealmController::createRealm(const HttpRequestPtr &req, // Check user's realm limit (e.g., 5 realms per user) *dbClient << "SELECT COUNT(*) as count FROM realms WHERE user_id = $1" << user.id - >> [dbClient, user, name, realmType, callback](const Result& r3) { + >> [dbClient, user, name, displayName, realmType, callback](const Result& r3) { if (!r3.empty() && r3[0]["count"].as() >= 5) { callback(jsonError("You have reached the maximum number of realms (5)")); return; @@ -288,10 +309,10 @@ void RealmController::createRealm(const HttpRequestPtr &req, // 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) { + *dbClient << "INSERT INTO realms (user_id, name, display_name, stream_key, realm_type) " + "VALUES ($1, $2, $3, $4, 'stream') RETURNING id" + << user.id << name << displayName << streamKey + >> [callback, name, displayName, streamKey](const Result& r4) { if (r4.empty()) { callback(jsonError("Failed to create realm")); return; @@ -304,6 +325,7 @@ void RealmController::createRealm(const HttpRequestPtr &req, resp["success"] = true; resp["realm"]["id"] = static_cast(r4[0]["id"].as()); resp["realm"]["name"] = name; + resp["realm"]["displayName"] = displayName; resp["realm"]["streamKey"] = streamKey; resp["realm"]["type"] = "stream"; callback(jsonResp(resp)); @@ -311,10 +333,10 @@ void RealmController::createRealm(const HttpRequestPtr &req, >> 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) { + *dbClient << "INSERT INTO realms (user_id, name, display_name, realm_type) " + "VALUES ($1, $2, $3, 'watch') RETURNING id" + << user.id << name << displayName + >> [callback, dbClient, name, displayName](const Result& r4) { if (r4.empty()) { callback(jsonError("Failed to create realm")); return; @@ -325,11 +347,12 @@ void RealmController::createRealm(const HttpRequestPtr &req, // Initialize watch_room_state *dbClient << "INSERT INTO watch_room_state (realm_id) VALUES ($1)" << realmId - >> [callback, name, realmId](const Result&) { + >> [callback, name, displayName, realmId](const Result&) { Json::Value resp; resp["success"] = true; resp["realm"]["id"] = static_cast(realmId); resp["realm"]["name"] = name; + resp["realm"]["displayName"] = displayName; resp["realm"]["type"] = "watch"; callback(jsonResp(resp)); } @@ -338,10 +361,10 @@ void RealmController::createRealm(const HttpRequestPtr &req, >> 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) { + *dbClient << "INSERT INTO realms (user_id, name, display_name, realm_type) " + "VALUES ($1, $2, $3, $4) RETURNING id" + << user.id << name << displayName << realmType + >> [callback, name, displayName, realmType](const Result& r4) { if (r4.empty()) { callback(jsonError("Failed to create realm")); return; @@ -351,6 +374,7 @@ void RealmController::createRealm(const HttpRequestPtr &req, resp["success"] = true; resp["realm"]["id"] = static_cast(r4[0]["id"].as()); resp["realm"]["name"] = name; + resp["realm"]["displayName"] = displayName; resp["realm"]["type"] = realmType; callback(jsonResp(resp)); } @@ -638,12 +662,7 @@ void RealmController::updateRealm(const HttpRequestPtr &req, >> 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); - } + std::string description = sanitizeUserInput((*json)["description"].asString(), 500); *dbClient << "UPDATE realms SET description = $1 WHERE id = $2 AND user_id = $3" << description << id << user.id @@ -759,7 +778,7 @@ void RealmController::getRealmByName(const HttpRequestPtr &, std::function &&callback, const std::string &realmName) { auto dbClient = app().getDbClient(); - *dbClient << "SELECT r.id, r.name, r.realm_type, r.is_live, r.viewer_count, r.viewer_multiplier, " + *dbClient << "SELECT r.id, r.name, r.display_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, " @@ -779,18 +798,13 @@ void RealmController::getRealmByName(const HttpRequestPtr &, auto& realm = resp["realm"]; realm["id"] = static_cast(r[0]["id"].as()); realm["name"] = r[0]["name"].as(); + realm["displayName"] = r[0]["display_name"].isNull() ? r[0]["name"].as() : r[0]["display_name"].as(); realm["type"] = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as(); realm["isLive"] = r[0]["is_live"].as(); // Apply viewer multiplier for visual "viewbotting" effect int64_t viewerCount = r[0]["viewer_count"].as(); int multiplier = r[0]["viewer_multiplier"].isNull() ? 1 : r[0]["viewer_multiplier"].as(); 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 dist(0, 99); - displayCount += dist(rng); - } realm["viewerCount"] = static_cast(displayCount); realm["chatEnabled"] = r[0]["chat_enabled"].as(); realm["chatGuestsAllowed"] = r[0]["chat_guests_allowed"].as(); @@ -835,12 +849,6 @@ void RealmController::getLiveRealms(const HttpRequestPtr &, int64_t viewerCount = row["viewer_count"].as(); int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as(); 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 dist(0, 99); - displayCount += dist(rng); - } realm["viewerCount"] = static_cast(displayCount); realm["username"] = row["username"].as(); realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); @@ -881,12 +889,6 @@ void RealmController::getAllRealms(const HttpRequestPtr &, int64_t viewerCount = row["viewer_count"].as(); int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as(); 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 dist(0, 99); - displayCount += dist(rng); - } realm["viewerCount"] = static_cast(displayCount); realm["chatEnabled"] = row["chat_enabled"].as(); realm["chatGuestsAllowed"] = row["chat_guests_allowed"].as(); @@ -954,18 +956,10 @@ void RealmController::getRealmStats(const HttpRequestPtr &, Json::Value json; json["success"] = true; - // 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 dist(0, 99); - randomBonus = dist(rng); - } - auto& s = json["stats"]; // Apply viewer multiplier for visual "viewbotting" effect - s["connections"] = static_cast(stats.uniqueViewers * multiplier + randomBonus); - s["total_connections"] = static_cast(stats.totalConnections * multiplier + randomBonus); + s["connections"] = static_cast(stats.uniqueViewers * multiplier); + s["total_connections"] = static_cast(stats.totalConnections * multiplier); s["bytes_in"] = static_cast(stats.totalBytesIn); s["bytes_out"] = static_cast(stats.totalBytesOut); s["bitrate"] = stats.bitrate; @@ -1017,12 +1011,6 @@ void RealmController::getPublicUserRealms(const HttpRequestPtr &, int64_t viewerCount = row["viewer_count"].as(); int multiplier = row["viewer_multiplier"].isNull() ? 1 : row["viewer_multiplier"].as(); 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 dist(0, 99); - displayCount += dist(rng); - } realm["viewerCount"] = static_cast(displayCount); realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as(); realm["videoCount"] = static_cast(row["video_count"].as()); diff --git a/backend/src/controllers/VideoController.cpp b/backend/src/controllers/VideoController.cpp index 6f841f5..a0bfaf5 100644 --- a/backend/src/controllers/VideoController.cpp +++ b/backend/src/controllers/VideoController.cpp @@ -601,19 +601,13 @@ void VideoController::uploadVideo(const HttpRequestPtr &req, const auto& file = parser.getFiles()[0]; // Get title from form data - std::string title = parser.getParameter("title"); + std::string title = sanitizeUserInput(parser.getParameter("title"), 255); if (title.empty()) { title = "Untitled Video"; } - if (title.length() > 255) { - title = title.substr(0, 255); - } // Get optional description - std::string description = parser.getParameter("description"); - if (description.length() > 5000) { - description = description.substr(0, 5000); - } + std::string description = sanitizeUserInput(parser.getParameter("description"), 5000); // Validate file size (500MB max) const size_t maxSize = 500 * 1024 * 1024; @@ -794,12 +788,10 @@ void VideoController::updateVideo(const HttpRequestPtr &req, std::string title, description; if (json->isMember("title")) { - title = (*json)["title"].asString(); - if (title.length() > 255) title = title.substr(0, 255); + title = sanitizeUserInput((*json)["title"].asString(), 255); } if (json->isMember("description")) { - description = (*json)["description"].asString(); - if (description.length() > 5000) description = description.substr(0, 5000); + description = sanitizeUserInput((*json)["description"].asString(), 5000); } if (json->isMember("title") && json->isMember("description")) { diff --git a/database/init.sql b/database/init.sql index df0340a..e28937c 100644 --- a/database/init.sql +++ b/database/init.sql @@ -79,6 +79,20 @@ BEGIN END IF; END $$; +-- Add display_name column if it doesn't exist (for existing databases) +-- display_name stores the user's preferred casing, name stores lowercase URL slug +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'realms' AND column_name = 'display_name' + ) THEN + ALTER TABLE realms ADD COLUMN display_name VARCHAR(255); + -- Populate display_name from name for existing realms + UPDATE realms SET display_name = name WHERE display_name IS NULL; + END IF; +END $$; + -- Make stream_key nullable for video realms (for existing databases) DO $$ BEGIN diff --git a/frontend/src/lib/chat/ttsStore.js b/frontend/src/lib/chat/ttsStore.js index af9bcbb..f645f6a 100644 --- a/frontend/src/lib/chat/ttsStore.js +++ b/frontend/src/lib/chat/ttsStore.js @@ -73,6 +73,9 @@ function shouldSpeak(message) { if (filter === 'guests' && !message.isGuest) return false; if (filter === 'registered' && message.isGuest) return false; + // Skip graffiti messages (they're just images, nothing to read) + if (message.content && /^\[graffiti\].*\[\/graffiti\]$/i.test(message.content.trim())) return false; + return true; } @@ -90,8 +93,11 @@ function cleanTextForTTS(text) { clean = clean.replace(/~~(.+?)~~/g, '$1'); // strikethrough clean = clean.replace(/`(.+?)`/g, '$1'); // code - // Remove URLs - clean = clean.replace(/https?:\/\/\S+/g, 'link'); + // Remove URLs completely (don't read them aloud) + clean = clean.replace(/https?:\/\/\S+/g, ''); + + // Remove any graffiti tags that might be embedded + clean = clean.replace(/\[graffiti\].*?\[\/graffiti\]/gi, ''); // Limit length to prevent very long TTS if (clean.length > 200) { diff --git a/frontend/src/lib/components/ChessGameOverlay.svelte b/frontend/src/lib/components/ChessGameOverlay.svelte index 9db027a..fd452f6 100644 --- a/frontend/src/lib/components/ChessGameOverlay.svelte +++ b/frontend/src/lib/components/ChessGameOverlay.svelte @@ -1,6 +1,8 @@
- {#each systemMessages as sysMsg (sysMsg.id)} -
- [{sysMsg.timestamp}] - {sysMsg.text} -
- {/each} - - {#each $filteredMessages as message (message.messageId)} - chatWebSocket.deleteMessage(message.messageId)} - on:showProfile={handleShowProfile} - /> + {#each allMessages as msg (msg.type === 'system' ? msg.id : msg.messageId)} + {#if msg.type === 'system'} +
+ [{msg.timestamp}] + {msg.text} +
+ {:else} + chatWebSocket.deleteMessage(msg.messageId)} + on:showProfile={handleShowProfile} + /> + {/if} {/each}
- {username}@realms:~$ + {username}@{realmId || 'global'}:~$ { + const isConnected = String(realm.realmId) === String(connected); const checked = isGlobal || selected.has(realm.realmId) ? '[✓]' : '[ ]'; + const connMarker = isConnected ? ' <--' : ''; const users = realm.participantCount || 0; - addSystemMessage(`${checked} ${realm.realmId} (${users} users)`); + addSystemMessage(`${checked} ${realm.realmId} (${users} users)${connMarker}`); }); } addSystemMessage(''); + addSystemMessage(`Connected to: ${connected || 'none'} (messages sent here)`); if (isGlobal) { - addSystemMessage('Currently: Global (all realms)'); + addSystemMessage('Viewing: Global (all realms)'); } else { const names = Array.from(selected).join(', '); - addSystemMessage(`Filtered to: ${names}`); + addSystemMessage(`Viewing: ${names}`); } addSystemMessage('========================'); } catch (error) { @@ -350,7 +355,7 @@ async function joinRealmChat(nameOrId, addSystemMessage, chatWebSocket, setRealm if (response.ok) { const data = await response.json(); targetRealmId = String(data.realm.id); - realmName = data.realm.name || nameOrId; + realmName = data.realm.displayName || data.realm.name || nameOrId; } else { addSystemMessage(`Realm "${nameOrId}" not found`); return; diff --git a/frontend/src/lib/components/watch/WatchPlaylist.svelte b/frontend/src/lib/components/watch/WatchPlaylist.svelte index 5c139d9..2fb3a28 100644 --- a/frontend/src/lib/components/watch/WatchPlaylist.svelte +++ b/frontend/src/lib/components/watch/WatchPlaylist.svelte @@ -322,7 +322,7 @@