{"version":3,"file":"apply-repo.mjs","names":[],"sources":["../../../../../../src/utils/tarball.ts","../../../../../../src/astro/routes/api/settings/seed/apply-repo.ts"],"sourcesContent":["/**\n * Extract a gzipped tarball (e.g. a GitHub repository archive) fully in\n * memory. Bounded so a hostile or oversized archive can't exhaust the Worker.\n */\n\nimport { createGzipDecoder, unpackTar } from \"modern-tar\";\n\nconst MAX_DECOMPRESSED_BYTES = 50 * 1024 * 1024;\nconst MAX_TAR_FILES = 5000;\nconst MAX_FILE_BYTES = 5 * 1024 * 1024;\nconst LEADING_DOT_SLASH = /^\\.\\//;\n\nasync function collectStream(\n\tstream: ReadableStream<Uint8Array>,\n\tlimit: number,\n): Promise<Uint8Array> {\n\tconst reader = stream.getReader();\n\tconst chunks: Uint8Array[] = [];\n\tlet total = 0;\n\ttry {\n\t\tfor (;;) {\n\t\t\tconst { done, value } = await reader.read();\n\t\t\tif (done) break;\n\t\t\ttotal += value.length;\n\t\t\tif (total > limit) throw new Error(`Archive exceeds ${limit} bytes decompressed`);\n\t\t\tchunks.push(value);\n\t\t}\n\t} finally {\n\t\treader.releaseLock();\n\t}\n\tconst out = new Uint8Array(total);\n\tlet offset = 0;\n\tfor (const c of chunks) {\n\t\tout.set(c, offset);\n\t\toffset += c.length;\n\t}\n\treturn out;\n}\n\n/**\n * Files in the archive, keyed by path with the archive's single top-level\n * directory removed (GitHub archives are `owner-repo-sha/…`). Only regular\n * files whose path matches `keep` (when given) are returned.\n */\nexport async function extractTarball(\n\tdata: ArrayBuffer,\n\tkeep?: (path: string) => boolean,\n): Promise<Map<string, Uint8Array>> {\n\t// Decompress fully first: piping straight into the tar decoder deadlocks in\n\t// workerd (the decoder's pull waits on a pipe stalled on its own drain).\n\tconst decompressed = await collectStream(\n\t\tnew Response(data).body!.pipeThrough(createGzipDecoder()),\n\t\tMAX_DECOMPRESSED_BYTES,\n\t);\n\tlet count = 0;\n\tconst entries = await unpackTar(decompressed, {\n\t\tstrip: 0,\n\t\tfilter: (header) => {\n\t\t\tif (header.type !== \"file\") return false;\n\t\t\tif (header.size > MAX_FILE_BYTES) return false;\n\t\t\tconst name = header.name.replace(LEADING_DOT_SLASH, \"\");\n\t\t\tconst rel = name.includes(\"/\") ? name.slice(name.indexOf(\"/\") + 1) : name;\n\t\t\tif (keep && !keep(rel)) return false;\n\t\t\tif (++count > MAX_TAR_FILES) throw new Error(`Archive has more than ${MAX_TAR_FILES} files`);\n\t\t\treturn true;\n\t\t},\n\t});\n\tconst files = new Map<string, Uint8Array>();\n\tfor (const e of entries) {\n\t\tif (!e.data || !e.header.name) continue;\n\t\tconst name = e.header.name.replace(LEADING_DOT_SLASH, \"\");\n\t\tfiles.set(name.includes(\"/\") ? name.slice(name.indexOf(\"/\") + 1) : name, e.data);\n\t}\n\treturn files;\n}\n","/**\n * Apply the seed in a GitHub repo to this site.\n *\n * POST /_emdash/api/settings/seed/apply-repo  { owner, repo, ref? }\n *\n * Downloads the repo archive (public repos need no token), composes the seed\n * from its `seed/` directory (or a legacy single seed.json) and applies it\n * update-on-conflict, content included; site identity settings are skipped.\n * How a project takes its theme's seed, at provisioning and on every roll.\n */\n\nimport type { APIRoute } from \"astro\";\nimport { z } from \"zod\";\n\nimport { requirePerm } from \"#api/authorize.js\";\nimport { apiError, apiSuccess, handleError } from \"#api/error.js\";\nimport { isParseError, parseBody } from \"#api/parse.js\";\nimport { OptionsRepository } from \"#db/repositories/options.js\";\nimport { applySeed } from \"#seed/apply.js\";\nimport { composeSeed } from \"#seed/fs.js\";\nimport { validateSeed } from \"#seed/validate.js\";\n\nimport { extractTarball } from \"../../../../../utils/tarball.js\";\n\nexport const prerender = false;\n\nconst NAME = /^[A-Za-z0-9_.-]{1,100}$/;\nconst BodySchema = z.object({\n\towner: z.string().regex(NAME),\n\trepo: z.string().regex(NAME),\n\tref: z.string().max(200).optional(),\n});\nconst IDENTITY_KEYS = [\"title\", \"tagline\", \"url\"] as const;\n\nexport const POST: APIRoute = async ({ locals, request }) => {\n\tconst { emdash, user } = locals;\n\tif (!emdash?.db) return apiError(\"NOT_CONFIGURED\", \"EmDash is not initialized\", 500);\n\n\tconst denied = requirePerm(user, \"settings:manage\");\n\tif (denied) return denied;\n\n\tconst body = await parseBody(request, BodySchema);\n\tif (isParseError(body)) return body;\n\n\ttry {\n\t\tconst ref = body.ref ? `/${encodeURIComponent(body.ref)}` : \"\";\n\t\t// Anonymous GitHub API calls from shared egress IPs are rate-limited\n\t\t// into 403s; send the site's connected-repo token when there is one.\n\t\tconst ghToken = await new OptionsRepository(emdash.db).get<string>(\"github:token\");\n\t\tconst headers: Record<string, string> = {\n\t\t\t\"User-Agent\": \"premium-cms\",\n\t\t\tAccept: \"application/vnd.github+json\",\n\t\t};\n\t\tif (ghToken) headers.Authorization = `Bearer ${ghToken}`;\n\t\tconst res = await fetch(\n\t\t\t`https://api.github.com/repos/${body.owner}/${body.repo}/tarball${ref}`,\n\t\t\t{ headers, redirect: \"follow\" },\n\t\t);\n\t\tif (!res.ok) {\n\t\t\treturn apiError(\n\t\t\t\t\"REPO_UNAVAILABLE\",\n\t\t\t\t`Could not download ${body.owner}/${body.repo} (${res.status})`,\n\t\t\t\t502,\n\t\t\t);\n\t\t}\n\t\tconst files = await extractTarball(\n\t\t\tawait res.arrayBuffer(),\n\t\t\t(p) => p.startsWith(\"seed/\") || p === \"seed.json\",\n\t\t);\n\t\tconst tree = new Map<string, string>();\n\t\tconst dec = new TextDecoder();\n\t\tfor (const [p, data] of files) if (p.endsWith(\".json\")) tree.set(p, dec.decode(data));\n\t\tconst seed = composeSeed(tree);\n\t\tif (!seed) return apiSuccess({ applied: false, reason: \"no seed in repo\" });\n\n\t\tconst validation = validateSeed(seed);\n\t\tif (!validation.valid) {\n\t\t\treturn apiError(\"INVALID_SEED\", `Invalid seed: ${validation.errors.join(\", \")}`, 400);\n\t\t}\n\t\tif (seed.settings) {\n\t\t\tconst settings = { ...seed.settings } as Record<string, unknown>;\n\t\t\tfor (const k of IDENTITY_KEYS) delete settings[k];\n\t\t\tseed.settings = settings as typeof seed.settings;\n\t\t}\n\t\tconst result = await applySeed(emdash.db, seed, {\n\t\t\tincludeContent: true,\n\t\t\tonConflict: \"update\",\n\t\t\tstorage: emdash.storage ?? undefined,\n\t\t});\n\t\treturn apiSuccess({ applied: true, files: tree.size, ...result });\n\t} catch (error) {\n\t\treturn handleError(error, \"Failed to apply the repo's seed\", \"SEED_ERROR\");\n\t}\n};\n"],"mappings":"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"}