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