SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
7.8 KB · 234 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Git — Personal Git Platform4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/server.mjs8 *  Purpose : Fastify bootstrap — wires config, git core, API, web UI9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import Fastify from 'fastify';14import rateLimit from '@fastify/rate-limit';15import fastifyStatic from '@fastify/static';16import { join } from 'node:path';17import { pathToFileURL } from 'node:url';18import process from 'node:process';19import { loadConfig, ensureDirs, PROJECT_ROOT } from './config.mjs';20import { Repos } from './git/repo.mjs';21import { MetaStore, ActivityFeed } from './lib/store.mjs';22import { Cache } from './lib/cache.mjs';23import { TokenStore } from './auth/token.mjs';24import { ReleaseStore } from './git/releases.mjs';25import { registerSmartHttp } from './git/smart-http.mjs';26import { registerHookRoutes, ensureHooksInstalled } from './git/hooks.mjs';27import { registerApi } from './api/v1.mjs';28import { registerWeb } from './web/routes.mjs';29import { initHighlighter } from './render/highlight.mjs';30import { renderMarkdown, renderPlain } from './render/markdown.mjs';31import { renderOgImage } from './render/og-image.mjs';32import { repoOverview } from './lib/overview.mjs';33import { buildSearchIndex } from './lib/search.mjs';34import { contributionCalendar } from './stats/activity.mjs';3536const CSP = [37  "default-src 'self'",38  "img-src * data:",39  "script-src 'self'",40  "style-src 'self' 'unsafe-inline'",41  "font-src 'self'",42  "connect-src 'self'",43  "object-src 'none'",44  "base-uri 'self'",45  "form-action 'self'",46  "frame-ancestors 'none'",47].join('; ');4849/**50 * Shared application context passed to every route module.51 * @param {ReturnType<typeof loadConfig>} config52 */53export function buildContext(config) {54  const ctx = {55    config,56    repos: new Repos(config.gitRoot, config.trashDir),57    meta: new MetaStore(config.dataDir),58    tokens: new TokenStore(config.dataDir),59    cache: new Cache(config.cacheDir),60    activity: new ActivityFeed(config.dataDir),61    releases: new ReleaseStore(config.dataDir),62    startedAt: Date.now(),63  };6465  /**66   * Render + cache a repo README for a given commit.67   * @returns {Promise<{html: string, path: string}|null>}68   */69  ctx.renderReadme = async (repo, sha) => {70    const cachePath = ctx.cache.repoPath(repo, sha, 'readme.json');71    const cached = ctx.cache.getJSON(cachePath);72    if (cached) return cached;73    const readme = await ctx.repos.readme(repo, sha);74    if (!readme) return null;75    const source = readme.content.toString('utf8');76    const isMarkdown = /\.(md|markdown)$/i.test(readme.path);77    const html = isMarkdown78      ? await renderMarkdown(source, {79          repo,80          ref: sha,81          basePath: readme.path.includes('/') ? readme.path.slice(0, readme.path.lastIndexOf('/')) : '.',82          publicUrl: config.publicUrl,83        })84      : renderPlain(source);85    const result = { html, path: readme.path };86    ctx.cache.setJSON(cachePath, result);87    return result;88  };8990  /**91   * Render + cache the OG card for a repo (or the site card with repo=null).92   * @returns {Promise<Buffer>}93   */94  ctx.ogImage = async (repo) => {95    if (!repo) {96      const path = ctx.cache.path('global', 'og-site.png');97      const hit = ctx.cache.getBuffer(path);98      if (hit) return hit;99      const { buffer } = await renderOgImage(null);100      ctx.cache.set(path, buffer);101      return buffer;102    }103    const overview = await repoOverview(ctx, repo);104    if (!overview) return null;105    const sha = overview.head ?? 'empty';106    const path = ctx.cache.repoPath(repo, sha, 'og.png');107    const hit = ctx.cache.getBuffer(path);108    if (hit) return hit;109    const { buffer } = await renderOgImage({110      name: overview.name,111      description: overview.description,112      languages: overview.languages,113    });114    ctx.cache.set(path, buffer);115    return buffer;116  };117118  /** Post-push cache warmers — recompute what visitors will hit next. */119  ctx.warmers = async (repo) => {120    const overview = await repoOverview(ctx, repo);121    if (overview?.head) {122      await ctx.renderReadme(repo, overview.head).catch(() => null);123      await ctx.ogImage(repo).catch(() => null);124    }125    await buildSearchIndex(ctx).catch(() => null);126    await contributionCalendar(ctx).catch(() => null);127  };128129  return ctx;130}131132/**133 * Build the fully-wired Fastify app (used by main and by the test suite).134 * @param {ReturnType<typeof loadConfig>} config135 * @returns {Promise<{app: import('fastify').FastifyInstance, ctx: object}>}136 */137export async function buildServer(config) {138  ensureDirs(config);139  await initHighlighter();140  const ctx = buildContext(config);141142  const app = Fastify({143    trustProxy: true,144    logger: {145      level: config.logLevel,146      redact: ['req.headers.authorization'],147    },148    bodyLimit: 5 * 1024 * 1024,149  });150151  app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => done(null, body));152  // Release-asset uploads stream straight to disk — never buffered in memory.153  app.addContentTypeParser('application/octet-stream', (_req, payload, done) => done(null, payload));154155  await app.register(rateLimit, {156    max: 200,157    timeWindow: '1 minute',158    allowList: (request) => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.ip),159  });160161  app.addHook('onSend', (request, reply, payload, done) => {162    reply.header('X-Content-Type-Options', 'nosniff');163    reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');164    const type = String(reply.getHeader('content-type') ?? '');165    if (type.includes('text/html')) {166      reply.header('Content-Security-Policy', CSP);167      reply.header('X-Frame-Options', 'DENY');168    }169    done(null, payload);170  });171172  await app.register(fastifyStatic, {173    root: join(PROJECT_ROOT, 'src/web/assets'),174    prefix: '/assets/',175    maxAge: config.isDev ? 0 : '1d',176    immutable: false,177    index: false,178  });179  await app.register(fastifyStatic, {180    root: join(PROJECT_ROOT, 'node_modules/mermaid/dist'),181    prefix: '/assets/vendor/',182    maxAge: config.isDev ? 0 : '7d',183    decorateReply: false,184    index: false,185  });186187  app.get('/healthz', async () => {188    const footprint = ctx.cache.footprint();189    return {190      status: 'ok',191      uptimeSeconds: Math.round((Date.now() - ctx.startedAt) / 1000),192      repos: ctx.repos.list().length,193      cache: footprint,194      version: '1.0.0',195    };196  });197198  registerSmartHttp(app, ctx);199  registerHookRoutes(app, ctx);200  registerApi(app, ctx);201  await registerWeb(app, ctx);202203  return { app, ctx };204}205206/** Entrypoint. */207async function main() {208  const config = loadConfig();209  const { app, ctx } = await buildServer(config);210  ensureHooksInstalled(ctx);211  try {212    await app.listen({ port: config.port, host: config.host });213    app.log.info(`SPB Git listening on http://${config.host}:${config.port} (public: ${config.publicUrl})`);214  } catch (err) {215    app.log.error(err);216    process.exit(1);217  }218  for (const signal of ['SIGINT', 'SIGTERM']) {219    process.on(signal, async () => {220      app.log.info({ signal }, 'shutting down');221      await app.close();222      process.exit(0);223    });224  }225}226227// Run when invoked directly (node src/server.mjs) or under pm2 fork mode,228// where argv[1] is pm2's ProcessContainerFork and pm_exec_path is our script.229const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;230const underPm2 = (process.env.pm_exec_path ?? '').endsWith('server.mjs');231if (invokedDirectly || underPm2) {232  main();233}234