/** * Apply the seed in a GitHub repo to this site. * * POST /_emdash/api/settings/seed/apply-repo { owner, repo, ref? } * * Downloads the repo archive (public repos need no token), composes the seed * from its `seed/` directory (or a legacy single seed.json) and applies it * update-on-conflict, content included; site identity settings are skipped. * How a project takes its theme's seed, at provisioning and on every roll. */ import type { APIRoute } from "astro"; import { z } from "zod"; import { requirePerm } from "#api/authorize.js"; import { apiError, apiSuccess, handleError } from "#api/error.js"; import { isParseError, parseBody } from "#api/parse.js"; import { OptionsRepository } from "#db/repositories/options.js"; import { applySeed } from "#seed/apply.js"; import { composeSeed } from "#seed/fs.js"; import { validateSeed } from "#seed/validate.js"; import { extractTarball } from "../../../../../utils/tarball.js"; export const prerender = false; const NAME = /^[A-Za-z0-9_.-]{1,100}$/; const BodySchema = z.object({ owner: z.string().regex(NAME), repo: z.string().regex(NAME), ref: z.string().max(200).optional(), }); const IDENTITY_KEYS = ["title", "tagline", "url"] as const; export const POST: APIRoute = async ({ locals, request }) => { const { emdash, user } = locals; if (!emdash?.db) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); const denied = requirePerm(user, "settings:manage"); if (denied) return denied; const body = await parseBody(request, BodySchema); if (isParseError(body)) return body; try { const ref = body.ref ? `/${encodeURIComponent(body.ref)}` : ""; // Anonymous GitHub API calls from shared egress IPs are rate-limited // into 403s; send the site's connected-repo token when there is one. const ghToken = await new OptionsRepository(emdash.db).get("github:token"); const headers: Record = { "User-Agent": "premium-cms", Accept: "application/vnd.github+json", }; if (ghToken) headers.Authorization = `Bearer ${ghToken}`; const res = await fetch( `https://api.github.com/repos/${body.owner}/${body.repo}/tarball${ref}`, { headers, redirect: "follow" }, ); if (!res.ok) { return apiError( "REPO_UNAVAILABLE", `Could not download ${body.owner}/${body.repo} (${res.status})`, 502, ); } const files = await extractTarball( await res.arrayBuffer(), (p) => p.startsWith("seed/") || p === "seed.json", ); const tree = new Map(); const dec = new TextDecoder(); for (const [p, data] of files) if (p.endsWith(".json")) tree.set(p, dec.decode(data)); const seed = composeSeed(tree); if (!seed) return apiSuccess({ applied: false, reason: "no seed in repo" }); const validation = validateSeed(seed); if (!validation.valid) { return apiError("INVALID_SEED", `Invalid seed: ${validation.errors.join(", ")}`, 400); } if (seed.settings) { const settings = { ...seed.settings } as Record; for (const k of IDENTITY_KEYS) delete settings[k]; seed.settings = settings as typeof seed.settings; } const result = await applySeed(emdash.db, seed, { includeContent: true, onConflict: "update", storage: emdash.storage ?? undefined, }); return apiSuccess({ applied: true, files: tree.size, ...result }); } catch (error) { return handleError(error, "Failed to apply the repo's seed", "SEED_ERROR"); } };