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, git } 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 name = body.name?.trim() ?? "";
124 const createTag = body.create_tag === "on";
125 const tagName = createTag ? (body.tag_name?.trim() ?? "") : null;
126 const revision = createTag ? (body.revision?.trim() ?? "") : null;
127 const formValues = {
128 ...body,
129 create_tag: createTag,
130 include_source_code: body.include_source_code === "on",
131 };
132
133 if (createTag && (!user!.git_name?.trim() || !user!.git_email?.trim())) {
134 return html(
135 <NewRelease
136 user={user!}
137 repo={repo}
138 error="You must set your git name and email in Settings before creating a tagged release"
139 values={formValues}
140 />,
141 );
142 }
143
144 if (!name) {
145 return html(
146 <NewRelease
147 user={user!}
148 repo={repo}
149 error="Release title is required"
150 values={formValues}
151 />,
152 );
153 }
154
155 if (createTag) {
156 if (!tagName) {
157 return html(
158 <NewRelease
159 user={user!}
160 repo={repo}
161 error="Tag name is required when creating a git tag"
162 values={formValues}
163 />,
164 );
165 }
166 if (tagName.includes("/")) {
167 return html(
168 <NewRelease
169 user={user!}
170 repo={repo}
171 error="Tag name must not contain slashes"
172 values={formValues}
173 />,
174 );
175 }
176 if (!revision) {
177 return html(
178 <NewRelease
179 user={user!}
180 repo={repo}
181 error="Revision is required when creating a git tag"
182 values={formValues}
183 />,
184 );
185 }
186 }
187
188 // Create git tag
189 if (createTag && tagName && revision) {
190 const notes = body.notes?.trim() || null;
191 const tagMessage = notes ? `${name}\n\n${notes}` : name;
192 const tagResult = await git.createTag(
193 repo.name,
194 tagName,
195 revision,
196 tagMessage,
197 user!.git_name!,
198 user!.git_email!,
199 );
200 if (tagResult === "already_exists") {
201 return html(
202 <NewRelease
203 user={user!}
204 repo={repo}
205 error={`Git tag "${tagName}" already exists in this repository`}
206 values={formValues}
207 />,
208 );
209 }
210 if (tagResult === "bad_ref") {
211 return html(
212 <NewRelease
213 user={user!}
214 repo={repo}
215 error={`"${revision}" is not a valid revision in this repository`}
216 values={formValues}
217 />,
218 );
219 }
220 if (tagResult === "error") {
221 return html(
222 <NewRelease
223 user={user!}
224 repo={repo}
225 error="Failed to create git tag"
226 values={formValues}
227 />,
228 );
229 }
230 }
231
232 const includeSource =
233 body.include_source_code === "on" && createTag && !!tagName;
234 const now = new Date().toISOString();
235
236 // Collect uploaded file data before opening the transaction so we
237 // don't hold it open across slow I/O.
238 const rawFiles = body.files;
239 const uploadedFiles: {
240 filename: string;
241 data: Blob;
242 size: number;
243 contentType: string;
244 }[] = [];
245 if (rawFiles) {
246 const files = Array.isArray(rawFiles) ? rawFiles : [rawFiles];
247 for (const file of files) {
248 if (file.size === 0) continue;
249 uploadedFiles.push({
250 filename: sanitizeFilename(file.name),
251 data: file,
252 size: file.size,
253 contentType: file.type || "application/octet-stream",
254 });
255 }
256 }
257
258 // Insert the release record and all asset records in one transaction
259 // so that partial failures don't leave orphaned DB rows.
260 // releaseDir is captured inside the callback so the catch can clean
261 // up files even though the auto-increment ID isn't known until after
262 // the INSERT.
263 let releaseDir: string | null = null;
264 const releaseId = await db
265 .transaction()
266 .execute(async (trx) => {
267 const inserted = await trx
268 .insertInto("releases")
269 .values({
270 repo_id: repo.id,
271 tag_name: tagName,
272 name,
273 notes: body.notes?.trim() || null,
274 include_source_code: includeSource ? 1 : 0,
275 created_at: now,
276 })
277 .returning("id")
278 .executeTakeFirstOrThrow();
279
280 const id = inserted.id;
281 releaseDir = path.join(RELEASES_DIR, String(id));
282
283 if (includeSource) {
284 const sourceDir = path.join(releaseDir, "source");
285 mkdirSync(sourceDir, { recursive: true });
286 // Write a sentinel file; the actual archives are
287 // generated asynchronously after the response is sent.
288 await Bun.write(path.join(sourceDir, ".pending"), "");
289 }
290
291 if (uploadedFiles.length > 0) {
292 const assetsDir = path.join(releaseDir, "assets");
293 mkdirSync(assetsDir, { recursive: true });
294 for (const f of uploadedFiles) {
295 await Bun.write(
296 path.join(assetsDir, f.filename),
297 f.data,
298 );
299 await trx
300 .insertInto("release_assets")
301 .values({
302 release_id: id,
303 filename: f.filename,
304 size: f.size,
305 content_type: f.contentType,
306 created_at: now,
307 })
308 .execute();
309 }
310 }
311
312 return id;
313 })
314 .catch((err) => {
315 // Roll back any partially-written files if the transaction
316 // failed — the DB rollback handles the DB side automatically.
317 if (releaseDir) {
318 rmSync(releaseDir, { recursive: true, force: true });
319 }
320 throw err;
321 });
322
323 // Kick off source archive generation in the background so the
324 // response can be sent immediately. The .pending sentinel written
325 // inside the transaction signals to the detail view that archives
326 // are still being prepared. If the release is deleted while
327 // generation is in progress the background task will hit errors
328 // (the directory will have been removed) and silently bail out;
329 // SQLite AUTOINCREMENT guarantees the ID is never reused, so there
330 // is no risk of contaminating a later release.
331 if (includeSource) {
332 const sourceDir = path.join(
333 RELEASES_DIR,
334 String(releaseId),
335 "source",
336 );
337 const controller = new AbortController();
338 archivingTasks.set(releaseId, controller);
339 (async () => {
340 try {
341 await archiveRepo(
342 repo.name,
343 tagName!,
344 repo.name,
345 sourceDir,
346 controller.signal,
347 );
348 rmSync(path.join(sourceDir, ".pending"), {
349 force: true,
350 });
351 } catch {
352 // Either the release was deleted (abort) or archiving
353 // failed. Remove the source dir so the UI shows no
354 // stale state.
355 rmSync(sourceDir, { recursive: true, force: true });
356 } finally {
357 archivingTasks.delete(releaseId);
358 }
359 })();
360 }
361
362 return new Response(null, {
363 status: 302,
364 headers: {
365 Location: `/${repo.name}/releases/${releaseId}`,
366 },
367 });
368 },
369 {
370 body: t.Object({
371 create_tag: t.Optional(t.String()),
372 tag_name: t.Optional(t.String()),
373 revision: t.Optional(t.String()),
374 name: t.Optional(t.String()),
375 notes: t.Optional(t.String()),
376 include_source_code: t.Optional(t.String()),
377 files: t.Optional(t.Union([t.File(), t.Array(t.File())])),
378 }),
379 type: "multipart/form-data",
380 },
381 )
382
383 .get(
384 "/:repo/releases/:id",
385 async ({ params, cookie }) => {
386 const user = await resolveSession(cookie.session.value);
387 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
388 if (!repo) return new Response("Not found", { status: 404 });
389
390 const release = await db
391 .selectFrom("releases")
392 .selectAll()
393 .where("repo_id", "=", repo.id)
394 .where("id", "=", params.id)
395 .executeTakeFirst();
396 if (!release) return new Response("Not found", { status: 404 });
397
398 const assets = await db
399 .selectFrom("release_assets")
400 .selectAll()
401 .where("release_id", "=", release.id)
402 .orderBy("id", "asc")
403 .execute();
404
405 const notesHtml = release.notes
406 ? renderMarkdown(release.notes)
407 : "";
408
409 // Detect which source archives exist on disk, and whether
410 // generation is still in progress (indicated by a .pending file).
411 const sourceArchives: {
412 format: string;
413 filename: string;
414 size: number;
415 }[] = [];
416 let sourceArchivesPending = false;
417 if (release.include_source_code) {
418 const base = `${repo.name}-${release.tag_name}`;
419 const sourceDir = path.join(
420 RELEASES_DIR,
421 String(release.id),
422 "source",
423 );
424 if (await Bun.file(path.join(sourceDir, ".pending")).exists()) {
425 sourceArchivesPending = true;
426 } else {
427 for (const [format, ext] of [
428 ["zip", ".zip"],
429 ["tar.gz", ".tar.gz"],
430 ["tar.zst", ".tar.zst"],
431 ] as const) {
432 const filePath = path.join(sourceDir, `${base}${ext}`);
433 const f = Bun.file(filePath);
434 if (await f.exists()) {
435 sourceArchives.push({
436 format,
437 filename: `${base}${ext}`,
438 size: f.size,
439 });
440 }
441 }
442 }
443 }
444
445 return html(
446 <ReleaseDetail
447 user={user}
448 repo={repo}
449 release={release}
450 notesHtml={notesHtml}
451 assets={assets}
452 sourceArchives={sourceArchives}
453 sourceArchivesPending={sourceArchivesPending}
454 />,
455 );
456 },
457 {
458 params: t.Object({
459 repo: t.String(),
460 id: t.Numeric(),
461 }),
462 },
463 )
464
465 .post(
466 "/:repo/releases/:id/delete",
467 async ({ params, cookie }) => {
468 const user = await resolveSession(cookie.session.value);
469 const deny = requireAdmin(user);
470 if (deny) return deny;
471 const repo = await getRepo(params.repo, true);
472 if (!repo) return new Response("Not found", { status: 404 });
473
474 const release = await db
475 .selectFrom("releases")
476 .select("id")
477 .where("repo_id", "=", repo.id)
478 .where("id", "=", params.id)
479 .executeTakeFirst();
480 if (!release) return new Response("Not found", { status: 404 });
481
482 // Abort any in-progress archive generation before touching disk so
483 // the background task doesn't race with the rmSync below.
484 archivingTasks.get(release.id)?.abort();
485 archivingTasks.delete(release.id);
486
487 // Remove files from disk before the DB record so that a crash
488 // between the two leaves a broken-but-visible repo rather than a
489 // DB record pointing to missing files.
490 const releaseDir = path.join(RELEASES_DIR, String(release.id));
491 rmSync(releaseDir, { recursive: true, force: true });
492 await db
493 .deleteFrom("releases")
494 .where("id", "=", release.id)
495 .execute();
496
497 return new Response(null, {
498 status: 302,
499 headers: { Location: `/${repo.name}/releases` },
500 });
501 },
502 {
503 params: t.Object({
504 repo: t.String(),
505 id: t.Numeric(),
506 }),
507 },
508 )
509
510 .get(
511 "/:repo/releases/:id/assets/:filename",
512 async ({ params, cookie }) => {
513 const user = await resolveSession(cookie.session.value);
514 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
515 if (!repo) return new Response("Not found", { status: 404 });
516
517 const release = await db
518 .selectFrom("releases")
519 .select("id")
520 .where("repo_id", "=", repo.id)
521 .where("id", "=", params.id)
522 .executeTakeFirst();
523 if (!release) return new Response("Not found", { status: 404 });
524
525 const safeFilename = path.basename(params.filename);
526 const asset = await db
527 .selectFrom("release_assets")
528 .selectAll()
529 .where("release_id", "=", release.id)
530 .where("filename", "=", safeFilename)
531 .executeTakeFirst();
532 if (!asset) return new Response("Not found", { status: 404 });
533
534 const filePath = path.join(
535 RELEASES_DIR,
536 String(release.id),
537 "assets",
538 safeFilename,
539 );
540 const file = Bun.file(filePath);
541 if (!(await file.exists()))
542 return new Response("Not found", { status: 404 });
543
544 return new Response(file, {
545 headers: {
546 "Content-Disposition": `attachment; filename="${safeFilename}"`,
547 "Content-Type": "application/octet-stream",
548 },
549 });
550 },
551 {
552 params: t.Object({
553 repo: t.String(),
554 id: t.Numeric(),
555 filename: t.String(),
556 }),
557 },
558 )
559
560 .get(
561 "/:repo/releases/:id/source/:filename",
562 async ({ params, cookie }) => {
563 const user = await resolveSession(cookie.session.value);
564 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
565 if (!repo) return new Response("Not found", { status: 404 });
566
567 const release = await db
568 .selectFrom("releases")
569 .select(["id", "include_source_code"])
570 .where("repo_id", "=", repo.id)
571 .where("id", "=", params.id)
572 .executeTakeFirst();
573 if (!release || !release.include_source_code)
574 return new Response("Not found", { status: 404 });
575
576 const safeFilename = path.basename(params.filename);
577 const filePath = path.join(
578 RELEASES_DIR,
579 String(release.id),
580 "source",
581 safeFilename,
582 );
583 const file = Bun.file(filePath);
584 if (!(await file.exists()))
585 return new Response("Not found", { status: 404 });
586
587 return new Response(file, {
588 headers: {
589 "Content-Disposition": `attachment; filename="${safeFilename}"`,
590 "Content-Type": "application/octet-stream",
591 },
592 });
593 },
594 {
595 params: t.Object({
596 repo: t.String(),
597 id: t.Numeric(),
598 filename: t.String(),
599 }),
600 },
601 );
602