Media upload service for Subspeak
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
+13
@@ -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"]
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+216
@@ -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/<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}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user