SPB Git

spb/spbgit Public MIT

SPB Git — the platform hosting itself

JavaScript 73.9% CSS 11.7% Nunjucks 11.6% Shell 2.7%
10.6 KB · 246 lines javascript
Raw Blame History
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