import { createServer } from 'node:http';
import { mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { routes } from './routes.js';
import { HttpError, sendJson } from './http.js';
import { parseCookies, userForToken, COOKIE } from './auth.js';
import { sendFile, safeJoin } from './static.js';
import { PUBLIC_DIR, MEDIA_DIR, VIDEO_DIR, THUMB_DIR } from './paths.js';
for (const dir of [VIDEO_DIR, THUMB_DIR]) mkdirSync(dir, { recursive: true });
const PORT = Number(process.env.PORT) || 3000;
const HOST = process.env.HOST || '127.0.0.1';
/** Compile "GET /api/videos/:id" into a matcher. */
const table = Object.entries(routes).map(([key, handler]) => {
const [method, pattern] = key.split(' ');
const names = [];
const source = pattern
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/:(\w+)/g, (_, name) => { names.push(name); return '([^/]+)'; });
return { method, regex: new RegExp(`^${source}$`), names, handler };
});
function match(method, pathname) {
let pathExists = false;
for (const route of table) {
const m = route.regex.exec(pathname);
if (!m) continue;
pathExists = true;
if (route.method !== method) continue;
const params = {};
route.names.forEach((name, i) => { params[name] = decodeURIComponent(m[i + 1]); });
return { handler: route.handler, params };
}
return pathExists ? { methodMismatch: true } : null;
}
const server = createServer(async (req, res) => {
const started = Date.now();
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const pathname = url.pathname.replace(/\/+$/, '') || '/';
res.on('finish', () => {
if (process.env.QUIET) return;
const ms = Date.now() - started;
console.log(`${req.method} ${url.pathname} ${res.statusCode} ${ms}ms`);
});
try {
// Media files (videos + thumbnails), served with Range support.
if (pathname.startsWith('/media/')) {
const file = safeJoin(MEDIA_DIR, pathname.slice('/media'.length));
if (file && (await sendFile(req, res, file, { cacheControl: 'public, max-age=86400' }))) return;
return sendJson(res, 404, { error: 'Media not found.' });
}
if (pathname.startsWith('/api/')) {
const found = match(req.method, pathname);
if (!found) return sendJson(res, 404, { error: `No API route for ${pathname}` });
if (found.methodMismatch) return sendJson(res, 405, { error: `${req.method} is not allowed here.` });
const token = parseCookies(req.headers.cookie || '')[COOKIE];
const user = userForToken(token);
const ctx = {
req, res, url, token, user,
params: found.params,
query: url.searchParams,
requireUser() {
if (!user) throw new HttpError(401, 'You need to sign in to do that.');
return user;
},
};
await found.handler(ctx);
if (!res.writableEnded) sendJson(res, 204, {});
return;
}
// Static assets, then the SPA shell for every other path.
const asset = safeJoin(PUBLIC_DIR, pathname);
if (asset && pathname !== '/' && (await sendFile(req, res, asset, { cacheControl: 'no-cache' }))) return;
if (await sendFile(req, res, join(PUBLIC_DIR, 'index.html'), { cacheControl: 'no-cache' })) return;
sendJson(res, 404, { error: 'Not found.' });
} catch (err) {
if (res.writableEnded) return;
const status = err instanceof HttpError ? err.status : 500;
if (status >= 500) console.error(`error on ${req.method} ${pathname}:`, err);
sendJson(res, status, { error: status >= 500 ? 'Something went wrong on our end.' : err.message });
}
});
server.headersTimeout = 10 * 60 * 1000;
server.requestTimeout = 30 * 60 * 1000; // long enough for a big upload
server.listen(PORT, HOST, () => {
console.log(`MeTube running at http://${HOST}:${PORT}`);
});