diff --git a/backend/src/common/CryptoUtils.h b/backend/src/common/CryptoUtils.h new file mode 100644 index 0000000..56d8686 --- /dev/null +++ b/backend/src/common/CryptoUtils.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include +#include + +namespace crypto_utils { + +// Convert raw bytes to lowercase hex string +inline std::string bytesToHex(const unsigned char* data, size_t length) { + std::stringstream ss; + for (size_t i = 0; i < length; ++i) { + ss << std::hex << std::setw(2) << std::setfill('0') << static_cast(data[i]); + } + return ss.str(); +} + +// Overload for std::array +template +inline std::string bytesToHex(const std::array& data) { + return bytesToHex(data.data(), N); +} + +} // namespace crypto_utils diff --git a/backend/src/common/FileUtils.h b/backend/src/common/FileUtils.h index 80c0a1f..5d31a0b 100644 --- a/backend/src/common/FileUtils.h +++ b/backend/src/common/FileUtils.h @@ -10,6 +10,7 @@ #include #include #include +#include "CryptoUtils.h" // Generate cryptographically secure random hex filename with extension // Uses /dev/urandom for secure randomness instead of std::mt19937 @@ -22,22 +23,17 @@ inline std::string generateRandomFilename(const std::string& ext) { ssize_t bytesRead = read(fd, bytes.data(), bytes.size()); close(fd); if (bytesRead == static_cast(bytes.size())) { - std::stringstream ss; - for (unsigned char b : bytes) { - ss << std::hex << std::setw(2) << std::setfill('0') << static_cast(b); - } - return ss.str() + "." + ext; + return crypto_utils::bytesToHex(bytes) + "." + ext; } } // Fallback to std::random_device if /dev/urandom fails // (shouldn't happen on Linux, but provides resilience) std::random_device rd; - std::stringstream ss; - for (int i = 0; i < 16; ++i) { - ss << std::hex << std::setw(2) << std::setfill('0') << (rd() & 0xFF); + for (size_t i = 0; i < bytes.size(); ++i) { + bytes[i] = static_cast(rd() & 0xFF); } - return ss.str() + "." + ext; + return crypto_utils::bytesToHex(bytes) + "." + ext; } // Atomically create a file with exclusive access (O_CREAT | O_EXCL) diff --git a/backend/src/common/HttpHelpers.h b/backend/src/common/HttpHelpers.h index 921e9e7..b3eb3a1 100644 --- a/backend/src/common/HttpHelpers.h +++ b/backend/src/common/HttpHelpers.h @@ -1,9 +1,43 @@ #pragma once #include +#include #include +#include using namespace drogon; +// Pagination helper +struct PaginationParams { + int page; + int limit; + int offset; +}; + +inline PaginationParams parsePagination(const HttpRequestPtr& req, int defaultLimit = 20, int maxLimit = 50) { + PaginationParams params; + params.page = 1; + params.limit = defaultLimit; + + auto pageParam = req->getParameter("page"); + auto limitParam = req->getParameter("limit"); + + if (!pageParam.empty()) { + try { params.page = std::max(1, std::stoi(pageParam)); } catch (...) {} + } + if (!limitParam.empty()) { + try { params.limit = std::min(std::stoi(limitParam), maxLimit); } catch (...) {} + } + + params.offset = (params.page - 1) * params.limit; + return params; +} + +// RTMP URL validation +inline bool isValidRtmpUrl(const std::string& url) { + return url.length() >= 7 && + (url.substr(0, 7) == "rtmp://" || url.substr(0, 8) == "rtmps://"); +} + inline HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) { auto r = HttpResponse::newHttpJsonResponse(j); r->setStatusCode(c); diff --git a/backend/src/controllers/AudioController.cpp b/backend/src/controllers/AudioController.cpp index 56f6fb0..c78cfe6 100644 --- a/backend/src/controllers/AudioController.cpp +++ b/backend/src/controllers/AudioController.cpp @@ -113,20 +113,7 @@ namespace { void AudioController::getAllAudio(const HttpRequestPtr &req, std::function &&callback) { - int page = 1; - int limit = 20; - - auto pageParam = req->getParameter("page"); - auto limitParam = req->getParameter("limit"); - - if (!pageParam.empty()) { - try { page = std::stoi(pageParam); } catch (...) {} - } - if (!limitParam.empty()) { - try { limit = std::min(std::stoi(limitParam), 50); } catch (...) {} - } - - int offset = (page - 1) * limit; + auto pagination = parsePagination(req, 20, 50); auto dbClient = app().getDbClient(); *dbClient << "SELECT a.id, a.title, a.description, a.file_path, a.thumbnail_path, " @@ -139,7 +126,7 @@ void AudioController::getAllAudio(const HttpRequestPtr &req, "WHERE a.is_public = true AND a.status = 'ready' " "ORDER BY a.created_at DESC " "LIMIT $1 OFFSET $2" - << static_cast(limit) << static_cast(offset) + << static_cast(pagination.limit) << static_cast(pagination.offset) >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; @@ -287,20 +274,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req, } // Optional pagination (backwards compatible - no params = all results, but capped at 500) - int page = 1; - int limit = 500; // Default max to prevent huge responses - - auto pageParam = req->getParameter("page"); - auto limitParam = req->getParameter("limit"); - - if (!pageParam.empty()) { - try { page = std::max(1, std::stoi(pageParam)); } catch (...) {} - } - if (!limitParam.empty()) { - try { limit = std::min(std::max(1, std::stoi(limitParam)), 500); } catch (...) {} - } - - int offset = (page - 1) * limit; + auto pagination = parsePagination(req, 500, 500); auto dbClient = app().getDbClient(); *dbClient << "SELECT r.id, r.name, r.description, r.realm_type, r.title_color, r.created_at, " @@ -309,7 +283,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req, "JOIN users u ON r.user_id = u.id " "WHERE r.id = $1 AND r.is_active = true AND r.realm_type = 'audio'" << id - >> [callback, dbClient, id, limit, offset](const Result& realmResult) { + >> [callback, dbClient, id, pagination](const Result& realmResult) { if (realmResult.empty()) { callback(jsonError("Audio realm not found", k404NotFound)); return; @@ -321,7 +295,7 @@ void AudioController::getRealmAudio(const HttpRequestPtr &req, "WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' " "ORDER BY a.created_at DESC " "LIMIT $2 OFFSET $3" - << id << static_cast(limit) << static_cast(offset) + << id << static_cast(pagination.limit) << static_cast(pagination.offset) >> [callback, realmResult](const Result& r) { Json::Value resp; resp["success"] = true; @@ -369,20 +343,7 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req, } // Optional pagination - int page = 1; - int limit = 500; - - auto pageParam = req->getParameter("page"); - auto limitParam = req->getParameter("limit"); - - if (!pageParam.empty()) { - try { page = std::max(1, std::stoi(pageParam)); } catch (...) {} - } - if (!limitParam.empty()) { - try { limit = std::min(std::max(1, std::stoi(limitParam)), 500); } catch (...) {} - } - - int offset = (page - 1) * limit; + auto pagination = parsePagination(req, 500, 500); auto dbClient = app().getDbClient(); *dbClient << "SELECT r.id, r.name, r.description, r.realm_type, r.title_color, r.created_at, " @@ -391,7 +352,7 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req, "JOIN users u ON r.user_id = u.id " "WHERE LOWER(r.name) = LOWER($1) AND r.is_active = true AND r.realm_type = 'audio'" << realmName - >> [callback, dbClient, limit, offset](const Result& realmResult) { + >> [callback, dbClient, pagination](const Result& realmResult) { if (realmResult.empty()) { callback(jsonError("Audio realm not found", k404NotFound)); return; @@ -405,7 +366,7 @@ void AudioController::getRealmAudioByName(const HttpRequestPtr &req, "WHERE a.realm_id = $1 AND a.is_public = true AND a.status = 'ready' " "ORDER BY a.created_at DESC " "LIMIT $2 OFFSET $3" - << realmId << static_cast(limit) << static_cast(offset) + << realmId << static_cast(pagination.limit) << static_cast(pagination.offset) >> [callback, realmResult](const Result& r) { Json::Value resp; resp["success"] = true; diff --git a/backend/src/controllers/EbookController.cpp b/backend/src/controllers/EbookController.cpp index 8927a02..b15b4c0 100644 --- a/backend/src/controllers/EbookController.cpp +++ b/backend/src/controllers/EbookController.cpp @@ -22,20 +22,7 @@ using namespace drogon::orm; void EbookController::getAllEbooks(const HttpRequestPtr &req, std::function &&callback) { - int page = 1; - int limit = 20; - - auto pageParam = req->getParameter("page"); - auto limitParam = req->getParameter("limit"); - - if (!pageParam.empty()) { - try { page = std::stoi(pageParam); } catch (...) {} - } - if (!limitParam.empty()) { - try { limit = std::min(std::stoi(limitParam), 50); } catch (...) {} - } - - int offset = (page - 1) * limit; + auto pagination = parsePagination(req, 20, 50); auto dbClient = app().getDbClient(); *dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, " @@ -48,7 +35,7 @@ void EbookController::getAllEbooks(const HttpRequestPtr &req, "WHERE e.is_public = true AND e.status = 'ready' " "ORDER BY e.created_at DESC " "LIMIT $1 OFFSET $2" - << static_cast(limit) << static_cast(offset) + << static_cast(pagination.limit) << static_cast(pagination.offset) >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; diff --git a/backend/src/controllers/RealmController.cpp b/backend/src/controllers/RealmController.cpp index c7a73e6..0b44914 100644 --- a/backend/src/controllers/RealmController.cpp +++ b/backend/src/controllers/RealmController.cpp @@ -6,6 +6,7 @@ #include "../services/CensorService.h" #include "../common/HttpHelpers.h" #include "../common/AuthHelpers.h" +#include "../common/CryptoUtils.h" #include #include #include @@ -28,12 +29,7 @@ namespace { LOG_ERROR << "Failed to generate cryptographically secure random bytes"; throw std::runtime_error("Failed to generate secure stream key"); } - - std::stringstream ss; - for (size_t i = 0; i < sizeof(bytes); ++i) { - ss << std::hex << std::setw(2) << std::setfill('0') << static_cast(bytes[i]); - } - return ss.str(); + return crypto_utils::bytesToHex(bytes, sizeof(bytes)); } bool validateRealmName(const std::string& name) { diff --git a/backend/src/controllers/RestreamController.cpp b/backend/src/controllers/RestreamController.cpp index 630bdab..0037ac9 100644 --- a/backend/src/controllers/RestreamController.cpp +++ b/backend/src/controllers/RestreamController.cpp @@ -121,7 +121,7 @@ void RestreamController::addDestination(const HttpRequestPtr &req, } // Validate RTMP URL format - if (rtmpUrl.substr(0, 7) != "rtmp://" && rtmpUrl.substr(0, 8) != "rtmps://") { + if (!isValidRtmpUrl(rtmpUrl)) { callback(jsonError("RTMP URL must start with rtmp:// or rtmps://")); return; } @@ -208,7 +208,7 @@ void RestreamController::updateDestination(const HttpRequestPtr &req, callback(jsonError("RTMP URL must be 1-500 characters")); return; } - if (rtmpUrl.substr(0, 7) != "rtmp://" && rtmpUrl.substr(0, 8) != "rtmps://") { + if (!isValidRtmpUrl(rtmpUrl)) { callback(jsonError("RTMP URL must start with rtmp:// or rtmps://")); return; } diff --git a/backend/src/controllers/UserController.cpp b/backend/src/controllers/UserController.cpp index e9ae93e..692ed52 100644 --- a/backend/src/controllers/UserController.cpp +++ b/backend/src/controllers/UserController.cpp @@ -5,6 +5,7 @@ #include "../common/AuthHelpers.h" #include "../common/FileUtils.h" #include "../common/FileValidation.h" +#include "../common/CryptoUtils.h" #include #include #include @@ -23,52 +24,38 @@ namespace { std::string hashApiKey(const std::string& apiKey) { unsigned char hash[SHA256_DIGEST_LENGTH]; SHA256(reinterpret_cast(apiKey.c_str()), apiKey.length(), hash); - - std::stringstream ss; - for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) { - ss << std::hex << std::setfill('0') << std::setw(2) << static_cast(hash[i]); - } - return ss.str(); + return crypto_utils::bytesToHex(hash, SHA256_DIGEST_LENGTH); } - // Helper to set httpOnly access token cookie (2.5 hours to match JWT expiry) + // Parameterized cookie helper + void setCookie(const HttpResponsePtr& resp, const std::string& name, + const std::string& value, int maxAge) { + Cookie cookie(name, value); + cookie.setPath("/"); + cookie.setHttpOnly(true); + cookie.setSecure(false); // Set to true in production with HTTPS + cookie.setMaxAge(maxAge); + cookie.setSameSite(Cookie::SameSite::kLax); + resp->addCookie(cookie); + } + + constexpr int AUTH_COOKIE_MAX_AGE = 9000; // 2.5 hours + constexpr int REFRESH_COOKIE_MAX_AGE = 90 * 24 * 60 * 60; // 90 days + void setAuthCookie(const HttpResponsePtr& resp, const std::string& token) { - Cookie authCookie("auth_token", token); - authCookie.setPath("/"); - authCookie.setHttpOnly(true); - authCookie.setSecure(false); // Set to true in production with HTTPS - authCookie.setMaxAge(9000); // 2.5 hours (150 minutes, matches JWT expiry) - authCookie.setSameSite(Cookie::SameSite::kLax); - resp->addCookie(authCookie); + setCookie(resp, "auth_token", token, AUTH_COOKIE_MAX_AGE); } - // Helper to set httpOnly refresh token cookie (long-lived: 90 days) void setRefreshCookie(const HttpResponsePtr& resp, const std::string& token) { - Cookie refreshCookie("refresh_token", token); - refreshCookie.setPath("/"); - refreshCookie.setHttpOnly(true); - refreshCookie.setSecure(false); // Set to true in production with HTTPS - refreshCookie.setMaxAge(90 * 24 * 60 * 60); // 90 days - refreshCookie.setSameSite(Cookie::SameSite::kLax); - resp->addCookie(refreshCookie); + setCookie(resp, "refresh_token", token, REFRESH_COOKIE_MAX_AGE); } - // Helper to clear auth cookie void clearAuthCookie(const HttpResponsePtr& resp) { - Cookie authCookie("auth_token", ""); - authCookie.setPath("/"); - authCookie.setHttpOnly(true); - authCookie.setMaxAge(0); // Expire immediately - resp->addCookie(authCookie); + setCookie(resp, "auth_token", "", 0); } - // Helper to clear refresh cookie void clearRefreshCookie(const HttpResponsePtr& resp) { - Cookie refreshCookie("refresh_token", ""); - refreshCookie.setPath("/"); - refreshCookie.setHttpOnly(true); - refreshCookie.setMaxAge(0); // Expire immediately - resp->addCookie(refreshCookie); + setCookie(resp, "refresh_token", "", 0); } } @@ -1307,7 +1294,7 @@ void UserController::getBotApiKeys(const HttpRequestPtr &req, Json::Value key; key["id"] = static_cast(row["id"].as()); key["name"] = row["name"].as(); - key["scopes"] = row["scopes"].isNull() ? "chat:write" : row["scopes"].as(); + key["scopes"] = row["scopes"].isNull() ? "chat:rw" : row["scopes"].as(); key["createdAt"] = row["created_at"].as(); key["lastUsedAt"] = row["last_used_at"].isNull() ? "" : row["last_used_at"].as(); key["expiresAt"] = row["expires_at"].isNull() ? "" : row["expires_at"].as(); @@ -1386,12 +1373,7 @@ void UserController::createBotApiKey(const HttpRequestPtr &req, callback(jsonError("Failed to generate secure API key", k500InternalServerError)); return; } - std::stringstream ss; - ss << "key_"; - for (int i = 0; i < 32; i++) { - ss << std::hex << std::setfill('0') << std::setw(2) << static_cast(bytes[i]); - } - std::string apiKey = ss.str(); + std::string apiKey = "key_" + crypto_utils::bytesToHex(bytes, sizeof(bytes)); // SECURITY FIX: Hash the API key before storing (plaintext never stored) std::string apiKeyHash = hashApiKey(apiKey); diff --git a/backend/src/controllers/VideoController.cpp b/backend/src/controllers/VideoController.cpp index 623bff6..6f841f5 100644 --- a/backend/src/controllers/VideoController.cpp +++ b/backend/src/controllers/VideoController.cpp @@ -269,21 +269,7 @@ namespace { void VideoController::getAllVideos(const HttpRequestPtr &req, std::function &&callback) { - // Get pagination parameters - int page = 1; - int limit = 20; - - auto pageParam = req->getParameter("page"); - auto limitParam = req->getParameter("limit"); - - if (!pageParam.empty()) { - try { page = std::stoi(pageParam); } catch (...) {} - } - if (!limitParam.empty()) { - try { limit = std::min(std::stoi(limitParam), 50); } catch (...) {} - } - - int offset = (page - 1) * limit; + auto pagination = parsePagination(req, 20, 50); auto dbClient = app().getDbClient(); *dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, " @@ -296,7 +282,7 @@ void VideoController::getAllVideos(const HttpRequestPtr &req, "WHERE v.is_public = true AND v.status = 'ready' " "ORDER BY v.created_at DESC " "LIMIT $1 OFFSET $2" - << static_cast(limit) << static_cast(offset) + << static_cast(pagination.limit) << static_cast(pagination.offset) >> [callback](const Result& r) { Json::Value resp; resp["success"] = true; diff --git a/backend/src/services/DatabaseService.cpp b/backend/src/services/DatabaseService.cpp index c2f7ec4..2e56a44 100644 --- a/backend/src/services/DatabaseService.cpp +++ b/backend/src/services/DatabaseService.cpp @@ -1,5 +1,6 @@ #include "DatabaseService.h" #include "../services/RedisHelper.h" +#include "../common/CryptoUtils.h" #include #include #include @@ -27,12 +28,7 @@ namespace { LOG_ERROR << "Failed to generate cryptographically secure random bytes"; throw std::runtime_error("Failed to generate secure stream key"); } - - std::stringstream ss; - for (size_t i = 0; i < sizeof(bytes); ++i) { - ss << std::hex << std::setw(2) << std::setfill('0') << static_cast(bytes[i]); - } - return ss.str(); + return crypto_utils::bytesToHex(bytes, sizeof(bytes)); } } diff --git a/chat-service/src/common/CryptoUtils.h b/chat-service/src/common/CryptoUtils.h new file mode 100644 index 0000000..56d8686 --- /dev/null +++ b/chat-service/src/common/CryptoUtils.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include +#include +#include + +namespace crypto_utils { + +// Convert raw bytes to lowercase hex string +inline std::string bytesToHex(const unsigned char* data, size_t length) { + std::stringstream ss; + for (size_t i = 0; i < length; ++i) { + ss << std::hex << std::setw(2) << std::setfill('0') << static_cast(data[i]); + } + return ss.str(); +} + +// Overload for std::array +template +inline std::string bytesToHex(const std::array& data) { + return bytesToHex(data.data(), N); +} + +} // namespace crypto_utils diff --git a/chat-service/src/controllers/ChatWebSocketController.cpp b/chat-service/src/controllers/ChatWebSocketController.cpp index a4d3d76..0771143 100644 --- a/chat-service/src/controllers/ChatWebSocketController.cpp +++ b/chat-service/src/controllers/ChatWebSocketController.cpp @@ -3,6 +3,7 @@ #include "../services/AuthService.h" #include "../services/ModerationService.h" #include "../services/RedisMessageStore.h" +#include "../common/CryptoUtils.h" #include #include #include @@ -117,6 +118,28 @@ void ChatWebSocketController::broadcastParticipantLeft(const std::string& realmI } } +// Generate browser fingerprint from request headers +std::string ChatWebSocketController::generateFingerprint(const HttpRequestPtr& req, + const WebSocketConnectionPtr& wsConnPtr) { + std::string fingerprint = req->getHeader("X-Server-Fingerprint"); + if (!fingerprint.empty()) { + return fingerprint; + } + + std::string clientIp = req->getHeader("X-Real-IP"); + if (clientIp.empty()) { + clientIp = wsConnPtr->peerAddr().toIp(); + } + std::string userAgent = req->getHeader("User-Agent"); + std::string acceptLang = req->getHeader("Accept-Language"); + + std::string toHash = clientIp + "|" + userAgent + "|" + acceptLang; + + unsigned char hash[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(toHash.c_str()), toHash.length(), hash); + return crypto_utils::bytesToHex(hash, SHA256_DIGEST_LENGTH); +} + void ChatWebSocketController::handleNewConnection(const HttpRequestPtr& req, const WebSocketConnectionPtr& wsConnPtr) { LOG_INFO << "New WebSocket connection from " << wsConnPtr->peerAddr().toIpPort(); @@ -188,26 +211,7 @@ void ChatWebSocketController::handleNewConnection(const HttpRequestPtr& req, LOG_WARN << "User " << info.username << " has pending uberban - capturing fingerprint and applying ban"; // Generate fingerprint NOW (only for pending_uberban users) - std::string fingerprint = req->getHeader("X-Server-Fingerprint"); - if (fingerprint.empty()) { - std::string clientIp = req->getHeader("X-Real-IP"); - if (clientIp.empty()) { - clientIp = wsConnPtr->peerAddr().toIp(); - } - std::string userAgent = req->getHeader("User-Agent"); - std::string acceptLang = req->getHeader("Accept-Language"); - - std::string toHash = clientIp + "|" + userAgent + "|" + acceptLang; - - unsigned char hash[SHA256_DIGEST_LENGTH]; - SHA256(reinterpret_cast(toHash.c_str()), toHash.length(), hash); - - std::stringstream ss; - for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) { - ss << std::hex << std::setw(2) << std::setfill('0') << static_cast(hash[i]); - } - fingerprint = ss.str(); - } + std::string fingerprint = generateFingerprint(req, wsConnPtr); // Add fingerprint to banned set redis.addFingerprintBan(fingerprint); @@ -248,32 +252,12 @@ void ChatWebSocketController::handleNewConnection(const HttpRequestPtr& req, } // Generate fingerprint for guests (always needed for guest moderation) - std::string fingerprint = req->getHeader("X-Server-Fingerprint"); - if (fingerprint.empty()) { - std::string clientIp = req->getHeader("X-Real-IP"); - if (clientIp.empty()) { - clientIp = wsConnPtr->peerAddr().toIp(); - } - std::string userAgent = req->getHeader("User-Agent"); - std::string acceptLang = req->getHeader("Accept-Language"); - - std::string toHash = clientIp + "|" + userAgent + "|" + acceptLang; - - unsigned char hash[SHA256_DIGEST_LENGTH]; - SHA256(reinterpret_cast(toHash.c_str()), toHash.length(), hash); - - std::stringstream ss; - for (int i = 0; i < SHA256_DIGEST_LENGTH; i++) { - ss << std::hex << std::setw(2) << std::setfill('0') << static_cast(hash[i]); - } - fingerprint = ss.str(); - } - info.fingerprint = fingerprint; + info.fingerprint = generateFingerprint(req, wsConnPtr); // Check fingerprint ban for guests auto& redis = services::RedisMessageStore::getInstance(); - if (redis.isFingerprintBanned(fingerprint)) { - LOG_WARN << "Guest connection rejected - fingerprint banned: " << fingerprint.substr(0, 8) << "..."; + if (redis.isFingerprintBanned(info.fingerprint)) { + LOG_WARN << "Guest connection rejected - fingerprint banned: " << info.fingerprint.substr(0, 8) << "..."; Json::Value error; error["type"] = "error"; error["error"] = "You have been banned from chat."; @@ -550,7 +534,8 @@ void ChatWebSocketController::handleChatMessage(const WebSocketConnectionPtr& ws auto result = chatService.sendMessage( currentInfo.realmId, currentInfo.userId, currentInfo.username, userColor, avatarUrl, content, currentInfo.isGuest, currentInfo.isModerator, currentInfo.isStreamer, message, selfDestructSeconds, - currentInfo.isApiKeyConnection // SECURITY FIX: Pass bot flag for rate limiting + currentInfo.isApiKeyConnection, // SECURITY FIX: Pass bot flag for rate limiting + currentInfo.apiKeyId // SECURITY FIX: Rate limit per API key, not per user ); if (result == services::SendMessageResult::SUCCESS) { @@ -1408,6 +1393,42 @@ void ChatWebSocketController::checkGuestTimeouts() { } } +void ChatWebSocketController::checkPendingConnectionTimeouts() { + constexpr int PENDING_TIMEOUT_SECONDS = 15; + auto now = std::chrono::steady_clock::now(); + std::vector toDisconnect; + + { + std::lock_guard lock(connectionsMutex_); + for (const auto& [conn, startTime] : pendingConnections_) { + auto elapsed = std::chrono::duration_cast( + now - startTime + ).count(); + + if (elapsed >= PENDING_TIMEOUT_SECONDS) { + toDisconnect.push_back(conn); + } + } + + // Remove timed-out connections from pending map + for (const auto& conn : toDisconnect) { + pendingConnections_.erase(conn); + } + } + + // Disconnect outside the lock to avoid holding it while sending messages + for (const auto& conn : toDisconnect) { + Json::Value msg; + msg["type"] = "error"; + msg["error"] = "API key validation timed out. Please reconnect and try again."; + try { + conn->send(Json::writeString(Json::StreamWriterBuilder(), msg)); + } catch (...) {} + conn->shutdown(CloseCode::kViolation); + LOG_WARN << "Pending bot connection timed out after " << PENDING_TIMEOUT_SECONDS << " seconds - disconnecting"; + } +} + // Debug: Static initializer to verify this file is loaded static struct ChatWebSocketControllerLoader { ChatWebSocketControllerLoader() { diff --git a/chat-service/src/controllers/ChatWebSocketController.h b/chat-service/src/controllers/ChatWebSocketController.h index 3ae367a..f9a67ce 100644 --- a/chat-service/src/controllers/ChatWebSocketController.h +++ b/chat-service/src/controllers/ChatWebSocketController.h @@ -99,6 +99,9 @@ private: static void broadcastParticipantJoined(const std::string& realmId, const ConnectionInfo& joinedUser); static void broadcastParticipantLeft(const std::string& realmId, const std::string& userId, const std::string& username); + // Generate browser fingerprint from request headers + static std::string generateFingerprint(const HttpRequestPtr& req, const WebSocketConnectionPtr& wsConnPtr); + public: // Internal API: Try to uberban a user by ID (used by backend admin endpoint) // Returns: fingerprint if user was connected and banned, empty string if not connected diff --git a/chat-service/src/main.cpp b/chat-service/src/main.cpp index 6f1bbb8..01d47af 100644 --- a/chat-service/src/main.cpp +++ b/chat-service/src/main.cpp @@ -121,6 +121,12 @@ int main() { }); LOG_INFO << "Guest session timeout checker registered (45-123 minute random timeout)"; + // Register pending bot connection timeout checker (runs every 5 seconds) + app().getLoop()->runEvery(5.0, []() { + ChatWebSocketController::checkPendingConnectionTimeouts(); + }); + LOG_INFO << "Pending bot connection timeout checker registered (15 second timeout)"; + // Schedule sticker fetch (must be done here, after event loop is set up) stickerService.scheduleFetch(); diff --git a/chat-service/src/services/ChatService.cpp b/chat-service/src/services/ChatService.cpp index 0230404..1c0fd65 100644 --- a/chat-service/src/services/ChatService.cpp +++ b/chat-service/src/services/ChatService.cpp @@ -31,13 +31,14 @@ SendMessageResult ChatService::sendMessage(const std::string& realmId, bool isStreamer, models::ChatMessage& outMessage, int selfDestructSeconds, - bool isBot) { + bool isBot, + int64_t botApiKeyId) { auto& redis = RedisMessageStore::getInstance(); auto& modService = ModerationService::getInstance(); - // SECURITY FIX: Bot rate limiting (1 message per second) - if (isBot) { - if (!canBotSendMessage(userId)) { + // SECURITY FIX: Bot rate limiting (1 message per second per API key) + if (isBot && botApiKeyId > 0) { + if (!canBotSendMessage(botApiKeyId)) { return SendMessageResult::BOT_RATE_LIMITED; } } @@ -276,22 +277,22 @@ void ChatService::cleanupMessages() { } } -// SECURITY FIX: Bot rate limiting implementation -bool ChatService::canBotSendMessage(const std::string& botUserId) { +// SECURITY FIX: Bot rate limiting implementation (per API key, not per user) +bool ChatService::canBotSendMessage(int64_t apiKeyId) { std::lock_guard lock(botRateLimitMutex_); auto now = std::chrono::steady_clock::now(); - auto it = botLastMessage_.find(botUserId); + auto it = botLastMessage_.find(apiKeyId); if (it != botLastMessage_.end()) { auto elapsed = std::chrono::duration_cast(now - it->second).count(); if (elapsed < BOT_RATE_LIMIT_MS) { - LOG_DEBUG << "Bot " << botUserId << " rate limited (only " << elapsed << "ms since last message)"; + LOG_DEBUG << "Bot API key " << apiKeyId << " rate limited (only " << elapsed << "ms since last message)"; return false; } } - botLastMessage_[botUserId] = now; + botLastMessage_[apiKeyId] = now; return true; } diff --git a/chat-service/src/services/ChatService.h b/chat-service/src/services/ChatService.h index 0d55e26..9763446 100644 --- a/chat-service/src/services/ChatService.h +++ b/chat-service/src/services/ChatService.h @@ -39,7 +39,8 @@ public: bool isStreamer, models::ChatMessage& outMessage, int selfDestructSeconds = 0, - bool isBot = false); // SECURITY FIX: Bot rate limiting + bool isBot = false, + int64_t botApiKeyId = 0); // SECURITY FIX: Bot rate limiting per API key // Schedule a message for self-destruction void scheduleSelfDestruct(const std::string& realmId, const std::string& messageId, int delaySeconds); @@ -73,11 +74,11 @@ private: bool isContentValid(const std::string& content); void cleanupMessages(); - // SECURITY FIX: Bot rate limiting (1 message per second per bot) + // SECURITY FIX: Bot rate limiting (1 message per second per API key) static constexpr int BOT_RATE_LIMIT_MS = 1000; // 1 second between messages - std::unordered_map botLastMessage_; + std::unordered_map botLastMessage_; std::mutex botRateLimitMutex_; - bool canBotSendMessage(const std::string& botUserId); + bool canBotSendMessage(int64_t apiKeyId); }; } // namespace services diff --git a/frontend/src/app.css b/frontend/src/app.css index a0ab34c..510e65c 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -200,7 +200,6 @@ button:disabled { .nav { background: #000; - padding: 1rem 0; } .nav-container { diff --git a/frontend/src/lib/components/screensavers/FractalCrystalline.svelte b/frontend/src/lib/components/screensavers/FractalCrystalline.svelte index d7927d4..983a748 100644 --- a/frontend/src/lib/components/screensavers/FractalCrystalline.svelte +++ b/frontend/src/lib/components/screensavers/FractalCrystalline.svelte @@ -5,23 +5,25 @@ let canvas; let ctx; let animationId; - let particles = []; - let crystal = new Set(); // Stored as "x,y" strings for O(1) lookup let width, height; - let centerX, centerY; let hue = 0; let phase = 'growing'; // 'growing' | 'shattering' | 'dissolving' - let shatterParticles = []; let phaseStartTime = 0; + let branches = []; // Active growing branch tips + let crystalPoints = []; // All drawn points for shatter effect + let shatterParticles = []; const CONFIG = { - particleCount: 500, // Active random walkers - particleSpeed: 3, // Movement speed - stickDistance: 2, // Distance to attach to crystal - maxCrystalSize: 8000, // Max crystal points before shatter - hueShiftSpeed: 0.3, // Color cycling speed - shatterDuration: 2000, // Milliseconds for shatter effect - dissolveDuration: 2000 // Milliseconds for dissolve effect + seedCount: 5, // Number of initial seed points + branchSpeed: 2, // Pixels per frame + maxBranches: 800, // Max simultaneous branch tips + branchChance: 0.03, // Chance to spawn new branch per frame + turnAngle: 0.3, // Max random turn per frame (radians) + hueShiftSpeed: 0.2, // Color cycling speed + maxPoints: 15000, // Max crystal points before shatter + shatterDuration: 2500, // Milliseconds for shatter effect + dissolveDuration: 2000, // Milliseconds for dissolve effect + lineWidth: 2 }; function initCanvas() { @@ -31,47 +33,40 @@ canvas.width = width; canvas.height = height; ctx = canvas.getContext('2d'); - centerX = Math.floor(width / 2); - centerY = Math.floor(height / 2); } function initCrystal() { - crystal.clear(); - particles = []; + branches = []; + crystalPoints = []; shatterParticles = []; phase = 'growing'; phaseStartTime = performance.now(); - // Seed crystal at center with a small cluster - for (let dx = -2; dx <= 2; dx++) { - for (let dy = -2; dy <= 2; dy++) { - crystal.add(`${centerX + dx},${centerY + dy}`); - } - } - - // Initialize random walkers from edges - for (let i = 0; i < CONFIG.particleCount; i++) { - particles.push(createParticle()); - } - // Clear canvas if (ctx) { ctx.fillStyle = 'black'; ctx.fillRect(0, 0, width, height); } - } - function createParticle() { - // Spawn from random edge - const edge = Math.floor(Math.random() * 4); - let x, y; - switch (edge) { - case 0: x = Math.random() * width; y = 0; break; - case 1: x = width; y = Math.random() * height; break; - case 2: x = Math.random() * width; y = height; break; - case 3: x = 0; y = Math.random() * height; break; + // Create seed points at random locations + for (let i = 0; i < CONFIG.seedCount; i++) { + const x = Math.random() * width; + const y = Math.random() * height; + + // Each seed spawns multiple branches in different directions + const branchCount = 3 + Math.floor(Math.random() * 4); + for (let j = 0; j < branchCount; j++) { + const angle = (Math.PI * 2 * j) / branchCount + Math.random() * 0.5; + branches.push({ + x, + y, + angle, + hue: Math.random() * 360, + age: 0, + generation: 0 + }); + } } - return { x, y, hue: Math.random() * 360 }; } function update() { @@ -87,51 +82,90 @@ function updateGrowing() { hue = (hue + CONFIG.hueShiftSpeed) % 360; - for (let i = particles.length - 1; i >= 0; i--) { - const p = particles[i]; + const newBranches = []; - // Random walk toward center with bias - const dx = centerX - p.x; - const dy = centerY - p.y; - const dist = Math.sqrt(dx * dx + dy * dy); + for (let i = branches.length - 1; i >= 0; i--) { + const branch = branches[i]; - // Biased random walk (DLA with drift) - p.x += (Math.random() - 0.5) * CONFIG.particleSpeed * 2 + (dx / dist) * 0.5; - p.y += (Math.random() - 0.5) * CONFIG.particleSpeed * 2 + (dy / dist) * 0.5; + // Random walk - slight angle change + branch.angle += (Math.random() - 0.5) * CONFIG.turnAngle; - // Check for crystallization - if (shouldCrystallize(p)) { - const px = Math.round(p.x); - const py = Math.round(p.y); - crystal.add(`${px},${py}`); - particles[i] = createParticle(); // Respawn + // Calculate new position + const newX = branch.x + Math.cos(branch.angle) * CONFIG.branchSpeed; + const newY = branch.y + Math.sin(branch.angle) * CONFIG.branchSpeed; + + // Check bounds - kill branch if out of screen + if (newX < 0 || newX > width || newY < 0 || newY > height) { + branches.splice(i, 1); + continue; } - // Respawn if out of bounds - if (p.x < 0 || p.x > width || p.y < 0 || p.y > height) { - particles[i] = createParticle(); + // Store point for shatter effect + crystalPoints.push({ + x: newX, + y: newY, + hue: (branch.hue + branch.age * 0.5) % 360 + }); + + // Draw the branch segment + const h = (branch.hue + branch.age * 0.5) % 360; + ctx.strokeStyle = `hsla(${h}, 80%, 60%, 0.9)`; + ctx.lineWidth = CONFIG.lineWidth; + ctx.beginPath(); + ctx.moveTo(branch.x, branch.y); + ctx.lineTo(newX, newY); + ctx.stroke(); + + // Update branch position + branch.x = newX; + branch.y = newY; + branch.age++; + + // Chance to spawn a new branch (fork) + if (Math.random() < CONFIG.branchChance && branches.length + newBranches.length < CONFIG.maxBranches) { + const forkAngle = branch.angle + (Math.random() > 0.5 ? 1 : -1) * (0.3 + Math.random() * 0.7); + newBranches.push({ + x: newX, + y: newY, + angle: forkAngle, + hue: (branch.hue + 20 + Math.random() * 40) % 360, + age: 0, + generation: branch.generation + 1 + }); + } + + // Chance to die (increases with age and generation) + const deathChance = 0.001 + branch.age * 0.0001 + branch.generation * 0.002; + if (Math.random() < deathChance) { + branches.splice(i, 1); } } - // Check if crystal is full - if (crystal.size > CONFIG.maxCrystalSize) { + // Add new branches + branches.push(...newBranches); + + // Check if we should shatter (too many points or no more branches) + if (crystalPoints.length > CONFIG.maxPoints || (branches.length === 0 && crystalPoints.length > 100)) { startShatter(); } - } - function shouldCrystallize(p) { - const px = Math.round(p.x); - const py = Math.round(p.y); - - // Check neighbors - for (let dx = -CONFIG.stickDistance; dx <= CONFIG.stickDistance; dx++) { - for (let dy = -CONFIG.stickDistance; dy <= CONFIG.stickDistance; dy++) { - if (crystal.has(`${px + dx},${py + dy}`)) { - return true; - } + // Spawn new seeds occasionally if branches are dying off + if (branches.length < 10 && crystalPoints.length < CONFIG.maxPoints * 0.5) { + const x = Math.random() * width; + const y = Math.random() * height; + const branchCount = 2 + Math.floor(Math.random() * 3); + for (let j = 0; j < branchCount; j++) { + const angle = Math.random() * Math.PI * 2; + branches.push({ + x, + y, + angle, + hue: Math.random() * 360, + age: 0, + generation: 0 + }); } } - return false; } function startShatter() { @@ -140,15 +174,18 @@ shatterParticles = []; // Convert crystal points to shatter particles - for (const key of crystal) { - const [x, y] = key.split(',').map(Number); - const angle = Math.atan2(y - centerY, x - centerX); - const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); + // Sample points to avoid too many particles + const step = Math.max(1, Math.floor(crystalPoints.length / 3000)); + for (let i = 0; i < crystalPoints.length; i += step) { + const p = crystalPoints[i]; + const angle = Math.random() * Math.PI * 2; + const speed = 1 + Math.random() * 4; shatterParticles.push({ - x, y, - vx: Math.cos(angle) * (2 + Math.random() * 4), - vy: Math.sin(angle) * (2 + Math.random() * 4), - hue: (hue + dist * 0.3) % 360, + x: p.x, + y: p.y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + hue: p.hue, alpha: 1, size: 2 + Math.random() * 2 }); @@ -158,10 +195,22 @@ function updateShattering() { const elapsed = performance.now() - phaseStartTime; + // Clear with slight fade for trail effect + ctx.fillStyle = 'rgba(0, 0, 0, 0.1)'; + ctx.fillRect(0, 0, width, height); + for (const p of shatterParticles) { p.x += p.vx; p.y += p.vy; + p.vy += 0.05; // Slight gravity p.alpha = Math.max(0, 1 - (elapsed / CONFIG.shatterDuration)); + + if (p.alpha > 0) { + ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha})`; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + } } if (elapsed >= CONFIG.shatterDuration) { @@ -173,8 +222,20 @@ function updateDissolving() { const elapsed = performance.now() - phaseStartTime; + ctx.fillStyle = 'rgba(0, 0, 0, 0.08)'; + ctx.fillRect(0, 0, width, height); + for (const p of shatterParticles) { + p.x += p.vx * 0.5; + p.y += p.vy * 0.5; p.alpha = Math.max(0, 1 - (elapsed / CONFIG.dissolveDuration)); + + if (p.alpha > 0.01) { + ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha * 0.5})`; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size * 0.8, 0, Math.PI * 2); + ctx.fill(); + } } if (elapsed >= CONFIG.dissolveDuration) { @@ -182,51 +243,9 @@ } } - function draw() { - // Semi-transparent overlay for trail effect - ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'; - ctx.fillRect(0, 0, width, height); - - if (phase === 'growing') { - drawCrystal(); - drawParticles(); - } else { - drawShatterParticles(); - } - } - - function drawCrystal() { - for (const key of crystal) { - const [x, y] = key.split(',').map(Number); - const dist = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); - const h = (hue + dist * 0.3) % 360; - - ctx.fillStyle = `hsla(${h}, 80%, 60%, 0.9)`; - ctx.fillRect(x - 1, y - 1, 3, 3); - } - } - - function drawParticles() { - ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'; - for (const p of particles) { - ctx.fillRect(p.x, p.y, 2, 2); - } - } - - function drawShatterParticles() { - for (const p of shatterParticles) { - if (p.alpha <= 0) continue; - ctx.fillStyle = `hsla(${p.hue}, 80%, 60%, ${p.alpha})`; - ctx.beginPath(); - ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); - ctx.fill(); - } - } - function startAnimation() { function loop() { update(); - draw(); animationId = requestAnimationFrame(loop); } loop(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index a83d8fb..df4c41e 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -97,7 +97,6 @@ .nav { background: #000; - padding: var(--nav-padding-y) 0; } .nav-container {