/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/server.mjs * Purpose : Fastify bootstrap — plugins, auth hook, static/vendor assets, * healthz, daily maintenance, graceful shutdown * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import Fastify from 'fastify'; import fastifyCookie from '@fastify/cookie'; import fastifyFormbody from '@fastify/formbody'; import fastifyStatic from '@fastify/static'; import { config, ensureDataDirs } from './config.mjs'; import { ensureAuthBootstrap } from './auth/password.mjs'; import { SESSION_COOKIE, getApiToken, getSession, pruneSessions } from './auth/session.mjs'; import { getDb } from './db/db.mjs'; import { gcBlobs, storageStats } from './storage/blobs.mjs'; import { purgeTrash } from './storage/nodes.mjs'; import { pruneUploads } from './storage/upload.mjs'; import { pruneShares } from './shares/shares.mjs'; import { pruneRequests } from './shares/requests.mjs'; import { vacuumFts } from './search/search.mjs'; import { scheduleMissingExtractions } from './search/extract.mjs'; import { queueDepth } from './preview/queue.mjs'; import { registerApiV1 } from './api/v1.mjs'; import { registerWebRoutes } from './web/routes.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const startedAt = Date.now(); async function main() { ensureDataDirs(); getDb(); await ensureAuthBootstrap(); const app = Fastify({ logger: { level: config.logLevel, redact: { paths: [ 'req.headers.authorization', 'req.headers.cookie', 'res.headers["set-cookie"]', ], censor: '[redacted]', }, }, trustProxy: config.trustProxy, bodyLimit: 32 * 1024 * 1024, }); await app.register(fastifyCookie); await app.register(fastifyFormbody); // Upload chunks arrive as raw octet streams; handlers read req.raw directly. app.addContentTypeParser('application/octet-stream', (req, payload, done) => done(null, null)); // The in-browser editor saves file content as text/plain. app.addContentTypeParser('text/plain', { parseAs: 'buffer' }, (req, body, done) => done(null, body)); // ── Authentication decorator (session cookie or Bearer API token) ──── app.decorateRequest('authed', false); app.decorateRequest('authKind', ''); app.addHook('onRequest', async (req) => { const bearer = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1]; if (bearer && getApiToken(bearer)) { req.authed = true; req.authKind = 'token'; return; } const session = getSession(req.cookies?.[SESSION_COOKIE]); if (session) { req.authed = true; req.authKind = 'session'; } }); // ── Static assets (immutable app code + self-hosted vendor bundles) ── await app.register(fastifyStatic, { root: path.join(HERE, 'web', 'assets'), prefix: '/assets/', maxAge: '1h', cacheControl: true, }); const vendor = (prefix, ...segments) => app.register(fastifyStatic, { root: path.resolve(HERE, '..', 'node_modules', ...segments), prefix, decorateReply: false, maxAge: '7d', }); await vendor('/vendor/pdfjs/', 'pdfjs-dist', 'build'); await vendor('/vendor/wavesurfer/', 'wavesurfer.js', 'dist'); await vendor('/vendor/xlsx/', 'xlsx', 'dist'); await vendor('/vendor/fonts/inter/', '@fontsource', 'inter', 'files'); await vendor('/vendor/fonts/jetbrains-mono/', '@fontsource', 'jetbrains-mono', 'files'); // ── Health ──────────────────────────────────────────────────────────── app.get('/healthz', async () => { const stats = storageStats(); return { ok: true, uptimeSec: Math.floor((Date.now() - startedAt) / 1000), nodes: stats.nodeCount, usedBytes: stats.usedBytes, queueDepth: queueDepth(), }; }); registerApiV1(app); registerWebRoutes(app); // ── Daily maintenance ──────────────────────────────────────────────── const maintenance = async () => { try { const purged = purgeTrash(config.trashRetentionDays); const gcd = await gcBlobs(); const shares = pruneShares(); const requests = pruneRequests(); const sessions = pruneSessions(); const uploads = await pruneUploads(); vacuumFts(); const queued = scheduleMissingExtractions(); app.log.info({ purged, gcd, shares, requests, sessions, uploads, queued }, 'maintenance done'); } catch (err) { app.log.error({ err }, 'maintenance failed'); } }; setTimeout(maintenance, 30_000).unref(); setInterval(maintenance, 24 * 3_600_000).unref(); await app.listen({ port: config.port, host: config.host }); app.log.info(`SPB Drive listening on http://${config.host}:${config.port} → ${config.publicUrl}`); const shutdown = async (signal) => { app.log.info({ signal }, 'shutting down'); await app.close(); process.exit(0); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); } main().catch((err) => { console.error('Fatal boot error:', err); process.exit(1); });