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