#!/usr/bin/env node // Tiny zero-dependency media upload + serving service for Subspeak. // Routes (paths arrive verbatim from Traefik PathPrefix, nothing stripped): // POST /media/upload -> multipart/form-data field "file"; returns {url} // GET /media/uploads/... -> serves stored file (public, long cache) // GET /media/health -> health probe // Auth is enforced by Traefik basicauth in front of /media/upload; this app trusts that. import { createServer } from 'node:http'; import { readFile, writeFile, mkdir, stat } from 'node:fs/promises'; import { randomUUID } from 'node:crypto'; import path from 'node:path'; const PORT = Number(process.env.PORT || 5178); const DATA_DIR = process.env.MEDIA_DATA_DIR || '/data/uploads'; const PUBLIC_BASE = process.env.PUBLIC_BASE || 'https://subspeak.george1.dev'; const MAX_BYTES = 15 * 1024 * 1024; // 15 MB // Allowed content types -> canonical extension. const ALLOWED = new Map([ ['image/jpeg', 'jpg'], ['image/png', 'png'], ['image/webp', 'webp'], ['image/gif', 'gif'], ['image/avif', 'avif'], ['audio/mpeg', 'mp3'], ]); const SERVE_TYPES = new Map([ ['jpg', 'image/jpeg'], ['png', 'image/png'], ['webp', 'image/webp'], ['gif', 'image/gif'], ['avif', 'image/avif'], ['mp3', 'audio/mpeg'], ]); function sendJson(res, status, obj) { const body = JSON.stringify(obj); res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(body), }); res.end(body); } // Read the whole request body, enforcing MAX_BYTES. // On overflow we stop buffering and drain the rest of the stream so we can // still send a clean 413 JSON response (destroying the socket => 502 at proxy). function readBody(req, limit) { return new Promise((resolve, reject) => { const chunks = []; let total = 0; let over = false; req.on('data', (chunk) => { total += chunk.length; if (over) return; if (total > limit) { over = true; chunks.length = 0; return; } chunks.push(chunk); }); req.on('end', () => { if (over) { const err = new Error('too large'); err.statusCode = 413; reject(err); return; } resolve(Buffer.concat(chunks)); }); req.on('error', (err) => reject(err)); }); } // Minimal multipart/form-data parser. Returns the first part named "file". function parseMultipart(buffer, boundary) { const delimiter = Buffer.from(`--${boundary}`); const parts = []; let start = buffer.indexOf(delimiter); if (start === -1) return parts; start += delimiter.length; while (start < buffer.length) { // "--" after boundary marks the end. if (buffer[start] === 0x2d && buffer[start + 1] === 0x2d) break; // Skip CRLF after boundary. if (buffer[start] === 0x0d && buffer[start + 1] === 0x0a) start += 2; const headerEnd = buffer.indexOf(Buffer.from('\r\n\r\n'), start); if (headerEnd === -1) break; const headerText = buffer.slice(start, headerEnd).toString('utf8'); const bodyStart = headerEnd + 4; const next = buffer.indexOf(delimiter, bodyStart); if (next === -1) break; // Body ends 2 bytes (CRLF) before the next boundary delimiter. const bodyEnd = next - 2; const headers = {}; for (const line of headerText.split('\r\n')) { const idx = line.indexOf(':'); if (idx !== -1) headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim(); } const disposition = headers['content-disposition'] || ''; const nameMatch = /name="([^"]*)"/.exec(disposition); const filenameMatch = /filename="([^"]*)"/.exec(disposition); parts.push({ name: nameMatch ? nameMatch[1] : null, filename: filenameMatch ? filenameMatch[1] : null, contentType: (headers['content-type'] || '').split(';')[0].trim().toLowerCase(), data: buffer.slice(bodyStart, bodyEnd), }); start = next + delimiter.length; } return parts; } async function handleUpload(req, res) { const contentType = req.headers['content-type'] || ''; const boundaryMatch = /boundary=(?:"([^"]+)"|([^;]+))/i.exec(contentType); if (!contentType.startsWith('multipart/form-data') || !boundaryMatch) { return sendJson(res, 400, { error: 'expected multipart/form-data' }); } const boundary = boundaryMatch[1] || boundaryMatch[2]; let body; try { body = await readBody(req, MAX_BYTES); } catch (err) { if (err.statusCode === 413) return sendJson(res, 413, { error: 'file exceeds 15MB limit' }); return sendJson(res, 400, { error: 'could not read request body' }); } const parts = parseMultipart(body, boundary); const filePart = parts.find((p) => p.name === 'file'); if (!filePart || filePart.data.length === 0) { return sendJson(res, 400, { error: 'missing "file" field' }); } const ext = ALLOWED.get(filePart.contentType); if (!ext) { return sendJson(res, 415, { error: `unsupported content type: ${filePart.contentType || 'unknown'}` }); } if (filePart.data.length > MAX_BYTES) { return sendJson(res, 413, { error: 'file exceeds 15MB limit' }); } const now = new Date(); const year = String(now.getUTCFullYear()); const month = String(now.getUTCMonth() + 1).padStart(2, '0'); const uuid = randomUUID(); const relDir = path.join('uploads', year, month); const relPath = path.join(relDir, `${uuid}.${ext}`); const absDir = path.join(DATA_DIR, relDir); const absPath = path.join(DATA_DIR, relPath); try { await mkdir(absDir, { recursive: true }); await writeFile(absPath, filePart.data); } catch (err) { console.error('write failed', err); return sendJson(res, 500, { error: 'failed to store file' }); } const url = `${PUBLIC_BASE}/media/${relPath.split(path.sep).join('/')}`; return sendJson(res, 200, { url }); } async function handleServe(req, res, pathname) { // pathname like /media/uploads/2026/07/.png const rel = decodeURIComponent(pathname.replace(/^\/media\//, '')); // Guard against traversal. if (rel.includes('..') || rel.includes('\0')) { return sendJson(res, 400, { error: 'bad path' }); } const abs = path.join(DATA_DIR, rel); if (!abs.startsWith(path.join(DATA_DIR, 'uploads') + path.sep)) { return sendJson(res, 404, { error: 'not found' }); } try { const info = await stat(abs); if (!info.isFile()) throw Object.assign(new Error('nf'), { code: 'ENOENT' }); const ext = path.extname(abs).slice(1).toLowerCase(); const body = await readFile(abs); res.writeHead(200, { 'content-type': SERVE_TYPES.get(ext) || 'application/octet-stream', 'content-length': info.size, 'cache-control': 'public, max-age=31536000, immutable', // Files are public; CORS lets the Substack editor fetch media for re-upload. 'access-control-allow-origin': '*', }); res.end(body); } catch (err) { if (err.code === 'ENOENT') return sendJson(res, 404, { error: 'not found' }); console.error('serve failed', err); return sendJson(res, 500, { error: 'server error' }); } } const server = createServer((req, res) => { const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); const { pathname } = url; if (pathname === '/media/health' || pathname === '/health') { return sendJson(res, 200, { ok: true }); } if (req.method === 'POST' && pathname === '/media/upload') { return handleUpload(req, res); } if (req.method === 'GET' && pathname.startsWith('/media/uploads/')) { return handleServe(req, res, pathname); } return sendJson(res, 404, { error: 'not found' }); }); server.listen(PORT, () => { console.log(`media-upload listening on :${PORT}, data dir ${DATA_DIR}`); });