SPB Git

spb/drive Public

SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.

JavaScript 82.7% CSS 10.6% Nunjucks 3.6% Shell 1.8% SQL 1.3%
5.7 KB · 151 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/server.mjs8 *  Purpose : Fastify bootstrap — plugins, auth hook, static/vendor assets,9 *            healthz, daily maintenance, graceful shutdown10 *  License : MIT © Simon-Pierre Boucher11 * ─────────────────────────────────────────────12 */1314import path from 'node:path';15import { fileURLToPath } from 'node:url';16import Fastify from 'fastify';17import fastifyCookie from '@fastify/cookie';18import fastifyFormbody from '@fastify/formbody';19import fastifyStatic from '@fastify/static';20import { config, ensureDataDirs } from './config.mjs';21import { ensureAuthBootstrap } from './auth/password.mjs';22import { SESSION_COOKIE, getApiToken, getSession, pruneSessions } from './auth/session.mjs';23import { getDb } from './db/db.mjs';24import { gcBlobs, storageStats } from './storage/blobs.mjs';25import { purgeTrash } from './storage/nodes.mjs';26import { pruneUploads } from './storage/upload.mjs';27import { pruneShares } from './shares/shares.mjs';28import { pruneRequests } from './shares/requests.mjs';29import { vacuumFts } from './search/search.mjs';30import { scheduleMissingExtractions } from './search/extract.mjs';31import { queueDepth } from './preview/queue.mjs';32import { registerApiV1 } from './api/v1.mjs';33import { registerWebRoutes } from './web/routes.mjs';3435const HERE = path.dirname(fileURLToPath(import.meta.url));36const startedAt = Date.now();3738async function main() {39  ensureDataDirs();40  getDb();41  await ensureAuthBootstrap();4243  const app = Fastify({44    logger: {45      level: config.logLevel,46      redact: {47        paths: [48          'req.headers.authorization', 'req.headers.cookie',49          'res.headers["set-cookie"]',50        ],51        censor: '[redacted]',52      },53    },54    trustProxy: config.trustProxy,55    bodyLimit: 32 * 1024 * 1024,56  });5758  await app.register(fastifyCookie);59  await app.register(fastifyFormbody);6061  // Upload chunks arrive as raw octet streams; handlers read req.raw directly.62  app.addContentTypeParser('application/octet-stream', (req, payload, done) => done(null, null));63  // The in-browser editor saves file content as text/plain.64  app.addContentTypeParser('text/plain', { parseAs: 'buffer' }, (req, body, done) => done(null, body));6566  // ── Authentication decorator (session cookie or Bearer API token) ────67  app.decorateRequest('authed', false);68  app.decorateRequest('authKind', '');69  app.addHook('onRequest', async (req) => {70    const bearer = req.headers.authorization?.match(/^Bearer\s+(.+)$/i)?.[1];71    if (bearer && getApiToken(bearer)) {72      req.authed = true;73      req.authKind = 'token';74      return;75    }76    const session = getSession(req.cookies?.[SESSION_COOKIE]);77    if (session) {78      req.authed = true;79      req.authKind = 'session';80    }81  });8283  // ── Static assets (immutable app code + self-hosted vendor bundles) ──84  await app.register(fastifyStatic, {85    root: path.join(HERE, 'web', 'assets'),86    prefix: '/assets/',87    maxAge: '1h',88    cacheControl: true,89  });90  const vendor = (prefix, ...segments) =>91    app.register(fastifyStatic, {92      root: path.resolve(HERE, '..', 'node_modules', ...segments),93      prefix, decorateReply: false, maxAge: '7d',94    });95  await vendor('/vendor/pdfjs/', 'pdfjs-dist', 'build');96  await vendor('/vendor/wavesurfer/', 'wavesurfer.js', 'dist');97  await vendor('/vendor/xlsx/', 'xlsx', 'dist');98  await vendor('/vendor/fonts/inter/', '@fontsource', 'inter', 'files');99  await vendor('/vendor/fonts/jetbrains-mono/', '@fontsource', 'jetbrains-mono', 'files');100101  // ── Health ────────────────────────────────────────────────────────────102  app.get('/healthz', async () => {103    const stats = storageStats();104    return {105      ok: true,106      uptimeSec: Math.floor((Date.now() - startedAt) / 1000),107      nodes: stats.nodeCount,108      usedBytes: stats.usedBytes,109      queueDepth: queueDepth(),110    };111  });112113  registerApiV1(app);114  registerWebRoutes(app);115116  // ── Daily maintenance ────────────────────────────────────────────────117  const maintenance = async () => {118    try {119      const purged = purgeTrash(config.trashRetentionDays);120      const gcd = await gcBlobs();121      const shares = pruneShares();122      const requests = pruneRequests();123      const sessions = pruneSessions();124      const uploads = await pruneUploads();125      vacuumFts();126      const queued = scheduleMissingExtractions();127      app.log.info({ purged, gcd, shares, requests, sessions, uploads, queued }, 'maintenance done');128    } catch (err) {129      app.log.error({ err }, 'maintenance failed');130    }131  };132  setTimeout(maintenance, 30_000).unref();133  setInterval(maintenance, 24 * 3_600_000).unref();134135  await app.listen({ port: config.port, host: config.host });136  app.log.info(`SPB Drive listening on http://${config.host}:${config.port} → ${config.publicUrl}`);137138  const shutdown = async (signal) => {139    app.log.info({ signal }, 'shutting down');140    await app.close();141    process.exit(0);142  };143  process.on('SIGINT', () => shutdown('SIGINT'));144  process.on('SIGTERM', () => shutdown('SIGTERM'));145}146147main().catch((err) => {148  console.error('Fatal boot error:', err);149  process.exit(1);150});151