181 lines
5.6 KiB
Lua
181 lines
5.6 KiB
Lua
|
|
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
|