commit 71f7ef13718237a4ae22c4cd0776ba3627fbc2d4 Author: george Date: Mon Jul 6 12:01:47 2026 +0200 Media upload service for Subspeak diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ec15a3b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.git +*.log +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..34df797 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=5178 +ENV MEDIA_DATA_DIR=/data/uploads +ENV PUBLIC_BASE=https://subspeak.george1.dev +COPY package.json ./ +COPY server.mjs ./server.mjs +RUN mkdir -p /data/uploads +EXPOSE 5178 +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:5178/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" +CMD ["node", "server.mjs"] diff --git a/package.json b/package.json new file mode 100644 index 0000000..7810acf --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "subspeak-media-upload", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Tiny media upload + serving service for Subspeak (no dependencies).", + "scripts": { + "start": "node server.mjs" + } +} diff --git a/server.mjs b/server.mjs new file mode 100644 index 0000000..a239123 --- /dev/null +++ b/server.mjs @@ -0,0 +1,216 @@ +#!/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/.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}`); +});