Harden render server and import boundaries
This commit is contained in:
1 parent
5cc373cf6b
commit
fae55e3f13
11 files changed
+2400
-54
No files matched your search
+7
-2
@@ -10,11 +10,16 @@ RUN apt-get update && apt-get install -y \
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PORT=6000
|
||||
ENV HOST=127.0.0.1
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev
|
||||
COPY . .
|
||||
|
||||
RUN groupadd -r ruwio && useradd -r -g ruwio -d /app -s /usr/sbin/nologin ruwio \
|
||||
&& chown -R ruwio:ruwio /app
|
||||
USER ruwio
|
||||
|
||||
EXPOSE 6000
|
||||
CMD ["node", "server.js"]
|
||||
Generated
+2007
File diff suppressed because it is too large.
Load diff
@@ -9,7 +9,6 @@
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.2",
|
||||
"puppeteer-core": "^23.11.1"
|
||||
}
|
||||
|
||||
+240
-33
@@ -1,9 +1,8 @@
|
||||
import express from 'express'
|
||||
import cors from 'cors'
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import puppeteer from 'puppeteer-core'
|
||||
@@ -11,43 +10,77 @@ import puppeteer from 'puppeteer-core'
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
|
||||
const PORT = Number(process.env.PORT || 6000)
|
||||
const HOST = process.env.HOST || '127.0.0.1'
|
||||
const PORT = readIntEnv('PORT', 6000, 1, 65535)
|
||||
const CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
|
||||
const RENDER_HTML = path.join(__dirname, 'render.html')
|
||||
const RENDER_TOKEN = process.env.RENDER_TOKEN || ''
|
||||
const ALLOWED_ORIGINS = new Set(
|
||||
(process.env.RENDER_ALLOWED_ORIGINS ||
|
||||
'http://localhost:5200,http://127.0.0.1:5200,http://localhost:5173,http://127.0.0.1:5173,http://localhost:6000,http://127.0.0.1:6000')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
const BODY_LIMIT_BYTES = readSizeEnv('RENDER_MAX_BODY', 15 * 1024 * 1024)
|
||||
const MAX_ACTIVE_JOBS = readIntEnv('MAX_ACTIVE_JOBS', 1, 1, 16)
|
||||
const JOB_TIMEOUT_MS = readIntEnv('RENDER_JOB_TIMEOUT_MS', 2 * 60 * 1000, 1000, 60 * 60 * 1000)
|
||||
const JOB_TTL_MS = readIntEnv('RENDER_JOB_TTL_MS', 10 * 60 * 1000, 1000, 24 * 60 * 60 * 1000)
|
||||
const RATE_LIMIT_WINDOW_MS = readIntEnv('RENDER_RATE_WINDOW_MS', 60 * 1000, 1000, 60 * 60 * 1000)
|
||||
const RATE_LIMIT_MAX = readIntEnv('RENDER_RATE_MAX', 8, 1, 1000)
|
||||
const MAX_CANVAS_WIDTH = readIntEnv('RENDER_MAX_CANVAS_WIDTH', 4096, 1, 16384)
|
||||
const MAX_CANVAS_HEIGHT = readIntEnv('RENDER_MAX_CANVAS_HEIGHT', 4096, 1, 16384)
|
||||
const MAX_FPS = readIntEnv('RENDER_MAX_FPS', 60, 1, 240)
|
||||
const MAX_DURATION = readIntEnv('RENDER_MAX_DURATION', 60, 1, 60 * 60)
|
||||
const MAX_FRAMES = readIntEnv('RENDER_MAX_FRAMES', 3600, 1, 250000)
|
||||
const MAX_LAYERS = readIntEnv('RENDER_MAX_LAYERS', 500, 1, 100000)
|
||||
const MAX_KEYFRAMES = readIntEnv('RENDER_MAX_KEYFRAMES', 5000, 1, 1000000)
|
||||
const MAX_ASSETS = readIntEnv('RENDER_MAX_ASSETS', 500, 1, 100000)
|
||||
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json({ limit: '200mb' }))
|
||||
app.disable('x-powered-by')
|
||||
app.use(originGuard)
|
||||
app.use(rejectLargeBodies)
|
||||
app.use(express.json({ limit: BODY_LIMIT_BYTES }))
|
||||
|
||||
/** @type {Map<string, Job>} */
|
||||
const jobs = new Map()
|
||||
const rateBuckets = new Map()
|
||||
|
||||
app.get('/health', (_req, res) => res.json({ ok: true }))
|
||||
|
||||
app.post('/render', (req, res) => {
|
||||
app.post('/render', requireRenderToken, rateLimitRender, (req, res) => {
|
||||
if (activeJobCount() >= MAX_ACTIVE_JOBS) {
|
||||
return res.status(503).json({ error: 'render server is busy' })
|
||||
}
|
||||
const { scene, options } = req.body || {}
|
||||
if (!scene || !scene.canvas || !Array.isArray(scene.layers)) {
|
||||
return res.status(400).json({ error: 'invalid scene' })
|
||||
const validation = validateRenderRequest(scene, options || {})
|
||||
if (!validation.ok) {
|
||||
return res.status(400).json({ error: validation.error })
|
||||
}
|
||||
const job = {
|
||||
id: randomUUID(),
|
||||
accessToken: randomToken(),
|
||||
progress: 0,
|
||||
done: false,
|
||||
error: null,
|
||||
filePath: null,
|
||||
workDir: null,
|
||||
subscribers: new Set(),
|
||||
abort: new AbortController(),
|
||||
cleanupTimer: null,
|
||||
}
|
||||
jobs.set(job.id, job)
|
||||
res.json({ jobId: job.id })
|
||||
runJob(job, scene, options || {}).catch((err) => {
|
||||
res.json({ jobId: job.id, accessToken: job.accessToken })
|
||||
runJob(job, validation.scene, validation.options).catch((err) => {
|
||||
job.error = err?.message || String(err)
|
||||
job.done = true
|
||||
notify(job)
|
||||
scheduleCleanup(job, 0)
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/render/:id/events', (req, res) => {
|
||||
app.get('/render/:id/events', requireJobToken, (req, res) => {
|
||||
const job = jobs.get(req.params.id)
|
||||
if (!job) return res.status(404).end()
|
||||
res.writeHead(200, {
|
||||
@@ -60,7 +93,7 @@ app.get('/render/:id/events', (req, res) => {
|
||||
progress: job.progress,
|
||||
done: job.done,
|
||||
error: job.error,
|
||||
url: job.done && !job.error ? `/render/${job.id}/file` : null,
|
||||
url: job.done && !job.error ? `/render/${job.id}/file?token=${encodeURIComponent(job.accessToken)}` : null,
|
||||
}
|
||||
res.write(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
}
|
||||
@@ -73,19 +106,108 @@ app.get('/render/:id/events', (req, res) => {
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/render/:id/file', (req, res) => {
|
||||
app.get('/render/:id/file', requireJobToken, (req, res) => {
|
||||
const job = jobs.get(req.params.id)
|
||||
if (!job || !job.filePath || job.error) return res.status(404).end()
|
||||
res.download(job.filePath, 'animation.mp4')
|
||||
})
|
||||
|
||||
app.delete('/render/:id', (req, res) => {
|
||||
app.delete('/render/:id', requireJobToken, (req, res) => {
|
||||
const job = jobs.get(req.params.id)
|
||||
if (!job) return res.status(404).end()
|
||||
job.abort.abort()
|
||||
if (job.done) scheduleCleanup(job, 0)
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err?.type === 'entity.too.large') {
|
||||
return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` })
|
||||
}
|
||||
if (err instanceof SyntaxError && 'body' in err) {
|
||||
return res.status(400).json({ error: 'invalid JSON body' })
|
||||
}
|
||||
console.error(err)
|
||||
return res.status(500).json({ error: 'internal server error' })
|
||||
})
|
||||
|
||||
function originGuard(req, res, next) {
|
||||
const origin = req.get('Origin')
|
||||
if (origin) {
|
||||
if (!ALLOWED_ORIGINS.has(origin)) {
|
||||
return res.status(403).json({ error: 'origin is not allowed' })
|
||||
}
|
||||
res.setHeader('Access-Control-Allow-Origin', origin)
|
||||
res.setHeader('Vary', 'Origin')
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-RUWIO-Render-Token')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,DELETE,OPTIONS')
|
||||
}
|
||||
if (req.method === 'OPTIONS') return res.status(204).end()
|
||||
next()
|
||||
}
|
||||
|
||||
function rejectLargeBodies(req, res, next) {
|
||||
const len = Number(req.get('Content-Length') || 0)
|
||||
if (Number.isFinite(len) && len > BODY_LIMIT_BYTES) {
|
||||
return res.status(413).json({ error: `request body exceeds ${BODY_LIMIT_BYTES} bytes` })
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
function requireRenderToken(req, res, next) {
|
||||
if (!RENDER_TOKEN) {
|
||||
return res.status(503).json({ error: 'render server requires RENDER_TOKEN to be configured' })
|
||||
}
|
||||
const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || ''
|
||||
if (!secureEqual(token, RENDER_TOKEN)) return res.status(401).json({ error: 'invalid render token' })
|
||||
next()
|
||||
}
|
||||
|
||||
function requireJobToken(req, res, next) {
|
||||
const job = jobs.get(req.params.id)
|
||||
if (!job) return res.status(404).end()
|
||||
const token = bearerToken(req) || req.get('X-RUWIO-Render-Token') || String(req.query.token || '')
|
||||
if (!secureEqual(token, job.accessToken)) return res.status(401).json({ error: 'invalid job token' })
|
||||
next()
|
||||
}
|
||||
|
||||
function rateLimitRender(req, res, next) {
|
||||
const ip = req.ip || req.socket.remoteAddress || 'unknown'
|
||||
const now = Date.now()
|
||||
const bucket = rateBuckets.get(ip)
|
||||
if (!bucket || now - bucket.startedAt > RATE_LIMIT_WINDOW_MS) {
|
||||
rateBuckets.set(ip, { startedAt: now, count: 1 })
|
||||
return next()
|
||||
}
|
||||
bucket.count += 1
|
||||
if (bucket.count > RATE_LIMIT_MAX) return res.status(429).json({ error: 'too many render requests' })
|
||||
next()
|
||||
}
|
||||
|
||||
function bearerToken(req) {
|
||||
const auth = req.get('Authorization') || ''
|
||||
return auth.toLowerCase().startsWith('bearer ') ? auth.slice(7).trim() : ''
|
||||
}
|
||||
|
||||
function secureEqual(a, b) {
|
||||
if (!a || !b) return false
|
||||
const left = Buffer.from(String(a))
|
||||
const right = Buffer.from(String(b))
|
||||
return left.length === right.length && timingSafeEqual(left, right)
|
||||
}
|
||||
|
||||
function randomToken() {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
function activeJobCount() {
|
||||
let count = 0
|
||||
for (const job of jobs.values()) {
|
||||
if (!job.done) count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
function notify(job) {
|
||||
for (const sub of Array.from(job.subscribers)) {
|
||||
try { sub() } catch { /* noop */ }
|
||||
@@ -112,26 +234,18 @@ function playbackRange(scene) {
|
||||
}
|
||||
|
||||
async function runJob(job, scene, options) {
|
||||
const fps = Number(options.fps || scene.fps || 60)
|
||||
const { start: rangeIn, end: rangeOut } = playbackRange(scene)
|
||||
const rangeLen = Math.max(0, rangeOut - rangeIn)
|
||||
const duration = Number(options.duration || rangeLen)
|
||||
const totalFrames = Math.max(1, Math.round(duration * fps))
|
||||
const crf = String(options.crf ?? 18)
|
||||
const preset = String(options.preset ?? 'fast')
|
||||
const { start: rangeIn } = playbackRange(scene)
|
||||
const { fps, duration, totalFrames, crf, preset } = options
|
||||
|
||||
const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-'))
|
||||
job.workDir = workDir
|
||||
const outPath = path.join(workDir, 'out.mp4')
|
||||
const timeout = setTimeout(() => job.abort.abort(), JOB_TIMEOUT_MS)
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath: CHROME_PATH,
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--hide-scrollbars',
|
||||
],
|
||||
args: chromeArgs(),
|
||||
defaultViewport: {
|
||||
width: scene.canvas.width,
|
||||
height: scene.canvas.height,
|
||||
@@ -190,18 +304,111 @@ async function runJob(job, scene, options) {
|
||||
job.done = true
|
||||
notify(job)
|
||||
|
||||
setTimeout(() => cleanupJob(job.id, workDir).catch(() => {}), 10 * 60 * 1000)
|
||||
scheduleCleanup(job, JOB_TTL_MS)
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
if (!job.done) {
|
||||
try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
|
||||
}
|
||||
await browser.close().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupJob(jobId, workDir) {
|
||||
jobs.delete(jobId)
|
||||
await fs.rm(workDir, { recursive: true, force: true })
|
||||
function scheduleCleanup(job, delayMs) {
|
||||
if (job.cleanupTimer) clearTimeout(job.cleanupTimer)
|
||||
job.cleanupTimer = setTimeout(() => cleanupJob(job.id).catch(() => {}), delayMs)
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`animatr render server listening on :${PORT}`)
|
||||
async function cleanupJob(jobId) {
|
||||
const job = jobs.get(jobId)
|
||||
if (!job) return
|
||||
jobs.delete(jobId)
|
||||
if (job.workDir) await fs.rm(job.workDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
function validateRenderRequest(scene, options) {
|
||||
if (!scene || typeof scene !== 'object') return { ok: false, error: 'invalid scene' }
|
||||
if (!scene.canvas || typeof scene.canvas !== 'object') return { ok: false, error: 'invalid canvas' }
|
||||
const width = toBoundedInt(scene.canvas.width, 1, MAX_CANVAS_WIDTH)
|
||||
const height = toBoundedInt(scene.canvas.height, 1, MAX_CANVAS_HEIGHT)
|
||||
if (!width || !height) return { ok: false, error: `canvas must be between 1 and ${MAX_CANVAS_WIDTH}x${MAX_CANVAS_HEIGHT}` }
|
||||
if (!Array.isArray(scene.layers)) return { ok: false, error: 'scene layers must be an array' }
|
||||
if (scene.layers.length > MAX_LAYERS) return { ok: false, error: `scene has more than ${MAX_LAYERS} layers` }
|
||||
if (Array.isArray(scene.assets) && scene.assets.length > MAX_ASSETS) return { ok: false, error: `scene has more than ${MAX_ASSETS} assets` }
|
||||
let keyframes = 0
|
||||
for (const layer of scene.layers) {
|
||||
if (!Array.isArray(layer?.keyframes)) return { ok: false, error: 'every layer must have keyframes' }
|
||||
keyframes += layer.keyframes.length
|
||||
if (keyframes > MAX_KEYFRAMES) return { ok: false, error: `scene has more than ${MAX_KEYFRAMES} keyframes` }
|
||||
for (const kf of layer.keyframes) {
|
||||
if (!Number.isFinite(Number(kf?.time))) return { ok: false, error: 'keyframe times must be finite numbers' }
|
||||
}
|
||||
}
|
||||
const duration = Number(scene.duration)
|
||||
if (!Number.isFinite(duration) || duration <= 0 || duration > MAX_DURATION) {
|
||||
return { ok: false, error: `duration must be between 0 and ${MAX_DURATION} seconds` }
|
||||
}
|
||||
const fps = toBoundedInt(options.fps || scene.fps || 60, 1, MAX_FPS)
|
||||
if (!fps) return { ok: false, error: `fps must be between 1 and ${MAX_FPS}` }
|
||||
const range = playbackRange({ ...scene, canvas: { ...scene.canvas, width, height }, duration })
|
||||
const requestedDuration = Number(options.duration || Math.max(0, range.end - range.start))
|
||||
if (!Number.isFinite(requestedDuration) || requestedDuration <= 0 || requestedDuration > MAX_DURATION) {
|
||||
return { ok: false, error: `render duration must be between 0 and ${MAX_DURATION} seconds` }
|
||||
}
|
||||
const totalFrames = Math.max(1, Math.round(requestedDuration * fps))
|
||||
if (totalFrames > MAX_FRAMES) return { ok: false, error: `render has more than ${MAX_FRAMES} frames` }
|
||||
const crf = toBoundedInt(options.crf ?? 18, 0, 51)
|
||||
if (crf == null) return { ok: false, error: 'crf must be between 0 and 51' }
|
||||
const preset = String(options.preset ?? 'fast')
|
||||
const allowedPresets = new Set(['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'])
|
||||
if (!allowedPresets.has(preset)) return { ok: false, error: 'invalid ffmpeg preset' }
|
||||
return {
|
||||
ok: true,
|
||||
scene: { ...scene, canvas: { ...scene.canvas, width, height }, duration },
|
||||
options: { fps, duration: requestedDuration, totalFrames, crf: String(crf), preset },
|
||||
}
|
||||
}
|
||||
|
||||
function toBoundedInt(value, min, max) {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return null
|
||||
const i = Math.round(n)
|
||||
if (i < min || i > max) return null
|
||||
return i
|
||||
}
|
||||
|
||||
function chromeArgs() {
|
||||
const args = ['--disable-dev-shm-usage', '--hide-scrollbars']
|
||||
if (process.env.CHROME_NO_SANDBOX === '1') {
|
||||
console.warn('WARNING: CHROME_NO_SANDBOX=1 disables Chromium sandboxing for render jobs.')
|
||||
args.push('--no-sandbox', '--disable-setuid-sandbox')
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
function readIntEnv(name, fallback, min, max) {
|
||||
const raw = process.env[name]
|
||||
if (!raw) return fallback
|
||||
const n = Number.parseInt(raw, 10)
|
||||
if (!Number.isFinite(n) || n < min || n > max) {
|
||||
throw new Error(`${name} must be an integer between ${min} and ${max}`)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function readSizeEnv(name, fallback) {
|
||||
const raw = process.env[name]
|
||||
if (!raw) return fallback
|
||||
const match = raw.trim().match(/^(\d+)(b|kb|mb)?$/i)
|
||||
if (!match) throw new Error(`${name} must be a byte size like 1048576 or 15mb`)
|
||||
const amount = Number.parseInt(match[1], 10)
|
||||
const unit = (match[2] || 'b').toLowerCase()
|
||||
const mult = unit === 'mb' ? 1024 * 1024 : unit === 'kb' ? 1024 : 1
|
||||
return amount * mult
|
||||
}
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`animatr render server listening on ${HOST}:${PORT}`)
|
||||
console.log(`chromium: ${CHROME_PATH}`)
|
||||
if (!RENDER_TOKEN) console.warn('WARNING: RENDER_TOKEN is not set; POST /render is disabled.')
|
||||
})
|
||||
Reference in new issue
Block a user