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