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.9 KB · 235 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  // 'self' (not 'none') so the blob view can embed same-origin PDFs.44  "object-src 'self'",45  "base-uri 'self'",46  "form-action 'self'",47  "frame-ancestors 'none'",48].join('; ');4950/**51 * Shared application context passed to every route module.52 * @param {ReturnType<typeof loadConfig>} config53 */54export function buildContext(config) {55  const ctx = {56    config,57    repos: new Repos(config.gitRoot, config.trashDir),58    meta: new MetaStore(config.dataDir),59    tokens: new TokenStore(config.dataDir),60    cache: new Cache(config.cacheDir),61    activity: new ActivityFeed(config.dataDir),62    releases: new ReleaseStore(config.dataDir),63    startedAt: Date.now(),64  };6566  /**67   * Render + cache a repo README for a given commit.68   * @returns {Promise<{html: string, path: string}|null>}69   */70  ctx.renderReadme = async (repo, sha) => {71    const cachePath = ctx.cache.repoPath(repo, sha, 'readme.json');72    const cached = ctx.cache.getJSON(cachePath);73    if (cached) return cached;74    const readme = await ctx.repos.readme(repo, sha);75    if (!readme) return null;76    const source = readme.content.toString('utf8');77    const isMarkdown = /\.(md|markdown)$/i.test(readme.path);78    const html = isMarkdown79      ? await renderMarkdown(source, {80          repo,81          ref: sha,82          basePath: readme.path.includes('/') ? readme.path.slice(0, readme.path.lastIndexOf('/')) : '.',83          publicUrl: config.publicUrl,84        })85      : renderPlain(source);86    const result = { html, path: readme.path };87    ctx.cache.setJSON(cachePath, result);88    return result;89  };9091  /**92   * Render + cache the OG card for a repo (or the site card with repo=null).93   * @returns {Promise<Buffer>}94   */95  ctx.ogImage = async (repo) => {96    if (!repo) {97      const path = ctx.cache.path('global', 'og-site.png');98      const hit = ctx.cache.getBuffer(path);99      if (hit) return hit;100      const { buffer } = await renderOgImage(null);101      ctx.cache.set(path, buffer);102      return buffer;103    }104    const overview = await repoOverview(ctx, repo);105    if (!overview) return null;106    const sha = overview.head ?? 'empty';107    const path = ctx.cache.repoPath(repo, sha, 'og.png');108    const hit = ctx.cache.getBuffer(path);109    if (hit) return hit;110    const { buffer } = await renderOgImage({111      name: overview.name,112      description: overview.description,113      languages: overview.languages,114    });115    ctx.cache.set(path, buffer);116    return buffer;117  };118119  /** Post-push cache warmers — recompute what visitors will hit next. */120  ctx.warmers = async (repo) => {121    const overview = await repoOverview(ctx, repo);122    if (overview?.head) {123      await ctx.renderReadme(repo, overview.head).catch(() => null);124      await ctx.ogImage(repo).catch(() => null);125    }126    await buildSearchIndex(ctx).catch(() => null);127    await contributionCalendar(ctx).catch(() => null);128  };129130  return ctx;131}132133/**134 * Build the fully-wired Fastify app (used by main and by the test suite).135 * @param {ReturnType<typeof loadConfig>} config136 * @returns {Promise<{app: import('fastify').FastifyInstance, ctx: object}>}137 */138export async function buildServer(config) {139  ensureDirs(config);140  await initHighlighter();141  const ctx = buildContext(config);142143  const app = Fastify({144    trustProxy: true,145    logger: {146      level: config.logLevel,147      redact: ['req.headers.authorization'],148    },149    bodyLimit: 5 * 1024 * 1024,150  });151152  app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => done(null, body));153  // Release-asset uploads stream straight to disk — never buffered in memory.154  app.addContentTypeParser('application/octet-stream', (_req, payload, done) => done(null, payload));155156  await app.register(rateLimit, {157    max: 200,158    timeWindow: '1 minute',159    allowList: (request) => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.ip),160  });161162  app.addHook('onSend', (request, reply, payload, done) => {163    reply.header('X-Content-Type-Options', 'nosniff');164    reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');165    const type = String(reply.getHeader('content-type') ?? '');166    if (type.includes('text/html')) {167      reply.header('Content-Security-Policy', CSP);168      reply.header('X-Frame-Options', 'DENY');169    }170    done(null, payload);171  });172173  await app.register(fastifyStatic, {174    root: join(PROJECT_ROOT, 'src/web/assets'),175    prefix: '/assets/',176    maxAge: config.isDev ? 0 : '1d',177    immutable: false,178    index: false,179  });180  await app.register(fastifyStatic, {181    root: join(PROJECT_ROOT, 'node_modules/mermaid/dist'),182    prefix: '/assets/vendor/',183    maxAge: config.isDev ? 0 : '7d',184    decorateReply: false,185    index: false,186  });187188  app.get('/healthz', async () => {189    const footprint = ctx.cache.footprint();190    return {191      status: 'ok',192      uptimeSeconds: Math.round((Date.now() - ctx.startedAt) / 1000),193      repos: ctx.repos.list().length,194      cache: footprint,195      version: '1.0.0',196    };197  });198199  registerSmartHttp(app, ctx);200  registerHookRoutes(app, ctx);201  registerApi(app, ctx);202  await registerWeb(app, ctx);203204  return { app, ctx };205}206207/** Entrypoint. */208async function main() {209  const config = loadConfig();210  const { app, ctx } = await buildServer(config);211  ensureHooksInstalled(ctx);212  try {213    await app.listen({ port: config.port, host: config.host });214    app.log.info(`SPB Git listening on http://${config.host}:${config.port} (public: ${config.publicUrl})`);215  } catch (err) {216    app.log.error(err);217    process.exit(1);218  }219  for (const signal of ['SIGINT', 'SIGTERM']) {220    process.on(signal, async () => {221      app.log.info({ signal }, 'shutting down');222      await app.close();223      process.exit(0);224    });225  }226}227228// Run when invoked directly (node src/server.mjs) or under pm2 fork mode,229// where argv[1] is pm2's ProcessContainerFork and pm_exec_path is our script.230const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;231const underPm2 = (process.env.pm_exec_path ?? '').endsWith('server.mjs');232if (invokedDirectly || underPm2) {233  main();234}235