/** * ───────────────────────────────────────────── * SPB Git — Personal Git Platform * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/server.mjs * Purpose : Fastify bootstrap — wires config, git core, API, web UI * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import Fastify from 'fastify'; import rateLimit from '@fastify/rate-limit'; import fastifyStatic from '@fastify/static'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import process from 'node:process'; import { loadConfig, ensureDirs, PROJECT_ROOT } from './config.mjs'; import { Repos } from './git/repo.mjs'; import { MetaStore, ActivityFeed } from './lib/store.mjs'; import { Cache } from './lib/cache.mjs'; import { TokenStore } from './auth/token.mjs'; import { ReleaseStore } from './git/releases.mjs'; import { registerSmartHttp } from './git/smart-http.mjs'; import { registerHookRoutes, ensureHooksInstalled } from './git/hooks.mjs'; import { registerApi } from './api/v1.mjs'; import { registerWeb } from './web/routes.mjs'; import { initHighlighter } from './render/highlight.mjs'; import { renderMarkdown, renderPlain } from './render/markdown.mjs'; import { renderOgImage } from './render/og-image.mjs'; import { repoOverview } from './lib/overview.mjs'; import { buildSearchIndex } from './lib/search.mjs'; import { contributionCalendar } from './stats/activity.mjs'; const CSP = [ "default-src 'self'", "img-src * data:", "script-src 'self'", "style-src 'self' 'unsafe-inline'", "font-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'self'", "frame-ancestors 'none'", ].join('; '); /** * Shared application context passed to every route module. * @param {ReturnType} config */ export function buildContext(config) { const ctx = { config, repos: new Repos(config.gitRoot, config.trashDir), meta: new MetaStore(config.dataDir), tokens: new TokenStore(config.dataDir), cache: new Cache(config.cacheDir), activity: new ActivityFeed(config.dataDir), releases: new ReleaseStore(config.dataDir), startedAt: Date.now(), }; /** * Render + cache a repo README for a given commit. * @returns {Promise<{html: string, path: string}|null>} */ ctx.renderReadme = async (repo, sha) => { const cachePath = ctx.cache.repoPath(repo, sha, 'readme.json'); const cached = ctx.cache.getJSON(cachePath); if (cached) return cached; const readme = await ctx.repos.readme(repo, sha); if (!readme) return null; const source = readme.content.toString('utf8'); const isMarkdown = /\.(md|markdown)$/i.test(readme.path); const html = isMarkdown ? await renderMarkdown(source, { repo, ref: sha, basePath: readme.path.includes('/') ? readme.path.slice(0, readme.path.lastIndexOf('/')) : '.', publicUrl: config.publicUrl, }) : renderPlain(source); const result = { html, path: readme.path }; ctx.cache.setJSON(cachePath, result); return result; }; /** * Render + cache the OG card for a repo (or the site card with repo=null). * @returns {Promise} */ ctx.ogImage = async (repo) => { if (!repo) { const path = ctx.cache.path('global', 'og-site.png'); const hit = ctx.cache.getBuffer(path); if (hit) return hit; const { buffer } = await renderOgImage(null); ctx.cache.set(path, buffer); return buffer; } const overview = await repoOverview(ctx, repo); if (!overview) return null; const sha = overview.head ?? 'empty'; const path = ctx.cache.repoPath(repo, sha, 'og.png'); const hit = ctx.cache.getBuffer(path); if (hit) return hit; const { buffer } = await renderOgImage({ name: overview.name, description: overview.description, languages: overview.languages, }); ctx.cache.set(path, buffer); return buffer; }; /** Post-push cache warmers — recompute what visitors will hit next. */ ctx.warmers = async (repo) => { const overview = await repoOverview(ctx, repo); if (overview?.head) { await ctx.renderReadme(repo, overview.head).catch(() => null); await ctx.ogImage(repo).catch(() => null); } await buildSearchIndex(ctx).catch(() => null); await contributionCalendar(ctx).catch(() => null); }; return ctx; } /** * Build the fully-wired Fastify app (used by main and by the test suite). * @param {ReturnType} config * @returns {Promise<{app: import('fastify').FastifyInstance, ctx: object}>} */ export async function buildServer(config) { ensureDirs(config); await initHighlighter(); const ctx = buildContext(config); const app = Fastify({ trustProxy: true, logger: { level: config.logLevel, redact: ['req.headers.authorization'], }, bodyLimit: 5 * 1024 * 1024, }); app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => done(null, body)); // Release-asset uploads stream straight to disk — never buffered in memory. app.addContentTypeParser('application/octet-stream', (_req, payload, done) => done(null, payload)); await app.register(rateLimit, { max: 200, timeWindow: '1 minute', allowList: (request) => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.ip), }); app.addHook('onSend', (request, reply, payload, done) => { reply.header('X-Content-Type-Options', 'nosniff'); reply.header('Referrer-Policy', 'strict-origin-when-cross-origin'); const type = String(reply.getHeader('content-type') ?? ''); if (type.includes('text/html')) { reply.header('Content-Security-Policy', CSP); reply.header('X-Frame-Options', 'DENY'); } done(null, payload); }); await app.register(fastifyStatic, { root: join(PROJECT_ROOT, 'src/web/assets'), prefix: '/assets/', maxAge: config.isDev ? 0 : '1d', immutable: false, index: false, }); await app.register(fastifyStatic, { root: join(PROJECT_ROOT, 'node_modules/mermaid/dist'), prefix: '/assets/vendor/', maxAge: config.isDev ? 0 : '7d', decorateReply: false, index: false, }); app.get('/healthz', async () => { const footprint = ctx.cache.footprint(); return { status: 'ok', uptimeSeconds: Math.round((Date.now() - ctx.startedAt) / 1000), repos: ctx.repos.list().length, cache: footprint, version: '1.0.0', }; }); registerSmartHttp(app, ctx); registerHookRoutes(app, ctx); registerApi(app, ctx); await registerWeb(app, ctx); return { app, ctx }; } /** Entrypoint. */ async function main() { const config = loadConfig(); const { app, ctx } = await buildServer(config); ensureHooksInstalled(ctx); try { await app.listen({ port: config.port, host: config.host }); app.log.info(`SPB Git listening on http://${config.host}:${config.port} (public: ${config.publicUrl})`); } catch (err) { app.log.error(err); process.exit(1); } for (const signal of ['SIGINT', 'SIGTERM']) { process.on(signal, async () => { app.log.info({ signal }, 'shutting down'); await app.close(); process.exit(0); }); } } // Run when invoked directly (node src/server.mjs) or under pm2 fork mode, // where argv[1] is pm2's ProcessContainerFork and pm_exec_path is our script. const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; const underPm2 = (process.env.pm_exec_path ?? '').endsWith('server.mjs'); if (invokedDirectly || underPm2) { main(); }