import { mkdirSync, rmSync } from "node:fs"; import path from "node:path"; import { Elysia, t } from "elysia"; import config from "../config.ts"; import { paths, RELEASES_PER_PAGE } from "../constants.ts"; import { db, getRepo } from "../db/index.ts"; import { contentDisposition } from "../lib/contentDisposition.ts"; import { paginate } from "../lib/pagination.ts"; import { requireAdmin, resolveSession } from "../middleware/session.ts"; import { archiveRepo, git } from "../services/git.ts"; import { renderMarkdown } from "../services/markdown.ts"; import { NewRelease } from "../views/releases/NewRelease.tsx"; import { ReleaseDetail } from "../views/releases/ReleaseDetail.tsx"; import { ReleaseList } from "../views/releases/ReleaseList.tsx"; import { html } from "../views/render.tsx"; // Tracks AbortControllers for source archive generation tasks that are // currently in progress, keyed by release ID. Used to cancel generation // immediately when the corresponding release is deleted. const archivingTasks = new Map(); // FIFO queue of release archive jobs waiting for a slot. Each entry is // keyed by release ID so a delete can pull it out before it ever starts. interface PendingArchive { releaseId: number; repoName: string; tagName: string; sourceDir: string; } const queuedArchives: PendingArchive[] = []; function pumpArchiveQueue(): void { while ( queuedArchives.length > 0 && archivingTasks.size < config.MAX_CONCURRENT_ARCHIVE_JOBS ) { const next = queuedArchives.shift()!; runArchiveJob(next); } } function runArchiveJob(job: PendingArchive): void { const controller = new AbortController(); archivingTasks.set(job.releaseId, controller); void (async () => { try { await archiveRepo( job.repoName, job.tagName, job.repoName, job.sourceDir, controller.signal, ); rmSync(path.join(job.sourceDir, ".pending"), { force: true }); } catch { // Either the release was deleted (abort) or archiving failed. rmSync(job.sourceDir, { recursive: true, force: true }); } finally { archivingTasks.delete(job.releaseId); pumpArchiveQueue(); } })(); } function scheduleArchive(job: PendingArchive): void { if (archivingTasks.size >= config.MAX_CONCURRENT_ARCHIVE_JOBS) { queuedArchives.push(job); return; } runArchiveJob(job); } function sanitizeFilename(name: string): string { const safe = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_"); if (!safe || /^\.+$/.test(safe)) return "_"; return safe; } export const releasesRoutes = new Elysia() .guard({ cookie: t.Cookie({ session: t.Optional(t.String()) }), }) .get( "/:repo/releases", async ({ params, query, cookie }) => { const user = await resolveSession(cookie.session.value); const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const countRow = await db .selectFrom("releases") .select(db.fn.countAll().as("count")) .where("repo_id", "=", repo.id) .executeTakeFirst(); const { page: safePage, totalPages, offset, } = paginate( query.page, Number(countRow?.count ?? 0), RELEASES_PER_PAGE, ); const releasesRaw = await db .selectFrom("releases") .selectAll() .where("repo_id", "=", repo.id) .orderBy("id", "desc") .limit(RELEASES_PER_PAGE) .offset(offset) .execute(); // Attach asset counts const releaseIds = releasesRaw.map((r) => r.id); const assetCounts = releaseIds.length > 0 ? await db .selectFrom("release_assets") .select([ "release_id", db.fn.countAll().as("count"), ]) .where("release_id", "in", releaseIds) .groupBy("release_id") .execute() : []; const countMap = new Map( assetCounts.map((r) => [r.release_id, Number(r.count)]), ); const releases = releasesRaw.map((r) => ({ ...r, asset_count: countMap.get(r.id) ?? 0, })); const pagination = { page: safePage, totalPages, pageUrlTemplate: `/${repo.name}/releases?page={page}`, }; return html( , ); }, { query: t.Object({ page: t.Optional(t.Numeric()), }), }, ) .get("/:repo/releases/new", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await getRepo(params.repo, true); if (!repo) return new Response("Not found", { status: 404 }); return html(); }) .post( "/:repo/releases", async ({ params, body, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await getRepo(params.repo, true); if (!repo) return new Response("Not found", { status: 404 }); const name = body.name?.trim() ?? ""; const createTag = body.create_tag === "on"; const tagName = createTag ? (body.tag_name?.trim() ?? "") : null; const revision = createTag ? (body.revision?.trim() ?? "") : null; const formValues = { ...body, create_tag: createTag, include_source_code: body.include_source_code === "on", }; if (!name) { return html( , ); } if (createTag) { if (!tagName) { return html( , ); } if (!/^[a-zA-Z0-9._\-+]+$/.test(tagName)) { return html( , ); } if (!revision) { return html( , ); } } // Create git tag if (createTag && tagName && revision) { const notes = body.notes?.trim() || null; const tagMessage = notes ? `${name}\n\n${notes}` : name; const tagResult = await git.createTag( repo.name, tagName, revision, tagMessage, config.COMMITTER_NAME, config.COMMITTER_EMAIL, ); if (tagResult === "already_exists") { return html( , ); } if (tagResult === "bad_ref") { return html( , ); } if (tagResult === "error") { return html( , ); } } const includeSource = body.include_source_code === "on" && createTag && !!tagName; const now = new Date().toISOString(); // Collect uploaded file data before opening the transaction so we // don't hold it open across slow I/O. const rawFiles = body.files; const uploadedFiles: { filename: string; data: Blob; size: number; }[] = []; if (rawFiles) { const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles]; for (const file of files) { if (file.size === 0) continue; uploadedFiles.push({ filename: sanitizeFilename(file.name), data: file, size: file.size, }); } } // Insert the release record and all asset records in one transaction // so that partial failures don't leave orphaned DB rows. // releaseDir is captured inside the callback so the catch can clean // up files even though the auto-increment ID isn't known until after // the INSERT. let releaseDir: string | null = null; const releaseId = await db .transaction() .execute(async (trx) => { const inserted = await trx .insertInto("releases") .values({ repo_id: repo.id, tag_name: tagName, name, notes: body.notes?.trim() || null, include_source_code: includeSource ? 1 : 0, created_at: now, }) .returning("id") .executeTakeFirstOrThrow(); const id = inserted.id; releaseDir = path.join(paths.RELEASES_DIR, String(id)); if (includeSource) { const sourceDir = path.join(releaseDir, "source"); mkdirSync(sourceDir, { recursive: true }); // Write a sentinel file; the actual archives are // generated asynchronously after the response is sent. await Bun.write(path.join(sourceDir, ".pending"), ""); } if (uploadedFiles.length > 0) { const assetsDir = path.join(releaseDir, "assets"); mkdirSync(assetsDir, { recursive: true }); for (const f of uploadedFiles) { await Bun.write( path.join(assetsDir, f.filename), f.data, ); await trx .insertInto("release_assets") .values({ release_id: id, filename: f.filename, size: f.size, created_at: now, }) .execute(); } } return id; }) .catch((err) => { // Roll back any partially-written files if the transaction // failed — the DB rollback handles the DB side automatically. if (releaseDir) { rmSync(releaseDir, { recursive: true, force: true }); } throw err; }); // Kick off source archive generation in the background so the // response can be sent immediately. The .pending sentinel written // inside the transaction signals to the detail view that archives // are still being prepared. Concurrent jobs are capped at // MAX_CONCURRENT_ARCHIVE_JOBS so a flurry of release creations // can't saturate CPU; excess jobs queue in-memory. if (includeSource) { const sourceDir = path.join( paths.RELEASES_DIR, String(releaseId), "source", ); scheduleArchive({ releaseId, repoName: repo.name, tagName: tagName!, sourceDir, }); } return new Response(null, { status: 302, headers: { Location: `/${repo.name}/releases/${releaseId}`, }, }); }, { body: t.Object({ create_tag: t.Optional(t.String()), tag_name: t.Optional( t.String({ maxLength: config.MAX_TITLE_BYTES }), ), revision: t.Optional(t.String()), name: t.Optional( t.String({ maxLength: config.MAX_TITLE_BYTES }), ), notes: t.Optional( t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }), ), include_source_code: t.Optional(t.String()), files: t.Optional(t.Union([t.File(), t.Array(t.File())])), }), type: "multipart/form-data", }, ) .get( "/:repo/releases/:id", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const release = await db .selectFrom("releases") .selectAll() .where("repo_id", "=", repo.id) .where("id", "=", params.id) .executeTakeFirst(); if (!release) return new Response("Not found", { status: 404 }); const assets = await db .selectFrom("release_assets") .selectAll() .where("release_id", "=", release.id) .orderBy("id", "asc") .execute(); const notesHtml = release.notes ? renderMarkdown(release.notes) : ""; // Detect which source archives exist on disk, and whether // generation is still in progress (indicated by a .pending file). const sourceArchives: { format: string; filename: string; size: number; }[] = []; let sourceArchivesPending = false; if (release.include_source_code) { const base = `${repo.name}-${release.tag_name}`; const sourceDir = path.join( paths.RELEASES_DIR, String(release.id), "source", ); if (await Bun.file(path.join(sourceDir, ".pending")).exists()) { sourceArchivesPending = true; } else { for (const [format, ext] of [ ["zip", ".zip"], ["tar.gz", ".tar.gz"], ["tar.zst", ".tar.zst"], ] as const) { const filePath = path.join(sourceDir, `${base}${ext}`); const f = Bun.file(filePath); if (await f.exists()) { sourceArchives.push({ format, filename: `${base}${ext}`, size: f.size, }); } } } } return html( , ); }, { params: t.Object({ repo: t.String(), id: t.Numeric(), }), }, ) .post( "/:repo/releases/:id/delete", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const deny = requireAdmin(user); if (deny) return deny; const repo = await getRepo(params.repo, true); if (!repo) return new Response("Not found", { status: 404 }); const release = await db .selectFrom("releases") .select("id") .where("repo_id", "=", repo.id) .where("id", "=", params.id) .executeTakeFirst(); if (!release) return new Response("Not found", { status: 404 }); // Abort any in-progress archive generation before touching disk so // the background task doesn't race with the rmSync below. Also // pull queued (not-yet-started) archive jobs out of the queue. archivingTasks.get(release.id)?.abort(); archivingTasks.delete(release.id); const qIdx = queuedArchives.findIndex( (j) => j.releaseId === release.id, ); if (qIdx >= 0) queuedArchives.splice(qIdx, 1); // Remove files from disk before the DB record so that a crash // between the two leaves a broken-but-visible repo rather than a // DB record pointing to missing files. const releaseDir = path.join( paths.RELEASES_DIR, String(release.id), ); rmSync(releaseDir, { recursive: true, force: true }); await db .deleteFrom("releases") .where("id", "=", release.id) .execute(); return new Response(null, { status: 302, headers: { Location: `/${repo.name}/releases` }, }); }, { params: t.Object({ repo: t.String(), id: t.Numeric(), }), }, ) .get( "/:repo/releases/:id/assets/:filename", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const release = await db .selectFrom("releases") .select("id") .where("repo_id", "=", repo.id) .where("id", "=", params.id) .executeTakeFirst(); if (!release) return new Response("Not found", { status: 404 }); const safeFilename = path.basename(params.filename); const asset = await db .selectFrom("release_assets") .selectAll() .where("release_id", "=", release.id) .where("filename", "=", safeFilename) .executeTakeFirst(); if (!asset) return new Response("Not found", { status: 404 }); const filePath = path.join( paths.RELEASES_DIR, String(release.id), "assets", safeFilename, ); const file = Bun.file(filePath); if (!(await file.exists())) return new Response("Not found", { status: 404 }); return new Response(file, { headers: { "Content-Disposition": contentDisposition( "attachment", safeFilename, ), "Content-Type": "application/octet-stream", }, }); }, { params: t.Object({ repo: t.String(), id: t.Numeric(), filename: t.String(), }), }, ) .get( "/:repo/releases/:id/source/:filename", async ({ params, cookie }) => { const user = await resolveSession(cookie.session.value); const repo = await getRepo(params.repo, user?.isAdmin ?? false); if (!repo) return new Response("Not found", { status: 404 }); const release = await db .selectFrom("releases") .select(["id", "include_source_code"]) .where("repo_id", "=", repo.id) .where("id", "=", params.id) .executeTakeFirst(); if (!release || !release.include_source_code) return new Response("Not found", { status: 404 }); const safeFilename = path.basename(params.filename); const filePath = path.join( paths.RELEASES_DIR, String(release.id), "source", safeFilename, ); const file = Bun.file(filePath); if (!(await file.exists())) return new Response("Not found", { status: 404 }); return new Response(file, { headers: { "Content-Disposition": contentDisposition( "attachment", safeFilename, ), "Content-Type": "application/octet-stream", }, }); }, { params: t.Object({ repo: t.String(), id: t.Numeric(), filename: t.String(), }), }, );