Harden render server and import boundaries

This commit is contained in:
th committed 2026-06-05 20:05:32 +02:00
1 parent 5cc373cf6b
commit fae55e3f13
11 files changed
+2400 -54

No files matched your search

+28
View File
@@ -68,6 +68,34 @@ npm run desktop:build # build platform installer/package artifacts into release
app without creating an installer. `desktop:build` uses `electron-builder` and app without creating an installer. `desktop:build` uses `electron-builder` and
targets AppImage on Linux, NSIS on Windows, and DMG on macOS. targets AppImage on Linux, NSIS on Windows, and DMG on macOS.
## MP4 render server
MP4 export is handled by the optional render server in `server/`. It binds to
`127.0.0.1:6000` by default and requires a token before it will accept render
jobs.
```bash
cd server
npm ci
HOST=127.0.0.1 PORT=6000 RENDER_TOKEN="choose-a-long-random-token" npm start
```
Point the app at the server with matching Vite env values:
```bash
VITE_RENDER_SERVER=http://localhost:6000 VITE_RENDER_TOKEN="choose-a-long-random-token" npm run dev
```
The server exposes `HOST=0.0.0.0` only when explicitly requested. If you expose
it beyond localhost, set `RENDER_ALLOWED_ORIGINS` to the exact app origins that
should be allowed. Chromium sandboxing stays enabled by default; use
`CHROME_NO_SANDBOX=1` only for a deployment environment that cannot run the
sandbox.
The included `docker-compose.yml` keeps the published host port on
`127.0.0.1:6000` and requires `RENDER_TOKEN` in the environment before it will
start.
## Stack ## Stack
- Vue 3 (`<script setup>`) + Pinia state - Vue 3 (`<script setup>`) + Pinia state
+6 -1
View File
@@ -2,9 +2,14 @@ services:
animatr-render: animatr-render:
build: ./server build: ./server
ports: ports:
- "6000:6000" - "127.0.0.1:6000:6000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=6000 - PORT=6000
- HOST=0.0.0.0
- RENDER_TOKEN=${RENDER_TOKEN:?set RENDER_TOKEN before starting the render server}
shm_size: "2gb" shm_size: "2gb"
pids_limit: 256
mem_limit: 2g
cpus: 2
restart: unless-stopped restart: unless-stopped
+26 -3
View File
@@ -1,9 +1,11 @@
import { app, BrowserWindow, Menu, shell } from 'electron' import { app, BrowserWindow, Menu, shell } from 'electron'
import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron' import type { BrowserWindow as BrowserWindowInstance, Event, MenuItemConstructorOptions } from 'electron'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url'
const isDev = !app.isPackaged const isDev = !app.isPackaged
const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200' const devServerUrl = process.env.VITE_DEV_SERVER_URL ?? 'http://localhost:5200'
const devServerOrigin = new URL(devServerUrl).origin
const appIconPath = isDev const appIconPath = isDev
? path.join(process.cwd(), 'build', 'icon.png') ? path.join(process.cwd(), 'build', 'icon.png')
: path.join(process.resourcesPath, 'build', 'icon.png') : path.join(process.resourcesPath, 'build', 'icon.png')
@@ -15,6 +17,28 @@ function sendAppCommand(command: string): void {
mainWindow.webContents.send('ruwio:menu-command', command) mainWindow.webContents.send('ruwio:menu-command', command)
} }
function parseUrl(raw: string): URL | null {
try {
return new URL(raw)
} catch {
return null
}
}
function isAllowedAppNavigation(raw: string): boolean {
const url = parseUrl(raw)
if (!url) return false
if (isDev) return url.origin === devServerOrigin
if (url.protocol !== 'file:') return false
const appEntry = path.join(app.getAppPath(), 'dist', 'index.html')
return path.normalize(fileURLToPath(url)) === path.normalize(appEntry)
}
function isAllowedExternalUrl(raw: string): boolean {
const url = parseUrl(raw)
return url?.protocol === 'https:'
}
function createMainWindow(): void { function createMainWindow(): void {
mainWindow = new BrowserWindow({ mainWindow = new BrowserWindow({
width: 1280, width: 1280,
@@ -38,15 +62,14 @@ function createMainWindow(): void {
}) })
mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => { mainWindow.webContents.setWindowOpenHandler(({ url }: { url: string }) => {
if (url.startsWith('https://') || url.startsWith('http://')) { if (isAllowedExternalUrl(url)) {
void shell.openExternal(url) void shell.openExternal(url)
} }
return { action: 'deny' } return { action: 'deny' }
}) })
mainWindow.webContents.on('will-navigate', (event: Event, url: string) => { mainWindow.webContents.on('will-navigate', (event: Event, url: string) => {
const allowedUrl = isDev ? devServerUrl : `file://${path.join(app.getAppPath(), 'dist', 'index.html')}` if (!isAllowedAppNavigation(url)) {
if (!url.startsWith(allowedUrl)) {
event.preventDefault() event.preventDefault()
} }
}) })
+4
View File
@@ -7,6 +7,10 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#17171b" /> <meta name="theme-color" content="#17171b" />
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http://localhost:6000 http://127.0.0.1:6000 ws://localhost:5200 ws://127.0.0.1:5200; object-src 'none'; base-uri 'self'; frame-ancestors 'none'"
/>
<title>RUWIO</title> <title>RUWIO</title>
</head> </head>
<body> <body>
+7 -2
View File
@@ -10,11 +10,16 @@ RUN apt-get update && apt-get install -y \
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV PORT=6000 ENV PORT=6000
ENV HOST=127.0.0.1
WORKDIR /app WORKDIR /app
COPY package.json ./ COPY package.json package-lock.json ./
RUN npm install --omit=dev RUN npm ci --omit=dev
COPY . . 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 EXPOSE 6000
CMD ["node", "server.js"] CMD ["node", "server.js"]
+2007
View File
File diff suppressed because it is too large. Load diff
-1
View File
@@ -9,7 +9,6 @@
"dev": "node server.js" "dev": "node server.js"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5",
"express": "^4.21.2", "express": "^4.21.2",
"puppeteer-core": "^23.11.1" "puppeteer-core": "^23.11.1"
} }
+240 -33
View File
@@ -1,9 +1,8 @@
import express from 'express' import express from 'express'
import cors from 'cors'
import path from 'node:path' import path from 'node:path'
import fs from 'node:fs/promises' import fs from 'node:fs/promises'
import os from 'node:os' import os from 'node:os'
import { randomUUID } from 'node:crypto' import { randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import puppeteer from 'puppeteer-core' import puppeteer from 'puppeteer-core'
@@ -11,43 +10,77 @@ import puppeteer from 'puppeteer-core'
const __filename = fileURLToPath(import.meta.url) const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename) 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 CHROME_PATH = process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium'
const RENDER_HTML = path.join(__dirname, 'render.html') 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() const app = express()
app.use(cors()) app.disable('x-powered-by')
app.use(express.json({ limit: '200mb' })) app.use(originGuard)
app.use(rejectLargeBodies)
app.use(express.json({ limit: BODY_LIMIT_BYTES }))
/** @type {Map<string, Job>} */ /** @type {Map<string, Job>} */
const jobs = new Map() const jobs = new Map()
const rateBuckets = new Map()
app.get('/health', (_req, res) => res.json({ ok: true })) 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 || {} const { scene, options } = req.body || {}
if (!scene || !scene.canvas || !Array.isArray(scene.layers)) { const validation = validateRenderRequest(scene, options || {})
return res.status(400).json({ error: 'invalid scene' }) if (!validation.ok) {
return res.status(400).json({ error: validation.error })
} }
const job = { const job = {
id: randomUUID(), id: randomUUID(),
accessToken: randomToken(),
progress: 0, progress: 0,
done: false, done: false,
error: null, error: null,
filePath: null, filePath: null,
workDir: null,
subscribers: new Set(), subscribers: new Set(),
abort: new AbortController(), abort: new AbortController(),
cleanupTimer: null,
} }
jobs.set(job.id, job) jobs.set(job.id, job)
res.json({ jobId: job.id }) res.json({ jobId: job.id, accessToken: job.accessToken })
runJob(job, scene, options || {}).catch((err) => { runJob(job, validation.scene, validation.options).catch((err) => {
job.error = err?.message || String(err) job.error = err?.message || String(err)
job.done = true job.done = true
notify(job) 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) const job = jobs.get(req.params.id)
if (!job) return res.status(404).end() if (!job) return res.status(404).end()
res.writeHead(200, { res.writeHead(200, {
@@ -60,7 +93,7 @@ app.get('/render/:id/events', (req, res) => {
progress: job.progress, progress: job.progress,
done: job.done, done: job.done,
error: job.error, 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`) 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) const job = jobs.get(req.params.id)
if (!job || !job.filePath || job.error) return res.status(404).end() if (!job || !job.filePath || job.error) return res.status(404).end()
res.download(job.filePath, 'animation.mp4') 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) const job = jobs.get(req.params.id)
if (!job) return res.status(404).end() if (!job) return res.status(404).end()
job.abort.abort() job.abort.abort()
if (job.done) scheduleCleanup(job, 0)
res.json({ ok: true }) 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) { function notify(job) {
for (const sub of Array.from(job.subscribers)) { for (const sub of Array.from(job.subscribers)) {
try { sub() } catch { /* noop */ } try { sub() } catch { /* noop */ }
@@ -112,26 +234,18 @@ function playbackRange(scene) {
} }
async function runJob(job, scene, options) { async function runJob(job, scene, options) {
const fps = Number(options.fps || scene.fps || 60) const { start: rangeIn } = playbackRange(scene)
const { start: rangeIn, end: rangeOut } = playbackRange(scene) const { fps, duration, totalFrames, crf, preset } = options
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 workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-')) const workDir = await fs.mkdtemp(path.join(os.tmpdir(), 'animatr-'))
job.workDir = workDir
const outPath = path.join(workDir, 'out.mp4') const outPath = path.join(workDir, 'out.mp4')
const timeout = setTimeout(() => job.abort.abort(), JOB_TIMEOUT_MS)
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
executablePath: CHROME_PATH, executablePath: CHROME_PATH,
headless: 'new', headless: 'new',
args: [ args: chromeArgs(),
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--hide-scrollbars',
],
defaultViewport: { defaultViewport: {
width: scene.canvas.width, width: scene.canvas.width,
height: scene.canvas.height, height: scene.canvas.height,
@@ -190,18 +304,111 @@ async function runJob(job, scene, options) {
job.done = true job.done = true
notify(job) notify(job)
setTimeout(() => cleanupJob(job.id, workDir).catch(() => {}), 10 * 60 * 1000) scheduleCleanup(job, JOB_TTL_MS)
} finally { } finally {
clearTimeout(timeout)
if (!job.done) {
try { ffmpeg.kill('SIGKILL') } catch { /* noop */ }
}
await browser.close().catch(() => {}) await browser.close().catch(() => {})
} }
} }
async function cleanupJob(jobId, workDir) { function scheduleCleanup(job, delayMs) {
jobs.delete(jobId) if (job.cleanupTimer) clearTimeout(job.cleanupTimer)
await fs.rm(workDir, { recursive: true, force: true }) job.cleanupTimer = setTimeout(() => cleanupJob(job.id).catch(() => {}), delayMs)
} }
app.listen(PORT, () => { async function cleanupJob(jobId) {
console.log(`animatr render server listening on :${PORT}`) 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}`) console.log(`chromium: ${CHROME_PATH}`)
if (!RENDER_TOKEN) console.warn('WARNING: RENDER_TOKEN is not set; POST /render is disabled.')
}) })
+7 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, defineAsyncComponent, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { import {
PhArrowClockwise, PhArrowClockwise,
PhArrowCounterClockwise, PhArrowCounterClockwise,
@@ -35,7 +35,6 @@ import SvgLayerPanel from './components/SvgLayerPanel.vue'
import Timeline from './components/Timeline.vue' import Timeline from './components/Timeline.vue'
import TooltipHost from './components/TooltipHost.vue' import TooltipHost from './components/TooltipHost.vue'
import WelcomeDialog from './components/WelcomeDialog.vue' import WelcomeDialog from './components/WelcomeDialog.vue'
import TestOverlay from './devtools/TestOverlay.vue'
import ruwioLogo from './assets/logo.png' import ruwioLogo from './assets/logo.png'
import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui' import { confirmDialog, DialogsHost, IconButton, promptDialog, Toast } from './components/ui'
import { t } from './i18n' import { t } from './i18n'
@@ -71,6 +70,9 @@ const welcomeOpen = ref(false)
const fullscreenOpen = ref(false) const fullscreenOpen = ref(false)
const aboutOpen = ref(false) const aboutOpen = ref(false)
const helpOpen = ref(false) const helpOpen = ref(false)
const TestOverlay = import.meta.env.DEV
? defineAsyncComponent(() => import('./devtools/TestOverlay.vue'))
: null
const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight' const TIMELINE_HEIGHT_KEY = 'animatr:timelineHeight'
const DEFAULT_TIMELINE_HEIGHT = 220 const DEFAULT_TIMELINE_HEIGHT = 220
const MIN_TIMELINE_HEIGHT = 120 const MIN_TIMELINE_HEIGHT = 120
@@ -422,7 +424,7 @@ function nudgeSelection(dx: number, dy: number): boolean {
function onKeyDown(e: KeyboardEvent) { function onKeyDown(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) { if (e.ctrlKey && e.shiftKey && e.altKey && (e.code === 'KeyT' || e.key.toLowerCase() === 't')) {
e.preventDefault() e.preventDefault()
testsOpen.value = true if (import.meta.env.DEV) testsOpen.value = true
return return
} }
// TAB toggles the side panels (asset sidebar + properties aside) // TAB toggles the side panels (asset sidebar + properties aside)
@@ -610,7 +612,7 @@ async function runDesktopMenuCommand(command: string) {
aboutOpen.value = true aboutOpen.value = true
break break
case 'test-runner': case 'test-runner':
testsOpen.value = true if (import.meta.env.DEV) testsOpen.value = true
break break
} }
} }
@@ -803,7 +805,7 @@ onBeforeUnmount(() => {
</footer> </footer>
<DocumentSettings v-model="settingsOpen" /> <DocumentSettings v-model="settingsOpen" />
<ExportDialog v-model="exportOpen" /> <ExportDialog v-model="exportOpen" />
<TestOverlay v-model="testsOpen" /> <component :is="TestOverlay" v-if="TestOverlay" v-model="testsOpen" />
<FullscreenPreview v-model="fullscreenOpen" /> <FullscreenPreview v-model="fullscreenOpen" />
<AboutDialog v-model="aboutOpen" /> <AboutDialog v-model="aboutOpen" />
<HelpDialog v-model="helpOpen" /> <HelpDialog v-model="helpOpen" />
+10 -5
View File
@@ -2,6 +2,7 @@ import type { Scene } from '@/types/scene'
const DEFAULT_SERVER = 'http://localhost:6000' const DEFAULT_SERVER = 'http://localhost:6000'
const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER const SERVER_URL = (import.meta.env.VITE_RENDER_SERVER as string | undefined) ?? DEFAULT_SERVER
const RENDER_TOKEN = import.meta.env.VITE_RENDER_TOKEN as string | undefined
export interface ServerRenderOptions { export interface ServerRenderOptions {
onProgress?: (progress: number) => void onProgress?: (progress: number) => void
@@ -27,7 +28,10 @@ export async function renderOnServer(
const baseUrl = SERVER_URL const baseUrl = SERVER_URL
const startRes = await fetch(`${baseUrl}/render`, { const startRes = await fetch(`${baseUrl}/render`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(RENDER_TOKEN ? { Authorization: `Bearer ${RENDER_TOKEN}` } : {}),
},
body: JSON.stringify({ body: JSON.stringify({
scene, scene,
options: { options: {
@@ -42,18 +46,19 @@ export async function renderOnServer(
const body = await startRes.text().catch(() => '') const body = await startRes.text().catch(() => '')
throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`) throw new Error(`Server start failed (${startRes.status}): ${body.slice(0, 200)}`)
} }
const { jobId } = (await startRes.json()) as { jobId: string } const { jobId, accessToken } = (await startRes.json()) as { jobId: string; accessToken: string }
if (!jobId) throw new Error('Server did not return a jobId') if (!jobId || !accessToken) throw new Error('Server did not return job credentials')
const downloadUrl = await new Promise<string>((resolve, reject) => { const downloadUrl = await new Promise<string>((resolve, reject) => {
const es = new EventSource(`${baseUrl}/render/${jobId}/events`) const jobToken = encodeURIComponent(accessToken)
const es = new EventSource(`${baseUrl}/render/${jobId}/events?token=${jobToken}`)
const onAbort = () => { const onAbort = () => {
try { try {
es.close() es.close()
} catch { } catch {
/* noop */ /* noop */
} }
fetch(`${baseUrl}/render/${jobId}`, { method: 'DELETE' }).catch(() => {}) fetch(`${baseUrl}/render/${jobId}?token=${jobToken}`, { method: 'DELETE' }).catch(() => {})
reject(new DOMException('aborted', 'AbortError')) reject(new DOMException('aborted', 'AbortError'))
} }
if (opts.signal) { if (opts.signal) {
+65 -4
View File
@@ -17,6 +17,11 @@ const SIG_LOCAL = 0x04034b50
const SIG_CENTRAL = 0x02014b50 const SIG_CENTRAL = 0x02014b50
const SIG_EOCD = 0x06054b50 const SIG_EOCD = 0x06054b50
const VERSION_NEEDED = 20 const VERSION_NEEDED = 20
const MAX_ARCHIVE_BYTES = 256 * 1024 * 1024
const MAX_ENTRIES = 1000
const MAX_NAME_BYTES = 512
const MAX_ENTRY_BYTES = 100 * 1024 * 1024
const MAX_TOTAL_ENTRY_BYTES = 200 * 1024 * 1024
const CRC_TABLE = (() => { const CRC_TABLE = (() => {
const t = new Uint32Array(256) const t = new Uint32Array(256)
@@ -40,12 +45,20 @@ function crc32(data: Uint8Array): number {
/** Pack a list of file entries into a flat STORE zip byte stream. */ /** Pack a list of file entries into a flat STORE zip byte stream. */
export function zipStore(entries: ZipEntry[]): Uint8Array { export function zipStore(entries: ZipEntry[]): Uint8Array {
if (entries.length > MAX_ENTRIES) throw new Error(`ZIP has more than ${MAX_ENTRIES} entries.`)
const enc = new TextEncoder() const enc = new TextEncoder()
const localChunks: Uint8Array[] = [] const localChunks: Uint8Array[] = []
const centralChunks: Uint8Array[] = [] const centralChunks: Uint8Array[] = []
let offset = 0 let offset = 0
for (const entry of entries) { for (const entry of entries) {
validateZipName(entry.name)
if (entry.data.length > MAX_ENTRY_BYTES) {
throw new Error(`ZIP entry ${entry.name} exceeds ${MAX_ENTRY_BYTES} bytes.`)
}
const nameBytes = enc.encode(entry.name) const nameBytes = enc.encode(entry.name)
if (nameBytes.length > MAX_NAME_BYTES) {
throw new Error(`ZIP entry name ${entry.name} is too long.`)
}
const data = entry.data const data = entry.data
const crc = crc32(data) const crc = crc32(data)
// Local file header. // Local file header.
@@ -119,27 +132,35 @@ export function zipStore(entries: ZipEntry[]): Uint8Array {
/** Parse a STORE-only zip stream. Throws on bad signatures, truncated /** Parse a STORE-only zip stream. Throws on bad signatures, truncated
* data, or compressed entries (we don't ship a deflate path). */ * data, or compressed entries (we don't ship a deflate path). */
export function unzipStore(bytes: Uint8Array): ZipEntry[] { export function unzipStore(bytes: Uint8Array): ZipEntry[] {
if (bytes.length > MAX_ARCHIVE_BYTES) {
throw new Error(`ZIP archive exceeds ${MAX_ARCHIVE_BYTES} bytes.`)
}
if (bytes.length < 22) throw new Error('Not a ZIP archive (too small).')
const dec = new TextDecoder() const dec = new TextDecoder()
// EOCD is at least 22 bytes and may have up to 65535 bytes of // EOCD is at least 22 bytes and may have up to 65535 bytes of
// trailing comment; scan from the end within that window. // trailing comment; scan from the end within that window.
let eocdOff = -1 let eocdOff = -1
const minOff = Math.max(0, bytes.length - (22 + 65535)) const minOff = Math.max(0, bytes.length - (22 + 65535))
for (let i = bytes.length - 22; i >= minOff; i--) { for (let i = bytes.length - 22; i >= minOff; i--) {
const dv = new DataView(bytes.buffer, bytes.byteOffset + i, 4) const dv = view(bytes, i, 4)
if (dv.getUint32(0, true) === SIG_EOCD) { if (dv.getUint32(0, true) === SIG_EOCD) {
eocdOff = i eocdOff = i
break break
} }
} }
if (eocdOff < 0) throw new Error('Not a ZIP archive (EOCD not found).') if (eocdOff < 0) throw new Error('Not a ZIP archive (EOCD not found).')
const edv = new DataView(bytes.buffer, bytes.byteOffset + eocdOff) const edv = view(bytes, eocdOff, 22)
const total = edv.getUint16(10, true) const total = edv.getUint16(10, true)
if (total > MAX_ENTRIES) throw new Error(`ZIP has more than ${MAX_ENTRIES} entries.`)
const centralSize = edv.getUint32(12, true)
const centralOff = edv.getUint32(16, true) const centralOff = edv.getUint32(16, true)
ensureRange(bytes, centralOff, centralSize, 'ZIP central directory')
const entries: ZipEntry[] = [] const entries: ZipEntry[] = []
let p = centralOff let p = centralOff
let extractedTotal = 0
for (let i = 0; i < total; i++) { for (let i = 0; i < total; i++) {
const cdv = new DataView(bytes.buffer, bytes.byteOffset + p) const cdv = view(bytes, p, 46)
if (cdv.getUint32(0, true) !== SIG_CENTRAL) { if (cdv.getUint32(0, true) !== SIG_CENTRAL) {
throw new Error('Bad central directory entry.') throw new Error('Bad central directory entry.')
} }
@@ -149,20 +170,30 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
const extraLen = cdv.getUint16(30, true) const extraLen = cdv.getUint16(30, true)
const commentLen = cdv.getUint16(32, true) const commentLen = cdv.getUint16(32, true)
const localOff = cdv.getUint32(42, true) const localOff = cdv.getUint32(42, true)
if (nameLen > MAX_NAME_BYTES) throw new Error('ZIP entry name is too long.')
if (size > MAX_ENTRY_BYTES) throw new Error(`ZIP entry exceeds ${MAX_ENTRY_BYTES} bytes.`)
extractedTotal += size
if (extractedTotal > MAX_TOTAL_ENTRY_BYTES) {
throw new Error(`ZIP extracted data exceeds ${MAX_TOTAL_ENTRY_BYTES} bytes.`)
}
if (method !== 0) { if (method !== 0) {
throw new Error(`Unsupported ZIP compression method ${method} — only STORE is accepted.`) throw new Error(`Unsupported ZIP compression method ${method} — only STORE is accepted.`)
} }
ensureRange(bytes, p, 46 + nameLen + extraLen + commentLen, 'ZIP central directory entry')
const nameBytes = bytes.subarray(p + 46, p + 46 + nameLen) const nameBytes = bytes.subarray(p + 46, p + 46 + nameLen)
const name = dec.decode(nameBytes) const name = dec.decode(nameBytes)
validateZipName(name)
// Re-read the local header to skip its variable-length fields // Re-read the local header to skip its variable-length fields
// before the file data. // before the file data.
const ldv = new DataView(bytes.buffer, bytes.byteOffset + localOff) const ldv = view(bytes, localOff, 30)
if (ldv.getUint32(0, true) !== SIG_LOCAL) { if (ldv.getUint32(0, true) !== SIG_LOCAL) {
throw new Error('Bad local file header.') throw new Error('Bad local file header.')
} }
const lNameLen = ldv.getUint16(26, true) const lNameLen = ldv.getUint16(26, true)
const lExtraLen = ldv.getUint16(28, true) const lExtraLen = ldv.getUint16(28, true)
const dataOff = localOff + 30 + lNameLen + lExtraLen const dataOff = localOff + 30 + lNameLen + lExtraLen
ensureRange(bytes, localOff, 30 + lNameLen + lExtraLen, 'ZIP local file header')
ensureRange(bytes, dataOff, size, 'ZIP entry data')
const data = bytes.subarray(dataOff, dataOff + size) const data = bytes.subarray(dataOff, dataOff + size)
// Copy so callers get a standalone Uint8Array they can keep. // Copy so callers get a standalone Uint8Array they can keep.
entries.push({ name, data: new Uint8Array(data) }) entries.push({ name, data: new Uint8Array(data) })
@@ -171,6 +202,36 @@ export function unzipStore(bytes: Uint8Array): ZipEntry[] {
return entries return entries
} }
function view(bytes: Uint8Array, offset: number, length: number): DataView {
ensureRange(bytes, offset, length, 'ZIP data')
return new DataView(bytes.buffer, bytes.byteOffset + offset, length)
}
function ensureRange(bytes: Uint8Array, offset: number, length: number, label: string): void {
if (
!Number.isSafeInteger(offset) ||
!Number.isSafeInteger(length) ||
offset < 0 ||
length < 0 ||
offset > bytes.length ||
offset + length > bytes.length
) {
throw new Error(`${label} is outside the archive.`)
}
}
function validateZipName(name: string): void {
if (
!name ||
name.startsWith('/') ||
name.startsWith('\\') ||
name.includes('\\') ||
name.split('/').some((part) => part === '..' || part === '')
) {
throw new Error(`Unsafe ZIP entry name: ${name || '(empty)'}.`)
}
}
/** Magic check — `true` when the first four bytes match the ZIP local /** Magic check — `true` when the first four bytes match the ZIP local
* file header signature (PK\x03\x04). Used by the project-file * file header signature (PK\x03\x04). Used by the project-file
* reader to decide between .ruw (zip) and .json (legacy). */ * reader to decide between .ruw (zip) and .json (legacy). */