Initial commit - realms platform
This commit is contained in:
parent
c590ab6d18
commit
c717c3751c
234 changed files with 74103 additions and 15231 deletions
11
.env
11
.env
|
|
@ -1,11 +0,0 @@
|
|||
# Database
|
||||
DB_PASSWORD=your-secure-password
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-very-long-random-jwt-secret
|
||||
|
||||
# OvenMediaEngine API
|
||||
OME_API_TOKEN=your-ome-api-token
|
||||
|
||||
# Application
|
||||
APP_ENV=production
|
||||
93
.env.example
Normal file
93
.env.example
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# =============================================================================
|
||||
# realms.india Environment Configuration Template
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in your actual values
|
||||
# IMPORTANT: Never commit the .env file to version control!
|
||||
|
||||
# =============================================================================
|
||||
# Database Configuration
|
||||
# =============================================================================
|
||||
# PostgreSQL password for user 'streamuser'
|
||||
# SECURITY: Generate a strong password (16+ characters, mixed case, numbers, symbols)
|
||||
DB_PASSWORD=CHANGE_ME_generate_strong_password_here
|
||||
|
||||
# =============================================================================
|
||||
# JWT Authentication
|
||||
# =============================================================================
|
||||
# Secret key for signing JWT tokens
|
||||
# SECURITY: Must be a cryptographically secure random string (32+ characters)
|
||||
# Generate with: openssl rand -base64 32
|
||||
JWT_SECRET=CHANGE_ME_generate_with_openssl_rand_base64_32
|
||||
|
||||
# =============================================================================
|
||||
# OvenMediaEngine API
|
||||
# =============================================================================
|
||||
# API token for OvenMediaEngine management
|
||||
# Generate a random token for authentication
|
||||
OME_API_TOKEN=CHANGE_ME_generate_random_token_here
|
||||
|
||||
# =============================================================================
|
||||
# Application Environment
|
||||
# =============================================================================
|
||||
# Environment mode: development | production
|
||||
APP_ENV=production
|
||||
|
||||
# =============================================================================
|
||||
# Optional: Advanced Configuration
|
||||
# =============================================================================
|
||||
# Uncomment and configure if needed
|
||||
|
||||
# Database settings (defaults shown)
|
||||
# DB_HOST=postgres
|
||||
# DB_NAME=streaming
|
||||
# DB_USER=streamuser
|
||||
# DB_PORT=5432
|
||||
|
||||
# Redis settings
|
||||
# SECURITY: Generate a strong password for Redis authentication
|
||||
REDIS_PASSWORD=CHANGE_ME_generate_strong_password_here
|
||||
|
||||
# REDIS_HOST=redis
|
||||
# REDIS_PORT=6379
|
||||
# REDIS_DB=0
|
||||
|
||||
# Chat Redis database (defaults shown)
|
||||
# CHAT_REDIS_DB=1
|
||||
|
||||
# =============================================================================
|
||||
# Nakama Game Server
|
||||
# =============================================================================
|
||||
# Server key for client authentication
|
||||
# Generate with: openssl rand -hex 16
|
||||
NAKAMA_SERVER_KEY=CHANGE_ME_generate_with_openssl_rand_hex_16
|
||||
|
||||
# Console admin password
|
||||
# SECURITY: Use a strong password for the Nakama admin console
|
||||
NAKAMA_CONSOLE_PASSWORD=CHANGE_ME_nakama_admin_password
|
||||
|
||||
# Frontend Nakama configuration
|
||||
# These are passed to the SvelteKit frontend
|
||||
VITE_NAKAMA_SERVER_KEY=CHANGE_ME_must_match_NAKAMA_SERVER_KEY
|
||||
VITE_NAKAMA_HOST=localhost
|
||||
VITE_NAKAMA_PORT=80
|
||||
VITE_NAKAMA_USE_SSL=false
|
||||
|
||||
# =============================================================================
|
||||
# Security Notes:
|
||||
# =============================================================================
|
||||
# 1. Generate DB_PASSWORD: Use a password manager or:
|
||||
# openssl rand -base64 24
|
||||
#
|
||||
# 2. Generate JWT_SECRET: MUST be cryptographically secure:
|
||||
# openssl rand -base64 32
|
||||
#
|
||||
# 3. Generate OME_API_TOKEN:
|
||||
# openssl rand -hex 32
|
||||
#
|
||||
# 4. Generate NAKAMA_SERVER_KEY:
|
||||
# openssl rand -hex 16
|
||||
#
|
||||
# 5. Never use default/example values in production
|
||||
# 6. Restrict .env file permissions: chmod 600 .env
|
||||
# 7. Rotate secrets regularly (every 90 days recommended)
|
||||
# =============================================================================
|
||||
127
.forgejo/workflows/build.yml
Normal file
127
.forgejo/workflows/build.yml
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
# =============================================================================
|
||||
# Build and Push Docker Images to Forgejo Registry
|
||||
# =============================================================================
|
||||
# Triggers on push to main branch and pull requests
|
||||
# Builds: backend, frontend, chat-service, openresty
|
||||
# =============================================================================
|
||||
|
||||
name: Build and Push
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
env:
|
||||
REGISTRY: qbit.realms.pub
|
||||
IMAGE_PREFIX: realms
|
||||
|
||||
jobs:
|
||||
# ===========================================================================
|
||||
# Build Backend (C++/Drogon)
|
||||
# ===========================================================================
|
||||
build-backend:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Forgejo Registry
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Build Backend Image
|
||||
run: |
|
||||
docker build \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/backend:${{ github.sha }} \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/backend:latest \
|
||||
./backend
|
||||
|
||||
- name: Push Backend Image
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/backend:${{ github.sha }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/backend:latest
|
||||
|
||||
# ===========================================================================
|
||||
# Build Frontend (SvelteKit)
|
||||
# ===========================================================================
|
||||
build-frontend:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Forgejo Registry
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Build Frontend Image
|
||||
run: |
|
||||
docker build \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/frontend:${{ github.sha }} \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/frontend:latest \
|
||||
./frontend
|
||||
|
||||
- name: Push Frontend Image
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/frontend:${{ github.sha }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/frontend:latest
|
||||
|
||||
# ===========================================================================
|
||||
# Build Chat Service
|
||||
# ===========================================================================
|
||||
build-chat:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Forgejo Registry
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Build Chat Service Image
|
||||
run: |
|
||||
docker build \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/chat-service:${{ github.sha }} \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/chat-service:latest \
|
||||
./chat-service
|
||||
|
||||
- name: Push Chat Service Image
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/chat-service:${{ github.sha }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/chat-service:latest
|
||||
|
||||
# ===========================================================================
|
||||
# Build OpenResty (Nginx + Lua)
|
||||
# ===========================================================================
|
||||
build-openresty:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Forgejo Registry
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
|
||||
|
||||
- name: Build OpenResty Image
|
||||
run: |
|
||||
docker build \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/openresty:${{ github.sha }} \
|
||||
-t ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/openresty:latest \
|
||||
./openresty
|
||||
|
||||
- name: Push OpenResty Image
|
||||
if: github.event_name == 'push'
|
||||
run: |
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/openresty:${{ github.sha }}
|
||||
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}/openresty:latest
|
||||
84
.forgejo/workflows/deploy.yml
Normal file
84
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# =============================================================================
|
||||
# Deploy to Production Server
|
||||
# =============================================================================
|
||||
# Triggers after successful build on main/master branch
|
||||
# SSHs to production server and updates containers
|
||||
# =============================================================================
|
||||
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push"]
|
||||
types: [completed]
|
||||
branches: [main, master]
|
||||
|
||||
env:
|
||||
REGISTRY: qbit.realms.pub
|
||||
IMAGE_PREFIX: realms
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
# Only deploy if the build succeeded
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: docker
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup SSH key
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
# Add host key (skip strict checking for first connection)
|
||||
ssh-keyscan -p ${{ secrets.DEPLOY_PORT }} ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts 2>/dev/null || true
|
||||
|
||||
- name: Copy docker-compose to server
|
||||
run: |
|
||||
scp -i ~/.ssh/deploy_key -P ${{ secrets.DEPLOY_PORT }} \
|
||||
-o StrictHostKeyChecking=no \
|
||||
docker-compose.prod.yml \
|
||||
root@${{ secrets.DEPLOY_HOST }}:/opt/realms/docker-compose.yml
|
||||
|
||||
- name: Deploy to Production
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key -p ${{ secrets.DEPLOY_PORT }} \
|
||||
-o StrictHostKeyChecking=no \
|
||||
root@${{ secrets.DEPLOY_HOST }} '
|
||||
set -e
|
||||
cd /opt/realms
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login ${{ env.REGISTRY }} -u ${{ github.actor }} --password-stdin
|
||||
|
||||
# Pull latest images
|
||||
docker compose pull
|
||||
|
||||
# Bring up services with zero-downtime restart
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# Prune old images
|
||||
docker image prune -f
|
||||
|
||||
# Show running containers
|
||||
docker compose ps
|
||||
'
|
||||
|
||||
- name: Cleanup SSH key
|
||||
if: always()
|
||||
run: rm -f ~/.ssh/deploy_key
|
||||
|
||||
- name: Health Check
|
||||
run: |
|
||||
sleep 10
|
||||
ssh -i ~/.ssh/deploy_key -p ${{ secrets.DEPLOY_PORT }} \
|
||||
-o StrictHostKeyChecking=no \
|
||||
root@${{ secrets.DEPLOY_HOST }} '
|
||||
# Check if services are running
|
||||
docker compose ps --format "table {{.Name}}\t{{.Status}}"
|
||||
|
||||
# Basic health check for frontend
|
||||
curl -sf http://localhost:80/health || echo "Frontend health check pending"
|
||||
' || true
|
||||
146
.gitignore
vendored
Normal file
146
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# =============================================================================
|
||||
# realms.india - Git Ignore Configuration
|
||||
# =============================================================================
|
||||
|
||||
# Environment Variables (CRITICAL - Contains Secrets!)
|
||||
# ======================================================
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
# Docker
|
||||
# ======================================================
|
||||
docker-compose.override.yml
|
||||
.docker/
|
||||
|
||||
# Database
|
||||
# ======================================================
|
||||
*.sql.backup
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
postgres_data/
|
||||
redis_data/
|
||||
|
||||
# Logs
|
||||
# ======================================================
|
||||
*.log
|
||||
logs/
|
||||
ome_logs/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Build Artifacts
|
||||
# ======================================================
|
||||
build/
|
||||
dist/
|
||||
out/
|
||||
.output/
|
||||
*.o
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
*.exe
|
||||
CMakeCache.txt
|
||||
CMakeFiles/
|
||||
cmake_install.cmake
|
||||
Makefile
|
||||
*.cmake
|
||||
!CMakeLists.txt
|
||||
|
||||
# Dependencies
|
||||
# ======================================================
|
||||
node_modules/
|
||||
.pnp
|
||||
.pnp.js
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
bun.lockb
|
||||
bun.lock
|
||||
_deps/
|
||||
conan_install/
|
||||
|
||||
# IDE & Editors
|
||||
# ======================================================
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
.project
|
||||
.classpath
|
||||
.settings/
|
||||
|
||||
# Frontend
|
||||
# ======================================================
|
||||
.svelte-kit/
|
||||
.vercel/
|
||||
.netlify/
|
||||
.cache/
|
||||
.tmp/
|
||||
.temp/
|
||||
|
||||
# Testing
|
||||
# ======================================================
|
||||
coverage/
|
||||
.nyc_output/
|
||||
*.lcov
|
||||
|
||||
# OS
|
||||
# ======================================================
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
|
||||
# Uploads & User Content
|
||||
# ======================================================
|
||||
uploads/
|
||||
avatars/
|
||||
!uploads/.gitkeep
|
||||
|
||||
# Security & Credentials
|
||||
# ======================================================
|
||||
*.pem
|
||||
*.key
|
||||
*.cert
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
credentials.json
|
||||
secrets.json
|
||||
.pgp/
|
||||
.gnupg/
|
||||
backend/config.json
|
||||
!backend/config.json.example
|
||||
|
||||
# Temporary Files
|
||||
# ======================================================
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
*.bak
|
||||
*.backup
|
||||
*~.nib
|
||||
*.swp
|
||||
*.save
|
||||
|
||||
# GPG/PGP
|
||||
# ======================================================
|
||||
/tmp/pgp_verify/
|
||||
*.asc
|
||||
*.sig
|
||||
!*.asc.example
|
||||
|
||||
# Claude Code
|
||||
# ======================================================
|
||||
.claude/
|
||||
|
|
@ -15,6 +15,15 @@ find_package(PostgreSQL REQUIRED)
|
|||
pkg_check_modules(HIREDIS REQUIRED hiredis)
|
||||
pkg_check_modules(REDIS_PLUS_PLUS redis++)
|
||||
|
||||
# Find libzip for EPUB cover extraction
|
||||
pkg_check_modules(LIBZIP REQUIRED libzip)
|
||||
|
||||
# Find GPGME for PGP signature verification
|
||||
pkg_check_modules(GPGME REQUIRED gpgme)
|
||||
|
||||
# Find OpenSSL for cryptographic operations (SECURITY FIX #4)
|
||||
find_package(OpenSSL REQUIRED)
|
||||
|
||||
# Manual fallback for redis++
|
||||
if(NOT REDIS_PLUS_PLUS_FOUND)
|
||||
find_path(REDIS_PLUS_PLUS_INCLUDE_DIR sw/redis++/redis++.h
|
||||
|
|
@ -61,10 +70,19 @@ set(SOURCES
|
|||
src/controllers/UserController.cpp
|
||||
src/controllers/AdminController.cpp
|
||||
src/controllers/RealmController.cpp
|
||||
src/controllers/RestreamController.cpp
|
||||
src/controllers/VideoController.cpp
|
||||
src/controllers/AudioController.cpp
|
||||
src/controllers/EbookController.cpp
|
||||
src/controllers/ForumController.cpp
|
||||
src/controllers/WatchController.cpp
|
||||
src/services/DatabaseService.cpp
|
||||
src/services/StatsService.cpp
|
||||
src/services/RedisHelper.cpp
|
||||
src/services/AuthService.cpp
|
||||
src/services/RestreamService.cpp
|
||||
src/services/CensorService.cpp
|
||||
src/services/TreasuryService.cpp
|
||||
)
|
||||
|
||||
# Create executable
|
||||
|
|
@ -79,6 +97,8 @@ target_include_directories(${PROJECT_NAME}
|
|||
SYSTEM PRIVATE
|
||||
${HIREDIS_INCLUDE_DIRS}
|
||||
${REDIS_PLUS_PLUS_INCLUDE_DIRS}
|
||||
${LIBZIP_INCLUDE_DIRS}
|
||||
${GPGME_INCLUDE_DIRS}
|
||||
)
|
||||
|
||||
# Link libraries
|
||||
|
|
@ -89,6 +109,10 @@ target_link_libraries(${PROJECT_NAME}
|
|||
${REDIS_PLUS_PLUS_LIBRARIES}
|
||||
${HIREDIS_LIBRARIES}
|
||||
${BCRYPT_LIBRARY}
|
||||
${LIBZIP_LIBRARIES}
|
||||
${GPGME_LIBRARIES}
|
||||
OpenSSL::SSL
|
||||
OpenSSL::Crypto
|
||||
pthread
|
||||
)
|
||||
|
||||
|
|
@ -97,6 +121,7 @@ target_compile_options(${PROJECT_NAME}
|
|||
PRIVATE
|
||||
${HIREDIS_CFLAGS_OTHER}
|
||||
${REDIS_PLUS_PLUS_CFLAGS_OTHER}
|
||||
${GPGME_CFLAGS_OTHER}
|
||||
-Wall
|
||||
-Wextra
|
||||
-Wpedantic
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ FROM drogonframework/drogon:latest
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# Install additional dependencies including GPG for PGP verification
|
||||
# Install additional dependencies including GPGME for PGP verification, FFmpeg for thumbnails, and libzip for EPUB
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
postgresql-client \
|
||||
|
|
@ -14,6 +14,9 @@ RUN apt-get update && apt-get install -y \
|
|||
libssl-dev \
|
||||
gnupg \
|
||||
gnupg2 \
|
||||
libgpgme-dev \
|
||||
ffmpeg \
|
||||
libzip-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Try to install redis-plus-plus from package manager first
|
||||
|
|
@ -85,7 +88,7 @@ COPY config.json .
|
|||
|
||||
# Create uploads directory with proper permissions
|
||||
# Using nobody user's UID/GID (65534) for consistency with nginx
|
||||
RUN mkdir -p /app/uploads/avatars && \
|
||||
RUN mkdir -p /app/uploads/avatars /app/uploads/stickers /app/uploads/sticker-submissions /app/uploads/videos /app/uploads/logo /app/uploads/ebooks /app/uploads/ebooks/covers /app/uploads/forums && \
|
||||
chown -R 65534:65534 /app/uploads && \
|
||||
chmod -R 755 /app/uploads
|
||||
|
||||
|
|
@ -102,8 +105,10 @@ echo "Checking library dependencies..."\n\
|
|||
ldd ./build/streaming-backend\n\
|
||||
echo "Checking GPG installation..."\n\
|
||||
gpg --version\n\
|
||||
echo "Checking FFmpeg installation..."\n\
|
||||
ffmpeg -version | head -1\n\
|
||||
echo "Ensuring upload directories exist with proper permissions..."\n\
|
||||
mkdir -p /app/uploads/avatars\n\
|
||||
mkdir -p /app/uploads/avatars /app/uploads/stickers /app/uploads/sticker-submissions /app/uploads/videos /app/uploads/logo /app/uploads/ebooks /app/uploads/ebooks/covers /app/uploads/forums\n\
|
||||
chown -R 65534:65534 /app/uploads\n\
|
||||
chmod -R 755 /app/uploads\n\
|
||||
echo "Starting application..."\n\
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
"port": 5432,
|
||||
"dbname": "streaming",
|
||||
"user": "streamuser",
|
||||
"passwd": "streampass",
|
||||
"passwd": "CHANGE_ME_database_password",
|
||||
"is_fast": false,
|
||||
"connection_number": 10
|
||||
}
|
||||
|
|
@ -28,16 +28,27 @@
|
|||
"client_max_body_size": "100M",
|
||||
"enable_brotli": true,
|
||||
"enable_gzip": true,
|
||||
"log_level": "DEBUG"
|
||||
"log_level": "INFO"
|
||||
},
|
||||
"redis": {
|
||||
"host": "redis",
|
||||
"port": 6379
|
||||
"port": 6379,
|
||||
"db": 1,
|
||||
"timeout": 5
|
||||
},
|
||||
"ome": {
|
||||
"api_url": "http://ovenmediaengine:8081",
|
||||
"api_token": "your-api-token"
|
||||
"api_token": "CHANGE_ME_ome_api_token"
|
||||
},
|
||||
"plugins": [],
|
||||
"custom_config": {}
|
||||
"custom_config": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,54 @@
|
|||
#include <drogon/drogon.h>
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <cstdlib>
|
||||
#include <ctime>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
||||
// SECURITY FIX #32: Add audit logging for admin CLI operations
|
||||
namespace {
|
||||
void writeAuditLog(const std::string& action, const std::string& target,
|
||||
const std::string& status, const std::string& details = "") {
|
||||
// Get current timestamp
|
||||
auto now = std::time(nullptr);
|
||||
auto tm = *std::localtime(&now);
|
||||
std::ostringstream timestamp;
|
||||
timestamp << std::put_time(&tm, "%Y-%m-%d %H:%M:%S");
|
||||
|
||||
// Get hostname for audit trail
|
||||
char hostname[256] = "unknown";
|
||||
gethostname(hostname, sizeof(hostname));
|
||||
|
||||
// Build log entry
|
||||
std::ostringstream logEntry;
|
||||
logEntry << "[" << timestamp.str() << "] "
|
||||
<< "HOST=" << hostname << " "
|
||||
<< "ACTION=" << action << " "
|
||||
<< "TARGET=" << target << " "
|
||||
<< "STATUS=" << status;
|
||||
if (!details.empty()) {
|
||||
logEntry << " DETAILS=" << details;
|
||||
}
|
||||
logEntry << std::endl;
|
||||
|
||||
// Write to audit log file
|
||||
std::string logPath = "/var/log/admin_tool_audit.log";
|
||||
std::ofstream logFile(logPath, std::ios::app);
|
||||
if (logFile.is_open()) {
|
||||
logFile << logEntry.str();
|
||||
logFile.close();
|
||||
}
|
||||
|
||||
// Also output to stderr for immediate visibility
|
||||
std::cerr << "[AUDIT] " << logEntry.str();
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[]) {
|
||||
if (argc < 2) {
|
||||
std::cerr << "Usage: " << argv[0] << " -promote-admin <username>" << std::endl;
|
||||
|
|
@ -34,6 +77,8 @@ int main(int argc, char* argv[]) {
|
|||
1 // connection number
|
||||
);
|
||||
|
||||
writeAuditLog("PROMOTE_ADMIN_ATTEMPT", username, "STARTED");
|
||||
|
||||
try {
|
||||
// Check if user exists
|
||||
auto result = dbClient->execSqlSync(
|
||||
|
|
@ -42,12 +87,14 @@ int main(int argc, char* argv[]) {
|
|||
);
|
||||
|
||||
if (result.empty()) {
|
||||
writeAuditLog("PROMOTE_ADMIN", username, "FAILED", "user_not_found");
|
||||
std::cerr << "Error: User '" << username << "' not found." << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
bool isAdmin = result[0]["is_admin"].as<bool>();
|
||||
if (isAdmin) {
|
||||
writeAuditLog("PROMOTE_ADMIN", username, "SKIPPED", "already_admin");
|
||||
std::cout << "User '" << username << "' is already an admin." << std::endl;
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -58,13 +105,16 @@ int main(int argc, char* argv[]) {
|
|||
username
|
||||
);
|
||||
|
||||
writeAuditLog("PROMOTE_ADMIN", username, "SUCCESS");
|
||||
std::cout << "Successfully promoted '" << username << "' to admin." << std::endl;
|
||||
return 0;
|
||||
|
||||
} catch (const DrogonDbException& e) {
|
||||
writeAuditLog("PROMOTE_ADMIN", username, "ERROR", e.base().what());
|
||||
std::cerr << "Database error: " << e.base().what() << std::endl;
|
||||
return 1;
|
||||
} catch (const std::exception& e) {
|
||||
writeAuditLog("PROMOTE_ADMIN", username, "ERROR", e.what());
|
||||
std::cerr << "Error: " << e.what() << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
|
|
|||
82
backend/src/common/AuthHelpers.h
Normal file
82
backend/src/common/AuthHelpers.h
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpRequest.h>
|
||||
#include "../services/AuthService.h"
|
||||
#include "HttpHelpers.h"
|
||||
#include <optional>
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
// Extract user from request (cookie or Bearer token)
|
||||
inline UserInfo getUserFromRequest(const HttpRequestPtr& req) {
|
||||
UserInfo user;
|
||||
std::string token = req->getCookie("auth_token");
|
||||
|
||||
if (token.empty()) {
|
||||
std::string auth = req->getHeader("Authorization");
|
||||
if (!auth.empty() && auth.substr(0, 7) == "Bearer ") {
|
||||
token = auth.substr(7);
|
||||
}
|
||||
}
|
||||
|
||||
if (!token.empty()) {
|
||||
AuthService::getInstance().validateToken(token, user);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// Authorization check macro - returns 401 if not authenticated
|
||||
// SECURITY FIX #26: Also checks if account is disabled
|
||||
#define CHECK_AUTH(user, callback) \
|
||||
if (user.id == 0 || user.isDisabled) { \
|
||||
callback(jsonError("Unauthorized", k401Unauthorized)); \
|
||||
return; \
|
||||
}
|
||||
|
||||
// Admin check macro - returns 403 if not admin
|
||||
// SECURITY FIX #26: Also checks if account is disabled
|
||||
#define CHECK_ADMIN(user, callback) \
|
||||
if (user.id == 0 || !user.isAdmin || user.isDisabled) { \
|
||||
callback(jsonError("Admin access required", k403Forbidden)); \
|
||||
return; \
|
||||
}
|
||||
|
||||
// Parse ID from string with error handling
|
||||
template<typename T = int64_t>
|
||||
std::optional<T> parseId(const std::string& idStr) {
|
||||
try {
|
||||
return static_cast<T>(std::stoll(idStr));
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
}
|
||||
|
||||
// ID parsing macro - returns 400 if invalid
|
||||
#define PARSE_ID(varName, idStr, callback) \
|
||||
auto varName##_opt = parseId(idStr); \
|
||||
if (!varName##_opt) { \
|
||||
callback(jsonError("Invalid ID")); \
|
||||
return; \
|
||||
} \
|
||||
auto varName = *varName##_opt;
|
||||
|
||||
#define PARSE_ID_MSG(varName, idStr, callback, errorMsg) \
|
||||
auto varName##_opt = parseId(idStr); \
|
||||
if (!varName##_opt) { \
|
||||
callback(jsonError(errorMsg)); \
|
||||
return; \
|
||||
} \
|
||||
auto varName = *varName##_opt;
|
||||
|
||||
// Database error handler macro
|
||||
#define DB_ERROR(callback, operation) \
|
||||
[callback](const DrogonDbException& e) { \
|
||||
LOG_ERROR << "Failed to " operation ": " << e.base().what(); \
|
||||
callback(jsonError("Database error")); \
|
||||
}
|
||||
|
||||
// Database error handler with custom error message
|
||||
#define DB_ERROR_MSG(callback, operation, errorMsg) \
|
||||
[callback](const DrogonDbException& e) { \
|
||||
LOG_ERROR << "Failed to " operation ": " << e.base().what(); \
|
||||
callback(jsonError(errorMsg)); \
|
||||
}
|
||||
253
backend/src/common/FileUtils.h
Normal file
253
backend/src/common/FileUtils.h
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <filesystem>
|
||||
#include <array>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <fcntl.h>
|
||||
#include <trantor/utils/Logger.h>
|
||||
|
||||
// Generate cryptographically secure random hex filename with extension
|
||||
// Uses /dev/urandom for secure randomness instead of std::mt19937
|
||||
inline std::string generateRandomFilename(const std::string& ext) {
|
||||
std::array<unsigned char, 16> bytes;
|
||||
|
||||
// Read from /dev/urandom for cryptographically secure randomness
|
||||
int fd = open("/dev/urandom", O_RDONLY);
|
||||
if (fd >= 0) {
|
||||
ssize_t bytesRead = read(fd, bytes.data(), bytes.size());
|
||||
close(fd);
|
||||
if (bytesRead == static_cast<ssize_t>(bytes.size())) {
|
||||
std::stringstream ss;
|
||||
for (unsigned char b : bytes) {
|
||||
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(b);
|
||||
}
|
||||
return ss.str() + "." + ext;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to std::random_device if /dev/urandom fails
|
||||
// (shouldn't happen on Linux, but provides resilience)
|
||||
std::random_device rd;
|
||||
std::stringstream ss;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
ss << std::hex << std::setw(2) << std::setfill('0') << (rd() & 0xFF);
|
||||
}
|
||||
return ss.str() + "." + ext;
|
||||
}
|
||||
|
||||
// Atomically create a file with exclusive access (O_CREAT | O_EXCL)
|
||||
// Returns the file descriptor on success, or -1 on failure
|
||||
// This prevents TOCTOU race conditions
|
||||
inline int createFileExclusive(const std::string& path) {
|
||||
return open(path.c_str(), O_WRONLY | O_CREAT | O_EXCL, 0644);
|
||||
}
|
||||
|
||||
// Write data to file atomically with exclusive creation
|
||||
// Returns true on success, false on failure
|
||||
inline bool writeFileExclusive(const std::string& path, const char* data, size_t size) {
|
||||
int fd = createFileExclusive(path);
|
||||
if (fd < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ssize_t written = 0;
|
||||
while (written < static_cast<ssize_t>(size)) {
|
||||
ssize_t result = write(fd, data + written, size - written);
|
||||
if (result < 0) {
|
||||
close(fd);
|
||||
unlink(path.c_str()); // Clean up on error
|
||||
return false;
|
||||
}
|
||||
written += result;
|
||||
}
|
||||
|
||||
close(fd);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ============== INPUT SANITIZATION ==============
|
||||
|
||||
// Check if a string is valid UTF-8
|
||||
inline bool isValidUtf8(const std::string& str) {
|
||||
const unsigned char* bytes = reinterpret_cast<const unsigned char*>(str.data());
|
||||
size_t len = str.length();
|
||||
size_t i = 0;
|
||||
|
||||
while (i < len) {
|
||||
if (bytes[i] <= 0x7F) {
|
||||
// ASCII
|
||||
i++;
|
||||
} else if ((bytes[i] & 0xE0) == 0xC0) {
|
||||
// 2-byte sequence
|
||||
if (i + 1 >= len || (bytes[i + 1] & 0xC0) != 0x80) return false;
|
||||
i += 2;
|
||||
} else if ((bytes[i] & 0xF0) == 0xE0) {
|
||||
// 3-byte sequence
|
||||
if (i + 2 >= len || (bytes[i + 1] & 0xC0) != 0x80 || (bytes[i + 2] & 0xC0) != 0x80) return false;
|
||||
i += 3;
|
||||
} else if ((bytes[i] & 0xF8) == 0xF0) {
|
||||
// 4-byte sequence
|
||||
if (i + 3 >= len || (bytes[i + 1] & 0xC0) != 0x80 || (bytes[i + 2] & 0xC0) != 0x80 || (bytes[i + 3] & 0xC0) != 0x80) return false;
|
||||
i += 4;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Remove control characters (C0 and C1 control codes) from a string
|
||||
// Keeps printable characters, newlines, and tabs
|
||||
inline std::string sanitizeText(const std::string& input) {
|
||||
std::string result;
|
||||
result.reserve(input.size());
|
||||
|
||||
for (unsigned char c : input) {
|
||||
// Allow printable ASCII (0x20-0x7E), tab (0x09), newline (0x0A), carriage return (0x0D)
|
||||
// Also allow UTF-8 continuation bytes (0x80-0xFF) which are handled by isValidUtf8
|
||||
if ((c >= 0x20 && c <= 0x7E) || c == 0x09 || c == 0x0A || c == 0x0D || c >= 0x80) {
|
||||
result += c;
|
||||
}
|
||||
// Skip C0 control characters (0x00-0x1F except tab/newline/CR)
|
||||
// Skip DEL (0x7F)
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Sanitize user input text: validate UTF-8 and remove control characters
|
||||
inline std::string sanitizeUserInput(const std::string& input, size_t maxLength = 0) {
|
||||
// First validate UTF-8
|
||||
if (!isValidUtf8(input)) {
|
||||
// If not valid UTF-8, try to salvage by keeping only ASCII
|
||||
std::string ascii;
|
||||
for (char c : input) {
|
||||
if (c >= 0x20 && c <= 0x7E) {
|
||||
ascii += c;
|
||||
}
|
||||
}
|
||||
if (maxLength > 0 && ascii.length() > maxLength) {
|
||||
ascii = ascii.substr(0, maxLength);
|
||||
}
|
||||
return ascii;
|
||||
}
|
||||
|
||||
// Remove control characters
|
||||
std::string sanitized = sanitizeText(input);
|
||||
|
||||
// Truncate if needed
|
||||
if (maxLength > 0 && sanitized.length() > maxLength) {
|
||||
// Try to truncate at UTF-8 character boundary
|
||||
size_t truncateAt = maxLength;
|
||||
while (truncateAt > 0 && (sanitized[truncateAt] & 0xC0) == 0x80) {
|
||||
truncateAt--;
|
||||
}
|
||||
sanitized = sanitized.substr(0, truncateAt);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Ensure directory exists, optionally with permissions
|
||||
inline bool ensureDirectoryExists(const std::string& path, bool setPermissions = false) {
|
||||
try {
|
||||
if (!std::filesystem::exists(path)) {
|
||||
std::filesystem::create_directories(path);
|
||||
}
|
||||
if (setPermissions) {
|
||||
std::filesystem::permissions(path,
|
||||
std::filesystem::perms::owner_all |
|
||||
std::filesystem::perms::group_read | std::filesystem::perms::group_exec |
|
||||
std::filesystem::perms::others_read | std::filesystem::perms::others_exec
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to create directory " << path << ": " << e.what();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate path is safe (no shell metacharacters, within allowed directory)
|
||||
inline bool isPathSafe(const std::string& path, const std::string& allowedDir) {
|
||||
const std::string dangerous = ";|&$`\\\"'<>(){}[]!#*?~";
|
||||
for (char c : path) {
|
||||
if (dangerous.find(c) != std::string::npos) {
|
||||
LOG_WARN << "Rejected path with dangerous character: " << c;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
auto canonical = std::filesystem::canonical(path);
|
||||
auto allowedCanonical = std::filesystem::canonical(allowedDir);
|
||||
if (canonical.string().find(allowedCanonical.string()) != 0) {
|
||||
LOG_WARN << "Path traversal attempt: " << path;
|
||||
return false;
|
||||
}
|
||||
} catch (...) {
|
||||
auto parent = std::filesystem::path(path).parent_path();
|
||||
if (!std::filesystem::exists(parent)) return false;
|
||||
try {
|
||||
auto parentCanonical = std::filesystem::canonical(parent);
|
||||
auto allowedCanonical = std::filesystem::canonical(allowedDir);
|
||||
if (parentCanonical.string().find(allowedCanonical.string()) != 0) {
|
||||
LOG_WARN << "Path traversal attempt in parent: " << path;
|
||||
return false;
|
||||
}
|
||||
} catch (...) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Execute command safely using execv (no shell interpretation)
|
||||
inline std::string execCommandSafe(const std::vector<std::string>& args) {
|
||||
if (args.empty()) return "";
|
||||
|
||||
int pipefd[2];
|
||||
if (pipe(pipefd) == -1) return "";
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == -1) {
|
||||
close(pipefd[0]);
|
||||
close(pipefd[1]);
|
||||
return "";
|
||||
}
|
||||
|
||||
if (pid == 0) {
|
||||
close(pipefd[0]);
|
||||
dup2(pipefd[1], STDOUT_FILENO);
|
||||
dup2(open("/dev/null", O_WRONLY), STDERR_FILENO);
|
||||
close(pipefd[1]);
|
||||
|
||||
std::vector<char*> argv;
|
||||
for (const auto& arg : args) {
|
||||
argv.push_back(const_cast<char*>(arg.c_str()));
|
||||
}
|
||||
argv.push_back(nullptr);
|
||||
|
||||
execv(args[0].c_str(), argv.data());
|
||||
_exit(1);
|
||||
}
|
||||
|
||||
close(pipefd[1]);
|
||||
|
||||
std::string result;
|
||||
std::array<char, 128> buffer;
|
||||
ssize_t n;
|
||||
while ((n = read(pipefd[0], buffer.data(), buffer.size())) > 0) {
|
||||
result.append(buffer.data(), n);
|
||||
}
|
||||
close(pipefd[0]);
|
||||
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return result;
|
||||
}
|
||||
218
backend/src/common/FileValidation.h
Normal file
218
backend/src/common/FileValidation.h
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <cstddef>
|
||||
|
||||
// ============== IMAGE VALIDATION ==============
|
||||
|
||||
struct ImageValidation {
|
||||
bool valid;
|
||||
std::string detectedType; // "jpeg", "png", "gif", "webp", "svg"
|
||||
std::string extension; // ".jpg", ".png", ".gif", ".webp", ".svg"
|
||||
};
|
||||
|
||||
inline ImageValidation validateImageMagicBytes(const char* data, size_t size, bool allowSvg = false) {
|
||||
ImageValidation result{false, "", ""};
|
||||
if (!data || size < 3) return result;
|
||||
|
||||
const unsigned char* bytes = reinterpret_cast<const unsigned char*>(data);
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if (size >= 3 && bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF) {
|
||||
return {true, "jpeg", ".jpg"};
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if (size >= 8 && bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47 &&
|
||||
bytes[4] == 0x0D && bytes[5] == 0x0A && bytes[6] == 0x1A && bytes[7] == 0x0A) {
|
||||
return {true, "png", ".png"};
|
||||
}
|
||||
|
||||
// GIF: 47 49 46 38 (GIF87a or GIF89a)
|
||||
if (size >= 6 && bytes[0] == 0x47 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x38 &&
|
||||
(bytes[4] == 0x37 || bytes[4] == 0x39) && bytes[5] == 0x61) {
|
||||
return {true, "gif", ".gif"};
|
||||
}
|
||||
|
||||
// WebP: RIFF....WEBP
|
||||
if (size >= 12 && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 &&
|
||||
bytes[8] == 0x57 && bytes[9] == 0x45 && bytes[10] == 0x42 && bytes[11] == 0x50) {
|
||||
return {true, "webp", ".webp"};
|
||||
}
|
||||
|
||||
// SVG: Check for <?xml or <svg (with optional BOM/whitespace)
|
||||
if (allowSvg && size >= 4) {
|
||||
std::string content(data, std::min(size, (size_t)256));
|
||||
size_t start = 0;
|
||||
// Skip BOM if present
|
||||
if (size >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) {
|
||||
start = 3;
|
||||
}
|
||||
// Skip whitespace
|
||||
while (start < content.size() && std::isspace(static_cast<unsigned char>(content[start]))) {
|
||||
start++;
|
||||
}
|
||||
std::string trimmed = content.substr(start);
|
||||
if (trimmed.rfind("<?xml", 0) == 0 || trimmed.rfind("<svg", 0) == 0) {
|
||||
return {true, "svg", ".svg"};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============== VIDEO VALIDATION ==============
|
||||
|
||||
struct VideoValidation {
|
||||
bool valid;
|
||||
std::string detectedType; // "mp4", "webm", "mov"
|
||||
std::string extension; // ".mp4", ".webm", ".mov"
|
||||
};
|
||||
|
||||
inline VideoValidation validateVideoMagicBytes(const char* data, size_t size) {
|
||||
VideoValidation result{false, "", ""};
|
||||
if (!data || size < 12) return result;
|
||||
|
||||
const unsigned char* bytes = reinterpret_cast<const unsigned char*>(data);
|
||||
|
||||
// MP4/M4V/MOV: Check for ftyp atom
|
||||
if (size >= 12 && bytes[4] == 0x66 && bytes[5] == 0x74 && bytes[6] == 0x79 && bytes[7] == 0x70) {
|
||||
char brand[5] = {(char)bytes[8], (char)bytes[9], (char)bytes[10], (char)bytes[11], 0};
|
||||
std::string brandStr(brand);
|
||||
|
||||
if (brandStr == "qt " || brandStr.substr(0, 2) == "qt") {
|
||||
return {true, "mov", ".mov"};
|
||||
}
|
||||
return {true, "mp4", ".mp4"};
|
||||
}
|
||||
|
||||
// WebM/MKV: EBML header
|
||||
if (size >= 4 && bytes[0] == 0x1A && bytes[1] == 0x45 && bytes[2] == 0xDF && bytes[3] == 0xA3) {
|
||||
return {true, "webm", ".webm"};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============== AUDIO VALIDATION ==============
|
||||
|
||||
struct AudioValidation {
|
||||
bool valid;
|
||||
std::string detectedType; // "mp3", "wav", "flac", "ogg", "aac", "m4a"
|
||||
std::string extension;
|
||||
};
|
||||
|
||||
inline AudioValidation validateAudioMagicBytes(const char* data, size_t size) {
|
||||
AudioValidation result{false, "", ""};
|
||||
if (!data || size < 4) return result;
|
||||
|
||||
const unsigned char* bytes = reinterpret_cast<const unsigned char*>(data);
|
||||
|
||||
// MP3: ID3 tag or frame sync
|
||||
if (size >= 3 && bytes[0] == 0x49 && bytes[1] == 0x44 && bytes[2] == 0x33) {
|
||||
return {true, "mp3", ".mp3"};
|
||||
}
|
||||
if (size >= 2 && bytes[0] == 0xFF &&
|
||||
(bytes[1] == 0xFB || bytes[1] == 0xFA || bytes[1] == 0xF3 || bytes[1] == 0xF2)) {
|
||||
return {true, "mp3", ".mp3"};
|
||||
}
|
||||
|
||||
// WAV: RIFF....WAVE
|
||||
if (size >= 12 && bytes[0] == 0x52 && bytes[1] == 0x49 && bytes[2] == 0x46 && bytes[3] == 0x46 &&
|
||||
bytes[8] == 0x57 && bytes[9] == 0x41 && bytes[10] == 0x56 && bytes[11] == 0x45) {
|
||||
return {true, "wav", ".wav"};
|
||||
}
|
||||
|
||||
// FLAC: fLaC
|
||||
if (size >= 4 && bytes[0] == 0x66 && bytes[1] == 0x4C && bytes[2] == 0x61 && bytes[3] == 0x43) {
|
||||
return {true, "flac", ".flac"};
|
||||
}
|
||||
|
||||
// OGG: OggS
|
||||
if (size >= 4 && bytes[0] == 0x4F && bytes[1] == 0x67 && bytes[2] == 0x67 && bytes[3] == 0x53) {
|
||||
return {true, "ogg", ".ogg"};
|
||||
}
|
||||
|
||||
// AAC: ADTS frame sync
|
||||
if (size >= 2 && bytes[0] == 0xFF && (bytes[1] == 0xF1 || bytes[1] == 0xF9)) {
|
||||
return {true, "aac", ".aac"};
|
||||
}
|
||||
|
||||
// M4A/AAC in MP4 container
|
||||
if (size >= 12 && bytes[4] == 0x66 && bytes[5] == 0x74 && bytes[6] == 0x79 && bytes[7] == 0x70) {
|
||||
char brand[5] = {(char)bytes[8], (char)bytes[9], (char)bytes[10], (char)bytes[11], 0};
|
||||
std::string brandStr(brand);
|
||||
if (brandStr == "M4A " || brandStr == "mp42" || brandStr == "isom" || brandStr == "M4B ") {
|
||||
return {true, "m4a", ".m4a"};
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============== EPUB VALIDATION ==============
|
||||
|
||||
// Properly validates EPUB format according to spec:
|
||||
// - Must be a valid ZIP file
|
||||
// - First entry must be named "mimetype" (uncompressed)
|
||||
// - Content must be exactly "application/epub+zip"
|
||||
inline bool isValidEpub(const char* data, size_t size) {
|
||||
if (!data || size < 58) return false;
|
||||
|
||||
const unsigned char* bytes = reinterpret_cast<const unsigned char*>(data);
|
||||
|
||||
// Must be ZIP: PK\x03\x04 local file header signature
|
||||
if (bytes[0] != 0x50 || bytes[1] != 0x4B || bytes[2] != 0x03 || bytes[3] != 0x04) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse ZIP local file header
|
||||
// Offset 8: compression method (2 bytes, little-endian)
|
||||
// For EPUB, mimetype must be uncompressed (method 0)
|
||||
uint16_t compressionMethod = bytes[8] | (bytes[9] << 8);
|
||||
if (compressionMethod != 0) {
|
||||
return false; // mimetype must be stored uncompressed
|
||||
}
|
||||
|
||||
// Offset 18: compressed size (4 bytes, little-endian)
|
||||
uint32_t compressedSize = bytes[18] | (bytes[19] << 8) | (bytes[20] << 16) | (bytes[21] << 24);
|
||||
|
||||
// Offset 22: uncompressed size (4 bytes, little-endian)
|
||||
uint32_t uncompressedSize = bytes[22] | (bytes[23] << 8) | (bytes[24] << 16) | (bytes[25] << 24);
|
||||
|
||||
// For uncompressed files, these should be equal
|
||||
if (compressedSize != uncompressedSize) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Offset 26: file name length (2 bytes, little-endian)
|
||||
uint16_t fileNameLen = bytes[26] | (bytes[27] << 8);
|
||||
|
||||
// Offset 28: extra field length (2 bytes, little-endian)
|
||||
uint16_t extraFieldLen = bytes[28] | (bytes[29] << 8);
|
||||
|
||||
// File name starts at offset 30
|
||||
if (size < 30 + fileNameLen + extraFieldLen + compressedSize) {
|
||||
return false; // File too small
|
||||
}
|
||||
|
||||
// Check that first entry is named "mimetype"
|
||||
if (fileNameLen != 8) {
|
||||
return false;
|
||||
}
|
||||
std::string fileName(data + 30, 8);
|
||||
if (fileName != "mimetype") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Content starts after header + filename + extra field
|
||||
size_t contentOffset = 30 + fileNameLen + extraFieldLen;
|
||||
|
||||
// Check content is exactly "application/epub+zip"
|
||||
const std::string expectedMimetype = "application/epub+zip";
|
||||
if (compressedSize != expectedMimetype.size()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string actualMimetype(data + contentOffset, compressedSize);
|
||||
return actualMimetype == expectedMimetype;
|
||||
}
|
||||
29
backend/src/common/HttpHelpers.h
Normal file
29
backend/src/common/HttpHelpers.h
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpResponse.h>
|
||||
#include <json/json.h>
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
inline HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
|
||||
auto r = HttpResponse::newHttpJsonResponse(j);
|
||||
r->setStatusCode(c);
|
||||
return r;
|
||||
}
|
||||
|
||||
inline HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
|
||||
Json::Value j;
|
||||
j["success"] = false;
|
||||
j["error"] = error;
|
||||
return jsonResp(j, code);
|
||||
}
|
||||
|
||||
inline HttpResponsePtr jsonSuccess(const Json::Value& data = Json::Value()) {
|
||||
Json::Value j;
|
||||
j["success"] = true;
|
||||
if (!data.isNull()) {
|
||||
for (const auto& key : data.getMemberNames()) {
|
||||
j[key] = data[key];
|
||||
}
|
||||
}
|
||||
return jsonResp(j);
|
||||
}
|
||||
|
|
@ -1,243 +0,0 @@
|
|||
#pragma once
|
||||
|
||||
#include <drogon/drogon.h>
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <variant>
|
||||
#include <optional>
|
||||
#include <concepts>
|
||||
|
||||
namespace utils {
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
// Result type for better error handling
|
||||
template<typename T>
|
||||
using Result = std::variant<T, std::string>;
|
||||
|
||||
template<typename T>
|
||||
inline bool isOk(const Result<T>& r) { return std::holds_alternative<T>(r); }
|
||||
|
||||
template<typename T>
|
||||
inline T& getValue(Result<T>& r) { return std::get<T>(r); }
|
||||
|
||||
template<typename T>
|
||||
inline const std::string& getError(const Result<T>& r) { return std::get<std::string>(r); }
|
||||
|
||||
// JSON Response helpers
|
||||
inline HttpResponsePtr jsonOk(const Json::Value& data) {
|
||||
return HttpResponse::newHttpJsonResponse(data);
|
||||
}
|
||||
|
||||
inline HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
|
||||
Json::Value json;
|
||||
json["error"] = error;
|
||||
json["success"] = false;
|
||||
auto resp = HttpResponse::newHttpJsonResponse(json);
|
||||
resp->setStatusCode(code);
|
||||
return resp;
|
||||
}
|
||||
|
||||
inline HttpResponsePtr jsonResp(const Json::Value& data, HttpStatusCode code = k200OK) {
|
||||
auto resp = HttpResponse::newHttpJsonResponse(data);
|
||||
resp->setStatusCode(code);
|
||||
return resp;
|
||||
}
|
||||
|
||||
// Database helper with automatic error handling
|
||||
template<typename... Args>
|
||||
inline void dbQuery(const std::string& query,
|
||||
std::function<void(const drogon::orm::Result&)> onSuccess,
|
||||
std::function<void(const std::string&)> onError,
|
||||
Args&&... args) {
|
||||
auto db = app().getDbClient();
|
||||
(*db << query << std::forward<Args>(args)...)
|
||||
>> [onSuccess](const drogon::orm::Result& r) { onSuccess(r); }
|
||||
>> [onError](const drogon::orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "DB Error: " << e.base().what();
|
||||
onError(e.base().what());
|
||||
};
|
||||
}
|
||||
|
||||
// Simplified DB query that returns JSON response
|
||||
template<typename... Args>
|
||||
inline void dbJsonQuery(const std::string& query,
|
||||
std::function<Json::Value(const drogon::orm::Result&)> transform,
|
||||
std::function<void(const HttpResponsePtr&)> callback,
|
||||
Args&&... args) {
|
||||
dbQuery(query,
|
||||
[transform, callback](const drogon::orm::Result& r) {
|
||||
callback(jsonOk(transform(r)));
|
||||
},
|
||||
[callback](const std::string& error) {
|
||||
callback(jsonError(error, k500InternalServerError));
|
||||
},
|
||||
std::forward<Args>(args)...
|
||||
);
|
||||
}
|
||||
|
||||
// Thread pool executor with type constraints
|
||||
template<typename F>
|
||||
requires std::invocable<F>
|
||||
inline void runAsync(F&& task) {
|
||||
if (auto loop = app().getLoop()) {
|
||||
loop->queueInLoop([task = std::forward<F>(task)]() {
|
||||
std::thread([task]() {
|
||||
try {
|
||||
task();
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Async task error: " << e.what();
|
||||
}
|
||||
}).detach();
|
||||
});
|
||||
} else {
|
||||
// Fallback to sync execution
|
||||
task();
|
||||
}
|
||||
}
|
||||
|
||||
// Config helper
|
||||
template<typename T>
|
||||
inline std::optional<T> getConfig(const std::string& path) {
|
||||
try {
|
||||
const auto& config = app().getCustomConfig();
|
||||
std::vector<std::string> parts;
|
||||
std::stringstream ss(path);
|
||||
std::string part;
|
||||
while (std::getline(ss, part, '.')) {
|
||||
parts.push_back(part);
|
||||
}
|
||||
|
||||
Json::Value current = config;
|
||||
for (const auto& p : parts) {
|
||||
if (!current.isMember(p)) return std::nullopt;
|
||||
current = current[p];
|
||||
}
|
||||
|
||||
if constexpr (std::is_same_v<T, std::string>) {
|
||||
return current.asString();
|
||||
} else if constexpr (std::is_same_v<T, int>) {
|
||||
return current.asInt();
|
||||
} else if constexpr (std::is_same_v<T, bool>) {
|
||||
return current.asBool();
|
||||
} else if constexpr (std::is_same_v<T, double>) {
|
||||
return current.asDouble();
|
||||
}
|
||||
} catch (...) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// Environment variable helper with fallback
|
||||
template<typename T = std::string>
|
||||
inline T getEnv(const std::string& key, const T& defaultValue = T{}) {
|
||||
const char* val = std::getenv(key.c_str());
|
||||
if (!val) return defaultValue;
|
||||
|
||||
if constexpr (std::is_same_v<T, std::string>) {
|
||||
return std::string(val);
|
||||
} else if constexpr (std::is_same_v<T, int>) {
|
||||
try { return std::stoi(val); } catch (...) { return defaultValue; }
|
||||
} else if constexpr (std::is_same_v<T, bool>) {
|
||||
std::string s(val);
|
||||
return s == "true" || s == "1" || s == "yes";
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Random string generator
|
||||
inline std::string randomString(size_t length = 32) {
|
||||
auto bytes = drogon::utils::genRandomString(length);
|
||||
return drogon::utils::base64Encode(
|
||||
reinterpret_cast<const unsigned char*>(bytes.data()),
|
||||
bytes.length()
|
||||
);
|
||||
}
|
||||
|
||||
// Timer helper
|
||||
class ScopedTimer {
|
||||
std::string name_;
|
||||
std::chrono::steady_clock::time_point start_;
|
||||
public:
|
||||
explicit ScopedTimer(const std::string& name)
|
||||
: name_(name), start_(std::chrono::steady_clock::now()) {}
|
||||
|
||||
~ScopedTimer() {
|
||||
auto duration = std::chrono::steady_clock::now() - start_;
|
||||
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(duration).count();
|
||||
LOG_DEBUG << name_ << " took " << ms << "ms";
|
||||
}
|
||||
};
|
||||
|
||||
#define TIMED_SCOPE(name) utils::ScopedTimer _timer(name)
|
||||
|
||||
// WebSocket broadcast helper
|
||||
template<typename Container>
|
||||
inline void wsBroadcast(const Container& connections, const Json::Value& message) {
|
||||
auto msg = Json::FastWriter().write(message);
|
||||
for (const auto& conn : connections) {
|
||||
if (conn->connected()) {
|
||||
conn->send(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiter
|
||||
class RateLimiter {
|
||||
std::unordered_map<std::string, std::deque<std::chrono::steady_clock::time_point>> requests_;
|
||||
std::mutex mutex_;
|
||||
size_t maxRequests_;
|
||||
std::chrono::seconds window_;
|
||||
|
||||
public:
|
||||
RateLimiter(size_t maxRequests = 10, std::chrono::seconds window = std::chrono::seconds(60))
|
||||
: maxRequests_(maxRequests), window_(window) {}
|
||||
|
||||
bool allow(const std::string& key) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto& timestamps = requests_[key];
|
||||
|
||||
// Remove old timestamps
|
||||
while (!timestamps.empty() && now - timestamps.front() > window_) {
|
||||
timestamps.pop_front();
|
||||
}
|
||||
|
||||
if (timestamps.size() >= maxRequests_) {
|
||||
return false;
|
||||
}
|
||||
|
||||
timestamps.push_back(now);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Global rate limiter instance
|
||||
inline RateLimiter& rateLimiter() {
|
||||
static RateLimiter limiter;
|
||||
return limiter;
|
||||
}
|
||||
|
||||
// Validation helpers
|
||||
inline bool isValidStreamKey(const std::string& key) {
|
||||
return key.length() == 32 &&
|
||||
std::all_of(key.begin(), key.end(), [](char c) {
|
||||
return std::isxdigit(c);
|
||||
});
|
||||
}
|
||||
|
||||
// JSON conversion helper
|
||||
template<typename T>
|
||||
inline Json::Value toJson(const T& obj);
|
||||
|
||||
// Specializations for common types
|
||||
template<>
|
||||
inline Json::Value toJson(const std::map<std::string, std::string>& m) {
|
||||
Json::Value json;
|
||||
for (const auto& [k, v] : m) {
|
||||
json[k] = v;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
} // namespace utils
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -12,6 +12,67 @@ public:
|
|||
ADD_METHOD_TO(AdminController::disconnectStream, "/api/admin/streams/{1}/disconnect", Post);
|
||||
ADD_METHOD_TO(AdminController::promoteToStreamer, "/api/admin/users/{1}/promote", Post);
|
||||
ADD_METHOD_TO(AdminController::demoteFromStreamer, "/api/admin/users/{1}/demote", Post);
|
||||
ADD_METHOD_TO(AdminController::promoteToRestreamer, "/api/admin/users/{1}/promote-restreamer", Post);
|
||||
ADD_METHOD_TO(AdminController::demoteFromRestreamer, "/api/admin/users/{1}/demote-restreamer", Post);
|
||||
ADD_METHOD_TO(AdminController::promoteToBot, "/api/admin/users/{1}/promote-bot", Post);
|
||||
ADD_METHOD_TO(AdminController::demoteFromBot, "/api/admin/users/{1}/demote-bot", Post);
|
||||
ADD_METHOD_TO(AdminController::getAllBotApiKeys, "/api/admin/bot-keys", Get);
|
||||
ADD_METHOD_TO(AdminController::deleteBotApiKey, "/api/admin/bot-keys/{1}", Delete);
|
||||
ADD_METHOD_TO(AdminController::uploadStickers, "/api/admin/stickers/upload", Post);
|
||||
ADD_METHOD_TO(AdminController::getStickers, "/api/admin/stickers", Get);
|
||||
ADD_METHOD_TO(AdminController::deleteSticker, "/api/admin/stickers/{1}", Delete);
|
||||
ADD_METHOD_TO(AdminController::renameSticker, "/api/admin/stickers/{1}/rename", Put);
|
||||
ADD_METHOD_TO(AdminController::promoteToStickerCreator, "/api/admin/users/{1}/promote-sticker-creator", Post);
|
||||
ADD_METHOD_TO(AdminController::demoteFromStickerCreator, "/api/admin/users/{1}/demote-sticker-creator", Post);
|
||||
ADD_METHOD_TO(AdminController::promoteToUploader, "/api/admin/users/{1}/promote-uploader", Post);
|
||||
ADD_METHOD_TO(AdminController::demoteFromUploader, "/api/admin/users/{1}/demote-uploader", Post);
|
||||
ADD_METHOD_TO(AdminController::promoteToTexter, "/api/admin/users/{1}/promote-texter", Post);
|
||||
ADD_METHOD_TO(AdminController::demoteFromTexter, "/api/admin/users/{1}/demote-texter", Post);
|
||||
ADD_METHOD_TO(AdminController::promoteToWatchCreator, "/api/admin/users/{1}/promote-watch-creator", Post);
|
||||
ADD_METHOD_TO(AdminController::demoteFromWatchCreator, "/api/admin/users/{1}/demote-watch-creator", Post);
|
||||
ADD_METHOD_TO(AdminController::promoteToModerator, "/api/admin/users/{1}/promote-moderator", Post);
|
||||
ADD_METHOD_TO(AdminController::demoteFromModerator, "/api/admin/users/{1}/demote-moderator", Post);
|
||||
ADD_METHOD_TO(AdminController::getStickerSubmissions, "/api/admin/sticker-submissions", Get);
|
||||
ADD_METHOD_TO(AdminController::approveStickerSubmission, "/api/admin/sticker-submissions/{1}/approve", Post);
|
||||
ADD_METHOD_TO(AdminController::denyStickerSubmission, "/api/admin/sticker-submissions/{1}/deny", Post);
|
||||
ADD_METHOD_TO(AdminController::uploadHonkSound, "/api/admin/honks/upload", Post);
|
||||
ADD_METHOD_TO(AdminController::getHonkSounds, "/api/admin/honks", Get);
|
||||
ADD_METHOD_TO(AdminController::deleteHonkSound, "/api/admin/honks/{1}", Delete);
|
||||
ADD_METHOD_TO(AdminController::setActiveHonkSound, "/api/admin/honks/{1}/activate", Post);
|
||||
ADD_METHOD_TO(AdminController::getActiveHonkSound, "/api/honk/active", Get);
|
||||
ADD_METHOD_TO(AdminController::getChatSettings, "/api/admin/settings/chat", Get);
|
||||
ADD_METHOD_TO(AdminController::updateChatSettings, "/api/admin/settings/chat", Put);
|
||||
ADD_METHOD_TO(AdminController::getRealms, "/api/admin/realms", Get);
|
||||
ADD_METHOD_TO(AdminController::deleteRealm, "/api/admin/realms/{1}", Delete);
|
||||
ADD_METHOD_TO(AdminController::setViewerMultiplier, "/api/admin/realms/{1}/viewer-multiplier", Post);
|
||||
ADD_METHOD_TO(AdminController::deleteUser, "/api/admin/users/{1}", Delete);
|
||||
ADD_METHOD_TO(AdminController::disableUser, "/api/admin/users/{1}/disable", Post);
|
||||
ADD_METHOD_TO(AdminController::enableUser, "/api/admin/users/{1}/enable", Post);
|
||||
ADD_METHOD_TO(AdminController::uberbanUser, "/api/admin/users/{1}/uberban", Post);
|
||||
ADD_METHOD_TO(AdminController::incrementReferrals, "/api/admin/users/{1}/increment-referrals", Post);
|
||||
ADD_METHOD_TO(AdminController::getVideos, "/api/admin/videos", Get);
|
||||
ADD_METHOD_TO(AdminController::deleteVideo, "/api/admin/videos/{1}", Delete);
|
||||
ADD_METHOD_TO(AdminController::getAudios, "/api/admin/audios", Get);
|
||||
ADD_METHOD_TO(AdminController::deleteAudio, "/api/admin/audios/{1}", Delete);
|
||||
ADD_METHOD_TO(AdminController::getEbooks, "/api/admin/ebooks", Get);
|
||||
ADD_METHOD_TO(AdminController::deleteEbook, "/api/admin/ebooks/{1}", Delete);
|
||||
ADD_METHOD_TO(AdminController::getSiteSettings, "/api/admin/settings/site", Get);
|
||||
ADD_METHOD_TO(AdminController::updateSiteSettings, "/api/admin/settings/site", Put);
|
||||
ADD_METHOD_TO(AdminController::uploadSiteLogo, "/api/admin/settings/site/logo", Post);
|
||||
ADD_METHOD_TO(AdminController::getPublicSiteSettings, "/api/settings/site", Get);
|
||||
ADD_METHOD_TO(AdminController::getCensoredWords, "/api/internal/censored-words", Get);
|
||||
ADD_METHOD_TO(AdminController::uploadDefaultAvatars, "/api/admin/default-avatars/upload", Post);
|
||||
ADD_METHOD_TO(AdminController::getDefaultAvatars, "/api/admin/default-avatars", Get);
|
||||
ADD_METHOD_TO(AdminController::deleteDefaultAvatar, "/api/admin/default-avatars/{1}", Delete);
|
||||
ADD_METHOD_TO(AdminController::getRandomDefaultAvatar, "/api/default-avatar/random", Get);
|
||||
ADD_METHOD_TO(AdminController::trackStickerUsage, "/api/internal/stickers/track-usage", Post);
|
||||
ADD_METHOD_TO(AdminController::getStickerStats, "/api/stats/stickers", Get);
|
||||
ADD_METHOD_TO(AdminController::downloadAllStickers, "/api/admin/stickers/download-all", Get);
|
||||
// SSL Certificate Management
|
||||
ADD_METHOD_TO(AdminController::getSSLSettings, "/api/admin/settings/ssl", Get);
|
||||
ADD_METHOD_TO(AdminController::updateSSLSettings, "/api/admin/settings/ssl", Put);
|
||||
ADD_METHOD_TO(AdminController::requestCertificate, "/api/admin/ssl/request", Post);
|
||||
ADD_METHOD_TO(AdminController::getSSLStatus, "/api/admin/ssl/status", Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
void getUsers(const HttpRequestPtr &req,
|
||||
|
|
@ -32,6 +93,216 @@ public:
|
|||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
private:
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
||||
void promoteToRestreamer(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void demoteFromRestreamer(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void promoteToBot(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void demoteFromBot(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void getAllBotApiKeys(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteBotApiKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &keyId);
|
||||
|
||||
void uploadStickers(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getStickers(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteSticker(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &stickerId);
|
||||
|
||||
void renameSticker(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &stickerId);
|
||||
|
||||
void promoteToStickerCreator(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void demoteFromStickerCreator(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void promoteToUploader(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void demoteFromUploader(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void promoteToTexter(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void demoteFromTexter(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void promoteToWatchCreator(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void demoteFromWatchCreator(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void promoteToModerator(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void demoteFromModerator(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void getStickerSubmissions(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void approveStickerSubmission(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &submissionId);
|
||||
|
||||
void denyStickerSubmission(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &submissionId);
|
||||
|
||||
void uploadHonkSound(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getHonkSounds(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteHonkSound(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &honkId);
|
||||
|
||||
void setActiveHonkSound(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &honkId);
|
||||
|
||||
void getActiveHonkSound(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getChatSettings(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void updateChatSettings(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getRealms(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteRealm(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void setViewerMultiplier(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void deleteUser(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void disableUser(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void enableUser(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void uberbanUser(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void incrementReferrals(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void getVideos(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &videoId);
|
||||
|
||||
void getAudios(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &audioId);
|
||||
|
||||
void getEbooks(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId);
|
||||
|
||||
void getSiteSettings(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void updateSiteSettings(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void uploadSiteLogo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getPublicSiteSettings(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getCensoredWords(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void uploadDefaultAvatars(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getDefaultAvatars(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteDefaultAvatar(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &avatarId);
|
||||
|
||||
void getRandomDefaultAvatar(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void trackStickerUsage(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getStickerStats(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void downloadAllStickers(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
// SSL Certificate Management
|
||||
void getSSLSettings(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void updateSSLSettings(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void requestCertificate(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getSSLStatus(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
};
|
||||
1051
backend/src/controllers/AudioController.cpp
Normal file
1051
backend/src/controllers/AudioController.cpp
Normal file
File diff suppressed because it is too large
Load diff
72
backend/src/controllers/AudioController.h
Normal file
72
backend/src/controllers/AudioController.h
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class AudioController : public HttpController<AudioController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
// Public endpoints
|
||||
ADD_METHOD_TO(AudioController::getAllAudio, "/api/audio", Get);
|
||||
ADD_METHOD_TO(AudioController::getLatestAudio, "/api/audio/latest", Get);
|
||||
ADD_METHOD_TO(AudioController::getAudio, "/api/audio/{1}", Get);
|
||||
ADD_METHOD_TO(AudioController::getRealmAudio, "/api/audio/realm/{1}", Get);
|
||||
ADD_METHOD_TO(AudioController::getRealmAudioByName, "/api/audio/realm/name/{1}", Get);
|
||||
ADD_METHOD_TO(AudioController::incrementPlayCount, "/api/audio/{1}/play", Post);
|
||||
|
||||
// Authenticated endpoints
|
||||
ADD_METHOD_TO(AudioController::getMyAudio, "/api/user/audio", Get);
|
||||
ADD_METHOD_TO(AudioController::uploadAudio, "/api/user/audio", Post);
|
||||
ADD_METHOD_TO(AudioController::updateAudio, "/api/audio/{1}", Put);
|
||||
ADD_METHOD_TO(AudioController::deleteAudio, "/api/audio/{1}", Delete);
|
||||
ADD_METHOD_TO(AudioController::uploadThumbnail, "/api/audio/{1}/thumbnail", Post);
|
||||
ADD_METHOD_TO(AudioController::deleteThumbnail, "/api/audio/{1}/thumbnail", Delete);
|
||||
METHOD_LIST_END
|
||||
|
||||
// Public audio listing
|
||||
void getAllAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getLatestAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &audioId);
|
||||
|
||||
void getRealmAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void getRealmAudioByName(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmName);
|
||||
|
||||
void incrementPlayCount(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &audioId);
|
||||
|
||||
// Authenticated audio management
|
||||
void getMyAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void uploadAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void updateAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &audioId);
|
||||
|
||||
void deleteAudio(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &audioId);
|
||||
|
||||
void uploadThumbnail(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &audioId);
|
||||
|
||||
void deleteThumbnail(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &audioId);
|
||||
};
|
||||
908
backend/src/controllers/EbookController.cpp
Normal file
908
backend/src/controllers/EbookController.cpp
Normal file
|
|
@ -0,0 +1,908 @@
|
|||
#include "EbookController.h"
|
||||
#include "../services/DatabaseService.h"
|
||||
#include "../common/HttpHelpers.h"
|
||||
#include "../common/AuthHelpers.h"
|
||||
#include "../common/FileUtils.h"
|
||||
#include "../common/FileValidation.h"
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <drogon/Cookie.h>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <regex>
|
||||
#include <cerrno>
|
||||
|
||||
// File size limits
|
||||
static constexpr size_t MAX_EBOOK_SIZE = 100 * 1024 * 1024; // 100MB
|
||||
static constexpr size_t MAX_COVER_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
|
||||
using namespace drogon::orm;
|
||||
|
||||
void EbookController::getAllEbooks(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
int page = 1;
|
||||
int limit = 20;
|
||||
|
||||
auto pageParam = req->getParameter("page");
|
||||
auto limitParam = req->getParameter("limit");
|
||||
|
||||
if (!pageParam.empty()) {
|
||||
try { page = std::stoi(pageParam); } catch (...) {}
|
||||
}
|
||||
if (!limitParam.empty()) {
|
||||
try { limit = std::min(std::stoi(limitParam), 50); } catch (...) {}
|
||||
}
|
||||
|
||||
int offset = (page - 1) * limit;
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, "
|
||||
"e.file_size_bytes, e.chapter_count, e.read_count, e.created_at, e.realm_id, "
|
||||
"u.id as user_id, u.username, u.avatar_url, "
|
||||
"r.name as realm_name "
|
||||
"FROM ebooks e "
|
||||
"JOIN users u ON e.user_id = u.id "
|
||||
"JOIN realms r ON e.realm_id = r.id "
|
||||
"WHERE e.is_public = true AND e.status = 'ready' "
|
||||
"ORDER BY e.created_at DESC "
|
||||
"LIMIT $1 OFFSET $2"
|
||||
<< static_cast<int64_t>(limit) << static_cast<int64_t>(offset)
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value ebooks(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value ebook;
|
||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
ebook["title"] = row["title"].as<std::string>();
|
||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
||||
ebook["readCount"] = row["read_count"].as<int>();
|
||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
||||
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
||||
ebook["username"] = row["username"].as<std::string>();
|
||||
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||
ebook["realmName"] = row["realm_name"].as<std::string>();
|
||||
ebooks.append(ebook);
|
||||
}
|
||||
|
||||
resp["ebooks"] = ebooks;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get ebooks");
|
||||
}
|
||||
|
||||
void EbookController::getLatestEbooks(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, "
|
||||
"e.file_size_bytes, e.chapter_count, e.read_count, e.created_at, e.realm_id, "
|
||||
"u.id as user_id, u.username, u.avatar_url, "
|
||||
"r.name as realm_name "
|
||||
"FROM ebooks e "
|
||||
"JOIN users u ON e.user_id = u.id "
|
||||
"JOIN realms r ON e.realm_id = r.id "
|
||||
"WHERE e.is_public = true AND e.status = 'ready' "
|
||||
"ORDER BY e.created_at DESC "
|
||||
"LIMIT 5"
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value ebooks(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value ebook;
|
||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
ebook["title"] = row["title"].as<std::string>();
|
||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
||||
ebook["readCount"] = row["read_count"].as<int>();
|
||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
||||
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
||||
ebook["username"] = row["username"].as<std::string>();
|
||||
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||
ebook["realmName"] = row["realm_name"].as<std::string>();
|
||||
ebooks.append(ebook);
|
||||
}
|
||||
|
||||
resp["ebooks"] = ebooks;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get latest ebooks");
|
||||
}
|
||||
|
||||
void EbookController::getEbook(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId) {
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(ebookId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid ebook ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, "
|
||||
"e.file_size_bytes, e.chapter_count, e.read_count, e.is_public, e.status, "
|
||||
"e.created_at, e.realm_id, "
|
||||
"u.id as user_id, u.username, u.avatar_url, "
|
||||
"r.name as realm_name "
|
||||
"FROM ebooks e "
|
||||
"JOIN users u ON e.user_id = u.id "
|
||||
"JOIN realms r ON e.realm_id = r.id "
|
||||
"WHERE e.id = $1 AND e.status = 'ready'"
|
||||
<< id
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Ebook not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& row = r[0];
|
||||
|
||||
if (!row["is_public"].as<bool>()) {
|
||||
callback(jsonError("Ebook not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
auto& ebook = resp["ebook"];
|
||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
ebook["title"] = row["title"].as<std::string>();
|
||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
||||
ebook["readCount"] = row["read_count"].as<int>();
|
||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
||||
ebook["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
||||
ebook["username"] = row["username"].as<std::string>();
|
||||
ebook["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||
ebook["realmName"] = row["realm_name"].as<std::string>();
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get ebook");
|
||||
}
|
||||
|
||||
void EbookController::getUserEbooks(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username) {
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, "
|
||||
"e.file_size_bytes, e.chapter_count, e.read_count, e.created_at, e.realm_id, "
|
||||
"u.id as user_id, u.username, u.avatar_url, "
|
||||
"r.name as realm_name "
|
||||
"FROM ebooks e "
|
||||
"JOIN users u ON e.user_id = u.id "
|
||||
"JOIN realms r ON e.realm_id = r.id "
|
||||
"WHERE u.username = $1 AND e.is_public = true AND e.status = 'ready' "
|
||||
"ORDER BY e.created_at DESC"
|
||||
<< username
|
||||
>> [callback, username](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["username"] = username;
|
||||
Json::Value ebooks(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value ebook;
|
||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
ebook["title"] = row["title"].as<std::string>();
|
||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
||||
ebook["readCount"] = row["read_count"].as<int>();
|
||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
||||
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||
ebook["realmName"] = row["realm_name"].as<std::string>();
|
||||
ebooks.append(ebook);
|
||||
}
|
||||
|
||||
resp["ebooks"] = ebooks;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get user ebooks");
|
||||
}
|
||||
|
||||
void EbookController::getRealmEbooks(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(realmId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid realm ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
// First get realm info
|
||||
*dbClient << "SELECT r.id, r.name, r.description, r.realm_type, r.title_color, r.created_at, "
|
||||
"u.id as user_id, u.username, u.avatar_url "
|
||||
"FROM realms r "
|
||||
"JOIN users u ON r.user_id = u.id "
|
||||
"WHERE r.id = $1 AND r.is_active = true AND r.realm_type = 'ebook'"
|
||||
<< id
|
||||
>> [callback, dbClient, id](const Result& realmResult) {
|
||||
if (realmResult.empty()) {
|
||||
callback(jsonError("Ebook realm not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get ebooks for this realm
|
||||
*dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, "
|
||||
"e.file_size_bytes, e.chapter_count, e.read_count, e.created_at "
|
||||
"FROM ebooks e "
|
||||
"WHERE e.realm_id = $1 AND e.is_public = true AND e.status = 'ready' "
|
||||
"ORDER BY e.created_at DESC LIMIT 100"
|
||||
<< id
|
||||
>> [callback, realmResult](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
|
||||
// Realm info
|
||||
auto& realm = resp["realm"];
|
||||
realm["id"] = static_cast<Json::Int64>(realmResult[0]["id"].as<int64_t>());
|
||||
realm["name"] = realmResult[0]["name"].as<std::string>();
|
||||
realm["description"] = realmResult[0]["description"].isNull() ? "" : realmResult[0]["description"].as<std::string>();
|
||||
realm["titleColor"] = realmResult[0]["title_color"].isNull() ? "#ffffff" : realmResult[0]["title_color"].as<std::string>();
|
||||
realm["createdAt"] = realmResult[0]["created_at"].as<std::string>();
|
||||
realm["userId"] = static_cast<Json::Int64>(realmResult[0]["user_id"].as<int64_t>());
|
||||
realm["username"] = realmResult[0]["username"].as<std::string>();
|
||||
realm["avatarUrl"] = realmResult[0]["avatar_url"].isNull() ? "" : realmResult[0]["avatar_url"].as<std::string>();
|
||||
|
||||
// Ebooks
|
||||
Json::Value ebooks(Json::arrayValue);
|
||||
for (const auto& row : r) {
|
||||
Json::Value ebook;
|
||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
ebook["title"] = row["title"].as<std::string>();
|
||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
||||
ebook["readCount"] = row["read_count"].as<int>();
|
||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
||||
ebooks.append(ebook);
|
||||
}
|
||||
|
||||
resp["ebooks"] = ebooks;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get realm ebooks");
|
||||
}
|
||||
>> DB_ERROR(callback, "get realm");
|
||||
}
|
||||
|
||||
void EbookController::incrementReadCount(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId) {
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(ebookId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid ebook ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE ebooks SET read_count = read_count + 1 "
|
||||
"WHERE id = $1 AND is_public = true AND status = 'ready' "
|
||||
"RETURNING read_count"
|
||||
<< id
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Ebook not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["readCount"] = r[0]["read_count"].as<int>();
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "increment read count");
|
||||
}
|
||||
|
||||
void EbookController::getMyEbooks(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT e.id, e.title, e.description, e.file_path, e.cover_path, "
|
||||
"e.file_size_bytes, e.chapter_count, e.read_count, e.is_public, e.status, e.created_at, "
|
||||
"e.realm_id, r.name as realm_name "
|
||||
"FROM ebooks e "
|
||||
"JOIN realms r ON e.realm_id = r.id "
|
||||
"WHERE e.user_id = $1 AND e.status != 'deleted' "
|
||||
"ORDER BY e.created_at DESC"
|
||||
<< user.id
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value ebooks(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value ebook;
|
||||
ebook["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
ebook["title"] = row["title"].as<std::string>();
|
||||
ebook["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||
ebook["filePath"] = row["file_path"].as<std::string>();
|
||||
ebook["coverPath"] = row["cover_path"].isNull() ? "" : row["cover_path"].as<std::string>();
|
||||
ebook["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
||||
ebook["chapterCount"] = row["chapter_count"].isNull() ? 0 : row["chapter_count"].as<int>();
|
||||
ebook["readCount"] = row["read_count"].as<int>();
|
||||
ebook["isPublic"] = row["is_public"].as<bool>();
|
||||
ebook["status"] = row["status"].as<std::string>();
|
||||
ebook["createdAt"] = row["created_at"].as<std::string>();
|
||||
ebook["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||
ebook["realmName"] = row["realm_name"].as<std::string>();
|
||||
ebooks.append(ebook);
|
||||
}
|
||||
|
||||
resp["ebooks"] = ebooks;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get user ebooks");
|
||||
}
|
||||
|
||||
void EbookController::uploadEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
MultiPartParser parser;
|
||||
parser.parse(req);
|
||||
|
||||
// Get realm ID from form data - required
|
||||
std::string realmIdStr = parser.getParameter<std::string>("realmId");
|
||||
if (realmIdStr.empty()) {
|
||||
callback(jsonError("Realm ID is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t realmId;
|
||||
try {
|
||||
realmId = std::stoll(realmIdStr);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid realm ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract file
|
||||
if (parser.getFiles().empty()) {
|
||||
callback(jsonError("No file uploaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& file = parser.getFiles()[0];
|
||||
|
||||
// Get title from form data - sanitize input
|
||||
std::string title = sanitizeUserInput(parser.getParameter<std::string>("title"), 255);
|
||||
if (title.empty()) {
|
||||
title = "Untitled Ebook";
|
||||
}
|
||||
|
||||
// Get optional description - sanitize input
|
||||
std::string description = sanitizeUserInput(parser.getParameter<std::string>("description"), 5000);
|
||||
|
||||
// Validate file size
|
||||
size_t fileSize = file.fileLength();
|
||||
if (fileSize > MAX_EBOOK_SIZE) {
|
||||
callback(jsonError("File too large (max 100MB)"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileSize == 0) {
|
||||
callback(jsonError("Empty file uploaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate EPUB magic bytes
|
||||
if (!isValidEpub(file.fileData(), fileSize)) {
|
||||
LOG_WARN << "Ebook upload rejected: invalid EPUB file";
|
||||
callback(jsonError("Invalid file. Only EPUB format is allowed."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy file data before async call
|
||||
std::string fileDataStr(file.fileData(), fileSize);
|
||||
|
||||
// Check if user has uploader role and the realm exists and belongs to them
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT u.is_uploader, r.id as realm_id, r.realm_type "
|
||||
"FROM users u "
|
||||
"LEFT JOIN realms r ON r.user_id = u.id AND r.id = $2 "
|
||||
"WHERE u.id = $1"
|
||||
<< user.id << realmId
|
||||
>> [callback, user, dbClient, realmId, title, description, fileDataStr, fileSize](const Result& r) {
|
||||
if (r.empty() || !r[0]["is_uploader"].as<bool>()) {
|
||||
callback(jsonError("You don't have permission to upload ebooks", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if realm exists and belongs to user
|
||||
if (r[0]["realm_id"].isNull()) {
|
||||
callback(jsonError("Ebook realm not found or doesn't belong to you", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's an ebook realm
|
||||
std::string realmType = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as<std::string>();
|
||||
if (realmType != "ebook") {
|
||||
callback(jsonError("Can only upload ebooks to ebook realms", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const std::string uploadDir = "/app/uploads/ebooks";
|
||||
if (!ensureDirectoryExists(uploadDir)) {
|
||||
callback(jsonError("Failed to create upload directory"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unique filename and create file atomically
|
||||
// This prevents TOCTOU race conditions
|
||||
std::string filename;
|
||||
std::string fullPath;
|
||||
int maxAttempts = 10;
|
||||
|
||||
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
|
||||
filename = generateRandomFilename("epub");
|
||||
fullPath = uploadDir + "/" + filename;
|
||||
|
||||
// Try to create file atomically with exclusive access
|
||||
if (writeFileExclusive(fullPath, fileDataStr.data(), fileSize)) {
|
||||
break; // Success
|
||||
}
|
||||
|
||||
// If file already exists (EEXIST), try again with new name
|
||||
if (errno == EEXIST) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other error - fail
|
||||
LOG_ERROR << "Failed to create file: " << fullPath << " errno: " << errno;
|
||||
callback(jsonError("Failed to save file"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify file was created
|
||||
if (!std::filesystem::exists(fullPath)) {
|
||||
LOG_ERROR << "File was not created after " << maxAttempts << " attempts";
|
||||
callback(jsonError("Failed to save file"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
std::string filePath = "/uploads/ebooks/" + filename;
|
||||
|
||||
// Insert ebook record - status is 'ready' (no server-side processing needed)
|
||||
*dbClient << "INSERT INTO ebooks (user_id, realm_id, title, description, file_path, "
|
||||
"file_size_bytes, status, is_public) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, 'ready', true) RETURNING id, created_at"
|
||||
<< user.id << realmId << title << description << filePath
|
||||
<< static_cast<int64_t>(fileSize)
|
||||
>> [callback, title, filePath, fileSize, realmId](const Result& r2) {
|
||||
if (r2.empty()) {
|
||||
callback(jsonError("Failed to save ebook record"));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t ebookId = r2[0]["id"].as<int64_t>();
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["ebook"]["id"] = static_cast<Json::Int64>(ebookId);
|
||||
resp["ebook"]["realmId"] = static_cast<Json::Int64>(realmId);
|
||||
resp["ebook"]["title"] = title;
|
||||
resp["ebook"]["filePath"] = filePath;
|
||||
resp["ebook"]["fileSizeBytes"] = static_cast<Json::Int64>(fileSize);
|
||||
resp["ebook"]["status"] = "ready";
|
||||
resp["ebook"]["createdAt"] = r2[0]["created_at"].as<std::string>();
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback, fullPath](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to insert ebook: " << e.base().what();
|
||||
// Clean up file on DB error
|
||||
std::filesystem::remove(fullPath);
|
||||
callback(jsonError("Failed to save ebook"));
|
||||
};
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception saving ebook file: " << e.what();
|
||||
callback(jsonError("Failed to save file"));
|
||||
}
|
||||
}
|
||||
>> DB_ERROR(callback, "check uploader status");
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in uploadEbook: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
void EbookController::updateEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(ebookId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid ebook ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Verify ownership
|
||||
*dbClient << "SELECT id FROM ebooks WHERE id = $1 AND user_id = $2 AND status != 'deleted'"
|
||||
<< id << user.id
|
||||
>> [callback, json, dbClient, id](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Ebook not found or access denied", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string title, description;
|
||||
|
||||
if (json->isMember("title")) {
|
||||
title = sanitizeUserInput((*json)["title"].asString(), 255);
|
||||
}
|
||||
if (json->isMember("description")) {
|
||||
description = sanitizeUserInput((*json)["description"].asString(), 5000);
|
||||
}
|
||||
|
||||
if (json->isMember("title") && json->isMember("description")) {
|
||||
*dbClient << "UPDATE ebooks SET title = $1, description = $2, updated_at = NOW() WHERE id = $3"
|
||||
<< title << description << id
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Ebook updated successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update ebook", "Failed to update ebook");
|
||||
} else if (json->isMember("title")) {
|
||||
*dbClient << "UPDATE ebooks SET title = $1, updated_at = NOW() WHERE id = $2"
|
||||
<< title << id
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Ebook updated successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update ebook", "Failed to update ebook");
|
||||
} else if (json->isMember("description")) {
|
||||
*dbClient << "UPDATE ebooks SET description = $1, updated_at = NOW() WHERE id = $2"
|
||||
<< description << id
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Ebook updated successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update ebook", "Failed to update ebook");
|
||||
} else {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "No changes to apply";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
}
|
||||
>> DB_ERROR(callback, "verify ebook ownership");
|
||||
}
|
||||
|
||||
void EbookController::deleteEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(ebookId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid ebook ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get file path and verify ownership
|
||||
*dbClient << "SELECT file_path, cover_path FROM ebooks "
|
||||
"WHERE id = $1 AND user_id = $2 AND status != 'deleted'"
|
||||
<< id << user.id
|
||||
>> [callback, dbClient, id](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Ebook not found or access denied", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string filePath = r[0]["file_path"].as<std::string>();
|
||||
std::string coverPath = r[0]["cover_path"].isNull() ? "" : r[0]["cover_path"].as<std::string>();
|
||||
|
||||
// Soft delete by setting status to 'deleted'
|
||||
*dbClient << "UPDATE ebooks SET status = 'deleted' WHERE id = $1"
|
||||
<< id
|
||||
>> [callback, filePath, coverPath](const Result&) {
|
||||
// Delete files from disk with path validation
|
||||
try {
|
||||
std::string fullEbookPath = "/app" + filePath;
|
||||
// Validate path is within allowed directory before deletion
|
||||
if (isPathSafe(fullEbookPath, "/app/uploads/ebooks")) {
|
||||
if (std::filesystem::exists(fullEbookPath)) {
|
||||
std::filesystem::remove(fullEbookPath);
|
||||
}
|
||||
} else {
|
||||
LOG_WARN << "Blocked deletion of file outside uploads: " << fullEbookPath;
|
||||
}
|
||||
if (!coverPath.empty()) {
|
||||
std::string fullCoverPath = "/app" + coverPath;
|
||||
// Validate cover path as well
|
||||
if (isPathSafe(fullCoverPath, "/app/uploads/ebooks")) {
|
||||
if (std::filesystem::exists(fullCoverPath)) {
|
||||
std::filesystem::remove(fullCoverPath);
|
||||
}
|
||||
} else {
|
||||
LOG_WARN << "Blocked deletion of cover outside uploads: " << fullCoverPath;
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
LOG_WARN << "Failed to delete ebook files: " << e.what();
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Ebook deleted successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "delete ebook", "Failed to delete ebook");
|
||||
}
|
||||
>> DB_ERROR(callback, "get ebook for deletion");
|
||||
}
|
||||
|
||||
void EbookController::uploadCover(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(ebookId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid ebook ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
MultiPartParser parser;
|
||||
parser.parse(req);
|
||||
|
||||
if (parser.getFiles().empty()) {
|
||||
callback(jsonError("No file uploaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& file = parser.getFiles()[0];
|
||||
size_t fileSize = file.fileLength();
|
||||
|
||||
// Validate cover file size
|
||||
if (fileSize > MAX_COVER_SIZE) {
|
||||
callback(jsonError("Cover image too large (max 5MB)"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileSize == 0) {
|
||||
callback(jsonError("Empty file uploaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate image type
|
||||
auto validation = validateImageMagicBytes(file.fileData(), fileSize);
|
||||
if (!validation.valid) {
|
||||
callback(jsonError("Invalid image file. Only JPG, PNG, and WebP are allowed."));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string fileDataStr(file.fileData(), fileSize);
|
||||
std::string fileExt = validation.extension;
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Verify ownership
|
||||
*dbClient << "SELECT id, cover_path FROM ebooks WHERE id = $1 AND user_id = $2 AND status != 'deleted'"
|
||||
<< id << user.id
|
||||
>> [callback, dbClient, id, fileDataStr, fileSize, fileExt](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Ebook not found or access denied", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string oldCoverPath = r[0]["cover_path"].isNull() ? "" : r[0]["cover_path"].as<std::string>();
|
||||
|
||||
// Ensure covers directory exists
|
||||
const std::string coverDir = "/app/uploads/ebooks/covers";
|
||||
if (!ensureDirectoryExists(coverDir)) {
|
||||
callback(jsonError("Failed to create upload directory"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unique filename and create file atomically
|
||||
std::string filename;
|
||||
std::string fullPath;
|
||||
int maxAttempts = 10;
|
||||
|
||||
for (int attempt = 0; attempt < maxAttempts; ++attempt) {
|
||||
filename = generateRandomFilename(fileExt);
|
||||
fullPath = coverDir + "/" + filename;
|
||||
|
||||
if (writeFileExclusive(fullPath, fileDataStr.data(), fileSize)) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (errno == EEXIST) {
|
||||
continue;
|
||||
}
|
||||
|
||||
callback(jsonError("Failed to save cover"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!std::filesystem::exists(fullPath)) {
|
||||
callback(jsonError("Failed to save cover"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string coverPath = "/uploads/ebooks/covers/" + filename;
|
||||
|
||||
// Update database
|
||||
*dbClient << "UPDATE ebooks SET cover_path = $1 WHERE id = $2"
|
||||
<< coverPath << id
|
||||
>> [callback, coverPath, oldCoverPath](const Result&) {
|
||||
// Delete old cover if exists (with path validation)
|
||||
if (!oldCoverPath.empty()) {
|
||||
try {
|
||||
std::string oldFullPath = "/app" + oldCoverPath;
|
||||
// Validate path before deletion
|
||||
if (isPathSafe(oldFullPath, "/app/uploads/ebooks")) {
|
||||
if (std::filesystem::exists(oldFullPath)) {
|
||||
std::filesystem::remove(oldFullPath);
|
||||
}
|
||||
} else {
|
||||
LOG_WARN << "Blocked deletion of old cover outside uploads: " << oldFullPath;
|
||||
}
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["coverPath"] = coverPath;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback, fullPath](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update cover path: " << e.base().what();
|
||||
std::filesystem::remove(fullPath);
|
||||
callback(jsonError("Failed to save cover"));
|
||||
};
|
||||
}
|
||||
>> DB_ERROR(callback, "verify ebook ownership");
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in uploadCover: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
void EbookController::downloadEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId) {
|
||||
// Require authentication for downloads
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Please log in to download ebooks", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(ebookId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid ebook ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get ebook info - only allow download of public, ready ebooks
|
||||
*dbClient << "SELECT title, file_path FROM ebooks WHERE id = $1 AND is_public = true AND status = 'ready'"
|
||||
<< id
|
||||
>> [callback, id](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Ebook not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string title = r[0]["title"].as<std::string>();
|
||||
std::string filePath = r[0]["file_path"].as<std::string>();
|
||||
std::string fullPath = "/app" + filePath;
|
||||
|
||||
// Validate path is within allowed directory
|
||||
if (!isPathSafe(fullPath, "/app/uploads/ebooks")) {
|
||||
LOG_WARN << "Blocked access to file outside uploads: " << fullPath;
|
||||
callback(jsonError("Ebook file not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check file exists
|
||||
if (!std::filesystem::exists(fullPath)) {
|
||||
LOG_ERROR << "Ebook file not found: " << fullPath;
|
||||
callback(jsonError("Ebook file not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize title for filename (remove special chars)
|
||||
std::string safeTitle;
|
||||
for (char c : title) {
|
||||
if (std::isalnum(c) || c == ' ' || c == '-' || c == '_') {
|
||||
safeTitle += c;
|
||||
}
|
||||
}
|
||||
if (safeTitle.empty()) safeTitle = "ebook";
|
||||
if (safeTitle.length() > 100) safeTitle = safeTitle.substr(0, 100);
|
||||
|
||||
// Use Drogon's file response for efficient streaming
|
||||
auto resp = HttpResponse::newFileResponse(fullPath, "", CT_CUSTOM);
|
||||
resp->addHeader("Content-Type", "application/epub+zip");
|
||||
resp->addHeader("Content-Disposition", "attachment; filename=\"" + safeTitle + ".epub\"");
|
||||
callback(resp);
|
||||
}
|
||||
>> DB_ERROR(callback, "download ebook");
|
||||
}
|
||||
72
backend/src/controllers/EbookController.h
Normal file
72
backend/src/controllers/EbookController.h
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class EbookController : public HttpController<EbookController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
// Public endpoints
|
||||
ADD_METHOD_TO(EbookController::getAllEbooks, "/api/ebooks", Get);
|
||||
ADD_METHOD_TO(EbookController::getLatestEbooks, "/api/ebooks/latest", Get);
|
||||
ADD_METHOD_TO(EbookController::getEbook, "/api/ebooks/{1}", Get);
|
||||
ADD_METHOD_TO(EbookController::getUserEbooks, "/api/ebooks/user/{1}", Get);
|
||||
ADD_METHOD_TO(EbookController::getRealmEbooks, "/api/ebooks/realm/{1}", Get);
|
||||
ADD_METHOD_TO(EbookController::incrementReadCount, "/api/ebooks/{1}/read", Post);
|
||||
|
||||
// Authenticated endpoints
|
||||
ADD_METHOD_TO(EbookController::getMyEbooks, "/api/user/ebooks", Get);
|
||||
ADD_METHOD_TO(EbookController::uploadEbook, "/api/user/ebooks", Post);
|
||||
ADD_METHOD_TO(EbookController::updateEbook, "/api/ebooks/{1}", Put);
|
||||
ADD_METHOD_TO(EbookController::deleteEbook, "/api/ebooks/{1}", Delete);
|
||||
ADD_METHOD_TO(EbookController::uploadCover, "/api/ebooks/{1}/cover", Post);
|
||||
ADD_METHOD_TO(EbookController::downloadEbook, "/api/ebooks/{1}/download", Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
// Public ebook listing
|
||||
void getAllEbooks(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getLatestEbooks(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId);
|
||||
|
||||
void getUserEbooks(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username);
|
||||
|
||||
void getRealmEbooks(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void incrementReadCount(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId);
|
||||
|
||||
// Authenticated ebook management
|
||||
void getMyEbooks(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void uploadEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void updateEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId);
|
||||
|
||||
void deleteEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId);
|
||||
|
||||
void uploadCover(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId);
|
||||
|
||||
void downloadEbook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &ebookId);
|
||||
};
|
||||
1788
backend/src/controllers/ForumController.cpp
Normal file
1788
backend/src/controllers/ForumController.cpp
Normal file
File diff suppressed because it is too large
Load diff
122
backend/src/controllers/ForumController.h
Normal file
122
backend/src/controllers/ForumController.h
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class ForumController : public HttpController<ForumController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
// Forum CRUD
|
||||
ADD_METHOD_TO(ForumController::getForums, "/api/forums", Get);
|
||||
ADD_METHOD_TO(ForumController::getForum, "/api/forums/{1}", Get);
|
||||
ADD_METHOD_TO(ForumController::createForum, "/api/forums", Post);
|
||||
ADD_METHOD_TO(ForumController::updateForum, "/api/forums/{1}", Put);
|
||||
ADD_METHOD_TO(ForumController::deleteForum, "/api/forums/{1}", Delete);
|
||||
ADD_METHOD_TO(ForumController::uploadBanner, "/api/forums/{1}/banner", Post);
|
||||
ADD_METHOD_TO(ForumController::deleteBanner, "/api/forums/{1}/banner", Delete);
|
||||
ADD_METHOD_TO(ForumController::updateBannerPosition, "/api/forums/{1}/banner/position", Put);
|
||||
ADD_METHOD_TO(ForumController::updateTitleColor, "/api/forums/{1}/title-color", Put);
|
||||
|
||||
// Thread CRUD
|
||||
ADD_METHOD_TO(ForumController::getThreads, "/api/forums/{1}/threads", Get);
|
||||
ADD_METHOD_TO(ForumController::getThread, "/api/forums/{1}/threads/{2}", Get);
|
||||
ADD_METHOD_TO(ForumController::createThread, "/api/forums/{1}/threads", Post);
|
||||
ADD_METHOD_TO(ForumController::updateThread, "/api/forums/{1}/threads/{2}", Put);
|
||||
ADD_METHOD_TO(ForumController::deleteThread, "/api/forums/{1}/threads/{2}", Delete);
|
||||
ADD_METHOD_TO(ForumController::pinThread, "/api/forums/{1}/threads/{2}/pin", Post);
|
||||
ADD_METHOD_TO(ForumController::lockThread, "/api/forums/{1}/threads/{2}/lock", Post);
|
||||
|
||||
// Post CRUD
|
||||
ADD_METHOD_TO(ForumController::getPosts, "/api/forums/{1}/threads/{2}/posts", Get);
|
||||
ADD_METHOD_TO(ForumController::createPost, "/api/forums/{1}/threads/{2}/posts", Post);
|
||||
ADD_METHOD_TO(ForumController::updatePost, "/api/forums/{1}/threads/{2}/posts/{3}", Put);
|
||||
ADD_METHOD_TO(ForumController::deletePost, "/api/forums/{1}/threads/{2}/posts/{3}", Delete);
|
||||
|
||||
// Moderation
|
||||
ADD_METHOD_TO(ForumController::getBannedUsers, "/api/forums/{1}/bans", Get);
|
||||
ADD_METHOD_TO(ForumController::banUser, "/api/forums/{1}/bans", Post);
|
||||
ADD_METHOD_TO(ForumController::unbanUser, "/api/forums/{1}/bans/{2}", Delete);
|
||||
METHOD_LIST_END
|
||||
|
||||
// Forum methods
|
||||
void getForums(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
void getForum(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void createForum(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
void updateForum(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void deleteForum(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void uploadBanner(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void deleteBanner(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void updateBannerPosition(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void updateTitleColor(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
|
||||
// Thread methods
|
||||
void getThreads(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void getThread(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &threadId);
|
||||
void createThread(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void updateThread(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &threadId);
|
||||
void deleteThread(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &threadId);
|
||||
void pinThread(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &threadId);
|
||||
void lockThread(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &threadId);
|
||||
|
||||
// Post methods
|
||||
void getPosts(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &threadId);
|
||||
void createPost(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &threadId);
|
||||
void updatePost(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &threadId,
|
||||
const std::string &postId);
|
||||
void deletePost(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &threadId,
|
||||
const std::string &postId);
|
||||
|
||||
// Moderation methods
|
||||
void getBannedUsers(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void banUser(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId);
|
||||
void unbanUser(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &forumId, const std::string &banId);
|
||||
|
||||
private:
|
||||
bool isForumModerator(int64_t userId, int64_t forumOwnerId, bool isAdmin);
|
||||
bool isUserBanned(int64_t forumId, int64_t userId);
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -15,10 +15,18 @@ public:
|
|||
ADD_METHOD_TO(RealmController::regenerateRealmKey, "/api/realms/{1}/regenerate-key", Post);
|
||||
ADD_METHOD_TO(RealmController::getRealmByName, "/api/realms/by-name/{1}", Get);
|
||||
ADD_METHOD_TO(RealmController::getLiveRealms, "/api/realms/live", Get);
|
||||
ADD_METHOD_TO(RealmController::getAllRealms, "/api/realms/all", Get);
|
||||
ADD_METHOD_TO(RealmController::validateRealmKey, "/api/realms/validate/{1}", Get);
|
||||
ADD_METHOD_TO(RealmController::issueRealmViewerToken, "/api/realms/{1}/viewer-token", Get);
|
||||
ADD_METHOD_TO(RealmController::getRealmStreamKey, "/api/realms/{1}/stream-key", Get);
|
||||
ADD_METHOD_TO(RealmController::getRealmStats, "/api/realms/{1}/stats", Get);
|
||||
ADD_METHOD_TO(RealmController::getPublicUserRealms, "/api/realms/user/{1}", Get);
|
||||
ADD_METHOD_TO(RealmController::uploadOfflineImage, "/api/realms/{1}/offline-image", Post);
|
||||
ADD_METHOD_TO(RealmController::deleteOfflineImage, "/api/realms/{1}/offline-image", Delete);
|
||||
ADD_METHOD_TO(RealmController::getRealmModerators, "/api/realms/{1}/moderators", Get);
|
||||
ADD_METHOD_TO(RealmController::addRealmModerator, "/api/realms/{1}/moderators", Post);
|
||||
ADD_METHOD_TO(RealmController::removeRealmModerator, "/api/realms/{1}/moderators/{2}", Delete);
|
||||
ADD_METHOD_TO(RealmController::updateTitleColor, "/api/realms/{1}/title-color", Put);
|
||||
METHOD_LIST_END
|
||||
|
||||
void getUserRealms(const HttpRequestPtr &req,
|
||||
|
|
@ -50,6 +58,9 @@ public:
|
|||
void getLiveRealms(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getAllRealms(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void validateRealmKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &key);
|
||||
|
|
@ -66,6 +77,32 @@ public:
|
|||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
private:
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
||||
void getPublicUserRealms(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username);
|
||||
|
||||
void uploadOfflineImage(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void deleteOfflineImage(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void getRealmModerators(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void addRealmModerator(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void removeRealmModerator(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &moderatorId);
|
||||
|
||||
void updateTitleColor(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
};
|
||||
413
backend/src/controllers/RestreamController.cpp
Normal file
413
backend/src/controllers/RestreamController.cpp
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
#include "RestreamController.h"
|
||||
#include "../services/RestreamService.h"
|
||||
#include "../common/HttpHelpers.h"
|
||||
#include "../common/AuthHelpers.h"
|
||||
|
||||
using namespace drogon::orm;
|
||||
|
||||
void RestreamController::verifyRestreamPermission(const HttpRequestPtr &req, int64_t realmId,
|
||||
std::function<void(bool authorized, const UserInfo& user)> callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
|
||||
if (user.id == 0) {
|
||||
callback(false, user);
|
||||
return;
|
||||
}
|
||||
|
||||
// Admin can always manage restreams
|
||||
if (user.isAdmin) {
|
||||
callback(true, user);
|
||||
return;
|
||||
}
|
||||
|
||||
// Must have restreamer role
|
||||
if (!user.isRestreamer) {
|
||||
callback(false, user);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user owns the realm
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT user_id FROM realms WHERE id = $1"
|
||||
<< realmId
|
||||
>> [callback, user](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(false, user);
|
||||
return;
|
||||
}
|
||||
int64_t ownerId = r[0]["user_id"].as<int64_t>();
|
||||
callback(ownerId == user.id, user);
|
||||
}
|
||||
>> [callback, user](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(false, user);
|
||||
};
|
||||
}
|
||||
|
||||
void RestreamController::getDestinations(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
int64_t id = std::stoll(realmId);
|
||||
|
||||
verifyRestreamPermission(req, id, [callback, id](bool authorized, const UserInfo& user) {
|
||||
if (!authorized) {
|
||||
callback(jsonError("Unauthorized - requires restreamer role and realm ownership", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id, name, rtmp_url, stream_key, enabled, is_connected, last_error, "
|
||||
"last_connected_at, created_at FROM restream_destinations WHERE realm_id = $1 "
|
||||
"ORDER BY created_at ASC"
|
||||
<< id
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value destinations(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value dest;
|
||||
dest["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
dest["name"] = row["name"].as<std::string>();
|
||||
dest["rtmpUrl"] = row["rtmp_url"].as<std::string>();
|
||||
dest["streamKey"] = row["stream_key"].as<std::string>();
|
||||
dest["enabled"] = row["enabled"].as<bool>();
|
||||
dest["isConnected"] = row["is_connected"].as<bool>();
|
||||
dest["lastError"] = row["last_error"].isNull() ? "" : row["last_error"].as<std::string>();
|
||||
dest["lastConnectedAt"] = row["last_connected_at"].isNull() ? "" : row["last_connected_at"].as<std::string>();
|
||||
dest["createdAt"] = row["created_at"].as<std::string>();
|
||||
destinations.append(dest);
|
||||
}
|
||||
|
||||
resp["destinations"] = destinations;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "get restream destinations", "Failed to get restream destinations");
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamController::addDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
int64_t id = std::stoll(realmId);
|
||||
|
||||
verifyRestreamPermission(req, id, [this, callback, id, req](bool authorized, const UserInfo& user) {
|
||||
if (!authorized) {
|
||||
callback(jsonError("Unauthorized - requires restreamer role and realm ownership", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
auto jsonPtr = req->getJsonObject();
|
||||
if (!jsonPtr) {
|
||||
callback(jsonError("Invalid JSON body"));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& json = *jsonPtr;
|
||||
std::string name = json.get("name", "").asString();
|
||||
std::string rtmpUrl = json.get("rtmpUrl", "").asString();
|
||||
std::string streamKey = json.get("streamKey", "").asString();
|
||||
bool enabled = json.get("enabled", true).asBool();
|
||||
|
||||
// Validate inputs
|
||||
if (name.empty() || name.length() > 100) {
|
||||
callback(jsonError("Name is required and must be less than 100 characters"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rtmpUrl.empty() || rtmpUrl.length() > 500) {
|
||||
callback(jsonError("RTMP URL is required and must be less than 500 characters"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate RTMP URL format
|
||||
if (rtmpUrl.substr(0, 7) != "rtmp://" && rtmpUrl.substr(0, 8) != "rtmps://") {
|
||||
callback(jsonError("RTMP URL must start with rtmp:// or rtmps://"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (streamKey.empty() || streamKey.length() > 500) {
|
||||
callback(jsonError("Stream key is required and must be less than 500 characters"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Check current count (max 2 destinations per realm)
|
||||
*dbClient << "SELECT COUNT(*) as count FROM restream_destinations WHERE realm_id = $1"
|
||||
<< id
|
||||
>> [dbClient, callback, id, name, rtmpUrl, streamKey, enabled](const Result& r) {
|
||||
int64_t count = r[0]["count"].as<int64_t>();
|
||||
if (count >= 2) {
|
||||
callback(jsonError("Maximum of 2 restream destinations per realm"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert new destination
|
||||
*dbClient << "INSERT INTO restream_destinations (realm_id, name, rtmp_url, stream_key, enabled) "
|
||||
"VALUES ($1, $2, $3, $4, $5) RETURNING id, created_at"
|
||||
<< id << name << rtmpUrl << streamKey << enabled
|
||||
>> [callback, name, rtmpUrl, streamKey, enabled](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Failed to create destination"));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Restream destination created";
|
||||
resp["destination"]["id"] = static_cast<Json::Int64>(r[0]["id"].as<int64_t>());
|
||||
resp["destination"]["name"] = name;
|
||||
resp["destination"]["rtmpUrl"] = rtmpUrl;
|
||||
resp["destination"]["streamKey"] = streamKey;
|
||||
resp["destination"]["enabled"] = enabled;
|
||||
resp["destination"]["isConnected"] = false;
|
||||
resp["destination"]["createdAt"] = r[0]["created_at"].as<std::string>();
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "create restream destination", "Failed to create destination");
|
||||
}
|
||||
>> DB_ERROR(callback, "check destination count");
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamController::updateDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &destinationId) {
|
||||
int64_t rid = std::stoll(realmId);
|
||||
int64_t did = std::stoll(destinationId);
|
||||
|
||||
verifyRestreamPermission(req, rid, [callback, rid, did, req](bool authorized, const UserInfo& user) {
|
||||
if (!authorized) {
|
||||
callback(jsonError("Unauthorized", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
auto jsonPtr = req->getJsonObject();
|
||||
if (!jsonPtr) {
|
||||
callback(jsonError("Invalid JSON body"));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& json = *jsonPtr;
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Validate fields if provided
|
||||
if (json.isMember("name")) {
|
||||
std::string name = json["name"].asString();
|
||||
if (name.empty() || name.length() > 100) {
|
||||
callback(jsonError("Name must be 1-100 characters"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (json.isMember("rtmpUrl")) {
|
||||
std::string rtmpUrl = json["rtmpUrl"].asString();
|
||||
if (rtmpUrl.empty() || rtmpUrl.length() > 500) {
|
||||
callback(jsonError("RTMP URL must be 1-500 characters"));
|
||||
return;
|
||||
}
|
||||
if (rtmpUrl.substr(0, 7) != "rtmp://" && rtmpUrl.substr(0, 8) != "rtmps://") {
|
||||
callback(jsonError("RTMP URL must start with rtmp:// or rtmps://"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (json.isMember("streamKey")) {
|
||||
std::string streamKey = json["streamKey"].asString();
|
||||
if (streamKey.empty() || streamKey.length() > 500) {
|
||||
callback(jsonError("Stream key must be 1-500 characters"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool hasAnyField = json.isMember("name") || json.isMember("rtmpUrl") ||
|
||||
json.isMember("streamKey") || json.isMember("enabled");
|
||||
if (!hasAnyField) {
|
||||
callback(jsonError("No fields to update"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update enabled (most common case)
|
||||
if (json.isMember("enabled") && !json.isMember("name") &&
|
||||
!json.isMember("rtmpUrl") && !json.isMember("streamKey")) {
|
||||
bool newEnabled = json["enabled"].asBool();
|
||||
|
||||
// If disabling, stop the push first
|
||||
if (!newEnabled) {
|
||||
// Get the realm's stream key to stop the push
|
||||
*dbClient << "SELECT stream_key FROM realms WHERE id = $1"
|
||||
<< rid
|
||||
>> [dbClient, callback, did, rid, newEnabled](const Result& r) {
|
||||
if (!r.empty()) {
|
||||
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
||||
// Stop the push
|
||||
RestreamService::getInstance().stopPush(streamKey, did, [](bool) {});
|
||||
}
|
||||
|
||||
// Update the database
|
||||
*dbClient << "UPDATE restream_destinations SET enabled = $1 WHERE id = $2 AND realm_id = $3"
|
||||
<< newEnabled << did << rid
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Destination updated";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update restream destination", "Failed to update destination");
|
||||
}
|
||||
>> DB_ERROR(callback, "get realm");
|
||||
} else {
|
||||
// Just enable it
|
||||
*dbClient << "UPDATE restream_destinations SET enabled = $1 WHERE id = $2 AND realm_id = $3"
|
||||
<< newEnabled << did << rid
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Destination updated";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update restream destination", "Failed to update destination");
|
||||
}
|
||||
}
|
||||
// Update name only
|
||||
else if (json.isMember("name") && !json.isMember("enabled") &&
|
||||
!json.isMember("rtmpUrl") && !json.isMember("streamKey")) {
|
||||
*dbClient << "UPDATE restream_destinations SET name = $1 WHERE id = $2 AND realm_id = $3"
|
||||
<< json["name"].asString() << did << rid
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Destination updated";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update restream destination", "Failed to update destination");
|
||||
}
|
||||
// Full update with all fields
|
||||
else {
|
||||
std::string name = json.get("name", "").asString();
|
||||
std::string rtmpUrl = json.get("rtmpUrl", "").asString();
|
||||
std::string streamKey = json.get("streamKey", "").asString();
|
||||
bool enabled = json.get("enabled", true).asBool();
|
||||
|
||||
*dbClient << "UPDATE restream_destinations SET "
|
||||
"name = $1, rtmp_url = $2, stream_key = $3, enabled = $4 "
|
||||
"WHERE id = $5 AND realm_id = $6"
|
||||
<< name << rtmpUrl << streamKey << enabled << did << rid
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Destination updated";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update restream destination", "Failed to update destination");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamController::deleteDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &destinationId) {
|
||||
int64_t rid = std::stoll(realmId);
|
||||
int64_t did = std::stoll(destinationId);
|
||||
|
||||
verifyRestreamPermission(req, rid, [callback, rid, did](bool authorized, const UserInfo& user) {
|
||||
if (!authorized) {
|
||||
callback(jsonError("Unauthorized", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// First get the realm's stream key to stop any active push
|
||||
*dbClient << "SELECT r.stream_key FROM realms r "
|
||||
"JOIN restream_destinations rd ON rd.realm_id = r.id "
|
||||
"WHERE rd.id = $1 AND rd.realm_id = $2"
|
||||
<< did << rid
|
||||
>> [dbClient, callback, did, rid](const Result& r) {
|
||||
if (!r.empty()) {
|
||||
std::string streamKey = r[0]["stream_key"].as<std::string>();
|
||||
|
||||
// Stop the push if active
|
||||
RestreamService::getInstance().stopPush(streamKey, did, [](bool) {});
|
||||
}
|
||||
|
||||
// Delete the destination
|
||||
*dbClient << "DELETE FROM restream_destinations WHERE id = $1 AND realm_id = $2"
|
||||
<< did << rid
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Destination deleted";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "delete restream destination", "Failed to delete destination");
|
||||
}
|
||||
>> DB_ERROR(callback, "get stream key for delete");
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamController::testDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &destinationId) {
|
||||
int64_t rid = std::stoll(realmId);
|
||||
int64_t did = std::stoll(destinationId);
|
||||
|
||||
verifyRestreamPermission(req, rid, [callback, rid, did](bool authorized, const UserInfo& user) {
|
||||
if (!authorized) {
|
||||
callback(jsonError("Unauthorized", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get the destination and realm info
|
||||
*dbClient << "SELECT rd.id, rd.name, rd.rtmp_url, rd.stream_key, rd.enabled, "
|
||||
"r.stream_key as realm_stream_key, r.is_live "
|
||||
"FROM restream_destinations rd "
|
||||
"JOIN realms r ON rd.realm_id = r.id "
|
||||
"WHERE rd.id = $1 AND rd.realm_id = $2"
|
||||
<< did << rid
|
||||
>> [callback, did](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Destination not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
bool isLive = r[0]["is_live"].as<bool>();
|
||||
if (!isLive) {
|
||||
callback(jsonError("Stream must be live to test restream connection"));
|
||||
return;
|
||||
}
|
||||
|
||||
RestreamDestination dest;
|
||||
dest.id = r[0]["id"].as<int64_t>();
|
||||
dest.name = r[0]["name"].as<std::string>();
|
||||
dest.rtmpUrl = r[0]["rtmp_url"].as<std::string>();
|
||||
dest.streamKey = r[0]["stream_key"].as<std::string>();
|
||||
dest.enabled = r[0]["enabled"].as<bool>();
|
||||
|
||||
std::string realmStreamKey = r[0]["realm_stream_key"].as<std::string>();
|
||||
|
||||
// Try to start the push
|
||||
RestreamService::getInstance().startPush(realmStreamKey, dest,
|
||||
[callback, dest](bool success, const std::string& error) {
|
||||
Json::Value resp;
|
||||
resp["success"] = success;
|
||||
if (success) {
|
||||
resp["message"] = "Restream connection successful";
|
||||
resp["isConnected"] = true;
|
||||
} else {
|
||||
resp["message"] = "Restream connection failed";
|
||||
resp["error"] = error;
|
||||
resp["isConnected"] = false;
|
||||
}
|
||||
callback(jsonResp(resp, success ? k200OK : k400BadRequest));
|
||||
});
|
||||
}
|
||||
>> DB_ERROR(callback, "test restream destination");
|
||||
});
|
||||
}
|
||||
44
backend/src/controllers/RestreamController.h
Normal file
44
backend/src/controllers/RestreamController.h
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class RestreamController : public HttpController<RestreamController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
ADD_METHOD_TO(RestreamController::getDestinations, "/api/realms/{1}/restream", Get);
|
||||
ADD_METHOD_TO(RestreamController::addDestination, "/api/realms/{1}/restream", Post);
|
||||
ADD_METHOD_TO(RestreamController::updateDestination, "/api/realms/{1}/restream/{2}", Put);
|
||||
ADD_METHOD_TO(RestreamController::deleteDestination, "/api/realms/{1}/restream/{2}", Delete);
|
||||
ADD_METHOD_TO(RestreamController::testDestination, "/api/realms/{1}/restream/{2}/test", Post);
|
||||
METHOD_LIST_END
|
||||
|
||||
void getDestinations(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void addDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void updateDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &destinationId);
|
||||
|
||||
void deleteDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &destinationId);
|
||||
|
||||
void testDestination(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &destinationId);
|
||||
|
||||
private:
|
||||
// Verify user has restream permission for a realm (owner + restreamer role)
|
||||
void verifyRestreamPermission(const HttpRequestPtr &req, int64_t realmId,
|
||||
std::function<void(bool authorized, const UserInfo& user)> callback);
|
||||
};
|
||||
|
|
@ -4,6 +4,9 @@
|
|||
#include "../services/RedisHelper.h"
|
||||
#include "../services/OmeClient.h"
|
||||
#include "../services/AuthService.h"
|
||||
#include "../services/RestreamService.h"
|
||||
#include "../common/HttpHelpers.h"
|
||||
#include "../common/AuthHelpers.h"
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <drogon/Cookie.h>
|
||||
#include <random>
|
||||
|
|
@ -15,24 +18,10 @@ using namespace drogon::orm;
|
|||
|
||||
// Helper functions at the top
|
||||
namespace {
|
||||
// JSON response helper - saves 6-8 lines per endpoint
|
||||
HttpResponsePtr jsonResp(const Json::Value& j, HttpStatusCode c = k200OK) {
|
||||
auto r = HttpResponse::newHttpJsonResponse(j);
|
||||
r->setStatusCode(c);
|
||||
return r;
|
||||
}
|
||||
|
||||
HttpResponsePtr jsonOk(const Json::Value& data) {
|
||||
return jsonResp(data);
|
||||
}
|
||||
|
||||
HttpResponsePtr jsonError(const std::string& error, HttpStatusCode code = k400BadRequest) {
|
||||
Json::Value j;
|
||||
j["success"] = false;
|
||||
j["error"] = error;
|
||||
return jsonResp(j, code);
|
||||
}
|
||||
|
||||
// Quick JSON builder for common patterns
|
||||
Json::Value json(std::initializer_list<std::pair<const char*, Json::Value>> items) {
|
||||
Json::Value j;
|
||||
|
|
@ -41,19 +30,6 @@ namespace {
|
|||
}
|
||||
return j;
|
||||
}
|
||||
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req) {
|
||||
UserInfo user;
|
||||
std::string auth = req->getHeader("Authorization");
|
||||
|
||||
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
|
||||
return user;
|
||||
}
|
||||
|
||||
std::string token = auth.substr(7);
|
||||
AuthService::getInstance().validateToken(token, user);
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
||||
// Static member definitions
|
||||
|
|
@ -122,10 +98,7 @@ void StreamController::disconnectStream(const HttpRequestPtr &req,
|
|||
}
|
||||
});
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error: " << e.base().what();
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
>> DB_ERROR(callback, "disconnect stream");
|
||||
}
|
||||
|
||||
void StreamController::getStreamStats(const HttpRequestPtr &,
|
||||
|
|
@ -252,7 +225,8 @@ void StreamController::heartbeat(const HttpRequestPtr &req,
|
|||
return;
|
||||
}
|
||||
|
||||
services::RedisHelper::instance().expireAsync("viewer_token:" + token, 30,
|
||||
// Refresh token TTL to 5 minutes on heartbeat
|
||||
services::RedisHelper::instance().expireAsync("viewer_token:" + token, 300,
|
||||
[callback](bool success) {
|
||||
if (!success) {
|
||||
callback(jsonResp({}, k500InternalServerError));
|
||||
|
|
@ -286,28 +260,28 @@ void StreamWebSocketController::handleNewConnection(const HttpRequestPtr &req,
|
|||
const WebSocketConnectionPtr& wsConnPtr) {
|
||||
LOG_INFO << "New WebSocket connection established";
|
||||
|
||||
auto token = req->getCookie("viewer_token");
|
||||
if (token.empty()) {
|
||||
LOG_WARN << "WebSocket connection without viewer token";
|
||||
wsConnPtr->shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
RedisHelper::getKeyAsync("viewer_token:" + token,
|
||||
[wsConnPtr, token](const std::string& streamKey) {
|
||||
if (streamKey.empty()) {
|
||||
LOG_WARN << "Invalid viewer token";
|
||||
wsConnPtr->shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Allow anonymous connections for receiving public broadcasts (stream_live/stream_offline)
|
||||
// These are used by the home page to get instant updates
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
tokenConnections_[token].insert(wsConnPtr);
|
||||
connections_.insert(wsConnPtr);
|
||||
|
||||
auto token = req->getCookie("viewer_token");
|
||||
if (!token.empty()) {
|
||||
// If viewer token is provided, validate and track it
|
||||
RedisHelper::getKeyAsync("viewer_token:" + token,
|
||||
[wsConnPtr, token](const std::string& streamKey) {
|
||||
if (!streamKey.empty()) {
|
||||
std::lock_guard<std::mutex> lock(connectionsMutex_);
|
||||
tokenConnections_[token].insert(wsConnPtr);
|
||||
LOG_INFO << "WebSocket authenticated for stream: " << streamKey;
|
||||
} else {
|
||||
LOG_DEBUG << "WebSocket with invalid/expired viewer token - treating as anonymous";
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
LOG_DEBUG << "Anonymous WebSocket connection (no viewer token)";
|
||||
}
|
||||
}
|
||||
|
||||
void StreamWebSocketController::handleConnectionClosed(const WebSocketConnectionPtr& wsConnPtr) {
|
||||
|
|
@ -368,3 +342,240 @@ void StreamWebSocketController::broadcastStatsUpdate(const Json::Value& stats) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OvenMediaEngine Webhook Handlers
|
||||
void StreamController::handleOmeWebhook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
auto jsonPtr = req->getJsonObject();
|
||||
if (!jsonPtr) {
|
||||
LOG_WARN << "OME webhook received with invalid JSON";
|
||||
callback(jsonError("Invalid JSON", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& payload = *jsonPtr;
|
||||
std::string eventType = payload.get("eventType", "").asString();
|
||||
|
||||
LOG_INFO << "OME Webhook received: " << eventType;
|
||||
LOG_DEBUG << "OME Webhook payload: " << payload.toStyledString();
|
||||
|
||||
// Extract stream information
|
||||
std::string streamName;
|
||||
if (payload.isMember("stream") && payload["stream"].isMember("name")) {
|
||||
streamName = payload["stream"]["name"].asString();
|
||||
} else if (payload.isMember("streamName")) {
|
||||
streamName = payload["streamName"].asString();
|
||||
}
|
||||
|
||||
if (streamName.empty()) {
|
||||
LOG_WARN << "OME webhook missing stream name";
|
||||
callback(jsonOk(json({{"success", true}, {"message", "Acknowledged"}})));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
if (eventType == "streamCreated" || eventType == "stream.created" || eventType == "publish") {
|
||||
// Stream started - mark realm as live immediately
|
||||
LOG_INFO << "Stream started via webhook: " << streamName;
|
||||
|
||||
*dbClient << "UPDATE realms SET is_live = true, viewer_count = 0, "
|
||||
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1 RETURNING id"
|
||||
<< streamName
|
||||
>> [streamName](const Result& r) {
|
||||
LOG_INFO << "Realm marked as live via webhook: " << streamName;
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
Json::Value msg;
|
||||
msg["type"] = "stream_live";
|
||||
msg["stream_key"] = streamName;
|
||||
msg["is_live"] = true;
|
||||
StreamWebSocketController::broadcastStatsUpdate(msg);
|
||||
|
||||
// Trigger immediate stats fetch
|
||||
StatsService::getInstance().updateStreamStats(streamName);
|
||||
|
||||
// Pre-warm thumbnail cache so it's ready when users see the stream
|
||||
// This makes an async request to generate the thumbnail in the background
|
||||
auto client = HttpClient::newHttpClient("http://localhost:8088");
|
||||
auto req = HttpRequest::newHttpRequest();
|
||||
req->setPath("/thumb/" + streamName + ".webp");
|
||||
req->setMethod(drogon::Get);
|
||||
client->sendRequest(req, [streamName](ReqResult result, const HttpResponsePtr& response) {
|
||||
if (result == ReqResult::Ok && response && response->statusCode() == k200OK) {
|
||||
LOG_INFO << "Thumbnail pre-warmed for stream: " << streamName;
|
||||
} else {
|
||||
LOG_DEBUG << "Thumbnail pre-warm pending for: " << streamName << " (stream may still be initializing)";
|
||||
}
|
||||
}, 10.0); // 10 second timeout for thumbnail generation
|
||||
|
||||
// Start restream destinations if realm has any
|
||||
if (!r.empty()) {
|
||||
int64_t realmId = r[0]["id"].as<int64_t>();
|
||||
RestreamService::getInstance().startAllDestinations(streamName, realmId);
|
||||
}
|
||||
}
|
||||
>> [streamName](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to mark realm live via webhook: " << e.base().what();
|
||||
};
|
||||
}
|
||||
else if (eventType == "streamDeleted" || eventType == "stream.deleted" || eventType == "unpublish") {
|
||||
// Stream ended - mark realm as offline immediately
|
||||
LOG_INFO << "Stream ended via webhook: " << streamName;
|
||||
|
||||
*dbClient << "UPDATE realms SET is_live = false, viewer_count = 0, "
|
||||
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1 RETURNING id"
|
||||
<< streamName
|
||||
>> [streamName](const Result& r) {
|
||||
LOG_INFO << "Realm marked as offline via webhook: " << streamName;
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
Json::Value msg;
|
||||
msg["type"] = "stream_offline";
|
||||
msg["stream_key"] = streamName;
|
||||
msg["is_live"] = false;
|
||||
StreamWebSocketController::broadcastStatsUpdate(msg);
|
||||
|
||||
// Stop all restream destinations
|
||||
if (!r.empty()) {
|
||||
int64_t realmId = r[0]["id"].as<int64_t>();
|
||||
RestreamService::getInstance().stopAllDestinations(streamName, realmId);
|
||||
}
|
||||
}
|
||||
>> [streamName](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to mark realm offline via webhook: " << e.base().what();
|
||||
};
|
||||
}
|
||||
else if (eventType == "sessionCreated" || eventType == "viewer.connected") {
|
||||
// Viewer connected
|
||||
LOG_INFO << "Viewer connected to stream: " << streamName;
|
||||
StatsService::getInstance().updateStreamStats(streamName);
|
||||
}
|
||||
else if (eventType == "sessionDeleted" || eventType == "viewer.disconnected") {
|
||||
// Viewer disconnected
|
||||
LOG_INFO << "Viewer disconnected from stream: " << streamName;
|
||||
StatsService::getInstance().updateStreamStats(streamName);
|
||||
}
|
||||
|
||||
// Always respond with success to acknowledge the webhook
|
||||
callback(jsonOk(json({{"success", true}, {"message", "Webhook processed"}})));
|
||||
}
|
||||
|
||||
void StreamController::handleOmeAdmission(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
// Admission webhook - validates if a stream is allowed to publish/play
|
||||
// OME sends: { "client": {...}, "request": { "direction", "protocol", "status", "url", ... } }
|
||||
auto jsonPtr = req->getJsonObject();
|
||||
if (!jsonPtr) {
|
||||
LOG_WARN << "OME admission webhook received with invalid JSON";
|
||||
callback(jsonError("Invalid JSON", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& payload = *jsonPtr;
|
||||
LOG_INFO << "OME Admission webhook: " << payload.toStyledString();
|
||||
|
||||
// Check if this is a "closing" status - just acknowledge it
|
||||
if (payload.isMember("request") && payload["request"].isMember("status")) {
|
||||
std::string status = payload["request"]["status"].asString();
|
||||
if (status == "closing") {
|
||||
LOG_INFO << "OME admission closing notification";
|
||||
Json::Value response;
|
||||
callback(jsonOk(response)); // Empty response for closing
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract stream key from URL: rtmp://host:port/app/STREAM_KEY or similar
|
||||
std::string streamKey;
|
||||
if (payload.isMember("request") && payload["request"].isMember("url")) {
|
||||
std::string url = payload["request"]["url"].asString();
|
||||
// URL format: scheme://host[:port]/app/stream_key[/file][?query]
|
||||
// Find the stream key after /app/
|
||||
size_t appPos = url.find("/app/");
|
||||
if (appPos != std::string::npos) {
|
||||
std::string afterApp = url.substr(appPos + 5); // Skip "/app/"
|
||||
// Remove any trailing path or query string
|
||||
size_t endPos = afterApp.find_first_of("/?");
|
||||
if (endPos != std::string::npos) {
|
||||
streamKey = afterApp.substr(0, endPos);
|
||||
} else {
|
||||
streamKey = afterApp;
|
||||
}
|
||||
}
|
||||
LOG_INFO << "Extracted stream key from URL: " << streamKey << " (URL: " << url << ")";
|
||||
}
|
||||
|
||||
if (streamKey.empty()) {
|
||||
LOG_WARN << "OME admission webhook: could not extract stream key, allowing by default";
|
||||
Json::Value response;
|
||||
response["allowed"] = true;
|
||||
callback(jsonOk(response));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check direction - only validate "incoming" (publish) requests
|
||||
std::string direction;
|
||||
if (payload.isMember("request") && payload["request"].isMember("direction")) {
|
||||
direction = payload["request"]["direction"].asString();
|
||||
}
|
||||
|
||||
if (direction == "outgoing") {
|
||||
// Playback request - allow all for now (could add viewer auth later)
|
||||
LOG_INFO << "Allowing outgoing (playback) request for: " << streamKey;
|
||||
Json::Value response;
|
||||
response["allowed"] = true;
|
||||
callback(jsonOk(response));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate stream key against database for incoming (publish) requests
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id FROM realms WHERE stream_key = $1 AND is_active = true"
|
||||
<< streamKey
|
||||
>> [callback, streamKey](const Result& r) {
|
||||
Json::Value response;
|
||||
if (!r.empty()) {
|
||||
LOG_INFO << "Stream key validated for admission: " << streamKey;
|
||||
response["allowed"] = true;
|
||||
|
||||
// Mark stream as live immediately when publishing is approved
|
||||
int64_t realmId = r[0]["id"].as<int64_t>();
|
||||
auto db = app().getDbClient();
|
||||
*db << "UPDATE realms SET is_live = true, viewer_count = 0, "
|
||||
"updated_at = CURRENT_TIMESTAMP WHERE id = $1"
|
||||
<< realmId
|
||||
>> [streamKey, realmId](const Result&) {
|
||||
LOG_INFO << "Realm marked live on admission: " << streamKey;
|
||||
|
||||
// Broadcast to WebSocket clients
|
||||
Json::Value msg;
|
||||
msg["type"] = "stream_live";
|
||||
msg["stream_key"] = streamKey;
|
||||
msg["is_live"] = true;
|
||||
StreamWebSocketController::broadcastStatsUpdate(msg);
|
||||
|
||||
// Trigger stats fetch
|
||||
StatsService::getInstance().updateStreamStats(streamKey);
|
||||
|
||||
// Start restream destinations
|
||||
RestreamService::getInstance().startAllDestinations(streamKey, realmId);
|
||||
}
|
||||
>> [streamKey](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to mark realm live on admission: " << e.base().what();
|
||||
};
|
||||
} else {
|
||||
LOG_WARN << "Invalid stream key rejected: " << streamKey;
|
||||
response["allowed"] = false;
|
||||
response["reason"] = "Invalid or inactive stream key";
|
||||
}
|
||||
callback(jsonOk(response));
|
||||
}
|
||||
>> [callback, streamKey](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Database error during admission check: " << e.base().what();
|
||||
// Allow on DB error to prevent blocking legitimate streams
|
||||
Json::Value response;
|
||||
response["allowed"] = true;
|
||||
callback(jsonOk(response));
|
||||
};
|
||||
}
|
||||
|
|
@ -18,6 +18,9 @@ public:
|
|||
ADD_METHOD_TO(StreamController::getActiveStreams, "/api/stream/active", Get);
|
||||
ADD_METHOD_TO(StreamController::issueViewerToken, "/api/stream/token/{1}", Get);
|
||||
ADD_METHOD_TO(StreamController::heartbeat, "/api/stream/heartbeat/{1}", Post);
|
||||
// OvenMediaEngine webhook endpoints
|
||||
ADD_METHOD_TO(StreamController::handleOmeWebhook, "/api/webhook/ome", Post);
|
||||
ADD_METHOD_TO(StreamController::handleOmeAdmission, "/api/webhook/ome/admission", Post);
|
||||
METHOD_LIST_END
|
||||
|
||||
void health(const HttpRequestPtr &req,
|
||||
|
|
@ -45,6 +48,13 @@ public:
|
|||
void heartbeat(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &streamKey);
|
||||
|
||||
// OvenMediaEngine webhook handlers
|
||||
void handleOmeWebhook(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void handleOmeAdmission(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
};
|
||||
|
||||
class StreamWebSocketController : public WebSocketController<StreamWebSocketController> {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,16 +13,40 @@ public:
|
|||
ADD_METHOD_TO(UserController::pgpChallenge, "/api/auth/pgp-challenge", Post);
|
||||
ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post);
|
||||
ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get);
|
||||
ADD_METHOD_TO(UserController::getToken, "/api/user/token", Get);
|
||||
ADD_METHOD_TO(UserController::updateProfile, "/api/user/profile", Put);
|
||||
ADD_METHOD_TO(UserController::updatePassword, "/api/user/password", Put);
|
||||
ADD_METHOD_TO(UserController::togglePgpOnly, "/api/user/pgp-only", Put);
|
||||
ADD_METHOD_TO(UserController::addPgpKey, "/api/user/pgp-key", Post);
|
||||
ADD_METHOD_TO(UserController::getPgpKeys, "/api/user/pgp-keys", Get);
|
||||
ADD_METHOD_TO(UserController::uploadAvatar, "/api/user/avatar", Post);
|
||||
ADD_METHOD_TO(UserController::uploadBanner, "/api/user/banner", Post);
|
||||
ADD_METHOD_TO(UserController::getProfile, "/api/users/{1}", Get);
|
||||
ADD_METHOD_TO(UserController::getUserPgpKeys, "/api/users/{1}/pgp-keys", Get);
|
||||
ADD_METHOD_TO(UserController::updateColor, "/api/user/color", Put);
|
||||
ADD_METHOD_TO(UserController::getAvailableColors, "/api/colors/available", Get);
|
||||
ADD_METHOD_TO(UserController::getBotApiKeys, "/api/user/bot-keys", Get);
|
||||
ADD_METHOD_TO(UserController::createBotApiKey, "/api/user/bot-keys", Post);
|
||||
ADD_METHOD_TO(UserController::deleteBotApiKey, "/api/user/bot-keys/{1}", Delete);
|
||||
ADD_METHOD_TO(UserController::validateBotApiKey, "/api/internal/validate-bot-key", Post);
|
||||
ADD_METHOD_TO(UserController::processPendingUberban, "/api/internal/user/{1}/process-pending-uberban", Post);
|
||||
ADD_METHOD_TO(UserController::submitSticker, "/api/stickers/submit", Post);
|
||||
ADD_METHOD_TO(UserController::getMySubmissions, "/api/stickers/my-submissions", Get);
|
||||
ADD_METHOD_TO(UserController::uploadGraffiti, "/api/user/graffiti", Post);
|
||||
ADD_METHOD_TO(UserController::deleteGraffiti, "/api/user/graffiti", Delete);
|
||||
// Übercoin endpoints
|
||||
ADD_METHOD_TO(UserController::sendUbercoin, "/api/ubercoin/send", Post);
|
||||
ADD_METHOD_TO(UserController::previewUbercoin, "/api/ubercoin/preview", Post);
|
||||
ADD_METHOD_TO(UserController::getTreasury, "/api/ubercoin/treasury", Get);
|
||||
// Treasury cron endpoints (admin-only, called by scheduled tasks)
|
||||
ADD_METHOD_TO(UserController::treasuryApplyGrowth, "/api/ubercoin/cron/growth", Post);
|
||||
ADD_METHOD_TO(UserController::treasuryDistribute, "/api/ubercoin/cron/distribute", Post);
|
||||
// Referral code endpoints
|
||||
ADD_METHOD_TO(UserController::getReferralCodes, "/api/user/referral-codes", Get);
|
||||
ADD_METHOD_TO(UserController::purchaseReferralCode, "/api/user/referral-codes/purchase", Post);
|
||||
ADD_METHOD_TO(UserController::validateReferralCode, "/api/auth/validate-referral", Post);
|
||||
ADD_METHOD_TO(UserController::registerWithReferral, "/api/auth/register-referral", Post);
|
||||
ADD_METHOD_TO(UserController::getReferralSettings, "/api/settings/referral", Get);
|
||||
METHOD_LIST_END
|
||||
|
||||
void register_(const HttpRequestPtr &req,
|
||||
|
|
@ -43,6 +67,9 @@ public:
|
|||
void getCurrentUser(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getToken(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void updateProfile(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
|
|
@ -61,6 +88,9 @@ public:
|
|||
void uploadAvatar(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void uploadBanner(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getProfile(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username);
|
||||
|
|
@ -75,6 +105,76 @@ public:
|
|||
void getAvailableColors(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getBotApiKeys(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void createBotApiKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteBotApiKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &keyId);
|
||||
|
||||
void validateBotApiKey(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void processPendingUberban(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &userId);
|
||||
|
||||
void submitSticker(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getMySubmissions(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void uploadGraffiti(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void deleteGraffiti(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
// Übercoin methods
|
||||
void sendUbercoin(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void previewUbercoin(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getTreasury(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
// Treasury cron methods (admin-only)
|
||||
void treasuryApplyGrowth(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void treasuryDistribute(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
// Referral code methods
|
||||
void getReferralCodes(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void purchaseReferralCode(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void validateReferralCode(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void registerWithReferral(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getReferralSettings(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
private:
|
||||
UserInfo getUserFromRequest(const HttpRequestPtr &req);
|
||||
// Übercoin helper: Calculate burn rate based on account age
|
||||
// Formula: max(1, 99 * e^(-account_age_days / 180))
|
||||
double calculateBurnRate(int accountAgeDays);
|
||||
|
||||
// Referral code helper: Generate random alphanumeric code
|
||||
std::string generateReferralCode(int length = 12);
|
||||
|
||||
// Übercoin helper: Calculate account age in days from created_at timestamp
|
||||
int calculateAccountAgeDays(const std::string& createdAt);
|
||||
};
|
||||
926
backend/src/controllers/VideoController.cpp
Normal file
926
backend/src/controllers/VideoController.cpp
Normal file
|
|
@ -0,0 +1,926 @@
|
|||
#include "VideoController.h"
|
||||
#include "../services/DatabaseService.h"
|
||||
#include "../services/RedisHelper.h" // SECURITY FIX #13: Redis for view rate limiting
|
||||
#include "../common/HttpHelpers.h"
|
||||
#include "../common/AuthHelpers.h"
|
||||
#include "../common/FileUtils.h"
|
||||
#include "../common/FileValidation.h"
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <drogon/Cookie.h>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <fstream>
|
||||
#include <filesystem>
|
||||
#include <cstdlib>
|
||||
#include <array>
|
||||
#include <thread>
|
||||
#include <unistd.h>
|
||||
#include <sys/wait.h>
|
||||
#include <fcntl.h>
|
||||
|
||||
using namespace drogon::orm;
|
||||
|
||||
namespace {
|
||||
// Video metadata extracted from a single ffprobe call
|
||||
struct VideoMetadata {
|
||||
int duration = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int bitrate = 0;
|
||||
std::string videoCodec;
|
||||
std::string audioCodec;
|
||||
};
|
||||
|
||||
// Get all video metadata with a single ffprobe call (5x faster than separate calls)
|
||||
VideoMetadata getVideoMetadata(const std::string& videoPath) {
|
||||
VideoMetadata meta;
|
||||
|
||||
if (!isPathSafe(videoPath, "/app/uploads")) {
|
||||
LOG_ERROR << "Unsafe video path rejected: " << videoPath;
|
||||
return meta;
|
||||
}
|
||||
|
||||
std::vector<std::string> args = {
|
||||
"/usr/bin/ffprobe", "-v", "error",
|
||||
"-show_format", "-show_streams",
|
||||
"-of", "json",
|
||||
videoPath
|
||||
};
|
||||
|
||||
std::string output = execCommandSafe(args);
|
||||
if (output.empty()) {
|
||||
return meta;
|
||||
}
|
||||
|
||||
try {
|
||||
Json::Value root;
|
||||
Json::CharReaderBuilder builder;
|
||||
std::string errors;
|
||||
std::istringstream stream(output);
|
||||
|
||||
if (!Json::parseFromStream(builder, stream, &root, &errors)) {
|
||||
LOG_ERROR << "Failed to parse ffprobe JSON: " << errors;
|
||||
return meta;
|
||||
}
|
||||
|
||||
// Extract format info (duration, bitrate)
|
||||
if (root.isMember("format")) {
|
||||
const auto& format = root["format"];
|
||||
if (format.isMember("duration")) {
|
||||
meta.duration = static_cast<int>(std::stof(format["duration"].asString()));
|
||||
}
|
||||
if (format.isMember("bit_rate")) {
|
||||
try {
|
||||
meta.bitrate = std::stoi(format["bit_rate"].asString());
|
||||
} catch (...) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract stream info (video/audio codecs, dimensions)
|
||||
if (root.isMember("streams") && root["streams"].isArray()) {
|
||||
for (const auto& stream : root["streams"]) {
|
||||
std::string codecType = stream.get("codec_type", "").asString();
|
||||
|
||||
if (codecType == "video" && meta.videoCodec.empty()) {
|
||||
meta.videoCodec = stream.get("codec_name", "").asString();
|
||||
meta.width = stream.get("width", 0).asInt();
|
||||
meta.height = stream.get("height", 0).asInt();
|
||||
} else if (codecType == "audio" && meta.audioCodec.empty()) {
|
||||
meta.audioCodec = stream.get("codec_name", "").asString();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception parsing video metadata: " << e.what();
|
||||
}
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
// Video JSON detail levels for API responses
|
||||
enum class VideoJsonLevel {
|
||||
Minimal, // 9 fields - for realm video lists
|
||||
Basic, // 11 fields - for user video lists (+ realmId, realmName)
|
||||
Standard, // 14 fields - for public lists (+ userId, username, avatarUrl)
|
||||
Extended // 20 fields - for single video detail (+ technical metadata)
|
||||
};
|
||||
|
||||
// Build video JSON object from database row (reduces code duplication)
|
||||
Json::Value buildVideoJson(const drogon::orm::Row& row, VideoJsonLevel level) {
|
||||
Json::Value video;
|
||||
|
||||
// Core fields (all levels)
|
||||
video["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
video["title"] = row["title"].as<std::string>();
|
||||
video["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||
video["filePath"] = row["file_path"].as<std::string>();
|
||||
video["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
||||
video["previewPath"] = row["preview_path"].isNull() ? "" : row["preview_path"].as<std::string>();
|
||||
video["durationSeconds"] = row["duration_seconds"].as<int>();
|
||||
video["viewCount"] = row["view_count"].as<int>();
|
||||
video["createdAt"] = row["created_at"].as<std::string>();
|
||||
|
||||
if (level == VideoJsonLevel::Minimal) return video;
|
||||
|
||||
// Basic+ fields (realm info)
|
||||
video["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||
video["realmName"] = row["realm_name"].as<std::string>();
|
||||
|
||||
if (level == VideoJsonLevel::Basic) return video;
|
||||
|
||||
// Standard+ fields (user info)
|
||||
video["userId"] = static_cast<Json::Int64>(row["user_id"].as<int64_t>());
|
||||
video["username"] = row["username"].as<std::string>();
|
||||
video["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as<std::string>();
|
||||
|
||||
if (level == VideoJsonLevel::Standard) return video;
|
||||
|
||||
// Extended fields (technical metadata)
|
||||
video["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
||||
video["width"] = row["width"].isNull() ? 0 : row["width"].as<int>();
|
||||
video["height"] = row["height"].isNull() ? 0 : row["height"].as<int>();
|
||||
video["bitrate"] = row["bitrate"].isNull() ? 0 : row["bitrate"].as<int>();
|
||||
video["videoCodec"] = row["video_codec"].isNull() ? "" : row["video_codec"].as<std::string>();
|
||||
video["audioCodec"] = row["audio_codec"].isNull() ? "" : row["audio_codec"].as<std::string>();
|
||||
|
||||
return video;
|
||||
}
|
||||
|
||||
// Generate static WebP thumbnail (safe version - no shell)
|
||||
bool generateThumbnail(const std::string& videoPath, const std::string& thumbnailPath, int seekSeconds = 2) {
|
||||
if (!isPathSafe(videoPath, "/app/uploads") || !isPathSafe(thumbnailPath, "/app/uploads")) {
|
||||
LOG_ERROR << "Unsafe path rejected for thumbnail generation";
|
||||
return false;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == -1) return false;
|
||||
|
||||
if (pid == 0) {
|
||||
// Child process - redirect stderr to /dev/null
|
||||
int devnull = open("/dev/null", O_WRONLY);
|
||||
dup2(devnull, STDERR_FILENO);
|
||||
dup2(devnull, STDOUT_FILENO);
|
||||
close(devnull);
|
||||
|
||||
std::string seekStr = std::to_string(seekSeconds);
|
||||
|
||||
execl("/usr/bin/ffmpeg", "ffmpeg",
|
||||
"-y", "-ss", seekStr.c_str(),
|
||||
"-i", videoPath.c_str(),
|
||||
"-vframes", "1",
|
||||
"-vf", "scale=320:-1",
|
||||
"-c:v", "libwebp",
|
||||
"-quality", "80",
|
||||
thumbnailPath.c_str(),
|
||||
nullptr);
|
||||
_exit(1);
|
||||
}
|
||||
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0 && std::filesystem::exists(thumbnailPath);
|
||||
}
|
||||
|
||||
// Generate animated WebP preview (safe version - no shell)
|
||||
bool generateAnimatedPreview(const std::string& videoPath, const std::string& previewPath, int seekSeconds = 2, int duration = 3) {
|
||||
if (!isPathSafe(videoPath, "/app/uploads") || !isPathSafe(previewPath, "/app/uploads")) {
|
||||
LOG_ERROR << "Unsafe path rejected for preview generation";
|
||||
return false;
|
||||
}
|
||||
|
||||
pid_t pid = fork();
|
||||
if (pid == -1) return false;
|
||||
|
||||
if (pid == 0) {
|
||||
// Child process
|
||||
int devnull = open("/dev/null", O_WRONLY);
|
||||
dup2(devnull, STDERR_FILENO);
|
||||
dup2(devnull, STDOUT_FILENO);
|
||||
close(devnull);
|
||||
|
||||
std::string seekStr = std::to_string(seekSeconds);
|
||||
std::string durationStr = std::to_string(duration);
|
||||
|
||||
execl("/usr/bin/ffmpeg", "ffmpeg",
|
||||
"-y", "-ss", seekStr.c_str(),
|
||||
"-t", durationStr.c_str(),
|
||||
"-i", videoPath.c_str(),
|
||||
"-vf", "scale=320:-1,fps=10",
|
||||
"-loop", "0",
|
||||
"-c:v", "libwebp",
|
||||
"-quality", "60",
|
||||
previewPath.c_str(),
|
||||
nullptr);
|
||||
_exit(1);
|
||||
}
|
||||
|
||||
int status;
|
||||
waitpid(pid, &status, 0);
|
||||
return WIFEXITED(status) && WEXITSTATUS(status) == 0 && std::filesystem::exists(previewPath);
|
||||
}
|
||||
|
||||
// Process video thumbnails asynchronously
|
||||
void processVideoThumbnails(int64_t videoId, const std::string& videoFullPath, const std::string& uploadsDir) {
|
||||
// Run thumbnail generation in a separate thread
|
||||
std::thread([videoId, videoFullPath, uploadsDir]() {
|
||||
try {
|
||||
// Get all video metadata with a single ffprobe call (5x faster)
|
||||
VideoMetadata meta = getVideoMetadata(videoFullPath);
|
||||
|
||||
// Calculate seek position (10% into video, min 1s, max 30s)
|
||||
int seekPos = std::max(1, std::min(30, meta.duration / 10));
|
||||
|
||||
// Generate filenames
|
||||
std::string baseName = std::filesystem::path(videoFullPath).stem().string();
|
||||
std::string thumbnailFilename = baseName + "_thumb.webp";
|
||||
std::string previewFilename = baseName + "_preview.webp";
|
||||
std::string thumbnailFullPath = uploadsDir + "/" + thumbnailFilename;
|
||||
std::string previewFullPath = uploadsDir + "/" + previewFilename;
|
||||
|
||||
// Generate thumbnails
|
||||
bool thumbOk = generateThumbnail(videoFullPath, thumbnailFullPath, seekPos);
|
||||
bool previewOk = generateAnimatedPreview(videoFullPath, previewFullPath, seekPos, 3);
|
||||
|
||||
// Update database
|
||||
std::string thumbnailPath = thumbOk ? "/uploads/videos/" + thumbnailFilename : "";
|
||||
std::string previewPath = previewOk ? "/uploads/videos/" + previewFilename : "";
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE videos SET thumbnail_path = $1, preview_path = $2, "
|
||||
"duration_seconds = $3, width = $4, height = $5, "
|
||||
"bitrate = $6, video_codec = $7, audio_codec = $8 "
|
||||
"WHERE id = $9"
|
||||
<< thumbnailPath << previewPath << meta.duration << meta.width << meta.height
|
||||
<< meta.bitrate << meta.videoCodec << meta.audioCodec << videoId
|
||||
>> [videoId](const Result&) {
|
||||
LOG_INFO << "Video " << videoId << " thumbnails and metadata generated successfully";
|
||||
}
|
||||
>> [videoId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update video " << videoId << " metadata: " << e.base().what();
|
||||
};
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception processing thumbnails for video " << videoId << ": " << e.what();
|
||||
}
|
||||
}).detach();
|
||||
}
|
||||
}
|
||||
|
||||
void VideoController::getAllVideos(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
// Get pagination parameters
|
||||
int page = 1;
|
||||
int limit = 20;
|
||||
|
||||
auto pageParam = req->getParameter("page");
|
||||
auto limitParam = req->getParameter("limit");
|
||||
|
||||
if (!pageParam.empty()) {
|
||||
try { page = std::stoi(pageParam); } catch (...) {}
|
||||
}
|
||||
if (!limitParam.empty()) {
|
||||
try { limit = std::min(std::stoi(limitParam), 50); } catch (...) {}
|
||||
}
|
||||
|
||||
int offset = (page - 1) * limit;
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
|
||||
"v.duration_seconds, v.view_count, v.created_at, v.realm_id, "
|
||||
"u.id as user_id, u.username, u.avatar_url, "
|
||||
"r.name as realm_name "
|
||||
"FROM videos v "
|
||||
"JOIN users u ON v.user_id = u.id "
|
||||
"JOIN realms r ON v.realm_id = r.id "
|
||||
"WHERE v.is_public = true AND v.status = 'ready' "
|
||||
"ORDER BY v.created_at DESC "
|
||||
"LIMIT $1 OFFSET $2"
|
||||
<< static_cast<int64_t>(limit) << static_cast<int64_t>(offset)
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value videos(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
videos.append(buildVideoJson(row, VideoJsonLevel::Standard));
|
||||
}
|
||||
|
||||
resp["videos"] = videos;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get videos");
|
||||
}
|
||||
|
||||
void VideoController::getLatestVideos(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
|
||||
"v.duration_seconds, v.view_count, v.created_at, v.realm_id, "
|
||||
"u.id as user_id, u.username, u.avatar_url, "
|
||||
"r.name as realm_name "
|
||||
"FROM videos v "
|
||||
"JOIN users u ON v.user_id = u.id "
|
||||
"JOIN realms r ON v.realm_id = r.id "
|
||||
"WHERE v.is_public = true AND v.status = 'ready' "
|
||||
"ORDER BY v.created_at DESC "
|
||||
"LIMIT 5"
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value videos(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
videos.append(buildVideoJson(row, VideoJsonLevel::Standard));
|
||||
}
|
||||
|
||||
resp["videos"] = videos;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get latest videos");
|
||||
}
|
||||
|
||||
void VideoController::getVideo(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &videoId) {
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(videoId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid video ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
|
||||
"v.duration_seconds, v.file_size_bytes, v.width, v.height, "
|
||||
"v.bitrate, v.video_codec, v.audio_codec, "
|
||||
"v.view_count, v.is_public, v.status, v.created_at, v.realm_id, "
|
||||
"u.id as user_id, u.username, u.avatar_url, "
|
||||
"r.name as realm_name "
|
||||
"FROM videos v "
|
||||
"JOIN users u ON v.user_id = u.id "
|
||||
"JOIN realms r ON v.realm_id = r.id "
|
||||
"WHERE v.id = $1 AND v.status = 'ready'"
|
||||
<< id
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Video not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& row = r[0];
|
||||
|
||||
// Check if video is public
|
||||
if (!row["is_public"].as<bool>()) {
|
||||
callback(jsonError("Video not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["video"] = buildVideoJson(row, VideoJsonLevel::Extended);
|
||||
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get video");
|
||||
}
|
||||
|
||||
void VideoController::getUserVideos(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username) {
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
|
||||
"v.duration_seconds, v.view_count, v.created_at, v.realm_id, "
|
||||
"u.id as user_id, u.username, u.avatar_url, "
|
||||
"r.name as realm_name "
|
||||
"FROM videos v "
|
||||
"JOIN users u ON v.user_id = u.id "
|
||||
"JOIN realms r ON v.realm_id = r.id "
|
||||
"WHERE u.username = $1 AND v.is_public = true AND v.status = 'ready' "
|
||||
"ORDER BY v.created_at DESC"
|
||||
<< username
|
||||
>> [callback, username](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["username"] = username;
|
||||
Json::Value videos(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
videos.append(buildVideoJson(row, VideoJsonLevel::Standard));
|
||||
}
|
||||
|
||||
resp["videos"] = videos;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get user videos");
|
||||
}
|
||||
|
||||
void VideoController::getRealmVideos(const HttpRequestPtr &,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId) {
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(realmId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid realm ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
// First get realm info
|
||||
*dbClient << "SELECT r.id, r.name, r.description, r.realm_type, r.title_color, r.created_at, "
|
||||
"u.id as user_id, u.username, u.avatar_url "
|
||||
"FROM realms r "
|
||||
"JOIN users u ON r.user_id = u.id "
|
||||
"WHERE r.id = $1 AND r.is_active = true AND r.realm_type = 'video'"
|
||||
<< id
|
||||
>> [callback, dbClient, id](const Result& realmResult) {
|
||||
if (realmResult.empty()) {
|
||||
callback(jsonError("Video realm not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
// Get videos for this realm
|
||||
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
|
||||
"v.duration_seconds, v.view_count, v.created_at "
|
||||
"FROM videos v "
|
||||
"WHERE v.realm_id = $1 AND v.is_public = true AND v.status = 'ready' "
|
||||
"ORDER BY v.created_at DESC"
|
||||
<< id
|
||||
>> [callback, realmResult](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
|
||||
// Realm info
|
||||
auto& realm = resp["realm"];
|
||||
realm["id"] = static_cast<Json::Int64>(realmResult[0]["id"].as<int64_t>());
|
||||
realm["name"] = realmResult[0]["name"].as<std::string>();
|
||||
realm["description"] = realmResult[0]["description"].isNull() ? "" : realmResult[0]["description"].as<std::string>();
|
||||
realm["titleColor"] = realmResult[0]["title_color"].isNull() ? "#ffffff" : realmResult[0]["title_color"].as<std::string>();
|
||||
realm["createdAt"] = realmResult[0]["created_at"].as<std::string>();
|
||||
realm["userId"] = static_cast<Json::Int64>(realmResult[0]["user_id"].as<int64_t>());
|
||||
realm["username"] = realmResult[0]["username"].as<std::string>();
|
||||
realm["avatarUrl"] = realmResult[0]["avatar_url"].isNull() ? "" : realmResult[0]["avatar_url"].as<std::string>();
|
||||
|
||||
// Videos (Minimal level - no realm/user info since it's implied)
|
||||
Json::Value videos(Json::arrayValue);
|
||||
for (const auto& row : r) {
|
||||
videos.append(buildVideoJson(row, VideoJsonLevel::Minimal));
|
||||
}
|
||||
|
||||
resp["videos"] = videos;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get realm videos");
|
||||
}
|
||||
>> DB_ERROR(callback, "get realm");
|
||||
}
|
||||
|
||||
void VideoController::incrementViewCount(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &videoId) {
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(videoId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid video ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY FIX #13: Rate limit view count increments per IP per video
|
||||
// Prevents artificial view count inflation
|
||||
std::string clientIp = req->getPeerAddr().toIp();
|
||||
std::string rateKey = "view_limit:" + std::to_string(id) + ":" + clientIp;
|
||||
|
||||
// Check if this IP has already viewed this video recently (5 minute window)
|
||||
RedisHelper::getKeyAsync(rateKey, [callback, id, rateKey, clientIp](const std::string& exists) {
|
||||
if (!exists.empty()) {
|
||||
// Already counted recently - return success but don't increment
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "View already counted";
|
||||
callback(jsonResp(resp));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set rate limit key first (TTL 300 seconds = 5 minutes)
|
||||
RedisHelper::storeKeyAsync(rateKey, "1", 300, [callback, id](bool stored) {
|
||||
if (!stored) {
|
||||
LOG_WARN << "Failed to set view rate limit key, allowing view anyway";
|
||||
}
|
||||
|
||||
// Increment view count in database
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE videos SET view_count = view_count + 1 "
|
||||
"WHERE id = $1 AND is_public = true AND status = 'ready' "
|
||||
"RETURNING view_count"
|
||||
<< id
|
||||
>> [callback](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Video not found", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["viewCount"] = r[0]["view_count"].as<int>();
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "increment view count");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void VideoController::getMyVideos(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT v.id, v.title, v.description, v.file_path, v.thumbnail_path, v.preview_path, "
|
||||
"v.duration_seconds, v.file_size_bytes, v.view_count, v.is_public, v.status, v.created_at, "
|
||||
"v.realm_id, r.name as realm_name "
|
||||
"FROM videos v "
|
||||
"JOIN realms r ON v.realm_id = r.id "
|
||||
"WHERE v.user_id = $1 AND v.status != 'deleted' "
|
||||
"ORDER BY v.created_at DESC"
|
||||
<< user.id
|
||||
>> [callback](const Result& r) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
Json::Value videos(Json::arrayValue);
|
||||
|
||||
for (const auto& row : r) {
|
||||
Json::Value video;
|
||||
video["id"] = static_cast<Json::Int64>(row["id"].as<int64_t>());
|
||||
video["title"] = row["title"].as<std::string>();
|
||||
video["description"] = row["description"].isNull() ? "" : row["description"].as<std::string>();
|
||||
video["filePath"] = row["file_path"].as<std::string>();
|
||||
video["thumbnailPath"] = row["thumbnail_path"].isNull() ? "" : row["thumbnail_path"].as<std::string>();
|
||||
video["previewPath"] = row["preview_path"].isNull() ? "" : row["preview_path"].as<std::string>();
|
||||
video["durationSeconds"] = row["duration_seconds"].as<int>();
|
||||
video["fileSizeBytes"] = static_cast<Json::Int64>(row["file_size_bytes"].as<int64_t>());
|
||||
video["viewCount"] = row["view_count"].as<int>();
|
||||
video["isPublic"] = row["is_public"].as<bool>();
|
||||
video["status"] = row["status"].as<std::string>();
|
||||
video["createdAt"] = row["created_at"].as<std::string>();
|
||||
video["realmId"] = static_cast<Json::Int64>(row["realm_id"].as<int64_t>());
|
||||
video["realmName"] = row["realm_name"].as<std::string>();
|
||||
videos.append(video);
|
||||
}
|
||||
|
||||
resp["videos"] = videos;
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR(callback, "get user videos");
|
||||
}
|
||||
|
||||
void VideoController::uploadVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||
try {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
MultiPartParser parser;
|
||||
parser.parse(req);
|
||||
|
||||
// Get realm ID from form data - required
|
||||
std::string realmIdStr = parser.getParameter<std::string>("realmId");
|
||||
if (realmIdStr.empty()) {
|
||||
callback(jsonError("Realm ID is required"));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t realmId;
|
||||
try {
|
||||
realmId = std::stoll(realmIdStr);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid realm ID"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract all data from parser before async call
|
||||
if (parser.getFiles().empty()) {
|
||||
callback(jsonError("No file uploaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
const auto& file = parser.getFiles()[0];
|
||||
|
||||
// Get title from form data
|
||||
std::string title = parser.getParameter<std::string>("title");
|
||||
if (title.empty()) {
|
||||
title = "Untitled Video";
|
||||
}
|
||||
if (title.length() > 255) {
|
||||
title = title.substr(0, 255);
|
||||
}
|
||||
|
||||
// Get optional description
|
||||
std::string description = parser.getParameter<std::string>("description");
|
||||
if (description.length() > 5000) {
|
||||
description = description.substr(0, 5000);
|
||||
}
|
||||
|
||||
// Validate file size (500MB max)
|
||||
const size_t maxSize = 500 * 1024 * 1024;
|
||||
size_t fileSize = file.fileLength();
|
||||
if (fileSize > maxSize) {
|
||||
callback(jsonError("File too large (max 500MB)"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileSize == 0) {
|
||||
callback(jsonError("Empty file uploaded"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate video magic bytes
|
||||
auto validation = validateVideoMagicBytes(file.fileData(), fileSize);
|
||||
if (!validation.valid) {
|
||||
LOG_WARN << "Video upload rejected: invalid video magic bytes";
|
||||
callback(jsonError("Invalid video file. Only MP4, WebM, and MOV are allowed."));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string fileExt = validation.extension.substr(1);
|
||||
|
||||
// Write file to disk IMMEDIATELY (before async DB calls) to avoid holding 500MB in memory
|
||||
const std::string uploadDir = "/app/uploads/videos";
|
||||
if (!ensureDirectoryExists(uploadDir)) {
|
||||
callback(jsonError("Failed to create upload directory"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
std::string filename = generateRandomFilename(fileExt);
|
||||
std::string fullPath = uploadDir + "/" + filename;
|
||||
|
||||
// Ensure file doesn't exist
|
||||
while (std::filesystem::exists(fullPath)) {
|
||||
filename = generateRandomFilename(fileExt);
|
||||
fullPath = uploadDir + "/" + filename;
|
||||
}
|
||||
|
||||
// Write directly from Drogon buffer (no memory copy)
|
||||
try {
|
||||
std::ofstream ofs(fullPath, std::ios::binary);
|
||||
if (!ofs) {
|
||||
LOG_ERROR << "Failed to create file: " << fullPath;
|
||||
callback(jsonError("Failed to save file"));
|
||||
return;
|
||||
}
|
||||
|
||||
ofs.write(file.fileData(), fileSize);
|
||||
ofs.close();
|
||||
|
||||
if (!std::filesystem::exists(fullPath)) {
|
||||
LOG_ERROR << "File was not created: " << fullPath;
|
||||
callback(jsonError("Failed to save file"));
|
||||
return;
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception saving video file: " << e.what();
|
||||
callback(jsonError("Failed to save file"));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string filePath = "/uploads/videos/" + filename;
|
||||
|
||||
// Check if user has uploader role and the realm exists and belongs to them
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT u.is_uploader, r.id as realm_id, r.realm_type "
|
||||
"FROM users u "
|
||||
"LEFT JOIN realms r ON r.user_id = u.id AND r.id = $2 "
|
||||
"WHERE u.id = $1"
|
||||
<< user.id << realmId
|
||||
>> [callback, user, dbClient, realmId, title, description, fullPath, filePath, fileSize, uploadDir](const Result& r) {
|
||||
if (r.empty() || !r[0]["is_uploader"].as<bool>()) {
|
||||
std::filesystem::remove(fullPath); // Clean up file on permission failure
|
||||
callback(jsonError("You don't have permission to upload videos", k403Forbidden));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if realm exists and belongs to user
|
||||
if (r[0]["realm_id"].isNull()) {
|
||||
std::filesystem::remove(fullPath); // Clean up file
|
||||
callback(jsonError("Video realm not found or doesn't belong to you", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if it's a video realm
|
||||
std::string realmType = r[0]["realm_type"].isNull() ? "stream" : r[0]["realm_type"].as<std::string>();
|
||||
if (realmType != "video") {
|
||||
std::filesystem::remove(fullPath); // Clean up file
|
||||
callback(jsonError("Can only upload videos to video realms", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert video record - status is 'ready' for now (no processing)
|
||||
*dbClient << "INSERT INTO videos (user_id, realm_id, title, description, file_path, "
|
||||
"file_size_bytes, status, is_public, duration_seconds) "
|
||||
"VALUES ($1, $2, $3, $4, $5, $6, 'ready', true, 0) RETURNING id, created_at"
|
||||
<< user.id << realmId << title << description << filePath
|
||||
<< static_cast<int64_t>(fileSize)
|
||||
>> [callback, title, filePath, fileSize, realmId, fullPath, uploadDir](const Result& r2) {
|
||||
if (r2.empty()) {
|
||||
std::filesystem::remove(fullPath); // Clean up file
|
||||
callback(jsonError("Failed to save video record"));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t videoId = r2[0]["id"].as<int64_t>();
|
||||
|
||||
// Start async thumbnail generation
|
||||
processVideoThumbnails(videoId, fullPath, uploadDir);
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["video"]["id"] = static_cast<Json::Int64>(videoId);
|
||||
resp["video"]["realmId"] = static_cast<Json::Int64>(realmId);
|
||||
resp["video"]["title"] = title;
|
||||
resp["video"]["filePath"] = filePath;
|
||||
resp["video"]["fileSizeBytes"] = static_cast<Json::Int64>(fileSize);
|
||||
resp["video"]["status"] = "ready";
|
||||
resp["video"]["createdAt"] = r2[0]["created_at"].as<std::string>();
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> [callback, fullPath](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to insert video: " << e.base().what();
|
||||
// Clean up file on DB error
|
||||
std::filesystem::remove(fullPath);
|
||||
callback(jsonError("Failed to save video"));
|
||||
};
|
||||
}
|
||||
>> [callback, fullPath](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to check uploader status: " << e.base().what();
|
||||
std::filesystem::remove(fullPath); // Clean up file on DB error
|
||||
callback(jsonError("Database error"));
|
||||
};
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Exception in uploadVideo: " << e.what();
|
||||
callback(jsonError("Internal server error"));
|
||||
}
|
||||
}
|
||||
|
||||
void VideoController::updateVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &videoId) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(videoId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid video ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = req->getJsonObject();
|
||||
if (!json) {
|
||||
callback(jsonError("Invalid JSON"));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Verify ownership
|
||||
*dbClient << "SELECT id FROM videos WHERE id = $1 AND user_id = $2 AND status != 'deleted'"
|
||||
<< id << user.id
|
||||
>> [callback, json, dbClient, id](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Video not found or access denied", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string title, description;
|
||||
|
||||
if (json->isMember("title")) {
|
||||
title = (*json)["title"].asString();
|
||||
if (title.length() > 255) title = title.substr(0, 255);
|
||||
}
|
||||
if (json->isMember("description")) {
|
||||
description = (*json)["description"].asString();
|
||||
if (description.length() > 5000) description = description.substr(0, 5000);
|
||||
}
|
||||
|
||||
if (json->isMember("title") && json->isMember("description")) {
|
||||
*dbClient << "UPDATE videos SET title = $1, description = $2 WHERE id = $3"
|
||||
<< title << description << id
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Video updated successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update video", "Failed to update video");
|
||||
} else if (json->isMember("title")) {
|
||||
*dbClient << "UPDATE videos SET title = $1 WHERE id = $2"
|
||||
<< title << id
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Video updated successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update video", "Failed to update video");
|
||||
} else if (json->isMember("description")) {
|
||||
*dbClient << "UPDATE videos SET description = $1 WHERE id = $2"
|
||||
<< description << id
|
||||
>> [callback](const Result&) {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Video updated successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "update video", "Failed to update video");
|
||||
} else {
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "No changes to apply";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
}
|
||||
>> DB_ERROR(callback, "verify video ownership");
|
||||
}
|
||||
|
||||
void VideoController::deleteVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &videoId) {
|
||||
UserInfo user = getUserFromRequest(req);
|
||||
if (user.id == 0) {
|
||||
callback(jsonError("Unauthorized", k401Unauthorized));
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t id;
|
||||
try {
|
||||
id = std::stoll(videoId);
|
||||
} catch (...) {
|
||||
callback(jsonError("Invalid video ID", k400BadRequest));
|
||||
return;
|
||||
}
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get file paths and verify ownership
|
||||
*dbClient << "SELECT file_path, thumbnail_path, preview_path FROM videos "
|
||||
"WHERE id = $1 AND user_id = $2 AND status != 'deleted'"
|
||||
<< id << user.id
|
||||
>> [callback, dbClient, id](const Result& r) {
|
||||
if (r.empty()) {
|
||||
callback(jsonError("Video not found or access denied", k404NotFound));
|
||||
return;
|
||||
}
|
||||
|
||||
std::string filePath = r[0]["file_path"].as<std::string>();
|
||||
std::string thumbnailPath = r[0]["thumbnail_path"].isNull() ? "" : r[0]["thumbnail_path"].as<std::string>();
|
||||
std::string previewPath = r[0]["preview_path"].isNull() ? "" : r[0]["preview_path"].as<std::string>();
|
||||
|
||||
// Soft delete by setting status to 'deleted'
|
||||
*dbClient << "UPDATE videos SET status = 'deleted' WHERE id = $1"
|
||||
<< id
|
||||
>> [callback, filePath, thumbnailPath, previewPath](const Result&) {
|
||||
// Delete files from disk
|
||||
try {
|
||||
std::string fullVideoPath = "/app" + filePath;
|
||||
if (std::filesystem::exists(fullVideoPath)) {
|
||||
std::filesystem::remove(fullVideoPath);
|
||||
}
|
||||
if (!thumbnailPath.empty()) {
|
||||
std::string fullThumbPath = "/app" + thumbnailPath;
|
||||
if (std::filesystem::exists(fullThumbPath)) {
|
||||
std::filesystem::remove(fullThumbPath);
|
||||
}
|
||||
}
|
||||
if (!previewPath.empty()) {
|
||||
std::string fullPreviewPath = "/app" + previewPath;
|
||||
if (std::filesystem::exists(fullPreviewPath)) {
|
||||
std::filesystem::remove(fullPreviewPath);
|
||||
}
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
LOG_WARN << "Failed to delete video files: " << e.what();
|
||||
}
|
||||
|
||||
Json::Value resp;
|
||||
resp["success"] = true;
|
||||
resp["message"] = "Video deleted successfully";
|
||||
callback(jsonResp(resp));
|
||||
}
|
||||
>> DB_ERROR_MSG(callback, "delete video", "Failed to delete video");
|
||||
}
|
||||
>> DB_ERROR(callback, "get video for deletion");
|
||||
}
|
||||
62
backend/src/controllers/VideoController.h
Normal file
62
backend/src/controllers/VideoController.h
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
class VideoController : public HttpController<VideoController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
// Public endpoints
|
||||
ADD_METHOD_TO(VideoController::getAllVideos, "/api/videos", Get);
|
||||
ADD_METHOD_TO(VideoController::getLatestVideos, "/api/videos/latest", Get);
|
||||
ADD_METHOD_TO(VideoController::getVideo, "/api/videos/{1}", Get);
|
||||
ADD_METHOD_TO(VideoController::getUserVideos, "/api/videos/user/{1}", Get);
|
||||
ADD_METHOD_TO(VideoController::getRealmVideos, "/api/videos/realm/{1}", Get);
|
||||
ADD_METHOD_TO(VideoController::incrementViewCount, "/api/videos/{1}/view", Post);
|
||||
|
||||
// Authenticated endpoints
|
||||
ADD_METHOD_TO(VideoController::getMyVideos, "/api/user/videos", Get);
|
||||
ADD_METHOD_TO(VideoController::uploadVideo, "/api/user/videos", Post);
|
||||
ADD_METHOD_TO(VideoController::updateVideo, "/api/videos/{1}", Put);
|
||||
ADD_METHOD_TO(VideoController::deleteVideo, "/api/videos/{1}", Delete);
|
||||
METHOD_LIST_END
|
||||
|
||||
// Public video listing
|
||||
void getAllVideos(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getLatestVideos(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void getVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &videoId);
|
||||
|
||||
void getUserVideos(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &username);
|
||||
|
||||
void getRealmVideos(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void incrementViewCount(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &videoId);
|
||||
|
||||
// Authenticated video management
|
||||
void getMyVideos(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void uploadVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
void updateVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &videoId);
|
||||
|
||||
void deleteVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &videoId);
|
||||
};
|
||||
1305
backend/src/controllers/WatchController.cpp
Normal file
1305
backend/src/controllers/WatchController.cpp
Normal file
File diff suppressed because it is too large
Load diff
118
backend/src/controllers/WatchController.h
Normal file
118
backend/src/controllers/WatchController.h
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpController.h>
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include "../services/AuthService.h"
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
||||
class WatchController : public HttpController<WatchController> {
|
||||
public:
|
||||
METHOD_LIST_BEGIN
|
||||
// List watch rooms
|
||||
ADD_METHOD_TO(WatchController::getWatchRooms, "/api/watch/rooms", Get);
|
||||
|
||||
// Playlist management
|
||||
ADD_METHOD_TO(WatchController::getPlaylist, "/api/watch/{1}/playlist", Get);
|
||||
ADD_METHOD_TO(WatchController::addToPlaylist, "/api/watch/{1}/playlist", Post);
|
||||
ADD_METHOD_TO(WatchController::removeFromPlaylist, "/api/watch/{1}/playlist/{2}", Delete);
|
||||
ADD_METHOD_TO(WatchController::reorderPlaylist, "/api/watch/{1}/playlist/reorder", Put);
|
||||
ADD_METHOD_TO(WatchController::toggleLock, "/api/watch/{1}/playlist/{2}/lock", Put);
|
||||
|
||||
// Playback control
|
||||
ADD_METHOD_TO(WatchController::getRoomState, "/api/watch/{1}/state", Get);
|
||||
ADD_METHOD_TO(WatchController::playVideo, "/api/watch/{1}/play", Post);
|
||||
ADD_METHOD_TO(WatchController::pauseVideo, "/api/watch/{1}/pause", Post);
|
||||
ADD_METHOD_TO(WatchController::seekVideo, "/api/watch/{1}/seek", Post);
|
||||
ADD_METHOD_TO(WatchController::skipVideo, "/api/watch/{1}/skip", Post);
|
||||
ADD_METHOD_TO(WatchController::nextVideo, "/api/watch/{1}/next", Post);
|
||||
|
||||
// Settings
|
||||
ADD_METHOD_TO(WatchController::updateSettings, "/api/watch/{1}/settings", Put);
|
||||
|
||||
// Duration update (called by chat-service when player reports duration)
|
||||
ADD_METHOD_TO(WatchController::updateDuration, "/api/watch/{1}/duration", Post);
|
||||
METHOD_LIST_END
|
||||
|
||||
// List watch rooms
|
||||
void getWatchRooms(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||
|
||||
// Playlist management
|
||||
void getPlaylist(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void addToPlaylist(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void removeFromPlaylist(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &itemId);
|
||||
|
||||
void reorderPlaylist(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void toggleLock(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId,
|
||||
const std::string &itemId);
|
||||
|
||||
// Playback control
|
||||
void getRoomState(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void playVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void pauseVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void seekVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void skipVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
void nextVideo(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
// Settings
|
||||
void updateSettings(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
// Duration update (called by chat-service when player reports duration)
|
||||
void updateDuration(const HttpRequestPtr &req,
|
||||
std::function<void(const HttpResponsePtr &)> &&callback,
|
||||
const std::string &realmId);
|
||||
|
||||
private:
|
||||
bool canControlPlaylist(const UserInfo& user, int64_t realmId, int64_t ownerId,
|
||||
const std::string& mode, const std::string& whitelist);
|
||||
bool canControlPlayback(const UserInfo& user, int64_t ownerId);
|
||||
std::string extractYouTubeVideoId(const std::string& url);
|
||||
|
||||
// Helper to add video to playlist (reduces callback nesting)
|
||||
void addVideoToPlaylist(
|
||||
std::function<void(const HttpResponsePtr &)> callback,
|
||||
const DbClientPtr& dbClient,
|
||||
int64_t realmId,
|
||||
const UserInfo& user,
|
||||
const std::string& videoId,
|
||||
const std::string& title,
|
||||
int durationSeconds,
|
||||
const std::string& thumbnailUrl,
|
||||
const std::string& username,
|
||||
const std::string& fingerprint,
|
||||
int64_t ownerId);
|
||||
};
|
||||
|
|
@ -7,6 +7,8 @@
|
|||
#include "services/DatabaseService.h"
|
||||
#include "services/StatsService.h"
|
||||
#include "services/AuthService.h"
|
||||
#include "services/CensorService.h"
|
||||
#include "services/TreasuryService.h"
|
||||
#include <exception>
|
||||
#include <csignal>
|
||||
#include <sys/stat.h>
|
||||
|
|
@ -37,7 +39,7 @@ int main() {
|
|||
LOG_INFO << "Initializing StatsService...";
|
||||
StatsService::getInstance().initialize();
|
||||
|
||||
// Register a pre-routing advice to handle CORS
|
||||
// Register a pre-routing advice to handle CORS and CSRF protection
|
||||
app().registerPreRoutingAdvice([](const HttpRequestPtr &req,
|
||||
AdviceCallback &&acb,
|
||||
AdviceChainCallback &&accb) {
|
||||
|
|
@ -60,6 +62,35 @@ int main() {
|
|||
acb(resp);
|
||||
return;
|
||||
}
|
||||
|
||||
// SECURITY FIX #18: CSRF protection for state-changing requests
|
||||
// Require Origin or Referer header for POST/PUT/DELETE requests
|
||||
if (req->getMethod() == Post || req->getMethod() == Put || req->getMethod() == Delete) {
|
||||
std::string origin = req->getHeader("Origin");
|
||||
std::string referer = req->getHeader("Referer");
|
||||
std::string path = req->getPath();
|
||||
|
||||
// Skip CSRF check for API endpoints that use Bearer token auth
|
||||
// (Bearer tokens are not automatically sent by browsers, so CSRF is not a concern)
|
||||
std::string authHeader = req->getHeader("Authorization");
|
||||
bool hasBearerToken = !authHeader.empty() && authHeader.substr(0, 7) == "Bearer ";
|
||||
|
||||
// Skip CSRF check for internal endpoints (server-to-server calls)
|
||||
bool isInternalEndpoint = path.find("/api/webhook/") == 0 ||
|
||||
path.find("/api/internal/") == 0;
|
||||
|
||||
// If not using Bearer auth and not an internal endpoint, require Origin or Referer header
|
||||
if (!hasBearerToken && !isInternalEndpoint && origin.empty() && referer.empty()) {
|
||||
LOG_WARN << "CSRF protection: Blocked request without Origin/Referer to "
|
||||
<< req->getPath() << " from " << req->getPeerAddr().toIpPort();
|
||||
auto resp = HttpResponse::newHttpResponse();
|
||||
resp->setStatusCode(k403Forbidden);
|
||||
resp->setBody("Missing Origin or Referer header");
|
||||
acb(resp);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
accb();
|
||||
});
|
||||
|
||||
|
|
@ -82,14 +113,38 @@ int main() {
|
|||
app().registerBeginningAdvice([]() {
|
||||
LOG_INFO << "Application started successfully";
|
||||
|
||||
// Clean up stuck audio processing jobs on startup
|
||||
LOG_INFO << "Cleaning up stuck audio processing jobs...";
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE audio_files SET status = 'failed' "
|
||||
"WHERE status = 'processing' AND created_at < NOW() - INTERVAL '5 minutes'"
|
||||
>> [](const drogon::orm::Result& r) {
|
||||
if (r.affectedRows() > 0) {
|
||||
LOG_INFO << "Marked " << r.affectedRows() << " stuck audio jobs as failed";
|
||||
}
|
||||
}
|
||||
>> [](const drogon::orm::DrogonDbException& e) {
|
||||
LOG_WARN << "Failed to clean up stuck audio jobs: " << e.base().what();
|
||||
};
|
||||
|
||||
// Start the stats polling timer
|
||||
LOG_INFO << "Starting stats polling...";
|
||||
StatsService::getInstance().startPolling();
|
||||
|
||||
// Load censored words from database
|
||||
LOG_INFO << "Loading censored words...";
|
||||
CensorService::getInstance().loadCensoredWords();
|
||||
|
||||
// Start treasury scheduler (hourly check for growth/distribution)
|
||||
LOG_INFO << "Starting treasury scheduler...";
|
||||
TreasuryService::getInstance().initialize();
|
||||
TreasuryService::getInstance().startScheduler();
|
||||
});
|
||||
|
||||
app().setTermSignalHandler([]() {
|
||||
LOG_INFO << "Received termination signal, shutting down...";
|
||||
StatsService::getInstance().shutdown();
|
||||
TreasuryService::getInstance().shutdown();
|
||||
app().quit();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
|
||||
struct Realm {
|
||||
int64_t id;
|
||||
int64_t userId;
|
||||
std::string name;
|
||||
std::string streamKey;
|
||||
bool isActive;
|
||||
bool isLive;
|
||||
int64_t viewerCount;
|
||||
std::chrono::system_clock::time_point createdAt;
|
||||
std::chrono::system_clock::time_point updatedAt;
|
||||
};
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <chrono>
|
||||
|
||||
struct StreamKey {
|
||||
int64_t id;
|
||||
int64_t user_id;
|
||||
std::string key;
|
||||
bool is_active;
|
||||
std::chrono::system_clock::time_point created_at;
|
||||
std::chrono::system_clock::time_point updated_at;
|
||||
};
|
||||
|
|
@ -9,6 +9,8 @@
|
|||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <cstdlib>
|
||||
#include <gpgme.h>
|
||||
#include <filesystem>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
|
@ -32,150 +34,137 @@ bool AuthService::validatePassword(const std::string& password, std::string& err
|
|||
return true;
|
||||
}
|
||||
|
||||
// Helper function to execute GPG commands
|
||||
std::string executeGpgCommand(const std::string& command) {
|
||||
std::array<char, 128> buffer;
|
||||
std::string result;
|
||||
// GPGME-based PGP signature verification (no shell commands for security)
|
||||
namespace {
|
||||
// RAII wrapper for gpgme_data_t (named to avoid conflict with deprecated GpgmeData typedef)
|
||||
class GpgmeDataWrapper {
|
||||
public:
|
||||
GpgmeDataWrapper() : data_(nullptr) {}
|
||||
~GpgmeDataWrapper() { if (data_) gpgme_data_release(data_); }
|
||||
gpgme_data_t* ptr() { return &data_; }
|
||||
gpgme_data_t get() { return data_; }
|
||||
bool valid() const { return data_ != nullptr; }
|
||||
private:
|
||||
gpgme_data_t data_;
|
||||
};
|
||||
|
||||
FILE* pipe = popen(command.c_str(), "r");
|
||||
if (!pipe) {
|
||||
LOG_ERROR << "Failed to execute GPG command: " << command;
|
||||
return "";
|
||||
}
|
||||
|
||||
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
|
||||
result += buffer.data();
|
||||
}
|
||||
|
||||
int exitCode = pclose(pipe);
|
||||
|
||||
// Exit code is returned as status << 8, so we need to extract the actual exit code
|
||||
int actualExitCode = WEXITSTATUS(exitCode);
|
||||
|
||||
if (actualExitCode != 0) {
|
||||
LOG_ERROR << "GPG command failed with exit code: " << actualExitCode
|
||||
<< " for command: " << command
|
||||
<< " output: " << result;
|
||||
// Don't return empty string immediately - sometimes GPG returns non-zero but still works
|
||||
}
|
||||
|
||||
return result;
|
||||
// RAII wrapper for gpgme_ctx_t
|
||||
class GpgmeContextWrapper {
|
||||
public:
|
||||
GpgmeContextWrapper() : ctx_(nullptr) {}
|
||||
~GpgmeContextWrapper() { if (ctx_) gpgme_release(ctx_); }
|
||||
gpgme_ctx_t* ptr() { return &ctx_; }
|
||||
gpgme_ctx_t get() { return ctx_; }
|
||||
bool valid() const { return ctx_ != nullptr; }
|
||||
private:
|
||||
gpgme_ctx_t ctx_;
|
||||
};
|
||||
}
|
||||
|
||||
// Server-side PGP signature verification
|
||||
bool verifyPgpSignature(const std::string& message, const std::string& signature, const std::string& publicKey) {
|
||||
try {
|
||||
// Create temporary directory for GPG operations
|
||||
std::string tmpDir = "/tmp/pgp_verify_" + drogon::utils::genRandomString(8);
|
||||
std::string mkdirCmd = "mkdir -p " + tmpDir;
|
||||
if (system(mkdirCmd.c_str()) != 0) {
|
||||
LOG_ERROR << "Failed to create temporary directory: " << tmpDir;
|
||||
// Initialize GPGME
|
||||
gpgme_check_version(nullptr);
|
||||
|
||||
// Create temporary directory for isolated keyring using filesystem
|
||||
std::string tmpDir = "/tmp/pgp_verify_" + drogon::utils::genRandomString(16);
|
||||
std::filesystem::create_directories(tmpDir);
|
||||
std::filesystem::permissions(tmpDir, std::filesystem::perms::owner_all);
|
||||
|
||||
// Set GNUPGHOME environment for this context
|
||||
std::string gnupgHome = tmpDir;
|
||||
|
||||
// Create GPGME context
|
||||
GpgmeContextWrapper ctx;
|
||||
gpgme_error_t err = gpgme_new(ctx.ptr());
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create GPGME context: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create GPG home directory
|
||||
std::string keyringDir = tmpDir + "/gnupg";
|
||||
std::string mkdirGpgCmd = "mkdir -p " + keyringDir + " && chmod 700 " + keyringDir;
|
||||
if (system(mkdirGpgCmd.c_str()) != 0) {
|
||||
LOG_ERROR << "Failed to create GPG home directory: " << keyringDir;
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
// Set the engine info to use our temporary directory
|
||||
err = gpgme_ctx_set_engine_info(ctx.get(), GPGME_PROTOCOL_OpenPGP, nullptr, gnupgHome.c_str());
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to set GPGME engine info: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Write files
|
||||
std::string messageFile = tmpDir + "/message.txt";
|
||||
std::string sigFile = tmpDir + "/signature.asc";
|
||||
std::string pubkeyFile = tmpDir + "/pubkey.asc";
|
||||
// Set protocol
|
||||
gpgme_set_protocol(ctx.get(), GPGME_PROTOCOL_OpenPGP);
|
||||
|
||||
// Write message file
|
||||
std::ofstream msgOut(messageFile);
|
||||
if (!msgOut) {
|
||||
LOG_ERROR << "Failed to create message file: " << messageFile;
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
// Import the public key
|
||||
GpgmeDataWrapper keyData;
|
||||
err = gpgme_data_new_from_mem(keyData.ptr(), publicKey.c_str(), publicKey.size(), 1);
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create key data: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
msgOut << message;
|
||||
msgOut.close();
|
||||
|
||||
// Write signature file
|
||||
std::ofstream sigOut(sigFile);
|
||||
if (!sigOut) {
|
||||
LOG_ERROR << "Failed to create signature file: " << sigFile;
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
err = gpgme_op_import(ctx.get(), keyData.get());
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to import public key: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
sigOut << signature;
|
||||
sigOut.close();
|
||||
|
||||
// Write public key file
|
||||
std::ofstream keyOut(pubkeyFile);
|
||||
if (!keyOut) {
|
||||
LOG_ERROR << "Failed to create public key file: " << pubkeyFile;
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
gpgme_import_result_t importResult = gpgme_op_import_result(ctx.get());
|
||||
if (!importResult || (importResult->imported == 0 && importResult->unchanged == 0)) {
|
||||
LOG_ERROR << "No keys were imported";
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
keyOut << publicKey;
|
||||
keyOut.close();
|
||||
|
||||
// Initialize GPG (create trustdb if needed)
|
||||
std::string initCmd = "GNUPGHOME=" + keyringDir + " gpg --batch --yes --list-keys 2>&1";
|
||||
executeGpgCommand(initCmd); // This will create the trustdb if it doesn't exist
|
||||
LOG_DEBUG << "GPGME imported " << importResult->imported << " keys, "
|
||||
<< importResult->unchanged << " unchanged";
|
||||
|
||||
// Import the public key to the temporary keyring
|
||||
// Use --trust-model always to avoid trust issues
|
||||
std::string importCmd = "GNUPGHOME=" + keyringDir +
|
||||
" gpg --batch --yes --trust-model always --import " + pubkeyFile + " 2>&1";
|
||||
std::string importResult = executeGpgCommand(importCmd);
|
||||
// Create data objects for signature and message
|
||||
GpgmeDataWrapper sigData;
|
||||
err = gpgme_data_new_from_mem(sigData.ptr(), signature.c_str(), signature.size(), 1);
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create signature data: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
LOG_DEBUG << "GPG import result: " << importResult;
|
||||
|
||||
// Check if import was successful (be more lenient with the check)
|
||||
bool importSuccess = (importResult.find("imported") != std::string::npos) ||
|
||||
(importResult.find("unchanged") != std::string::npos) ||
|
||||
(importResult.find("processed: 1") != std::string::npos) ||
|
||||
(importResult.find("public key") != std::string::npos);
|
||||
|
||||
if (!importSuccess) {
|
||||
LOG_ERROR << "Failed to import public key. Import output: " << importResult;
|
||||
// Try to get more information about what went wrong
|
||||
std::string debugCmd = "GNUPGHOME=" + keyringDir + " gpg --list-keys 2>&1";
|
||||
std::string debugResult = executeGpgCommand(debugCmd);
|
||||
LOG_ERROR << "GPG keyring state: " << debugResult;
|
||||
|
||||
// Cleanup
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
GpgmeDataWrapper msgData;
|
||||
err = gpgme_data_new_from_mem(msgData.ptr(), message.c_str(), message.size(), 1);
|
||||
if (err) {
|
||||
LOG_ERROR << "Failed to create message data: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
// Use --trust-model always to avoid trust issues
|
||||
std::string verifyCmd = "GNUPGHOME=" + keyringDir +
|
||||
" gpg --batch --yes --trust-model always --verify " +
|
||||
sigFile + " " + messageFile + " 2>&1";
|
||||
std::string verifyResult = executeGpgCommand(verifyCmd);
|
||||
|
||||
LOG_DEBUG << "GPG verify result: " << verifyResult;
|
||||
|
||||
// Check if verification succeeded (check both English and potential localized messages)
|
||||
bool verified = (verifyResult.find("Good signature") != std::string::npos) ||
|
||||
(verifyResult.find("gpg: Good signature") != std::string::npos) ||
|
||||
(verifyResult.find("Signature made") != std::string::npos &&
|
||||
verifyResult.find("BAD signature") == std::string::npos);
|
||||
|
||||
if (!verified) {
|
||||
LOG_WARN << "Signature verification failed. Verify output: " << verifyResult;
|
||||
} else {
|
||||
LOG_INFO << "Signature verification successful for challenge";
|
||||
err = gpgme_op_verify(ctx.get(), sigData.get(), msgData.get(), nullptr);
|
||||
if (err) {
|
||||
LOG_WARN << "Signature verification failed: " << gpgme_strerror(err);
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cleanup temporary files
|
||||
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||
system(cleanupCmd.c_str());
|
||||
// Check verification result
|
||||
gpgme_verify_result_t verifyResult = gpgme_op_verify_result(ctx.get());
|
||||
if (!verifyResult || !verifyResult->signatures) {
|
||||
LOG_WARN << "No signatures found in verification result";
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if signature is valid
|
||||
gpgme_signature_t sig = verifyResult->signatures;
|
||||
bool verified = (sig->status == GPG_ERR_NO_ERROR);
|
||||
|
||||
if (verified) {
|
||||
LOG_INFO << "Signature verification successful for challenge";
|
||||
} else {
|
||||
LOG_WARN << "Signature verification failed: " << gpgme_strerror(sig->status);
|
||||
}
|
||||
|
||||
// Cleanup temporary directory
|
||||
std::filesystem::remove_all(tmpDir);
|
||||
|
||||
return verified;
|
||||
|
||||
|
|
@ -314,8 +303,8 @@ void AuthService::registerUser(const std::string& username, const std::string& p
|
|||
return;
|
||||
}
|
||||
|
||||
if (!std::regex_match(username, std::regex("^[a-zA-Z0-9_]+$"))) {
|
||||
callback(false, "Username can only contain letters, numbers, and underscores", 0);
|
||||
if (!std::regex_match(username, std::regex("^[a-zA-Z][a-zA-Z0-9_]*$"))) {
|
||||
callback(false, "Username must start with a letter and contain only letters, numbers, and underscores", 0);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -454,7 +443,7 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "SELECT id, username, password_hash, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
||||
*dbClient << "SELECT id, username, password_hash, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, is_disabled, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color "
|
||||
"FROM users WHERE username = $1 LIMIT 1"
|
||||
<< username
|
||||
>> [password, callback, this](const Result& r) {
|
||||
|
|
@ -464,6 +453,13 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if account is disabled
|
||||
bool isDisabled = r[0]["is_disabled"].isNull() ? false : r[0]["is_disabled"].as<bool>();
|
||||
if (isDisabled) {
|
||||
callback(false, "Account disabled", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if PGP-only is enabled BEFORE password validation
|
||||
bool isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
|
||||
|
|
@ -493,10 +489,19 @@ void AuthService::loginUser(const std::string& username, const std::string& pass
|
|||
user.id = r[0]["id"].as<int64_t>();
|
||||
user.username = r[0]["username"].as<std::string>();
|
||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||
user.isModerator = r[0]["is_moderator"].isNull() ? false : r[0]["is_moderator"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isRestreamer = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
|
||||
user.isBot = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
|
||||
user.isTexter = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].as<bool>();
|
||||
user.isPgpOnly = isPgpOnly;
|
||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
user.bannerUrl = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
|
||||
user.bannerPosition = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
|
||||
user.bannerZoom = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
|
||||
user.bannerPositionX = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
|
||||
user.graffitiUrl = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_url"].as<std::string>();
|
||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||
|
||||
|
|
@ -593,8 +598,8 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_streamer, "
|
||||
"u.is_pgp_only, u.bio, u.avatar_url, u.pgp_only_enabled_at, u.user_color "
|
||||
*dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_moderator, u.is_streamer, u.is_restreamer, u.is_bot, u.is_texter, "
|
||||
"u.is_pgp_only, u.is_disabled, u.bio, u.avatar_url, u.banner_url, u.banner_position, u.banner_zoom, u.banner_position_x, u.graffiti_url, u.pgp_only_enabled_at, u.user_color "
|
||||
"FROM pgp_keys pk JOIN users u ON pk.user_id = u.id "
|
||||
"WHERE u.username = $1 ORDER BY pk.created_at DESC LIMIT 1"
|
||||
<< username
|
||||
|
|
@ -606,6 +611,13 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
return;
|
||||
}
|
||||
|
||||
// Check if account is disabled
|
||||
bool isDisabled = r[0]["is_disabled"].isNull() ? false : r[0]["is_disabled"].as<bool>();
|
||||
if (isDisabled) {
|
||||
callback(false, "Account disabled", UserInfo{});
|
||||
return;
|
||||
}
|
||||
|
||||
std::string publicKey = r[0]["public_key"].as<std::string>();
|
||||
|
||||
// CRITICAL: Server-side signature verification
|
||||
|
|
@ -623,10 +635,19 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
user.id = r[0]["id"].as<int64_t>();
|
||||
user.username = r[0]["username"].as<std::string>();
|
||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||
user.isModerator = r[0]["is_moderator"].isNull() ? false : r[0]["is_moderator"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isRestreamer = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
|
||||
user.isBot = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
|
||||
user.isTexter = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].as<bool>();
|
||||
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
user.bannerUrl = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
|
||||
user.bannerPosition = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
|
||||
user.bannerZoom = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
|
||||
user.bannerPositionX = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
|
||||
user.graffitiUrl = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_url"].as<std::string>();
|
||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||
|
||||
|
|
@ -653,25 +674,70 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
|||
}
|
||||
}
|
||||
|
||||
std::string AuthService::generateToken(const UserInfo& user) {
|
||||
try {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
// SECURITY FIX #5: Validate JWT secret has minimum length and entropy
|
||||
void AuthService::validateAndLoadJwtSecret() {
|
||||
if (!jwtSecret_.empty()) {
|
||||
return; // Already loaded and validated
|
||||
}
|
||||
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
if (!envSecret || strlen(envSecret) == 0) {
|
||||
throw std::runtime_error("JWT_SECRET environment variable is not set");
|
||||
}
|
||||
|
||||
size_t secretLen = strlen(envSecret);
|
||||
|
||||
// Require at least 32 characters (256 bits) for HS256
|
||||
if (secretLen < 32) {
|
||||
throw std::runtime_error("JWT_SECRET must be at least 32 characters (256 bits) for security");
|
||||
}
|
||||
|
||||
// Basic entropy check - ensure not all same character
|
||||
bool hasVariety = false;
|
||||
for (size_t i = 1; i < secretLen && !hasVariety; ++i) {
|
||||
if (envSecret[i] != envSecret[0]) {
|
||||
hasVariety = true;
|
||||
}
|
||||
}
|
||||
if (!hasVariety) {
|
||||
throw std::runtime_error("JWT_SECRET has insufficient entropy - all characters are the same");
|
||||
}
|
||||
|
||||
// Check for common weak secrets
|
||||
std::string secretLower = envSecret;
|
||||
std::transform(secretLower.begin(), secretLower.end(), secretLower.begin(), ::tolower);
|
||||
if (secretLower.find("secret") != std::string::npos ||
|
||||
secretLower.find("password") != std::string::npos ||
|
||||
secretLower.find("123456") != std::string::npos) {
|
||||
LOG_WARN << "JWT_SECRET appears to contain common weak patterns - consider using a stronger secret";
|
||||
}
|
||||
|
||||
jwtSecret_ = std::string(envSecret);
|
||||
LOG_INFO << "JWT secret loaded and validated (" << secretLen << " characters)";
|
||||
}
|
||||
|
||||
std::string AuthService::generateToken(const UserInfo& user) {
|
||||
try {
|
||||
validateAndLoadJwtSecret();
|
||||
|
||||
// SECURITY FIX: Reduced JWT expiry from 24h to 1h to limit token exposure window
|
||||
auto token = jwt::create()
|
||||
.set_issuer("streaming-app")
|
||||
.set_type("JWS")
|
||||
.set_type("JWT")
|
||||
.set_issued_at(std::chrono::system_clock::now())
|
||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(24))
|
||||
.set_expires_at(std::chrono::system_clock::now() + std::chrono::hours(1))
|
||||
.set_payload_claim("user_id", jwt::claim(std::to_string(user.id)))
|
||||
.set_payload_claim("username", jwt::claim(user.username))
|
||||
.set_payload_claim("is_admin", jwt::claim(std::to_string(user.isAdmin)))
|
||||
.set_payload_claim("is_moderator", jwt::claim(std::to_string(user.isModerator)))
|
||||
.set_payload_claim("is_streamer", jwt::claim(std::to_string(user.isStreamer)))
|
||||
.set_payload_claim("is_restreamer", jwt::claim(std::to_string(user.isRestreamer)))
|
||||
.set_payload_claim("is_disabled", jwt::claim(std::to_string(user.isDisabled))) // SECURITY FIX #26
|
||||
.set_payload_claim("token_version", jwt::claim(std::to_string(user.tokenVersion))) // SECURITY FIX #10
|
||||
.set_payload_claim("color_code", jwt::claim(
|
||||
user.colorCode.empty() ? "#561D5E" : user.colorCode
|
||||
)) // Ensure color is never empty
|
||||
.set_payload_claim("avatar_url", jwt::claim(user.avatarUrl))
|
||||
.sign(jwt::algorithm::hs256{jwtSecret_});
|
||||
|
||||
return token;
|
||||
|
|
@ -683,10 +749,7 @@ std::string AuthService::generateToken(const UserInfo& user) {
|
|||
|
||||
bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
||||
try {
|
||||
if (jwtSecret_.empty()) {
|
||||
const char* envSecret = std::getenv("JWT_SECRET");
|
||||
jwtSecret_ = envSecret ? std::string(envSecret) : "your-jwt-secret";
|
||||
}
|
||||
validateAndLoadJwtSecret();
|
||||
|
||||
auto decoded = jwt::decode(token);
|
||||
|
||||
|
|
@ -699,8 +762,20 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
userInfo.id = std::stoll(decoded.get_payload_claim("user_id").as_string());
|
||||
userInfo.username = decoded.get_payload_claim("username").as_string();
|
||||
userInfo.isAdmin = decoded.get_payload_claim("is_admin").as_string() == "1";
|
||||
userInfo.isModerator = decoded.has_payload_claim("is_moderator") ?
|
||||
decoded.get_payload_claim("is_moderator").as_string() == "1" : false;
|
||||
userInfo.isStreamer = decoded.has_payload_claim("is_streamer") ?
|
||||
decoded.get_payload_claim("is_streamer").as_string() == "1" : false;
|
||||
userInfo.isRestreamer = decoded.has_payload_claim("is_restreamer") ?
|
||||
decoded.get_payload_claim("is_restreamer").as_string() == "1" : false;
|
||||
|
||||
// SECURITY FIX #26: Extract disabled status
|
||||
userInfo.isDisabled = decoded.has_payload_claim("is_disabled") ?
|
||||
decoded.get_payload_claim("is_disabled").as_string() == "1" : false;
|
||||
|
||||
// SECURITY FIX #10: Extract token version for revocation check
|
||||
userInfo.tokenVersion = decoded.has_payload_claim("token_version") ?
|
||||
std::stoi(decoded.get_payload_claim("token_version").as_string()) : 1;
|
||||
|
||||
// Get color from token if available, otherwise will need to fetch from DB
|
||||
if (decoded.has_payload_claim("color_code")) {
|
||||
|
|
@ -710,6 +785,12 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
userInfo.colorCode = "#561D5E";
|
||||
}
|
||||
|
||||
// SECURITY FIX #26: Reject tokens from disabled accounts
|
||||
if (userInfo.isDisabled) {
|
||||
LOG_DEBUG << "Token rejected - user account is disabled: " << userInfo.username;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (const std::exception& e) {
|
||||
LOG_DEBUG << "Token validation failed: " << e.what();
|
||||
|
|
@ -717,6 +798,23 @@ bool AuthService::validateToken(const std::string& token, UserInfo& userInfo) {
|
|||
}
|
||||
}
|
||||
|
||||
// Chat service compatibility method
|
||||
std::optional<UserClaims> AuthService::verifyToken(const std::string& token) {
|
||||
UserInfo userInfo;
|
||||
if (validateToken(token, userInfo)) {
|
||||
UserClaims claims;
|
||||
claims.userId = std::to_string(userInfo.id);
|
||||
claims.username = userInfo.username;
|
||||
claims.userColor = userInfo.colorCode;
|
||||
claims.isAdmin = userInfo.isAdmin;
|
||||
claims.isModerator = userInfo.isModerator;
|
||||
claims.isStreamer = userInfo.isStreamer;
|
||||
claims.isRestreamer = userInfo.isRestreamer;
|
||||
return claims;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
||||
const std::string& newPassword,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
|
|
@ -771,9 +869,11 @@ void AuthService::updatePassword(int64_t userId, const std::string& oldPassword,
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "UPDATE users SET password_hash = $1 WHERE id = $2"
|
||||
// SECURITY FIX #10: Increment token_version to invalidate all existing tokens
|
||||
*dbClient << "UPDATE users SET password_hash = $1, token_version = COALESCE(token_version, 0) + 1 WHERE id = $2"
|
||||
<< newHash << userId
|
||||
>> [callback](const Result&) {
|
||||
>> [callback, userId](const Result&) {
|
||||
LOG_INFO << "Password updated and token_version incremented for user " << userId;
|
||||
callback(true, "");
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
|
|
@ -804,7 +904,7 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
|||
return;
|
||||
}
|
||||
|
||||
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
||||
*dbClient << "SELECT id, username, is_admin, is_moderator, is_streamer, is_restreamer, is_bot, is_texter, is_pgp_only, bio, avatar_url, banner_url, banner_position, banner_zoom, banner_position_x, graffiti_url, pgp_only_enabled_at, user_color "
|
||||
"FROM users WHERE id = $1 LIMIT 1"
|
||||
<< userId
|
||||
>> [callback](const Result& r) {
|
||||
|
|
@ -818,10 +918,19 @@ void AuthService::fetchUserInfo(int64_t userId, std::function<void(bool, const U
|
|||
user.id = r[0]["id"].as<int64_t>();
|
||||
user.username = r[0]["username"].as<std::string>();
|
||||
user.isAdmin = r[0]["is_admin"].isNull() ? false : r[0]["is_admin"].as<bool>();
|
||||
user.isModerator = r[0]["is_moderator"].isNull() ? false : r[0]["is_moderator"].as<bool>();
|
||||
user.isStreamer = r[0]["is_streamer"].isNull() ? false : r[0]["is_streamer"].as<bool>();
|
||||
user.isRestreamer = r[0]["is_restreamer"].isNull() ? false : r[0]["is_restreamer"].as<bool>();
|
||||
user.isBot = r[0]["is_bot"].isNull() ? false : r[0]["is_bot"].as<bool>();
|
||||
user.isTexter = r[0]["is_texter"].isNull() ? false : r[0]["is_texter"].as<bool>();
|
||||
user.isPgpOnly = r[0]["is_pgp_only"].isNull() ? false : r[0]["is_pgp_only"].as<bool>();
|
||||
user.bio = r[0]["bio"].isNull() ? "" : r[0]["bio"].as<std::string>();
|
||||
user.avatarUrl = r[0]["avatar_url"].isNull() ? "" : r[0]["avatar_url"].as<std::string>();
|
||||
user.bannerUrl = r[0]["banner_url"].isNull() ? "" : r[0]["banner_url"].as<std::string>();
|
||||
user.bannerPosition = r[0]["banner_position"].isNull() ? 50 : r[0]["banner_position"].as<int>();
|
||||
user.bannerZoom = r[0]["banner_zoom"].isNull() ? 100 : r[0]["banner_zoom"].as<int>();
|
||||
user.bannerPositionX = r[0]["banner_position_x"].isNull() ? 50 : r[0]["banner_position_x"].as<int>();
|
||||
user.graffitiUrl = r[0]["graffiti_url"].isNull() ? "" : r[0]["graffiti_url"].as<std::string>();
|
||||
user.pgpOnlyEnabledAt = r[0]["pgp_only_enabled_at"].isNull() ? "" : r[0]["pgp_only_enabled_at"].as<std::string>();
|
||||
user.colorCode = r[0]["user_color"].isNull() ? "#561D5E" : r[0]["user_color"].as<std::string>();
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#include <string>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <jwt-cpp/jwt.h>
|
||||
#include <bcrypt/BCrypt.hpp>
|
||||
|
||||
|
|
@ -9,12 +10,38 @@ struct UserInfo {
|
|||
int64_t id = 0;
|
||||
std::string username;
|
||||
bool isAdmin = false;
|
||||
bool isModerator = false; // Site-wide moderator role
|
||||
bool isStreamer = false;
|
||||
bool isRestreamer = false;
|
||||
bool isBot = false;
|
||||
bool isTexter = false;
|
||||
bool isPgpOnly = false;
|
||||
bool isDisabled = false; // SECURITY FIX #26: Track disabled status
|
||||
std::string bio;
|
||||
std::string avatarUrl;
|
||||
std::string bannerUrl;
|
||||
int bannerPosition = 50; // Y position percentage (0-100) for object-position
|
||||
int bannerZoom = 100; // Zoom percentage (100-200)
|
||||
int bannerPositionX = 50; // X position percentage (0-100) for object-position
|
||||
std::string graffitiUrl;
|
||||
std::string pgpOnlyEnabledAt;
|
||||
std::string colorCode;
|
||||
double ubercoinBalance = 0.0; // Übercoin balance (3 decimal places)
|
||||
std::string createdAt; // Account creation date (for burn rate calculation)
|
||||
int tokenVersion = 1; // SECURITY FIX #10: Token version for revocation
|
||||
};
|
||||
|
||||
// Chat service compatibility struct
|
||||
struct UserClaims {
|
||||
std::string userId;
|
||||
std::string username;
|
||||
std::string userColor;
|
||||
bool isAdmin;
|
||||
bool isModerator; // Site-wide moderator role
|
||||
bool isStreamer;
|
||||
bool isRestreamer;
|
||||
|
||||
UserClaims() : isAdmin(false), isModerator(false), isStreamer(false), isRestreamer(false) {}
|
||||
};
|
||||
|
||||
class AuthService {
|
||||
|
|
@ -41,6 +68,9 @@ public:
|
|||
std::string generateToken(const UserInfo& user);
|
||||
bool validateToken(const std::string& token, UserInfo& userInfo);
|
||||
|
||||
// Chat service compatibility method
|
||||
std::optional<UserClaims> verifyToken(const std::string& token);
|
||||
|
||||
// New method to fetch complete user info including color
|
||||
void fetchUserInfo(int64_t userId, std::function<void(bool, const UserInfo&)> callback);
|
||||
|
||||
|
|
@ -58,4 +88,5 @@ private:
|
|||
std::string jwtSecret_;
|
||||
|
||||
bool validatePassword(const std::string& password, std::string& error);
|
||||
void validateAndLoadJwtSecret(); // SECURITY FIX #5
|
||||
};
|
||||
164
backend/src/services/CensorService.cpp
Normal file
164
backend/src/services/CensorService.cpp
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
#include "CensorService.h"
|
||||
#include <drogon/drogon.h>
|
||||
#include <sstream>
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
||||
// 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;
|
||||
|
||||
void CensorService::loadCensoredWords(std::function<void(bool)> callback) {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
*dbClient << "SELECT setting_value FROM site_settings WHERE setting_key = 'censored_words'"
|
||||
>> [this, callback](const Result& r) {
|
||||
// Build new patterns in temporary variables
|
||||
std::vector<std::string> newWords;
|
||||
std::optional<std::regex> newPattern;
|
||||
|
||||
if (!r.empty() && !r[0]["setting_value"].isNull()) {
|
||||
std::string wordsStr = r[0]["setting_value"].as<std::string>();
|
||||
|
||||
// Parse comma-separated words
|
||||
std::stringstream ss(wordsStr);
|
||||
std::string word;
|
||||
while (std::getline(ss, word, ',') && newWords.size() < MAX_WORD_COUNT) {
|
||||
// Trim whitespace
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build combined pattern
|
||||
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 << "Loaded " << censoredWords_.size() << " censored words";
|
||||
|
||||
if (callback) {
|
||||
callback(true);
|
||||
}
|
||||
}
|
||||
>> [callback](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to load censored words: " << e.base().what();
|
||||
if (callback) {
|
||||
callback(false);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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) const {
|
||||
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 asterisks of same length
|
||||
result += std::string(match.length(), '*');
|
||||
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) const {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::string> CensorService::getCensoredWords() const {
|
||||
std::shared_lock<std::shared_mutex> lock(mutex_);
|
||||
return censoredWords_;
|
||||
}
|
||||
37
backend/src/services/CensorService.h
Normal file
37
backend/src/services/CensorService.h
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
#pragma once
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include <shared_mutex>
|
||||
#include <regex>
|
||||
#include <functional>
|
||||
#include <optional>
|
||||
|
||||
class CensorService {
|
||||
public:
|
||||
static CensorService& getInstance() {
|
||||
static CensorService instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Load censored words from database
|
||||
void loadCensoredWords(std::function<void(bool)> callback = nullptr);
|
||||
|
||||
// Censor text by replacing censored words with asterisks (case-insensitive)
|
||||
std::string censor(const std::string& text) const;
|
||||
|
||||
// Check if text contains any censored words
|
||||
bool containsCensoredWords(const std::string& text) const;
|
||||
|
||||
// Get the list of censored words (for debugging/admin)
|
||||
std::vector<std::string> getCensoredWords() const;
|
||||
|
||||
private:
|
||||
CensorService() = default;
|
||||
|
||||
mutable std::shared_mutex mutex_;
|
||||
std::vector<std::string> censoredWords_;
|
||||
std::optional<std::regex> combinedPattern_; // Single combined pattern for efficiency
|
||||
|
||||
// Build a single combined regex pattern from all words
|
||||
std::optional<std::regex> buildCombinedPattern(const std::vector<std::string>& words);
|
||||
};
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
|
||||
namespace middleware {
|
||||
|
||||
class CorsMiddleware {
|
||||
public:
|
||||
struct Config {
|
||||
std::vector<std::string> allowOrigins = {"*"};
|
||||
std::vector<std::string> allowMethods = {"GET", "POST", "PUT", "DELETE", "OPTIONS"};
|
||||
std::vector<std::string> allowHeaders = {"Content-Type", "Authorization"};
|
||||
bool allowCredentials = true;
|
||||
int maxAge = 86400;
|
||||
};
|
||||
|
||||
static void enable(const Config& config = {}) {
|
||||
using namespace drogon;
|
||||
|
||||
auto cfg = std::make_shared<Config>(config);
|
||||
|
||||
auto addHeaders = [cfg](const HttpResponsePtr &resp, const HttpRequestPtr &req) {
|
||||
std::string origin = req->getHeader("Origin");
|
||||
|
||||
// Check if origin is allowed
|
||||
bool allowed = false;
|
||||
for (const auto& allowedOrigin : cfg->allowOrigins) {
|
||||
if (allowedOrigin == "*" || allowedOrigin == origin) {
|
||||
allowed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
resp->addHeader("Access-Control-Allow-Origin", origin.empty() ? "*" : origin);
|
||||
resp->addHeader("Access-Control-Allow-Methods", joinStrings(cfg->allowMethods, ", "));
|
||||
resp->addHeader("Access-Control-Allow-Headers", joinStrings(cfg->allowHeaders, ", "));
|
||||
if (cfg->allowCredentials) {
|
||||
resp->addHeader("Access-Control-Allow-Credentials", "true");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle preflight requests
|
||||
app().registerPreRoutingAdvice([cfg, addHeaders](const HttpRequestPtr &req,
|
||||
AdviceCallback &&acb,
|
||||
AdviceChainCallback &&accb) {
|
||||
if (req->getMethod() == Options) {
|
||||
auto resp = HttpResponse::newHttpResponse();
|
||||
resp->setStatusCode(k204NoContent);
|
||||
addHeaders(resp, req);
|
||||
resp->addHeader("Access-Control-Max-Age", std::to_string(cfg->maxAge));
|
||||
acb(resp);
|
||||
return;
|
||||
}
|
||||
accb();
|
||||
});
|
||||
|
||||
// Add CORS headers to all responses
|
||||
app().registerPostHandlingAdvice([addHeaders](const HttpRequestPtr &req,
|
||||
const HttpResponsePtr &resp) {
|
||||
addHeaders(resp, req);
|
||||
});
|
||||
}
|
||||
|
||||
private:
|
||||
static std::string joinStrings(const std::vector<std::string>& strings, const std::string& delimiter) {
|
||||
std::string result;
|
||||
for (size_t i = 0; i < strings.size(); ++i) {
|
||||
result += strings[i];
|
||||
if (i < strings.size() - 1) result += delimiter;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace middleware
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
#include "DatabaseService.h"
|
||||
#include "../services/RedisHelper.h"
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <random>
|
||||
#include <sstream>
|
||||
#include <iomanip>
|
||||
#include <openssl/rand.h>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
|
@ -20,14 +20,17 @@ namespace {
|
|||
}
|
||||
}
|
||||
|
||||
// SECURITY FIX: Use cryptographically secure random bytes instead of mt19937
|
||||
std::string generateStreamKey() {
|
||||
std::random_device rd;
|
||||
std::mt19937 gen(rd());
|
||||
std::uniform_int_distribution<> dis(0, 255);
|
||||
unsigned char bytes[32]; // 32 bytes = 64 hex characters
|
||||
if (RAND_bytes(bytes, sizeof(bytes)) != 1) {
|
||||
LOG_ERROR << "Failed to generate cryptographically secure random bytes";
|
||||
throw std::runtime_error("Failed to generate secure stream key");
|
||||
}
|
||||
|
||||
std::stringstream ss;
|
||||
for (int i = 0; i < 16; ++i) {
|
||||
ss << std::hex << std::setw(2) << std::setfill('0') << dis(gen);
|
||||
for (size_t i = 0; i < sizeof(bytes); ++i) {
|
||||
ss << std::hex << std::setw(2) << std::setfill('0') << static_cast<int>(bytes[i]);
|
||||
}
|
||||
return ss.str();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ void RedisHelper::ensureConnected() {
|
|||
sw::redis::ConnectionOptions opts;
|
||||
opts.host = getRedisHost();
|
||||
opts.port = getRedisPort();
|
||||
opts.db = getRedisDb();
|
||||
|
||||
const char* envPass = std::getenv("REDIS_PASS");
|
||||
if (envPass && strlen(envPass) > 0) {
|
||||
|
|
@ -37,7 +38,7 @@ void RedisHelper::ensureConnected() {
|
|||
opts.socket_timeout = std::chrono::milliseconds(1000);
|
||||
opts.connect_timeout = std::chrono::milliseconds(1000);
|
||||
|
||||
LOG_INFO << "Connecting to Redis at " << opts.host << ":" << opts.port;
|
||||
LOG_INFO << "Connecting to Redis at " << opts.host << ":" << opts.port << " db=" << opts.db;
|
||||
|
||||
_redis = std::make_unique<sw::redis::Redis>(opts);
|
||||
_redis->ping();
|
||||
|
|
@ -82,6 +83,24 @@ int RedisHelper::getRedisPort() const {
|
|||
return 6379;
|
||||
}
|
||||
|
||||
int RedisHelper::getRedisDb() const {
|
||||
const char* envDb = std::getenv("REDIS_DB");
|
||||
if (envDb) {
|
||||
try {
|
||||
return std::stoi(envDb);
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
try {
|
||||
const auto& config = drogon::app().getCustomConfig();
|
||||
if (config.isMember("redis") && config["redis"].isMember("db")) {
|
||||
return config["redis"]["db"].asInt();
|
||||
}
|
||||
} catch (...) {}
|
||||
|
||||
return 0; // Default to db 0
|
||||
}
|
||||
|
||||
void RedisHelper::executeInThreadPool(std::function<void()> task) {
|
||||
auto loop = drogon::app().getLoop();
|
||||
if (!loop) {
|
||||
|
|
@ -216,6 +235,7 @@ std::unique_ptr<sw::redis::Redis> RedisHelper::getConnection() {
|
|||
sw::redis::ConnectionOptions opts;
|
||||
opts.host = getRedisHost();
|
||||
opts.port = getRedisPort();
|
||||
opts.db = getRedisDb();
|
||||
|
||||
const char* envPass = std::getenv("REDIS_PASS");
|
||||
if (envPass && strlen(envPass) > 0) {
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ private:
|
|||
void executeInThreadPool(std::function<void()> task);
|
||||
std::string getRedisHost() const;
|
||||
int getRedisPort() const;
|
||||
int getRedisDb() const;
|
||||
|
||||
std::unique_ptr<sw::redis::Redis> _redis;
|
||||
bool _initialized;
|
||||
|
|
|
|||
397
backend/src/services/RestreamService.cpp
Normal file
397
backend/src/services/RestreamService.cpp
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
#include "RestreamService.h"
|
||||
#include <drogon/drogon.h>
|
||||
#include <drogon/orm/DbClient.h>
|
||||
#include <memory>
|
||||
|
||||
using namespace drogon;
|
||||
using namespace drogon::orm;
|
||||
|
||||
// execCurl removed - using Drogon HttpClient instead for security
|
||||
|
||||
std::string RestreamService::getBaseUrl() {
|
||||
const char* envUrl = std::getenv("OME_API_URL");
|
||||
if (envUrl) {
|
||||
return std::string(envUrl);
|
||||
}
|
||||
return "http://ovenmediaengine:8081";
|
||||
}
|
||||
|
||||
std::string RestreamService::getApiToken() {
|
||||
const char* envToken = std::getenv("OME_API_TOKEN");
|
||||
if (!envToken || strlen(envToken) == 0) {
|
||||
throw std::runtime_error("OME_API_TOKEN environment variable is not set");
|
||||
}
|
||||
return std::string(envToken);
|
||||
}
|
||||
|
||||
HttpClientPtr RestreamService::getClient() {
|
||||
return HttpClient::newHttpClient(getBaseUrl());
|
||||
}
|
||||
|
||||
HttpRequestPtr RestreamService::createRequest(HttpMethod method, const std::string& path) {
|
||||
auto request = HttpRequest::newHttpRequest();
|
||||
request->setMethod(method);
|
||||
request->setPath(path);
|
||||
|
||||
const auto token = getApiToken();
|
||||
const auto b64 = drogon::utils::base64Encode(token);
|
||||
request->addHeader("Authorization", std::string("Basic ") + b64);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
HttpRequestPtr RestreamService::createJsonRequest(HttpMethod method, const std::string& path,
|
||||
const Json::Value& body) {
|
||||
auto request = HttpRequest::newHttpJsonRequest(body);
|
||||
request->setMethod(method);
|
||||
request->setPath(path);
|
||||
|
||||
const auto token = getApiToken();
|
||||
const auto b64 = drogon::utils::base64Encode(token);
|
||||
request->addHeader("Authorization", std::string("Basic ") + b64);
|
||||
|
||||
return request;
|
||||
}
|
||||
|
||||
std::string RestreamService::generatePushId(const std::string& streamKey, int64_t destinationId) {
|
||||
return "restream_" + streamKey + "_" + std::to_string(destinationId);
|
||||
}
|
||||
|
||||
void RestreamService::updateDestinationStatus(int64_t destinationId, bool isConnected, const std::string& error) {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
if (isConnected) {
|
||||
*dbClient << "UPDATE restream_destinations SET is_connected = true, last_error = NULL, "
|
||||
"last_connected_at = CURRENT_TIMESTAMP WHERE id = $1"
|
||||
<< destinationId
|
||||
>> [destinationId](const Result&) {
|
||||
LOG_INFO << "Restream destination " << destinationId << " connected";
|
||||
}
|
||||
>> [destinationId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update restream destination " << destinationId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
} else {
|
||||
*dbClient << "UPDATE restream_destinations SET is_connected = false, last_error = $1 WHERE id = $2"
|
||||
<< error << destinationId
|
||||
>> [destinationId](const Result&) {
|
||||
LOG_INFO << "Restream destination " << destinationId << " disconnected";
|
||||
}
|
||||
>> [destinationId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update restream destination " << destinationId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
void RestreamService::startPush(const std::string& sourceStreamKey, const RestreamDestination& dest,
|
||||
std::function<void(bool, const std::string&)> callback) {
|
||||
// Build the full destination URL with stream key
|
||||
std::string fullUrl = dest.rtmpUrl;
|
||||
if (!fullUrl.empty() && fullUrl.back() != '/') {
|
||||
fullUrl += '/';
|
||||
}
|
||||
fullUrl += dest.streamKey;
|
||||
|
||||
std::string pushId = generatePushId(sourceStreamKey, dest.id);
|
||||
auto destId = dest.id;
|
||||
|
||||
LOG_INFO << "Starting RTMP push for stream " << sourceStreamKey
|
||||
<< " to " << dest.name << " (" << dest.rtmpUrl << ")";
|
||||
|
||||
// Build JSON body
|
||||
Json::Value body;
|
||||
body["id"] = pushId;
|
||||
body["stream"]["name"] = sourceStreamKey;
|
||||
body["protocol"] = "rtmp";
|
||||
body["url"] = fullUrl;
|
||||
|
||||
// Use Drogon HttpClient instead of curl for security
|
||||
auto request = createJsonRequest(drogon::Post, "/v1/vhosts/default/apps/app:startPush", body);
|
||||
|
||||
LOG_INFO << "Sending HTTP request for push start";
|
||||
|
||||
getClient()->sendRequest(request,
|
||||
[this, callback, pushId, sourceStreamKey, destId](ReqResult result, const HttpResponsePtr& response) {
|
||||
if (result != ReqResult::Ok || !response) {
|
||||
std::string error = "Failed to connect to OME API";
|
||||
updateDestinationStatus(destId, false, error);
|
||||
callback(false, error);
|
||||
LOG_ERROR << "Failed to start RTMP push: " << error;
|
||||
return;
|
||||
}
|
||||
|
||||
auto json = response->getJsonObject();
|
||||
if (json) {
|
||||
int statusCode = (*json).get("statusCode", 0).asInt();
|
||||
std::string message = (*json).get("message", "").asString();
|
||||
|
||||
// 200 = success, 400 with "Duplicate ID" = already running (treat as success)
|
||||
bool isSuccess = (statusCode == 200);
|
||||
bool isDuplicate = (statusCode == 400 && message.find("Duplicate") != std::string::npos);
|
||||
|
||||
if (isSuccess || isDuplicate) {
|
||||
// Track the active push
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
activePushes_[sourceStreamKey][destId] = pushId;
|
||||
}
|
||||
updateDestinationStatus(destId, true, "");
|
||||
callback(true, "");
|
||||
if (isDuplicate) {
|
||||
LOG_INFO << "RTMP push already active (duplicate ID): " << pushId;
|
||||
} else {
|
||||
LOG_INFO << "RTMP push started successfully: " << pushId;
|
||||
}
|
||||
return;
|
||||
} else {
|
||||
std::string error = (*json).get("message", "Unknown error").asString();
|
||||
updateDestinationStatus(destId, false, error);
|
||||
callback(false, error);
|
||||
LOG_ERROR << "Failed to start RTMP push: " << error;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
std::string error = "Invalid response from OME API";
|
||||
updateDestinationStatus(destId, false, error);
|
||||
callback(false, error);
|
||||
LOG_ERROR << "Failed to start RTMP push: " << error;
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamService::stopPush(const std::string& sourceStreamKey, int64_t destinationId,
|
||||
std::function<void(bool)> callback) {
|
||||
std::string pushId;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
auto streamIt = activePushes_.find(sourceStreamKey);
|
||||
if (streamIt != activePushes_.end()) {
|
||||
auto destIt = streamIt->second.find(destinationId);
|
||||
if (destIt != streamIt->second.end()) {
|
||||
pushId = destIt->second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If not tracked in memory, generate the push ID anyway and try to stop it
|
||||
// This handles cases where server restarted but push is still active on OME
|
||||
if (pushId.empty()) {
|
||||
pushId = generatePushId(sourceStreamKey, destinationId);
|
||||
}
|
||||
|
||||
LOG_INFO << "Stopping RTMP push: " << pushId;
|
||||
|
||||
// Build JSON body
|
||||
Json::Value body;
|
||||
body["id"] = pushId;
|
||||
|
||||
// Use Drogon HttpClient instead of curl for security
|
||||
auto request = createJsonRequest(drogon::Post, "/v1/vhosts/default/apps/app:stopPush", body);
|
||||
|
||||
LOG_INFO << "Sending HTTP request for push stop";
|
||||
|
||||
getClient()->sendRequest(request,
|
||||
[this, callback, pushId, sourceStreamKey, destinationId](ReqResult result, const HttpResponsePtr& response) {
|
||||
// Remove from tracking regardless of result
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
auto streamIt = activePushes_.find(sourceStreamKey);
|
||||
if (streamIt != activePushes_.end()) {
|
||||
streamIt->second.erase(destinationId);
|
||||
if (streamIt->second.empty()) {
|
||||
activePushes_.erase(streamIt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateDestinationStatus(destinationId, false, "");
|
||||
|
||||
if (result == ReqResult::Ok && response) {
|
||||
auto json = response->getJsonObject();
|
||||
if (json) {
|
||||
int statusCode = (*json).get("statusCode", 0).asInt();
|
||||
if (statusCode == 200 || statusCode == 404) {
|
||||
callback(true);
|
||||
LOG_INFO << "RTMP push stopped: " << pushId;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Even if API call failed, we've removed from tracking
|
||||
callback(true);
|
||||
LOG_WARN << "RTMP push stop may have failed, but removed from tracking: " << pushId;
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamService::stopAllPushes(const std::string& sourceStreamKey,
|
||||
std::function<void(bool)> callback) {
|
||||
std::vector<int64_t> destinationIds;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
auto streamIt = activePushes_.find(sourceStreamKey);
|
||||
if (streamIt != activePushes_.end()) {
|
||||
for (const auto& [destId, pushId] : streamIt->second) {
|
||||
destinationIds.push_back(destId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (destinationIds.empty()) {
|
||||
callback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stop each push
|
||||
auto remaining = std::make_shared<std::atomic<int>>(destinationIds.size());
|
||||
auto allSuccess = std::make_shared<std::atomic<bool>>(true);
|
||||
|
||||
for (int64_t destId : destinationIds) {
|
||||
stopPush(sourceStreamKey, destId, [remaining, allSuccess, callback](bool success) {
|
||||
if (!success) {
|
||||
allSuccess->store(false);
|
||||
}
|
||||
if (--(*remaining) == 0) {
|
||||
callback(allSuccess->load());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void RestreamService::getPushStatus(const std::string& sourceStreamKey, int64_t destinationId,
|
||||
std::function<void(bool, bool isConnected, const std::string& error)> callback) {
|
||||
std::string pushId;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(pushMutex_);
|
||||
auto streamIt = activePushes_.find(sourceStreamKey);
|
||||
if (streamIt != activePushes_.end()) {
|
||||
auto destIt = streamIt->second.find(destinationId);
|
||||
if (destIt != streamIt->second.end()) {
|
||||
pushId = destIt->second;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (pushId.empty()) {
|
||||
callback(true, false, "Not connected");
|
||||
return;
|
||||
}
|
||||
|
||||
// OME API: GET /v1/vhosts/{vhost}/apps/{app}/push
|
||||
std::string path = "/v1/vhosts/default/apps/app/push";
|
||||
auto request = createRequest(Get, path);
|
||||
|
||||
getClient()->sendRequest(request,
|
||||
[callback, pushId](ReqResult result, const HttpResponsePtr& response) {
|
||||
if (result == ReqResult::Ok && response && response->getStatusCode() == k200OK) {
|
||||
try {
|
||||
auto json = *response->getJsonObject();
|
||||
// Look for our push in the response
|
||||
if (json.isMember("response") && json["response"].isArray()) {
|
||||
for (const auto& push : json["response"]) {
|
||||
if (push["id"].asString() == pushId) {
|
||||
std::string state = push.get("state", "unknown").asString();
|
||||
bool connected = (state == "started" || state == "connected");
|
||||
std::string error = push.get("error", "").asString();
|
||||
callback(true, connected, error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
callback(true, false, "Push not found");
|
||||
} catch (const std::exception& e) {
|
||||
callback(false, false, e.what());
|
||||
}
|
||||
} else {
|
||||
callback(false, false, "Failed to get push status");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void RestreamService::startAllDestinations(const std::string& streamKey, int64_t realmId) {
|
||||
LOG_INFO << "Starting all restream destinations for realm " << realmId;
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id, realm_id, name, rtmp_url, stream_key, enabled "
|
||||
"FROM restream_destinations WHERE realm_id = $1 AND enabled = true"
|
||||
<< realmId
|
||||
>> [this, streamKey](const Result& r) {
|
||||
for (const auto& row : r) {
|
||||
RestreamDestination dest;
|
||||
dest.id = row["id"].as<int64_t>();
|
||||
dest.realmId = row["realm_id"].as<int64_t>();
|
||||
dest.name = row["name"].as<std::string>();
|
||||
dest.rtmpUrl = row["rtmp_url"].as<std::string>();
|
||||
dest.streamKey = row["stream_key"].as<std::string>();
|
||||
dest.enabled = row["enabled"].as<bool>();
|
||||
|
||||
startPush(streamKey, dest, [dest](bool success, const std::string& error) {
|
||||
if (!success) {
|
||||
LOG_ERROR << "Failed to start restream to " << dest.name << ": " << error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
>> [realmId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to fetch restream destinations for realm " << realmId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void RestreamService::stopAllDestinations(const std::string& streamKey, int64_t realmId) {
|
||||
LOG_INFO << "Stopping all restream destinations for realm " << realmId;
|
||||
|
||||
stopAllPushes(streamKey, [realmId](bool success) {
|
||||
if (!success) {
|
||||
LOG_WARN << "Some restream pushes may not have stopped cleanly for realm " << realmId;
|
||||
}
|
||||
});
|
||||
|
||||
// Also update all destinations in DB as disconnected
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE restream_destinations SET is_connected = false WHERE realm_id = $1"
|
||||
<< realmId
|
||||
>> [](const Result&) {}
|
||||
>> [realmId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update restream destinations for realm " << realmId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void RestreamService::attemptReconnections(const std::string& streamKey, int64_t realmId) {
|
||||
// Get all enabled but disconnected destinations and try to reconnect
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT id, realm_id, name, rtmp_url, stream_key, enabled, is_connected "
|
||||
"FROM restream_destinations "
|
||||
"WHERE realm_id = $1 AND enabled = true AND is_connected = false"
|
||||
<< realmId
|
||||
>> [this, streamKey](const Result& r) {
|
||||
for (const auto& row : r) {
|
||||
RestreamDestination dest;
|
||||
dest.id = row["id"].as<int64_t>();
|
||||
dest.realmId = row["realm_id"].as<int64_t>();
|
||||
dest.name = row["name"].as<std::string>();
|
||||
dest.rtmpUrl = row["rtmp_url"].as<std::string>();
|
||||
dest.streamKey = row["stream_key"].as<std::string>();
|
||||
dest.enabled = row["enabled"].as<bool>();
|
||||
|
||||
LOG_INFO << "Attempting to reconnect restream destination: " << dest.name;
|
||||
|
||||
startPush(streamKey, dest, [dest](bool success, const std::string& error) {
|
||||
if (success) {
|
||||
LOG_INFO << "Reconnected restream to " << dest.name;
|
||||
} else {
|
||||
LOG_WARN << "Reconnection failed for " << dest.name << ": " << error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
>> [realmId](const DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to fetch disconnected restream destinations for realm " << realmId
|
||||
<< ": " << e.base().what();
|
||||
};
|
||||
}
|
||||
75
backend/src/services/RestreamService.h
Normal file
75
backend/src/services/RestreamService.h
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
#pragma once
|
||||
#include <drogon/HttpClient.h>
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <mutex>
|
||||
#include <memory>
|
||||
|
||||
struct RestreamDestination {
|
||||
int64_t id;
|
||||
int64_t realmId;
|
||||
std::string name;
|
||||
std::string rtmpUrl;
|
||||
std::string streamKey;
|
||||
bool enabled;
|
||||
bool isConnected;
|
||||
std::string lastError;
|
||||
};
|
||||
|
||||
class RestreamService {
|
||||
public:
|
||||
static RestreamService& getInstance() {
|
||||
static RestreamService instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
// Start pushing stream to a destination
|
||||
void startPush(const std::string& sourceStreamKey, const RestreamDestination& dest,
|
||||
std::function<void(bool, const std::string&)> callback);
|
||||
|
||||
// Stop pushing stream to a destination
|
||||
void stopPush(const std::string& sourceStreamKey, int64_t destinationId,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Stop all pushes for a stream
|
||||
void stopAllPushes(const std::string& sourceStreamKey,
|
||||
std::function<void(bool)> callback);
|
||||
|
||||
// Get push status for a destination
|
||||
void getPushStatus(const std::string& sourceStreamKey, int64_t destinationId,
|
||||
std::function<void(bool, bool isConnected, const std::string& error)> callback);
|
||||
|
||||
// Start all enabled destinations for a realm when stream goes live
|
||||
void startAllDestinations(const std::string& streamKey, int64_t realmId);
|
||||
|
||||
// Stop all destinations for a realm when stream goes offline
|
||||
void stopAllDestinations(const std::string& streamKey, int64_t realmId);
|
||||
|
||||
// Attempt reconnection for failed destinations (called periodically)
|
||||
void attemptReconnections(const std::string& streamKey, int64_t realmId);
|
||||
|
||||
private:
|
||||
RestreamService() = default;
|
||||
~RestreamService() = default;
|
||||
RestreamService(const RestreamService&) = delete;
|
||||
RestreamService& operator=(const RestreamService&) = delete;
|
||||
|
||||
std::string getBaseUrl();
|
||||
std::string getApiToken();
|
||||
drogon::HttpClientPtr getClient();
|
||||
drogon::HttpRequestPtr createRequest(drogon::HttpMethod method, const std::string& path);
|
||||
drogon::HttpRequestPtr createJsonRequest(drogon::HttpMethod method, const std::string& path,
|
||||
const Json::Value& body);
|
||||
|
||||
// Generate a unique push ID for tracking
|
||||
std::string generatePushId(const std::string& streamKey, int64_t destinationId);
|
||||
|
||||
// Update destination status in database
|
||||
void updateDestinationStatus(int64_t destinationId, bool isConnected, const std::string& error);
|
||||
|
||||
// Track active pushes: streamKey -> (destinationId -> pushId)
|
||||
std::unordered_map<std::string, std::unordered_map<int64_t, std::string>> activePushes_;
|
||||
std::mutex pushMutex_;
|
||||
};
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
#include "../controllers/StreamController.h"
|
||||
#include "../services/RedisHelper.h"
|
||||
#include "../services/OmeClient.h"
|
||||
#include "../services/RestreamService.h"
|
||||
#include <drogon/HttpClient.h>
|
||||
#include <drogon/utils/Utilities.h>
|
||||
#include <set>
|
||||
|
|
@ -117,13 +118,19 @@ void StatsService::pollOmeStats() {
|
|||
for (const auto& streamKey : activeStreamKeys) {
|
||||
LOG_INFO << "Processing active stream: " << streamKey;
|
||||
|
||||
// IMMEDIATELY update database to mark as live
|
||||
// IMMEDIATELY update database to mark as live and get realm ID
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "UPDATE realms SET is_live = true, viewer_count = 0, "
|
||||
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1"
|
||||
"updated_at = CURRENT_TIMESTAMP WHERE stream_key = $1 RETURNING id"
|
||||
<< streamKey
|
||||
>> [streamKey](const orm::Result&) {
|
||||
>> [streamKey](const orm::Result& r) {
|
||||
LOG_INFO << "Successfully marked realm as live: " << streamKey;
|
||||
|
||||
// Attempt reconnection for any disconnected restream destinations
|
||||
if (!r.empty()) {
|
||||
int64_t realmId = r[0]["id"].as<int64_t>();
|
||||
RestreamService::getInstance().attemptReconnections(streamKey, realmId);
|
||||
}
|
||||
}
|
||||
>> [streamKey](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update realm live status: " << e.base().what();
|
||||
|
|
@ -167,7 +174,9 @@ void StatsService::updateStreamStats(const std::string& streamKey) {
|
|||
fetchStatsFromOme(streamKey, [this, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
// Only count viewer tokens when stream is actually live
|
||||
// Offline streams should show 0 viewers (tokens may linger for 5 min after disconnect)
|
||||
updatedStats.uniqueViewers = stats.isLive ? getUniqueViewerCount(streamKey) : 0;
|
||||
|
||||
storeStatsInRedis(streamKey, updatedStats);
|
||||
|
||||
|
|
@ -267,31 +276,31 @@ void StatsService::fetchStatsFromOme(const std::string& streamKey,
|
|||
hasInput = true;
|
||||
const auto& input = data["input"];
|
||||
|
||||
// Get bitrate from input tracks
|
||||
// Get bitrate from input tracks (OME returns bytes/sec, convert to bits/sec)
|
||||
if (input.isMember("tracks") && input["tracks"].isArray()) {
|
||||
for (const auto& track : input["tracks"]) {
|
||||
if (track["type"].asString() == "video" && track.isMember("bitrate")) {
|
||||
stats.bitrate = track["bitrate"].asDouble();
|
||||
stats.bitrate = track["bitrate"].asDouble() * 8; // Convert bytes/sec to bits/sec
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative: Check lastThroughputIn
|
||||
// Alternative: Check lastThroughputIn (OME returns bytes/sec, convert to bits/sec)
|
||||
if (!hasInput && data.isMember("lastThroughputIn")) {
|
||||
double throughput = data["lastThroughputIn"].asDouble();
|
||||
if (throughput > 0) {
|
||||
hasInput = true;
|
||||
stats.bitrate = throughput;
|
||||
stats.bitrate = throughput * 8; // Convert bytes/sec to bits/sec
|
||||
}
|
||||
}
|
||||
|
||||
// Alternative: Check avgThroughputIn
|
||||
// Alternative: Check avgThroughputIn (OME returns bytes/sec, convert to bits/sec)
|
||||
if (!hasInput && data.isMember("avgThroughputIn")) {
|
||||
double avgThroughput = data["avgThroughputIn"].asDouble();
|
||||
if (avgThroughput > 0) {
|
||||
hasInput = true;
|
||||
stats.bitrate = avgThroughput;
|
||||
stats.bitrate = avgThroughput * 8; // Convert bytes/sec to bits/sec
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -479,8 +488,8 @@ void StatsService::getStreamStats(const std::string& streamKey,
|
|||
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
// Set uniqueViewers on cache miss
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
// Only count viewer tokens when stream is actually live
|
||||
updatedStats.uniqueViewers = stats.isLive ? getUniqueViewerCount(streamKey) : 0;
|
||||
callback(true, updatedStats);
|
||||
} else {
|
||||
callback(false, stats);
|
||||
|
|
@ -518,14 +527,37 @@ void StatsService::getStreamStats(const std::string& streamKey,
|
|||
std::chrono::seconds(json["last_updated"].asInt64())
|
||||
);
|
||||
|
||||
// Verify is_live from database (source of truth from webhooks)
|
||||
// This prevents stale cache from overriding the webhook-updated DB state
|
||||
auto dbClient = app().getDbClient();
|
||||
*dbClient << "SELECT is_live FROM realms WHERE stream_key = $1"
|
||||
<< streamKey
|
||||
>> [callback, stats](const orm::Result& r) mutable {
|
||||
if (!r.empty()) {
|
||||
bool dbIsLive = r[0]["is_live"].as<bool>();
|
||||
// If database says live but cache says offline, trust database
|
||||
// (webhooks update DB immediately, cache may be stale)
|
||||
if (dbIsLive && !stats.isLive) {
|
||||
LOG_DEBUG << "Overriding stale cache: DB says live, cache says offline";
|
||||
stats.isLive = true;
|
||||
}
|
||||
}
|
||||
callback(true, stats);
|
||||
}
|
||||
>> [callback, stats](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to verify is_live from DB: " << e.base().what();
|
||||
// Fall back to cached value on DB error
|
||||
callback(true, stats);
|
||||
};
|
||||
LOG_DEBUG << "Retrieved cached stats for " << streamKey;
|
||||
return; // Callback handled async
|
||||
} else {
|
||||
// Fallback to fresh fetch if cached data is corrupted
|
||||
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
// Only count viewer tokens when stream is actually live
|
||||
updatedStats.uniqueViewers = stats.isLive ? getUniqueViewerCount(streamKey) : 0;
|
||||
callback(true, updatedStats);
|
||||
} else {
|
||||
callback(false, stats);
|
||||
|
|
@ -538,7 +570,8 @@ void StatsService::getStreamStats(const std::string& streamKey,
|
|||
fetchStatsFromOme(streamKey, [this, callback, streamKey](bool success, const StreamStats& stats) {
|
||||
if (success) {
|
||||
StreamStats updatedStats = stats;
|
||||
updatedStats.uniqueViewers = getUniqueViewerCount(streamKey);
|
||||
// Only count viewer tokens when stream is actually live
|
||||
updatedStats.uniqueViewers = stats.isLive ? getUniqueViewerCount(streamKey) : 0;
|
||||
callback(true, updatedStats);
|
||||
} else {
|
||||
callback(false, stats);
|
||||
|
|
|
|||
|
|
@ -69,5 +69,7 @@ private:
|
|||
|
||||
std::atomic<bool> running_{false};
|
||||
std::optional<trantor::TimerId> timerId_;
|
||||
std::chrono::seconds pollInterval_{2}; // Poll every 2 seconds
|
||||
// Poll every 5 seconds for near-instant stats updates
|
||||
// Real-time updates also come via OME webhooks (see StreamController::handleOmeWebhook)
|
||||
std::chrono::seconds pollInterval_{5};
|
||||
};
|
||||
281
backend/src/services/TreasuryService.cpp
Normal file
281
backend/src/services/TreasuryService.cpp
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
#include "TreasuryService.h"
|
||||
#include <ctime>
|
||||
#include <cmath>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
|
||||
using namespace drogon;
|
||||
|
||||
TreasuryService::~TreasuryService() {
|
||||
shutdown();
|
||||
}
|
||||
|
||||
void TreasuryService::initialize() {
|
||||
LOG_INFO << "Initializing Treasury Service...";
|
||||
running_ = true;
|
||||
}
|
||||
|
||||
void TreasuryService::startScheduler() {
|
||||
if (!running_) {
|
||||
LOG_WARN << "Treasury service not initialized, cannot start scheduler";
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO << "Starting treasury scheduler...";
|
||||
|
||||
if (auto loop = drogon::app().getLoop()) {
|
||||
try {
|
||||
// Do an immediate check on startup (catches up on missed tasks)
|
||||
checkAndRunTasks();
|
||||
|
||||
// Then set up the hourly timer
|
||||
timerId_ = loop->runEvery(
|
||||
checkInterval_.count(),
|
||||
[this]() {
|
||||
if (!running_) return;
|
||||
try {
|
||||
checkAndRunTasks();
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Error in treasury scheduler: " << e.what();
|
||||
}
|
||||
}
|
||||
);
|
||||
LOG_INFO << "Treasury scheduler started with " << checkInterval_.count() << "s interval";
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Failed to create treasury timer: " << e.what();
|
||||
}
|
||||
} else {
|
||||
LOG_ERROR << "Event loop not available for treasury scheduler";
|
||||
}
|
||||
}
|
||||
|
||||
void TreasuryService::shutdown() {
|
||||
LOG_INFO << "Shutting down Treasury Service...";
|
||||
running_ = false;
|
||||
|
||||
if (timerId_.has_value()) {
|
||||
if (auto loop = drogon::app().getLoop()) {
|
||||
loop->invalidateTimer(timerId_.value());
|
||||
}
|
||||
timerId_.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void TreasuryService::checkAndRunTasks() {
|
||||
LOG_INFO << "Treasury scheduler: checking for pending tasks...";
|
||||
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get current time info
|
||||
std::time_t now = std::time(nullptr);
|
||||
std::tm* localTime = std::localtime(&now);
|
||||
int dayOfWeek = localTime->tm_wday; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday
|
||||
|
||||
// Get treasury timestamps
|
||||
*dbClient << "SELECT last_growth_at, last_distribution_at FROM ubercoin_treasury WHERE id = 1"
|
||||
>> [this, dayOfWeek, localTime](const orm::Result& r) {
|
||||
if (r.empty()) {
|
||||
LOG_WARN << "Treasury record not found";
|
||||
return;
|
||||
}
|
||||
|
||||
bool needsGrowth = false;
|
||||
bool needsDistribution = false;
|
||||
|
||||
std::time_t now = std::time(nullptr);
|
||||
std::tm* todayStart = std::localtime(&now);
|
||||
todayStart->tm_hour = 0;
|
||||
todayStart->tm_min = 0;
|
||||
todayStart->tm_sec = 0;
|
||||
std::time_t todayStartTime = std::mktime(todayStart);
|
||||
|
||||
// Check if growth is needed (Mon-Sat, once per day)
|
||||
if (dayOfWeek >= 1 && dayOfWeek <= 6) { // Monday to Saturday
|
||||
if (r[0]["last_growth_at"].isNull()) {
|
||||
needsGrowth = true;
|
||||
} else {
|
||||
std::string lastGrowthStr = r[0]["last_growth_at"].as<std::string>();
|
||||
std::tm lastGrowthTm = {};
|
||||
std::istringstream ss(lastGrowthStr);
|
||||
ss >> std::get_time(&lastGrowthTm, "%Y-%m-%d %H:%M:%S");
|
||||
std::time_t lastGrowthTime = std::mktime(&lastGrowthTm);
|
||||
|
||||
// If last growth was before today, we need to apply growth
|
||||
if (lastGrowthTime < todayStartTime) {
|
||||
needsGrowth = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if distribution is needed (Sunday, once per week)
|
||||
if (dayOfWeek == 0) { // Sunday
|
||||
if (r[0]["last_distribution_at"].isNull()) {
|
||||
needsDistribution = true;
|
||||
} else {
|
||||
std::string lastDistStr = r[0]["last_distribution_at"].as<std::string>();
|
||||
std::tm lastDistTm = {};
|
||||
std::istringstream ss(lastDistStr);
|
||||
ss >> std::get_time(&lastDistTm, "%Y-%m-%d %H:%M:%S");
|
||||
std::time_t lastDistTime = std::mktime(&lastDistTm);
|
||||
|
||||
// If last distribution was before today, we need to distribute
|
||||
if (lastDistTime < todayStartTime) {
|
||||
needsDistribution = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsGrowth) {
|
||||
LOG_INFO << "Treasury scheduler: applying daily growth";
|
||||
this->applyDailyGrowth();
|
||||
}
|
||||
|
||||
if (needsDistribution) {
|
||||
LOG_INFO << "Treasury scheduler: distributing to users";
|
||||
this->distributeToUsers();
|
||||
}
|
||||
|
||||
if (!needsGrowth && !needsDistribution) {
|
||||
LOG_INFO << "Treasury scheduler: no tasks needed at this time";
|
||||
}
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Treasury scheduler: failed to check timestamps: " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void TreasuryService::applyDailyGrowth() {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Apply 3.3% growth to treasury balance
|
||||
*dbClient << "UPDATE ubercoin_treasury SET balance = balance * 1.033, last_growth_at = NOW() WHERE id = 1 RETURNING balance"
|
||||
>> [](const orm::Result& r) {
|
||||
double newBalance = 0.0;
|
||||
if (!r.empty()) {
|
||||
newBalance = r[0]["balance"].as<double>();
|
||||
}
|
||||
LOG_INFO << "Treasury growth applied (3.3%). New balance: " << newBalance;
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to apply treasury growth: " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
void TreasuryService::distributeToUsers() {
|
||||
auto dbClient = app().getDbClient();
|
||||
|
||||
// Get treasury balance
|
||||
*dbClient << "SELECT balance FROM ubercoin_treasury WHERE id = 1"
|
||||
>> [this, dbClient](const orm::Result& r) {
|
||||
if (r.empty()) {
|
||||
LOG_ERROR << "Treasury not found for distribution";
|
||||
return;
|
||||
}
|
||||
|
||||
double treasuryBalance = r[0]["balance"].as<double>();
|
||||
|
||||
if (treasuryBalance <= 0) {
|
||||
LOG_INFO << "Treasury empty, nothing to distribute";
|
||||
return;
|
||||
}
|
||||
|
||||
// Get all users with their created_at for burn rate calculation
|
||||
*dbClient << "SELECT id, created_at, ubercoin_balance FROM users"
|
||||
>> [this, dbClient, treasuryBalance](const orm::Result& users) {
|
||||
if (users.empty()) {
|
||||
LOG_INFO << "No users to distribute to";
|
||||
return;
|
||||
}
|
||||
|
||||
int64_t userCount = users.size();
|
||||
double sharePerUser = treasuryBalance / static_cast<double>(userCount);
|
||||
|
||||
double totalDistributed = 0.0;
|
||||
double totalDestroyed = 0.0;
|
||||
|
||||
// Calculate distributions for each user
|
||||
for (const auto& row : users) {
|
||||
int64_t userId = row["id"].as<int64_t>();
|
||||
std::string createdAt = row["created_at"].as<std::string>();
|
||||
double currentBalance = row["ubercoin_balance"].isNull() ? 0.0 : row["ubercoin_balance"].as<double>();
|
||||
|
||||
int accountAgeDays = this->calculateAccountAgeDays(createdAt);
|
||||
double burnRate = this->calculateBurnRate(accountAgeDays);
|
||||
|
||||
// Calculate received amount (after burn) - ceiling for user benefit
|
||||
double receivedAmount = sharePerUser * (100.0 - burnRate) / 100.0;
|
||||
receivedAmount = std::ceil(receivedAmount * 1000.0) / 1000.0;
|
||||
|
||||
double destroyedAmount = sharePerUser - receivedAmount;
|
||||
|
||||
double newBalance = currentBalance + receivedAmount;
|
||||
|
||||
// Update user balance
|
||||
*dbClient << "UPDATE users SET ubercoin_balance = $1 WHERE id = $2"
|
||||
<< newBalance << userId
|
||||
>> [](const orm::Result&) {}
|
||||
>> [userId](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to update user " << userId << " balance in distribution: " << e.base().what();
|
||||
};
|
||||
|
||||
totalDistributed += receivedAmount;
|
||||
totalDestroyed += destroyedAmount;
|
||||
}
|
||||
|
||||
// Reset treasury balance to 0 and update total_destroyed
|
||||
*dbClient << "UPDATE ubercoin_treasury SET balance = 0, total_destroyed = total_destroyed + $1, last_distribution_at = NOW() WHERE id = 1"
|
||||
<< totalDestroyed
|
||||
>> [totalDistributed, totalDestroyed, userCount](const orm::Result&) {
|
||||
LOG_INFO << "Treasury distributed successfully. Users: " << userCount
|
||||
<< ", Distributed: " << totalDistributed
|
||||
<< ", Destroyed: " << totalDestroyed;
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to reset treasury: " << e.base().what();
|
||||
};
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get users for distribution: " << e.base().what();
|
||||
};
|
||||
}
|
||||
>> [](const orm::DrogonDbException& e) {
|
||||
LOG_ERROR << "Failed to get treasury balance: " << e.base().what();
|
||||
};
|
||||
}
|
||||
|
||||
double TreasuryService::calculateBurnRate(int accountAgeDays) {
|
||||
double burnRate = 99.0 * std::exp(-static_cast<double>(accountAgeDays) / 180.0);
|
||||
return std::max(1.0, burnRate);
|
||||
}
|
||||
|
||||
int TreasuryService::calculateAccountAgeDays(const std::string& createdAt) {
|
||||
try {
|
||||
// Parse ISO 8601 timestamp (e.g., "2025-01-15T10:30:00+00:00" or "2025-01-15 10:30:00")
|
||||
std::tm tm = {};
|
||||
std::istringstream ss(createdAt);
|
||||
|
||||
// Try ISO 8601 format first
|
||||
ss >> std::get_time(&tm, "%Y-%m-%dT%H:%M:%S");
|
||||
if (ss.fail()) {
|
||||
// Try space-separated format
|
||||
ss.clear();
|
||||
ss.str(createdAt);
|
||||
ss >> std::get_time(&tm, "%Y-%m-%d %H:%M:%S");
|
||||
}
|
||||
|
||||
if (ss.fail()) {
|
||||
LOG_WARN << "Failed to parse created_at timestamp: " << createdAt;
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::time_t createdTime = std::mktime(&tm);
|
||||
std::time_t now = std::time(nullptr);
|
||||
|
||||
// Calculate difference in days
|
||||
double diffSeconds = std::difftime(now, createdTime);
|
||||
return static_cast<int>(diffSeconds / (60 * 60 * 24));
|
||||
} catch (const std::exception& e) {
|
||||
LOG_ERROR << "Error calculating account age: " << e.what();
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
40
backend/src/services/TreasuryService.h
Normal file
40
backend/src/services/TreasuryService.h
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#pragma once
|
||||
#include <drogon/drogon.h>
|
||||
#include <trantor/net/EventLoop.h>
|
||||
#include <atomic>
|
||||
#include <optional>
|
||||
#include <chrono>
|
||||
|
||||
class TreasuryService {
|
||||
public:
|
||||
static TreasuryService& getInstance() {
|
||||
static TreasuryService instance;
|
||||
return instance;
|
||||
}
|
||||
|
||||
void initialize();
|
||||
void startScheduler();
|
||||
void shutdown();
|
||||
|
||||
// Manual triggers (for testing/admin)
|
||||
void applyDailyGrowth();
|
||||
void distributeToUsers();
|
||||
|
||||
private:
|
||||
TreasuryService() = default;
|
||||
~TreasuryService();
|
||||
TreasuryService(const TreasuryService&) = delete;
|
||||
TreasuryService& operator=(const TreasuryService&) = delete;
|
||||
|
||||
void checkAndRunTasks();
|
||||
|
||||
// Burn rate calculation helpers
|
||||
double calculateBurnRate(int accountAgeDays);
|
||||
int calculateAccountAgeDays(const std::string& createdAt);
|
||||
|
||||
std::atomic<bool> running_{false};
|
||||
std::optional<trantor::TimerId> timerId_;
|
||||
|
||||
// Check every hour (3600 seconds)
|
||||
std::chrono::seconds checkInterval_{3600};
|
||||
};
|
||||
93
bot-sdk/examples/echo-bot.js
Normal file
93
bot-sdk/examples/echo-bot.js
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* Echo Bot Example
|
||||
*
|
||||
* A simple bot that echoes back messages that start with "!echo"
|
||||
*
|
||||
* Usage:
|
||||
* node echo-bot.js <api-key> <server-url> <realm-id>
|
||||
*
|
||||
* Example:
|
||||
* node echo-bot.js abc123 wss://example.com/chat/ws my-realm
|
||||
*/
|
||||
|
||||
import ChatBot from '../index.js';
|
||||
|
||||
// Get arguments
|
||||
const [,, apiKey, serverUrl, realmId] = process.argv;
|
||||
|
||||
if (!apiKey || !serverUrl || !realmId) {
|
||||
console.log('Usage: node echo-bot.js <api-key> <server-url> <realm-id>');
|
||||
console.log('Example: node echo-bot.js abc123 wss://example.com/chat/ws my-realm');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Create the bot
|
||||
const bot = new ChatBot('EchoBot');
|
||||
|
||||
// Set up message handler
|
||||
bot.messageHandler = (message) => {
|
||||
console.log(`[${message.username}]: ${message.content}`);
|
||||
|
||||
// Echo back messages that start with !echo
|
||||
if (message.content.startsWith('!echo ')) {
|
||||
const echoText = message.content.slice(6);
|
||||
bot.print(echoText);
|
||||
}
|
||||
|
||||
// Respond to !hello
|
||||
if (message.content === '!hello') {
|
||||
bot.print(`Hello, ${message.username}!`);
|
||||
}
|
||||
|
||||
// Show help
|
||||
if (message.content === '!help') {
|
||||
bot.print('Commands: !echo <text>, !hello, !help');
|
||||
}
|
||||
};
|
||||
|
||||
// Set up event handlers
|
||||
bot.onConnect = () => {
|
||||
console.log('Bot connected!');
|
||||
};
|
||||
|
||||
bot.onJoin = (room) => {
|
||||
console.log(`Bot joined room: ${room}`);
|
||||
bot.print('EchoBot is online! Type !help for commands.');
|
||||
};
|
||||
|
||||
bot.onParticipantJoin = (participant) => {
|
||||
console.log(`${participant.username} joined the chat`);
|
||||
};
|
||||
|
||||
bot.onParticipantLeave = (participant) => {
|
||||
console.log(`${participant.username} left the chat`);
|
||||
};
|
||||
|
||||
bot.onError = (error) => {
|
||||
console.error('Bot error:', error.message);
|
||||
};
|
||||
|
||||
// Connect and join
|
||||
async function main() {
|
||||
try {
|
||||
console.log(`Connecting to ${serverUrl}...`);
|
||||
await bot.connect(apiKey, serverUrl);
|
||||
|
||||
console.log(`Joining room: ${realmId}...`);
|
||||
await bot.joinRoom(realmId);
|
||||
|
||||
console.log('Bot is running. Press Ctrl+C to stop.');
|
||||
} catch (error) {
|
||||
console.error('Failed to start bot:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down bot...');
|
||||
bot.disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
main();
|
||||
221
bot-sdk/examples/trivia-bot.js
Normal file
221
bot-sdk/examples/trivia-bot.js
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/**
|
||||
* Trivia Bot Example
|
||||
*
|
||||
* A bot that runs trivia games in chat
|
||||
*
|
||||
* Usage:
|
||||
* node trivia-bot.js <api-key> <server-url> <realm-id>
|
||||
*
|
||||
* Commands:
|
||||
* !trivia start - Start a new trivia game
|
||||
* !trivia stop - Stop the current game
|
||||
* !trivia score - Show current scores
|
||||
* !answer <answer> - Submit an answer
|
||||
*/
|
||||
|
||||
import ChatBot from '../index.js';
|
||||
|
||||
// Get arguments
|
||||
const [,, apiKey, serverUrl, realmId] = process.argv;
|
||||
|
||||
if (!apiKey || !serverUrl || !realmId) {
|
||||
console.log('Usage: node trivia-bot.js <api-key> <server-url> <realm-id>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Trivia questions
|
||||
const questions = [
|
||||
{ question: "What planet is known as the Red Planet?", answer: "mars" },
|
||||
{ question: "What is the largest mammal in the world?", answer: "blue whale" },
|
||||
{ question: "In what year did the Titanic sink?", answer: "1912" },
|
||||
{ question: "What is the chemical symbol for gold?", answer: "au" },
|
||||
{ question: "Who painted the Mona Lisa?", answer: "leonardo da vinci" },
|
||||
{ question: "What is the capital of Japan?", answer: "tokyo" },
|
||||
{ question: "How many sides does a hexagon have?", answer: "6" },
|
||||
{ question: "What is the smallest prime number?", answer: "2" },
|
||||
{ question: "What element does 'O' represent on the periodic table?", answer: "oxygen" },
|
||||
{ question: "In what year did World War II end?", answer: "1945" },
|
||||
{ question: "What is the largest organ in the human body?", answer: "skin" },
|
||||
{ question: "Who wrote Romeo and Juliet?", answer: "shakespeare" },
|
||||
{ question: "What is the speed of light in km/s (approximately)?", answer: "300000" },
|
||||
{ question: "What is the tallest mountain in the world?", answer: "everest" },
|
||||
{ question: "What gas do plants absorb from the atmosphere?", answer: "carbon dioxide" }
|
||||
];
|
||||
|
||||
// Game state
|
||||
let gameActive = false;
|
||||
let currentQuestion = null;
|
||||
let questionIndex = 0;
|
||||
let scores = {};
|
||||
let questionTimeout = null;
|
||||
let usedQuestions = [];
|
||||
|
||||
// Create the bot
|
||||
const bot = new ChatBot('TriviaBot');
|
||||
|
||||
function shuffleArray(array) {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
}
|
||||
|
||||
function getNextQuestion() {
|
||||
if (usedQuestions.length === questions.length) {
|
||||
usedQuestions = [];
|
||||
}
|
||||
|
||||
const available = questions.filter((_, i) => !usedQuestions.includes(i));
|
||||
const randomIndex = Math.floor(Math.random() * available.length);
|
||||
const originalIndex = questions.indexOf(available[randomIndex]);
|
||||
usedQuestions.push(originalIndex);
|
||||
|
||||
return available[randomIndex];
|
||||
}
|
||||
|
||||
function askQuestion() {
|
||||
if (!gameActive) return;
|
||||
|
||||
currentQuestion = getNextQuestion();
|
||||
questionIndex++;
|
||||
|
||||
bot.print(`Question #${questionIndex}: ${currentQuestion.question}`);
|
||||
|
||||
// Set timeout for unanswered question
|
||||
questionTimeout = setTimeout(() => {
|
||||
if (currentQuestion) {
|
||||
bot.print(`Time's up! The answer was: ${currentQuestion.answer}`);
|
||||
currentQuestion = null;
|
||||
|
||||
// Ask next question after delay
|
||||
setTimeout(() => {
|
||||
if (gameActive) askQuestion();
|
||||
}, 3000);
|
||||
}
|
||||
}, 30000); // 30 seconds to answer
|
||||
}
|
||||
|
||||
function checkAnswer(username, answer) {
|
||||
if (!currentQuestion) return;
|
||||
|
||||
const normalizedAnswer = answer.toLowerCase().trim();
|
||||
const correctAnswer = currentQuestion.answer.toLowerCase().trim();
|
||||
|
||||
if (normalizedAnswer === correctAnswer || normalizedAnswer.includes(correctAnswer) || correctAnswer.includes(normalizedAnswer)) {
|
||||
clearTimeout(questionTimeout);
|
||||
|
||||
// Award points
|
||||
if (!scores[username]) scores[username] = 0;
|
||||
scores[username]++;
|
||||
|
||||
bot.print(`Correct! ${username} got it right! (+1 point, total: ${scores[username]})`);
|
||||
currentQuestion = null;
|
||||
|
||||
// Ask next question after delay
|
||||
setTimeout(() => {
|
||||
if (gameActive) askQuestion();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
function showScores() {
|
||||
const sortedScores = Object.entries(scores)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 10);
|
||||
|
||||
if (sortedScores.length === 0) {
|
||||
bot.print('No scores yet!');
|
||||
return;
|
||||
}
|
||||
|
||||
let scoreText = 'Leaderboard: ';
|
||||
sortedScores.forEach(([user, score], i) => {
|
||||
scoreText += `${i + 1}. ${user}: ${score} pts | `;
|
||||
});
|
||||
|
||||
bot.print(scoreText.slice(0, -3));
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
if (gameActive) {
|
||||
bot.print('A game is already in progress!');
|
||||
return;
|
||||
}
|
||||
|
||||
gameActive = true;
|
||||
questionIndex = 0;
|
||||
bot.print('Trivia game starting! Answer with "!answer <your answer>". You have 30 seconds per question.');
|
||||
|
||||
setTimeout(() => {
|
||||
askQuestion();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function stopGame() {
|
||||
if (!gameActive) {
|
||||
bot.print('No game is currently running.');
|
||||
return;
|
||||
}
|
||||
|
||||
gameActive = false;
|
||||
currentQuestion = null;
|
||||
clearTimeout(questionTimeout);
|
||||
|
||||
bot.print('Trivia game stopped!');
|
||||
showScores();
|
||||
}
|
||||
|
||||
// Set up message handler
|
||||
bot.messageHandler = (message) => {
|
||||
const content = message.content.trim().toLowerCase();
|
||||
|
||||
if (content === '!trivia start') {
|
||||
startGame();
|
||||
} else if (content === '!trivia stop') {
|
||||
stopGame();
|
||||
} else if (content === '!trivia score' || content === '!trivia scores') {
|
||||
showScores();
|
||||
} else if (content === '!trivia help') {
|
||||
bot.print('Commands: !trivia start, !trivia stop, !trivia score, !answer <answer>');
|
||||
} else if (content.startsWith('!answer ')) {
|
||||
const answer = message.content.slice(8);
|
||||
checkAnswer(message.username, answer);
|
||||
}
|
||||
};
|
||||
|
||||
// Set up event handlers
|
||||
bot.onJoin = () => {
|
||||
bot.print('TriviaBot is online! Type "!trivia start" to begin a game.');
|
||||
};
|
||||
|
||||
bot.onError = (error) => {
|
||||
console.error('Bot error:', error.message);
|
||||
};
|
||||
|
||||
// Connect and join
|
||||
async function main() {
|
||||
try {
|
||||
console.log(`Connecting to ${serverUrl}...`);
|
||||
await bot.connect(apiKey, serverUrl);
|
||||
|
||||
console.log(`Joining room: ${realmId}...`);
|
||||
await bot.joinRoom(realmId);
|
||||
|
||||
console.log('TriviaBot is running. Press Ctrl+C to stop.');
|
||||
} catch (error) {
|
||||
console.error('Failed to start bot:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down bot...');
|
||||
if (gameActive) stopGame();
|
||||
bot.disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
main();
|
||||
369
bot-sdk/index.js
Normal file
369
bot-sdk/index.js
Normal file
|
|
@ -0,0 +1,369 @@
|
|||
import WebSocket from 'ws';
|
||||
|
||||
/**
|
||||
* ChatBot - A simple bot SDK for Realms chat
|
||||
*
|
||||
* Mirrors the existing XMPP bot API for easy migration.
|
||||
*
|
||||
* @example
|
||||
* const bot = new ChatBot('MyBot');
|
||||
*
|
||||
* bot.messageHandler = (message) => {
|
||||
* console.log(`${message.username}: ${message.content}`);
|
||||
* if (message.content === '!hello') {
|
||||
* bot.print('Hello there!');
|
||||
* }
|
||||
* };
|
||||
*
|
||||
* bot.connect('your-api-key', 'wss://example.com/chat/ws')
|
||||
* .then(() => bot.joinRoom('realm-id'))
|
||||
* .catch(console.error);
|
||||
*/
|
||||
class ChatBot {
|
||||
/**
|
||||
* Create a new ChatBot instance
|
||||
* @param {string} name - Display name for the bot (informational only, actual name comes from API key owner)
|
||||
*/
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
this.ws = null;
|
||||
this.apiKey = null;
|
||||
this.serverUrl = null;
|
||||
this.realmId = null;
|
||||
this.connected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 5;
|
||||
this.reconnectDelay = 1000;
|
||||
this.shouldReconnect = true;
|
||||
|
||||
// Event handlers
|
||||
this.messageHandler = null;
|
||||
this.onConnect = null;
|
||||
this.onDisconnect = null;
|
||||
this.onError = null;
|
||||
this.onJoin = null;
|
||||
this.onParticipantJoin = null;
|
||||
this.onParticipantLeave = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to the chat server
|
||||
* @param {string} apiKey - Your bot API key from the Settings page
|
||||
* @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/chat/ws')
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
connect(apiKey, serverUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.apiKey = apiKey;
|
||||
this.serverUrl = serverUrl;
|
||||
this.shouldReconnect = true;
|
||||
|
||||
// SECURITY FIX: Don't put API key in URL (it gets logged by servers/proxies)
|
||||
// Instead, send auth message after connection opens
|
||||
this.ws = new WebSocket(serverUrl);
|
||||
|
||||
// Store resolve/reject for when we get the welcome message
|
||||
this._connectResolve = resolve;
|
||||
this._connectReject = reject;
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log(`[${this.name}] Connected to server, authenticating...`);
|
||||
// SECURITY FIX: Send API key via message instead of URL
|
||||
this._send({ type: 'auth', apiKey: apiKey });
|
||||
// Don't resolve yet - wait for welcome message after API key validation
|
||||
});
|
||||
|
||||
this.ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this._handleMessage(message);
|
||||
} catch (e) {
|
||||
console.error(`[${this.name}] Failed to parse message:`, e);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
console.log(`[${this.name}] Connection closed`);
|
||||
this.connected = false;
|
||||
|
||||
if (this.onDisconnect) {
|
||||
this.onDisconnect();
|
||||
}
|
||||
|
||||
if (this.shouldReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this._attemptReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
console.error(`[${this.name}] WebSocket error:`, error.message);
|
||||
|
||||
if (this.onError) {
|
||||
this.onError(error);
|
||||
}
|
||||
|
||||
if (this._connectReject) {
|
||||
this._connectReject(error);
|
||||
this._connectResolve = null;
|
||||
this._connectReject = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Timeout for connection/authentication
|
||||
setTimeout(() => {
|
||||
if (this._connectResolve) {
|
||||
this._connectReject(new Error('Connection timeout - no welcome message received'));
|
||||
this._connectResolve = null;
|
||||
this._connectReject = null;
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the chat server
|
||||
*/
|
||||
disconnect() {
|
||||
this.shouldReconnect = false;
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
this.connected = false;
|
||||
console.log(`[${this.name}] Disconnected`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a chat room (realm)
|
||||
* @param {string} realmId - The realm ID to join
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
joinRoom(realmId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.connected) {
|
||||
reject(new Error('Not connected to server'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.realmId = realmId;
|
||||
|
||||
// Store resolve/reject for when we get the join response
|
||||
this._joinResolve = resolve;
|
||||
this._joinReject = reject;
|
||||
|
||||
this._send({
|
||||
type: 'join',
|
||||
realmId: realmId
|
||||
});
|
||||
|
||||
// Timeout after 10 seconds
|
||||
setTimeout(() => {
|
||||
if (this._joinResolve) {
|
||||
this._joinReject(new Error('Join timeout'));
|
||||
this._joinResolve = null;
|
||||
this._joinReject = null;
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to the current room
|
||||
* @param {string} message - The message to send
|
||||
*/
|
||||
print(message) {
|
||||
if (!this.connected || !this.realmId) {
|
||||
console.error(`[${this.name}] Cannot send message: not connected or not in a room`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._send({
|
||||
type: 'message',
|
||||
content: message
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of participants in the current room
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
getParticipants() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.connected || !this.realmId) {
|
||||
reject(new Error('Not connected or not in a room'));
|
||||
return;
|
||||
}
|
||||
|
||||
this._participantsResolve = resolve;
|
||||
this._participantsReject = reject;
|
||||
|
||||
this._send({
|
||||
type: 'get_participants'
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (this._participantsResolve) {
|
||||
this._participantsReject(new Error('Get participants timeout'));
|
||||
this._participantsResolve = null;
|
||||
this._participantsReject = null;
|
||||
}
|
||||
}, 10000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Send a message over WebSocket
|
||||
* @private
|
||||
*/
|
||||
_send(data) {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Handle incoming messages
|
||||
* @private
|
||||
*/
|
||||
_handleMessage(msg) {
|
||||
switch (msg.type) {
|
||||
case 'welcome':
|
||||
// Authentication successful - connection is ready
|
||||
console.log(`[${this.name}] Authenticated as: ${msg.username}`);
|
||||
this.connected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.username = msg.username;
|
||||
this.userId = msg.userId;
|
||||
|
||||
if (this._connectResolve) {
|
||||
this._connectResolve();
|
||||
this._connectResolve = null;
|
||||
this._connectReject = null;
|
||||
}
|
||||
|
||||
if (this.onConnect) {
|
||||
this.onConnect();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message':
|
||||
case 'new_message':
|
||||
// Chat message from a user
|
||||
if (this.messageHandler && msg.content) {
|
||||
this.messageHandler({
|
||||
userId: msg.userId,
|
||||
username: msg.username,
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
userColor: msg.userColor,
|
||||
avatarUrl: msg.avatarUrl,
|
||||
isGuest: msg.isGuest,
|
||||
isModerator: msg.isModerator,
|
||||
isStreamer: msg.isStreamer
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'message_deleted':
|
||||
// Message was deleted by a moderator - bots can ignore this
|
||||
break;
|
||||
|
||||
case 'history':
|
||||
// Chat history - bots can ignore this
|
||||
break;
|
||||
|
||||
case 'join_success':
|
||||
console.log(`[${this.name}] Joined room: ${this.realmId}`);
|
||||
if (this._joinResolve) {
|
||||
this._joinResolve();
|
||||
this._joinResolve = null;
|
||||
this._joinReject = null;
|
||||
}
|
||||
if (this.onJoin) {
|
||||
this.onJoin(this.realmId);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
console.error(`[${this.name}] Server error:`, msg.error);
|
||||
|
||||
// If we're still connecting and get an error, reject the connect promise
|
||||
if (this._connectReject) {
|
||||
this._connectReject(new Error(msg.error));
|
||||
this._connectResolve = null;
|
||||
this._connectReject = null;
|
||||
}
|
||||
|
||||
if (this._joinReject && msg.error.includes('join')) {
|
||||
this._joinReject(new Error(msg.error));
|
||||
this._joinResolve = null;
|
||||
this._joinReject = null;
|
||||
}
|
||||
if (this.onError) {
|
||||
this.onError(new Error(msg.error));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'participant_joined':
|
||||
if (this.onParticipantJoin) {
|
||||
this.onParticipantJoin({
|
||||
userId: msg.userId,
|
||||
username: msg.username,
|
||||
isGuest: msg.isGuest
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'participant_left':
|
||||
if (this.onParticipantLeave) {
|
||||
this.onParticipantLeave({
|
||||
userId: msg.userId,
|
||||
username: msg.username
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'participants':
|
||||
if (this._participantsResolve) {
|
||||
this._participantsResolve(msg.participants || []);
|
||||
this._participantsResolve = null;
|
||||
this._participantsReject = null;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'system':
|
||||
// System messages (user joined, left, etc.)
|
||||
console.log(`[${this.name}] System: ${msg.content}`);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown message type, ignore
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal: Attempt to reconnect
|
||||
* @private
|
||||
*/
|
||||
_attemptReconnect() {
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||
|
||||
console.log(`[${this.name}] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await this.connect(this.apiKey, this.serverUrl);
|
||||
if (this.realmId) {
|
||||
await this.joinRoom(this.realmId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[${this.name}] Reconnect failed:`, e.message);
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
|
||||
export default ChatBot;
|
||||
export { ChatBot };
|
||||
21
bot-sdk/package.json
Normal file
21
bot-sdk/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"name": "realms-bot-sdk",
|
||||
"version": "1.0.0",
|
||||
"description": "Bot SDK for Realms chat",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "node examples/echo-bot.js"
|
||||
},
|
||||
"keywords": [
|
||||
"realms",
|
||||
"chat",
|
||||
"bot",
|
||||
"websocket"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
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
|
||||
7
database/00-create-nakama-db.sql
Normal file
7
database/00-create-nakama-db.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
-- Create Nakama database for game server
|
||||
-- This runs before init.sql (alphabetically sorted)
|
||||
-- Note: This runs in the context of the postgres database
|
||||
|
||||
-- Create the nakama database if it doesn't exist
|
||||
SELECT 'CREATE DATABASE nakama'
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'nakama')\gexec
|
||||
11
database/00-init-nakama.sh
Normal file
11
database/00-init-nakama.sh
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
#!/bin/bash
|
||||
# Create Nakama database for game server
|
||||
# This script runs before init.sql (alphabetically)
|
||||
set -e
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "postgres" <<-EOSQL
|
||||
CREATE DATABASE nakama;
|
||||
GRANT ALL PRIVILEGES ON DATABASE nakama TO $POSTGRES_USER;
|
||||
EOSQL
|
||||
|
||||
echo "Nakama database created successfully"
|
||||
1094
database/init.sql
1094
database/init.sql
File diff suppressed because it is too large
Load diff
41
devops/forgejo-server/.env.example
Normal file
41
devops/forgejo-server/.env.example
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# =============================================================================
|
||||
# Forgejo Server Environment Configuration
|
||||
# Copy to .env and fill in values
|
||||
# =============================================================================
|
||||
|
||||
# Domain for Forgejo (required)
|
||||
FORGEJO_DOMAIN=bit.realms.pub
|
||||
|
||||
# PostgreSQL Configuration
|
||||
POSTGRES_USER=forgejo
|
||||
POSTGRES_PASSWORD=CHANGE_ME_SECURE_PASSWORD
|
||||
POSTGRES_DB=forgejo
|
||||
|
||||
# Forgejo Security Keys (generate with: openssl rand -hex 32)
|
||||
# SECRET_KEY: Used for encrypting data
|
||||
FORGEJO_SECRET_KEY=CHANGE_ME_GENERATE_WITH_openssl_rand_hex_32
|
||||
|
||||
# INTERNAL_TOKEN: Used for internal API authentication
|
||||
FORGEJO_INTERNAL_TOKEN=CHANGE_ME_GENERATE_WITH_openssl_rand_hex_32
|
||||
|
||||
# JWT_SECRET: Used for OAuth2 JWT tokens
|
||||
FORGEJO_JWT_SECRET=CHANGE_ME_GENERATE_WITH_openssl_rand_hex_32
|
||||
|
||||
# =============================================================================
|
||||
# Runner Configuration (set after initial setup)
|
||||
# =============================================================================
|
||||
|
||||
# Runner registration token (get from Forgejo admin panel)
|
||||
# Site Administration > Actions > Runners > Create new Runner
|
||||
# RUNNER_TOKEN=
|
||||
|
||||
# =============================================================================
|
||||
# Generate secure values with:
|
||||
#
|
||||
# # Generate all secrets at once
|
||||
# echo "FORGEJO_SECRET_KEY=$(openssl rand -hex 32)"
|
||||
# echo "FORGEJO_INTERNAL_TOKEN=$(openssl rand -hex 32)"
|
||||
# echo "FORGEJO_JWT_SECRET=$(openssl rand -hex 32)"
|
||||
# echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)"
|
||||
#
|
||||
# =============================================================================
|
||||
40
devops/forgejo-server/Caddyfile
Normal file
40
devops/forgejo-server/Caddyfile
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# =============================================================================
|
||||
# Caddy Configuration for Forgejo
|
||||
# Automatic HTTPS with Let's Encrypt
|
||||
# =============================================================================
|
||||
|
||||
{$FORGEJO_DOMAIN} {
|
||||
# Reverse proxy to Forgejo
|
||||
reverse_proxy forgejo:3000
|
||||
|
||||
# Enable compression
|
||||
encode gzip zstd
|
||||
|
||||
# Security headers
|
||||
header {
|
||||
# HSTS
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
# Prevent clickjacking
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
# XSS protection
|
||||
X-Content-Type-Options "nosniff"
|
||||
X-XSS-Protection "1; mode=block"
|
||||
# Referrer policy
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
# Remove server header
|
||||
-Server
|
||||
}
|
||||
|
||||
# Logging
|
||||
log {
|
||||
output file /data/access.log {
|
||||
roll_size 10mb
|
||||
roll_keep 5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# HTTP to HTTPS redirect (automatic with Caddy, but explicit for clarity)
|
||||
http://{$FORGEJO_DOMAIN} {
|
||||
redir https://{$FORGEJO_DOMAIN}{uri} permanent
|
||||
}
|
||||
137
devops/forgejo-server/README.md
Normal file
137
devops/forgejo-server/README.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Forgejo Server Setup
|
||||
|
||||
Git server with CI/CD for realms.india infrastructure.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Droplet with Docker and Docker Compose installed (via Terraform cloud-init)
|
||||
- Volume mounted at `/mnt/forgejo`
|
||||
- DNS A record pointing to droplet IP
|
||||
|
||||
## Initial Setup
|
||||
|
||||
### 1. Copy configuration files
|
||||
|
||||
```bash
|
||||
# SSH to Forgejo server via jump host
|
||||
ssh realms-forgejo
|
||||
|
||||
# Copy files to /opt/forgejo
|
||||
cd /opt/forgejo
|
||||
# (upload docker-compose.yml, Caddyfile, .env.example)
|
||||
```
|
||||
|
||||
### 2. Generate secrets and configure environment
|
||||
|
||||
```bash
|
||||
cd /opt/forgejo
|
||||
cp .env.example .env
|
||||
|
||||
# Generate secure values
|
||||
echo "FORGEJO_SECRET_KEY=$(openssl rand -hex 32)"
|
||||
echo "FORGEJO_INTERNAL_TOKEN=$(openssl rand -hex 32)"
|
||||
echo "FORGEJO_JWT_SECRET=$(openssl rand -hex 32)"
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)"
|
||||
|
||||
# Edit .env with generated values
|
||||
vim .env
|
||||
```
|
||||
|
||||
### 3. Start Forgejo (without runner)
|
||||
|
||||
```bash
|
||||
docker compose up -d forgejo-db forgejo caddy
|
||||
docker compose logs -f forgejo
|
||||
```
|
||||
|
||||
### 4. Initial Forgejo Configuration
|
||||
|
||||
1. Visit `https://bit.realms.pub`
|
||||
2. Create admin account (first user becomes admin)
|
||||
3. Configure settings as needed
|
||||
|
||||
### 5. Register the Actions Runner
|
||||
|
||||
```bash
|
||||
# Get runner token from Forgejo
|
||||
# Site Administration > Actions > Runners > Create new Runner
|
||||
|
||||
# Register the runner
|
||||
docker compose run --rm forgejo-runner \
|
||||
forgejo-runner register \
|
||||
--instance https://bit.realms.pub \
|
||||
--token YOUR_RUNNER_TOKEN \
|
||||
--name realms-runner \
|
||||
--labels ubuntu-latest,docker \
|
||||
--no-interactive
|
||||
|
||||
# Start the runner
|
||||
docker compose up -d forgejo-runner
|
||||
```
|
||||
|
||||
### 6. Verify Setup
|
||||
|
||||
```bash
|
||||
# Check all services
|
||||
docker compose ps
|
||||
|
||||
# Check logs
|
||||
docker compose logs -f
|
||||
|
||||
# Test Git SSH
|
||||
ssh -T git@bit.realms.pub -p 2222
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### View logs
|
||||
```bash
|
||||
docker compose logs -f [service]
|
||||
```
|
||||
|
||||
### Restart services
|
||||
```bash
|
||||
docker compose restart [service]
|
||||
```
|
||||
|
||||
### Backup
|
||||
```bash
|
||||
# Stop services
|
||||
docker compose down
|
||||
|
||||
# Backup volumes
|
||||
tar -czvf forgejo-backup-$(date +%Y%m%d).tar.gz /mnt/forgejo
|
||||
|
||||
# Restart
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Update Forgejo
|
||||
|
||||
```bash
|
||||
# Pull new image
|
||||
docker compose pull forgejo
|
||||
|
||||
# Recreate container
|
||||
docker compose up -d forgejo
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Runner won't start
|
||||
- Ensure runner is registered first
|
||||
- Check `/mnt/forgejo/runner-data/.runner` exists
|
||||
- Check logs: `docker compose logs forgejo-runner`
|
||||
|
||||
### SSL certificate issues
|
||||
- Ensure DNS is properly configured
|
||||
- Check Caddy logs: `docker compose logs caddy`
|
||||
- Caddy auto-obtains certs, may take a minute on first start
|
||||
|
||||
### Database connection issues
|
||||
- Check PostgreSQL is healthy: `docker compose ps`
|
||||
- Check logs: `docker compose logs forgejo-db`
|
||||
|
||||
### Git SSH not working
|
||||
- Verify port 2222 is open in firewall
|
||||
- Test: `ssh -T git@bit.realms.pub -p 2222 -v`
|
||||
208
devops/forgejo-server/docker-compose.yml
Normal file
208
devops/forgejo-server/docker-compose.yml
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
# =============================================================================
|
||||
# Forgejo Git Server - Docker Compose Stack
|
||||
# Forgejo 11.0.8 LTS with PostgreSQL, Caddy, and Actions Runner
|
||||
# =============================================================================
|
||||
|
||||
services:
|
||||
# ---------------------------------------------------------------------------
|
||||
# PostgreSQL Database
|
||||
# ---------------------------------------------------------------------------
|
||||
forgejo-db:
|
||||
image: postgres:16-alpine
|
||||
container_name: forgejo-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-forgejo}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-forgejo}
|
||||
volumes:
|
||||
- /mnt/forgejo/forgejo-db:/var/lib/postgresql/data
|
||||
networks:
|
||||
- forgejo-internal
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-forgejo} -d ${POSTGRES_DB:-forgejo}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forgejo Git Server
|
||||
# Using rootless image for better security
|
||||
# ---------------------------------------------------------------------------
|
||||
forgejo:
|
||||
image: codeberg.org/forgejo/forgejo:11.0.8-rootless
|
||||
container_name: forgejo
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
forgejo-db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Database
|
||||
FORGEJO__database__DB_TYPE: postgres
|
||||
FORGEJO__database__HOST: forgejo-db:5432
|
||||
FORGEJO__database__NAME: ${POSTGRES_DB:-forgejo}
|
||||
FORGEJO__database__USER: ${POSTGRES_USER:-forgejo}
|
||||
FORGEJO__database__PASSWD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD required}
|
||||
|
||||
# Server configuration
|
||||
FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN:?FORGEJO_DOMAIN required}
|
||||
FORGEJO__server__ROOT_URL: https://${FORGEJO_DOMAIN}/
|
||||
FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN}
|
||||
FORGEJO__server__SSH_PORT: 2222
|
||||
FORGEJO__server__SSH_LISTEN_PORT: 2222
|
||||
FORGEJO__server__START_SSH_SERVER: "true"
|
||||
FORGEJO__server__HTTP_PORT: 3000
|
||||
FORGEJO__server__LFS_START_SERVER: "true"
|
||||
|
||||
# Security
|
||||
FORGEJO__security__INSTALL_LOCK: "true"
|
||||
FORGEJO__security__SECRET_KEY: ${FORGEJO_SECRET_KEY:?FORGEJO_SECRET_KEY required}
|
||||
FORGEJO__security__INTERNAL_TOKEN: ${FORGEJO_INTERNAL_TOKEN:?FORGEJO_INTERNAL_TOKEN required}
|
||||
FORGEJO__security__PASSWORD_COMPLEXITY: "lower,upper,digit"
|
||||
FORGEJO__security__MIN_PASSWORD_LENGTH: "12"
|
||||
|
||||
# OAuth2 JWT secret
|
||||
FORGEJO__oauth2__JWT_SECRET: ${FORGEJO_JWT_SECRET:?FORGEJO_JWT_SECRET required}
|
||||
|
||||
# Service settings
|
||||
FORGEJO__service__DISABLE_REGISTRATION: "false"
|
||||
FORGEJO__service__REQUIRE_SIGNIN_VIEW: "false"
|
||||
FORGEJO__service__ENABLE_NOTIFY_MAIL: "false"
|
||||
|
||||
# Actions (CI/CD)
|
||||
FORGEJO__actions__ENABLED: "true"
|
||||
FORGEJO__actions__DEFAULT_ACTIONS_URL: "https://code.forgejo.org"
|
||||
|
||||
# Repository settings
|
||||
FORGEJO__repository__DEFAULT_BRANCH: "main"
|
||||
FORGEJO__repository__ENABLE_PUSH_CREATE_USER: "true"
|
||||
FORGEJO__repository__ENABLE_PUSH_CREATE_ORG: "true"
|
||||
|
||||
# LFS settings
|
||||
FORGEJO__lfs__PATH: /data/lfs
|
||||
|
||||
# Webhook settings (for CI/CD)
|
||||
FORGEJO__webhook__ALLOWED_HOST_LIST: "private"
|
||||
FORGEJO__webhook__SKIP_TLS_VERIFY: "false"
|
||||
|
||||
# Log settings
|
||||
FORGEJO__log__MODE: "console"
|
||||
FORGEJO__log__LEVEL: "Info"
|
||||
|
||||
volumes:
|
||||
- /mnt/forgejo/forgejo-data:/data
|
||||
- /etc/timezone:/etc/timezone:ro
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
networks:
|
||||
- forgejo-internal
|
||||
- forgejo-public
|
||||
ports:
|
||||
# Git SSH - exposed publicly
|
||||
- "2222:2222"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/healthz"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Caddy Reverse Proxy
|
||||
# Automatic HTTPS with Let's Encrypt
|
||||
# ---------------------------------------------------------------------------
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
container_name: forgejo-caddy
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
forgejo:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
networks:
|
||||
- forgejo-public
|
||||
environment:
|
||||
FORGEJO_DOMAIN: ${FORGEJO_DOMAIN}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forgejo Actions Runner
|
||||
# For CI/CD pipelines
|
||||
# ---------------------------------------------------------------------------
|
||||
forgejo-runner:
|
||||
image: code.forgejo.org/forgejo/runner:6.3.1
|
||||
container_name: forgejo-runner
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
forgejo:
|
||||
condition: service_healthy
|
||||
docker-dind:
|
||||
condition: service_started
|
||||
environment:
|
||||
DOCKER_HOST: tcp://docker-dind:2376
|
||||
DOCKER_TLS_VERIFY: "1"
|
||||
DOCKER_CERT_PATH: /certs/client
|
||||
volumes:
|
||||
- /mnt/forgejo/runner-data:/data
|
||||
- dind-certs-client:/certs/client:ro
|
||||
networks:
|
||||
- forgejo-internal
|
||||
- dind-network
|
||||
command: >
|
||||
sh -c '
|
||||
if [ ! -f /data/.runner ]; then
|
||||
echo "Runner not registered. Please run registration command first."
|
||||
echo "See README for registration instructions."
|
||||
sleep infinity
|
||||
fi
|
||||
forgejo-runner daemon --config /data/config.yaml
|
||||
'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Docker-in-Docker for Runner
|
||||
# Allows building Docker images in CI/CD
|
||||
# ---------------------------------------------------------------------------
|
||||
docker-dind:
|
||||
image: docker:27-dind
|
||||
container_name: forgejo-dind
|
||||
restart: unless-stopped
|
||||
privileged: true
|
||||
environment:
|
||||
DOCKER_TLS_CERTDIR: /certs
|
||||
volumes:
|
||||
- dind-certs-ca:/certs/ca
|
||||
- dind-certs-client:/certs/client
|
||||
- dind-storage:/var/lib/docker
|
||||
networks:
|
||||
- dind-network
|
||||
# Resource limits for 1GB RAM droplet
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
|
||||
# =============================================================================
|
||||
# Networks
|
||||
# =============================================================================
|
||||
networks:
|
||||
forgejo-internal:
|
||||
driver: bridge
|
||||
internal: true
|
||||
forgejo-public:
|
||||
driver: bridge
|
||||
dind-network:
|
||||
driver: bridge
|
||||
|
||||
# =============================================================================
|
||||
# Volumes
|
||||
# =============================================================================
|
||||
volumes:
|
||||
caddy_data:
|
||||
caddy_config:
|
||||
dind-certs-ca:
|
||||
dind-certs-client:
|
||||
dind-storage:
|
||||
44
devops/terraform/.gitignore
vendored
Normal file
44
devops/terraform/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Local .terraform directories
|
||||
**/.terraform/*
|
||||
|
||||
# .tfstate files
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
|
||||
# Crash log files
|
||||
crash.log
|
||||
crash.*.log
|
||||
|
||||
# Exclude all .tfvars files, which are likely to contain sensitive data
|
||||
*.tfvars
|
||||
*.tfvars.json
|
||||
|
||||
# But include the example file
|
||||
!terraform.tfvars.example
|
||||
|
||||
# Ignore override files as they are usually used to override resources locally
|
||||
override.tf
|
||||
override.tf.json
|
||||
*_override.tf
|
||||
*_override.tf.json
|
||||
|
||||
# Ignore CLI configuration files
|
||||
.terraformrc
|
||||
terraform.rc
|
||||
|
||||
# Ignore lock file (optional - some teams commit this)
|
||||
# .terraform.lock.hcl
|
||||
|
||||
# Secrets directory
|
||||
.secrets/
|
||||
|
||||
# SSH keys
|
||||
*.pem
|
||||
*_key
|
||||
*.key
|
||||
id_*
|
||||
!*.pub
|
||||
|
||||
# Backup files
|
||||
*.backup
|
||||
*.bak
|
||||
106
devops/terraform/.terraform.lock.hcl
generated
Normal file
106
devops/terraform/.terraform.lock.hcl
generated
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# This file is maintained automatically by "terraform init".
|
||||
# Manual edits may be lost in future updates.
|
||||
|
||||
provider "registry.terraform.io/digitalocean/digitalocean" {
|
||||
version = "2.72.0"
|
||||
constraints = ">= 2.34.0, ~> 2.34"
|
||||
hashes = [
|
||||
"h1:Evhic408nTkCx4bGRFOvzYKkpuHTQOWRw5UdU0tjVFE=",
|
||||
"zh:06fd036391b1e67e33b9aeb6e717b3be7f3fa358192111ac6cebc6d71e69ae17",
|
||||
"zh:159fd52b64482467994faf7fa2988f53817f7e213ddf0a5e51de3d49375d9c36",
|
||||
"zh:16b91e919a2b6f49cd6882d4241b9e05480a3838804114208e60f1b47b29b8e6",
|
||||
"zh:39dfb113e810070fe932ab8906280ea9f81e7009e165b9933c48c5f0b9b30b9d",
|
||||
"zh:432b3ef2f5b3d06821cb4cb11d705125bd365d7d6fb08db4b3ddcd0354471c5b",
|
||||
"zh:4e9e286f148df4de33dd6a656e6be5586bc3e49c744bc5b6a315f2cf179c3803",
|
||||
"zh:60a56107c1b047dc8be17a6944d6098a1fea6894929874ea70ff59a7879f7252",
|
||||
"zh:7ef58ae0830af5559f0cb9f6cff260bf7bd4c120d31781ce1c6297fe9b149d6d",
|
||||
"zh:9623850701a5a1d7840a32451b48bc6423791814e98cde8a26a112e824102845",
|
||||
"zh:a73e4eae3c510de4c3882f301a4024efe1c733b9ef20492b85d367f04c83db1a",
|
||||
"zh:a8ac85aa1bec870b88c8ff15c7d682b6239cc7fd3ec43348c71a0c25e1bc8d18",
|
||||
"zh:d72c44619d38e471a1a13809c111b459396fcb61be97058287bb883b422973a3",
|
||||
"zh:d8f2751dd548e996890dd8b91ee834fbdd64aeeb41df1ef0ffbf97b03e07ce19",
|
||||
"zh:decf393b215330f62b4f43135dd4827c0c32b3b230e5d9cf78d973e6862043a8",
|
||||
"zh:ecfe43046837abc2b9b1eeac945d103513fc485d63f0c2cc39e71b52d9c417f8",
|
||||
"zh:f27c41e46fcd5a2d9faba3aa51a4769dad3f413ac65203fb96ff7a0c68a801e6",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/cloudinit" {
|
||||
version = "2.3.7"
|
||||
constraints = ">= 2.3.0, ~> 2.3"
|
||||
hashes = [
|
||||
"h1:h1Pr6uNwq+iDEGrnQJEHzOTz+yVTW0AJgZrGXuoO4Qs=",
|
||||
"zh:06f1c54e919425c3139f8aeb8fcf9bceca7e560d48c9f0c1e3bb0a8ad9d9da1e",
|
||||
"zh:0e1e4cf6fd98b019e764c28586a386dc136129fef50af8c7165a067e7e4a31d5",
|
||||
"zh:1871f4337c7c57287d4d67396f633d224b8938708b772abfc664d1f80bd67edd",
|
||||
"zh:2b9269d91b742a71b2248439d5e9824f0447e6d261bfb86a8a88528609b136d1",
|
||||
"zh:3d8ae039af21426072c66d6a59a467d51f2d9189b8198616888c1b7fc42addc7",
|
||||
"zh:3ef4e2db5bcf3e2d915921adced43929214e0946a6fb11793085d9a48995ae01",
|
||||
"zh:42ae54381147437c83cbb8790cc68935d71b6357728a154109d3220b1beb4dc9",
|
||||
"zh:4496b362605ae4cbc9ef7995d102351e2fe311897586ffc7a4a262ccca0c782a",
|
||||
"zh:652a2401257a12706d32842f66dac05a735693abcb3e6517d6b5e2573729ba13",
|
||||
"zh:7406c30806f5979eaed5f50c548eced2ea18ea121e01801d2f0d4d87a04f6a14",
|
||||
"zh:7848429fd5a5bcf35f6fee8487df0fb64b09ec071330f3ff240c0343fe2a5224",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/local" {
|
||||
version = "2.6.1"
|
||||
constraints = "~> 2.4"
|
||||
hashes = [
|
||||
"h1:Ey2jPUGfuahWUs8w82yeZKBMYqh9SM3e6J+EQt7QTKk=",
|
||||
"zh:10050d08f416de42a857e4b6f76809aae63ea4ec6f5c852a126a915dede814b4",
|
||||
"zh:2df2a3ebe9830d4759c59b51702e209fe053f47453cb4688f43c063bac8746b7",
|
||||
"zh:2e759568bcc38c86ca0e43701d34cf29945736fdc8e429c5b287ddc2703c7b18",
|
||||
"zh:6a62a34e48500ab4aea778e355e162ebde03260b7a9eb9edc7e534c84fbca4c6",
|
||||
"zh:74373728ba32a1d5450a3a88ac45624579e32755b086cd4e51e88d9aca240ef6",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:8dddae588971a996f622e7589cd8b9da7834c744ac12bfb59c97fa77ded95255",
|
||||
"zh:946f82f66353bb97aefa8d95c4ca86db227f9b7c50b82415289ac47e4e74d08d",
|
||||
"zh:e9a5c09e6f35e510acf15b666fd0b34a30164cecdcd81ce7cda0f4b2dade8d91",
|
||||
"zh:eafe5b873ef42b32feb2f969c38ff8652507e695620cbaf03b9db714bee52249",
|
||||
"zh:ec146289fa27650c9d433bb5c7847379180c0b7a323b1b94e6e7ad5d2a7dbe71",
|
||||
"zh:fc882c35ce05631d76c0973b35adde26980778fc81d9da81a2fade2b9d73423b",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/random" {
|
||||
version = "3.7.2"
|
||||
constraints = "~> 3.6"
|
||||
hashes = [
|
||||
"h1:0hcNr59VEJbhZYwuDE/ysmyTS0evkfcLarlni+zATPM=",
|
||||
"zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
|
||||
"zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
|
||||
"zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab",
|
||||
"zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3",
|
||||
"zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212",
|
||||
"zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f",
|
||||
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
|
||||
"zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34",
|
||||
"zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967",
|
||||
"zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d",
|
||||
"zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62",
|
||||
"zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0",
|
||||
]
|
||||
}
|
||||
|
||||
provider "registry.terraform.io/hashicorp/tls" {
|
||||
version = "4.1.0"
|
||||
constraints = ">= 4.0.0, ~> 4.0"
|
||||
hashes = [
|
||||
"h1:y9cHrgcuaZt592In6xQzz1lx7k/B9EeWrAb8K7QqOgU=",
|
||||
"zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2",
|
||||
"zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8",
|
||||
"zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc",
|
||||
"zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc",
|
||||
"zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac",
|
||||
"zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882",
|
||||
"zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d",
|
||||
"zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298",
|
||||
"zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297",
|
||||
"zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a",
|
||||
"zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c",
|
||||
"zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54",
|
||||
]
|
||||
}
|
||||
107
devops/terraform/main.tf
Normal file
107
devops/terraform/main.tf
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# =============================================================================
|
||||
# realms.india - DigitalOcean Infrastructure (Phase A: Jump Host + Forgejo)
|
||||
# =============================================================================
|
||||
|
||||
locals {
|
||||
common_tags = concat([
|
||||
var.project_name,
|
||||
var.environment,
|
||||
"terraform-managed"
|
||||
], var.tags)
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# VPC Module
|
||||
# =============================================================================
|
||||
|
||||
module "vpc" {
|
||||
source = "./modules/vpc"
|
||||
|
||||
name = "${var.project_name}-vpc-${var.environment}"
|
||||
region = var.region
|
||||
ip_range = var.vpc_ip_range
|
||||
description = "VPC for ${var.project_name} ${var.environment} environment"
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# SSH Keys Module
|
||||
# =============================================================================
|
||||
|
||||
module "ssh_keys" {
|
||||
source = "./modules/ssh_keys"
|
||||
|
||||
project_name = var.project_name
|
||||
environment = var.environment
|
||||
admin_ssh_public_keys = var.admin_ssh_public_keys
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Jump Host Module (Bastion)
|
||||
# =============================================================================
|
||||
|
||||
module "jump_host" {
|
||||
source = "./modules/jump_host"
|
||||
|
||||
project_name = var.project_name
|
||||
environment = var.environment
|
||||
region = var.region
|
||||
vpc_uuid = module.vpc.vpc_id
|
||||
vpc_ip_range = var.vpc_ip_range
|
||||
ssh_keys = module.ssh_keys.all_ssh_key_ids
|
||||
droplet_size = var.jump_host_size
|
||||
droplet_image = var.jump_host_image
|
||||
ssh_port = var.jump_host_ssh_port
|
||||
enable_backups = var.enable_droplet_backups
|
||||
tags = local.common_tags
|
||||
internal_private_key = module.ssh_keys.internal_private_key
|
||||
|
||||
depends_on = [module.vpc, module.ssh_keys]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Forgejo Module
|
||||
# =============================================================================
|
||||
|
||||
module "forgejo" {
|
||||
source = "./modules/forgejo"
|
||||
|
||||
project_name = var.project_name
|
||||
environment = var.environment
|
||||
region = var.region
|
||||
vpc_uuid = module.vpc.vpc_id
|
||||
vpc_ip_range = var.vpc_ip_range
|
||||
ssh_keys = module.ssh_keys.forgejo_ssh_key_ids
|
||||
droplet_size = var.forgejo_droplet_size
|
||||
droplet_image = var.forgejo_droplet_image
|
||||
volume_size = var.forgejo_volume_size
|
||||
ssh_port = var.forgejo_ssh_port
|
||||
git_ssh_port = var.forgejo_git_ssh_port
|
||||
domain = var.forgejo_domain
|
||||
enable_backups = var.enable_droplet_backups
|
||||
tags = local.common_tags
|
||||
|
||||
# DNS Configuration
|
||||
manage_dns = var.manage_dns
|
||||
dns_zone = var.dns_zone
|
||||
dns_record_name = "qbit" # Creates qbit.realms.pub
|
||||
|
||||
depends_on = [module.vpc, module.ssh_keys]
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# Firewalls Module
|
||||
# =============================================================================
|
||||
|
||||
module "firewalls" {
|
||||
source = "./modules/firewalls"
|
||||
|
||||
project_name = var.project_name
|
||||
environment = var.environment
|
||||
vpc_ip_range = var.vpc_ip_range
|
||||
jump_host_droplet_id = module.jump_host.droplet_id
|
||||
jump_host_ssh_port = var.jump_host_ssh_port
|
||||
forgejo_droplet_id = module.forgejo.droplet_id
|
||||
forgejo_git_ssh_port = var.forgejo_git_ssh_port
|
||||
|
||||
depends_on = [module.jump_host, module.forgejo]
|
||||
}
|
||||
187
devops/terraform/modules/firewalls/main.tf
Normal file
187
devops/terraform/modules/firewalls/main.tf
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
# =============================================================================
|
||||
# Firewalls Module
|
||||
# Defense in depth with DigitalOcean Cloud Firewalls
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Jump Host Firewall
|
||||
# Only allows SSH on non-standard port from anywhere
|
||||
# -----------------------------------------------------------------------------
|
||||
resource "digitalocean_firewall" "jump_host" {
|
||||
name = "${var.project_name}-${var.environment}-jump-fw"
|
||||
|
||||
droplet_ids = [var.jump_host_droplet_id]
|
||||
|
||||
# Inbound: SSH on non-standard port
|
||||
inbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = var.jump_host_ssh_port
|
||||
source_addresses = ["0.0.0.0/0", "::/0"]
|
||||
}
|
||||
|
||||
# Inbound: Allow all VPC traffic
|
||||
inbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "1-65535"
|
||||
source_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
|
||||
inbound_rule {
|
||||
protocol = "udp"
|
||||
port_range = "1-65535"
|
||||
source_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
|
||||
inbound_rule {
|
||||
protocol = "icmp"
|
||||
source_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
|
||||
# Outbound: Only necessary traffic (security hardening)
|
||||
outbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "53"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # DNS
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "udp"
|
||||
port_range = "53"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # DNS
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "80"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # HTTP (apt)
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "443"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # HTTPS
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "udp"
|
||||
port_range = "123"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # NTP
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "icmp"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"]
|
||||
}
|
||||
|
||||
# VPC outbound (all ports for internal communication)
|
||||
outbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "1-65535"
|
||||
destination_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "udp"
|
||||
port_range = "1-65535"
|
||||
destination_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Forgejo Firewall
|
||||
# Allows HTTP, HTTPS, and Git SSH from anywhere
|
||||
# System SSH only from VPC (handled by VPC rule)
|
||||
# -----------------------------------------------------------------------------
|
||||
resource "digitalocean_firewall" "forgejo" {
|
||||
name = "${var.project_name}-${var.environment}-forgejo-fw"
|
||||
|
||||
droplet_ids = [var.forgejo_droplet_id]
|
||||
|
||||
# Inbound: HTTP
|
||||
inbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "80"
|
||||
source_addresses = ["0.0.0.0/0", "::/0"]
|
||||
}
|
||||
|
||||
# Inbound: HTTPS
|
||||
inbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "443"
|
||||
source_addresses = ["0.0.0.0/0", "::/0"]
|
||||
}
|
||||
|
||||
# Inbound: Git SSH
|
||||
inbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = var.forgejo_git_ssh_port
|
||||
source_addresses = ["0.0.0.0/0", "::/0"]
|
||||
}
|
||||
|
||||
# Inbound: Allow all VPC traffic (includes system SSH on non-standard port)
|
||||
inbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "1-65535"
|
||||
source_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
|
||||
inbound_rule {
|
||||
protocol = "udp"
|
||||
port_range = "1-65535"
|
||||
source_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
|
||||
inbound_rule {
|
||||
protocol = "icmp"
|
||||
source_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
|
||||
# Outbound: Only necessary traffic (security hardening)
|
||||
outbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "53"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # DNS
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "udp"
|
||||
port_range = "53"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # DNS
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "80"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # HTTP (apt, Let's Encrypt)
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "443"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # HTTPS (Docker, webhooks)
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "udp"
|
||||
port_range = "123"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"] # NTP
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "icmp"
|
||||
destination_addresses = ["0.0.0.0/0", "::/0"]
|
||||
}
|
||||
|
||||
# VPC outbound (all ports for internal communication)
|
||||
outbound_rule {
|
||||
protocol = "tcp"
|
||||
port_range = "1-65535"
|
||||
destination_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
|
||||
outbound_rule {
|
||||
protocol = "udp"
|
||||
port_range = "1-65535"
|
||||
destination_addresses = [var.vpc_ip_range]
|
||||
}
|
||||
}
|
||||
13
devops/terraform/modules/firewalls/outputs.tf
Normal file
13
devops/terraform/modules/firewalls/outputs.tf
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# =============================================================================
|
||||
# Firewalls Module Outputs
|
||||
# =============================================================================
|
||||
|
||||
output "jump_host_firewall_id" {
|
||||
description = "ID of the jump host firewall"
|
||||
value = digitalocean_firewall.jump_host.id
|
||||
}
|
||||
|
||||
output "forgejo_firewall_id" {
|
||||
description = "ID of the Forgejo firewall"
|
||||
value = digitalocean_firewall.forgejo.id
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue