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/api/v1.mjs8 * Purpose : JSON API — public reads, PAT-gated writes9 * License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { z } from 'zod';14import { isValidRepoName } from '../lib/util.mjs';15import { makeRequireAuth } from '../auth/token.mjs';16import { installHooks } from '../git/hooks.mjs';17import { repoOverview, allOverviews, siteStats } from '../lib/overview.mjs';18import { computeLanguages } from '../stats/languages.mjs';19import { isValidAssetName, isValidTagName } from '../git/releases.mjs';2021const ASSET_BODY_LIMIT = 4 * 1024 * 1024 * 1024 + 1024; // 4 GiB + header slack2223const TOPIC_RE = /^[a-z0-9][a-z0-9-]{0,34}$/;2425const createSchema = z.object({26 name: z.string().min(1).max(64),27 description: z.string().max(350).optional().default(''),28 topics: z.array(z.string().regex(TOPIC_RE)).max(10).optional().default([]),29 homepage: z.union([z.literal(''), z.string().url().max(300)]).optional().default(''),30 pinned: z.boolean().optional().default(false),31 defaultBranch: z.string().regex(/^[\w./-]{1,100}$/).optional().default('main'),32});3334const patchSchema = z.object({35 description: z.string().max(350).optional(),36 topics: z.array(z.string().regex(TOPIC_RE)).max(10).optional(),37 homepage: z.union([z.literal(''), z.string().url().max(300)]).optional(),38 pinned: z.boolean().optional(),39 defaultBranch: z.string().regex(/^[\w./-]{1,100}$/).optional(),40}).strict();4142const tokenSchema = z.object({43 label: z.string().min(1).max(80).optional().default('unnamed'),44});4546/** Uniform error payload — CLAUDE.md §7 envelope. */47function apiError(reply, status, code, message) {48 return reply.code(status).send({ error: { code, message } });49}5051/** Zod-parse a body or reply 400. Returns null on failure. */52function parseBody(schema, request, reply) {53 const result = schema.safeParse(request.body ?? {});54 if (!result.success) {55 apiError(reply, 400, 'validation_failed', result.error.issues.map((i) => `${i.path.join('.') || 'body'}: ${i.message}`).join('; '));56 return null;57 }58 return result.data;59}6061/**62 * Register /api/v1 routes.63 * @param {import('fastify').FastifyInstance} app64 * @param {{config, repos, meta, tokens, cache, activity}} ctx65 */66export function registerApi(app, ctx) {67 const requireAuth = makeRequireAuth(ctx.tokens);6869 app.get('/api/v1/repos', async () => {70 const overviews = await allOverviews(ctx);71 return { repos: overviews };72 });7374 app.get('/api/v1/repos/:name', async (request, reply) => {75 const overview = await repoOverview(ctx, request.params.name);76 if (!overview) return apiError(reply, 404, 'not_found', 'repository not found');77 return overview;78 });7980 app.get('/api/v1/repos/:name/languages', async (request, reply) => {81 const { name } = request.params;82 if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');83 const head = await ctx.repos.head(name);84 if (!head) return { languages: [], totalBytes: 0 };85 const cachePath = ctx.cache.repoPath(name, head, 'languages.json');86 return ctx.cache.remember(cachePath, async () => computeLanguages(await ctx.repos.allFiles(name, head)));87 });8889 app.get('/api/v1/repos/:name/commits', async (request, reply) => {90 const { name } = request.params;91 if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');92 const refInput = String(request.query.ref ?? '') || (await ctx.repos.defaultBranch(name));93 const sha = await ctx.repos.resolveRef(name, refInput);94 if (!sha) return apiError(reply, 404, 'ref_not_found', `unknown ref: ${refInput}`);95 const page = Math.max(1, Number(request.query.page) || 1);96 const { commits, hasNext } = await ctx.repos.log(name, sha, { page });97 return { ref: refInput, sha, page, hasNext, commits };98 });99100 app.get('/api/v1/stats', async () => siteStats(ctx));101102 app.post('/api/v1/repos', async (request, reply) => {103 if (!(await requireAuth(request, reply))) return reply;104 const body = parseBody(createSchema, request, reply);105 if (!body) return reply;106 if (!isValidRepoName(body.name)) {107 return apiError(reply, 400, 'invalid_name', 'repo names must match ^[a-z0-9][a-z0-9._-]{0,63}$');108 }109 if (ctx.repos.exists(body.name)) return apiError(reply, 409, 'already_exists', 'repository already exists');110 await ctx.repos.create(body.name, body.defaultBranch);111 installHooks(ctx.repos.dir(body.name), ctx.config.port);112 ctx.meta.upsert(body.name, {113 description: body.description,114 topics: body.topics,115 homepage: body.homepage,116 pinned: body.pinned,117 created: new Date().toISOString(),118 defaultBranch: body.defaultBranch,119 });120 const overview = await repoOverview(ctx, body.name);121 return reply.code(201).send(overview);122 });123124 app.patch('/api/v1/repos/:name', async (request, reply) => {125 if (!(await requireAuth(request, reply))) return reply;126 const { name } = request.params;127 if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');128 const body = parseBody(patchSchema, request, reply);129 if (!body) return reply;130 if (body.defaultBranch) {131 const branches = await ctx.repos.branches(name);132 if (branches.length > 0 && !branches.some((b) => b.name === body.defaultBranch)) {133 return apiError(reply, 400, 'unknown_branch', `branch does not exist: ${body.defaultBranch}`);134 }135 await ctx.repos.setDefaultBranch(name, body.defaultBranch);136 }137 ctx.meta.upsert(name, body);138 ctx.cache.bustRepo(name);139 return repoOverview(ctx, name);140 });141142 app.delete('/api/v1/repos/:name', async (request, reply) => {143 if (!(await requireAuth(request, reply))) return reply;144 const { name } = request.params;145 if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');146 const trashPath = ctx.repos.softDelete(name);147 ctx.meta.remove(name);148 ctx.cache.bustRepo(name);149 ctx.releases.removeRepo(name);150 request.log.info({ name, trashPath }, 'repository soft-deleted');151 return { deleted: name, recoverable: true, note: 'moved to trash — recoverable for 30 days' };152 });153154 app.post('/api/v1/tokens', async (request, reply) => {155 if (!(await requireAuth(request, reply))) return reply;156 const body = parseBody(tokenSchema, request, reply);157 if (!body) return reply;158 const { token, record } = await ctx.tokens.create(body.label);159 const { hash: _hash, ...safe } = record;160 return reply.code(201).send({ token, ...safe });161 });162163 app.get('/api/v1/tokens', async (request, reply) => {164 if (!(await requireAuth(request, reply))) return reply;165 return { tokens: ctx.tokens.list() };166 });167168 app.delete('/api/v1/tokens/:id', async (request, reply) => {169 if (!(await requireAuth(request, reply))) return reply;170 const ok = ctx.tokens.revoke(String(request.params.id));171 if (!ok) return apiError(reply, 404, 'not_found', 'token not found');172 return { revoked: request.params.id };173 });174175 // ── Releases: binary assets attached to tags (GitHub-style) ──────176 app.get('/api/v1/repos/:name/releases', async (request, reply) => {177 const { name } = request.params;178 if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');179 const tags = await ctx.repos.tags(name);180 const assets = ctx.releases.list(name);181 const releases = tags.map((tag) => {182 const key = tag.name.replaceAll('/', '_');183 return {184 tag: tag.name,185 date: tag.date,186 subject: tag.subject,187 assets: (assets[key] ?? []).map((a) => ({188 ...a,189 url: `${ctx.config.publicUrl}/releases/${name}/${key}/${encodeURIComponent(a.name)}`,190 })),191 };192 });193 return { releases };194 });195196 app.put('/api/v1/repos/:name/releases/:tag/assets/:asset', {197 bodyLimit: ASSET_BODY_LIMIT,198 }, async (request, reply) => {199 if (!(await requireAuth(request, reply))) return reply;200 const { name, tag, asset } = request.params;201 if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');202 if (!isValidTagName(tag)) return apiError(reply, 400, 'invalid_tag', 'invalid tag name');203 if (!isValidAssetName(asset)) {204 return apiError(reply, 400, 'invalid_asset', 'asset names must match ^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$');205 }206 const tags = await ctx.repos.tags(name);207 const tagKey = tag.replaceAll('/', '_');208 if (!tags.some((t) => t.name.replaceAll('/', '_') === tagKey)) {209 return apiError(reply, 400, 'unknown_tag', `tag does not exist: ${tag} — push the tag first (git push --tags)`);210 }211 if (!request.body || typeof request.body.pipe !== 'function') {212 return apiError(reply, 400, 'no_body', 'send the file as the request body with Content-Type: application/octet-stream');213 }214 try {215 const saved = await ctx.releases.save(name, tagKey, asset, request.body);216 request.log.info({ name, tag, asset, size: saved.size }, 'release asset uploaded');217 return reply.code(201).send({218 ...saved,219 tag,220 url: `${ctx.config.publicUrl}/releases/${name}/${tagKey}/${encodeURIComponent(asset)}`,221 });222 } catch (err) {223 return apiError(reply, 400, 'upload_failed', err.message);224 }225 });226227 app.delete('/api/v1/repos/:name/releases/:tag/assets/:asset', async (request, reply) => {228 if (!(await requireAuth(request, reply))) return reply;229 const { name, tag, asset } = request.params;230 if (!ctx.repos.exists(name)) return apiError(reply, 404, 'not_found', 'repository not found');231 if (!isValidTagName(tag) || !isValidAssetName(asset)) {232 return apiError(reply, 400, 'invalid_name', 'invalid tag or asset name');233 }234 const ok = ctx.releases.remove(name, tag.replaceAll('/', '_'), asset);235 if (!ok) return apiError(reply, 404, 'not_found', 'asset not found');236 return { deleted: asset, tag };237 });238239 // Auth ping used by `spbgit doctor`.240 app.get('/api/v1/whoami', async (request, reply) => {241 const record = await requireAuth(request, reply);242 if (!record) return reply;243 return { owner: ctx.config.owner.name, tokenLabel: record.label, tokenId: record.id };244 });245}246