spb/spbgit Public MIT
SPB Git — the platform hosting itself
JavaScript 73.9%
CSS 11.7%
Nunjucks 11.6%
Shell 2.7%
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