diff --git a/README.md b/README.md new file mode 100644 index 0000000..e40d5be --- /dev/null +++ b/README.md @@ -0,0 +1,383 @@ +# realms.india + +A self-hosted live streaming and gaming platform with real-time chat, game server integration, and media management. + +## Features + +- **Live Streaming** - RTMP, SRT, and WebRTC ingest with HLS/LLHLS output +- **Real-time Chat** - WebSocket-based chat with moderation tools +- **Game Server** - Nakama-powered multiplayer games and social features +- **Media Library** - E-book hosting and audio/video management +- **User Authentication** - JWT-based auth with optional PGP key support + +--- + +## System Requirements + +### Minimum +- 4GB RAM +- 2 CPU cores +- 20GB storage + +### Recommended +- 8GB+ RAM +- 4+ CPU cores +- 100GB+ SSD storage + +### Software +- Docker 24+ +- Docker Compose v2+ +- Git + +### Operating System +- **Linux** (Ubuntu 22.04 recommended) - best for production +- macOS +- Windows with WSL2 + +--- + +## Quick Start (Development) + +```bash +# Clone the repository +git clone +cd realms.india + +# Copy environment template +cp .env.example .env + +# Generate secrets and edit .env (see Configuration section below) +nano .env + +# Build and start all services +docker-compose up --build +``` + +Access the application at **http://localhost** + +--- + +## Configuration + +### Required Environment Variables + +Copy `.env.example` to `.env` and configure these required variables: + +| Variable | Description | How to Generate | +|----------|-------------|-----------------| +| `DB_PASSWORD` | PostgreSQL database password | `openssl rand -base64 24` | +| `JWT_SECRET` | Secret key for JWT token signing | `openssl rand -base64 32` | +| `OME_API_TOKEN` | OvenMediaEngine API authentication | `openssl rand -hex 32` | +| `REDIS_PASSWORD` | Redis authentication password | `openssl rand -base64 24` | +| `NAKAMA_SERVER_KEY` | Nakama client authentication key | `openssl rand -hex 16` | +| `NAKAMA_CONSOLE_PASSWORD` | Nakama admin console password | Choose a strong password | +| `VITE_NAKAMA_SERVER_KEY` | Frontend Nakama key (must match `NAKAMA_SERVER_KEY`) | Same as `NAKAMA_SERVER_KEY` | + +### Optional Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `DB_HOST` | `postgres` | PostgreSQL host | +| `DB_NAME` | `streaming` | Database name | +| `DB_USER` | `streamuser` | Database user | +| `DB_PORT` | `5432` | PostgreSQL port | +| `REDIS_HOST` | `redis` | Redis host | +| `REDIS_PORT` | `6379` | Redis port | +| `REDIS_DB` | `0` | Redis database number | +| `CHAT_REDIS_DB` | `1` | Chat service Redis database | +| `APP_ENV` | `production` | Environment mode (`development` or `production`) | +| `VITE_NAKAMA_HOST` | `localhost` | Nakama host for frontend | +| `VITE_NAKAMA_PORT` | `80` | Nakama port for frontend | +| `VITE_NAKAMA_USE_SSL` | `false` | Enable SSL for Nakama connection | + +### Generate All Secrets at Once + +```bash +echo "DB_PASSWORD=$(openssl rand -base64 24)" +echo "JWT_SECRET=$(openssl rand -base64 32)" +echo "OME_API_TOKEN=$(openssl rand -hex 32)" +echo "REDIS_PASSWORD=$(openssl rand -base64 24)" +NAKAMA_KEY=$(openssl rand -hex 16) +echo "NAKAMA_SERVER_KEY=$NAKAMA_KEY" +echo "VITE_NAKAMA_SERVER_KEY=$NAKAMA_KEY" +echo "NAKAMA_CONSOLE_PASSWORD=" +``` + +--- + +## Services Overview + +| Service | Description | Internal Port | +|---------|-------------|---------------| +| **postgres** | PostgreSQL 16 database for persistent storage | 5432 | +| **redis** | Redis 7 for caching, sessions, and message queues | 6379 | +| **drogon-backend** | Main API server (C++ Drogon) - streaming, users, realms | 8080 | +| **chat-service** | Real-time chat WebSocket server (C++ Drogon) | 8081 | +| **nakama** | Game server for multiplayer features | 7350 | +| **ovenmediaengine** | Media streaming server (RTMP/SRT/WebRTC ingest, HLS output) | 8081 (API) | +| **openresty** | Nginx-based reverse proxy with Lua scripting | 80, 443 | +| **sveltekit** | SvelteKit web frontend | 3000 | +| **certbot** | Automatic SSL certificate management | - | + +--- + +## Exposed Ports + +### Web Traffic +| Port | Protocol | Description | +|------|----------|-------------| +| 80 | TCP | HTTP | +| 443 | TCP | HTTPS | +| 8088 | TCP | HLS/LLHLS streaming proxy | + +### Streaming Ingest +| Port | Protocol | Description | +|------|----------|-------------| +| 1935 | TCP | RTMP ingest | +| 9999 | UDP | SRT ingest | + +### WebRTC +| Port | Protocol | Description | +|------|----------|-------------| +| 3333 | TCP | WebRTC signaling | +| 3478 | UDP | STUN/TURN server | +| 10000-10009 | UDP | WebRTC ICE candidates | + +--- + +## Streaming Setup + +### OBS Studio Configuration + +1. Open OBS Studio and go to **Settings > Stream** +2. Set **Service** to "Custom..." +3. Configure your stream: + +**RTMP:** +``` +Server: rtmp://your-domain:1935/app +Stream Key: +``` + +**SRT:** +``` +Server: srt://your-domain:9999 +``` + +### Getting Your Stream Key + +Stream keys are generated per-realm in the application. After creating a realm, you can find your stream key in the realm settings. + +--- + +## Production Deployment + +### Prerequisites + +1. A server with the recommended specifications +2. A domain name pointed to your server +3. Ports 80, 443, 1935, 9999, 3333, 3478, and 10000-10009 open in your firewall + +### Deployment Steps + +#### 1. Prepare the Server + +```bash +# Create application directory +sudo mkdir -p /opt/realms +cd /opt/realms + +# Copy required files +# - docker-compose.prod.yml (rename to docker-compose.yml) +# - .env (configured with your secrets) +# - init.sql (from database/init.sql) +# - Server.xml (from ovenmediaengine/Server.xml) +# - config.json (from backend/config.json.example) +``` + +#### 2. Configure Environment + +```bash +# Copy and configure environment +cp .env.example .env +nano .env + +# Update these for production: +# - Set all secrets (see Configuration section) +# - Set VITE_NAKAMA_HOST to your domain +# - Set VITE_NAKAMA_USE_SSL=true +# - Set APP_ENV=production +``` + +#### 3. Configure SSL + +The Certbot container handles automatic SSL certificate generation. Ensure: +- Your domain's DNS A record points to your server +- Ports 80 and 443 are accessible from the internet + +```bash +# Set your email for Let's Encrypt notifications +export CERTBOT_EMAIL=admin@your-domain.com +``` + +#### 4. Start Services + +```bash +# Pull images and start +docker-compose -f docker-compose.prod.yml up -d + +# Check status +docker-compose -f docker-compose.prod.yml ps + +# View logs +docker-compose -f docker-compose.prod.yml logs -f +``` + +### Firewall Configuration (UFW) + +```bash +# Allow web traffic +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp + +# Allow streaming +sudo ufw allow 1935/tcp # RTMP +sudo ufw allow 9999/udp # SRT +sudo ufw allow 8088/tcp # HLS proxy + +# Allow WebRTC +sudo ufw allow 3333/tcp # Signaling +sudo ufw allow 3478/udp # STUN/TURN +sudo ufw allow 10000:10009/udp # ICE candidates + +# Enable firewall +sudo ufw enable +``` + +### Updating + +```bash +cd /opt/realms + +# Pull latest images +docker-compose -f docker-compose.prod.yml pull + +# Restart with new images +docker-compose -f docker-compose.prod.yml up -d +``` + +--- + +## Troubleshooting + +### Health Checks + +Check if services are healthy: + +```bash +# View service status +docker-compose ps + +# Check specific service health +docker inspect --format='{{.State.Health.Status}}' realms-drogon-backend-1 +``` + +### Health Check Endpoints + +| Service | Endpoint | +|---------|----------| +| Backend API | `http://localhost:8080/api/health` | +| Chat Service | `http://localhost:8081/` | +| Nakama | `http://localhost:7350/healthcheck` | + +### Viewing Logs + +```bash +# All services +docker-compose logs -f + +# Specific service +docker-compose logs -f drogon-backend +docker-compose logs -f chat-service +docker-compose logs -f nakama + +# Last 100 lines +docker-compose logs --tail=100 drogon-backend +``` + +### Common Issues + +#### Services won't start +```bash +# Check for port conflicts +sudo lsof -i :80 +sudo lsof -i :443 + +# Verify .env file exists and has all required variables +cat .env | grep -v "^#" | grep -v "^$" + +# Rebuild containers +docker-compose down +docker-compose up --build +``` + +#### Database connection errors +```bash +# Check PostgreSQL is healthy +docker-compose logs postgres + +# Verify database credentials match .env +docker-compose exec postgres psql -U streamuser -d streaming -c "SELECT 1" +``` + +#### Stream not working +```bash +# Check OvenMediaEngine logs +docker-compose logs ovenmediaengine + +# Verify stream key is correct +# Check that RTMP port 1935 is accessible +nc -zv your-domain 1935 +``` + +#### SSL certificate issues +```bash +# Check Certbot logs +docker-compose logs certbot + +# Manually request certificate +docker-compose exec certbot certbot certonly --webroot \ + --webroot-path=/var/www/certbot \ + -d your-domain.com +``` + +### Reset Everything + +```bash +# Stop all containers and remove volumes (WARNING: deletes all data) +docker-compose down -v + +# Remove all images +docker-compose down --rmi all + +# Fresh start +docker-compose up --build +``` + +--- + +## Security Notes + +1. **Never commit `.env`** - Keep your secrets out of version control +2. **Rotate secrets regularly** - Recommended every 90 days +3. **Restrict file permissions** - `chmod 600 .env` +4. **Use strong passwords** - Minimum 16 characters with mixed case, numbers, and symbols +5. **Keep Docker updated** - Regularly update Docker and base images +6. **Enable firewall** - Only expose necessary ports +7. **Use HTTPS** - Always use SSL in production + +--- + +## License + +See LICENSE file for details. diff --git a/chat-service/src/controllers/ChatWebSocketController.cpp b/chat-service/src/controllers/ChatWebSocketController.cpp index 069251c..c47eb1d 100644 --- a/chat-service/src/controllers/ChatWebSocketController.cpp +++ b/chat-service/src/controllers/ChatWebSocketController.cpp @@ -952,7 +952,9 @@ void ChatWebSocketController::handleRename(const WebSocketConnectionPtr& wsConnP lastRenameTime_[wsConnPtr] = std::chrono::steady_clock::now(); // SECURITY FIX #31: Store guest name server-side for persistence - if (!info.fingerprint.empty()) { + // Only store for actual guests (isGuest check prevents authenticated users + // who haven't cleared fingerprint from storing their username) + if (info.isGuest && !info.fingerprint.empty()) { services::RedisMessageStore::getInstance().setGuestName(info.fingerprint, newName); } } @@ -1030,6 +1032,11 @@ void ChatWebSocketController::handleAuthMessage(const WebSocketConnectionPtr& ws it->second.isSiteModerator = claims->isModerator; // isModerator in claims is site-wide it->second.avatarUrl = claims->avatarUrl; + // SECURITY FIX: Clear fingerprint when upgrading to authenticated user + // This prevents the registered username from being stored against the fingerprint + // which would cause it to appear on guest sessions with the same fingerprint + it->second.fingerprint.clear(); + // Update usernameToConnection_ map: remove old guest name, add new authenticated name if (!oldUsername.empty()) { std::string lowerOld = oldUsername; diff --git a/chat-service/src/controllers/WatchSyncController.cpp b/chat-service/src/controllers/WatchSyncController.cpp index 6339b6d..3e82e9c 100644 --- a/chat-service/src/controllers/WatchSyncController.cpp +++ b/chat-service/src/controllers/WatchSyncController.cpp @@ -208,16 +208,28 @@ void WatchSyncController::broadcastRoomSync(const std::string& realmId) { // Check if video has ended and should auto-advance if (it->second.playbackState == "playing" && - it->second.durationSeconds > 0 && !it->second.currentVideoId.empty()) { double expectedTime = getExpectedTime(it->second); + + // Debug logging to diagnose end detection + static int debugCounter = 0; + if (++debugCounter % 10 == 0) { // Log every 10 sync cycles + LOG_DEBUG << "Room " << realmId << " sync check: expectedTime=" << expectedTime + << ", durationSeconds=" << it->second.durationSeconds + << ", videoId=" << it->second.currentVideoId + << ", locked=" << it->second.currentVideoLocked; + } + // Add 1 second buffer to account for timing variations - if (expectedTime >= static_cast(it->second.durationSeconds) + 1.0) { + if (it->second.durationSeconds > 0 && + expectedTime >= static_cast(it->second.durationSeconds) + 1.0) { shouldAutoAdvance = true; // Mark as ended to prevent multiple auto-advance calls it->second.playbackState = "ended"; it->second.stateVersion++; - LOG_INFO << "Video ended in room " << realmId << ", auto-advancing"; + LOG_INFO << "Video ended in room " << realmId << ", auto-advancing" + << " (expectedTime=" << expectedTime + << ", duration=" << it->second.durationSeconds << ")"; } } @@ -863,6 +875,12 @@ void WatchSyncController::handleJoinRoom(const WebSocketConnectionPtr& wsConnPtr state.currentPlaylistItemId = video["id"].asInt64(); state.currentVideoTitle = video["title"].asString(); state.durationSeconds = video["durationSeconds"].asInt(); + state.currentVideoLocked = video.isMember("isLocked") && video["isLocked"].asBool(); + + LOG_INFO << "Room " << newRealmId << " initialized: videoId=" << state.currentVideoId + << ", duration=" << state.durationSeconds + << ", locked=" << state.currentVideoLocked + << ", playbackState=" << state.playbackState; } } @@ -1486,6 +1504,20 @@ void WatchSyncController::handleUpdateDuration(const WebSocketConnectionPtr& wsC LOG_INFO << "Duration reported for playlist item " << playlistItemId << " in room " << info.realmId << ": " << durationSeconds << "s"; + // Update in-memory state immediately (don't wait for backend confirmation) + { + std::lock_guard lock(roomStatesMutex_); + auto it = roomStates_.find(info.realmId); + if (it != roomStates_.end() && + it->second.currentPlaylistItemId == playlistItemId) { + if (it->second.durationSeconds == 0 || it->second.durationSeconds != durationSeconds) { + LOG_INFO << "Updating in-memory duration from " << it->second.durationSeconds + << " to " << durationSeconds << " for room " << info.realmId; + it->second.durationSeconds = durationSeconds; + } + } + } + // Forward to backend API auto client = HttpClient::newHttpClient("http://drogon-backend:8080"); Json::Value reqBody; diff --git a/frontend/src/lib/components/watch/YouTubePlayer.svelte b/frontend/src/lib/components/watch/YouTubePlayer.svelte index 980d504..fe36025 100644 --- a/frontend/src/lib/components/watch/YouTubePlayer.svelte +++ b/frontend/src/lib/components/watch/YouTubePlayer.svelte @@ -120,17 +120,14 @@ } // Report duration when video starts playing (duration is now available) - // Only report if we haven't already for this playlist item + // Always report if we haven't already for this playlist item - server needs accurate duration const storeState = $watchSync; const playlistItemId = storeState.currentVideo?.id; - const storedDuration = storeState.currentVideo?.durationSeconds || 0; - if (playlistItemId && - playlistItemId !== durationReportedForItemId && - storedDuration === 0) { + if (playlistItemId && playlistItemId !== durationReportedForItemId) { const playerDuration = player.getDuration(); if (playerDuration > 0) { - console.log(`Reporting duration for playlist item ${playlistItemId}: ${playerDuration}s`); + console.log(`Reporting duration for playlist item ${playlistItemId}: ${Math.floor(playerDuration)}s`); watchSync.reportDuration(playlistItemId, playerDuration); durationReportedForItemId = playlistItemId; } diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 49928a2..34978f1 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -130,7 +130,7 @@ async function loadData() { loading = true; - await Promise.all([loadUsers(), loadStreams(), loadStickers(), loadHonkSounds(), loadChatSettings(), loadSiteSettings(), loadRealms(), loadStickerSubmissions(), loadVideos(), loadAudios(), loadEbooks(), loadDefaultAvatars(), loadBotApiKeys(), loadSSLSettings()]); + await Promise.all([loadUsers(), loadStreams(), loadStickers(), loadHonkSounds(), loadChatSettings(), loadSiteSettings(), loadRealms(), loadStickerSubmissions(), loadVideos(), loadAudios(), loadEbooks(), loadDefaultAvatars(), loadBotApiKeys(), loadSSLSettings(), loadUberbannedFingerprints()]); loading = false; }