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,7 @@
build/
.git/
.gitignore
*.md
.vscode/
.idea/
*.log

View file

@ -0,0 +1,55 @@
cmake_minimum_required(VERSION 3.15)
project(chat_service CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Find required packages
find_package(Drogon CONFIG REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(JSONCPP REQUIRED jsoncpp)
# Source files
set(SOURCES
src/main.cpp
src/controllers/ChatController.cpp
src/controllers/ChatWebSocketController.cpp
src/controllers/ModerationController.cpp
src/controllers/ChatAdminController.cpp
src/controllers/WatchSyncController.cpp
src/services/ChatService.cpp
src/services/RedisMessageStore.cpp
src/services/AuthService.cpp
src/services/ModerationService.cpp
src/services/StickerService.cpp
src/services/CensorService.cpp
src/middleware/ChatAuthMiddleware.cpp
)
# Create executable
add_executable(${PROJECT_NAME} ${SOURCES})
# Link libraries
target_link_libraries(${PROJECT_NAME} PRIVATE
Drogon::Drogon
hiredis
redis++
${JSONCPP_LIBRARIES}
ssl
crypto
z
uuid
pthread
)
# Include directories
target_include_directories(${PROJECT_NAME} PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
${JSONCPP_INCLUDE_DIRS}
)
# Set output directory
set_target_properties(${PROJECT_NAME} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin
)

87
chat-service/Dockerfile Normal file
View file

@ -0,0 +1,87 @@
FROM drogonframework/drogon:latest
WORKDIR /app
# Install additional dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
git \
cmake \
libhiredis-dev \
curl \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Try to install redis-plus-plus from package manager first
RUN apt-get update && \
(apt-get install -y libredis++-dev || echo "Package not available") && \
rm -rf /var/lib/apt/lists/*
# If redis-plus-plus wasn't available from package manager, build from source
RUN if ! pkg-config --exists redis++; then \
echo "Building redis-plus-plus from source..." && \
git clone --depth 1 https://github.com/sewenew/redis-plus-plus.git && \
cd redis-plus-plus && \
mkdir build && \
cd build && \
cmake -DCMAKE_BUILD_TYPE=Release \
-DREDIS_PLUS_PLUS_CXX_STANDARD=17 \
-DREDIS_PLUS_PLUS_BUILD_TEST=OFF \
-DREDIS_PLUS_PLUS_BUILD_STATIC=OFF \
-DCMAKE_INSTALL_PREFIX=/usr/local .. && \
make -j$(nproc) && \
make install && \
cd ../.. && \
rm -rf redis-plus-plus; \
fi
# Install jwt-cpp (header-only library)
RUN git clone --depth 1 https://github.com/Thalhammer/jwt-cpp.git && \
cd jwt-cpp && \
mkdir build && \
cd build && \
cmake .. && \
make install && \
cd ../.. && \
rm -rf jwt-cpp
# Update library cache
RUN ldconfig
# Copy source code
COPY CMakeLists.txt ./
COPY src/ src/
# Clean any previous build
RUN rm -rf build CMakeCache.txt
# Create build directory
RUN mkdir -p build
# Build the chat service
RUN cd build && \
cmake .. -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_RPATH="/usr/local/lib" \
-DCMAKE_BUILD_WITH_INSTALL_RPATH=TRUE && \
cmake --build . -j$(nproc)
# Copy configuration
COPY config.json .
# Expose port
EXPOSE 8081
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=40s \
CMD curl -f http://localhost:8081/api/chat/health 2>/dev/null || exit 1
# Create startup script
RUN echo '#!/bin/bash\n\
echo "Checking library dependencies..."\n\
ldd ./build/bin/chat_service\n\
echo "Starting chat service..."\n\
exec ./build/bin/chat_service' > start.sh && \
chmod +x start.sh
# Run the service
CMD ["./start.sh"]

331
chat-service/README.md Normal file
View file

@ -0,0 +1,331 @@
# Chat Service for realms.india
A scalable, real-time chat service built with Drogon C++ framework and Redis for the realms.india streaming platform.
## Features
- **Real-time WebSocket Communication** - Instant message delivery
- **Guest Chat Support** - Anonymous users with configurable naming patterns
- **Redis-Based Storage** - Fast, in-memory message storage with configurable retention
- **Moderation Tools** - Ban, mute, timeout, and message deletion
- **Slow Mode** - Per-realm rate limiting
- **Links Filtering** - Configurable link permission per realm
- **Global/Local Channels** - Multi-channel support
- **User Color Preservation** - Registered users maintain their platform colors
- **Auto-cleanup** - Periodic message cleanup based on retention settings
## Architecture
### Components
1. **Chat Service (Port 8081)** - Drogon-based C++ backend
- WebSocket server for real-time communication
- REST API for settings and moderation
- Redis integration for message storage
2. **Redis Database 1** - Message and moderation data
- Messages (sorted sets per realm)
- Ban/mute lists
- Chat settings
- Slow mode tracking
3. **PostgreSQL** - Persistent settings
- Realm chat configurations
- Chat moderators
- Global chat settings
### Data Flow
```
Frontend (SvelteKit)
↓ WebSocket
OpenResty (Proxy)
↓ /chat/*
Chat Service (Drogon)
Redis (Messages) + PostgreSQL (Settings)
```
## API Endpoints
### WebSocket
**Connect:** `ws://host/chat/stream/{realmId}?token=<jwt>`
**Client → Server Messages:**
```json
{
"type": "message",
"content": "Hello world!",
"userColor": "#FF5733"
}
{
"type": "join",
"realmId": "realm-uuid"
}
{
"type": "mod_action",
"action": "ban|mute|timeout|delete",
"targetUserId": "user-uuid",
"messageId": "message-uuid",
"duration": 300,
"reason": "Spam"
}
```
**Server → Client Messages:**
```json
{
"type": "welcome",
"username": "guest1234",
"userId": "guest:guest1234",
"isGuest": true,
"isModerator": false,
"realmId": "realm-uuid"
}
{
"type": "history",
"messages": [...]
}
{
"type": "new_message",
"messageId": "uuid",
"username": "user",
"content": "Hello!",
...
}
{
"type": "message_deleted",
"messageId": "uuid"
}
{
"type": "error",
"error": "You are muted"
}
```
### REST API
#### Messages
- `GET /api/chat/messages/:realmId?limit=100&before=<timestamp>` - Get messages
- `POST /api/chat/send` - Send message (alternative to WebSocket)
- `DELETE /api/chat/message/:messageId?realmId=<id>` - Delete message
#### Settings
- `GET /api/chat/settings/:realmId` - Get realm chat settings
- `PUT /api/chat/settings/:realmId` - Update realm settings (moderator only)
#### Moderation
- `POST /api/chat/ban` - Ban user from realm
- `POST /api/chat/unban` - Unban user
- `POST /api/chat/mute` - Mute user (temporary)
- `POST /api/chat/unmute` - Unmute user
- `POST /api/chat/timeout` - Timeout user (short-term mute)
- `GET /api/chat/banned/:realmId` - List banned users
#### Admin
- `GET /api/chat/admin/settings` - Get global settings
- `PUT /api/chat/admin/settings` - Update global settings
- `GET /api/chat/admin/stats` - Get chat statistics
## Configuration
### chat-service/config.json
```json
{
"redis": {
"host": "redis",
"port": 6379,
"db": 1
},
"chat": {
"default_retention_hours": 24,
"max_message_length": 500,
"max_messages_per_realm": 1000,
"guest_prefix": "guest",
"guest_id_pattern": "{prefix}{number}",
"cleanup_interval_seconds": 300
}
}
```
### Guest ID Patterns
Customize guest usernames with placeholders:
- `{prefix}` - Guest prefix (configurable)
- `{number}` - Auto-incrementing number
- `{random}` - Random 4-digit hex
Examples:
- `{prefix}{number}` → guest1, guest2, guest3...
- `anon{random}` → anon4a7f, anonb2c9...
- `friend_{number}` → friend_1, friend_2...
## Realm Settings
Customize chat behavior per realm (stored in PostgreSQL `realms` table):
- `chat_retention_hours` (default: 24) - How long to keep messages
- `chat_slow_mode_seconds` (default: 0) - Minimum seconds between messages
- `chat_links_allowed` (default: true) - Allow URLs in messages
- `chat_subscribers_only` (default: false) - Restrict chat to subscribers
## Moderation
### Moderators
- Realm creators are auto-moderators
- Admins are moderators everywhere
- Additional moderators stored in `chat_moderators` table
### Actions
1. **Ban** - Permanent, prevents all messages
2. **Mute** - Temporary silence (duration in seconds)
3. **Timeout** - Short-term mute (typically 60s)
4. **Delete Message** - Remove specific message
## Redis Schema
```
# Messages (sorted set, score = timestamp)
chat:messages:{realmId} → [{message JSON}, ...]
# Global settings
chat:settings:global → {guestPrefix, guestIdPattern, defaultRetentionHours}
# Realm settings
chat:settings:realm:{realmId} → {retentionHours, slowModeSeconds, ...}
# Bans (set)
chat:banned:{realmId} → {userId1, userId2, ...}
# Mutes (string with TTL)
chat:muted:{realmId}:{userId} → "1"
# Slow mode tracking (string with TTL)
chat:slowmode:{realmId}:{userId} → "1"
# Active users (sorted set, score = last seen)
chat:active:{realmId} → {userId: timestamp}
# Guest counter
chat:guest:counter → integer
```
## Building
### Prerequisites
- CMake 3.15+
- C++20 compiler (GCC 10+ or Clang 12+)
- Conan package manager
- Docker (for containerized build)
### Local Build
```bash
cd chat-service
# Install dependencies
conan install . --output-folder=build --build=missing -s compiler.cppstd=20
# Build
cd build
cmake .. -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake -DCMAKE_BUILD_TYPE=Release
cmake --build . -j$(nproc)
# Run
./bin/chat_service
```
### Docker Build
```bash
cd chat-service
docker build -t chat-service .
docker run -p 8081:8081 \
-e JWT_SECRET=your-secret \
-e REDIS_HOST=redis \
chat-service
```
## Deployment
The chat service is automatically included in the main docker-compose.yml:
```bash
docker-compose up -d chat-service
```
## Frontend Integration
### Basic Chat Panel
```svelte
<script>
import ChatPanel from '$lib/components/chat/ChatPanel.svelte';
export let realmId;
export let userColor = '#FFFFFF';
</script>
<ChatPanel {realmId} {userColor} />
```
### Pseudo-Terminal (~ key activation)
```svelte
<script>
import ChatTerminal from '$lib/components/chat/ChatTerminal.svelte';
</script>
<ChatTerminal defaultRealmId="realm-uuid" />
```
Add to your root layout to make it globally available.
## Security
- **JWT Authentication** - Required for registered users
- **Rate Limiting** - 20 messages/second per IP at proxy level
- **Content Validation** - Length limits, XSS prevention
- **Moderator Checks** - Permission validation for all mod actions
- **CORS** - Restricted to localhost origins in development
## Performance
- **WebSocket** - Persistent connections, minimal overhead
- **Redis** - Sub-millisecond message retrieval
- **Auto-cleanup** - Configurable retention to manage memory
- **Message Cap** - Max 1000 messages per realm (configurable)
## Monitoring
Check service health:
```bash
curl http://localhost:8081/api/chat/admin/settings
```
View logs:
```bash
docker logs realms-chat-service
```
## Future Enhancements
- [ ] Emoji support
- [ ] Message reactions
- [ ] Thread/reply support
- [ ] User mentions (@username)
- [ ] Rich media embeds
- [ ] Chat commands (/me, /shrug, etc.)
- [ ] Message search
- [ ] Whispers/DMs
- [ ] Channel creation (beyond realms)
- [ ] Chatbot integration hooks

View file

@ -0,0 +1,14 @@
[requires]
drogon/1.9.3
hiredis/1.2.0
redis-plus-plus/1.3.12
jwt-cpp/0.7.0
[generators]
CMakeDeps
CMakeToolchain
[options]
drogon/*:shared=False
drogon/*:with_postgres=False
drogon/*:with_redis=False

49
chat-service/config.json Normal file
View file

@ -0,0 +1,49 @@
{
"app": {
"threads_num": 4,
"enable_session": false,
"session_timeout": 0,
"document_root": "./",
"max_connections": 10000,
"max_connections_per_ip": 100,
"load_config_file": true,
"run_as_daemon": false,
"log_path": "",
"log_level": "INFO",
"log_size_limit": 100000000,
"load_libs": [],
"use_sendfile": true,
"use_gzip": true,
"static_files_cache_time": 0,
"load_config_in_advance": true,
"log_access": true
},
"listeners": [
{
"address": "0.0.0.0",
"port": 8081,
"https": false
}
],
"redis": {
"host": "${REDIS_HOST}",
"port": 6379,
"db": 1,
"timeout": 5
},
"jwt": {
"secret": "${JWT_SECRET}"
},
"chat": {
"default_retention_hours": 24,
"max_message_length": 500,
"max_messages_per_realm": 1000,
"guest_prefix": "guest",
"guest_id_pattern": "{prefix}{number}",
"cleanup_interval_seconds": 300
},
"backend_api": {
"host": "drogon-backend",
"port": 8080
}
}

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;
};

134
chat-service/src/main.cpp Normal file
View file

@ -0,0 +1,134 @@
#include <drogon/drogon.h>
#include <iostream>
#include "services/RedisMessageStore.h"
#include "services/AuthService.h"
#include "services/ChatService.h"
#include "services/StickerService.h"
#include "services/CensorService.h"
#include "controllers/ChatController.h"
#include "controllers/ChatWebSocketController.h"
#include "controllers/ModerationController.h"
#include "controllers/ChatAdminController.h"
#include "controllers/WatchSyncController.h"
using namespace drogon;
int main() {
// Load configuration
app().loadConfigFile("config.json");
LOG_INFO << "Starting Chat Service...";
// Get configuration values
auto config = app().getCustomConfig();
auto redisConfig = config.get("redis", Json::Value::null);
auto jwtConfig = config.get("jwt", Json::Value::null);
// Initialize Redis
auto& redis = services::RedisMessageStore::getInstance();
// Get Redis host from environment or config
std::string redisHost = redisConfig.get("host", "localhost").asString();
const char* envRedisHost = std::getenv("REDIS_HOST");
if (envRedisHost) {
redisHost = envRedisHost;
}
// Get Redis password from environment
std::string redisPass = "";
const char* envRedisPass = std::getenv("REDIS_PASS");
if (envRedisPass && strlen(envRedisPass) > 0) {
redisPass = envRedisPass;
}
redis.initialize(
redisHost,
redisConfig.get("port", 6379).asInt(),
redisConfig.get("db", 1).asInt(),
redisPass
);
// Initialize Auth Service
std::string jwtSecret = jwtConfig.get("secret", "").asString();
if (jwtSecret.empty()) {
// Try environment variable
const char* envSecret = std::getenv("JWT_SECRET");
if (envSecret) {
jwtSecret = envSecret;
} else {
LOG_ERROR << "JWT_SECRET not configured!";
return 1;
}
}
auto& authService = services::AuthService::getInstance();
authService.initialize(jwtSecret);
// Initialize Chat Service
auto& chatService = services::ChatService::getInstance();
chatService.initialize();
// Initialize Sticker Service (for :roll: and :rtd: processing)
auto& stickerService = services::StickerService::getInstance();
stickerService.initialize();
// Initialize Censor Service (for word filtering)
auto& censorService = services::CensorService::getInstance();
censorService.initialize();
LOG_INFO << "Chat initialization complete";
// Set CORS
app().registerPostHandlingAdvice([](const HttpRequestPtr& req, const HttpResponsePtr& resp) {
resp->addHeader("Access-Control-Allow-Origin", "*");
resp->addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
resp->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
resp->addHeader("Access-Control-Max-Age", "3600");
});
// Handle OPTIONS requests
app().registerPreHandlingAdvice([](const HttpRequestPtr& req, AdviceCallback&& acb, AdviceChainCallback&& accb) {
if (req->method() == Options) {
auto resp = HttpResponse::newHttpResponse();
resp->setStatusCode(k200OK);
resp->addHeader("Access-Control-Allow-Origin", "*");
resp->addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
resp->addHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
resp->addHeader("Access-Control-Max-Age", "3600");
acb(resp);
return;
}
accb();
});
// Ensure WebSocket controllers are loaded
ChatWebSocketController::ensureLoaded();
WatchSyncController::ensureLoaded();
// Explicitly register WebSocket paths (workaround for custom CMake builds)
app().registerWebSocketController("/chat/ws", "ChatWebSocketController");
app().registerWebSocketController("/chat/stream/{1}", "ChatWebSocketController");
app().registerWebSocketController("/watch/ws", "WatchSyncController");
LOG_INFO << "WebSocket paths explicitly registered";
LOG_INFO << "Chat Service initialized successfully";
LOG_INFO << "WebSocket endpoint: ws://localhost:8081/chat/stream/{realmId}";
LOG_INFO << "REST API: http://localhost:8081/api/chat/*";
// Register guest session timeout checker (runs every 5 minutes)
app().getLoop()->runEvery(300.0, []() {
ChatWebSocketController::checkGuestTimeouts();
});
LOG_INFO << "Guest session timeout checker registered (45-123 minute random timeout)";
// Schedule sticker fetch (must be done here, after event loop is set up)
stickerService.scheduleFetch();
// Schedule censored words fetch (must be done here, after event loop is set up)
censorService.scheduleFetch();
// Run the application
app().run();
return 0;
}

View file

@ -0,0 +1,46 @@
#include "ChatAuthMiddleware.h"
#include "../services/AuthService.h"
#include <json/json.h>
void ChatAuthMiddleware::doFilter(const HttpRequestPtr& req,
FilterCallback&& fcb,
FilterChainCallback&& fccb) {
// Extract token from Authorization header
auto authHeader = req->getHeader("Authorization");
if (authHeader.empty()) {
Json::Value error;
error["error"] = "Missing authorization token";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
fcb(resp);
return;
}
// Remove "Bearer " prefix if present
std::string token = authHeader;
if (token.find("Bearer ") == 0) {
token = token.substr(7);
}
// Verify token
auto& authService = services::AuthService::getInstance();
auto claims = authService.verifyToken(token);
if (!claims.has_value()) {
Json::Value error;
error["error"] = "Invalid or expired token";
auto resp = HttpResponse::newHttpJsonResponse(error);
resp->setStatusCode(k401Unauthorized);
fcb(resp);
return;
}
// Store user info in request attributes for use in controllers
req->attributes()->insert("userId", claims->userId);
req->attributes()->insert("username", claims->username);
req->attributes()->insert("isAdmin", claims->isAdmin);
// Continue to next filter or controller
fccb();
}

View file

@ -0,0 +1,11 @@
#pragma once
#include <drogon/HttpFilter.h>
using namespace drogon;
class ChatAuthMiddleware : public HttpFilter<ChatAuthMiddleware> {
public:
void doFilter(const HttpRequestPtr& req,
FilterCallback&& fcb,
FilterChainCallback&& fccb) override;
};

View file

@ -0,0 +1,179 @@
#pragma once
#include <string>
#include <json/json.h>
#include <drogon/utils/Utilities.h>
namespace models {
struct ChatMessage {
std::string messageId;
std::string realmId;
std::string userId;
std::string username;
std::string userColor;
std::string avatarUrl;
std::string content;
int64_t timestamp;
bool isGuest;
bool isModerator;
bool isStreamer;
std::string channel; // "global" or "realm:{realmId}"
int64_t selfDestructAt; // 0 = permanent, otherwise Unix timestamp (ms) when message should be deleted
bool usedRoll; // true if message originally contained :roll:
bool usedRtd; // true if message originally contained :rtd:
ChatMessage() : selfDestructAt(0), usedRoll(false), usedRtd(false) {}
ChatMessage(const std::string& realmId_, const std::string& userId_,
const std::string& username_, const std::string& userColor_,
const std::string& avatarUrl_, const std::string& content_,
bool isGuest_ = false, bool isModerator_ = false, bool isStreamer_ = false,
int selfDestructSeconds = 0, bool usedRoll_ = false, bool usedRtd_ = false)
: realmId(realmId_), userId(userId_), username(username_),
userColor(userColor_), avatarUrl(avatarUrl_), content(content_),
isGuest(isGuest_), isModerator(isModerator_), isStreamer(isStreamer_), selfDestructAt(0),
usedRoll(usedRoll_), usedRtd(usedRtd_) {
messageId = drogon::utils::getUuid();
timestamp = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()
).count();
channel = "realm:" + realmId;
// Calculate self-destruct time if specified
if (selfDestructSeconds > 0) {
selfDestructAt = timestamp + (selfDestructSeconds * 1000);
}
}
Json::Value toJson() const {
Json::Value json;
json["messageId"] = messageId;
json["realmId"] = realmId;
json["userId"] = userId;
json["username"] = username;
json["userColor"] = userColor;
if (!avatarUrl.empty()) {
json["avatarUrl"] = avatarUrl;
}
json["content"] = content;
json["timestamp"] = (Json::Int64)timestamp;
json["isGuest"] = isGuest;
json["isModerator"] = isModerator;
json["isStreamer"] = isStreamer;
json["channel"] = channel;
if (selfDestructAt > 0) {
json["selfDestructAt"] = (Json::Int64)selfDestructAt;
}
if (usedRoll) {
json["usedRoll"] = true;
}
if (usedRtd) {
json["usedRtd"] = true;
}
return json;
}
static ChatMessage fromJson(const Json::Value& json) {
ChatMessage msg;
msg.messageId = json["messageId"].asString();
msg.realmId = json["realmId"].asString();
msg.userId = json["userId"].asString();
msg.username = json["username"].asString();
msg.userColor = json["userColor"].asString();
msg.avatarUrl = json.get("avatarUrl", "").asString();
msg.content = json["content"].asString();
msg.timestamp = json["timestamp"].asInt64();
msg.isGuest = json["isGuest"].asBool();
msg.isModerator = json["isModerator"].asBool();
msg.isStreamer = json["isStreamer"].asBool();
msg.channel = json.get("channel", "realm:" + msg.realmId).asString();
msg.selfDestructAt = json.get("selfDestructAt", 0).asInt64();
msg.usedRoll = json.get("usedRoll", false).asBool();
msg.usedRtd = json.get("usedRtd", false).asBool();
return msg;
}
std::string serialize() const {
Json::StreamWriterBuilder builder;
builder["indentation"] = "";
return Json::writeString(builder, toJson());
}
static ChatMessage deserialize(const std::string& str) {
Json::CharReaderBuilder builder;
Json::Value json;
std::istringstream s(str);
std::string errs;
if (Json::parseFromStream(builder, s, &json, &errs)) {
return fromJson(json);
}
return ChatMessage();
}
};
struct ChatSettings {
std::string realmId;
int retentionHours;
int slowModeSeconds;
bool linksAllowed;
bool subscribersOnly;
bool chatGuestsAllowed;
ChatSettings() : retentionHours(24), slowModeSeconds(0),
linksAllowed(true), subscribersOnly(false),
chatGuestsAllowed(true) {}
Json::Value toJson() const {
Json::Value json;
json["realmId"] = realmId;
json["retentionHours"] = retentionHours;
json["slowModeSeconds"] = slowModeSeconds;
json["linksAllowed"] = linksAllowed;
json["subscribersOnly"] = subscribersOnly;
json["chatGuestsAllowed"] = chatGuestsAllowed;
return json;
}
static ChatSettings fromJson(const Json::Value& json) {
ChatSettings settings;
settings.realmId = json["realmId"].asString();
settings.retentionHours = json.get("retentionHours", 24).asInt();
settings.slowModeSeconds = json.get("slowModeSeconds", 0).asInt();
settings.linksAllowed = json.get("linksAllowed", true).asBool();
settings.subscribersOnly = json.get("subscribersOnly", false).asBool();
settings.chatGuestsAllowed = json.get("chatGuestsAllowed", true).asBool();
return settings;
}
};
struct GlobalChatSettings {
std::string guestPrefix;
int defaultRetentionHours;
bool guestsAllowedSiteWide;
bool registrationEnabled;
GlobalChatSettings() : guestPrefix("Guest"),
defaultRetentionHours(24),
guestsAllowedSiteWide(true),
registrationEnabled(true) {}
Json::Value toJson() const {
Json::Value json;
json["guestPrefix"] = guestPrefix;
json["defaultRetentionHours"] = defaultRetentionHours;
json["guestsAllowedSiteWide"] = guestsAllowedSiteWide;
json["registrationEnabled"] = registrationEnabled;
return json;
}
static GlobalChatSettings fromJson(const Json::Value& json) {
GlobalChatSettings settings;
settings.guestPrefix = json.get("guestPrefix", "Guest").asString();
settings.defaultRetentionHours = json.get("defaultRetentionHours", 24).asInt();
settings.guestsAllowedSiteWide = json.get("guestsAllowedSiteWide", true).asBool();
settings.registrationEnabled = json.get("registrationEnabled", true).asBool();
return settings;
}
};
} // namespace models

View file

@ -0,0 +1,67 @@
#pragma once
#include <string>
#include <chrono>
#include <json/json.h>
namespace models {
enum class ModActionType {
BAN, // Legacy (per-realm user ID ban)
UNBAN, // Legacy (per-realm user ID unban)
MUTE, // Mute user (permanent by default, optional duration)
UNMUTE,
DELETE_MESSAGE,
UBERBAN, // Site-wide fingerprint ban (harsh)
UNUBERBAN, // Remove site-wide fingerprint ban
REALM_BAN, // Per-realm ban (user ID or fingerprint)
REALM_UNBAN, // Remove per-realm ban
KICK // Temporary disconnect + rejoin block
};
struct ModerationAction {
ModActionType type;
std::string realmId;
std::string targetUserId;
std::string moderatorId;
std::string reason;
int64_t duration; // in seconds, 0 for permanent
int64_t timestamp;
ModerationAction() = default;
ModerationAction(ModActionType type_, const std::string& realmId_,
const std::string& targetUserId_, const std::string& moderatorId_,
const std::string& reason_ = "", int64_t duration_ = 0)
: type(type_), realmId(realmId_), targetUserId(targetUserId_),
moderatorId(moderatorId_), reason(reason_), duration(duration_) {
timestamp = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()
).count();
}
Json::Value toJson() const {
Json::Value json;
json["type"] = static_cast<int>(type);
json["realmId"] = realmId;
json["targetUserId"] = targetUserId;
json["moderatorId"] = moderatorId;
json["reason"] = reason;
json["duration"] = (Json::Int64)duration;
json["timestamp"] = (Json::Int64)timestamp;
return json;
}
static ModerationAction fromJson(const Json::Value& json) {
ModerationAction action;
action.type = static_cast<ModActionType>(json["type"].asInt());
action.realmId = json["realmId"].asString();
action.targetUserId = json["targetUserId"].asString();
action.moderatorId = json["moderatorId"].asString();
action.reason = json.get("reason", "").asString();
action.duration = json.get("duration", 0).asInt64();
action.timestamp = json["timestamp"].asInt64();
return action;
}
};
} // namespace models

View file

@ -0,0 +1,73 @@
#include "AuthService.h"
#include <drogon/drogon.h>
namespace services {
AuthService& AuthService::getInstance() {
static AuthService instance;
return instance;
}
void AuthService::initialize(const std::string& jwtSecret) {
jwtSecret_ = jwtSecret;
LOG_INFO << "AuthService initialized";
}
std::optional<UserClaims> AuthService::verifyToken(const std::string& token) {
try {
auto decoded = jwt::decode(token);
auto verifier = jwt::verify()
.allow_algorithm(jwt::algorithm::hs256{jwtSecret_})
.with_issuer("streaming-app");
verifier.verify(decoded);
UserClaims claims;
claims.userId = decoded.get_payload_claim("user_id").as_string();
claims.username = decoded.get_payload_claim("username").as_string();
// Optional claims
if (decoded.has_payload_claim("color_code")) {
claims.userColor = decoded.get_payload_claim("color_code").as_string();
}
if (decoded.has_payload_claim("avatar_url")) {
claims.avatarUrl = decoded.get_payload_claim("avatar_url").as_string();
}
if (decoded.has_payload_claim("is_admin")) {
// Backend sends "1" or "0" as strings
auto adminStr = decoded.get_payload_claim("is_admin").as_string();
claims.isAdmin = (adminStr == "1");
}
if (decoded.has_payload_claim("is_moderator")) {
// Backend sends "1" or "0" as strings
auto modStr = decoded.get_payload_claim("is_moderator").as_string();
claims.isModerator = (modStr == "1");
}
if (decoded.has_payload_claim("is_streamer")) {
// Backend sends "1" or "0" as strings
auto streamerStr = decoded.get_payload_claim("is_streamer").as_string();
claims.isStreamer = (streamerStr == "1");
}
LOG_INFO << "[verifyToken] Successfully verified token for user: " << claims.username;
return claims;
} catch (const std::exception& e) {
LOG_DEBUG << "[verifyToken] Token verification failed: " << e.what();
return std::nullopt;
}
}
bool AuthService::isUserModerator(const std::string& userId, const std::string& realmId) {
// TODO: Query main backend API or database to check moderator status
// For now, we'll implement a simple HTTP call to the backend
return false;
}
bool AuthService::isUserStreamer(const std::string& userId, const std::string& realmId) {
// TODO: Query main backend API or database to check if user owns the realm
return false;
}
} // namespace services

View file

@ -0,0 +1,41 @@
#pragma once
#include <string>
#include <optional>
#include <json/json.h>
#include <jwt-cpp/jwt.h>
namespace services {
struct UserClaims {
std::string userId;
std::string username;
std::string userColor;
std::string avatarUrl;
bool isAdmin;
bool isModerator; // Site-wide moderator role
bool isStreamer;
UserClaims() : isAdmin(false), isModerator(false), isStreamer(false) {}
};
class AuthService {
public:
static AuthService& getInstance();
void initialize(const std::string& jwtSecret);
std::optional<UserClaims> verifyToken(const std::string& token);
bool isUserModerator(const std::string& userId, const std::string& realmId);
bool isUserStreamer(const std::string& userId, const std::string& realmId);
private:
AuthService() = default;
~AuthService() = default;
AuthService(const AuthService&) = delete;
AuthService& operator=(const AuthService&) = delete;
std::string jwtSecret_;
};
} // namespace services

View file

@ -0,0 +1,279 @@
#include "CensorService.h"
#include <drogon/drogon.h>
#include <drogon/HttpClient.h>
#include <sstream>
#include <algorithm>
#include <cctype>
namespace services {
CensorService& CensorService::getInstance() {
static CensorService instance;
return instance;
}
void CensorService::initialize() {
initialized_ = true;
LOG_INFO << "CensorService initialized";
}
void CensorService::scheduleFetch() {
LOG_INFO << "Scheduling censored words fetch in 2 seconds...";
drogon::app().getLoop()->runAfter(2.0, [this]() {
LOG_INFO << "Pre-fetching censored words from backend...";
fetchCensoredWordsAsync();
});
// No periodic refresh - cache invalidation is triggered by backend
}
void CensorService::invalidateCache() {
LOG_INFO << "Cache invalidation requested, fetching censored words from backend...";
fetchCensoredWordsAsync();
}
void CensorService::fetchCensoredWordsFromBackend() {
auto config = drogon::app().getCustomConfig();
auto backendConfig = config.get("backend_api", Json::Value::null);
std::string host;
int port;
if (backendConfig.isNull() || !backendConfig.isMember("host")) {
host = "drogon-backend";
port = 8080;
} else {
host = backendConfig.get("host", "drogon-backend").asString();
port = backendConfig.get("port", 8080).asInt();
}
auto client = drogon::HttpClient::newHttpClient("http://" + host + ":" + std::to_string(port));
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->setPath("/api/internal/censored-words");
std::pair<drogon::ReqResult, drogon::HttpResponsePtr> result = client->sendRequest(req, 5.0);
if (result.first != drogon::ReqResult::Ok) {
LOG_ERROR << "Failed to fetch censored words from backend: request failed";
return;
}
auto resp = result.second;
if (resp->getStatusCode() != drogon::k200OK) {
LOG_ERROR << "Failed to fetch censored words from backend: HTTP " << resp->getStatusCode();
return;
}
try {
auto json = resp->getJsonObject();
if (!json || !(*json)["success"].asBool()) {
LOG_ERROR << "Failed to fetch censored words: invalid response";
return;
}
std::string wordsStr = (*json)["censored_words"].asString();
// Build new data in temporary variables
std::vector<std::string> newWords;
std::optional<std::regex> newPattern;
if (!wordsStr.empty()) {
std::stringstream ss(wordsStr);
std::string word;
while (std::getline(ss, word, ',') && newWords.size() < MAX_WORD_COUNT) {
size_t start = word.find_first_not_of(" \t\r\n");
size_t end = word.find_last_not_of(" \t\r\n");
if (start != std::string::npos && end != std::string::npos) {
word = word.substr(start, end - start + 1);
// Skip empty words and words exceeding max length (ReDoS prevention)
if (!word.empty() && word.length() <= MAX_WORD_LENGTH) {
newWords.push_back(word);
} else if (word.length() > MAX_WORD_LENGTH) {
LOG_WARN << "Skipping censored word exceeding " << MAX_WORD_LENGTH << " chars";
}
}
}
newPattern = buildCombinedPattern(newWords);
}
// Atomic swap under lock
{
std::unique_lock<std::shared_mutex> lock(mutex_);
censoredWords_ = std::move(newWords);
combinedPattern_ = std::move(newPattern);
}
LOG_DEBUG << "Fetched " << censoredWords_.size() << " censored words from backend";
} catch (const std::exception& e) {
LOG_ERROR << "Error parsing censored words response: " << e.what();
}
}
void CensorService::fetchCensoredWordsAsync() {
auto config = drogon::app().getCustomConfig();
auto backendConfig = config.get("backend_api", Json::Value::null);
std::string host;
int port;
if (backendConfig.isNull() || !backendConfig.isMember("host")) {
host = "drogon-backend";
port = 8080;
} else {
host = backendConfig.get("host", "drogon-backend").asString();
port = backendConfig.get("port", 8080).asInt();
}
std::string url = "http://" + host + ":" + std::to_string(port);
auto client = drogon::HttpClient::newHttpClient(url, drogon::app().getLoop());
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->setPath("/api/internal/censored-words");
client->sendRequest(req, [this, client](drogon::ReqResult result, const drogon::HttpResponsePtr& resp) {
if (result != drogon::ReqResult::Ok) {
LOG_ERROR << "Async fetch censored words failed";
return;
}
if (resp->getStatusCode() != drogon::k200OK) {
LOG_ERROR << "Async fetch censored words failed: HTTP " << resp->getStatusCode();
return;
}
try {
auto json = resp->getJsonObject();
if (!json || !(*json)["success"].asBool()) {
LOG_ERROR << "Async fetch censored words: invalid response";
return;
}
std::string wordsStr = (*json)["censored_words"].asString();
// Build new data in temporary variables
std::vector<std::string> newWords;
std::optional<std::regex> newPattern;
if (!wordsStr.empty()) {
std::stringstream ss(wordsStr);
std::string word;
while (std::getline(ss, word, ',') && newWords.size() < MAX_WORD_COUNT) {
size_t start = word.find_first_not_of(" \t\r\n");
size_t end = word.find_last_not_of(" \t\r\n");
if (start != std::string::npos && end != std::string::npos) {
word = word.substr(start, end - start + 1);
// Skip empty words and words exceeding max length (ReDoS prevention)
if (!word.empty() && word.length() <= MAX_WORD_LENGTH) {
newWords.push_back(word);
} else if (word.length() > MAX_WORD_LENGTH) {
LOG_WARN << "Skipping censored word exceeding " << MAX_WORD_LENGTH << " chars";
}
}
}
newPattern = buildCombinedPattern(newWords);
}
// Atomic swap under lock
{
std::unique_lock<std::shared_mutex> lock(mutex_);
censoredWords_ = std::move(newWords);
combinedPattern_ = std::move(newPattern);
}
LOG_INFO << "Successfully fetched " << censoredWords_.size() << " censored words from backend";
} catch (const std::exception& e) {
LOG_ERROR << "Error parsing async censored words response: " << e.what();
}
}, 10.0);
}
std::optional<std::regex> CensorService::buildCombinedPattern(const std::vector<std::string>& words) {
if (words.empty()) {
return std::nullopt;
}
try {
// Build combined pattern: \b(word1|word2|word3)\b
std::string pattern = "\\b(";
bool first = true;
for (const auto& word : words) {
if (!first) {
pattern += "|";
}
first = false;
// Escape special regex characters
for (char c : word) {
if (c == '.' || c == '^' || c == '$' || c == '*' || c == '+' ||
c == '?' || c == '(' || c == ')' || c == '[' || c == ']' ||
c == '{' || c == '}' || c == '|' || c == '\\') {
pattern += '\\';
}
pattern += c;
}
}
pattern += ")\\b";
return std::regex(pattern, std::regex_constants::icase);
} catch (const std::regex_error& e) {
LOG_ERROR << "Failed to build combined censored pattern: " << e.what();
return std::nullopt;
}
}
std::string CensorService::censor(const std::string& text) {
if (text.empty()) {
return text;
}
std::shared_lock<std::shared_mutex> lock(mutex_);
if (!combinedPattern_) {
return text;
}
std::string result;
try {
// Replace censored words with asterisks
std::sregex_iterator begin(text.begin(), text.end(), *combinedPattern_);
std::sregex_iterator end;
size_t lastPos = 0;
for (std::sregex_iterator it = begin; it != end; ++it) {
const std::smatch& match = *it;
// Append text before match
result += text.substr(lastPos, match.position() - lastPos);
// Replace match with fixed asterisks
result += "****";
lastPos = match.position() + match.length();
}
// Append remaining text
result += text.substr(lastPos);
} catch (const std::regex_error& e) {
LOG_ERROR << "Regex replace error: " << e.what();
return text;
}
return result;
}
bool CensorService::containsCensoredWords(const std::string& text) {
if (text.empty()) {
return false;
}
std::shared_lock<std::shared_mutex> lock(mutex_);
if (!combinedPattern_) {
return false;
}
try {
return std::regex_search(text, *combinedPattern_);
} catch (const std::regex_error& e) {
LOG_ERROR << "Regex search error: " << e.what();
return false;
}
}
} // namespace services

View file

@ -0,0 +1,51 @@
#pragma once
#include <string>
#include <vector>
#include <mutex>
#include <shared_mutex>
#include <regex>
#include <chrono>
#include <optional>
namespace services {
// Maximum length for a single censored word (ReDoS prevention)
static constexpr size_t MAX_WORD_LENGTH = 100;
// Maximum number of censored words
static constexpr size_t MAX_WORD_COUNT = 500;
class CensorService {
public:
static CensorService& getInstance();
void initialize();
// Schedule initial fetch from backend (call from main after event loop setup)
void scheduleFetch();
// Censor text by replacing censored words with asterisks (case-insensitive)
std::string censor(const std::string& text);
// Check if text contains any censored words
bool containsCensoredWords(const std::string& text);
// Invalidate cache and refetch from backend (called when backend updates words)
void invalidateCache();
private:
CensorService() = default;
~CensorService() = default;
CensorService(const CensorService&) = delete;
CensorService& operator=(const CensorService&) = delete;
void fetchCensoredWordsAsync();
void fetchCensoredWordsFromBackend();
std::optional<std::regex> buildCombinedPattern(const std::vector<std::string>& words);
mutable std::shared_mutex mutex_;
std::vector<std::string> censoredWords_;
std::optional<std::regex> combinedPattern_; // Single combined pattern for efficiency
bool initialized_ = false;
};
} // namespace services

View file

@ -0,0 +1,294 @@
#include "ChatService.h"
#include "RedisMessageStore.h"
#include "ModerationService.h"
#include "StickerService.h"
#include "CensorService.h"
#include "../controllers/ChatWebSocketController.h"
#include <drogon/drogon.h>
#include <drogon/HttpClient.h>
#include <regex>
namespace services {
ChatService& ChatService::getInstance() {
static ChatService instance;
return instance;
}
void ChatService::initialize() {
LOG_INFO << "ChatService initialized";
startCleanupTask();
}
SendMessageResult ChatService::sendMessage(const std::string& realmId,
const std::string& userId,
const std::string& username,
const std::string& userColor,
const std::string& avatarUrl,
const std::string& content,
bool isGuest,
bool isModerator,
bool isStreamer,
models::ChatMessage& outMessage,
int selfDestructSeconds,
bool isBot) {
auto& redis = RedisMessageStore::getInstance();
auto& modService = ModerationService::getInstance();
// SECURITY FIX: Bot rate limiting (1 message per second)
if (isBot) {
if (!canBotSendMessage(userId)) {
return SendMessageResult::BOT_RATE_LIMITED;
}
}
// Check if user can chat (not banned or muted)
if (!isGuest && !modService.canUserChat(realmId, userId)) {
if (redis.isBanned(realmId, userId)) {
return SendMessageResult::BANNED;
}
if (redis.isMuted(realmId, userId)) {
return SendMessageResult::MUTED;
}
}
// Validate content
if (!isContentValid(content)) {
return SendMessageResult::INVALID_CONTENT;
}
// Check message length
auto config = drogon::app().getCustomConfig().get("chat", Json::Value::null);
int maxLength = config.get("max_message_length", 500).asInt();
if (content.length() > static_cast<size_t>(maxLength)) {
return SendMessageResult::MESSAGE_TOO_LONG;
}
// Get realm settings
auto settings = redis.getRealmSettings(realmId);
// Check if guests are allowed (global setting overrides realm setting)
if (isGuest) {
auto globalSettings = getGlobalSettings();
// Global setting takes priority - if disabled site-wide, no guests can chat
if (!globalSettings.guestsAllowedSiteWide) {
return SendMessageResult::GUESTS_NOT_ALLOWED;
}
// Per-realm setting - if disabled for this realm, guests can't chat here
if (!settings.chatGuestsAllowed) {
return SendMessageResult::GUESTS_NOT_ALLOWED;
}
}
// Check if links are allowed (unless moderator or streamer)
if (!isModerator && !isStreamer && !settings.linksAllowed && containsLinks(content)) {
return SendMessageResult::LINKS_NOT_ALLOWED;
}
// Check slow mode (unless moderator or streamer)
// Guests are also subject to slow mode to prevent spam
if (!isModerator && !isStreamer) {
if (!redis.canSendMessage(realmId, userId, settings.slowModeSeconds)) {
return SendMessageResult::SLOW_MODE;
}
}
// Process special stickers (:roll: and :rtd:)
auto& stickerService = StickerService::getInstance();
auto stickerResult = stickerService.processSpecialStickers(content);
std::string processedContent = stickerResult.content;
// Track sticker usage (async, fire-and-forget)
auto usedStickers = stickerService.extractStickerNames(processedContent);
if (!usedStickers.empty()) {
stickerService.trackStickerUsage(usedStickers);
}
// Apply censoring to remove banned words
auto& censorService = CensorService::getInstance();
processedContent = censorService.censor(processedContent);
// If message is empty after censoring, reject it
if (processedContent.empty()) {
return SendMessageResult::INVALID_CONTENT;
}
// Create message with optional self-destruct timer and roll/rtd flags
models::ChatMessage message(realmId, userId, username, userColor, avatarUrl, processedContent,
isGuest, isModerator, isStreamer, selfDestructSeconds,
stickerResult.usedRoll, stickerResult.usedRtd);
// Store in Redis
if (!redis.addMessage(message)) {
return SendMessageResult::ERROR;
}
// Record message sent for slow mode (all users except mods/streamers)
if (!isModerator && !isStreamer) {
redis.recordMessageSent(realmId, userId);
}
// Record user activity
redis.recordUserActivity(realmId, userId);
// Schedule self-destruct if timer is set
if (selfDestructSeconds > 0) {
scheduleSelfDestruct(realmId, message.messageId, selfDestructSeconds);
}
outMessage = message;
return SendMessageResult::SUCCESS;
}
void ChatService::scheduleSelfDestruct(const std::string& realmId, const std::string& messageId, int delaySeconds) {
LOG_DEBUG << "Scheduling self-destruct for message " << messageId << " in " << delaySeconds << " seconds";
// Use Drogon's timer to schedule deletion
drogon::app().getLoop()->runAfter(delaySeconds, [realmId, messageId]() {
LOG_DEBUG << "Self-destructing message: " << messageId;
auto& modService = ModerationService::getInstance();
if (modService.deleteMessage(realmId, messageId, "system:self-destruct")) {
// Broadcast deletion to all connected clients in the realm
Json::Value broadcast;
broadcast["type"] = "message_deleted";
broadcast["messageId"] = messageId;
ChatWebSocketController::broadcastToRealm(realmId, broadcast);
LOG_DEBUG << "Broadcasted self-destruct deletion for message: " << messageId;
}
});
}
std::vector<models::ChatMessage> ChatService::getRealmMessages(const std::string& realmId,
int limit,
int64_t beforeTimestamp) {
auto& redis = RedisMessageStore::getInstance();
return redis.getMessages(realmId, limit, beforeTimestamp);
}
bool ChatService::deleteMessage(const std::string& realmId, const std::string& messageId,
const std::string& moderatorId) {
auto& modService = ModerationService::getInstance();
return modService.deleteMessage(realmId, messageId, moderatorId);
}
models::ChatSettings ChatService::getRealmSettings(const std::string& realmId) {
auto& redis = RedisMessageStore::getInstance();
return redis.getRealmSettings(realmId);
}
bool ChatService::updateRealmSettings(const std::string& realmId, const models::ChatSettings& settings) {
auto& redis = RedisMessageStore::getInstance();
redis.setRealmSettings(realmId, settings);
return true;
}
models::GlobalChatSettings ChatService::getGlobalSettings() {
auto& redis = RedisMessageStore::getInstance();
return redis.getGlobalSettings();
}
bool ChatService::updateGlobalSettings(const models::GlobalChatSettings& settings) {
auto& redis = RedisMessageStore::getInstance();
redis.setGlobalSettings(settings);
return true;
}
std::string ChatService::generateGuestUsername() {
auto& redis = RedisMessageStore::getInstance();
return redis.generateGuestId("");
}
std::string ChatService::getRandomDefaultAvatar() {
try {
// Synchronous HTTP request to backend for random default avatar
auto client = drogon::HttpClient::newHttpClient("http://drogon-backend:8080");
auto req = drogon::HttpRequest::newHttpRequest();
req->setPath("/api/default-avatar/random");
req->setMethod(drogon::Get);
auto [result, resp] = client->sendRequest(req, 2.0); // 2 second timeout
if (result == drogon::ReqResult::Ok && resp) {
auto json = resp->getJsonObject();
if (json && (*json)["success"].asBool()) {
auto avatarUrl = (*json)["avatarUrl"];
if (!avatarUrl.isNull()) {
return avatarUrl.asString();
}
}
}
} catch (const std::exception& e) {
LOG_WARN << "Failed to get random default avatar: " << e.what();
}
return ""; // Return empty string if no default avatars available
}
void ChatService::startCleanupTask() {
auto config = drogon::app().getCustomConfig().get("chat", Json::Value::null);
int intervalSeconds = config.get("cleanup_interval_seconds", 300).asInt();
// Schedule periodic cleanup
drogon::app().getLoop()->runEvery(intervalSeconds, [this]() {
cleanupMessages();
});
LOG_INFO << "Chat cleanup task started (interval: " << intervalSeconds << "s)";
}
bool ChatService::containsLinks(const std::string& content) {
// Simple regex to detect URLs
std::regex urlPattern(R"((https?://|www\.)[^\s]+)", std::regex::icase);
return std::regex_search(content, urlPattern);
}
bool ChatService::isContentValid(const std::string& content) {
// Check if content is empty or only whitespace
if (content.empty() || content.find_first_not_of(" \t\n\r") == std::string::npos) {
return false;
}
return true;
}
void ChatService::cleanupMessages() {
LOG_DEBUG << "Running message cleanup task";
auto& redis = RedisMessageStore::getInstance();
if (!redis.isInitialized()) {
LOG_WARN << "Redis not initialized - skipping cleanup";
return;
}
// Get list of active realms from Redis
auto activeRealms = redis.getActiveRealms();
LOG_DEBUG << "Cleaning up messages for " << activeRealms.size() << " active realms";
for (const auto& realmId : activeRealms) {
auto settings = redis.getRealmSettings(realmId);
if (settings.retentionHours > 0) {
redis.cleanupOldMessages(realmId, settings.retentionHours);
LOG_DEBUG << "Cleaned up old messages for realm: " << realmId
<< " (retention: " << settings.retentionHours << "h)";
}
}
}
// SECURITY FIX: Bot rate limiting implementation
bool ChatService::canBotSendMessage(const std::string& botUserId) {
std::lock_guard<std::mutex> lock(botRateLimitMutex_);
auto now = std::chrono::steady_clock::now();
auto it = botLastMessage_.find(botUserId);
if (it != botLastMessage_.end()) {
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - it->second).count();
if (elapsed < BOT_RATE_LIMIT_MS) {
LOG_DEBUG << "Bot " << botUserId << " rate limited (only " << elapsed << "ms since last message)";
return false;
}
}
botLastMessage_[botUserId] = now;
return true;
}
} // namespace services

View file

@ -0,0 +1,83 @@
#pragma once
#include <string>
#include <vector>
#include <functional>
#include <unordered_map>
#include <chrono>
#include <mutex>
#include "../models/ChatMessage.h"
namespace services {
enum class SendMessageResult {
SUCCESS,
BANNED,
MUTED,
SLOW_MODE,
BOT_RATE_LIMITED, // SECURITY FIX: Bot-specific rate limiting
LINKS_NOT_ALLOWED,
MESSAGE_TOO_LONG,
INVALID_CONTENT,
GUESTS_NOT_ALLOWED,
ERROR
};
class ChatService {
public:
static ChatService& getInstance();
void initialize();
SendMessageResult sendMessage(const std::string& realmId,
const std::string& userId,
const std::string& username,
const std::string& userColor,
const std::string& avatarUrl,
const std::string& content,
bool isGuest,
bool isModerator,
bool isStreamer,
models::ChatMessage& outMessage,
int selfDestructSeconds = 0,
bool isBot = false); // SECURITY FIX: Bot rate limiting
// Schedule a message for self-destruction
void scheduleSelfDestruct(const std::string& realmId, const std::string& messageId, int delaySeconds);
std::vector<models::ChatMessage> getRealmMessages(const std::string& realmId,
int limit = 100,
int64_t beforeTimestamp = 0);
bool deleteMessage(const std::string& realmId, const std::string& messageId,
const std::string& moderatorId);
models::ChatSettings getRealmSettings(const std::string& realmId);
bool updateRealmSettings(const std::string& realmId, const models::ChatSettings& settings);
models::GlobalChatSettings getGlobalSettings();
bool updateGlobalSettings(const models::GlobalChatSettings& settings);
std::string generateGuestUsername();
std::string getRandomDefaultAvatar();
void startCleanupTask();
private:
ChatService() = default;
~ChatService() = default;
ChatService(const ChatService&) = delete;
ChatService& operator=(const ChatService&) = delete;
bool containsLinks(const std::string& content);
bool isContentValid(const std::string& content);
void cleanupMessages();
// SECURITY FIX: Bot rate limiting (1 message per second per bot)
static constexpr int BOT_RATE_LIMIT_MS = 1000; // 1 second between messages
std::unordered_map<std::string, std::chrono::steady_clock::time_point> botLastMessage_;
std::mutex botRateLimitMutex_;
bool canBotSendMessage(const std::string& botUserId);
};
} // namespace services

View file

@ -0,0 +1,249 @@
#include "ModerationService.h"
#include "RedisMessageStore.h"
#include <drogon/drogon.h>
namespace services {
ModerationService& ModerationService::getInstance() {
static ModerationService instance;
return instance;
}
bool ModerationService::banUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId, const std::string& reason) {
auto& redis = RedisMessageStore::getInstance();
bool success = redis.addBan(realmId, targetUserId);
if (success) {
models::ModerationAction action(models::ModActionType::BAN, realmId,
targetUserId, moderatorId, reason);
logAction(action);
}
return success;
}
bool ModerationService::unbanUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId) {
auto& redis = RedisMessageStore::getInstance();
bool success = redis.removeBan(realmId, targetUserId);
if (success) {
models::ModerationAction action(models::ModActionType::UNBAN, realmId,
targetUserId, moderatorId);
logAction(action);
}
return success;
}
// Site-wide fingerprint ban ("uberban") - only for admins and site moderators
bool ModerationService::uberbanUser(const std::string& fingerprint, const std::string& moderatorId,
const std::string& reason) {
auto& redis = RedisMessageStore::getInstance();
bool success = redis.addFingerprintBan(fingerprint);
if (success) {
models::ModerationAction action(models::ModActionType::UBERBAN, "site-wide",
fingerprint, moderatorId, reason);
logAction(action);
}
return success;
}
bool ModerationService::unUberbanUser(const std::string& fingerprint, const std::string& moderatorId) {
auto& redis = RedisMessageStore::getInstance();
bool success = redis.removeFingerprintBan(fingerprint);
if (success) {
models::ModerationAction action(models::ModActionType::UNUBERBAN, "site-wide",
fingerprint, moderatorId);
logAction(action);
}
return success;
}
bool ModerationService::isUserUberbanned(const std::string& fingerprint) {
auto& redis = RedisMessageStore::getInstance();
return redis.isFingerprintBanned(fingerprint);
}
std::vector<std::string> ModerationService::getUberbannedFingerprints() {
auto& redis = RedisMessageStore::getInstance();
return redis.getBannedFingerprints();
}
// Per-realm ban (supports user ID and fingerprint for guests)
bool ModerationService::banUserFromRealm(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId, const std::string& reason,
const std::string& guestFingerprint) {
auto& redis = RedisMessageStore::getInstance();
bool success = false;
// Ban by user ID if provided
if (!targetUserId.empty()) {
success = redis.addRealmBan(realmId, "user:" + targetUserId);
}
// Ban by fingerprint if provided (for guests)
else if (!guestFingerprint.empty()) {
success = redis.addRealmBan(realmId, "fp:" + guestFingerprint);
}
if (success) {
std::string target = !targetUserId.empty() ? targetUserId : ("fp:" + guestFingerprint);
models::ModerationAction action(models::ModActionType::REALM_BAN, realmId,
target, moderatorId, reason);
logAction(action);
}
return success;
}
bool ModerationService::unbanUserFromRealm(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId,
const std::string& guestFingerprint) {
auto& redis = RedisMessageStore::getInstance();
bool success = false;
// Unban by user ID if provided
if (!targetUserId.empty()) {
success = redis.removeRealmBan(realmId, "user:" + targetUserId);
}
// Unban by fingerprint if provided (for guests)
else if (!guestFingerprint.empty()) {
success = redis.removeRealmBan(realmId, "fp:" + guestFingerprint);
}
if (success) {
std::string target = !targetUserId.empty() ? targetUserId : ("fp:" + guestFingerprint);
models::ModerationAction action(models::ModActionType::REALM_UNBAN, realmId,
target, moderatorId);
logAction(action);
}
return success;
}
bool ModerationService::isUserBannedFromRealm(const std::string& realmId, const std::string& userId,
const std::string& guestFingerprint) {
auto& redis = RedisMessageStore::getInstance();
return redis.isRealmBanned(realmId, userId, guestFingerprint);
}
std::vector<std::string> ModerationService::getRealmBannedIdentifiers(const std::string& realmId) {
auto& redis = RedisMessageStore::getInstance();
return redis.getRealmBannedIdentifiers(realmId);
}
// Kick (disconnect + 1 minute rejoin block)
bool ModerationService::kickUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId, const std::string& reason,
int durationSeconds) {
auto& redis = RedisMessageStore::getInstance();
bool success = redis.addKick(realmId, targetUserId, durationSeconds);
if (success) {
models::ModerationAction action(models::ModActionType::KICK, realmId,
targetUserId, moderatorId, reason, durationSeconds);
logAction(action);
}
return success;
}
bool ModerationService::isUserKicked(const std::string& realmId, const std::string& userId) {
auto& redis = RedisMessageStore::getInstance();
return redis.isKicked(realmId, userId);
}
bool ModerationService::muteUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId, int durationSeconds,
const std::string& reason) {
auto& redis = RedisMessageStore::getInstance();
bool success = redis.addMute(realmId, targetUserId, durationSeconds);
if (success) {
models::ModerationAction action(models::ModActionType::MUTE, realmId,
targetUserId, moderatorId, reason, durationSeconds);
logAction(action);
}
return success;
}
bool ModerationService::unmuteUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId) {
auto& redis = RedisMessageStore::getInstance();
bool success = redis.removeMute(realmId, targetUserId);
if (success) {
models::ModerationAction action(models::ModActionType::UNMUTE, realmId,
targetUserId, moderatorId);
logAction(action);
}
return success;
}
bool ModerationService::deleteMessage(const std::string& realmId, const std::string& messageId,
const std::string& moderatorId) {
auto& redis = RedisMessageStore::getInstance();
bool success = redis.deleteMessage(realmId, messageId);
if (success) {
models::ModerationAction action(models::ModActionType::DELETE_MESSAGE, realmId,
messageId, moderatorId);
logAction(action);
}
return success;
}
bool ModerationService::canUserChat(const std::string& realmId, const std::string& userId,
const std::string& fingerprint) {
auto& redis = RedisMessageStore::getInstance();
// Check if uberbanned (site-wide fingerprint ban)
if (!fingerprint.empty() && redis.isFingerprintBanned(fingerprint)) {
return false;
}
// Check if banned from this specific realm (new realm ban system)
if (redis.isRealmBanned(realmId, userId, fingerprint)) {
return false;
}
// Check legacy per-realm ban
if (redis.isBanned(realmId, userId)) {
return false;
}
// Check if kicked from this realm
if (!userId.empty() && redis.isKicked(realmId, userId)) {
return false;
}
// Check if muted
if (redis.isMuted(realmId, userId)) {
return false;
}
return true;
}
std::vector<std::string> ModerationService::getBannedUsers(const std::string& realmId) {
auto& redis = RedisMessageStore::getInstance();
return redis.getBannedUsers(realmId);
}
void ModerationService::logAction(const models::ModerationAction& action) {
LOG_INFO << "Moderation action: type=" << static_cast<int>(action.type)
<< " realm=" << action.realmId
<< " target=" << action.targetUserId
<< " moderator=" << action.moderatorId
<< " reason=" << action.reason;
}
} // namespace services

View file

@ -0,0 +1,67 @@
#pragma once
#include <string>
#include <vector>
#include "../models/ModerationAction.h"
namespace services {
class ModerationService {
public:
static ModerationService& getInstance();
// Legacy per-realm ban (user ID only) - kept for backward compatibility
bool banUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId, const std::string& reason = "");
bool unbanUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId);
// Site-wide fingerprint ban ("uberban") - only for admins and site moderators
bool uberbanUser(const std::string& fingerprint, const std::string& moderatorId,
const std::string& reason = "");
bool unUberbanUser(const std::string& fingerprint, const std::string& moderatorId);
bool isUserUberbanned(const std::string& fingerprint);
std::vector<std::string> getUberbannedFingerprints();
// Per-realm ban (supports user ID and fingerprint for guests)
bool banUserFromRealm(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId, const std::string& reason = "",
const std::string& guestFingerprint = "");
bool unbanUserFromRealm(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId,
const std::string& guestFingerprint = "");
bool isUserBannedFromRealm(const std::string& realmId, const std::string& userId,
const std::string& guestFingerprint = "");
std::vector<std::string> getRealmBannedIdentifiers(const std::string& realmId);
// Kick (disconnect + 1 minute rejoin block)
bool kickUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId, const std::string& reason = "",
int durationSeconds = 60);
bool isUserKicked(const std::string& realmId, const std::string& userId);
// durationSeconds: 0 = permanent (default), >0 = temporary with auto-expire
bool muteUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId, int durationSeconds = 0,
const std::string& reason = "");
bool unmuteUser(const std::string& realmId, const std::string& targetUserId,
const std::string& moderatorId);
bool deleteMessage(const std::string& realmId, const std::string& messageId,
const std::string& moderatorId);
bool canUserChat(const std::string& realmId, const std::string& userId,
const std::string& fingerprint = "");
std::vector<std::string> getBannedUsers(const std::string& realmId);
private:
ModerationService() = default;
~ModerationService() = default;
ModerationService(const ModerationService&) = delete;
ModerationService& operator=(const ModerationService&) = delete;
void logAction(const models::ModerationAction& action);
};
} // namespace services

View file

@ -0,0 +1,651 @@
#include "RedisMessageStore.h"
#include <drogon/drogon.h>
#include <regex>
#include <random>
using namespace sw::redis;
using namespace models;
namespace services {
RedisMessageStore& RedisMessageStore::getInstance() {
static RedisMessageStore instance;
return instance;
}
void RedisMessageStore::initialize(const std::string& host, int port, int db, const std::string& password) {
ConnectionOptions opts;
opts.host = host;
opts.port = port;
opts.db = db;
opts.socket_timeout = std::chrono::milliseconds(5000);
if (!password.empty()) {
opts.password = password;
}
redis_ = std::make_unique<Redis>(opts);
LOG_INFO << "RedisMessageStore initialized: " << host << ":" << port << " db=" << db;
}
void RedisMessageStore::trackActiveRealm(const std::string& realmId) {
if (!redis_) return;
try {
auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()
).count();
redis_->zadd("chat:active_realms", realmId, static_cast<double>(now));
} catch (const Error& e) {
LOG_ERROR << "Redis error tracking active realm: " << e.what();
}
}
std::vector<std::string> RedisMessageStore::getActiveRealms() {
std::vector<std::string> realms;
if (!redis_) return realms;
try {
// Get realms active in the last hour
auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()
).count();
auto cutoff = now - 3600; // 1 hour ago
BoundedInterval<double> interval(static_cast<double>(cutoff), static_cast<double>(now), BoundType::CLOSED);
redis_->zrangebyscore("chat:active_realms", interval, std::back_inserter(realms));
} catch (const Error& e) {
LOG_ERROR << "Redis error getting active realms: " << e.what();
}
return realms;
}
bool RedisMessageStore::addMessage(const ChatMessage& message) {
if (!redis_) {
LOG_ERROR << "Redis not initialized - cannot add message";
return false;
}
try {
// Track this realm as active
trackActiveRealm(message.realmId);
auto key = getMessagesKey(message.realmId);
auto serialized = message.serialize();
// Add to sorted set with timestamp as score
redis_->zadd(key, serialized, static_cast<double>(message.timestamp));
// Trim to max messages per realm
auto maxMessages = drogon::app().getCustomConfig().get("chat", Json::Value::null)
.get("max_messages_per_realm", 1000).asInt64();
redis_->zremrangebyrank(key, 0, -(maxMessages + 1));
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error adding message: " << e.what();
return false;
}
}
std::vector<ChatMessage> RedisMessageStore::getMessages(const std::string& realmId,
int limit,
int64_t beforeTimestamp) {
std::vector<ChatMessage> messages;
if (!redis_) {
LOG_ERROR << "Redis not initialized - cannot get messages";
return messages;
}
try {
auto key = getMessagesKey(realmId);
std::vector<std::string> results;
if (beforeTimestamp > 0) {
// Get messages before timestamp
BoundedInterval<double> interval(0, static_cast<double>(beforeTimestamp), BoundType::CLOSED);
LimitOptions limitOpt(0, limit);
redis_->zrevrangebyscore(key, interval, limitOpt, std::back_inserter(results));
} else {
// Get latest messages
redis_->zrevrange(key, 0, limit - 1, std::back_inserter(results));
}
for (const auto& serialized : results) {
messages.push_back(ChatMessage::deserialize(serialized));
}
// Reverse to get chronological order
std::reverse(messages.begin(), messages.end());
} catch (const Error& e) {
LOG_ERROR << "Redis error getting messages: " << e.what();
}
return messages;
}
bool RedisMessageStore::deleteMessage(const std::string& realmId, const std::string& messageId) {
try {
auto key = getMessagesKey(realmId);
// Get all messages and remove the one with matching ID
std::vector<std::string> messages;
redis_->zrange(key, 0, -1, std::back_inserter(messages));
for (const auto& serialized : messages) {
auto msg = ChatMessage::deserialize(serialized);
if (msg.messageId == messageId) {
redis_->zrem(key, serialized);
return true;
}
}
return false;
} catch (const Error& e) {
LOG_ERROR << "Redis error deleting message: " << e.what();
return false;
}
}
void RedisMessageStore::cleanupOldMessages(const std::string& realmId, int retentionHours) {
try {
auto key = getMessagesKey(realmId);
auto cutoffTime = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::system_clock::now().time_since_epoch()
).count() - (retentionHours * 3600000LL);
BoundedInterval<double> interval(0, static_cast<double>(cutoffTime), BoundType::CLOSED);
redis_->zremrangebyscore(key, interval);
} catch (const Error& e) {
LOG_ERROR << "Redis error cleaning up messages: " << e.what();
}
}
void RedisMessageStore::setGlobalSettings(const GlobalChatSettings& settings) {
try {
redis_->hset("chat:settings:global", "guestPrefix", settings.guestPrefix);
redis_->hset("chat:settings:global", "defaultRetentionHours",
std::to_string(settings.defaultRetentionHours));
redis_->hset("chat:settings:global", "guestsAllowedSiteWide",
settings.guestsAllowedSiteWide ? "1" : "0");
redis_->hset("chat:settings:global", "registrationEnabled",
settings.registrationEnabled ? "1" : "0");
} catch (const Error& e) {
LOG_ERROR << "Redis error setting global settings: " << e.what();
}
}
GlobalChatSettings RedisMessageStore::getGlobalSettings() {
GlobalChatSettings settings;
try {
std::unordered_map<std::string, std::string> data;
redis_->hgetall("chat:settings:global", std::inserter(data, data.begin()));
if (!data.empty()) {
// Safely read each field with defaults for missing/empty values
auto it = data.find("guestPrefix");
if (it != data.end() && !it->second.empty()) {
settings.guestPrefix = it->second;
}
it = data.find("defaultRetentionHours");
if (it != data.end() && !it->second.empty()) {
settings.defaultRetentionHours = std::stoi(it->second);
}
it = data.find("guestsAllowedSiteWide");
if (it != data.end()) {
settings.guestsAllowedSiteWide = (it->second == "1");
}
it = data.find("registrationEnabled");
if (it != data.end()) {
settings.registrationEnabled = (it->second == "1");
}
} else {
// Initialize with defaults from config
auto config = drogon::app().getCustomConfig().get("chat", Json::Value::null);
settings.guestPrefix = config.get("guest_prefix", "Guest").asString();
settings.defaultRetentionHours = config.get("default_retention_hours", 24).asInt();
settings.guestsAllowedSiteWide = config.get("guests_allowed_site_wide", true).asBool();
settings.registrationEnabled = config.get("registration_enabled", true).asBool();
setGlobalSettings(settings);
}
} catch (const Error& e) {
LOG_ERROR << "Redis error getting global settings: " << e.what();
}
return settings;
}
void RedisMessageStore::setRealmSettings(const std::string& realmId, const ChatSettings& settings) {
try {
auto key = "chat:settings:realm:" + realmId;
redis_->hset(key, "retentionHours", std::to_string(settings.retentionHours));
redis_->hset(key, "slowModeSeconds", std::to_string(settings.slowModeSeconds));
redis_->hset(key, "linksAllowed", settings.linksAllowed ? "1" : "0");
redis_->hset(key, "subscribersOnly", settings.subscribersOnly ? "1" : "0");
redis_->hset(key, "chatGuestsAllowed", settings.chatGuestsAllowed ? "1" : "0");
} catch (const Error& e) {
LOG_ERROR << "Redis error setting realm settings: " << e.what();
}
}
ChatSettings RedisMessageStore::getRealmSettings(const std::string& realmId) {
ChatSettings settings;
settings.realmId = realmId;
try {
auto key = "chat:settings:realm:" + realmId;
std::unordered_map<std::string, std::string> data;
redis_->hgetall(key, std::inserter(data, data.begin()));
if (!data.empty()) {
// Safely read each field with defaults for missing/empty values
auto it = data.find("retentionHours");
if (it != data.end() && !it->second.empty()) {
settings.retentionHours = std::stoi(it->second);
}
it = data.find("slowModeSeconds");
if (it != data.end() && !it->second.empty()) {
settings.slowModeSeconds = std::stoi(it->second);
}
it = data.find("linksAllowed");
if (it != data.end()) {
settings.linksAllowed = (it->second == "1");
}
it = data.find("subscribersOnly");
if (it != data.end()) {
settings.subscribersOnly = (it->second == "1");
}
it = data.find("chatGuestsAllowed");
// Default to true if not set (backward compatibility)
settings.chatGuestsAllowed = (it == data.end() || it->second != "0");
} else {
// Use global defaults
auto globalSettings = getGlobalSettings();
settings.retentionHours = globalSettings.defaultRetentionHours;
}
} catch (const Error& e) {
LOG_ERROR << "Redis error getting realm settings: " << e.what();
}
return settings;
}
bool RedisMessageStore::addBan(const std::string& realmId, const std::string& userId) {
try {
auto key = getBanKey(realmId);
redis_->sadd(key, userId);
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error adding ban: " << e.what();
return false;
}
}
bool RedisMessageStore::removeBan(const std::string& realmId, const std::string& userId) {
try {
auto key = getBanKey(realmId);
redis_->srem(key, userId);
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error removing ban: " << e.what();
return false;
}
}
bool RedisMessageStore::isBanned(const std::string& realmId, const std::string& userId) {
try {
auto key = getBanKey(realmId);
return redis_->sismember(key, userId);
} catch (const Error& e) {
LOG_ERROR << "Redis error checking ban: " << e.what();
return false;
}
}
std::vector<std::string> RedisMessageStore::getBannedUsers(const std::string& realmId) {
std::vector<std::string> banned;
try {
auto key = getBanKey(realmId);
redis_->smembers(key, std::back_inserter(banned));
} catch (const Error& e) {
LOG_ERROR << "Redis error getting banned users: " << e.what();
}
return banned;
}
bool RedisMessageStore::addMute(const std::string& realmId, const std::string& userId, int durationSeconds) {
try {
auto key = getMuteKey(realmId, userId);
if (durationSeconds <= 0) {
// Permanent mute - no expiry
redis_->set(key, "1");
LOG_INFO << "Added permanent mute in " << realmId << " for user " << userId;
} else {
// Temporary mute with TTL
redis_->setex(key, durationSeconds, "1");
LOG_INFO << "Added temporary mute in " << realmId << " for user " << userId << " (" << durationSeconds << "s)";
}
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error adding mute: " << e.what();
return false;
}
}
bool RedisMessageStore::removeMute(const std::string& realmId, const std::string& userId) {
try {
auto key = getMuteKey(realmId, userId);
redis_->del(key);
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error removing mute: " << e.what();
return false;
}
}
bool RedisMessageStore::isMuted(const std::string& realmId, const std::string& userId) {
try {
auto key = getMuteKey(realmId, userId);
auto val = redis_->get(key);
return val.has_value();
} catch (const Error& e) {
LOG_ERROR << "Redis error checking mute: " << e.what();
return false;
}
}
bool RedisMessageStore::canSendMessage(const std::string& realmId, const std::string& userId, int slowModeSeconds) {
if (slowModeSeconds <= 0) return true;
if (!redis_) {
LOG_ERROR << "Redis not initialized - denying message for safety";
return false; // Fail safe: deny if Redis unavailable
}
try {
auto key = getSlowModeKey(realmId, userId);
auto val = redis_->get(key);
return !val.has_value();
} catch (const Error& e) {
LOG_ERROR << "Redis error checking slow mode: " << e.what();
return false; // Fail safe: deny on error
}
}
void RedisMessageStore::recordMessageSent(const std::string& realmId, const std::string& userId) {
try {
auto settings = getRealmSettings(realmId);
if (settings.slowModeSeconds > 0) {
auto key = getSlowModeKey(realmId, userId);
redis_->setex(key, settings.slowModeSeconds, "1");
}
} catch (const Error& e) {
LOG_ERROR << "Redis error recording message: " << e.what();
}
}
std::string RedisMessageStore::generateGuestId(const std::string& pattern) {
try {
auto globalSettings = getGlobalSettings();
// Generate 5-character Base62 string (0-9, a-z, A-Z)
std::random_device rd;
std::mt19937 gen(rd());
const char base62_chars[] = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
std::uniform_int_distribution<> dis(0, 61); // 62 characters (0-61)
std::string randomStr;
for (int i = 0; i < 5; i++) {
randomStr += base62_chars[dis(gen)];
}
// Always use format: prefix + random (e.g., "Guest3xYzA")
return globalSettings.guestPrefix + randomStr;
} catch (const Error& e) {
LOG_ERROR << "Redis error generating guest ID: " << e.what();
return "guest" + std::to_string(time(nullptr));
}
}
// SECURITY FIX #31: Server-side guest name persistence
bool RedisMessageStore::setGuestName(const std::string& fingerprint, const std::string& name) {
if (fingerprint.empty() || name.empty()) return false;
try {
// Store with 30 day TTL (guest names expire after inactivity)
redis_->setex("chat:guest_name:" + fingerprint, 30 * 24 * 60 * 60, name);
LOG_DEBUG << "Stored guest name for fingerprint: " << fingerprint.substr(0, 8) << "...";
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error setting guest name: " << e.what();
return false;
}
}
std::optional<std::string> RedisMessageStore::getGuestName(const std::string& fingerprint) {
if (fingerprint.empty()) return std::nullopt;
try {
auto result = redis_->get("chat:guest_name:" + fingerprint);
if (result) {
LOG_DEBUG << "Found stored guest name for fingerprint: " << fingerprint.substr(0, 8) << "...";
return *result;
}
return std::nullopt;
} catch (const Error& e) {
LOG_ERROR << "Redis error getting guest name: " << e.what();
return std::nullopt;
}
}
bool RedisMessageStore::clearGuestName(const std::string& fingerprint) {
if (fingerprint.empty()) return false;
try {
redis_->del("chat:guest_name:" + fingerprint);
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error clearing guest name: " << e.what();
return false;
}
}
void RedisMessageStore::recordUserActivity(const std::string& realmId, const std::string& userId) {
try {
auto key = getActiveUsersKey(realmId);
auto now = std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()
).count();
redis_->zadd(key, userId, static_cast<double>(now));
// Remove inactive users (>5 minutes)
auto cutoff = now - 300;
BoundedInterval<double> interval(0, static_cast<double>(cutoff), BoundType::CLOSED);
redis_->zremrangebyscore(key, interval);
} catch (const Error& e) {
LOG_ERROR << "Redis error recording activity: " << e.what();
}
}
int RedisMessageStore::getActiveUserCount(const std::string& realmId) {
try {
auto key = getActiveUsersKey(realmId);
return redis_->zcard(key);
} catch (const Error& e) {
LOG_ERROR << "Redis error getting active count: " << e.what();
return 0;
}
}
// Pending uberban (set by backend or chat-service when admin marks user for deferred uberban)
bool RedisMessageStore::setPendingUberban(const std::string& userId) {
if (userId.empty()) return false;
try {
// 30 day TTL - if user never reconnects, the key will expire
// Backend also sets this with TTL as backup
redis_->setex("pending_uberban:" + userId, std::chrono::seconds(30 * 24 * 60 * 60), "1");
LOG_INFO << "Set pending uberban for user: " << userId << " (TTL: 30 days)";
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error setting pending uberban: " << e.what();
return false;
}
}
bool RedisMessageStore::hasPendingUberban(const std::string& userId) {
if (userId.empty()) return false;
try {
auto val = redis_->get("pending_uberban:" + userId);
return val.has_value();
} catch (const Error& e) {
LOG_ERROR << "Redis error checking pending uberban: " << e.what();
return false;
}
}
bool RedisMessageStore::clearPendingUberban(const std::string& userId) {
if (userId.empty()) return false;
try {
redis_->del("pending_uberban:" + userId);
LOG_INFO << "Cleared pending uberban for user: " << userId;
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error clearing pending uberban: " << e.what();
return false;
}
}
// Fingerprint bans (site-wide, for guests only)
// These use a single global Redis set: chat:banned:fingerprints
bool RedisMessageStore::addFingerprintBan(const std::string& fingerprint) {
if (fingerprint.empty()) return false;
try {
redis_->sadd("chat:banned:fingerprints", fingerprint);
LOG_INFO << "Added fingerprint ban: " << fingerprint.substr(0, 8) << "...";
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error adding fingerprint ban: " << e.what();
return false;
}
}
bool RedisMessageStore::removeFingerprintBan(const std::string& fingerprint) {
if (fingerprint.empty()) return false;
try {
redis_->srem("chat:banned:fingerprints", fingerprint);
LOG_INFO << "Removed fingerprint ban: " << fingerprint.substr(0, 8) << "...";
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error removing fingerprint ban: " << e.what();
return false;
}
}
bool RedisMessageStore::isFingerprintBanned(const std::string& fingerprint) {
if (fingerprint.empty()) return false;
try {
return redis_->sismember("chat:banned:fingerprints", fingerprint);
} catch (const Error& e) {
LOG_ERROR << "Redis error checking fingerprint ban: " << e.what();
return false; // Fail open - allow if Redis error
}
}
std::vector<std::string> RedisMessageStore::getBannedFingerprints() {
std::vector<std::string> fingerprints;
try {
redis_->smembers("chat:banned:fingerprints", std::back_inserter(fingerprints));
} catch (const Error& e) {
LOG_ERROR << "Redis error getting banned fingerprints: " << e.what();
}
return fingerprints;
}
// Realm-specific bans (supports both user IDs and fingerprints)
// identifier format: "user:{userId}" or "fp:{fingerprint}"
bool RedisMessageStore::addRealmBan(const std::string& realmId, const std::string& identifier) {
if (identifier.empty()) return false;
try {
auto key = getRealmBanKey(realmId);
redis_->sadd(key, identifier);
LOG_INFO << "Added realm ban in " << realmId << ": " << identifier;
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error adding realm ban: " << e.what();
return false;
}
}
bool RedisMessageStore::removeRealmBan(const std::string& realmId, const std::string& identifier) {
if (identifier.empty()) return false;
try {
auto key = getRealmBanKey(realmId);
redis_->srem(key, identifier);
LOG_INFO << "Removed realm ban in " << realmId << ": " << identifier;
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error removing realm ban: " << e.what();
return false;
}
}
bool RedisMessageStore::isRealmBanned(const std::string& realmId, const std::string& userId, const std::string& fingerprint) {
try {
auto key = getRealmBanKey(realmId);
// Check user ban if userId provided
if (!userId.empty()) {
if (redis_->sismember(key, "user:" + userId)) {
return true;
}
}
// Check fingerprint ban if fingerprint provided
if (!fingerprint.empty()) {
if (redis_->sismember(key, "fp:" + fingerprint)) {
return true;
}
}
return false;
} catch (const Error& e) {
LOG_ERROR << "Redis error checking realm ban: " << e.what();
return false; // Fail open - allow if Redis error
}
}
std::vector<std::string> RedisMessageStore::getRealmBannedIdentifiers(const std::string& realmId) {
std::vector<std::string> identifiers;
try {
auto key = getRealmBanKey(realmId);
redis_->smembers(key, std::back_inserter(identifiers));
} catch (const Error& e) {
LOG_ERROR << "Redis error getting realm banned identifiers: " << e.what();
}
return identifiers;
}
// Kicks (temporary disconnect + rejoin block with TTL)
bool RedisMessageStore::addKick(const std::string& realmId, const std::string& userId, int durationSeconds) {
if (userId.empty()) return false;
try {
auto key = getKickKey(realmId, userId);
redis_->setex(key, durationSeconds, "1");
LOG_INFO << "Added kick in " << realmId << " for user " << userId << " (" << durationSeconds << "s)";
return true;
} catch (const Error& e) {
LOG_ERROR << "Redis error adding kick: " << e.what();
return false;
}
}
bool RedisMessageStore::isKicked(const std::string& realmId, const std::string& userId) {
if (userId.empty()) return false;
try {
auto key = getKickKey(realmId, userId);
auto val = redis_->get(key);
return val.has_value();
} catch (const Error& e) {
LOG_ERROR << "Redis error checking kick: " << e.what();
return false; // Fail open - allow if Redis error
}
}
} // namespace services

View file

@ -0,0 +1,124 @@
#pragma once
#include <memory>
#include <string>
#include <vector>
#include <optional>
#include <sw/redis++/redis++.h>
#include "../models/ChatMessage.h"
namespace services {
class RedisMessageStore {
public:
static RedisMessageStore& getInstance();
void initialize(const std::string& host, int port, int db = 1, const std::string& password = "");
bool isInitialized() const { return redis_ != nullptr; }
// Track active realms for cleanup
void trackActiveRealm(const std::string& realmId);
std::vector<std::string> getActiveRealms();
// Message operations
bool addMessage(const models::ChatMessage& message);
std::vector<models::ChatMessage> getMessages(const std::string& realmId,
int limit = 100,
int64_t beforeTimestamp = 0);
bool deleteMessage(const std::string& realmId, const std::string& messageId);
void cleanupOldMessages(const std::string& realmId, int retentionHours);
// Settings operations
void setGlobalSettings(const models::GlobalChatSettings& settings);
models::GlobalChatSettings getGlobalSettings();
void setRealmSettings(const std::string& realmId, const models::ChatSettings& settings);
models::ChatSettings getRealmSettings(const std::string& realmId);
// Moderation operations (per-realm)
bool addBan(const std::string& realmId, const std::string& userId);
bool removeBan(const std::string& realmId, const std::string& userId);
bool isBanned(const std::string& realmId, const std::string& userId);
std::vector<std::string> getBannedUsers(const std::string& realmId);
// durationSeconds: 0 = permanent, >0 = temporary with TTL
bool addMute(const std::string& realmId, const std::string& userId, int durationSeconds = 0);
bool removeMute(const std::string& realmId, const std::string& userId);
bool isMuted(const std::string& realmId, const std::string& userId);
// Fingerprint bans - site-wide "uberban" (for guests and registered users)
bool addFingerprintBan(const std::string& fingerprint);
bool removeFingerprintBan(const std::string& fingerprint);
bool isFingerprintBanned(const std::string& fingerprint);
std::vector<std::string> getBannedFingerprints();
// Realm-specific bans (supports both user IDs and fingerprints)
// identifier format: "user:{userId}" or "fp:{fingerprint}"
bool addRealmBan(const std::string& realmId, const std::string& identifier);
bool removeRealmBan(const std::string& realmId, const std::string& identifier);
bool isRealmBanned(const std::string& realmId, const std::string& userId, const std::string& fingerprint = "");
std::vector<std::string> getRealmBannedIdentifiers(const std::string& realmId);
// Kicks (temporary disconnect + rejoin block with TTL)
bool addKick(const std::string& realmId, const std::string& userId, int durationSeconds = 60);
bool isKicked(const std::string& realmId, const std::string& userId);
// Slow mode
bool canSendMessage(const std::string& realmId, const std::string& userId, int slowModeSeconds);
void recordMessageSent(const std::string& realmId, const std::string& userId);
// Guest ID generation
std::string generateGuestId(const std::string& pattern);
// SECURITY FIX #31: Server-side guest name persistence (by fingerprint)
// Stores guest's chosen name server-side so it persists across sessions
// More secure than localStorage which is accessible to same-origin XSS
bool setGuestName(const std::string& fingerprint, const std::string& name);
std::optional<std::string> getGuestName(const std::string& fingerprint);
bool clearGuestName(const std::string& fingerprint);
// Active users
void recordUserActivity(const std::string& realmId, const std::string& userId);
int getActiveUserCount(const std::string& realmId);
// Pending uberban check (set by backend or chat-service, checked on connect)
bool setPendingUberban(const std::string& userId);
bool hasPendingUberban(const std::string& userId);
bool clearPendingUberban(const std::string& userId);
private:
RedisMessageStore() = default;
~RedisMessageStore() = default;
RedisMessageStore(const RedisMessageStore&) = delete;
RedisMessageStore& operator=(const RedisMessageStore&) = delete;
std::unique_ptr<sw::redis::Redis> redis_;
std::string getMessagesKey(const std::string& realmId) const {
return "chat:messages:" + realmId;
}
std::string getBanKey(const std::string& realmId) const {
return "chat:banned:" + realmId;
}
std::string getMuteKey(const std::string& realmId, const std::string& userId) const {
return "chat:muted:" + realmId + ":" + userId;
}
std::string getSlowModeKey(const std::string& realmId, const std::string& userId) const {
return "chat:slowmode:" + realmId + ":" + userId;
}
std::string getActiveUsersKey(const std::string& realmId) const {
return "chat:active:" + realmId;
}
std::string getRealmBanKey(const std::string& realmId) const {
return "chat:realm_banned:" + realmId;
}
std::string getKickKey(const std::string& realmId, const std::string& userId) const {
return "chat:kicked:" + realmId + ":" + userId;
}
};
} // namespace services

View file

@ -0,0 +1,342 @@
#include "StickerService.h"
#include <drogon/drogon.h>
#include <drogon/HttpClient.h>
#include <regex>
#include <algorithm>
#include <unordered_set>
namespace services {
StickerService& StickerService::getInstance() {
static StickerService instance;
return instance;
}
void StickerService::initialize() {
// Initialize random number generator with random seed
std::random_device rd;
rng_.seed(rd());
initialized_ = true;
LOG_INFO << "StickerService initialized";
}
void StickerService::scheduleFetch() {
// This should be called from main.cpp after event loop setup
LOG_INFO << "Scheduling sticker fetch in 2 seconds...";
drogon::app().getLoop()->runAfter(2.0, [this]() {
LOG_INFO << "Pre-fetching stickers from backend...";
fetchStickersAsync();
});
}
void StickerService::ensureStickersLoaded() {
// Check if we need to fetch (lazy load on first use, then refresh daily)
auto now = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::seconds>(now - lastFetch_).count();
// Fetch if never fetched or cache expired
if (lastFetch_ == std::chrono::steady_clock::time_point{} || elapsed > CACHE_TTL_SECONDS) {
fetchStickersFromBackend();
}
}
void StickerService::fetchStickersFromBackend() {
auto config = drogon::app().getCustomConfig();
auto backendConfig = config.get("backend_api", Json::Value::null);
std::string host;
int port;
if (backendConfig.isNull() || !backendConfig.isMember("host")) {
host = "drogon-backend";
port = 8080;
} else {
host = backendConfig.get("host", "drogon-backend").asString();
port = backendConfig.get("port", 8080).asInt();
}
auto client = drogon::HttpClient::newHttpClient("http://" + host + ":" + std::to_string(port));
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->setPath("/api/admin/stickers");
// Synchronous request with timeout
std::pair<drogon::ReqResult, drogon::HttpResponsePtr> result = client->sendRequest(req, 5.0);
if (result.first != drogon::ReqResult::Ok) {
LOG_ERROR << "Failed to fetch stickers from backend: request failed";
return;
}
auto resp = result.second;
if (resp->getStatusCode() != drogon::k200OK) {
LOG_ERROR << "Failed to fetch stickers from backend: HTTP " << resp->getStatusCode();
return;
}
try {
auto json = resp->getJsonObject();
if (!json || !(*json)["success"].asBool()) {
LOG_ERROR << "Failed to fetch stickers: invalid response";
return;
}
std::lock_guard<std::mutex> lock(mutex_);
stickers_.clear();
const auto& stickersArray = (*json)["stickers"];
for (const auto& s : stickersArray) {
Sticker sticker;
sticker.id = s["id"].asInt64();
sticker.name = s["name"].asString();
sticker.filePath = s["filePath"].asString();
stickers_.push_back(sticker);
}
lastFetch_ = std::chrono::steady_clock::now();
LOG_DEBUG << "Fetched " << stickers_.size() << " stickers from backend";
} catch (const std::exception& e) {
LOG_ERROR << "Error parsing stickers response: " << e.what();
}
}
void StickerService::fetchStickersAsync() {
auto config = drogon::app().getCustomConfig();
LOG_DEBUG << "Custom config keys: " << config.getMemberNames().size();
for (const auto& key : config.getMemberNames()) {
LOG_DEBUG << " Config key: " << key;
}
auto backendConfig = config.get("backend_api", Json::Value::null);
std::string host;
int port;
if (backendConfig.isNull() || !backendConfig.isMember("host")) {
// Fallback: hardcode the Docker network hostname
LOG_WARN << "backend_api config not found, using hardcoded defaults";
host = "drogon-backend";
port = 8080;
} else {
host = backendConfig.get("host", "drogon-backend").asString();
port = backendConfig.get("port", 8080).asInt();
}
std::string url = "http://" + host + ":" + std::to_string(port);
LOG_INFO << "Creating HTTP client for: " << url;
auto client = drogon::HttpClient::newHttpClient(url, drogon::app().getLoop());
auto req = drogon::HttpRequest::newHttpRequest();
req->setMethod(drogon::Get);
req->setPath("/api/admin/stickers");
// Capture client to keep it alive during async request
client->sendRequest(req, [this, client](drogon::ReqResult result, const drogon::HttpResponsePtr& resp) {
if (result != drogon::ReqResult::Ok) {
std::string errorMsg;
switch (result) {
case drogon::ReqResult::BadResponse: errorMsg = "BadResponse"; break;
case drogon::ReqResult::NetworkFailure: errorMsg = "NetworkFailure"; break;
case drogon::ReqResult::BadServerAddress: errorMsg = "BadServerAddress"; break;
case drogon::ReqResult::Timeout: errorMsg = "Timeout"; break;
case drogon::ReqResult::HandshakeError: errorMsg = "HandshakeError"; break;
case drogon::ReqResult::InvalidCertificate: errorMsg = "InvalidCertificate"; break;
default: errorMsg = "Unknown(" + std::to_string(static_cast<int>(result)) + ")"; break;
}
LOG_ERROR << "Async fetch stickers failed: " << errorMsg;
return;
}
if (resp->getStatusCode() != drogon::k200OK) {
LOG_ERROR << "Async fetch stickers failed: HTTP " << resp->getStatusCode();
return;
}
try {
auto json = resp->getJsonObject();
if (!json || !(*json)["success"].asBool()) {
LOG_ERROR << "Async fetch stickers: invalid response";
return;
}
std::lock_guard<std::mutex> lock(mutex_);
stickers_.clear();
const auto& stickersArray = (*json)["stickers"];
for (const auto& s : stickersArray) {
Sticker sticker;
sticker.id = s["id"].asInt64();
sticker.name = s["name"].asString();
sticker.filePath = s["filePath"].asString();
stickers_.push_back(sticker);
}
lastFetch_ = std::chrono::steady_clock::now();
LOG_INFO << "Successfully fetched " << stickers_.size() << " stickers from backend";
} catch (const std::exception& e) {
LOG_ERROR << "Error parsing async stickers response: " << e.what();
}
}, 10.0); // 10 second timeout
}
void StickerService::refreshCache() {
fetchStickersAsync();
}
std::string StickerService::getRandomStickerName() {
// Ensure stickers are loaded (lazy load / refresh if expired)
ensureStickersLoaded();
std::lock_guard<std::mutex> lock(mutex_);
if (stickers_.empty()) {
LOG_WARN << ":roll: used but no stickers available in database";
return ":roll:"; // Keep :roll: as-is if no stickers available
}
std::uniform_int_distribution<size_t> dist(0, stickers_.size() - 1);
return ":" + stickers_[dist(rng_)].name + ":";
}
std::string StickerService::getRandomDiceStickerName() {
std::lock_guard<std::mutex> lock(mutex_);
// Generate random number 1-6
std::uniform_int_distribution<int> dist(1, 6);
int roll = dist(rng_);
return ":d" + std::to_string(roll) + ":";
}
ProcessedStickerResult StickerService::processSpecialStickers(const std::string& content) {
ProcessedStickerResult result;
result.usedRoll = false;
result.usedRtd = false;
std::string processed = content;
// Replace all occurrences of :roll: with random stickers
// Each :roll: gets a different random sticker
std::regex rollPattern(":roll:", std::regex::icase);
std::string::const_iterator searchStart(processed.cbegin());
std::smatch match;
std::string temp;
size_t lastPos = 0;
while (std::regex_search(searchStart, processed.cend(), match, rollPattern)) {
result.usedRoll = true;
size_t matchPos = match.position() + (searchStart - processed.cbegin());
temp += processed.substr(lastPos, matchPos - lastPos);
temp += getRandomStickerName();
lastPos = matchPos + match.length();
searchStart = match.suffix().first;
}
temp += processed.substr(lastPos);
processed = temp;
// Replace all occurrences of :rtd: with random dice stickers
std::regex rtdPattern(":rtd:", std::regex::icase);
searchStart = processed.cbegin();
temp.clear();
lastPos = 0;
while (std::regex_search(searchStart, processed.cend(), match, rtdPattern)) {
result.usedRtd = true;
size_t matchPos = match.position() + (searchStart - processed.cbegin());
temp += processed.substr(lastPos, matchPos - lastPos);
temp += getRandomDiceStickerName();
lastPos = matchPos + match.length();
searchStart = match.suffix().first;
}
temp += processed.substr(lastPos);
result.content = temp;
return result;
}
std::vector<std::string> StickerService::extractStickerNames(const std::string& content) {
// Ensure stickers are loaded before extracting
ensureStickersLoaded();
// Copy stickers under lock to minimize lock duration
std::vector<Sticker> stickersCopy;
{
std::lock_guard<std::mutex> lock(mutex_);
stickersCopy = stickers_;
}
std::vector<std::string> names;
std::unordered_set<std::string> seen; // Track unique stickers (case-insensitive)
std::regex stickerPattern(":([a-zA-Z0-9_]+):");
std::sregex_iterator iter(content.begin(), content.end(), stickerPattern);
std::sregex_iterator end;
while (iter != end) {
std::string name = (*iter)[1].str();
// Convert to lowercase for deduplication and lookup
std::string nameLower = name;
std::transform(nameLower.begin(), nameLower.end(), nameLower.begin(), ::tolower);
// Skip if we've already seen this sticker (case-insensitive)
if (seen.count(nameLower) > 0) {
++iter;
continue;
}
// Check if it's a valid sticker in our cache
auto it = std::find_if(stickersCopy.begin(), stickersCopy.end(),
[&nameLower](const Sticker& s) {
std::string stickerNameLower = s.name;
std::transform(stickerNameLower.begin(), stickerNameLower.end(),
stickerNameLower.begin(), ::tolower);
return stickerNameLower == nameLower;
});
if (it != stickersCopy.end()) {
names.push_back(it->name); // Use canonical name from cache
seen.insert(nameLower); // Mark as seen
}
++iter;
}
return names;
}
void StickerService::trackStickerUsage(const std::vector<std::string>& stickerNames) {
if (stickerNames.empty()) return;
auto config = drogon::app().getCustomConfig();
auto backendConfig = config.get("backend_api", Json::Value::null);
std::string host;
int port;
if (backendConfig.isNull() || !backendConfig.isMember("host")) {
host = "drogon-backend";
port = 8080;
} else {
host = backendConfig.get("host", "drogon-backend").asString();
port = backendConfig.get("port", 8080).asInt();
}
auto client = drogon::HttpClient::newHttpClient(
"http://" + host + ":" + std::to_string(port),
drogon::app().getLoop()
);
Json::Value body;
body["stickers"] = Json::arrayValue;
for (const auto& name : stickerNames) {
body["stickers"].append(name);
}
auto req = drogon::HttpRequest::newHttpJsonRequest(body);
req->setMethod(drogon::Post);
req->setPath("/api/internal/stickers/track-usage");
// Fire-and-forget - we don't wait for response
client->sendRequest(req, [client](drogon::ReqResult result, const drogon::HttpResponsePtr&) {
if (result != drogon::ReqResult::Ok) {
LOG_WARN << "Failed to track sticker usage: " << static_cast<int>(result);
} else {
LOG_DEBUG << "Sticker usage tracked successfully";
}
}, 5.0);
}
} // namespace services

View file

@ -0,0 +1,69 @@
#pragma once
#include <string>
#include <vector>
#include <unordered_map>
#include <mutex>
#include <chrono>
#include <random>
namespace services {
struct Sticker {
int64_t id;
std::string name;
std::string filePath;
};
struct ProcessedStickerResult {
std::string content;
bool usedRoll;
bool usedRtd;
};
class StickerService {
public:
static StickerService& getInstance();
void initialize();
// Schedule async fetch after event loop starts (call from main)
void scheduleFetch();
// Get a random sticker name for :roll:
std::string getRandomStickerName();
// Get a random dice sticker name for :rtd: (d1-d6)
std::string getRandomDiceStickerName();
// Process message content, replacing :roll: and :rtd: with actual sticker names
ProcessedStickerResult processSpecialStickers(const std::string& content);
// Force refresh the sticker cache
void refreshCache();
// Extract sticker names from message content (for usage tracking)
std::vector<std::string> extractStickerNames(const std::string& content);
// Track sticker usage - sends increment to backend (fire-and-forget)
void trackStickerUsage(const std::vector<std::string>& stickerNames);
private:
StickerService() = default;
~StickerService() = default;
StickerService(const StickerService&) = delete;
StickerService& operator=(const StickerService&) = delete;
void fetchStickersFromBackend();
void fetchStickersAsync();
void ensureStickersLoaded();
std::vector<Sticker> stickers_;
std::mutex mutex_;
std::chrono::steady_clock::time_point lastFetch_;
static constexpr int CACHE_TTL_SECONDS = 3600; // 1 hour cache
bool initialized_ = false;
std::mt19937 rng_;
};
} // namespace services