Files
subspeak-media/server.mjs
T

217 lines
7.3 KiB
JavaScript

#!/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 image content types -> canonical extension.
const ALLOWED = new Map([
['image/jpeg', 'jpg'],
['image/png', 'png'],
['image/webp', 'webp'],
['image/gif', 'gif'],
['image/avif', 'avif'],
]);
const SERVE_TYPES = new Map([
['jpg', 'image/jpeg'],
['png', 'image/png'],
['webp', 'image/webp'],
['gif', 'image/gif'],
['avif', 'image/avif'],
]);
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.
function readBody(req, limit) {
return new Promise((resolve, reject) => {
const chunks = [];
let total = 0;
let aborted = false;
req.on('data', (chunk) => {
if (aborted) return;
total += chunk.length;
if (total > limit) {
aborted = true;
const err = new Error('too large');
err.statusCode = 413;
req.destroy();
reject(err);
return;
}
chunks.push(chunk);
});
req.on('end', () => {
if (!aborted) resolve(Buffer.concat(chunks));
});
req.on('error', (err) => {
if (!aborted) 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/<uuid>.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',
});
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}`);
});