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