From c2bcc865272334807c9aa7b40b5e7942b70b6cf4 Mon Sep 17 00:00:00 2001 From: doomtube Date: Thu, 8 Jan 2026 19:42:22 -0500 Subject: [PATCH] fixes lol --- backend/src/controllers/RealmController.cpp | 26 ++- backend/src/controllers/RealmController.h | 5 + frontend/src/lib/chat/chatStore.js | 3 +- .../src/lib/components/StreamPlayer.svelte | 4 +- .../lib/components/StreamTileOverlay.svelte | 6 +- .../src/lib/components/chat/ChatPanel.svelte | 32 ++- frontend/src/routes/+page.svelte | 30 ++- frontend/src/routes/[realm]/live/+page.svelte | 6 +- .../src/routes/[realm]/watch/+page.svelte | 4 + frontend/src/routes/chat/popout/+page.svelte | 19 +- openresty/lua/thumbnail.lua | 189 ++++++++++++------ openresty/nginx.conf | 11 +- 12 files changed, 252 insertions(+), 83 deletions(-) diff --git a/backend/src/controllers/RealmController.cpp b/backend/src/controllers/RealmController.cpp index b0aa432..ce730f1 100644 --- a/backend/src/controllers/RealmController.cpp +++ b/backend/src/controllers/RealmController.cpp @@ -818,7 +818,8 @@ void RealmController::getLiveRealms(const HttpRequestPtr &, std::function &&callback) { auto dbClient = app().getDbClient(); // SECURITY: Do NOT expose stream_key in public API - it allows stream hijacking - *dbClient << "SELECT r.name, r.viewer_count, r.viewer_multiplier, u.username, u.avatar_url " + *dbClient << "SELECT r.name, r.viewer_count, r.viewer_multiplier, r.offline_image_url, r.title_color, " + "u.username, u.avatar_url " "FROM realms r JOIN users u ON r.user_id = u.id " "WHERE r.is_live = true AND r.is_active = true " "ORDER BY (r.viewer_count * COALESCE(r.viewer_multiplier, 1)) DESC" @@ -842,6 +843,8 @@ void RealmController::getLiveRealms(const HttpRequestPtr &, realm["viewerCount"] = static_cast(displayCount); realm["username"] = row["username"].as(); realm["avatarUrl"] = row["avatar_url"].isNull() ? "" : row["avatar_url"].as(); + realm["offlineImageUrl"] = row["offline_image_url"].isNull() ? "" : row["offline_image_url"].as(); + realm["titleColor"] = row["title_color"].isNull() ? "#ffffff" : row["title_color"].as(); resp.append(realm); } @@ -1435,4 +1438,25 @@ void RealmController::updateTitleColor(const HttpRequestPtr &req, >> DB_ERROR(callback, "update title color"); } >> DB_ERROR(callback, "check realm ownership"); +} + +// Internal endpoint for Lua thumbnail generator to lookup stream key by realm name +void RealmController::getStreamKeyByRealmName(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmName) { + auto dbClient = app().getDbClient(); + + *dbClient << "SELECT stream_key FROM realms WHERE name = $1 AND is_live = true AND is_active = true" + << realmName + >> [callback](const Result& r) { + if (r.empty()) { + callback(jsonError("Realm not found or not live", k404NotFound)); + return; + } + + Json::Value resp; + resp["streamKey"] = r[0]["stream_key"].as(); + callback(jsonResp(resp)); + } + >> DB_ERROR(callback, "get stream key by realm name"); } \ No newline at end of file diff --git a/backend/src/controllers/RealmController.h b/backend/src/controllers/RealmController.h index b3107cf..74890aa 100644 --- a/backend/src/controllers/RealmController.h +++ b/backend/src/controllers/RealmController.h @@ -27,6 +27,7 @@ public: 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); + ADD_METHOD_TO(RealmController::getStreamKeyByRealmName, "/internal/realm-stream-key/{1}", Get); METHOD_LIST_END void getUserRealms(const HttpRequestPtr &req, @@ -105,4 +106,8 @@ public: void updateTitleColor(const HttpRequestPtr &req, std::function &&callback, const std::string &realmId); + + void getStreamKeyByRealmName(const HttpRequestPtr &req, + std::function &&callback, + const std::string &realmName); }; \ No newline at end of file diff --git a/frontend/src/lib/chat/chatStore.js b/frontend/src/lib/chat/chatStore.js index d489eb5..88cfc06 100644 --- a/frontend/src/lib/chat/chatStore.js +++ b/frontend/src/lib/chat/chatStore.js @@ -83,7 +83,8 @@ export const filteredMessages = derived( const prev = filtered[i - 1]; const sameUser = String(prev.userId) === String(msg.userId); const sameRealm = String(prev.realmId) === String(msg.realmId); - const showHeader = !sameUser || !sameRealm || msg.usedRoll || msg.usedRtd; + const hasSelfDestruct = msg.selfDestructAt && msg.selfDestructAt > 0; + const showHeader = !sameUser || !sameRealm || msg.usedRoll || msg.usedRtd || hasSelfDestruct; return { ...msg, showHeader }; }); } diff --git a/frontend/src/lib/components/StreamPlayer.svelte b/frontend/src/lib/components/StreamPlayer.svelte index 754df2b..66cdcfb 100644 --- a/frontend/src/lib/components/StreamPlayer.svelte +++ b/frontend/src/lib/components/StreamPlayer.svelte @@ -127,7 +127,7 @@ const sources = [ { type: 'hls', - file: `${proto}://${host}:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${viewerToken}`, + file: `${proto}://${host}:${STREAM_PORT}/app/${actualStreamKey}/llhls.m3u8?token=${encodeURIComponent(viewerToken)}`, label: 'LLHLS' } ]; @@ -151,7 +151,7 @@ // Only add token if not already present (segments don't have it) if (viewerToken && url.includes('/app/') && !url.includes('token=')) { const separator = url.includes('?') ? '&' : '?'; - xhr.open('GET', url + separator + 'token=' + viewerToken, true); + xhr.open('GET', url + separator + 'token=' + encodeURIComponent(viewerToken), true); } xhr.withCredentials = true; } diff --git a/frontend/src/lib/components/StreamTileOverlay.svelte b/frontend/src/lib/components/StreamTileOverlay.svelte index 089e47f..85eee67 100644 --- a/frontend/src/lib/components/StreamTileOverlay.svelte +++ b/frontend/src/lib/components/StreamTileOverlay.svelte @@ -143,7 +143,7 @@ const sources = [ { type: 'hls', - file: `${proto}://${host}:${STREAM_PORT}/app/${stream.streamKey}/llhls.m3u8?token=${token}`, + file: `${proto}://${host}:${STREAM_PORT}/app/${stream.streamKey}/llhls.m3u8?token=${encodeURIComponent(token)}`, label: 'LLHLS' } ]; @@ -163,9 +163,9 @@ lowLatencyMode: true, backBufferLength: 30, xhrSetup: function(xhr, url) { - if (token && url.includes('/app/')) { + if (token && url.includes('/app/') && !url.includes('token=')) { const separator = url.includes('?') ? '&' : '?'; - xhr.open('GET', url + separator + 'token=' + token, true); + xhr.open('GET', url + separator + 'token=' + encodeURIComponent(token), true); } xhr.withCredentials = true; } diff --git a/frontend/src/lib/components/chat/ChatPanel.svelte b/frontend/src/lib/components/chat/ChatPanel.svelte index ff579df..e6a9f11 100644 --- a/frontend/src/lib/components/chat/ChatPanel.svelte +++ b/frontend/src/lib/components/chat/ChatPanel.svelte @@ -16,7 +16,7 @@ } from '$lib/chat/chatStore'; import { chatWebSocket } from '$lib/chat/chatWebSocket'; import { chatLayout } from '$lib/stores/chatLayout'; - import { auth } from '$lib/stores/auth'; + import { auth, isAuthenticated } from '$lib/stores/auth'; import { ttsEnabled, ttsSettings, @@ -55,6 +55,7 @@ let honkAudio = null; let honkSoundUrl = null; let mentionedMessageIds = new Set(); // Track which messages have already played honk + let wasAuthenticated = false; // Track previous auth state for reconnect detection $: isConnected = $connectionStatus === 'connected'; @@ -63,6 +64,16 @@ chatWebSocket.getParticipants(); } + // Reconnect WebSocket when user logs in or registers while already connected as guest + $: { + const nowAuthenticated = $isAuthenticated; + if (nowAuthenticated && !wasAuthenticated && isConnected) { + console.log('[ChatPanel] Auth state changed to authenticated, reconnecting...'); + chatWebSocket.manualReconnect(); + } + wasAuthenticated = nowAuthenticated; + } + function toggleMenu() { showMenu = !showMenu; if (showMenu) { @@ -192,11 +203,17 @@ console.log('Chat connecting with realmId:', realmId, token ? '(authenticated)' : '(guest)'); chatWebSocket.connect(realmId, token); - // Function to scroll to bottom + // Function to scroll to newest messages (bottom for UP flow, top for DOWN flow) const scrollToBottom = () => { if (autoScroll && messagesContainer) { requestAnimationFrame(() => { - messagesContainer.scrollTop = messagesContainer.scrollHeight; + if ($chatLayout.messagesFromTop) { + // column-reverse: newest at top, scroll to top + messagesContainer.scrollTop = 0; + } else { + // normal: newest at bottom, scroll to bottom + messagesContainer.scrollTop = messagesContainer.scrollHeight; + } }); } }; @@ -257,7 +274,13 @@ function handleScroll() { const { scrollTop, scrollHeight, clientHeight } = messagesContainer; - autoScroll = scrollTop + clientHeight >= scrollHeight - 50; + if ($chatLayout.messagesFromTop) { + // column-reverse: user at "newest" means scrollTop near 0 + autoScroll = scrollTop <= 50; + } else { + // normal: user at bottom means scrollTop + clientHeight near scrollHeight + autoScroll = scrollTop + clientHeight >= scrollHeight - 50; + } } function handleDeleteMessage(messageId) { @@ -640,7 +663,6 @@
LIVE
- {#if stream.streamKey} - {stream.name} - {/if} + {stream.name} e.target.style.display = 'none'} + />
+ {#if stream.offlineImageUrl} + + {/if}
{stream.name.charAt(0).toUpperCase()} diff --git a/frontend/src/routes/[realm]/live/+page.svelte b/frontend/src/routes/[realm]/live/+page.svelte index 0043b0b..7756515 100644 --- a/frontend/src/routes/[realm]/live/+page.svelte +++ b/frontend/src/routes/[realm]/live/+page.svelte @@ -322,12 +322,12 @@ sources.push( { type: 'hls', - file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/llhls.m3u8?token=${viewerToken}`, + file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/llhls.m3u8?token=${encodeURIComponent(viewerToken)}`, label: 'LLHLS (Low Latency)' }, { type: 'hls', - file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8?token=${viewerToken}`, + file: `${httpProto}://${host}:${STREAM_PORT}/app/${streamKey}/ts:playlist.m3u8?token=${encodeURIComponent(viewerToken)}`, label: 'HLS (Standard)' }, { @@ -361,7 +361,7 @@ // Only add if token is not already present in the URL if (viewerToken && url.includes('/app/') && !url.includes('token=')) { const separator = url.includes('?') ? '&' : '?'; - xhr.open('GET', url + separator + 'token=' + viewerToken, true); + xhr.open('GET', url + separator + 'token=' + encodeURIComponent(viewerToken), true); } xhr.withCredentials = true; } diff --git a/frontend/src/routes/[realm]/watch/+page.svelte b/frontend/src/routes/[realm]/watch/+page.svelte index 4d20c41..c0a8bbd 100644 --- a/frontend/src/routes/[realm]/watch/+page.svelte +++ b/frontend/src/routes/[realm]/watch/+page.svelte @@ -27,6 +27,10 @@ // Helper to get auth token for WebSocket connections async function getAuthToken() { if (!browser) return null; + // Guests don't have auth tokens - skip the API call to avoid 401 error + if (!$auth.user) { + return null; + } try { const response = await fetch('/api/user/token', { credentials: 'include' }); if (response.ok) { diff --git a/frontend/src/routes/chat/popout/+page.svelte b/frontend/src/routes/chat/popout/+page.svelte index ba3a924..ae5c767 100644 --- a/frontend/src/routes/chat/popout/+page.svelte +++ b/frontend/src/routes/chat/popout/+page.svelte @@ -5,11 +5,13 @@ import ChatPanel from '$lib/components/chat/ChatPanel.svelte'; let realmId = null; + let ready = false; onMount(() => { // Parse realm ID from URL parameter const params = new URLSearchParams(window.location.search); - realmId = params.get('realm'); + realmId = params.get('realm') || ''; // Empty string for global chat + ready = true; }); @@ -18,7 +20,11 @@
- + {#if ready} + + {:else} +
Connecting to chat...
+ {/if}
diff --git a/openresty/lua/thumbnail.lua b/openresty/lua/thumbnail.lua index c11801d..080b4ea 100644 --- a/openresty/lua/thumbnail.lua +++ b/openresty/lua/thumbnail.lua @@ -1,7 +1,7 @@ local _M = {} local THUMB_DIR = "/tmp/thumbs" -local CACHE_TTL = 5 -- seconds before regenerating +local GENERATION_INTERVAL = 600 -- 10 minutes in seconds local FFMPEG_TIMEOUT = 15 -- seconds (needs more time for animated capture) local ANIMATION_DURATION = 3 -- seconds of video to capture local ANIMATION_FPS = 8 -- frames per second in output @@ -31,31 +31,72 @@ local function file_size(path) return 0 end -local function file_age(path) - -- Get file modification time using stat - local handle = io.popen("stat -c %Y " .. path .. " 2>/dev/null") - if not handle then - return nil - end - local mtime = handle:read("*a") - handle:close() +-- Lookup stream_key from realm name via internal API +local function get_stream_key_for_realm(realm_name) + local http = require "resty.http" + local httpc = http.new() - if not mtime or mtime == "" then + local res, err = httpc:request_uri("http://backend:3000/internal/realm-stream-key/" .. ngx.escape_uri(realm_name), { + method = "GET", + headers = { ["Content-Type"] = "application/json" } + }) + + if not res then + ngx.log(ngx.ERR, "Failed to lookup stream key for realm ", realm_name, ": ", err) return nil end - local now = os.time() - return now - tonumber(mtime) + if res.status ~= 200 then + return nil + end + + local cjson = require "cjson" + local ok, data = pcall(cjson.decode, res.body) + if not ok or not data.streamKey then + return nil + end + + return data.streamKey end -local function generate_thumbnail(stream_key, thumb_path) +-- Get all live realms from API +local function get_live_realms() + local http = require "resty.http" + local httpc = http.new() + + local res, err = httpc:request_uri("http://backend:3000/api/realms/live", { + method = "GET", + headers = { ["Content-Type"] = "application/json" } + }) + + if not res then + ngx.log(ngx.ERR, "Failed to get live realms: ", err) + return {} + end + + if res.status ~= 200 then + ngx.log(ngx.ERR, "Failed to get live realms: status ", res.status) + return {} + end + + local cjson = require "cjson" + local ok, realms = pcall(cjson.decode, res.body) + if not ok then + ngx.log(ngx.ERR, "Failed to parse live realms response") + return {} + end + + return realms +end + +-- Generate thumbnail for a single stream +local function generate_thumbnail(stream_key) -- Build LLHLS URL - internal docker network local llhls_url = "http://ovenmediaengine:8080/app/" .. stream_key .. "/llhls.m3u8" + local thumb_path = THUMB_DIR .. "/" .. stream_key .. ".webp" local log_file = THUMB_DIR .. "/" .. stream_key .. ".log" -- First, try to generate animated WebP - -- Using os.execute which blocks properly until complete - -- SECURITY FIX: Use shell_escape to prevent command injection local cmd = string.format( "timeout %d ffmpeg -y -i %s -t %d -vf 'fps=%d,scale=320:-1:flags=lanczos' -c:v libwebp -lossless 0 -compression_level 3 -q:v 70 -loop 0 -preset default -an %s > %s 2>&1", FFMPEG_TIMEOUT, @@ -80,7 +121,6 @@ local function generate_thumbnail(stream_key, thumb_path) -- If animated webp failed, try static webp (single frame) ngx.log(ngx.WARN, "Animated webp failed (exit: ", tostring(exit_code), "), trying static") - -- SECURITY FIX: Use shell_escape to prevent command injection local static_cmd = string.format( "timeout %d ffmpeg -y -i %s -vframes 1 -vf 'scale=320:-1:flags=lanczos' -c:v libwebp -q:v 75 %s > %s 2>&1", FFMPEG_TIMEOUT, @@ -97,70 +137,103 @@ local function generate_thumbnail(stream_key, thumb_path) return true end - -- Read log file for error info - local log_content = "" - local log_f = io.open(log_file, "r") - if log_f then - log_content = log_f:read("*a") or "" - log_f:close() - end - - ngx.log(ngx.ERR, "Thumbnail generation failed (exit: ", tostring(exit_code), "): ", log_content:sub(-500)) + ngx.log(ngx.ERR, "Thumbnail generation failed for stream: ", stream_key) return false end +-- Background job: Generate thumbnails for all live streams +local function generate_all_thumbnails(premature) + if premature then + return + end + + ngx.log(ngx.INFO, "Starting thumbnail generation for all live streams") + + -- Ensure thumb directory exists + os.execute("mkdir -p " .. THUMB_DIR) + + local realms = get_live_realms() + local count = 0 + + for _, realm in ipairs(realms) do + if realm.name then + local stream_key = get_stream_key_for_realm(realm.name) + if stream_key then + generate_thumbnail(stream_key) + count = count + 1 + end + end + end + + ngx.log(ngx.INFO, "Thumbnail generation complete. Generated ", count, " thumbnails") + + -- Schedule next run + local ok, err = ngx.timer.at(GENERATION_INTERVAL, generate_all_thumbnails) + if not ok then + ngx.log(ngx.ERR, "Failed to schedule next thumbnail generation: ", err) + end +end + +-- Initialize background thumbnail generation (call from init_worker_by_lua_block) +function _M.init_worker() + -- Only run on worker 0 to avoid duplicate work + if ngx.worker.id() ~= 0 then + return + end + + ngx.log(ngx.INFO, "Initializing thumbnail generator on worker 0") + + -- Ensure thumb directory exists + os.execute("mkdir -p " .. THUMB_DIR) + + -- Start first generation after 10 seconds (let services start up) + local ok, err = ngx.timer.at(10, generate_all_thumbnails) + if not ok then + ngx.log(ngx.ERR, "Failed to start thumbnail generator: ", err) + end +end + +-- Serve existing thumbnail (no generation on request) function _M.serve() - -- Get stream key from nginx variable - local stream_key = ngx.var.stream_key - if not stream_key or stream_key == "" then + -- Get realm name from nginx variable + local realm_name = ngx.var.realm_name + if not realm_name or realm_name == "" then ngx.status = 400 ngx.header["Content-Type"] = "text/plain" - ngx.say("Missing stream key") + ngx.say("Missing realm name") return ngx.exit(400) end - -- Sanitize stream key (alphanumeric, dash, underscore only) - if not stream_key:match("^[%w%-_]+$") then + -- Sanitize realm name (alphanumeric, dash only) + if not realm_name:match("^[%w%-]+$") then ngx.status = 400 ngx.header["Content-Type"] = "text/plain" - ngx.say("Invalid stream key") + ngx.say("Invalid realm name") return ngx.exit(400) end + -- Lookup stream key from realm name + local stream_key = get_stream_key_for_realm(realm_name) + if not stream_key then + ngx.status = 404 + ngx.header["Content-Type"] = "text/plain" + ngx.say("Realm not found or not live") + return ngx.exit(404) + end + local thumb_path = THUMB_DIR .. "/" .. stream_key .. ".webp" - -- Check if cached thumbnail exists and is fresh - local age = file_age(thumb_path) - local needs_refresh = not age or age > CACHE_TTL - - if needs_refresh then - -- Generate new thumbnail - local ok = generate_thumbnail(stream_key, thumb_path) - if not ok then - -- If generation failed, check if we have a stale one to serve - if not file_exists(thumb_path) or file_size(thumb_path) < 100 then - ngx.status = 503 - ngx.header["Content-Type"] = "text/plain" - ngx.header["Retry-After"] = "5" - ngx.say("Thumbnail generation in progress") - return ngx.exit(503) - end - -- Serve stale thumbnail - end - end - - -- Verify file exists and has content + -- Check if thumbnail exists if not file_exists(thumb_path) or file_size(thumb_path) < 100 then - ngx.status = 503 + ngx.status = 404 ngx.header["Content-Type"] = "text/plain" - ngx.header["Retry-After"] = "5" - ngx.say("Thumbnail not ready") - return ngx.exit(503) + ngx.say("Thumbnail not available") + return ngx.exit(404) end -- Serve the thumbnail file ngx.header["Content-Type"] = "image/webp" - ngx.header["Cache-Control"] = "public, max-age=" .. CACHE_TTL + ngx.header["Cache-Control"] = "public, max-age=60" -- Cache for 1 minute on client ngx.header["Access-Control-Allow-Origin"] = "*" local f = io.open(thumb_path, "rb") diff --git a/openresty/nginx.conf b/openresty/nginx.conf index 1a246d7..54e1d78 100644 --- a/openresty/nginx.conf +++ b/openresty/nginx.conf @@ -33,7 +33,13 @@ http { 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; @@ -827,8 +833,9 @@ http { } # 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 $stream_key $1; + set $realm_name $1; # CORS headers for preflight if ($request_method = 'OPTIONS') {