screentinker/server/routes/media.js
screentinker 96b71a0d56
feat: transition engine — GL wipes across web, Tizen & Android (+ image↔video) (#204)
Client-side GL Transitions v1 across all three players with never-blank degradation: shader lib + generated manifest + real-WebGL CI, transition-as-widget normalization, persistent WebGL/GLES2 compositors (web/Tizen/native Android), image↔video wipes, SSRF-hardened media proxy, decode-gated image preload, dashboard picker. Fixes the playlist change-fingerprint that dropped transition edits. Alpha + on-device soak validated.
2026-07-20 16:45:32 -05:00

268 lines
14 KiB
JavaScript

'use strict';
// Media proxy — GET /media/proxy/:itemId
//
// Threat model (see also server/lib/ssrf-guard.js):
// * NOT an open proxy. The caller never supplies a URL — we look the item up server-side and fetch
// its stored content.remote_url. The only fetchable targets are URLs a customer already put in a
// playlist, so bandwidth-theft / abuse-laundering (using our box to fetch arbitrary public URLs)
// is eliminated by construction. The SSRF guard then handles the residual case: a customer who
// sets a hostile remote_url pointing at our internal network.
// * We serve upstream bytes SAME-ORIGIN (so a WebGL transition can read them), which means anything
// we serve executes in the ScreenTinker origin. Content-Type from upstream is a lie we never trust:
// we sniff magic bytes, refuse anything that isn't a real image/video, serve the SNIFFED type with
// X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox. That kills text/html -> XSS.
// * Size is capped on the STREAM (bytes actually received), not Content-Length (absent or lying);
// we abort mid-transfer at the ceiling. The socket is pinned to the vetted IP (anti-rebinding) with
// SNI/cert validation still against the original hostname. Redirects are re-vetted every hop.
// * Single-flight per cache key: 50 panels advancing to the same cold asset => 1 upstream fetch.
const express = require('express');
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const { db } = require('../db/database');
const config = require('../config');
const { assertSafeUrl, pinnedLookup, SsrfError } = require('../lib/ssrf-guard');
const router = express.Router();
const MAX_BYTES = parseInt(process.env.MEDIA_PROXY_MAX_BYTES, 10) || 128 * 1024 * 1024; // per-item ceiling; abort the stream past this
const MAX_REDIRECTS = 4;
const IDLE_TIMEOUT_MS = 20000; // no-progress socket timeout
const SNIFF_BYTES = 32; // enough for every magic below
const CACHE_DIR = path.join(config.dataDir, 'proxy-cache'); // NOT under contentDir (never statically served)
const CACHE_CAP_BYTES = parseInt(process.env.MEDIA_PROXY_CACHE_CAP_BYTES, 10) || 2 * 1024 * 1024 * 1024; // TOTAL cache cap
const TTL_MS = parseInt(process.env.MEDIA_PROXY_TTL_MS, 10) || 5 * 60 * 1000; // revalidate upstream past this age
// A full disk on a signage box is a cross-tenant outage — refuse to write when free space is below this.
function freeFloorBytes() { return parseInt(process.env.MEDIA_PROXY_FREE_FLOOR_BYTES, 10) || 1024 * 1024 * 1024; }
function freeSpace() { try { const s = fs.statfsSync(CACHE_DIR); return s.bavail * s.bsize; } catch (e) { return Infinity; } }
try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch (e) { /* fall through; writes will surface errors */ }
// A media-layer failure that is the upstream's fault (not an SSRF block).
class MediaError extends Error { constructor(msg) { super(msg); this.name = 'MediaError'; } }
const dataFile = (key) => path.join(CACHE_DIR, key + '.bin');
const metaFile = (key) => path.join(CACHE_DIR, key + '.json');
// ---- magic-byte sniff: return a real image/video mime, or null (=> reject) ----
function sniffMedia(buf) {
if (buf.length < 2) return null;
const b = buf;
const a4 = b.length >= 4 ? b.toString('latin1', 0, 4) : '';
// images
if (b[0] === 0xFF && b[1] === 0xD8 && b[2] === 0xFF) return 'image/jpeg';
if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4E && b[3] === 0x47) return 'image/png';
if (a4 === 'GIF8') return 'image/gif';
if (b[0] === 0x42 && b[1] === 0x4D) return 'image/bmp';
if (a4 === 'RIFF' && b.length >= 12 && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
// ISO-BMFF (mp4/avif/heic): '....ftyp<brand>'
if (b.length >= 12 && b.toString('latin1', 4, 8) === 'ftyp') {
const brand = b.toString('latin1', 8, 12);
if (brand === 'avif' || brand === 'avis') return 'image/avif';
if (brand === 'heic' || brand === 'heix' || brand === 'mif1') return 'image/heic';
return 'video/mp4'; // isom, mp42, mp41, dash, etc.
}
// videos
if (b[0] === 0x1A && b[1] === 0x45 && b[2] === 0xDF && b[3] === 0xA3) return 'video/webm'; // EBML (webm/mkv)
if (a4 === 'OggS') return 'video/ogg';
return null;
}
const ALLOWED_PREFIX = /^(image|video)\//;
// ---- outbound fetch: vet URL, pin socket to vetted IP, re-vet each redirect hop.
// Resolves { res } for a 200 stream, or { notModified:true } for a 304 (conditional revalidation).
function resolveWithRedirects(rawUrl, redirectsLeft, validators) {
return new Promise((resolve, reject) => {
assertSafeUrl(rawUrl).then(({ url, addresses }) => {
const mod = url.protocol === 'https:' ? https : http;
const headers = { 'user-agent': 'ScreenTinker-media-proxy', accept: 'image/*,video/*' };
if (validators && validators.etag) headers['if-none-match'] = validators.etag;
if (validators && validators.lastModified) headers['if-modified-since'] = validators.lastModified;
const req = mod.request(url, {
method: 'GET',
lookup: pinnedLookup(addresses), // connect to the vetted IP only (defeats DNS rebinding)
servername: url.hostname, // SNI + cert validation stay against the hostname, not the IP
headers,
}, (res) => {
const sc = res.statusCode;
if (sc === 304) { res.resume(); return resolve({ notModified: true }); } // still current upstream
if (sc >= 300 && sc < 400 && res.headers.location) {
res.resume(); // drain the redirect body
if (redirectsLeft <= 0) return reject(new MediaError('too-many-redirects'));
let next;
try { next = new URL(res.headers.location, url).toString(); }
catch (e) { return reject(new MediaError('bad-redirect')); }
return resolveWithRedirects(next, redirectsLeft - 1, validators).then(resolve, reject);
}
if (sc !== 200) { res.resume(); return reject(new MediaError('upstream-status-' + sc)); }
resolve({ res });
});
req.setTimeout(IDLE_TIMEOUT_MS, () => req.destroy(new MediaError('timeout')));
req.on('error', reject);
req.end();
}, reject);
});
}
// ---- consume a vetted 200 stream into the disk cache: sniff on first bytes, cap on the stream ----
function consumeToCache(res, key) {
return new Promise((resolve, reject) => {
{
// Free-space floor: try to reclaim from our own cache first, then refuse rather than fill the disk.
if (freeSpace() < freeFloorBytes()) {
evictIfOverCap();
if (freeSpace() < freeFloorBytes()) { res.destroy(); return reject(new MediaError('disk-full')); }
}
const tmp = dataFile(key) + '.tmp-' + crypto.randomBytes(6).toString('hex');
const out = fs.createWriteStream(tmp);
let received = 0, head = [], headLen = 0, sniffed = null, aborted = false;
const abort = (err) => {
if (aborted) return; aborted = true;
res.destroy(); out.destroy();
fs.unlink(tmp, () => {});
reject(err);
};
out.on('error', abort);
res.on('error', abort);
const trySniff = (final) => {
if (sniffed || (!final && headLen < SNIFF_BYTES)) return true;
sniffed = sniffMedia(Buffer.concat(head));
if (!sniffed || !ALLOWED_PREFIX.test(sniffed)) { abort(new MediaError('unsupported-content')); return false; }
head = null; // release
return true;
};
res.on('data', (chunk) => {
if (aborted) return;
received += chunk.length;
if (received > MAX_BYTES) return abort(new MediaError('too-large'));
if (!sniffed) { head.push(chunk); headLen += chunk.length; if (!trySniff(false)) return; }
if (!out.write(chunk)) { res.pause(); out.once('drain', () => res.resume()); }
});
res.on('end', () => {
if (aborted) return;
if (!sniffed && !trySniff(true)) return; // tiny file: sniff whatever we got
out.end(() => {
try {
fs.renameSync(tmp, dataFile(key));
const h = res.headers || {};
const meta = { type: sniffed, size: received, fetchedAt: Date.now(),
etag: h.etag || null, lastModified: h['last-modified'] || null };
fs.writeFileSync(metaFile(key), JSON.stringify(meta));
evictIfOverCap();
resolve(meta);
} catch (e) { fs.unlink(tmp, () => {}); reject(e); }
});
});
}
});
}
function readMeta(key) {
try {
const m = JSON.parse(fs.readFileSync(metaFile(key), 'utf8'));
if (m && ALLOWED_PREFIX.test(m.type) && fs.existsSync(dataFile(key))) return m;
} catch (e) { /* miss */ }
return null;
}
function isFresh(meta) { return meta && typeof meta.fetchedAt === 'number' && (Date.now() - meta.fetchedAt) < TTL_MS; }
// upstream unchanged (304) or a revalidation error: keep the bytes, just reset freshness + LRU stamp
function touchMeta(key, meta) {
meta.fetchedAt = Date.now();
try { fs.writeFileSync(metaFile(key), JSON.stringify(meta)); const t = new Date(); fs.utimesSync(dataFile(key), t, t); } catch (e) {}
return meta;
}
// Miss -> download. Stale hit -> conditional GET: 304 keeps the bytes, 200 replaces them, and a
// revalidation FAILURE serves the stale copy (a signage screen must never blank on a flaky upstream).
function fetchOrRevalidate(remoteUrl, key, existing) {
const validators = existing && (existing.etag || existing.lastModified)
? { etag: existing.etag, lastModified: existing.lastModified } : null;
return resolveWithRedirects(remoteUrl, MAX_REDIRECTS, validators).then((r) => {
if (r.notModified && existing) return touchMeta(key, existing);
return consumeToCache(r.res, key);
}).catch((err) => {
if (existing) return touchMeta(key, existing); // stale-while-error
throw err;
});
}
const inflight = new Map(); // cache key -> Promise<meta> (single-flight; covers revalidation too)
function ensureCached(remoteUrl, key, fetcher) {
const hit = readMeta(key);
if (hit && isFresh(hit)) return Promise.resolve(hit);
if (inflight.has(key)) return inflight.get(key);
const run = fetcher || ((u, k) => fetchOrRevalidate(u, k, hit)); // hit may be a stale copy to revalidate
const p = run(remoteUrl, key, hit).finally(() => inflight.delete(key));
inflight.set(key, p);
return p;
}
// bounded cache: evict oldest (by write time) until under the byte cap
function evictIfOverCap() {
try {
const bins = fs.readdirSync(CACHE_DIR).filter((f) => f.endsWith('.bin'));
const entries = bins.map((f) => {
const p = path.join(CACHE_DIR, f);
const s = fs.statSync(p);
return { p, base: f.slice(0, -4), size: s.size, mtime: s.mtimeMs };
});
let total = entries.reduce((a, e) => a + e.size, 0);
if (total <= CACHE_CAP_BYTES) return;
entries.sort((a, b) => a.mtime - b.mtime);
for (const e of entries) {
if (total <= CACHE_CAP_BYTES) break;
try { fs.unlinkSync(e.p); } catch (x) {}
fs.unlink(path.join(CACHE_DIR, e.base + '.json'), () => {});
total -= e.size;
}
} catch (e) { /* best-effort */ }
}
function serveFromCache(res, key, meta) {
res.setHeader('Content-Type', meta.type); // the SNIFFED type, never upstream's claim
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Content-Security-Policy', 'sandbox'); // if ever navigated to directly, no script/plugins
res.setHeader('Access-Control-Allow-Origin', '*'); // canvas/WebGL needs to read the pixels
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// short client cache so a stale screen self-corrects within ~a minute; server-side TTL handles upstream
res.setHeader('Cache-Control', 'public, max-age=60');
res.setHeader('Content-Length', meta.size);
const t = new Date(); fs.utimes(dataFile(key), t, t, () => {}); // LRU: stamp access time for eviction ordering
const rs = fs.createReadStream(dataFile(key));
rs.on('error', () => { if (!res.headersSent) res.status(500).end(); else res.destroy(); });
rs.pipe(res);
}
// Keyed on content.id (a TEXT id present in the id-free published_snapshot the player consumes; the
// playlist_items id is not). content OWNS remote_url, so this is the natural lookup and lets multiple
// playlist items that reference the same image share one cache entry. Still not an open proxy: only a
// content row a customer created is fetchable, and the SSRF guard gates the actual remote_url fetch.
router.get('/proxy/:contentId', (req, res) => {
const contentId = String(req.params.contentId || '');
if (!contentId || contentId.length > 64 || !/^[A-Za-z0-9_-]+$/.test(contentId)) {
return res.status(400).end();
}
let row;
try {
row = db.prepare('SELECT remote_url FROM content WHERE id = ?').get(contentId);
} catch (e) { return res.status(500).end(); }
if (!row || !row.remote_url) return res.status(404).end();
const key = crypto.createHash('sha256').update(String(row.remote_url)).digest('hex');
ensureCached(row.remote_url, key)
.then((meta) => serveFromCache(res, key, meta))
.catch((err) => {
if (res.headersSent) return res.destroy();
const code = err instanceof SsrfError ? 403 : (err instanceof MediaError ? 502 : 500);
res.status(code).end();
});
});
module.exports = router;
// exported for tests (security-critical internals, exercised without the DB/express layer)
module.exports.__test = { sniffMedia, resolveWithRedirects, consumeToCache, fetchOrRevalidate, ensureCached, readMeta, isFresh, touchMeta, evictIfOverCap, inflight, dataFile, metaFile, CACHE_DIR, MAX_BYTES, TTL_MS };