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 // '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