SPB Git

Repository Public

server.ts 6.9 KB 92 lines typescript

1import { statfs } from 'node:fs/promises';2import cookie from '@fastify/cookie';3import rateLimit from '@fastify/rate-limit';4import fastifyStatic from '@fastify/static';5import Fastify, { type FastifyInstance } from 'fastify';6import { z } from 'zod';7import { authRoutes, loadSession, requireAdmin, requireUser } from './auth.ts';8import { config, UserError } from './config.ts';9import { sql } from './db.ts';10import { fileRoutes } from './files.ts';11import { requestRoutes } from './requests.ts';12import { shareRoutes } from './shares.ts';13import { clamdPing } from './tools.ts';14import { uploadRoutes } from './uploads.ts';1516/** Link tokens never reach the logs: the secret part of public URLs is replaced before a request is logged. */17export const redactUrl = (url: string) => url.replace(/(\/(?:s|request|invite)\/|\/public\/(?:shares|requests)\/)[A-Za-z0-9_-]{16,}/g, '$1[redacted]');1819const CSP = ["default-src 'self'", "script-src 'self' 'wasm-unsafe-eval'", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: blob:", "media-src 'self' blob:", "font-src 'self' data:",20  "connect-src 'self'", "worker-src 'self' blob:", "object-src 'none'", "base-uri 'none'", "form-action 'self'", "frame-ancestors 'none'"].join('; ');2122export async function buildServer(o: { logger?: boolean } = {}): Promise<FastifyInstance> {23  const app = Fastify({24    trustProxy: config.trustProxy, bodyLimit: 1024 * 1024,25    logger: o.logger === false ? false : { level: 'info', redact: ['req.headers.authorization', 'req.headers.cookie'],26      serializers: { req: (r) => ({ method: r.method, url: redactUrl(r.url), ip: r.ip }), res: (r) => ({ statusCode: r.statusCode }) } },27  });28  await app.register(cookie);29  await app.register(rateLimit, { global: true, max: 900, timeWindow: '1 minute', keyGenerator: (req) => req.ip,30    errorResponseBuilder: (_req, ctx) => { const e = new UserError('rate_limited', 'too many requests, slow down', 429, { retryAfterS: Math.ceil(ctx.ttl / 1000) }); return e; } });31  // upload chunks arrive as a raw stream and are written straight to disk32  app.addContentTypeParser('application/octet-stream', (_req, payload, done) => done(null, payload));3334  app.addHook('onRequest', async (req, reply) => {35    reply.header('X-Content-Type-Options', 'nosniff').header('Referrer-Policy', 'no-referrer').header('X-Frame-Options', 'DENY')36      .header('Permissions-Policy', 'camera=(self), microphone=(), geolocation=(), payment=()').header('Cross-Origin-Opener-Policy', 'same-origin');37    if (config.origin.startsWith('https://')) reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');38    if (!req.url.startsWith('/api/')) { reply.header('Content-Security-Policy', CSP); return; }39    reply.header('Cache-Control', 'no-store');40    // CSRF: state-changing calls must come from our own pages (custom header forces a preflight we never grant, origin must match)41    if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {42      const origin = req.headers.origin;43      if (req.headers['x-spd-csrf'] !== '1' || (origin && origin !== config.origin)) throw new UserError('csrf', 'request refused', 403);44    }45    req.session = await loadSession(req, reply);46  });4748  app.setErrorHandler((err: Error & { statusCode?: number; validation?: unknown; code?: string }, req, reply) => {49    if (err instanceof UserError) return reply.code(err.status).send({ error: err.code, message: err.message, ...err.extra });50    if (err instanceof z.ZodError) return reply.code(400).send({ error: 'invalid_request', message: 'invalid request', issues: err.issues.map((i) => ({ path: i.path.join('.'), message: i.message })) });51    // text PostgreSQL cannot store (NUL bytes, broken encodings) is the caller's mistake, not ours52    if (err.code === '22021' || err.code === '22P05') return reply.code(400).send({ error: 'invalid_request', message: 'invalid characters in request' });53    const status = err.statusCode && err.statusCode >= 400 && err.statusCode < 500 ? err.statusCode : 500;54    if (status === 500) req.log.error({ err: { message: err.message, stack: err.stack, code: err.code } }, 'unhandled error');55    return reply.code(status).send({ error: status === 500 ? 'internal' : status === 413 ? 'too_large' : 'bad_request', message: status === 500 ? 'internal error' : err.message });56  });57  app.setNotFoundHandler((req, reply) => {58    if (req.url.startsWith('/api/') || !config.webDir || req.method !== 'GET') return reply.code(404).send({ error: 'not_found', message: 'not found' });59    return reply.header('Cache-Control', 'no-cache').sendFile('index.html'); // single-page app: every other path is a client route60  });6162  app.get('/api/v1/health/live', async () => ({ ok: true }));63  const readiness = async () => {64    const db = await sql`select 1`.then(() => true, () => false);65    const storage = await statfs(config.blobDir).then((s) => ({ ok: true, freeBytes: Number(s.bavail) * Number(s.bsize), totalBytes: Number(s.blocks) * Number(s.bsize) }), () => ({ ok: false, freeBytes: 0, totalBytes: 0 }));66    const antivirus = await clamdPing();67    const [q] = db ? await sql<{ lag: number | null; failed: number }[]>`select extract(epoch from now() - min(run_at) filter (where status = 'queued' and run_at <= now()))::int as lag, count(*) filter (where status = 'failed')::int as failed from jobs` : [{ lag: null, failed: 0 }];68    return { db, storage, antivirus, queueLagS: q!.lag ?? 0, failedJobs: q!.failed };69  };70  app.get('/api/v1/health/ready', async (_req, reply) => {71    const r = await readiness();72    const ok = r.db && r.storage.ok && (r.antivirus || config.avMode === 'optional');73    return reply.code(ok ? 200 : 503).send({ ok, db: r.db, storage: r.storage.ok, antivirus: r.antivirus });74  });75  app.get('/api/v1/admin/system', { preHandler: [requireUser, async (req) => requireAdmin(req)] }, async () => {76    const r = await readiness();77    const [c] = await sql<{ users: number; files: number; objects: number; bytes: string; shares: number; requests: number; quarantined: number }[]>`select (select count(*)::int from users) as users,78      (select count(*)::int from files where trashed_at is null) as files, (select count(*)::int from storage_objects) as objects, (select coalesce(sum(size), 0)::text from storage_objects) as bytes,79      (select count(*)::int from shares where revoked_at is null) as shares, (select count(*)::int from upload_requests where revoked_at is null) as requests,80      (select count(*)::int from upload_items where status = 'quarantined') as quarantined`;81    return { ...r, avMode: config.avMode, commit: config.commit, counts: { ...c!, bytes: Number(c!.bytes) } };82  });8384  authRoutes(app); fileRoutes(app); uploadRoutes(app); shareRoutes(app); requestRoutes(app);8586  if (config.webDir) {87    await app.register(fastifyStatic, { root: config.webDir, wildcard: false, maxAge: 0,88      setHeaders: (res, path) => { res.header('Cache-Control', /\/assets\//.test(path) ? 'public, max-age=31536000, immutable' : 'no-cache'); } });89  }90  return app;91}92
Files main
Menu

Theme