releases.tsx
Raw
1import { mkdirSync, rmSync } from "node:fs";
2import path from "node:path";
3import { Elysia, t } from "elysia";
4import config from "../config.ts";
5import { paths, RELEASES_PER_PAGE } from "../constants.ts";
6import { db, getRepo } from "../db/index.ts";
7import { contentDisposition } from "../lib/contentDisposition.ts";
8import { paginate } from "../lib/pagination.ts";
9import { requireAdmin, resolveSession } from "../middleware/session.ts";
10import { archiveRepo, git } from "../services/git.ts";
11import { renderMarkdown } from "../services/markdown.ts";
12import { NewRelease } from "../views/releases/NewRelease.tsx";
13import { ReleaseDetail } from "../views/releases/ReleaseDetail.tsx";
14import { ReleaseList } from "../views/releases/ReleaseList.tsx";
15import { html } from "../views/render.tsx";
16
17// Tracks AbortControllers for source archive generation tasks that are
18// currently in progress, keyed by release ID. Used to cancel generation
19// immediately when the corresponding release is deleted.
20const archivingTasks = new Map<number, AbortController>();
21
22// FIFO queue of release archive jobs waiting for a slot. Each entry is
23// keyed by release ID so a delete can pull it out before it ever starts.
24interface PendingArchive {
25 releaseId: number;
26 repoName: string;
27 tagName: string;
28 sourceDir: string;
29}
30const queuedArchives: PendingArchive[] = [];
31
32function pumpArchiveQueue(): void {
33 while (
34 queuedArchives.length > 0 &&
35 archivingTasks.size < config.MAX_CONCURRENT_ARCHIVE_JOBS
36 ) {
37 const next = queuedArchives.shift()!;
38 runArchiveJob(next);
39 }
40}
41
42function runArchiveJob(job: PendingArchive): void {
43 const controller = new AbortController();
44 archivingTasks.set(job.releaseId, controller);
45 void (async () => {
46 try {
47 await archiveRepo(
48 job.repoName,
49 job.tagName,
50 job.repoName,
51 job.sourceDir,
52 controller.signal,
53 );
54 rmSync(path.join(job.sourceDir, ".pending"), { force: true });
55 } catch {
56 // Either the release was deleted (abort) or archiving failed.
57 rmSync(job.sourceDir, { recursive: true, force: true });
58 } finally {
59 archivingTasks.delete(job.releaseId);
60 pumpArchiveQueue();
61 }
62 })();
63}
64
65function scheduleArchive(job: PendingArchive): void {
66 if (archivingTasks.size >= config.MAX_CONCURRENT_ARCHIVE_JOBS) {
67 queuedArchives.push(job);
68 return;
69 }
70 runArchiveJob(job);
71}
72
73function sanitizeFilename(name: string): string {
74 const safe = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_");
75 if (!safe || /^\.+$/.test(safe)) return "_";
76 return safe;
77}
78
79export const releasesRoutes = new Elysia()
80 .guard({
81 cookie: t.Cookie({ session: t.Optional(t.String()) }),
82 })
83
84 .get(
85 "/:repo/releases",
86 async ({ params, query, cookie }) => {
87 const user = await resolveSession(cookie.session.value);
88 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
89 if (!repo) return new Response("Not found", { status: 404 });
90
91 const countRow = await db
92 .selectFrom("releases")
93 .select(db.fn.countAll<number>().as("count"))
94 .where("repo_id", "=", repo.id)
95 .executeTakeFirst();
96 const {
97 page: safePage,
98 totalPages,
99 offset,
100 } = paginate(
101 query.page,
102 Number(countRow?.count ?? 0),
103 RELEASES_PER_PAGE,
104 );
105
106 const releasesRaw = await db
107 .selectFrom("releases")
108 .selectAll()
109 .where("repo_id", "=", repo.id)
110 .orderBy("id", "desc")
111 .limit(RELEASES_PER_PAGE)
112 .offset(offset)
113 .execute();
114
115 // Attach asset counts
116 const releaseIds = releasesRaw.map((r) => r.id);
117 const assetCounts =
118 releaseIds.length > 0
119 ? await db
120 .selectFrom("release_assets")
121 .select([
122 "release_id",
123 db.fn.countAll<number>().as("count"),
124 ])
125 .where("release_id", "in", releaseIds)
126 .groupBy("release_id")
127 .execute()
128 : [];
129 const countMap = new Map(
130 assetCounts.map((r) => [r.release_id, Number(r.count)]),
131 );
132
133 const releases = releasesRaw.map((r) => ({
134 ...r,
135 asset_count: countMap.get(r.id) ?? 0,
136 }));
137
138 const pagination = {
139 page: safePage,
140 totalPages,
141 pageUrlTemplate: `/${repo.name}/releases?page={page}`,
142 };
143
144 return html(
145 <ReleaseList
146 user={user}
147 repo={repo}
148 releases={releases}
149 pagination={pagination}
150 />,
151 );
152 },
153 {
154 query: t.Object({
155 page: t.Optional(t.Numeric()),
156 }),
157 },
158 )
159
160 .get("/:repo/releases/new", async ({ params, cookie }) => {
161 const user = await resolveSession(cookie.session.value);
162 const deny = requireAdmin(user);
163 if (deny) return deny;
164 const repo = await getRepo(params.repo, true);
165 if (!repo) return new Response("Not found", { status: 404 });
166 return html(<NewRelease user={user!} repo={repo} />);
167 })
168
169 .post(
170 "/:repo/releases",
171 async ({ params, body, cookie }) => {
172 const user = await resolveSession(cookie.session.value);
173 const deny = requireAdmin(user);
174 if (deny) return deny;
175 const repo = await getRepo(params.repo, true);
176 if (!repo) return new Response("Not found", { status: 404 });
177
178 const name = body.name?.trim() ?? "";
179 const createTag = body.create_tag === "on";
180 const tagName = createTag ? (body.tag_name?.trim() ?? "") : null;
181 const revision = createTag ? (body.revision?.trim() ?? "") : null;
182 const formValues = {
183 ...body,
184 create_tag: createTag,
185 include_source_code: body.include_source_code === "on",
186 };
187
188 if (!name) {
189 return html(
190 <NewRelease
191 user={user!}
192 repo={repo}
193 error="Release title is required"
194 values={formValues}
195 />,
196 );
197 }
198
199 if (createTag) {
200 if (!tagName) {
201 return html(
202 <NewRelease
203 user={user!}
204 repo={repo}
205 error="Tag name is required when creating a git tag"
206 values={formValues}
207 />,
208 );
209 }
210 if (!/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
211 return html(
212 <NewRelease
213 user={user!}
214 repo={repo}
215 error="Tag name may only contain letters, digits, dots, hyphens, underscores, and plus signs"
216 values={formValues}
217 />,
218 );
219 }
220 if (!revision) {
221 return html(
222 <NewRelease
223 user={user!}
224 repo={repo}
225 error="Revision is required when creating a git tag"
226 values={formValues}
227 />,
228 );
229 }
230 }
231
232 // Create git tag
233 if (createTag && tagName && revision) {
234 const notes = body.notes?.trim() || null;
235 const tagMessage = notes ? `${name}\n\n${notes}` : name;
236 const tagResult = await git.createTag(
237 repo.name,
238 tagName,
239 revision,
240 tagMessage,
241 config.COMMITTER_NAME,
242 config.COMMITTER_EMAIL,
243 );
244 if (tagResult === "already_exists") {
245 return html(
246 <NewRelease
247 user={user!}
248 repo={repo}
249 error={`Git tag "${tagName}" already exists in this repository`}
250 values={formValues}
251 />,
252 );
253 }
254 if (tagResult === "bad_ref") {
255 return html(
256 <NewRelease
257 user={user!}
258 repo={repo}
259 error={`"${revision}" is not a valid revision in this repository`}
260 values={formValues}
261 />,
262 );
263 }
264 if (tagResult === "error") {
265 return html(
266 <NewRelease
267 user={user!}
268 repo={repo}
269 error="Failed to create git tag"
270 values={formValues}
271 />,
272 );
273 }
274 }
275
276 const includeSource =
277 body.include_source_code === "on" && createTag && !!tagName;
278 const now = new Date().toISOString();
279
280 // Collect uploaded file data before opening the transaction so we
281 // don't hold it open across slow I/O.
282 const rawFiles = body.files;
283 const uploadedFiles: {
284 filename: string;
285 data: Blob;
286 size: number;
287 }[] = [];
288 if (rawFiles) {
289 const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
290 for (const file of files) {
291 if (file.size === 0) continue;
292 uploadedFiles.push({
293 filename: sanitizeFilename(file.name),
294 data: file,
295 size: file.size,
296 });
297 }
298 }
299
300 // Insert the release record and all asset records in one transaction
301 // so that partial failures don't leave orphaned DB rows.
302 // releaseDir is captured inside the callback so the catch can clean
303 // up files even though the auto-increment ID isn't known until after
304 // the INSERT.
305 let releaseDir: string | null = null;
306 const releaseId = await db
307 .transaction()
308 .execute(async (trx) => {
309 const inserted = await trx
310 .insertInto("releases")
311 .values({
312 repo_id: repo.id,
313 tag_name: tagName,
314 name,
315 notes: body.notes?.trim() || null,
316 include_source_code: includeSource ? 1 : 0,
317 created_at: now,
318 })
319 .returning("id")
320 .executeTakeFirstOrThrow();
321
322 const id = inserted.id;
323 releaseDir = path.join(paths.RELEASES_DIR, String(id));
324
325 if (includeSource) {
326 const sourceDir = path.join(releaseDir, "source");
327 mkdirSync(sourceDir, { recursive: true });
328 // Write a sentinel file; the actual archives are
329 // generated asynchronously after the response is sent.
330 await Bun.write(path.join(sourceDir, ".pending"), "");
331 }
332
333 if (uploadedFiles.length > 0) {
334 const assetsDir = path.join(releaseDir, "assets");
335 mkdirSync(assetsDir, { recursive: true });
336 for (const f of uploadedFiles) {
337 await Bun.write(
338 path.join(assetsDir, f.filename),
339 f.data,
340 );
341 await trx
342 .insertInto("release_assets")
343 .values({
344 release_id: id,
345 filename: f.filename,
346 size: f.size,
347 created_at: now,
348 })
349 .execute();
350 }
351 }
352
353 return id;
354 })
355 .catch((err) => {
356 // Roll back any partially-written files if the transaction
357 // failed — the DB rollback handles the DB side automatically.
358 if (releaseDir) {
359 rmSync(releaseDir, { recursive: true, force: true });
360 }
361 throw err;
362 });
363
364 // Kick off source archive generation in the background so the
365 // response can be sent immediately. The .pending sentinel written
366 // inside the transaction signals to the detail view that archives
367 // are still being prepared. Concurrent jobs are capped at
368 // MAX_CONCURRENT_ARCHIVE_JOBS so a flurry of release creations
369 // can't saturate CPU; excess jobs queue in-memory.
370 if (includeSource) {
371 const sourceDir = path.join(
372 paths.RELEASES_DIR,
373 String(releaseId),
374 "source",
375 );
376 scheduleArchive({
377 releaseId,
378 repoName: repo.name,
379 tagName: tagName!,
380 sourceDir,
381 });
382 }
383
384 return new Response(null, {
385 status: 302,
386 headers: {
387 Location: `/${repo.name}/releases/${releaseId}`,
388 },
389 });
390 },
391 {
392 body: t.Object({
393 create_tag: t.Optional(t.String()),
394 tag_name: t.Optional(
395 t.String({ maxLength: config.MAX_TITLE_BYTES }),
396 ),
397 revision: t.Optional(t.String()),
398 name: t.Optional(
399 t.String({ maxLength: config.MAX_TITLE_BYTES }),
400 ),
401 notes: t.Optional(
402 t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
403 ),
404 include_source_code: t.Optional(t.String()),
405 files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
406 }),
407 type: "multipart/form-data",
408 },
409 )
410
411 .get(
412 "/:repo/releases/:id",
413 async ({ params, cookie }) => {
414 const user = await resolveSession(cookie.session.value);
415 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
416 if (!repo) return new Response("Not found", { status: 404 });
417
418 const release = await db
419 .selectFrom("releases")
420 .selectAll()
421 .where("repo_id", "=", repo.id)
422 .where("id", "=", params.id)
423 .executeTakeFirst();
424 if (!release) return new Response("Not found", { status: 404 });
425
426 const assets = await db
427 .selectFrom("release_assets")
428 .selectAll()
429 .where("release_id", "=", release.id)
430 .orderBy("id", "asc")
431 .execute();
432
433 const notesHtml = release.notes
434 ? renderMarkdown(release.notes)
435 : "";
436
437 // Detect which source archives exist on disk, and whether
438 // generation is still in progress (indicated by a .pending file).
439 const sourceArchives: {
440 format: string;
441 filename: string;
442 size: number;
443 }[] = [];
444 let sourceArchivesPending = false;
445 if (release.include_source_code) {
446 const base = `${repo.name}-${release.tag_name}`;
447 const sourceDir = path.join(
448 paths.RELEASES_DIR,
449 String(release.id),
450 "source",
451 );
452 if (await Bun.file(path.join(sourceDir, ".pending")).exists()) {
453 sourceArchivesPending = true;
454 } else {
455 for (const [format, ext] of [
456 ["zip", ".zip"],
457 ["tar.gz", ".tar.gz"],
458 ["tar.zst", ".tar.zst"],
459 ] as const) {
460 const filePath = path.join(sourceDir, `${base}${ext}`);
461 const f = Bun.file(filePath);
462 if (await f.exists()) {
463 sourceArchives.push({
464 format,
465 filename: `${base}${ext}`,
466 size: f.size,
467 });
468 }
469 }
470 }
471 }
472
473 return html(
474 <ReleaseDetail
475 user={user}
476 repo={repo}
477 release={release}
478 notesHtml={notesHtml}
479 assets={assets}
480 sourceArchives={sourceArchives}
481 sourceArchivesPending={sourceArchivesPending}
482 />,
483 );
484 },
485 {
486 params: t.Object({
487 repo: t.String(),
488 id: t.Numeric(),
489 }),
490 },
491 )
492
493 .post(
494 "/:repo/releases/:id/delete",
495 async ({ params, cookie }) => {
496 const user = await resolveSession(cookie.session.value);
497 const deny = requireAdmin(user);
498 if (deny) return deny;
499 const repo = await getRepo(params.repo, true);
500 if (!repo) return new Response("Not found", { status: 404 });
501
502 const release = await db
503 .selectFrom("releases")
504 .select("id")
505 .where("repo_id", "=", repo.id)
506 .where("id", "=", params.id)
507 .executeTakeFirst();
508 if (!release) return new Response("Not found", { status: 404 });
509
510 // Abort any in-progress archive generation before touching disk so
511 // the background task doesn't race with the rmSync below. Also
512 // pull queued (not-yet-started) archive jobs out of the queue.
513 archivingTasks.get(release.id)?.abort();
514 archivingTasks.delete(release.id);
515 const qIdx = queuedArchives.findIndex(
516 (j) => j.releaseId === release.id,
517 );
518 if (qIdx >= 0) queuedArchives.splice(qIdx, 1);
519
520 // Remove files from disk before the DB record so that a crash
521 // between the two leaves a broken-but-visible repo rather than a
522 // DB record pointing to missing files.
523 const releaseDir = path.join(
524 paths.RELEASES_DIR,
525 String(release.id),
526 );
527 rmSync(releaseDir, { recursive: true, force: true });
528 await db
529 .deleteFrom("releases")
530 .where("id", "=", release.id)
531 .execute();
532
533 return new Response(null, {
534 status: 302,
535 headers: { Location: `/${repo.name}/releases` },
536 });
537 },
538 {
539 params: t.Object({
540 repo: t.String(),
541 id: t.Numeric(),
542 }),
543 },
544 )
545
546 .get(
547 "/:repo/releases/:id/assets/:filename",
548 async ({ params, cookie }) => {
549 const user = await resolveSession(cookie.session.value);
550 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
551 if (!repo) return new Response("Not found", { status: 404 });
552
553 const release = await db
554 .selectFrom("releases")
555 .select("id")
556 .where("repo_id", "=", repo.id)
557 .where("id", "=", params.id)
558 .executeTakeFirst();
559 if (!release) return new Response("Not found", { status: 404 });
560
561 const safeFilename = path.basename(params.filename);
562 const asset = await db
563 .selectFrom("release_assets")
564 .selectAll()
565 .where("release_id", "=", release.id)
566 .where("filename", "=", safeFilename)
567 .executeTakeFirst();
568 if (!asset) return new Response("Not found", { status: 404 });
569
570 const filePath = path.join(
571 paths.RELEASES_DIR,
572 String(release.id),
573 "assets",
574 safeFilename,
575 );
576 const file = Bun.file(filePath);
577 if (!(await file.exists()))
578 return new Response("Not found", { status: 404 });
579
580 return new Response(file, {
581 headers: {
582 "Content-Disposition": contentDisposition(
583 "attachment",
584 safeFilename,
585 ),
586 "Content-Type": "application/octet-stream",
587 },
588 });
589 },
590 {
591 params: t.Object({
592 repo: t.String(),
593 id: t.Numeric(),
594 filename: t.String(),
595 }),
596 },
597 )
598
599 .get(
600 "/:repo/releases/:id/source/:filename",
601 async ({ params, cookie }) => {
602 const user = await resolveSession(cookie.session.value);
603 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
604 if (!repo) return new Response("Not found", { status: 404 });
605
606 const release = await db
607 .selectFrom("releases")
608 .select(["id", "include_source_code"])
609 .where("repo_id", "=", repo.id)
610 .where("id", "=", params.id)
611 .executeTakeFirst();
612 if (!release || !release.include_source_code)
613 return new Response("Not found", { status: 404 });
614
615 const safeFilename = path.basename(params.filename);
616 const filePath = path.join(
617 paths.RELEASES_DIR,
618 String(release.id),
619 "source",
620 safeFilename,
621 );
622 const file = Bun.file(filePath);
623 if (!(await file.exists()))
624 return new Response("Not found", { status: 404 });
625
626 return new Response(file, {
627 headers: {
628 "Content-Disposition": contentDisposition(
629 "attachment",
630 safeFilename,
631 ),
632 "Content-Type": "application/octet-stream",
633 },
634 });
635 },
636 {
637 params: t.Object({
638 repo: t.String(),
639 id: t.Numeric(),
640 filename: t.String(),
641 }),
642 },
643 );
644