feat: JSON API v1 + Fastify bootstrap with security headers and rate limiting
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 | ||