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 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(paths.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 paths.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(
363 t.String({ maxLength: config.MAX_TITLE_BYTES }),
364 ),
365 revision: t.Optional(t.String()),
366 name: t.Optional(
367 t.String({ maxLength: config.MAX_TITLE_BYTES }),
368 ),
369 notes: t.Optional(
370 t.String({ maxLength: config.MAX_TEXT_BODY_BYTES }),
371 ),
372 include_source_code: t.Optional(t.String()),
373 files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
374 }),
375 type: "multipart/form-data",
376 },
377 )
378
379 .get(
380 "/:repo/releases/:id",
381 async ({ params, cookie }) => {
382 const user = await resolveSession(cookie.session.value);
383 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
384 if (!repo) return new Response("Not found", { status: 404 });
385
386 const release = await db
387 .selectFrom("releases")
388 .selectAll()
389 .where("repo_id", "=", repo.id)
390 .where("id", "=", params.id)
391 .executeTakeFirst();
392 if (!release) return new Response("Not found", { status: 404 });
393
394 const assets = await db
395 .selectFrom("release_assets")
396 .selectAll()
397 .where("release_id", "=", release.id)
398 .orderBy("id", "asc")
399 .execute();
400
401 const notesHtml = release.notes
402 ? renderMarkdown(release.notes)
403 : "";
404
405 // Detect which source archives exist on disk, and whether
406 // generation is still in progress (indicated by a .pending file).
407 const sourceArchives: {
408 format: string;
409 filename: string;
410 size: number;
411 }[] = [];
412 let sourceArchivesPending = false;
413 if (release.include_source_code) {
414 const base = `${repo.name}-${release.tag_name}`;
415 const sourceDir = path.join(
416 paths.RELEASES_DIR,
417 String(release.id),
418 "source",
419 );
420 if (await Bun.file(path.join(sourceDir, ".pending")).exists()) {
421 sourceArchivesPending = true;
422 } else {
423 for (const [format, ext] of [
424 ["zip", ".zip"],
425 ["tar.gz", ".tar.gz"],
426 ["tar.zst", ".tar.zst"],
427 ] as const) {
428 const filePath = path.join(sourceDir, `${base}${ext}`);
429 const f = Bun.file(filePath);
430 if (await f.exists()) {
431 sourceArchives.push({
432 format,
433 filename: `${base}${ext}`,
434 size: f.size,
435 });
436 }
437 }
438 }
439 }
440
441 return html(
442 <ReleaseDetail
443 user={user}
444 repo={repo}
445 release={release}
446 notesHtml={notesHtml}
447 assets={assets}
448 sourceArchives={sourceArchives}
449 sourceArchivesPending={sourceArchivesPending}
450 />,
451 );
452 },
453 {
454 params: t.Object({
455 repo: t.String(),
456 id: t.Numeric(),
457 }),
458 },
459 )
460
461 .post(
462 "/:repo/releases/:id/delete",
463 async ({ params, cookie }) => {
464 const user = await resolveSession(cookie.session.value);
465 const deny = requireAdmin(user);
466 if (deny) return deny;
467 const repo = await getRepo(params.repo, true);
468 if (!repo) return new Response("Not found", { status: 404 });
469
470 const release = await db
471 .selectFrom("releases")
472 .select("id")
473 .where("repo_id", "=", repo.id)
474 .where("id", "=", params.id)
475 .executeTakeFirst();
476 if (!release) return new Response("Not found", { status: 404 });
477
478 // Abort any in-progress archive generation before touching disk so
479 // the background task doesn't race with the rmSync below.
480 archivingTasks.get(release.id)?.abort();
481 archivingTasks.delete(release.id);
482
483 // Remove files from disk before the DB record so that a crash
484 // between the two leaves a broken-but-visible repo rather than a
485 // DB record pointing to missing files.
486 const releaseDir = path.join(
487 paths.RELEASES_DIR,
488 String(release.id),
489 );
490 rmSync(releaseDir, { recursive: true, force: true });
491 await db
492 .deleteFrom("releases")
493 .where("id", "=", release.id)
494 .execute();
495
496 return new Response(null, {
497 status: 302,
498 headers: { Location: `/${repo.name}/releases` },
499 });
500 },
501 {
502 params: t.Object({
503 repo: t.String(),
504 id: t.Numeric(),
505 }),
506 },
507 )
508
509 .get(
510 "/:repo/releases/:id/assets/:filename",
511 async ({ params, cookie }) => {
512 const user = await resolveSession(cookie.session.value);
513 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
514 if (!repo) return new Response("Not found", { status: 404 });
515
516 const release = await db
517 .selectFrom("releases")
518 .select("id")
519 .where("repo_id", "=", repo.id)
520 .where("id", "=", params.id)
521 .executeTakeFirst();
522 if (!release) return new Response("Not found", { status: 404 });
523
524 const safeFilename = path.basename(params.filename);
525 const asset = await db
526 .selectFrom("release_assets")
527 .selectAll()
528 .where("release_id", "=", release.id)
529 .where("filename", "=", safeFilename)
530 .executeTakeFirst();
531 if (!asset) return new Response("Not found", { status: 404 });
532
533 const filePath = path.join(
534 paths.RELEASES_DIR,
535 String(release.id),
536 "assets",
537 safeFilename,
538 );
539 const file = Bun.file(filePath);
540 if (!(await file.exists()))
541 return new Response("Not found", { status: 404 });
542
543 return new Response(file, {
544 headers: {
545 "Content-Disposition": `attachment; filename="${safeFilename}"`,
546 "Content-Type": "application/octet-stream",
547 },
548 });
549 },
550 {
551 params: t.Object({
552 repo: t.String(),
553 id: t.Numeric(),
554 filename: t.String(),
555 }),
556 },
557 )
558
559 .get(
560 "/:repo/releases/:id/source/:filename",
561 async ({ params, cookie }) => {
562 const user = await resolveSession(cookie.session.value);
563 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
564 if (!repo) return new Response("Not found", { status: 404 });
565
566 const release = await db
567 .selectFrom("releases")
568 .select(["id", "include_source_code"])
569 .where("repo_id", "=", repo.id)
570 .where("id", "=", params.id)
571 .executeTakeFirst();
572 if (!release || !release.include_source_code)
573 return new Response("Not found", { status: 404 });
574
575 const safeFilename = path.basename(params.filename);
576 const filePath = path.join(
577 paths.RELEASES_DIR,
578 String(release.id),
579 "source",
580 safeFilename,
581 );
582 const file = Bun.file(filePath);
583 if (!(await file.exists()))
584 return new Response("Not found", { status: 404 });
585
586 return new Response(file, {
587 headers: {
588 "Content-Disposition": `attachment; filename="${safeFilename}"`,
589 "Content-Type": "application/octet-stream",
590 },
591 });
592 },
593 {
594 params: t.Object({
595 repo: t.String(),
596 id: t.Numeric(),
597 filename: t.String(),
598 }),
599 },
600 );
601