SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%

feat: JSON API v1 + Fastify bootstrap with security headers and rate limiting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed yesterday (Aug 10, 2026) parent d681e69

Showing 2 changed files with +402 and −0

added src/api/v1.mjs +177 −0
@@ -0,0 +1,177 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/api/v1.mjs
8 + * Purpose : JSON API — public reads, PAT-gated writes
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import { z } from 'zod';
14 +import { isValidRepoName } from '../lib/util.mjs';
15 +import { makeRequireAuth } from '../auth/token.mjs';
16 +import { installHooks } from '../git/hooks.mjs';
17 +import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs';
18 +import { computeLanguages } from '../stats/languages.mjs';
19 +
20 +const TOPIC_RE = /^[a-z0-9][a-z0-9-]{0,34}$/;
21 +
22 +const createSchema = z.object({
23 + name: z.string().min(1).max(64),
24 + description: z.string().max(350).optional().default(''),
25 + topics: z.array(z.string().regex(TOPIC_RE)).max(10).optional().default([]),
26 + homepage: z.union([z.literal(''), z.string().url().max(300)]).optional().default(''),
27 + pinned: z.boolean().optional().default(false),
28 + defaultBranch: z.string().regex(/^[\w./-]{1,100}$/).optional().default('main'),
29 +});
30 +
31 +const patchSchema = z.object({
32 + description: z.string().max(350).optional(),
33 + topics: z.array(z.string().regex(TOPIC_RE)).max(10).optional(),
34 + homepage: z.union([z.literal(''), z.string().url().max(300)]).optional(),
35 + pinned: z.boolean().optional(),
36 + defaultBranch: z.string().regex(/^[\w./-]{1,100}$/).optional(),
37 +}).strict();
38 +
39 +const tokenSchema = z.object({
40 + label: z.string().min(1).max(80).optional().default('unnamed'),
41 +});
42 +
43 +/** Uniform error payload — CLAUDE.md §7 envelope. */
44 +function apiError(reply, status, code, message) {
45 + return reply.code(status).send({ error: { code, message } });
46 +}
47 +
48 +/** Zod-parse a body or reply 400. Returns null on failure. */
49 +function parseBody(schema, request, reply) {
50 + const result = schema.safeParse(request.body ?? {});
51 + if (!result.success) {
52 + apiError(reply, 400, 'validation_failed', result.error.issues.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`).join('; '));
53 + return null;
54 + }
55 + return result.data;
56 +}
57 +
58 +/**
59 + * Register /api/v1 routes.
60 + * @param {import('fastify').FastifyInstance} app
61 + * @param {{config, repos, meta, tokens, cache, activity}} ctx
62 + */
63 +export function registerApi(app, ctx) {
64 + const requireAuth = makeRequireAuth(ctx.tokens);
65 +
66 + app.get('/api/v1/repos', async () => {
67 + const overviews = await allOverviews(ctx);
68 + return { repos: overviews };
69 + });
70 +
71 + app.get('/api/v1/repos/:name', async (request, reply) => {
72 + const overview = await repoOverview(ctx, request.params.name);
73 + if (!overview) return apiError(reply, 404, 'not_found', 'repository not found');
74 + return overview;
75 + });
76 +
77 + app.get('/api/v1/repos/:name/languages', async (request, reply) => {
78 + const { name } = request.params;
79 + if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');
80 + const head = await ctx.repos.head(name);
81 + if (!head) return { languages: [], totalBytes: 0 };
82 + const cachePath = ctx.cache.repoPath(name, head, 'languages.json');
83 + return ctx.cache.remember(cachePath, async () => computeLanguages(await ctx.repos.allFiles(name, head)));
84 + });
85 +
86 + app.get('/api/v1/repos/:name/commits', async (request, reply) => {
87 + const { name } = request.params;
88 + if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');
89 + const refInput = String(request.query.ref ?? '') || (await ctx.repos.defaultBranch(name));
90 + const sha = await ctx.repos.resolveRef(name, refInput);
91 + if (!sha) return apiError(reply, 404, 'ref_not_found', `unknown ref: ${refInput}`);
92 + const page = Math.max(1, Number(request.query.page) || 1);
93 + const { commits, hasNext } = await ctx.repos.log(name, sha, { page });
94 + return { ref: refInput, sha, page, hasNext, commits };
95 + });
96 +
97 + app.get('/api/v1/stats', async () => siteStats(ctx));
98 +
99 + app.post('/api/v1/repos', async (request, reply) => {
100 + if (!(await requireAuth(request, reply))) return reply;
101 + const body = parseBody(createSchema, request, reply);
102 + if (!body) return reply;
103 + if (!isValidRepoName(body.name)) {
104 + return apiError(reply, 400, 'invalid_name', 'repo names must match ^[a-z0-9][a-z0-9._-]{0,63}$');
105 + }
106 + if (ctx.repos.exists(body.name)) return apiError(reply, 409, 'already_exists', 'repository already exists');
107 + await ctx.repos.create(body.name, body.defaultBranch);
108 + installHooks(ctx.repos.dir(body.name), ctx.config.port);
109 + ctx.meta.upsert(body.name, {
110 + description: body.description,
111 + topics: body.topics,
112 + homepage: body.homepage,
113 + pinned: body.pinned,
114 + created: new Date().toISOString(),
115 + defaultBranch: body.defaultBranch,
116 + });
117 + const overview = await repoOverview(ctx, body.name);
118 + return reply.code(201).send(overview);
119 + });
120 +
121 + app.patch('/api/v1/repos/:name', async (request, reply) => {
122 + if (!(await requireAuth(request, reply))) return reply;
123 + const { name } = request.params;
124 + if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');
125 + const body = parseBody(patchSchema, request, reply);
126 + if (!body) return reply;
127 + if (body.defaultBranch) {
128 + const branches = await ctx.repos.branches(name);
129 + if (branches.length > 0 && !branches.some((b) => b.name === body.defaultBranch)) {
130 + return apiError(reply, 400, 'unknown_branch', `branch does not exist: ${body.defaultBranch}`);
131 + }
132 + await ctx.repos.setDefaultBranch(name, body.defaultBranch);
133 + }
134 + ctx.meta.upsert(name, body);
135 + ctx.cache.bustRepo(name);
136 + return repoOverview(ctx, name);
137 + });
138 +
139 + app.delete('/api/v1/repos/:name', async (request, reply) => {
140 + if (!(await requireAuth(request, reply))) return reply;
141 + const { name } = request.params;
142 + if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');
143 + const trashPath = ctx.repos.softDelete(name);
144 + ctx.meta.remove(name);
145 + ctx.cache.bustRepo(name);
146 + request.log.info({ name, trashPath }, 'repository soft-deleted');
147 + return { deleted: name, recoverable: true, note: 'moved to trash — recoverable for 30 days' };
148 + });
149 +
150 + app.post('/api/v1/tokens', async (request, reply) => {
151 + if (!(await requireAuth(request, reply))) return reply;
152 + const body = parseBody(tokenSchema, request, reply);
153 + if (!body) return reply;
154 + const { token, record } = await ctx.tokens.create(body.label);
155 + const { hash: _hash, ...safe } = record;
156 + return reply.code(201).send({ token, ...safe });
157 + });
158 +
159 + app.get('/api/v1/tokens', async (request, reply) => {
160 + if (!(await requireAuth(request, reply))) return reply;
161 + return { tokens: ctx.tokens.list() };
162 + });
163 +
164 + app.delete('/api/v1/tokens/:id', async (request, reply) => {
165 + if (!(await requireAuth(request, reply))) return reply;
166 + const ok = ctx.tokens.revoke(String(request.params.id));
167 + if (!ok) return apiError(reply, 404, 'not_found', 'token not found');
168 + return { revoked: request.params.id };
169 + });
170 +
171 + // Auth ping used by `spbgit doctor`.
172 + app.get('/api/v1/whoami', async (request, reply) => {
173 + const record = await requireAuth(request, reply);
174 + if (!record) return reply;
175 + return { owner: ctx.config.owner.name, tokenLabel: record.label, tokenId: record.id };
176 + });
177 +}
added src/server.mjs +225 −0
@@ -0,0 +1,225 @@
1 +/**
2 + * ─────────────────────────────────────────────
3 + * SPB Git — Personal Git Platform
4 + * ─────────────────────────────────────────────
5 + * Author : Simon-Pierre Boucher
6 + * Contact : contact@spboucher.ai
7 + * File : src/server.mjs
8 + * Purpose : Fastify bootstrap — wires config, git core, API, web UI
9 + * License : MIT © Simon-Pierre Boucher
10 + * ─────────────────────────────────────────────
11 + */
12 +
13 +import Fastify from 'fastify';
14 +import rateLimit from '@fastify/rate-limit';
15 +import fastifyStatic from '@fastify/static';
16 +import { join } from 'node:path';
17 +import { pathToFileURL } from 'node:url';
18 +import process from 'node:process';
19 +import { loadConfig, ensureDirs, PROJECT_ROOT } from './config.mjs';
20 +import { Repos } from './git/repo.mjs';
21 +import { MetaStore, ActivityFeed } from './lib/store.mjs';
22 +import { Cache } from './lib/cache.mjs';
23 +import { TokenStore } from './auth/token.mjs';
24 +import { registerSmartHttp } from './git/smart-http.mjs';
25 +import { registerHookRoutes, ensureHooksInstalled } from './git/hooks.mjs';
26 +import { registerApi } from './api/v1.mjs';
27 +import { registerWeb } from './web/routes.mjs';
28 +import { initHighlighter } from './render/highlight.mjs';
29 +import { renderMarkdown, renderPlain } from './render/markdown.mjs';
30 +import { renderOgImage } from './render/og-image.mjs';
31 +import { repoOverview } from './lib/overview.mjs';
32 +import { buildSearchIndex } from './lib/search.mjs';
33 +import { contributionCalendar } from './stats/activity.mjs';
34 +
35 +const CSP = [
36 + "default-src 'self'",
37 + "img-src * data:",
38 + "script-src 'self'",
39 + "style-src 'self' 'unsafe-inline'",
40 + "font-src 'self'",
41 + "connect-src 'self'",
42 + "object-src 'none'",
43 + "base-uri 'self'",
44 + "form-action 'self'",
45 + "frame-ancestors 'none'",
46 +].join('; ');
47 +
48 +/**
49 + * Shared application context passed to every route module.
50 + * @param {ReturnType<typeof loadConfig>} config
51 + */
52 +export function buildContext(config) {
53 + const ctx = {
54 + config,
55 + repos: new Repos(config.gitRoot, config.trashDir),
56 + meta: new MetaStore(config.dataDir),
57 + tokens: new TokenStore(config.dataDir),
58 + cache: new Cache(config.cacheDir),
59 + activity: new ActivityFeed(config.dataDir),
60 + startedAt: Date.now(),
61 + };
62 +
63 + /**
64 + * Render + cache a repo README for a given commit.
65 + * @returns {Promise<{html: string, path: string}|null>}
66 + */
67 + ctx.renderReadme = async (repo, sha) => {
68 + const cachePath = ctx.cache.repoPath(repo, sha, 'readme.json');
69 + const cached = ctx.cache.getJSON(cachePath);
70 + if (cached) return cached;
71 + const readme = await ctx.repos.readme(repo, sha);
72 + if (!readme) return null;
73 + const source = readme.content.toString('utf8');
74 + const isMarkdown = /\.(md|markdown)$/i.test(readme.path);
75 + const html = isMarkdown
76 + ? await renderMarkdown(source, {
77 + repo,
78 + ref: sha,
79 + basePath: readme.path.includes('/') ? readme.path.slice(0, readme.path.lastIndexOf('/')) : '.',
80 + publicUrl: config.publicUrl,
81 + })
82 + : renderPlain(source);
83 + const result = { html, path: readme.path };
84 + ctx.cache.setJSON(cachePath, result);
85 + return result;
86 + };
87 +
88 + /**
89 + * Render + cache the OG card for a repo (or the site card with repo=null).
90 + * @returns {Promise<Buffer>}
91 + */
92 + ctx.ogImage = async (repo) => {
93 + if (!repo) {
94 + const path = ctx.cache.path('global', 'og-site.png');
95 + const hit = ctx.cache.getBuffer(path);
96 + if (hit) return hit;
97 + const { buffer } = await renderOgImage(null);
98 + ctx.cache.set(path, buffer);
99 + return buffer;
100 + }
101 + const overview = await repoOverview(ctx, repo);
102 + if (!overview) return null;
103 + const sha = overview.head ?? 'empty';
104 + const path = ctx.cache.repoPath(repo, sha, 'og.png');
105 + const hit = ctx.cache.getBuffer(path);
106 + if (hit) return hit;
107 + const { buffer } = await renderOgImage({
108 + name: overview.name,
109 + description: overview.description,
110 + languages: overview.languages,
111 + });
112 + ctx.cache.set(path, buffer);
113 + return buffer;
114 + };
115 +
116 + /** Post-push cache warmers — recompute what visitors will hit next. */
117 + ctx.warmers = async (repo) => {
118 + const overview = await repoOverview(ctx, repo);
119 + if (overview?.head) {
120 + await ctx.renderReadme(repo, overview.head).catch(() => null);
121 + await ctx.ogImage(repo).catch(() => null);
122 + }
123 + await buildSearchIndex(ctx).catch(() => null);
124 + await contributionCalendar(ctx).catch(() => null);
125 + };
126 +
127 + return ctx;
128 +}
129 +
130 +/**
131 + * Build the fully-wired Fastify app (used by main and by the test suite).
132 + * @param {ReturnType<typeof loadConfig>} config
133 + * @returns {Promise<{app: import('fastify').FastifyInstance, ctx: object}>}
134 + */
135 +export async function buildServer(config) {
136 + ensureDirs(config);
137 + await initHighlighter();
138 + const ctx = buildContext(config);
139 +
140 + const app = Fastify({
141 + trustProxy: true,
142 + logger: {
143 + level: config.logLevel,
144 + redact: ['req.headers.authorization'],
145 + },
146 + bodyLimit: 5 * 1024 * 1024,
147 + });
148 +
149 + app.addContentTypeParser('text/plain', { parseAs: 'string' }, (_req, body, done) => done(null, body));
150 +
151 + await app.register(rateLimit, {
152 + max: 200,
153 + timeWindow: '1 minute',
154 + allowList: (request) => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(request.ip),
155 + });
156 +
157 + app.addHook('onSend', (request, reply, payload, done) => {
158 + reply.header('X-Content-Type-Options', 'nosniff');
159 + reply.header('Referrer-Policy', 'strict-origin-when-cross-origin');
160 + const type = String(reply.getHeader('content-type') ?? '');
161 + if (type.includes('text/html')) {
162 + reply.header('Content-Security-Policy', CSP);
163 + reply.header('X-Frame-Options', 'DENY');
164 + }
165 + done(null, payload);
166 + });
167 +
168 + await app.register(fastifyStatic, {
169 + root: join(PROJECT_ROOT, 'src/web/assets'),
170 + prefix: '/assets/',
171 + maxAge: config.isDev ? 0 : '1d',
172 + immutable: false,
173 + index: false,
174 + });
175 + await app.register(fastifyStatic, {
176 + root: join(PROJECT_ROOT, 'node_modules/mermaid/dist'),
177 + prefix: '/assets/vendor/',
178 + maxAge: config.isDev ? 0 : '7d',
179 + decorateReply: false,
180 + index: false,
181 + });
182 +
183 + app.get('/healthz', async () => {
184 + const footprint = ctx.cache.footprint();
185 + return {
186 + status: 'ok',
187 + uptimeSeconds: Math.round((Date.now() - ctx.startedAt) / 1000),
188 + repos: ctx.repos.list().length,
189 + cache: footprint,
190 + version: '1.0.0',
191 + };
192 + });
193 +
194 + registerSmartHttp(app, ctx);
195 + registerHookRoutes(app, ctx);
196 + registerApi(app, ctx);
197 + await registerWeb(app, ctx);
198 +
199 + return { app, ctx };
200 +}
201 +
202 +/** Entrypoint. */
203 +async function main() {
204 + const config = loadConfig();
205 + const { app, ctx } = await buildServer(config);
206 + ensureHooksInstalled(ctx);
207 + try {
208 + await app.listen({ port: config.port, host: config.host });
209 + app.log.info(`SPB Git listening on http://${config.host}:${config.port} (public: ${config.publicUrl})`);
210 + } catch (err) {
211 + app.log.error(err);
212 + process.exit(1);
213 + }
214 + for (const signal of ['SIGINT', 'SIGTERM']) {
215 + process.on(signal, async () => {
216 + app.log.info({ signal }, 'shutting down');
217 + await app.close();
218 + process.exit(0);
219 + });
220 + }
221 +}
222 +
223 +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
224 + main();
225 +}
226