beeta/openresty/nginx.conf
doomtube 3c67397b7d
Some checks failed
Build and Push / build-all (push) Failing after 7m2s
Add public pyramid API routes to nginx config
The pyramid state, colors, face, and pixel info endpoints need
to be accessible without JWT authentication for loading the canvas.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 17:12:10 -05:00

1184 lines
No EOL
47 KiB
Nginx Configuration File

worker_processes auto;
error_log stderr warn;
# Run worker processes as nobody user (master process remains root for port binding)
user nobody nogroup;
# Expose environment variables to Lua workers
env REDIS_PASS;
env REDIS_PORT;
env REDIS_HOST;
env REDIS_DB;
env JWT_SECRET;
events {
worker_connections 1024;
}
http {
include mime.types;
default_type application/octet-stream;
# Temp directories
client_body_temp_path /var/cache/nginx/client_temp;
proxy_temp_path /var/cache/nginx/proxy_temp;
fastcgi_temp_path /var/cache/nginx/fastcgi_temp;
uwsgi_temp_path /var/cache/nginx/uwsgi_temp;
scgi_temp_path /var/cache/nginx/scgi_temp;
# Docker DNS resolver
resolver 127.0.0.11 valid=30s;
lua_package_path "/usr/local/openresty/nginx/lua/?.lua;;";
lua_shared_dict stream_keys 10m;
lua_shared_dict rate_limit 10m;
lua_shared_dict fingerprints 10m; # Server-side fingerprint cache
lua_shared_dict uberban_cache 1m; # Uberban status cache (5 second TTL)
# Initialize background thumbnail generator (runs every 10 minutes)
init_worker_by_lua_block {
local thumbnail = require "thumbnail"
thumbnail.init_worker()
}
# Enable compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml+rss;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# SECURITY FIX #17: Content Security Policy (application-wide)
# Note: 'unsafe-inline' required for style-src because Svelte uses inline styles for transitions and dynamic bindings
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.youtube.com https://s.ytimg.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; img-src 'self' data: https: blob:; media-src 'self' blob: https:; frame-src https://www.youtube.com https://www.youtube-nocookie.com; connect-src 'self' wss: ws:; font-src 'self' data: https://cdnjs.cloudflare.com;" always;
# Map to handle CORS origin properly
map $http_origin $cors_origin {
default $http_origin;
"" "";
}
# Map for WebSocket upgrade
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream backend {
server drogon-backend:8080;
}
upstream chat {
server chat-service:8081;
}
upstream frontend {
server sveltekit:3000;
}
upstream ome {
server ovenmediaengine:8080;
}
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth_limit:10m rate=3r/m;
limit_req_zone $binary_remote_addr zone=register_limit:10m rate=1r/m;
limit_req_zone $binary_remote_addr zone=chat_limit:10m rate=20r/s;
limit_req_zone $binary_remote_addr zone=avatar_limit:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=nakama_limit:10m rate=30r/s;
# SECURITY FIX #11: Rate limit viewer token requests
limit_req_zone $binary_remote_addr zone=viewer_token_limit:10m rate=10r/m;
# Increase client max body size for file uploads
# Default for most uploads (images, stickers)
client_max_body_size 5m;
# ==========================================================================
# HTTP Server - Redirect to HTTPS (except ACME challenges)
# ==========================================================================
server {
listen 80;
server_name _;
# ACME challenge endpoint for Let's Encrypt certificate validation
location /.well-known/acme-challenge/ {
root /var/www/certbot;
try_files $uri =404;
}
# Redirect all other HTTP traffic to HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# ==========================================================================
# HTTPS Server - Main application server
# ==========================================================================
server {
listen 443 ssl http2;
server_name beeta.realms.pub;
# SSL certificates (obtained by certbot on host, mounted via docker-compose)
ssl_certificate /etc/letsencrypt/live/beeta.realms.pub/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/beeta.realms.pub/privkey.pem;
# Modern SSL configuration
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
# Site-wide uberban check - blocks banned fingerprints from all endpoints
access_by_lua_block {
-- Skip OPTIONS requests (CORS preflight)
if ngx.req.get_method() == "OPTIONS" then
return
end
local uberban = require "uberban"
uberban.check_and_block()
}
# Security headers for the whole server
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# Fixed: Serve uploaded files with correct configuration
location /uploads/ {
# Use root directive with absolute path to avoid alias+try_files bug
root /app;
# Security settings
autoindex off;
# Only serve files, not directories
try_files $uri =404;
# Explicit MIME types for uploaded files
# SVG is safe when loaded via <img> tags (browsers sandbox them)
types {
image/jpeg jpg jpeg;
image/png png;
image/gif gif;
image/webp webp;
image/svg+xml svg svgz;
audio/mpeg mp3;
audio/wav wav;
audio/ogg ogg;
audio/mp4 m4a;
video/mp4 mp4 m4v;
video/webm webm;
video/quicktime mov;
}
default_type application/octet-stream;
# Security headers for all uploaded content
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
add_header X-Frame-Options "DENY" always;
# Cache static images
location ~* \.(jpg|jpeg|png|gif|webp|svg)$ {
expires 30d;
add_header Cache-Control "public, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
add_header X-Frame-Options "DENY" always;
}
# Cache audio files
location ~* \.(mp3|wav|ogg|m4a)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
add_header X-Frame-Options "DENY" always;
}
# Cache video files
location ~* \.(mp4|m4v|webm|mov)$ {
expires 7d;
add_header Cache-Control "public, max-age=604800" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Content-Security-Policy "default-src 'none'" always;
add_header X-Frame-Options "DENY" always;
add_header Accept-Ranges bytes;
}
}
# SvelteKit immutable assets (with content hashes)
location ~ ^/_app/immutable/ {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Long cache for immutable assets
proxy_cache_valid 200 1y;
expires 1y;
add_header Cache-Control "public, immutable" always;
access_log off;
}
# SvelteKit mutable assets
location ~ ^/_app/ {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Short cache for mutable assets
expires 1h;
add_header Cache-Control "public, max-age=3600" always;
}
# Authentication endpoints with strict rate limiting
location = /api/auth/register {
limit_req zone=register_limit burst=2 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /api/auth/login {
limit_req zone=auth_limit burst=5 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location ~ ^/api/auth/(pgp-challenge|pgp-verify) {
limit_req zone=auth_limit burst=5 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Public user profile endpoints - no authentication required
location ~ ^/api/users/[^/]+/?$ {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache public profiles for a short time
expires 1m;
add_header Cache-Control "public, max-age=60" always;
}
# Public user PGP keys endpoints - no authentication required
location ~ ^/api/users/[^/]+/pgp-keys$ {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache PGP keys for a bit longer as they change less frequently
expires 5m;
add_header Cache-Control "public, max-age=300" always;
}
# SECURITY FIX #11: Rate-limited viewer token endpoint
location ~ ^/api/realms/[0-9]+/viewer-token$ {
limit_req zone=viewer_token_limit burst=5 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# No cache for tokens
expires -1;
add_header Cache-Control "no-store, no-cache" always;
}
# Public site settings endpoint
location = /api/settings/site {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache site settings
expires 30s;
add_header Cache-Control "public, max-age=30" always;
}
# Public honk sound endpoint
location = /api/honk/active {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Short cache
expires 10s;
add_header Cache-Control "public, max-age=10" always;
}
# Public realm endpoints - includes single realm by ID (with viewer token authentication for stream-key)
location ~ ^/api/realms/(by-name/[^/]+|live|[0-9]+|[0-9]+/stats|[0-9]+/stream-key)$ {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Short cache for live realm data
expires 10s;
add_header Cache-Control "public, max-age=10" always;
}
# Public stream endpoints (some require viewer tokens)
location ~ ^/api/stream/(heartbeat/[^/]+)$ {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
}
# Chat service API endpoints
location /api/chat/ {
limit_req zone=chat_limit burst=30 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://chat;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Chat WebSocket endpoints
location /chat/ws {
# Inject server-side fingerprint header (cannot be spoofed by client)
access_by_lua_block {
local fingerprint = require "fingerprint"
fingerprint.inject_header()
}
proxy_pass http://chat;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
location ~ ^/chat/stream/(.+)$ {
# Inject server-side fingerprint header (cannot be spoofed by client)
access_by_lua_block {
local fingerprint = require "fingerprint"
fingerprint.inject_header()
}
proxy_pass http://chat;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Watch room sync WebSocket endpoint
location /watch/ws {
# Inject server-side fingerprint header (cannot be spoofed by client)
access_by_lua_block {
local fingerprint = require "fingerprint"
fingerprint.inject_header()
}
proxy_pass http://chat;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Ebook upload endpoint - larger body size limit
location = /api/user/ebooks {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# JWT validation at edge
access_by_lua_block {
local jwt_validator = require "jwt"
jwt_validator.validate_and_inject()
}
# Allow 100MB for ebook uploads
client_max_body_size 100m;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Extended timeout for large uploads
proxy_read_timeout 300s;
proxy_send_timeout 300s;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Audio upload endpoint - larger body size limit
location = /api/user/audio {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# JWT validation at edge
access_by_lua_block {
local jwt_validator = require "jwt"
jwt_validator.validate_and_inject()
}
# Allow 500MB for audio uploads
client_max_body_size 500m;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Extended timeout for large uploads
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Video upload endpoint - larger body size limit
location = /api/user/videos {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# JWT validation at edge
access_by_lua_block {
local jwt_validator = require "jwt"
jwt_validator.validate_and_inject()
}
# Allow 500MB for video uploads
client_max_body_size 500m;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Extended timeout for large uploads
proxy_read_timeout 600s;
proxy_send_timeout 600s;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Default avatar random endpoint - rate limited to prevent abuse
location = /api/default-avatar/random {
limit_req zone=avatar_limit burst=10 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Don't cache - ensures random avatar each time
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# Block internal API endpoints from external access (used for service-to-service communication)
location /api/internal/ {
return 403;
}
# Public stickers endpoint - no authentication required (guests need stickers for chat)
location = /api/admin/stickers {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache stickers list for 5 minutes
expires 5m;
add_header Cache-Control "public, max-age=300" always;
}
# Public watch room endpoints - guests can view playlist and add videos if allowed by settings
# Must be before the catch-all /api/ block to avoid JWT validation
location ~ ^/api/watch/[0-9]+/(playlist|state)$ {
limit_req zone=api_limit burst=20 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache" always;
}
# Public pyramid endpoints - no authentication required for canvas loading
location ~ ^/api/pyramid/(state|colors)$ {
limit_req zone=api_limit burst=20 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Short cache for pyramid state
expires 5s;
add_header Cache-Control "public, max-age=5" always;
}
# Public pyramid face endpoints
location ~ ^/api/pyramid/face/[0-4]$ {
limit_req zone=api_limit burst=20 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Short cache for face data
expires 5s;
add_header Cache-Control "public, max-age=5" always;
}
# Public pyramid pixel info endpoint
location ~ ^/api/pyramid/pixel/[0-4]/[0-9]+/[0-9]+$ {
limit_req zone=api_limit burst=20 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# No cache for pixel info
expires -1;
add_header Cache-Control "no-store, no-cache" always;
}
# Other API endpoints (authenticated)
location /api/ {
limit_req zone=api_limit burst=20 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# JWT validation at edge - reject invalid tokens before hitting backend
access_by_lua_block {
local jwt_validator = require "jwt"
jwt_validator.validate_and_inject()
}
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# Don't cache API responses
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
}
# WebSocket
location /ws/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Cookie $http_cookie;
# WebSocket timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# WebRTC Signaling proxy for OvenMediaEngine
# Handles wss:// → ws:// translation so OME doesn't need TLS certificates
location /webrtc/ {
# Proxy to OvenMediaEngine WebRTC signaling port
proxy_pass http://ovenmediaengine:3333/;
proxy_http_version 1.1;
proxy_buffering off;
# WebSocket upgrade headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebRTC signaling needs long timeouts
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Stream thumbnails - 3 second animated WebP generated on-demand via FFmpeg
# URL uses realm name for security, Lua looks up stream_key internally
location ~ ^/thumb/([^/]+)\.webp$ {
set $realm_name $1;
# CORS headers for preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# Generate and serve thumbnail via Lua
content_by_lua_block {
local thumbnail = require "thumbnail"
thumbnail.serve()
}
}
# Nakama Game Server HTTP API (/v2/ is the default path used by nakama-js)
# Uses runtime DNS resolution via variable to avoid startup failures
location /v2/ {
limit_req zone=nakama_limit burst=50 nodelay;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# Runtime DNS resolution - nakama may not be ready at nginx startup
set $nakama_backend nakama:7350;
proxy_pass http://$nakama_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Hide Nakama's CORS headers - nginx handles CORS
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Methods;
proxy_hide_header Access-Control-Allow-Headers;
proxy_hide_header Access-Control-Allow-Credentials;
expires -1;
add_header Cache-Control "no-store, no-cache" always;
}
# Nakama Game Server WebSocket (nakama-js connects to /ws with query params)
location = /ws {
# CORS headers for WebSocket upgrade request
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, Upgrade, Connection" always;
add_header Access-Control-Allow-Credentials "true" always;
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type, Authorization, Upgrade, Connection" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# Runtime DNS resolution
set $nakama_backend nakama:7350;
# Must include $is_args$args when using variables - nginx won't auto-append query string
proxy_pass http://$nakama_backend/ws$is_args$args;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Frontend (all other requests)
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Enable HTTP/1.1 for keep-alive
proxy_http_version 1.1;
proxy_set_header Connection "";
}
}
# Separate server block for port 8088 (HLS/LLHLS) - with SSL for production
server {
listen 8088 ssl http2;
server_name beeta.realms.pub;
# SSL certificates (same as main server block)
ssl_certificate /etc/letsencrypt/live/beeta.realms.pub/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/beeta.realms.pub/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
# Site-wide uberban check - blocks banned fingerprints from streaming
access_by_lua_block {
-- Skip OPTIONS requests (CORS preflight)
if ngx.req.get_method() == "OPTIONS" then
return
end
local uberban = require "uberban"
uberban.check_and_block()
}
# Security headers
add_header X-Content-Type-Options "nosniff" always;
# Token validation for HLS/LLHLS playlists and segments
location ~ ^/app/([^/]+)/(.*\.(m3u8|ts|m4s))$ {
set $stream_key $1;
set $file_path $2;
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Access-Control-Expose-Headers "Content-Length,Content-Range" always;
# Handle preflight
if ($request_method = 'OPTIONS') {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Headers "Range" always;
add_header Access-Control-Allow-Credentials "true" always;
add_header Content-Length 0;
add_header Content-Type text/plain;
return 204;
}
# Access control via Lua
# SECURITY FIX #24: Removed token value logging - sensitive data should not be in logs
access_by_lua_block {
local redis_helper = require "redis_helper"
-- Get viewer token from query parameter or cookie
-- Note: ngx.var.arg_token returns URL-encoded value, must decode it
local token = ngx.var.arg_token
if token then
token = ngx.unescape_uri(token)
end
-- If not in query parameter, try cookie
if not token then
local cookie_header = ngx.var.http_cookie
if cookie_header then
-- Extract viewer_token cookie
-- Handle URL-encoded cookies and spaces
cookie_header = ngx.unescape_uri(cookie_header)
for k, v in string.gmatch(cookie_header, "([^=]+)=([^;]+)") do
k = k:match("^%s*(.-)%s*$") -- trim whitespace
if k == "viewer_token" then
token = v:match("^%s*(.-)%s*$") -- trim whitespace
break
end
end
end
end
if not token then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.say("Missing viewer token")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Validate token
local valid_stream = redis_helper.validate_viewer_token(token, ngx.var.stream_key)
if not valid_stream then
ngx.status = ngx.HTTP_FORBIDDEN
ngx.say("Invalid viewer token")
return ngx.exit(ngx.HTTP_FORBIDDEN)
end
-- Refresh token TTL on segment access
redis_helper.refresh_viewer_token(token)
}
# Cache settings based on file type
# Segments (.ts, .m4s) - cache for 1 hour, playlists (.m3u8) - no cache
set $cache_control "no-cache, no-store, must-revalidate";
set $expires_time "-1";
if ($file_path ~ \.(ts|m4s)$) {
set $cache_control "public, max-age=3600";
set $expires_time "1h";
}
expires $expires_time;
add_header Cache-Control $cache_control always;
add_header X-Content-Type-Options "nosniff" always;
# Proxy to OvenMediaEngine (preserve query string for session parameter)
proxy_pass http://ome/app/$stream_key/$file_path$is_args$args;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Public access for stream info
location / {
# CORS headers
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Methods "GET, OPTIONS" always;
add_header Access-Control-Allow-Credentials "true" always;
proxy_pass http://ome;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}