Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
7
chat-service/.dockerignore
Normal file
7
chat-service/.dockerignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
build/
|
||||
.git/
|
||||
.gitignore
|
||||
*.md
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
55
chat-service/CMakeLists.txt
Normal file
55
chat-service/CMakeLists.txt
Normal 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
87
chat-service/Dockerfile
Normal 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
331
chat-service/README.md
Normal 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
|
||||
14
chat-service/conanfile.txt
Normal file
14
chat-service/conanfile.txt
Normal 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
49
chat-service/config.json
Normal 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
|
||||
}
|
||||
}
|
||||
129
chat-service/src/controllers/ChatAdminController.cpp
Normal file
129
chat-service/src/controllers/ChatAdminController.cpp
Normal 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);
|
||||
}
|
||||
21
chat-service/src/controllers/ChatAdminController.h
Normal file
21
chat-service/src/controllers/ChatAdminController.h
Normal 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);
|
||||
};
|
||||
184
chat-service/src/controllers/ChatController.cpp
Normal file
184
chat-service/src/controllers/ChatController.cpp
Normal 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);
|
||||
}
|
||||
38
chat-service/src/controllers/ChatController.h
Normal file
38
chat-service/src/controllers/ChatController.h
Normal 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);
|
||||
};
|
||||
1340
chat-service/src/controllers/ChatWebSocketController.cpp
Normal file
1340
chat-service/src/controllers/ChatWebSocketController.cpp
Normal file
File diff suppressed because it is too large
Load diff
103
chat-service/src/controllers/ChatWebSocketController.h
Normal file
103
chat-service/src/controllers/ChatWebSocketController.h
Normal 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);
|
||||
};
|
||||
392
chat-service/src/controllers/ModerationController.cpp
Normal file
392
chat-service/src/controllers/ModerationController.cpp
Normal 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);
|
||||
}
|
||||
46
chat-service/src/controllers/ModerationController.h
Normal file
46
chat-service/src/controllers/ModerationController.h
Normal 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);
|
||||
};
|
||||
1470
chat-service/src/controllers/WatchSyncController.cpp
Normal file
1470
chat-service/src/controllers/WatchSyncController.cpp
Normal file
File diff suppressed because it is too large
Load diff
152
chat-service/src/controllers/WatchSyncController.h
Normal file
152
chat-service/src/controllers/WatchSyncController.h
Normal 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
134
chat-service/src/main.cpp
Normal 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;
|
||||
}
|
||||
46
chat-service/src/middleware/ChatAuthMiddleware.cpp
Normal file
46
chat-service/src/middleware/ChatAuthMiddleware.cpp
Normal 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();
|
||||
}
|
||||
11
chat-service/src/middleware/ChatAuthMiddleware.h
Normal file
11
chat-service/src/middleware/ChatAuthMiddleware.h
Normal 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;
|
||||
};
|
||||
179
chat-service/src/models/ChatMessage.h
Normal file
179
chat-service/src/models/ChatMessage.h
Normal 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
|
||||
67
chat-service/src/models/ModerationAction.h
Normal file
67
chat-service/src/models/ModerationAction.h
Normal 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
|
||||
73
chat-service/src/services/AuthService.cpp
Normal file
73
chat-service/src/services/AuthService.cpp
Normal 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
|
||||
41
chat-service/src/services/AuthService.h
Normal file
41
chat-service/src/services/AuthService.h
Normal 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
|
||||
279
chat-service/src/services/CensorService.cpp
Normal file
279
chat-service/src/services/CensorService.cpp
Normal 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
|
||||
51
chat-service/src/services/CensorService.h
Normal file
51
chat-service/src/services/CensorService.h
Normal 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
|
||||
294
chat-service/src/services/ChatService.cpp
Normal file
294
chat-service/src/services/ChatService.cpp
Normal 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
|
||||
83
chat-service/src/services/ChatService.h
Normal file
83
chat-service/src/services/ChatService.h
Normal 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
|
||||
249
chat-service/src/services/ModerationService.cpp
Normal file
249
chat-service/src/services/ModerationService.cpp
Normal 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
|
||||
67
chat-service/src/services/ModerationService.h
Normal file
67
chat-service/src/services/ModerationService.h
Normal 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
|
||||
651
chat-service/src/services/RedisMessageStore.cpp
Normal file
651
chat-service/src/services/RedisMessageStore.cpp
Normal 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
|
||||
124
chat-service/src/services/RedisMessageStore.h
Normal file
124
chat-service/src/services/RedisMessageStore.h
Normal 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
|
||||
342
chat-service/src/services/StickerService.cpp
Normal file
342
chat-service/src/services/StickerService.cpp
Normal 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
|
||||
69
chat-service/src/services/StickerService.h
Normal file
69
chat-service/src/services/StickerService.h
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue