local _M = {} local THUMB_DIR = "/tmp/thumbs" local CACHE_TTL = 5 -- seconds before regenerating 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 -- SECURITY FIX: Shell escape function to prevent command injection local function shell_escape(str) -- Escape single quotes by ending the quoted string, adding escaped quote, and starting new quoted string return "'" .. string.gsub(str, "'", "'\\''") .. "'" end local function file_exists(path) local f = io.open(path, "r") if f then f:close() return true end return false end local function file_size(path) local f = io.open(path, "rb") if f then local size = f:seek("end") f:close() return size or 0 end 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() if not mtime or mtime == "" then return nil end local now = os.time() return now - tonumber(mtime) end local function generate_thumbnail(stream_key, thumb_path) -- Build LLHLS URL - internal docker network local llhls_url = "http://ovenmediaengine:8080/app/" .. stream_key .. "/llhls.m3u8" 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, shell_escape(llhls_url), ANIMATION_DURATION, ANIMATION_FPS, shell_escape(thumb_path), shell_escape(log_file) ) ngx.log(ngx.INFO, "Generating thumbnail for stream: ", stream_key) local exit_code = os.execute(cmd) -- Check if file was created and has content if file_exists(thumb_path) and file_size(thumb_path) > 100 then ngx.log(ngx.INFO, "Thumbnail generated successfully: ", thumb_path) os.remove(log_file) return true end -- 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, shell_escape(llhls_url), shell_escape(thumb_path), shell_escape(log_file) ) exit_code = os.execute(static_cmd) if file_exists(thumb_path) and file_size(thumb_path) > 100 then ngx.log(ngx.INFO, "Static thumbnail generated: ", thumb_path) os.remove(log_file) 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)) return false end function _M.serve() -- Get stream key from nginx variable local stream_key = ngx.var.stream_key if not stream_key or stream_key == "" then ngx.status = 400 ngx.header["Content-Type"] = "text/plain" ngx.say("Missing stream key") return ngx.exit(400) end -- Sanitize stream key (alphanumeric, dash, underscore only) if not stream_key:match("^[%w%-_]+$") then ngx.status = 400 ngx.header["Content-Type"] = "text/plain" ngx.say("Invalid stream key") return ngx.exit(400) 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 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 not ready") return ngx.exit(503) end -- Serve the thumbnail file ngx.header["Content-Type"] = "image/webp" ngx.header["Cache-Control"] = "public, max-age=" .. CACHE_TTL ngx.header["Access-Control-Allow-Origin"] = "*" local f = io.open(thumb_path, "rb") if not f then ngx.status = 500 ngx.header["Content-Type"] = "text/plain" ngx.say("Failed to read thumbnail") return ngx.exit(500) end local content = f:read("*a") f:close() ngx.print(content) end return _M