Initial commit - realms platform

This commit is contained in:
doomtube 2026-01-05 22:54:27 -05:00
parent c590ab6d18
commit c717c3751c
234 changed files with 74103 additions and 15231 deletions

View file

@ -0,0 +1,129 @@
#include "ChatAdminController.h"
#include "../services/ChatService.h"
#include "../services/AuthService.h"
#include "../services/RedisMessageStore.h"
#include "../services/StickerService.h"
#include "../services/CensorService.h"
#include <json/json.h>
void ChatAdminController::getGlobalSettings(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
// Verify admin
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !claims->isAdmin) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& chatService = services::ChatService::getInstance();
auto settings = chatService.getGlobalSettings();
auto resp = HttpResponse::newHttpJsonResponse(settings.toJson());
callback(resp);
}
void ChatAdminController::updateGlobalSettings(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
// Verify admin
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !claims->isAdmin) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto settings = models::GlobalChatSettings::fromJson(*jsonPtr);
auto& chatService = services::ChatService::getInstance();
bool success = chatService.updateGlobalSettings(settings);
Json::Value response;
response["success"] = success;
response["settings"] = settings.toJson();
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ChatAdminController::getStats(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
// Verify admin
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !claims->isAdmin) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
// TODO: Collect comprehensive chat statistics
Json::Value stats;
stats["totalMessages"] = 0;
stats["activeConnections"] = 0;
stats["totalRealms"] = 0;
auto resp = HttpResponse::newHttpJsonResponse(stats);
callback(resp);
}
void ChatAdminController::refreshStickers(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
// Internal endpoint called by backend after sticker modifications
// No auth required - only accessible within Docker network
LOG_INFO << "Sticker cache refresh requested";
auto& stickerService = services::StickerService::getInstance();
stickerService.refreshCache();
Json::Value response;
response["success"] = true;
response["message"] = "Sticker cache refresh initiated";
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ChatAdminController::refreshCensoredWords(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
// Internal endpoint called by backend after censored words modifications
// No auth required - only accessible within Docker network
LOG_INFO << "Censored words cache refresh requested";
auto& censorService = services::CensorService::getInstance();
censorService.invalidateCache();
Json::Value response;
response["success"] = true;
response["message"] = "Censored words cache refresh initiated";
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}

View file

@ -0,0 +1,21 @@
#pragma once
#include <drogon/HttpController.h>
using namespace drogon;
class ChatAdminController : public drogon::HttpController<ChatAdminController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(ChatAdminController::getGlobalSettings, "/api/chat/admin/settings", Get);
ADD_METHOD_TO(ChatAdminController::updateGlobalSettings, "/api/chat/admin/settings", Put);
ADD_METHOD_TO(ChatAdminController::getStats, "/api/chat/admin/stats", Get);
ADD_METHOD_TO(ChatAdminController::refreshStickers, "/api/chat/admin/stickers/refresh", Post);
ADD_METHOD_TO(ChatAdminController::refreshCensoredWords, "/api/chat/admin/censored-words/refresh", Post);
METHOD_LIST_END
void getGlobalSettings(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void updateGlobalSettings(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void getStats(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void refreshStickers(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void refreshCensoredWords(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
};

View file

@ -0,0 +1,184 @@
#include "ChatController.h"
#include "ChatWebSocketController.h"
#include "../services/ChatService.h"
#include "../services/AuthService.h"
#include <json/json.h>
void ChatController::getMessages(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& realmId) {
auto& chatService = services::ChatService::getInstance();
auto limitParam = req->getParameter("limit");
int limit = limitParam.empty() ? 100 : std::stoi(limitParam);
auto beforeParam = req->getParameter("before");
int64_t before = beforeParam.empty() ? 0 : std::stoll(beforeParam);
auto messages = chatService.getRealmMessages(realmId, limit, before);
Json::Value response;
response["messages"] = Json::arrayValue;
for (const auto& msg : messages) {
response["messages"].append(msg.toJson());
}
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ChatController::sendMessage(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
// This endpoint is mainly for testing; WebSocket is preferred
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
auto& json = *jsonPtr;
std::string realmId = json.get("realmId", "").asString();
std::string content = json.get("content", "").asString();
if (realmId.empty() || content.empty()) {
Json::Value error;
error["error"] = "Missing required fields";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
// Get user from token
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) {
token = token.substr(7);
}
auto claims = authService.verifyToken(token);
if (!claims.has_value()) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& chatService = services::ChatService::getInstance();
models::ChatMessage message;
auto result = chatService.sendMessage(
realmId, claims->userId, claims->username, claims->userColor, claims->avatarUrl, content,
false, false, false, message
);
if (result == services::SendMessageResult::SUCCESS) {
auto resp = HttpResponse::newHttpJsonResponse(message.toJson());
callback(resp);
} else {
Json::Value error;
error["error"] = "Failed to send message";
error["reason"] = static_cast<int>(result);
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
}
}
void ChatController::deleteMessage(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& messageId) {
// Verify user is moderator
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) {
token = token.substr(7);
}
auto claims = authService.verifyToken(token);
if (!claims.has_value()) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
std::string realmId = req->getParameter("realmId");
auto& chatService = services::ChatService::getInstance();
bool success = chatService.deleteMessage(realmId, messageId, claims->userId);
Json::Value response;
response["success"] = success;
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ChatController::getSettings(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& realmId) {
auto& chatService = services::ChatService::getInstance();
auto settings = chatService.getRealmSettings(realmId);
auto resp = HttpResponse::newHttpJsonResponse(settings.toJson());
callback(resp);
}
void ChatController::updateSettings(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& realmId) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
// Verify user is realm owner or moderator
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) {
token = token.substr(7);
}
auto claims = authService.verifyToken(token);
if (!claims.has_value()) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto settings = models::ChatSettings::fromJson(*jsonPtr);
settings.realmId = realmId;
auto& chatService = services::ChatService::getInstance();
bool success = chatService.updateRealmSettings(realmId, settings);
Json::Value response;
response["success"] = success;
response["settings"] = settings.toJson();
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ChatController::getRealmStats(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
// Get realm stats from WebSocket controller (active connections per realm)
auto stats = ChatWebSocketController::getRealmStats();
auto resp = HttpResponse::newHttpJsonResponse(stats);
callback(resp);
}

View file

@ -0,0 +1,38 @@
#pragma once
#include <drogon/HttpController.h>
using namespace drogon;
class ChatController : public drogon::HttpController<ChatController> {
public:
METHOD_LIST_BEGIN
ADD_METHOD_TO(ChatController::getMessages, "/api/chat/messages/{realmId}", Get);
ADD_METHOD_TO(ChatController::sendMessage, "/api/chat/send", Post);
ADD_METHOD_TO(ChatController::deleteMessage, "/api/chat/message/{messageId}", Delete);
ADD_METHOD_TO(ChatController::getSettings, "/api/chat/settings/{realmId}", Get);
ADD_METHOD_TO(ChatController::updateSettings, "/api/chat/settings/{realmId}", Put);
ADD_METHOD_TO(ChatController::getRealmStats, "/api/chat/realms/stats", Get);
METHOD_LIST_END
void getMessages(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& realmId);
void sendMessage(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback);
void deleteMessage(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& messageId);
void getSettings(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& realmId);
void updateSettings(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& realmId);
void getRealmStats(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,103 @@
#pragma once
#include <drogon/WebSocketController.h>
#include <drogon/PubSubService.h>
#include <unordered_map>
#include <memory>
#include <chrono>
using namespace drogon;
class ChatWebSocketController : public drogon::WebSocketController<ChatWebSocketController> {
public:
void handleNewMessage(const WebSocketConnectionPtr& wsConnPtr, std::string&& message,
const WebSocketMessageType& type) override;
void handleNewConnection(const HttpRequestPtr& req, const WebSocketConnectionPtr& wsConnPtr) override;
void handleConnectionClosed(const WebSocketConnectionPtr& wsConnPtr) override;
WS_PATH_LIST_BEGIN
WS_PATH_ADD("/chat/ws");
WS_PATH_ADD("/chat/stream/{1}");
WS_PATH_LIST_END
static void broadcastMessage(const std::string& realmId, const Json::Value& message);
static void broadcastToRealm(const std::string& realmId, const Json::Value& message);
static void broadcastToUser(const std::string& userId, const Json::Value& message);
// Get stats for all active realms (realmId -> participant count)
static Json::Value getRealmStats();
// Force linker to include this object file
static void ensureLoaded();
// Check and disconnect guests that have exceeded their session timeout
static void checkGuestTimeouts();
private:
struct ConnectionInfo {
std::string realmId;
std::string userId;
std::string username;
std::string userColor;
std::string avatarUrl;
std::string fingerprint; // Browser fingerprint for guests (empty for registered users)
bool isGuest = false;
bool isAdmin = false; // Site admin (from JWT)
bool isSiteModerator = false; // Site-wide moderator role (from JWT is_moderator claim)
bool isModerator = false; // Has mod powers in current realm (computed: admin/siteMod/realmOwner/realmMod)
bool isStreamer = false;
bool isRestreamer = false; // SECURITY FIX #9: Added for auth message handling
bool isApiKeyConnection = false; // Bot API key connections (can only send/receive, no mod actions)
int64_t apiKeyId = 0; // API key ID for bot connections (for connection limit tracking)
std::string botScopes; // Scopes for bot connections (e.g., "chat:rw")
std::chrono::system_clock::time_point connectionTime; // When this connection was established
int sessionTimeoutMinutes = 0; // Random timeout for guests (0 = no timeout)
};
static std::unordered_map<WebSocketConnectionPtr, ConnectionInfo> connections_;
static std::unordered_map<WebSocketConnectionPtr, std::chrono::steady_clock::time_point> pendingConnections_; // SECURITY FIX: Track pending API key validations
static std::unordered_map<int64_t, WebSocketConnectionPtr> apiKeyConnections_; // SECURITY FIX: Track 1 connection per API key
static std::unordered_map<WebSocketConnectionPtr, std::chrono::steady_clock::time_point> lastRenameTime_; // SECURITY FIX #24: Rate limit guest renames
static std::unordered_map<std::string, WebSocketConnectionPtr> usernameToConnection_; // SECURITY FIX #25: O(1) username collision lookup
static std::mutex connectionsMutex_;
void handleChatMessage(const WebSocketConnectionPtr& wsConnPtr,
const ConnectionInfo& info,
const Json::Value& data);
void handleJoinRealm(const WebSocketConnectionPtr& wsConnPtr,
ConnectionInfo& info,
const Json::Value& data);
void handleModAction(const WebSocketConnectionPtr& wsConnPtr,
const ConnectionInfo& info,
const Json::Value& data);
void handleGetParticipants(const WebSocketConnectionPtr& wsConnPtr,
const ConnectionInfo& info);
void handleRename(const WebSocketConnectionPtr& wsConnPtr,
ConnectionInfo& info,
const Json::Value& data);
// SECURITY FIX #9: Handle auth token sent as message instead of URL param
void handleAuthMessage(const WebSocketConnectionPtr& wsConnPtr,
const Json::Value& data);
// SECURITY FIX: Handle bot API key authentication via message (not URL)
void handleBotApiKeyAuth(const WebSocketConnectionPtr& wsConnPtr,
const std::string& apiKey);
void sendError(const WebSocketConnectionPtr& wsConnPtr, const std::string& error);
void sendSuccess(const WebSocketConnectionPtr& wsConnPtr, const Json::Value& data);
// Broadcast participant events (caller must hold connectionsMutex_)
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);
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
static std::string tryUberbanConnectedUser(const std::string& userId);
};

View file

@ -0,0 +1,392 @@
#include "ModerationController.h"
#include "ChatWebSocketController.h"
#include "../services/ModerationService.h"
#include "../services/AuthService.h"
#include <json/json.h>
// Helper to check if user can perform uberban (admins + site moderators only)
static bool canUberban(const services::UserClaims& claims) {
return claims.isAdmin || claims.isModerator;
}
// Helper to check if user can perform realm moderation (admin, site mod, or has mod flag)
static bool canModerate(const services::UserClaims& claims) {
return claims.isAdmin || claims.isModerator;
// TODO: Add realm owner and per-realm moderator checks via backend API
}
void ModerationController::uberbanUser(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
auto& json = *jsonPtr;
std::string fingerprint = json.get("fingerprint", "").asString();
std::string reason = json.get("reason", "").asString();
if (fingerprint.empty()) {
Json::Value error;
error["error"] = "Fingerprint required";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
// Verify admin or site moderator
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !canUberban(*claims)) {
Json::Value error;
error["error"] = "Unauthorized - only admins and site moderators can uberban";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& modService = services::ModerationService::getInstance();
bool success = modService.uberbanUser(fingerprint, claims->userId, reason);
Json::Value response;
response["success"] = success;
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ModerationController::unUberbanUser(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
auto& json = *jsonPtr;
std::string fingerprint = json.get("fingerprint", "").asString();
if (fingerprint.empty()) {
Json::Value error;
error["error"] = "Fingerprint required";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !canUberban(*claims)) {
Json::Value error;
error["error"] = "Unauthorized - only admins and site moderators can remove uberbans";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& modService = services::ModerationService::getInstance();
bool success = modService.unUberbanUser(fingerprint, claims->userId);
Json::Value response;
response["success"] = success;
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ModerationController::getUberbannedUsers(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !canUberban(*claims)) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& modService = services::ModerationService::getInstance();
auto uberbannedFingerprints = modService.getUberbannedFingerprints();
Json::Value response;
response["uberbannedFingerprints"] = Json::arrayValue;
for (const auto& fp : uberbannedFingerprints) {
response["uberbannedFingerprints"].append(fp);
}
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ModerationController::banUser(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
auto& json = *jsonPtr;
std::string realmId = json.get("realmId", "").asString();
std::string targetUserId = json.get("targetUserId", "").asString();
std::string guestFingerprint = json.get("fingerprint", "").asString();
std::string reason = json.get("reason", "").asString();
// Verify moderator
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !canModerate(*claims)) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& modService = services::ModerationService::getInstance();
bool success = modService.banUserFromRealm(realmId, targetUserId, claims->userId, reason, guestFingerprint);
Json::Value response;
response["success"] = success;
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ModerationController::unbanUser(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
auto& json = *jsonPtr;
std::string realmId = json.get("realmId", "").asString();
std::string targetUserId = json.get("targetUserId", "").asString();
std::string guestFingerprint = json.get("fingerprint", "").asString();
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !canModerate(*claims)) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& modService = services::ModerationService::getInstance();
bool success = modService.unbanUserFromRealm(realmId, targetUserId, claims->userId, guestFingerprint);
Json::Value response;
response["success"] = success;
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ModerationController::getBannedUsers(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& realmId) {
auto& modService = services::ModerationService::getInstance();
auto bannedIdentifiers = modService.getRealmBannedIdentifiers(realmId);
Json::Value response;
response["bannedUsers"] = Json::arrayValue;
for (const auto& identifier : bannedIdentifiers) {
response["bannedUsers"].append(identifier);
}
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ModerationController::kickUser(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
auto& json = *jsonPtr;
std::string realmId = json.get("realmId", "").asString();
std::string targetUserId = json.get("targetUserId", "").asString();
std::string reason = json.get("reason", "").asString();
int duration = json.get("duration", 60).asInt(); // Default 1 minute
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !canModerate(*claims)) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& modService = services::ModerationService::getInstance();
bool success = modService.kickUser(realmId, targetUserId, claims->userId, reason, duration);
Json::Value response;
response["success"] = success;
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ModerationController::muteUser(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
auto& json = *jsonPtr;
std::string realmId = json.get("realmId", "").asString();
std::string targetUserId = json.get("targetUserId", "").asString();
int duration = json.get("duration", 0).asInt(); // 0 = permanent (default)
std::string reason = json.get("reason", "").asString();
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !canModerate(*claims)) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& modService = services::ModerationService::getInstance();
bool success = modService.muteUser(realmId, targetUserId, claims->userId, duration, reason);
Json::Value response;
response["success"] = success;
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
void ModerationController::unmuteUser(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback) {
auto jsonPtr = req->getJsonObject();
if (!jsonPtr) {
Json::Value error;
error["error"] = "Invalid JSON";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k400BadRequest);
callback(resp);
return;
}
auto& json = *jsonPtr;
std::string realmId = json.get("realmId", "").asString();
std::string targetUserId = json.get("targetUserId", "").asString();
auto& authService = services::AuthService::getInstance();
auto token = req->getHeader("Authorization");
if (token.find("Bearer ") == 0) token = token.substr(7);
auto claims = authService.verifyToken(token);
if (!claims.has_value() || !canModerate(*claims)) {
Json::Value error;
error["error"] = "Unauthorized";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
callback(resp);
return;
}
auto& modService = services::ModerationService::getInstance();
bool success = modService.unmuteUser(realmId, targetUserId, claims->userId);
Json::Value response;
response["success"] = success;
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}
// Internal API: Called by backend to uberban a user
// Option C: All registered user uberbans are deferred - fingerprint captured on reconnect
// If user is connected: disconnect them + set pending_uberban (fingerprint captured on reconnect)
// If not connected: backend should set pending_uberban
// Returns { disconnected: true/false }
void ModerationController::internalUberbanUser(const HttpRequestPtr& req,
std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& userId) {
// This is an internal API - no auth check needed (only accessible from within Docker network)
// The backend AdminController handles authentication before calling this
// Try to disconnect the user if they're connected (sets pending_uberban + disconnects)
std::string result = ChatWebSocketController::tryUberbanConnectedUser(userId);
Json::Value response;
if (result == "disconnected") {
// User was connected - disconnected and pending_uberban set
// Fingerprint will be captured on reconnect
response["disconnected"] = true;
response["immediate"] = false; // Fingerprint captured on reconnect
LOG_INFO << "Internal uberban: User " << userId << " was connected, disconnected (pending uberban)";
} else {
// User not connected - caller should set pending_uberban
response["disconnected"] = false;
response["immediate"] = false;
LOG_INFO << "Internal uberban: User " << userId << " not connected, pending uberban needed";
}
auto resp = HttpResponse::newHttpJsonResponse(response);
callback(resp);
}

View file

@ -0,0 +1,46 @@
#pragma once
#include <drogon/HttpController.h>
using namespace drogon;
class ModerationController : public drogon::HttpController<ModerationController> {
public:
METHOD_LIST_BEGIN
// Site-wide uberban (admins + site moderators only)
ADD_METHOD_TO(ModerationController::uberbanUser, "/api/chat/uberban", Post);
ADD_METHOD_TO(ModerationController::unUberbanUser, "/api/chat/unuberban", Post);
ADD_METHOD_TO(ModerationController::getUberbannedUsers, "/api/chat/uberbanned", Get);
// Per-realm ban (admins, site mods, realm owners, realm mods)
ADD_METHOD_TO(ModerationController::banUser, "/api/chat/ban", Post);
ADD_METHOD_TO(ModerationController::unbanUser, "/api/chat/unban", Post);
ADD_METHOD_TO(ModerationController::getBannedUsers, "/api/chat/banned/{realmId}", Get);
// Kick (admins, site mods, realm owners, realm mods)
ADD_METHOD_TO(ModerationController::kickUser, "/api/chat/kick", Post);
// Mute (admins, site mods, realm owners, realm mods)
ADD_METHOD_TO(ModerationController::muteUser, "/api/chat/mute", Post);
ADD_METHOD_TO(ModerationController::unmuteUser, "/api/chat/unmute", Post);
// Internal endpoint for backend to uberban a connected user (gets fingerprint from active connection)
ADD_METHOD_TO(ModerationController::internalUberbanUser, "/api/internal/user/{userId}/uberban", Post);
METHOD_LIST_END
void uberbanUser(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void unUberbanUser(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void getUberbannedUsers(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void banUser(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void unbanUser(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void getBannedUsers(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& realmId);
void kickUser(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void muteUser(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void unmuteUser(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback);
void internalUberbanUser(const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback,
const std::string& userId);
};

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,152 @@
#pragma once
#include <drogon/WebSocketController.h>
#include <unordered_map>
#include <mutex>
#include <chrono>
#include <thread>
#include <atomic>
using namespace drogon;
class WatchSyncController : public drogon::WebSocketController<WatchSyncController> {
public:
void handleNewMessage(const WebSocketConnectionPtr& wsConnPtr, std::string&& message,
const WebSocketMessageType& type) override;
void handleNewConnection(const HttpRequestPtr& req, const WebSocketConnectionPtr& wsConnPtr) override;
void handleConnectionClosed(const WebSocketConnectionPtr& wsConnPtr) override;
WS_PATH_LIST_BEGIN
WS_PATH_ADD("/watch/ws");
WS_PATH_LIST_END
// Broadcast sync state to all viewers in a watch room
static void broadcastToRoom(const std::string& realmId, const Json::Value& message);
// Get viewer count for a room
static int getViewerCount(const std::string& realmId);
// Force linker to include this object file
static void ensureLoaded();
// Start/stop the sync loop (called on first connection and when last viewer leaves)
static void startSyncLoop();
static void stopSyncLoop();
private:
struct ViewerInfo {
std::string realmId;
std::string userId;
std::string username;
std::string authToken; // Store auth token for backend API calls
bool canAddToPlaylist = false;
bool canControlPlayback = false;
bool isGuest = false;
std::chrono::system_clock::time_point connectionTime;
// Rate limiting fields
int64_t lastMessageMs = 0; // Timestamp of last message
int messageCount = 0; // Message count in current window
int64_t windowStartMs = 0; // Start of rate limit window
};
// In-memory room state for accurate time tracking (CyTube-style)
struct RoomState {
std::string playbackState = "paused"; // "playing", "paused", "ended", "buffering"
double currentTime = 0.0; // Current playback position in seconds
int64_t lastUpdateMs = 0; // Timestamp of last update (milliseconds)
std::string currentVideoId; // YouTube video ID
int64_t currentPlaylistItemId = 0; // Playlist item ID
std::string currentVideoTitle;
int durationSeconds = 0;
bool leadInActive = false; // True during initial buffering period
int64_t leadInStartMs = 0; // When lead-in started
int repeatCount = 0; // Current repeat count for last video (max 3)
bool isRepeating = false; // True when in repeat mode (last video looping)
bool currentVideoLocked = false; // True if current video is locked (loops forever)
// Skip idempotency tracking
int64_t lastSkipMs = 0; // Timestamp of last skip (prevents double-skip)
uint64_t stateVersion = 0; // State version for sync validation
// State freshness tracking
int64_t lastDbSyncMs = 0; // Last time state was synced from database
};
static std::unordered_map<WebSocketConnectionPtr, ViewerInfo> viewers_;
static std::mutex viewersMutex_;
// In-memory room states (keyed by realmId)
static std::unordered_map<std::string, RoomState> roomStates_;
static std::mutex roomStatesMutex_;
// Sync loop thread
static std::thread syncLoopThread_;
static std::atomic<bool> syncLoopRunning_;
static std::mutex syncLoopMutex_; // Protects thread start/stop operations
// Sync loop - runs every second to update time and broadcast
static void syncLoop();
// Update room state from database (called when joining or on state change)
static void updateRoomStateFromDb(const std::string& realmId);
// Broadcast current state to all viewers in a room
static void broadcastRoomSync(const std::string& realmId);
// Auto-advance to next video when current video ends (server-side, no owner required)
static void autoAdvanceToNextVideo(const std::string& realmId);
// Get current expected playback time for a room
static double getExpectedTime(const RoomState& state);
void handleJoinRoom(const WebSocketConnectionPtr& wsConnPtr,
ViewerInfo& info,
const Json::Value& data);
void handleSyncRequest(const WebSocketConnectionPtr& wsConnPtr,
const ViewerInfo& info);
void handlePlaybackControl(const WebSocketConnectionPtr& wsConnPtr,
const ViewerInfo& info,
const Json::Value& data);
void handleSkipWithRepeat(const WebSocketConnectionPtr& wsConnPtr,
const ViewerInfo& info,
const Json::Value& data);
void performSkip(const WebSocketConnectionPtr& wsConnPtr,
const ViewerInfo& info,
const Json::Value& data);
void handleUpdateDuration(const WebSocketConnectionPtr& wsConnPtr,
const ViewerInfo& info,
const Json::Value& data);
void sendError(const WebSocketConnectionPtr& wsConnPtr, const std::string& error);
void sendSuccess(const WebSocketConnectionPtr& wsConnPtr, const Json::Value& data);
// Helper to check if connection still exists (for async callback safety)
static bool isConnectionValid(const WebSocketConnectionPtr& wsConnPtr);
// Helper to safely send to a connection (validates first)
static void safeSend(const WebSocketConnectionPtr& wsConnPtr, const std::string& message);
// Broadcast viewer count update
static void broadcastViewerCount(const std::string& realmId);
// Rate limiting check (returns true if message should be processed)
bool checkRateLimit(const WebSocketConnectionPtr& wsConnPtr);
// Constants for rate limiting
static constexpr int RATE_LIMIT_MESSAGES = 30; // Max messages per window
static constexpr int64_t RATE_LIMIT_WINDOW_MS = 10000; // 10 second window
static constexpr int64_t MIN_MESSAGE_INTERVAL_MS = 100; // Min 100ms between messages
// Skip debounce interval (prevent double-skips within this window)
static constexpr int64_t SKIP_DEBOUNCE_MS = 1000;
// Database sync freshness threshold (refresh from DB if older than this)
static constexpr int64_t DB_SYNC_STALE_MS = 5000;
};