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 { 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 (!/^[a-zA-Z0-9._\-+]+$/.test(tagName)) {
157 return html(
158 <NewRelease
159 user={user!}
160 repo={repo}
161 error="Tag name may only contain letters, digits, dots, hyphens, underscores, and plus signs"
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 config.COMMITTER_NAME,
188 config.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 }[] = [];
234 if (rawFiles) {
235 const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
236 for (const file of files) {
237 if (file.size === 0) continue;
238 uploadedFiles.push({
239 filename: sanitizeFilename(file.name),
240 data: file,
241 size: file.size,
242 });
243 }
244 }
245
246 // Insert the release record and all asset records in one transaction
247 // so that partial failures don't leave orphaned DB rows.
248 // releaseDir is captured inside the callback so the catch can clean
249 // up files even though the auto-increment ID isn't known until after
250 // the INSERT.
251 let releaseDir: string | null = null;
252 const releaseId = await db
253 .transaction()
254 .execute(async (trx) => {
255 const inserted = await trx
256 .insertInto("releases")
257 .values({
258 repo_id: repo.id,
259 tag_name: tagName,
260 name,
261 notes: body.notes?.trim() || null,
262 include_source_code: includeSource ? 1 : 0,
263 created_at: now,
264 })
265 .returning("id")
266 .executeTakeFirstOrThrow();
267
268 const id = inserted.id;
269 releaseDir = path.join(paths.RELEASES_DIR, String(id));
270
271 if (includeSource) {
272 const sourceDir = path.join(releaseDir, "source");
273 mkdirSync(sourceDir, { recursive: true });
274 // Write a sentinel file; the actual archives are
275 // generated asynchronously after the response is sent.
276 await Bun.write(path.join(sourceDir, ".pending"), "");
277 }
278
279 if (uploadedFiles.length > 0) {
280 const assetsDir = path.join(releaseDir, "assets");
281 mkdirSync(assetsDir, { recursive: true });
282 for (const f of uploadedFiles) {
283 await Bun.write(
284 path.join(assetsDir, f.filename),
285 f.data,
286 );
287 await trx
288 .insertInto("release_assets")
289 .values({
290 release_id: id,
291 filename: f.filename,
292 size: f.size,
293 created_at: now,
294 })
295 .execute();
296 }
297 }
298
299 return id;
300 })
301 .catch((err) => {
302 // Roll back any partially-written files if the transaction
303 // failed — the DB rollback handles the DB side automatically.
304 if (releaseDir) {
305 rmSync(releaseDir, { recursive: true, force: true });
306 }
307 throw err;
308 });
309
310 // Kick off source archive generation in the background so the
311 // response can be sent immediately. The .pending sentinel written
312 // inside the transaction signals to the detail view that archives
313 // are still being prepared. If the release is deleted while
314 // generation is in progress the background task will hit errors
315 // (the directory will have been removed) and silently bail out;
316 // SQLite AUTOINCREMENT guarantees the ID is never reused, so there
317 // is no risk of contaminating a later release.
318 if (includeSource) {
319 const sourceDir = path.join(
320 paths.RELEASES_DIR,
321 String(releaseId),
322 "source",
323 );
324 const controller = new AbortController();
325 archivingTasks.set(releaseId, controller);
326 (async () => {
327 try {
328 await archiveRepo(
329 repo.name,
330 tagName!,
331 repo.name,
332 sourceDir,
333 controller.signal,
334 );
335 rmSync(path.join(sourceDir, ".pending"), {
336 force: true,
337 });
338 } catch {
339 // Either the release was deleted (abort) or archiving
340 // failed. Remove the source dir so the UI shows no
341 // stale state.
342 rmSync(sourceDir, { recursive: true, force: true });
343 } finally {
344 archivingTasks.delete(releaseId);
345 }
346 })();
347 }
348
349 return new Response(null, {
350 status: 302,
351 headers: {
352 Location: `/${repo.name}/releases/${releaseId}`,
353 },
354 });
355 },
356 {
357 body: t.Object({
358 create_tag: t.Optional(t.String()),
359 tag_name: t.Optional(
360 t.String({ maxLength: config.MAX_TITLE_BYTES }),
361 ),
362 revision: t.Optional(t.String()),
363 name: t.Optional(
364 t.String({ maxLength: config.MAX_TITLE_BYTES }),
365 ),
366 notes: t.Optional(
367 t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
368 ),
369 include_source_code: t.Optional(t.String()),
370 files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
371 }),
372 type: "multipart/form-data",
373 },
374 )
375
376 .get(
377 "/:repo/releases/:id",
378 async ({ params, cookie }) => {
379 const user = await resolveSession(cookie.session.value);
380 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
381 if (!repo) return new Response("Not found", { status: 404 });
382
383 const release = await db
384 .selectFrom("releases")
385 .selectAll()
386 .where("repo_id", "=", repo.id)
387 .where("id", "=", params.id)
388 .executeTakeFirst();
389 if (!release) return new Response("Not found", { status: 404 });
390
391 const assets = await db
392 .selectFrom("release_assets")
393 .selectAll()
394 .where("release_id", "=", release.id)
395 .orderBy("id", "asc")
396 .execute();
397
398 const notesHtml = release.notes
399 ? renderMarkdown(release.notes)
400 : "";
401
402 // Detect which source archives exist on disk, and whether
403 // generation is still in progress (indicated by a .pending file).
404 const sourceArchives: {
405 format: string;
406 filename: string;
407 size: number;
408 }[] = [];
409 let sourceArchivesPending = false;
410 if (release.include_source_code) {
411 const base = `${repo.name}-${release.tag_name}`;
412 const sourceDir = path.join(
413 paths.RELEASES_DIR,
414 String(release.id),
415 "source",
416 );
417 if (await Bun.file(path.join(sourceDir, ".pending")).exists()) {
418 sourceArchivesPending = true;
419 } else {
420 for (const [format, ext] of [
421 ["zip", ".zip"],
422 ["tar.gz", ".tar.gz"],
423 ["tar.zst", ".tar.zst"],
424 ] as const) {
425 const filePath = path.join(sourceDir, `${base}${ext}`);
426 const f = Bun.file(filePath);
427 if (await f.exists()) {
428 sourceArchives.push({
429 format,
430 filename: `${base}${ext}`,
431 size: f.size,
432 });
433 }
434 }
435 }
436 }
437
438 return html(
439 <ReleaseDetail
440 user={user}
441 repo={repo}
442 release={release}
443 notesHtml={notesHtml}
444 assets={assets}
445 sourceArchives={sourceArchives}
446 sourceArchivesPending={sourceArchivesPending}
447 />,
448 );
449 },
450 {
451 params: t.Object({
452 repo: t.String(),
453 id: t.Numeric(),
454 }),
455 },
456 )
457
458 .post(
459 "/:repo/releases/:id/delete",
460 async ({ params, cookie }) => {
461 const user = await resolveSession(cookie.session.value);
462 const deny = requireAdmin(user);
463 if (deny) return deny;
464 const repo = await getRepo(params.repo, true);
465 if (!repo) return new Response("Not found", { status: 404 });
466
467 const release = await db
468 .selectFrom("releases")
469 .select("id")
470 .where("repo_id", "=", repo.id)
471 .where("id", "=", params.id)
472 .executeTakeFirst();
473 if (!release) return new Response("Not found", { status: 404 });
474
475 // Abort any in-progress archive generation before touching disk so
476 // the background task doesn't race with the rmSync below.
477 archivingTasks.get(release.id)?.abort();
478 archivingTasks.delete(release.id);
479
480 // Remove files from disk before the DB record so that a crash
481 // between the two leaves a broken-but-visible repo rather than a
482 // DB record pointing to missing files.
483 const releaseDir = path.join(
484 paths.RELEASES_DIR,
485 String(release.id),
486 );
487 rmSync(releaseDir, { recursive: true, force: true });
488 await db
489 .deleteFrom("releases")
490 .where("id", "=", release.id)
491 .execute();
492
493 return new Response(null, {
494 status: 302,
495 headers: { Location: `/${repo.name}/releases` },
496 });
497 },
498 {
499 params: t.Object({
500 repo: t.String(),
501 id: t.Numeric(),
502 }),
503 },
504 )
505
506 .get(
507 "/:repo/releases/:id/assets/:filename",
508 async ({ params, cookie }) => {
509 const user = await resolveSession(cookie.session.value);
510 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
511 if (!repo) return new Response("Not found", { status: 404 });
512
513 const release = await db
514 .selectFrom("releases")
515 .select("id")
516 .where("repo_id", "=", repo.id)
517 .where("id", "=", params.id)
518 .executeTakeFirst();
519 if (!release) return new Response("Not found", { status: 404 });
520
521 const safeFilename = path.basename(params.filename);
522 const asset = await db
523 .selectFrom("release_assets")
524 .selectAll()
525 .where("release_id", "=", release.id)
526 .where("filename", "=", safeFilename)
527 .executeTakeFirst();
528 if (!asset) return new Response("Not found", { status: 404 });
529
530 const filePath = path.join(
531 paths.RELEASES_DIR,
532 String(release.id),
533 "assets",
534 safeFilename,
535 );
536 const file = Bun.file(filePath);
537 if (!(await file.exists()))
538 return new Response("Not found", { status: 404 });
539
540 return new Response(file, {
541 headers: {
542 "Content-Disposition": `attachment; filename="${safeFilename}"`,
543 "Content-Type": "application/octet-stream",
544 },
545 });
546 },
547 {
548 params: t.Object({
549 repo: t.String(),
550 id: t.Numeric(),
551 filename: t.String(),
552 }),
553 },
554 )
555
556 .get(
557 "/:repo/releases/:id/source/:filename",
558 async ({ params, cookie }) => {
559 const user = await resolveSession(cookie.session.value);
560 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
561 if (!repo) return new Response("Not found", { status: 404 });
562
563 const release = await db
564 .selectFrom("releases")
565 .select(["id", "include_source_code"])
566 .where("repo_id", "=", repo.id)
567 .where("id", "=", params.id)
568 .executeTakeFirst();
569 if (!release || !release.include_source_code)
570 return new Response("Not found", { status: 404 });
571
572 const safeFilename = path.basename(params.filename);
573 const filePath = path.join(
574 paths.RELEASES_DIR,
575 String(release.id),
576 "source",
577 safeFilename,
578 );
579 const file = Bun.file(filePath);
580 if (!(await file.exists()))
581 return new Response("Not found", { status: 404 });
582
583 return new Response(file, {
584 headers: {
585 "Content-Disposition": `attachment; filename="${safeFilename}"`,
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