security: fix authz, DOS, and concurrency findings from review

- git smart-HTTP/SSH now require admin for private-repo clone, reject
  pending users in basic-auth, and rate-limit Basic auth attempts to
  block argon2-driven event-loop DOS and password spray (C1-C3)
- per-(client, kind) rate-limit buckets across all mutating routes:
  comments, reactions, uploads, repo/issue/patch/release creation (H1, H2)
- stream /raw via piped git cat-file with Range support, gate blob/diff/
  markdown rendering on MAX_RENDER_BYTES, cap MAX_RAW_DOWNLOAD_BYTES (H3)
- cap sharp limitInputPixels on avatar upload (H4)
- new contentDisposition helper for RFC 5987 filename* + safe ASCII (M2)
- CI run + release archive concurrency caps with FIFO queue, queued UI
  pill, and SSH-push CI trigger; pumpQueue is sole writer of
  runningTasks.set across triggerRun and retryRun (M3)
- replace shell-string interpolation in CI publish_zip with positional
  exec via per-call workDir (M6)
- defer H5 (Secure cookie / HSTS) with rationale captured in TODO.txt
AuthorKonata <konata@posteo.jp>
Date
Commiteb52ac0fd3fc4dfbbee9269c2efcd35cea103c8c
Parenta2f612e
23 files changed, 983 insertions(+), 156 deletions(-)
M.gitignore
@@ -40,3 +40,5 @@ data-test/
4040
4141 # Generated CSS
4242 public/assets/main.css
43+
44+.claude
MREADME.md
@@ -80,7 +80,7 @@ All settings are environment variables:
8080 | `MAX_USER_UPLOAD_BYTES` | `2097152` | Max upload size for non-admin users (2 MB) |
8181 | `INLINE_MAX_BYTES` | `524288` | Max file size rendered inline in the code view (512 KB) |
8282 | `SSH_DISABLED` | `0` | Set to `1` to disable the embedded SSH server |
83-| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` headers |
83+| `TRUSTED_PROXY` | `0` | Trust `X-Forwarded-For` headers \*\* |
8484 | `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting |
8585 | `HIGHLIGHT_WORKERS` | `4` | Syntax highlighting worker threads \* |
8686 | `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name for merges and UI edits |
@@ -98,6 +98,8 @@ All settings are environment variables:
9898
9999 \* Each highlighting worker loads its own copy of the language grammars and uses ~200 MB of memory. Increase with care.
100100
101+\*\* Set `TRUSTED_PROXY=1` only when Hearthforge is behind a reverse proxy that strips any incoming `X-Forwarded-For` from clients. Caddy and Traefik do this by default; nginx requires `proxy_set_header X-Forwarded-For $remote_addr;` (rather than the common `$proxy_add_x_forwarded_for`, which appends to a client-supplied value). Setting `TRUSTED_PROXY=1` in front of a proxy that does not strip means rate limits and any audit logging are spoofable per request.
102+
101103 ## CI/CD Pipelines
102104
103105 Hearthforge includes a built-in CI/CD system that runs pipelines in Docker or Podman containers,
Msrc/app.ts
@@ -37,8 +37,9 @@ export async function createApp(port: number) {
3737 await syncStartup();
3838 await cancelStaleRuns();
3939
40- // Recurring session cleanup — runs every 24 hours
41- setInterval(cleanupSessions, 24 * 60 * 60 * 1000);
40+ // Recurring session cleanup — runs hourly so the row count tracks
41+ // expiry instead of trailing it by up to a day.
42+ setInterval(cleanupSessions, 60 * 60 * 1000);
4243
4344 return new Elysia({
4445 serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES },
Msrc/config.ts
@@ -2,6 +2,13 @@ import path from "node:path";
22
33 const env = process.env;
44
5+/** parseInt with a default that distinguishes "unset" from "explicit 0". */
6+function intEnv(value: string | undefined, defaultValue: number): number {
7+ if (value === undefined || value === "") return defaultValue;
8+ const parsed = parseInt(value, 10);
9+ return Number.isFinite(parsed) ? parsed : defaultValue;
10+}
11+
512 const config = {
613 OWNER_DISPLAY_NAME: env.OWNER_DISPLAY_NAME ?? "Admin",
714 INLINE_MAX_BYTES: parseInt(env.INLINE_MAX_BYTES ?? "", 10) || 524288,
@@ -35,6 +42,23 @@ const config = {
3542 CI_MAX_HISTORY: parseInt(env.CI_MAX_HISTORY ?? "", 10) || 50,
3643 CI_MAX_CONCURRENT: parseInt(env.CI_MAX_CONCURRENT ?? "", 10) || 2,
3744 CI_DEFAULT_TIMEOUT: parseInt(env.CI_DEFAULT_TIMEOUT ?? "", 10) || 3600,
45+ // Cap on simultaneous source archive generation jobs (one release with
46+ // include_source_code spawns three git-archive + compressor pipelines
47+ // back-to-back). Without this cap, an admin firing several releases in
48+ // quick succession can saturate CPU. Excess jobs are queued in memory.
49+ MAX_CONCURRENT_ARCHIVE_JOBS:
50+ parseInt(env.MAX_CONCURRENT_ARCHIVE_JOBS ?? "", 10) || 2,
51+ // Cap on any server-side render that holds the whole content in
52+ // RAM and runs synchronous CPU work on it (blob view, commit/patch
53+ // diff, markdown). Above this, the UI shows a "too large to
54+ // preview" stub and links to the raw endpoint. Setting to 0
55+ // disables inline rendering entirely.
56+ MAX_RENDER_BYTES: intEnv(env.MAX_RENDER_BYTES, 10 * 1024 * 1024),
57+ // Cap on the streamed /raw download. 0 means no limit (current
58+ // behaviour) — useful on a trusted LAN where you actually want to
59+ // pull large blobs out of the browser. Public deployments should
60+ // pin this to something sane.
61+ MAX_RAW_DOWNLOAD_BYTES: intEnv(env.MAX_RAW_DOWNLOAD_BYTES, 0),
3862 };
3963
4064 // Derived values that depend on other config fields
Msrc/constants.ts
@@ -31,6 +31,26 @@ export const LOGIN_MAX_ATTEMPTS = 10;
3131 export const LOGIN_RATE_WINDOW_MS = 60_000;
3232 export const REGISTRATION_MAX_ATTEMPTS = 3;
3333 export const REGISTRATION_RATE_WINDOW_MS = 60 * 60_000;
34+// Bounds the per-IP cost of git smart-HTTP basic auth — every call to
35+// verifyBasicAuth runs argon2 (~100ms) and would otherwise be an
36+// unauthenticated event-loop DOS vector and a brute-force oracle.
37+export const GIT_AUTH_MAX_ATTEMPTS = 10;
38+export const GIT_AUTH_RATE_WINDOW_MS = 60_000;
39+
40+// Per-user / per-IP caps on user-content writes. Numbers are deliberately
41+// roomy for a logged-in person clicking around but tight enough that a
42+// scripted client can't fill the database in seconds.
43+export const COMMENT_MAX_PER_MIN = 30;
44+export const REACTION_MAX_PER_MIN = 60;
45+export const ISSUE_CREATE_MAX_PER_MIN = 10;
46+export const PATCH_CREATE_MAX_PER_MIN = 10;
47+export const REPO_CREATE_MAX_PER_HOUR = 30;
48+export const FILE_EDIT_MAX_PER_MIN = 30;
49+export const RELEASE_WRITE_MAX_PER_MIN = 20;
50+export const LABEL_WRITE_MAX_PER_MIN = 30;
51+export const UPLOAD_MAX_PER_MIN = 10;
52+export const RATE_WINDOW_MIN_MS = 60_000;
53+export const RATE_WINDOW_HOUR_MS = 60 * 60_000;
3454
3555 // Session
3656 export const SESSION_ID_BYTES = 32;
Asrc/lib/contentDisposition.ts
@@ -0,0 +1,26 @@
1+/**
2+ * Build a safe `Content-Disposition` header value.
3+ *
4+ * The display filename is restricted to a printable ASCII subset so it can
5+ * never break out of the quoted-string form (no `"`, `\`, CR, LF, NUL),
6+ * and a UTF-8 `filename*` parameter is added per RFC 5987 so unicode names
7+ * still come through to the client when possible.
8+ */
9+export function contentDisposition(
10+ type: "inline" | "attachment",
11+ name: string,
12+): string {
13+ // Drop path components and control chars; collapse anything not
14+ // printable-ASCII-and-safe-in-a-quoted-string into "_".
15+ const base = name.split(/[/\\]/).pop() ?? "";
16+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are exactly what we want to strip from HTTP header values
17+ const stripped = base.replace(/[\x00-\x1f\x7f"\\]/g, "_");
18+ const ascii = stripped.length > 0 ? stripped : "file";
19+ // biome-ignore lint/suspicious/noControlCharactersInRegex: control characters are exactly what we want to strip from HTTP header values
20+ const utf8 = encodeURIComponent(base.replace(/[\x00-\x1f\x7f]/g, "_"))
21+ // RFC 5987 disallows `'` in filename* value-chars (it is the
22+ // separator between charset, language, and value); encodeURIComponent
23+ // does not escape it, so do it explicitly.
24+ .replace(/'/g, "%27");
25+ return `${type}; filename="${ascii}"; filename*=UTF-8''${utf8}`;
26+}
Msrc/lib/rateLimiter.ts
@@ -5,28 +5,53 @@ interface Bucket {
55 resetAt: number;
66 }
77
8+export type RateLimitKind =
9+ | "login"
10+ | "passkey"
11+ | "git-auth"
12+ | "comment"
13+ | "reaction"
14+ | "upload"
15+ | "register"
16+ | "repo-create"
17+ | "issue-create"
18+ | "patch-create"
19+ | "label-write"
20+ | "release-write"
21+ | "file-edit";
22+
823 const buckets = new Map<string, Bucket>();
9-let sweepCounter = 0;
1024
11-function maybeSweep() {
12- if (++sweepCounter < 1000) return;
13- sweepCounter = 0;
25+function sweep() {
1426 const now = Date.now();
1527 for (const [key, bucket] of buckets) {
1628 if (now > bucket.resetAt) buckets.delete(key);
1729 }
1830 }
1931
32+// Periodic sweep so memory doesn't grow unboundedly when traffic is low
33+// and the request-driven sweep never reaches its threshold.
34+setInterval(sweep, 60 * 1000).unref();
35+
36+let sweepCounter = 0;
37+function maybeSweep() {
38+ if (++sweepCounter < 1000) return;
39+ sweepCounter = 0;
40+ sweep();
41+}
42+
2043 export function checkRateLimit(
2144 ip: string | null,
45+ kind: RateLimitKind,
2246 maxRequests: number,
2347 windowMs: number,
2448 ): boolean {
2549 if (config.RATE_LIMIT_DISABLED || !ip) return true;
50+ const key = `${ip}|${kind}`;
2651 const now = Date.now();
27- const bucket = buckets.get(ip);
52+ const bucket = buckets.get(key);
2853 if (!bucket || now > bucket.resetAt) {
29- buckets.set(ip, { count: 1, resetAt: now + windowMs });
54+ buckets.set(key, { count: 1, resetAt: now + windowMs });
3055 maybeSweep();
3156 return true;
3257 }
@@ -47,3 +72,25 @@ export function getClientIp(
4772 }
4873 return server?.requestIP(request)?.address ?? null;
4974 }
75+
76+/**
77+ * Rate-limit a mutating handler. Returns null if the request is allowed,
78+ * or a 429 Response if it isn't. The bucket is keyed on the user id when
79+ * available (so a single attacker can't bypass by rotating source IPs)
80+ * and on the IP when not.
81+ */
82+export function rateLimit(
83+ request: Request,
84+ server: Bun.Server<unknown> | null,
85+ userId: number | null,
86+ kind: RateLimitKind,
87+ maxRequests: number,
88+ windowMs: number,
89+): Response | null {
90+ const key = userId !== null ? `u${userId}` : getClientIp(request, server);
91+ if (checkRateLimit(key, kind, maxRequests, windowMs)) return null;
92+ return new Response("Too many requests. Please slow down.", {
93+ status: 429,
94+ headers: { "Content-Type": "text/plain; charset=utf-8" },
95+ });
96+}
Msrc/routes/auth.tsx
@@ -102,7 +102,14 @@ export const authRoutes = new Elysia()
102102 "/login",
103103 async ({ body, request, server }) => {
104104 const ip = getClientIp(request, server);
105- if (!checkRateLimit(ip, LOGIN_MAX_ATTEMPTS, LOGIN_RATE_WINDOW_MS)) {
105+ if (
106+ !checkRateLimit(
107+ ip,
108+ "login",
109+ LOGIN_MAX_ATTEMPTS,
110+ LOGIN_RATE_WINDOW_MS,
111+ )
112+ ) {
106113 return html(
107114 <Login error="Too many login attempts. Please try again later." />,
108115 );
@@ -154,6 +161,7 @@ export const authRoutes = new Elysia()
154161 if (
155162 !checkRateLimit(
156163 ip,
164+ "register",
157165 REGISTRATION_MAX_ATTEMPTS,
158166 REGISTRATION_RATE_WINDOW_MS,
159167 )
Msrc/routes/avatars.ts
@@ -1,7 +1,12 @@
11 import { Elysia, t } from "elysia";
22 import config from "../config.ts";
3-import { YEAR_SECONDS } from "../constants.ts";
3+import {
4+ RATE_WINDOW_MIN_MS,
5+ UPLOAD_MAX_PER_MIN,
6+ YEAR_SECONDS,
7+} from "../constants.ts";
48 import { db } from "../db/index.ts";
9+import { rateLimit } from "../lib/rateLimiter.ts";
510 import { requireAuth, resolveSession } from "../middleware/session.ts";
611 import {
712 avatarJxlPath,
@@ -52,10 +57,19 @@ export const avatarRoutes = new Elysia()
5257
5358 .post(
5459 "/settings/avatar",
55- async ({ body, cookie }) => {
60+ async ({ body, cookie, request, server }) => {
5661 const user = await resolveSession(cookie.session.value);
5762 const deny = requireAuth(user);
5863 if (deny) return deny;
64+ const limited = rateLimit(
65+ request,
66+ server,
67+ user?.id ?? null,
68+ "upload",
69+ UPLOAD_MAX_PER_MIN,
70+ RATE_WINDOW_MIN_MS,
71+ );
72+ if (limited) return limited;
5973 if (body.avatar.size > config.MAX_USER_UPLOAD_BYTES) {
6074 return new Response("Avatar file too large", { status: 400 });
6175 }
Msrc/routes/ci.tsx
@@ -3,10 +3,12 @@ import path from "node:path";
33 import { Elysia, t } from "elysia";
44 import { CI_RUNS_PER_PAGE, paths } from "../constants.ts";
55 import { db, getRepo } from "../db/index.ts";
6+import { contentDisposition } from "../lib/contentDisposition.ts";
67 import { paginate } from "../lib/pagination.ts";
78 import { requireAdmin, resolveSession } from "../middleware/session.ts";
89 import {
910 cancelRun,
11+ ciQueuePosition,
1012 parseCiConfig,
1113 purgeRepoCaches,
1214 retryRun,
@@ -146,6 +148,8 @@ export const ciRoutes = new Elysia()
146148 const runsWithCounts = runs.map((r) => ({
147149 ...r,
148150 artifact_count: artifactCountMap.get(r.id) ?? 0,
151+ queue_position:
152+ r.status === "queued" ? ciQueuePosition(r.id) : null,
149153 }));
150154
151155 // Determine why manual trigger may be unavailable (admin-only check)
@@ -252,6 +256,9 @@ export const ciRoutes = new Elysia()
252256 steps={steps}
253257 artifacts={artifacts}
254258 autoRefresh={query.refresh !== "off"}
259+ queuePosition={
260+ run.status === "queued" ? ciQueuePosition(run.id) : null
261+ }
255262 />,
256263 );
257264 },
@@ -508,7 +515,10 @@ export const ciRoutes = new Elysia()
508515
509516 return new Response(Bun.file(filePath), {
510517 headers: {
511- "Content-Disposition": `attachment; filename="${artifact.filename}"`,
518+ "Content-Disposition": contentDisposition(
519+ "attachment",
520+ artifact.filename,
521+ ),
512522 "Content-Type": "application/octet-stream",
513523 "Content-Length": String(artifact.size),
514524 },
Msrc/routes/git.ts
@@ -2,8 +2,15 @@ import { existsSync } from "node:fs";
22 import path from "node:path";
33 import * as argon2 from "argon2";
44 import { Elysia, t } from "elysia";
5-import { ADMIN_USERNAME, paths, VALID_REPO_NAME_RE } from "../constants.ts";
5+import {
6+ ADMIN_USERNAME,
7+ GIT_AUTH_MAX_ATTEMPTS,
8+ GIT_AUTH_RATE_WINDOW_MS,
9+ paths,
10+ VALID_REPO_NAME_RE,
11+} from "../constants.ts";
612 import { db } from "../db";
13+import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts";
714 import {
815 parseCiConfig,
916 shouldTriggerPush,
@@ -110,6 +117,7 @@ async function verifyBasicAuth(
110117 .selectFrom("users")
111118 .select("password_hash")
112119 .where("username", "=", username)
120+ .where("is_pending", "=", 0)
113121 .executeTakeFirst();
114122 if (!user?.password_hash) return false;
115123 try {
@@ -129,6 +137,37 @@ function unauthorized(): Response {
129137 });
130138 }
131139
140+function tooManyRequests(): Response {
141+ return new Response("Too Many Requests", {
142+ status: 429,
143+ headers: { "Content-Type": "text/plain" },
144+ });
145+}
146+
147+/**
148+ * Rate-limit the per-IP cost of `verifyBasicAuth`. Each call costs
149+ * ~100ms of argon2 work on the single event-loop thread, so without
150+ * this an unauthenticated attacker can pin the CPU and use the same
151+ * endpoint as a password-spray oracle around the /login limiter.
152+ *
153+ * Counts even non-Basic-header requests against the bucket: a private
154+ * repo is only listed in the UI for admins, so a non-admin only ever
155+ * pokes these endpoints intentionally and shouldn't get a free retry
156+ * by omitting the header.
157+ */
158+function checkGitAuthLimit(
159+ request: Request,
160+ server: Bun.Server<unknown> | null,
161+): boolean {
162+ const ip = getClientIp(request, server);
163+ return checkRateLimit(
164+ ip,
165+ "git-auth",
166+ GIT_AUTH_MAX_ATTEMPTS,
167+ GIT_AUTH_RATE_WINDOW_MS,
168+ );
169+}
170+
132171 async function getRepo(
133172 slug: string,
134173 ): Promise<{ name: string; repoPath: string; isPrivate: boolean } | null> {
@@ -162,7 +201,7 @@ export const gitRoutes = new Elysia()
162201 // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push)
163202 .get(
164203 "/:repo/info/refs",
165- async ({ params, query, request }) => {
204+ async ({ params, query, request, server }) => {
166205 const service = query.service;
167206 if (
168207 service !== "git-upload-pack" &&
@@ -175,12 +214,18 @@ export const gitRoutes = new Elysia()
175214 if (!repo) return new Response("Not Found", { status: 404 });
176215
177216 const authHeader = request.headers.get("Authorization");
178- if (service === "git-receive-pack") {
217+ const requiresAuth =
218+ service === "git-receive-pack" || repo.isPrivate;
219+ if (requiresAuth) {
220+ if (!checkGitAuthLimit(request, server))
221+ return tooManyRequests();
222+ // Match the UI: private repos are admin-only. The web UI
223+ // returns 404 to non-admins via getRepo() in repos.tsx, so
224+ // the smart-HTTP path must do the same — otherwise any
225+ // logged-in user could clone a "private" repo despite the
226+ // UI hiding it.
179227 if (!(await verifyBasicAuth(authHeader, true)))
180228 return unauthorized();
181- } else if (repo.isPrivate) {
182- if (!(await verifyBasicAuth(authHeader, false)))
183- return unauthorized();
184229 }
185230
186231 const gitCmd =
@@ -211,14 +256,16 @@ export const gitRoutes = new Elysia()
211256 )
212257
213258 // upload-pack POST — clone/fetch pack transfer (public for public repos)
214- .post("/:repo/git-upload-pack", async ({ params, request }) => {
259+ .post("/:repo/git-upload-pack", async ({ params, request, server }) => {
215260 const repo = await getRepo(params.repo);
216261 if (!repo) return new Response("Not Found", { status: 404 });
217262 if (repo.isPrivate) {
263+ // Admin-only: see info/refs branch above.
264+ if (!checkGitAuthLimit(request, server)) return tooManyRequests();
218265 if (
219266 !(await verifyBasicAuth(
220267 request.headers.get("Authorization"),
221- false,
268+ true,
222269 ))
223270 )
224271 return unauthorized();
@@ -237,7 +284,8 @@ export const gitRoutes = new Elysia()
237284 })
238285
239286 // receive-pack POST — push pack transfer (admin only)
240- .post("/:repo/git-receive-pack", async ({ params, request }) => {
287+ .post("/:repo/git-receive-pack", async ({ params, request, server }) => {
288+ if (!checkGitAuthLimit(request, server)) return tooManyRequests();
241289 if (
242290 !(await verifyBasicAuth(request.headers.get("Authorization"), true))
243291 )
Msrc/routes/issues.tsx
@@ -1,11 +1,20 @@
11 import { Elysia, t } from "elysia";
22 import { sql } from "kysely";
33 import config from "../config.ts";
4-import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
4+import {
5+ ALLOWED_REACTIONS,
6+ COMMENT_MAX_PER_MIN,
7+ ISSUE_CREATE_MAX_PER_MIN,
8+ ISSUES_PER_PAGE,
9+ LABEL_WRITE_MAX_PER_MIN,
10+ RATE_WINDOW_MIN_MS,
11+ REACTION_MAX_PER_MIN,
12+} from "../constants.ts";
513 import { issuesLabelFilter } from "../db/helpers.ts";
614 import { db, getRepo, type LabelRow } from "../db/index.ts";
715 import { authorizeCommentEdit } from "../lib/commentAuth.ts";
816 import { paginate } from "../lib/pagination.ts";
17+import { rateLimit } from "../lib/rateLimiter.ts";
918 import {
1019 requireAdmin,
1120 requireAuth,
@@ -200,10 +209,19 @@ export const issueRoutes = new Elysia()
200209
201210 .post(
202211 "/:repo/issues",
203- async ({ params, body, cookie }) => {
212+ async ({ params, body, cookie, request, server }) => {
204213 const user = await resolveSession(cookie.session.value);
205214 const deny = requireAuth(user);
206215 if (deny) return deny;
216+ const limited = rateLimit(
217+ request,
218+ server,
219+ user?.id ?? null,
220+ "issue-create",
221+ ISSUE_CREATE_MAX_PER_MIN,
222+ RATE_WINDOW_MIN_MS,
223+ );
224+ if (limited) return limited;
207225 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
208226 if (!repo) return new Response("Not found", { status: 404 });
209227
@@ -412,10 +430,19 @@ export const issueRoutes = new Elysia()
412430
413431 .post(
414432 "/:repo/issues/:number/comments",
415- async ({ params, body, cookie }) => {
433+ async ({ params, body, cookie, request, server }) => {
416434 const user = await resolveSession(cookie.session.value);
417435 const deny = requireAuth(user);
418436 if (deny) return deny;
437+ const limited = rateLimit(
438+ request,
439+ server,
440+ user?.id ?? null,
441+ "comment",
442+ COMMENT_MAX_PER_MIN,
443+ RATE_WINDOW_MIN_MS,
444+ );
445+ if (limited) return limited;
419446 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
420447 if (!repo) return new Response("Not found", { status: 404 });
421448
@@ -476,10 +503,19 @@ export const issueRoutes = new Elysia()
476503
477504 .post(
478505 "/:repo/issues/:number/react",
479- async ({ params, body, cookie }) => {
506+ async ({ params, body, cookie, request, server }) => {
480507 const user = await resolveSession(cookie.session.value);
481508 const deny = requireAuth(user);
482509 if (deny) return deny;
510+ const limited = rateLimit(
511+ request,
512+ server,
513+ user?.id ?? null,
514+ "reaction",
515+ REACTION_MAX_PER_MIN,
516+ RATE_WINDOW_MIN_MS,
517+ );
518+ if (limited) return limited;
483519 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
484520 if (!repo) return new Response("Not found", { status: 404 });
485521
@@ -639,10 +675,19 @@ export const issueRoutes = new Elysia()
639675
640676 .post(
641677 "/:repo/issues/:number/edit",
642- async ({ params, body, cookie }) => {
678+ async ({ params, body, cookie, request, server }) => {
643679 const user = await resolveSession(cookie.session.value);
644680 const deny = requireAuth(user);
645681 if (deny) return deny;
682+ const limited = rateLimit(
683+ request,
684+ server,
685+ user?.id ?? null,
686+ "comment",
687+ COMMENT_MAX_PER_MIN,
688+ RATE_WINDOW_MIN_MS,
689+ );
690+ if (limited) return limited;
646691 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
647692 if (!repo) return new Response("Not found", { status: 404 });
648693
@@ -687,10 +732,19 @@ export const issueRoutes = new Elysia()
687732
688733 .post(
689734 "/:repo/issues/:number/comments/:id/edit",
690- async ({ params, body, cookie }) => {
735+ async ({ params, body, cookie, request, server }) => {
691736 const user = await resolveSession(cookie.session.value);
692737 const deny = requireAuth(user);
693738 if (deny) return deny;
739+ const limited = rateLimit(
740+ request,
741+ server,
742+ user?.id ?? null,
743+ "comment",
744+ COMMENT_MAX_PER_MIN,
745+ RATE_WINDOW_MIN_MS,
746+ );
747+ if (limited) return limited;
694748 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
695749 if (!repo) return new Response("Not found", { status: 404 });
696750
@@ -731,9 +785,18 @@ export const issueRoutes = new Elysia()
731785
732786 .post(
733787 "/:repo/issues/:number/labels/add",
734- async ({ params, body, cookie }) => {
788+ async ({ params, body, cookie, request, server }) => {
735789 const user = await resolveSession(cookie.session.value);
736790 if (!user) return new Response("Unauthorized", { status: 401 });
791+ const limited = rateLimit(
792+ request,
793+ server,
794+ user.id,
795+ "label-write",
796+ LABEL_WRITE_MAX_PER_MIN,
797+ RATE_WINDOW_MIN_MS,
798+ );
799+ if (limited) return limited;
737800 const repo = await getRepo(params.repo, user.isAdmin);
738801 if (!repo) return new Response("Not found", { status: 404 });
739802
@@ -783,9 +846,18 @@ export const issueRoutes = new Elysia()
783846
784847 .post(
785848 "/:repo/issues/:number/labels/remove",
786- async ({ params, body, cookie }) => {
849+ async ({ params, body, cookie, request, server }) => {
787850 const user = await resolveSession(cookie.session.value);
788851 if (!user) return new Response("Unauthorized", { status: 401 });
852+ const limited = rateLimit(
853+ request,
854+ server,
855+ user.id,
856+ "label-write",
857+ LABEL_WRITE_MAX_PER_MIN,
858+ RATE_WINDOW_MIN_MS,
859+ );
860+ if (limited) return limited;
789861 const repo = await getRepo(params.repo, user.isAdmin);
790862 if (!repo) return new Response("Not found", { status: 404 });
791863
Msrc/routes/patches.tsx
@@ -1,11 +1,20 @@
11 import { Elysia, t } from "elysia";
22 import { sql } from "kysely";
33 import config from "../config.ts";
4-import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
4+import {
5+ ALLOWED_REACTIONS,
6+ COMMENT_MAX_PER_MIN,
7+ LABEL_WRITE_MAX_PER_MIN,
8+ PATCH_CREATE_MAX_PER_MIN,
9+ PATCHES_PER_PAGE,
10+ RATE_WINDOW_MIN_MS,
11+ REACTION_MAX_PER_MIN,
12+} from "../constants.ts";
513 import { patchesLabelFilter } from "../db/helpers.ts";
614 import { db, getRepo, type LabelRow } from "../db/index.ts";
715 import { authorizeCommentEdit } from "../lib/commentAuth.ts";
816 import { paginate } from "../lib/pagination.ts";
17+import { rateLimit } from "../lib/rateLimiter.ts";
918 import {
1019 requireAdmin,
1120 requireAuth,
@@ -235,10 +244,19 @@ export const patchRoutes = new Elysia()
235244
236245 .post(
237246 "/:repo/patches",
238- async ({ params, body, cookie }) => {
247+ async ({ params, body, cookie, request, server }) => {
239248 const user = await resolveSession(cookie.session.value);
240249 const deny = requireAuth(user);
241250 if (deny) return deny;
251+ const limited = rateLimit(
252+ request,
253+ server,
254+ user?.id ?? null,
255+ "patch-create",
256+ PATCH_CREATE_MAX_PER_MIN,
257+ RATE_WINDOW_MIN_MS,
258+ );
259+ if (limited) return limited;
242260 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
243261 if (!repo) return new Response("Not found", { status: 404 });
244262
@@ -649,10 +667,19 @@ export const patchRoutes = new Elysia()
649667
650668 .post(
651669 "/:repo/patches/:number/upload",
652- async ({ params, body, cookie }) => {
670+ async ({ params, body, cookie, request, server }) => {
653671 const user = await resolveSession(cookie.session.value);
654672 const deny = requireAuth(user);
655673 if (deny) return deny;
674+ const limited = rateLimit(
675+ request,
676+ server,
677+ user?.id ?? null,
678+ "patch-create",
679+ PATCH_CREATE_MAX_PER_MIN,
680+ RATE_WINDOW_MIN_MS,
681+ );
682+ if (limited) return limited;
656683 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
657684 if (!repo) return new Response("Not found", { status: 404 });
658685
@@ -793,10 +820,19 @@ export const patchRoutes = new Elysia()
793820
794821 .post(
795822 "/:repo/patches/:number/comments",
796- async ({ params, body, cookie }) => {
823+ async ({ params, body, cookie, request, server }) => {
797824 const user = await resolveSession(cookie.session.value);
798825 const deny = requireAuth(user);
799826 if (deny) return deny;
827+ const limited = rateLimit(
828+ request,
829+ server,
830+ user?.id ?? null,
831+ "comment",
832+ COMMENT_MAX_PER_MIN,
833+ RATE_WINDOW_MIN_MS,
834+ );
835+ if (limited) return limited;
800836 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
801837 if (!repo) return new Response("Not found", { status: 404 });
802838
@@ -849,10 +885,19 @@ export const patchRoutes = new Elysia()
849885
850886 .post(
851887 "/:repo/patches/:number/react",
852- async ({ params, body, cookie }) => {
888+ async ({ params, body, cookie, request, server }) => {
853889 const user = await resolveSession(cookie.session.value);
854890 const deny = requireAuth(user);
855891 if (deny) return deny;
892+ const limited = rateLimit(
893+ request,
894+ server,
895+ user?.id ?? null,
896+ "reaction",
897+ REACTION_MAX_PER_MIN,
898+ RATE_WINDOW_MIN_MS,
899+ );
900+ if (limited) return limited;
856901 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
857902 if (!repo) return new Response("Not found", { status: 404 });
858903
@@ -926,10 +971,19 @@ export const patchRoutes = new Elysia()
926971
927972 .post(
928973 "/:repo/patches/:number/comments/:id/edit",
929- async ({ params, body, cookie }) => {
974+ async ({ params, body, cookie, request, server }) => {
930975 const user = await resolveSession(cookie.session.value);
931976 const deny = requireAuth(user);
932977 if (deny) return deny;
978+ const limited = rateLimit(
979+ request,
980+ server,
981+ user?.id ?? null,
982+ "comment",
983+ COMMENT_MAX_PER_MIN,
984+ RATE_WINDOW_MIN_MS,
985+ );
986+ if (limited) return limited;
933987 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
934988 if (!repo) return new Response("Not found", { status: 404 });
935989
@@ -970,10 +1024,19 @@ export const patchRoutes = new Elysia()
9701024
9711025 .post(
9721026 "/:repo/patches/:number/edit",
973- async ({ params, body, cookie }) => {
1027+ async ({ params, body, cookie, request, server }) => {
9741028 const user = await resolveSession(cookie.session.value);
9751029 const deny = requireAuth(user);
9761030 if (deny) return deny;
1031+ const limited = rateLimit(
1032+ request,
1033+ server,
1034+ user?.id ?? null,
1035+ "comment",
1036+ COMMENT_MAX_PER_MIN,
1037+ RATE_WINDOW_MIN_MS,
1038+ );
1039+ if (limited) return limited;
9771040 const repo = await getRepo(params.repo, user?.isAdmin ?? false);
9781041 if (!repo) return new Response("Not found", { status: 404 });
9791042
@@ -1018,9 +1081,18 @@ export const patchRoutes = new Elysia()
10181081
10191082 .post(
10201083 "/:repo/patches/:number/labels/add",
1021- async ({ params, body, cookie }) => {
1084+ async ({ params, body, cookie, request, server }) => {
10221085 const user = await resolveSession(cookie.session.value);
10231086 if (!user) return new Response("Unauthorized", { status: 401 });
1087+ const limited = rateLimit(
1088+ request,
1089+ server,
1090+ user.id,
1091+ "label-write",
1092+ LABEL_WRITE_MAX_PER_MIN,
1093+ RATE_WINDOW_MIN_MS,
1094+ );
1095+ if (limited) return limited;
10241096 const repo = await getRepo(params.repo, user.isAdmin);
10251097 if (!repo) return new Response("Not found", { status: 404 });
10261098
@@ -1070,9 +1142,18 @@ export const patchRoutes = new Elysia()
10701142
10711143 .post(
10721144 "/:repo/patches/:number/labels/remove",
1073- async ({ params, body, cookie }) => {
1145+ async ({ params, body, cookie, request, server }) => {
10741146 const user = await resolveSession(cookie.session.value);
10751147 if (!user) return new Response("Unauthorized", { status: 401 });
1148+ const limited = rateLimit(
1149+ request,
1150+ server,
1151+ user.id,
1152+ "label-write",
1153+ LABEL_WRITE_MAX_PER_MIN,
1154+ RATE_WINDOW_MIN_MS,
1155+ );
1156+ if (limited) return limited;
10761157 const repo = await getRepo(params.repo, user.isAdmin);
10771158 if (!repo) return new Response("Not found", { status: 404 });
10781159
Msrc/routes/releases.tsx
@@ -4,6 +4,7 @@ import { Elysia, t } from "elysia";
44 import config from "../config.ts";
55 import { paths, RELEASES_PER_PAGE } from "../constants.ts";
66 import { db, getRepo } from "../db/index.ts";
7+import { contentDisposition } from "../lib/contentDisposition.ts";
78 import { paginate } from "../lib/pagination.ts";
89 import { requireAdmin, resolveSession } from "../middleware/session.ts";
910 import { archiveRepo, git } from "../services/git.ts";
@@ -18,6 +19,57 @@ import { html } from "../views/render.tsx";
1819 // immediately when the corresponding release is deleted.
1920 const archivingTasks = new Map<number, AbortController>();
2021
22+// FIFO queue of release archive jobs waiting for a slot. Each entry is
23+// keyed by release ID so a delete can pull it out before it ever starts.
24+interface PendingArchive {
25+ releaseId: number;
26+ repoName: string;
27+ tagName: string;
28+ sourceDir: string;
29+}
30+const queuedArchives: PendingArchive[] = [];
31+
32+function pumpArchiveQueue(): void {
33+ while (
34+ queuedArchives.length > 0 &&
35+ archivingTasks.size < config.MAX_CONCURRENT_ARCHIVE_JOBS
36+ ) {
37+ const next = queuedArchives.shift()!;
38+ runArchiveJob(next);
39+ }
40+}
41+
42+function runArchiveJob(job: PendingArchive): void {
43+ const controller = new AbortController();
44+ archivingTasks.set(job.releaseId, controller);
45+ void (async () => {
46+ try {
47+ await archiveRepo(
48+ job.repoName,
49+ job.tagName,
50+ job.repoName,
51+ job.sourceDir,
52+ controller.signal,
53+ );
54+ rmSync(path.join(job.sourceDir, ".pending"), { force: true });
55+ } catch {
56+ // Either the release was deleted (abort) or archiving failed.
57+ rmSync(job.sourceDir, { recursive: true, force: true });
58+ } finally {
59+ archivingTasks.delete(job.releaseId);
60+ pumpArchiveQueue();
61+ }
62+ })();
63+}
64+
65+function scheduleArchive(job: PendingArchive): void {
66+ if (archivingTasks.size >= config.MAX_CONCURRENT_ARCHIVE_JOBS) {
67+ queuedArchives.push(job);
68+ return;
69+ }
70+ runArchiveJob(job);
71+}
72+
2173 function sanitizeFilename(name: string): string {
2274 const safe = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_");
2375 if (!safe || /^\.+$/.test(safe)) return "_";
@@ -312,40 +364,21 @@ export const releasesRoutes = new Elysia()
312364 // Kick off source archive generation in the background so the
313365 // response can be sent immediately. The .pending sentinel written
314366 // 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.
367+ // are still being prepared. Concurrent jobs are capped at
368+ // MAX_CONCURRENT_ARCHIVE_JOBS so a flurry of release creations
369+ // can't saturate CPU; excess jobs queue in-memory.
320370 if (includeSource) {
321371 const sourceDir = path.join(
322372 paths.RELEASES_DIR,
323373 String(releaseId),
324374 "source",
325375 );
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- })();
376+ scheduleArchive({
377+ releaseId,
378+ repoName: repo.name,
379+ tagName: tagName!,
380+ sourceDir,
381+ });
349382 }
350383
351384 return new Response(null, {
@@ -475,9 +508,14 @@ export const releasesRoutes = new Elysia()
475508 if (!release) return new Response("Not found", { status: 404 });
476509
477510 // Abort any in-progress archive generation before touching disk so
478- // the background task doesn't race with the rmSync below.
511+ // the background task doesn't race with the rmSync below. Also
512+ // pull queued (not-yet-started) archive jobs out of the queue.
479513 archivingTasks.get(release.id)?.abort();
480514 archivingTasks.delete(release.id);
515+ const qIdx = queuedArchives.findIndex(
516+ (j) => j.releaseId === release.id,
517+ );
518+ if (qIdx >= 0) queuedArchives.splice(qIdx, 1);
481519
482520 // Remove files from disk before the DB record so that a crash
483521 // between the two leaves a broken-but-visible repo rather than a
@@ -541,7 +579,10 @@ export const releasesRoutes = new Elysia()
541579
542580 return new Response(file, {
543581 headers: {
544- "Content-Disposition": `attachment; filename="${safeFilename}"`,
582+ "Content-Disposition": contentDisposition(
583+ "attachment",
584+ safeFilename,
585+ ),
545586 "Content-Type": "application/octet-stream",
546587 },
547588 });
@@ -584,7 +625,10 @@ export const releasesRoutes = new Elysia()
584625
585626 return new Response(file, {
586627 headers: {
587- "Content-Disposition": `attachment; filename="${safeFilename}"`,
628+ "Content-Disposition": contentDisposition(
629+ "attachment",
630+ safeFilename,
631+ ),
588632 "Content-Type": "application/octet-stream",
589633 },
590634 });
Msrc/routes/repos.tsx
@@ -17,6 +17,7 @@ import {
1717 YEAR_SECONDS,
1818 } from "../constants.ts";
1919 import { db } from "../db/index.ts";
20+import { contentDisposition } from "../lib/contentDisposition.ts";
2021 import { redirect } from "../lib/redirect.ts";
2122 import { requireAdmin, resolveSession } from "../middleware/session.ts";
2223 import { git, repoPath, type TreeEntry } from "../services/git.ts";
@@ -48,6 +49,99 @@ async function getRepo(name: string, isAdmin: boolean) {
4849 return repo;
4950 }
5051
52+/**
53+ * Read a byte range out of a streaming source without ever holding
54+ * the full content in memory. Used by the /raw endpoint to honour
55+ * HTTP Range headers against `git show`'s pipe.
56+ */
57+function sliceStream(
58+ source: ReadableStream<Uint8Array>,
59+ start: number,
60+ length: number,
61+ onDone: () => void,
62+): ReadableStream<Uint8Array> {
63+ const reader = source.getReader();
64+ let skipped = 0;
65+ let emitted = 0;
66+ let finished = false;
67+ const finish = () => {
68+ if (finished) return;
69+ finished = true;
70+ reader.cancel().catch(() => {});
71+ onDone();
72+ };
73+ return new ReadableStream<Uint8Array>({
74+ async pull(controller) {
75+ while (emitted < length) {
76+ const { value, done } = await reader.read();
77+ if (done) {
78+ controller.close();
79+ finish();
80+ return;
81+ }
82+ let chunk = value;
83+ if (skipped < start) {
84+ const drop = Math.min(start - skipped, chunk.length);
85+ skipped += drop;
86+ chunk = chunk.subarray(drop);
87+ if (chunk.length === 0) continue;
88+ }
89+ const remaining = length - emitted;
90+ if (chunk.length > remaining)
91+ chunk = chunk.subarray(0, remaining);
92+ emitted += chunk.length;
93+ controller.enqueue(chunk);
94+ if (emitted >= length) {
95+ controller.close();
96+ finish();
97+ }
98+ return;
99+ }
100+ controller.close();
101+ finish();
102+ },
103+ cancel() {
104+ finish();
105+ },
106+ });
107+}
108+
109+/** Wrap a process stdout stream so the underlying process is killed on
110+ * close or cancel. Without this, a client disconnect partway through a
111+ * large blob leaves `git cat-file` running until its pipe back-pressures. */
112+function streamWithKill(
113+ source: ReadableStream<Uint8Array>,
114+ proc: { kill: () => void },
115+): ReadableStream<Uint8Array> {
116+ const reader = source.getReader();
117+ let killed = false;
118+ const finish = () => {
119+ if (killed) return;
120+ killed = true;
121+ reader.cancel().catch(() => {});
122+ proc.kill();
123+ };
124+ return new ReadableStream<Uint8Array>({
125+ async pull(controller) {
126+ try {
127+ const { value, done } = await reader.read();
128+ if (done) {
129+ controller.close();
130+ finish();
131+ return;
132+ }
133+ controller.enqueue(value);
134+ } catch (err) {
135+ controller.error(err);
136+ finish();
137+ }
138+ },
139+ cancel() {
140+ finish();
141+ },
142+ });
143+}
144+
51145 async function mimeForContent(
52146 filename: string,
53147 content: Buffer,
@@ -477,6 +571,30 @@ export const repoRoutes = new Elysia()
477571 if (!repo) return new Response("Not found", { status: 404 });
478572
479573 const filePath = decodeURIComponent(params["*"]);
574+ // Size-gate before reading the blob into memory: holding a
575+ // huge content buffer (and then running shiki/marked over it)
576+ // is the cheapest DOS vector against unauthenticated users on
577+ // a public repo.
578+ const size = await git.getFileSize(repo.name, params.ref, filePath);
579+ const filename = path.basename(filePath);
580+ if (size !== null && size > config.MAX_RENDER_BYTES) {
581+ const [branches, tags] = await Promise.all([
582+ git.branches(repo.name),
583+ git.tags(repo.name),
584+ ]);
585+ return html(
586+ <FileBlob
587+ user={user}
588+ repo={repo}
589+ ref={params.ref}
590+ filePath={filePath}
591+ view={{ type: "download", size }}
592+ branches={branches}
593+ tags={tags}
594+ markdownHtml={undefined}
595+ />,
596+ );
597+ }
480598 const [content, branches, tags, commitSHA] = await Promise.all([
481599 git.show(repo.name, params.ref, filePath),
482600 git.branches(repo.name),
@@ -486,7 +604,6 @@ export const repoRoutes = new Elysia()
486604 if (!content || !commitSHA)
487605 return new Response("Not found", { status: 404 });
488606
489- const filename = path.basename(filePath);
490607 const [view, markdownHtml] = await Promise.all([
491608 serveFile(
492609 content,
@@ -530,36 +647,88 @@ export const repoRoutes = new Elysia()
530647 if (!repo) return new Response("Not found", { status: 404 });
531648
532649 const filePath = decodeURIComponent(params["*"]);
533- const content = await git.show(repo.name, params.ref, filePath);
534- if (!content) return new Response("Not found", { status: 404 });
650+ // Resolve the file's blob hash and size up front. cat-file -s
651+ // is O(1) and lets us stream the blob below without ever
652+ // holding it whole in memory — old code did
653+ // `await arrayBuffer()` and sliced for Range, peaking at
654+ // file_size × concurrent_requests of RSS.
655+ const total = await git.getFileSize(repo.name, params.ref, filePath);
656+ if (total === null) return new Response("Not found", { status: 404 });
657+ if (
658+ config.MAX_RAW_DOWNLOAD_BYTES > 0 &&
659+ total > config.MAX_RAW_DOWNLOAD_BYTES
660+ ) {
661+ return new Response("File exceeds raw download size limit", {
662+ status: 413,
663+ });
664+ }
535665
536666 const filename = path.basename(filePath);
537- const contentType = await mimeForContent(filename, content);
538- const total = content.length;
667+ // We use `cat-file blob` rather than `git show` so the bytes
668+ // streamed exactly match what `cat-file -s` reported above —
669+ // `git show` can apply smudge filters / autocrlf, which would
670+ // make Content-Length wrong on filtered repos.
671+ const blobArgs = [
672+ "git",
673+ "-C",
674+ repoPath(repo.name),
675+ "cat-file",
676+ "blob",
677+ `${params.ref}:${filePath}`,
678+ ];
679+
680+ // Sniff content-type from the first bytes only — same idea as
681+ // the old mimeForContent but without buffering the full blob.
682+ const sniffStream = Bun.spawn(blobArgs, {
683+ stdout: "pipe",
684+ stderr: "ignore",
685+ });
686+ const sniffReader = sniffStream.stdout.getReader();
687+ const { value: firstChunk } = await sniffReader.read();
688+ sniffReader.cancel().catch(() => {});
689+ sniffStream.kill();
690+ const head = firstChunk
691+ ? Buffer.from(firstChunk.subarray(0, BINARY_DETECT_BYTES))
692+ : Buffer.alloc(0);
693+ const contentType = await mimeForContent(filename, head);
539694
540695 const rangeHeader = request.headers.get("Range");
696+ const fullProc = Bun.spawn(blobArgs, {
697+ stdout: "pipe",
698+ stderr: "ignore",
699+ });
541700 if (rangeHeader) {
542701 const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
543702 if (match) {
544703 const start = match[1] ? parseInt(match[1], 10) : 0;
545704 const end = match[2] ? parseInt(match[2], 10) : total - 1;
546705 const clampedEnd = Math.min(end, total - 1);
547- return new Response(content.subarray(start, clampedEnd + 1), {
706+ const length = clampedEnd - start + 1;
707+ // sliceStream kills fullProc once the slice is exhausted or
708+ // the consumer cancels — without this, requesting a tiny
709+ // range from a huge blob leaves `git cat-file` running.
710+ const sliced = sliceStream(fullProc.stdout, start, length, () =>
711+ fullProc.kill(),
712+ );
713+ return new Response(sliced, {
548714 status: 206,
549715 headers: {
550716 "Content-Type": contentType,
551717 "Content-Range": `bytes ${start}-${clampedEnd}/${total}`,
552718 "Accept-Ranges": "bytes",
553- "Content-Length": String(clampedEnd - start + 1),
719+ "Content-Length": String(length),
554720 },
555721 });
556722 }
557723 }
558724
559- return new Response(content, {
725+ // Wrap the full stream too so a client disconnect during a large
726+ // download kills the underlying git process instead of leaving it
727+ // wedged on a back-pressured pipe.
728+ return new Response(streamWithKill(fullProc.stdout, fullProc), {
560729 headers: {
561730 "Content-Type": contentType,
562- "Content-Disposition": `inline; filename="${filename}"`,
731+ "Content-Disposition": contentDisposition("inline", filename),
563732 "Content-Length": String(total),
564733 "Accept-Ranges": "bytes",
565734 },
@@ -734,6 +903,21 @@ export const repoRoutes = new Elysia()
734903 git.diff(repo.name, params.sha),
735904 ]);
736905 if (!meta) return new Response("Commit not found", { status: 404 });
906+ // Reject oversized diffs after generation but before highlighting.
907+ // Avoids running marked / shiki / DOMPurify over a multi-megabyte
908+ // diff which would synchronously stall the worker pool.
909+ if (rawDiff.length > config.MAX_RENDER_BYTES) {
910+ return html(
911+ <CommitDetail
912+ user={user}
913+ repo={repo}
914+ sha={params.sha}
915+ meta={meta}
916+ files={[]}
917+ tooLarge={rawDiff.length}
918+ />,
919+ );
920+ }
737921 const files = await prepareDiff(
738922 rawDiff,
739923 `commit:${repo.name}:${params.sha}`,
Msrc/services/avatar.ts
@@ -52,7 +52,13 @@ export async function processAndStoreAvatar(
5252 if (!type?.mime.startsWith("image/")) {
5353 throw new Error("Invalid image type");
5454 }
55- const { data, info } = await sharp(buffer)
55+ // Cap input pixels to defeat decompression bombs: a 2 MB
56+ // PNG/WebP can claim 16k×16k = ~256 MP and force sharp to allocate
57+ // ~1 GB of RGBA before the resize step. 4096*4096 = 16 MP is well
58+ // above any plausible avatar source (the output is 128×128).
59+ const { data, info } = await sharp(buffer, {
60+ limitInputPixels: 4096 * 4096,
61+ })
5662 .resize(128, 128, { fit: "cover", position: "center" })
5763 .ensureAlpha()
5864 .raw()
Msrc/services/ci.ts
@@ -66,6 +66,44 @@ const runningTasks = new Map<
6666 { controller: AbortController; containerId?: string }
6767 >();
6868
69+// FIFO queue of run IDs whose DB row is `status = "queued"`. We start the
70+// next one in `pumpQueue` whenever `runningTasks.size` drops below
71+// `CI_MAX_CONCURRENT`. `pumpQueue` is the SOLE writer of `runningTasks.set`
72+// — callers that want to start a run push to `queuedRunIds` and call
73+// `pumpQueue` synchronously. This makes the size check + slot reservation
74+// atomic against concurrent callers (no await between).
75+const queuedRunIds: number[] = [];
76+
77+async function promoteToPending(runId: number): Promise<void> {
78+ await db
79+ .updateTable("ci_runs")
80+ .set({ status: "pending" })
81+ .where("id", "=", runId)
82+ .execute();
83+}
84+
85+function pumpQueue(): void {
86+ while (
87+ queuedRunIds.length > 0 &&
88+ runningTasks.size < config.CI_MAX_CONCURRENT
89+ ) {
90+ const next = queuedRunIds.shift()!;
91+ const controller = new AbortController();
92+ runningTasks.set(next, { controller });
93+ // Promote queued→pending before spawning so executeRun's failure path
94+ // (which only matches pending/running) can still mark it failed if
95+ // it throws very early. We await the flip inside spawnRun's wrapper
96+ // so the order is: row=pending → executeRun starts → row=running.
97+ spawnRun(next, controller.signal, /* needsPromote */ true);
98+ }
99+}
100+
101+/** Position (1-based) of this queued run within the queue, or null. */
102+export function ciQueuePosition(runId: number): number | null {
103+ const idx = queuedRunIds.indexOf(runId);
104+ return idx < 0 ? null : idx + 1;
105+}
106+
69107 // --- TOML Parsing ---
70108
71109 export function parseCiConfig(tomlStr: string): CiConfig | null {
@@ -515,7 +553,6 @@ async function collectArtifacts(
515553 containerId: string,
516554 step: CiStep,
517555 _shell: string[],
518- workDir: string | undefined,
519556 envVars: string[],
520557 ): Promise<void> {
521558 const artifactDir = path.join(paths.CI_ARTIFACTS_DIR, String(runId));
@@ -545,62 +582,43 @@ async function collectArtifacts(
545582 }
546583 }
547584
585+ // Archive commands run with `path.dirname(srcPath)` as the working
586+ // directory and reference the source by its basename only, so the
587+ // user-controlled path never appears as part of an interpolated shell
588+ // string. Previously `publish_zip` used `sh -c "cd … && zip …"` with
589+ // raw interpolation — a step author who could write the CI TOML
590+ // could shell-inject through the source path. Today the only TOML
591+ // author is the admin, but this removes the implicit assumption.
548592 type ArchiveType = "tar" | "gzip" | "zip" | "zstd";
549593 const archiveFormats: Array<{
550594 type: ArchiveType;
551595 paths: string[];
552596 ext: string;
553- cmd: (src: string, dst: string) => string[];
597+ cmd: (basename: string, dst: string) => string[];
554598 }> = [
555599 {
556600 type: "tar",
557601 paths: toArray(step.publish_tar),
558602 ext: ".tar",
559- cmd: (src, dst) => [
560- "tar",
561- "-cf",
562- dst,
563- "-C",
564- path.dirname(src),
565- path.basename(src),
566- ],
603+ cmd: (basename, dst) => ["tar", "-cf", dst, basename],
567604 },
568605 {
569606 type: "gzip",
570607 paths: toArray(step.publish_gzip),
571608 ext: ".tar.gz",
572- cmd: (src, dst) => [
573- "tar",
574- "-czf",
575- dst,
576- "-C",
577- path.dirname(src),
578- path.basename(src),
579- ],
609+ cmd: (basename, dst) => ["tar", "-czf", dst, basename],
580610 },
581611 {
582612 type: "zstd",
583613 paths: toArray(step.publish_zstd),
584614 ext: ".tar.zst",
585- cmd: (src, dst) => [
586- "tar",
587- "--zstd",
588- "-cf",
589- dst,
590- "-C",
591- path.dirname(src),
592- path.basename(src),
593- ],
615+ cmd: (basename, dst) => ["tar", "--zstd", "-cf", dst, basename],
594616 },
595617 {
596618 type: "zip",
597619 paths: toArray(step.publish_zip),
598620 ext: ".zip",
599- cmd: (src, dst) => [
600- "sh",
601- "-c",
602- `cd ${path.dirname(src)} && zip -r ${dst} ${path.basename(src)}`,
603- ],
621+ cmd: (basename, dst) => ["zip", "-r", dst, basename],
604622 },
605623 ];
606624
@@ -609,11 +627,13 @@ async function collectArtifacts(
609627 for (const srcPath of archivePaths) {
610628 archiveIndex++;
611629 const tmpPath = `/tmp/hf-artifact-${runId}-${archiveIndex}${ext}`;
612- // Create archive inside container
630+ // Run with the source's parent directory as the working
631+ // directory so each archive tool can reference the source
632+ // by its basename — no -C, no shell.
613633 const execResult = await execInContainer(
614634 containerId,
615- cmd(srcPath, tmpPath),
616- workDir,
635+ cmd(path.basename(srcPath), tmpPath),
636+ path.dirname(srcPath),
617637 envVars,
618638 ).catch(() => null);
619639 if (!execResult || execResult.exitCode !== 0) continue;
@@ -861,7 +881,6 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
861881 containerId,
862882 step,
863883 shell,
864- cfg.work_dir,
865884 envArray,
866885 ).catch(() => {});
867886 }
@@ -959,6 +978,8 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> {
959978 .where("id", "=", runId)
960979 .executeTakeFirst();
961980 if (run) await pruneHistory(run.repo_id).catch(() => {});
981+ // A slot just freed up — start the next queued run if any.
982+ pumpQueue();
962983 }
963984 }
964985
@@ -1003,11 +1024,22 @@ export async function triggerRun(
10031024 .where("id", "=", runId.id)
10041025 .execute();
10051026
1006- const controller = new AbortController();
1007- runningTasks.set(runId.id, { controller });
1008-
1009- // Fire and forget — like release archiving
1010- spawnRun(runId.id, controller.signal);
1027+ // Flip to "queued" BEFORE pushing onto queuedRunIds. Otherwise a
1028+ // concurrent pumpQueue (from a finishing run) could observe our entry,
1029+ // promoteToPending it, and start executeRun while our UPDATE is still
1030+ // in flight — the late UPDATE would then clobber a running row back to
1031+ // "queued". Only pumpQueue (the sole writer of runningTasks.set) may
1032+ // mutate the row's status after the push. The TOCTOU concern is moot
1033+ // here because pumpQueue cannot pump a runId that hasn't been pushed.
1034+ if (runningTasks.size >= config.CI_MAX_CONCURRENT) {
1035+ await db
1036+ .updateTable("ci_runs")
1037+ .set({ status: "queued" })
1038+ .where("id", "=", runId.id)
1039+ .execute();
1040+ }
1041+ queuedRunIds.push(runId.id);
1042+ pumpQueue();
10111043
10121044 return runId.id;
10131045 }
@@ -1015,24 +1047,40 @@ export async function triggerRun(
10151047 // Wraps the fire-and-forget executeRun so unhandled exceptions (e.g. a throw
10161048 // before/after its own try/finally) are logged and the run is reconciled to
10171049 // "failure" instead of staying pending forever.
1018-function spawnRun(runId: number, signal: AbortSignal): void {
1019- void executeRun(runId, signal).catch(async (err) => {
1020- console.error(`[ci] executeRun threw for run ${runId}:`, err);
1021- runningTasks.delete(runId);
1050+function spawnRun(
1051+ runId: number,
1052+ signal: AbortSignal,
1053+ needsPromote = false,
1054+): void {
1055+ void (async () => {
10221056 try {
1023- await db
1024- .updateTable("ci_runs")
1025- .set({
1026- status: "failure",
1027- finished_at: new Date().toISOString(),
1028- })
1029- .where("id", "=", runId)
1030- .where("status", "in", ["pending", "running"])
1031- .execute();
1032- } catch (dbErr) {
1033- console.error(`[ci] failed to mark run ${runId} failed:`, dbErr);
1057+ if (needsPromote) await promoteToPending(runId);
1058+ await executeRun(runId, signal);
1059+ } catch (err) {
1060+ console.error(`[ci] executeRun threw for run ${runId}:`, err);
1061+ runningTasks.delete(runId);
1062+ try {
1063+ await db
1064+ .updateTable("ci_runs")
1065+ .set({
1066+ status: "failure",
1067+ finished_at: new Date().toISOString(),
1068+ })
1069+ .where("id", "=", runId)
1070+ // Include "queued" so a row that was promoted-but-not-yet-
1071+ // observed (or never promoted because promoteToPending threw)
1072+ // still gets marked failed instead of stuck.
1073+ .where("status", "in", ["pending", "running", "queued"])
1074+ .execute();
1075+ } catch (dbErr) {
1076+ console.error(
1077+ `[ci] failed to mark run ${runId} failed:`,
1078+ dbErr,
1079+ );
1080+ }
1081+ pumpQueue();
10341082 }
1035- });
1083+ })();
10361084 }
10371085
10381086 export async function retryRun(
@@ -1056,7 +1104,12 @@ export async function retryRun(
10561104 }
10571105 await db.deleteFrom("ci_artifacts").where("run_id", "=", runId).execute();
10581106
1059- // Reset run
1107+ // Reset to "pending" first; if a slot isn't free, flip to "queued"
1108+ // before enqueueing. Mirrors triggerRun: pumpQueue is the sole writer
1109+ // of runningTasks.set, so we never reserve a slot directly here. Two
1110+ // concurrent retryRun calls (or a retryRun racing triggerRun) all
1111+ // funnel through pumpQueue, which serializes the size check against
1112+ // slot reservation in a single synchronous turn.
10601113 await db
10611114 .updateTable("ci_runs")
10621115 .set({
@@ -1068,10 +1121,15 @@ export async function retryRun(
10681121 .where("id", "=", runId)
10691122 .execute();
10701123
1071- const controller = new AbortController();
1072- runningTasks.set(runId, { controller });
1073-
1074- spawnRun(runId, controller.signal);
1124+ if (runningTasks.size >= config.CI_MAX_CONCURRENT) {
1125+ await db
1126+ .updateTable("ci_runs")
1127+ .set({ status: "queued" })
1128+ .where("id", "=", runId)
1129+ .execute();
1130+ }
1131+ queuedRunIds.push(runId);
1132+ pumpQueue();
10751133 }
10761134
10771135 export async function cancelRun(runId: number): Promise<void> {
@@ -1083,11 +1141,13 @@ export async function cancelRun(runId: number): Promise<void> {
10831141 await removeContainer(containerId).catch(() => {});
10841142 }
10851143 }
1144+ const queueIdx = queuedRunIds.indexOf(runId);
1145+ if (queueIdx >= 0) queuedRunIds.splice(queueIdx, 1);
10861146 await db
10871147 .updateTable("ci_runs")
10881148 .set({ status: "cancelled", finished_at: new Date().toISOString() })
10891149 .where("id", "=", runId)
1090- .where("status", "in", ["pending", "running"])
1150+ .where("status", "in", ["pending", "running", "queued"])
10911151 .execute();
10921152 }
10931153
@@ -1142,7 +1202,7 @@ export async function cancelStaleRuns(): Promise<void> {
11421202 const stale = await db
11431203 .selectFrom("ci_runs")
11441204 .select("id")
1145- .where("status", "in", ["pending", "running"])
1205+ .where("status", "in", ["pending", "running", "queued"])
11461206 .execute();
11471207
11481208 await Promise.allSettled(
@@ -1156,7 +1216,7 @@ export async function cancelStaleRuns(): Promise<void> {
11561216 await db
11571217 .updateTable("ci_runs")
11581218 .set({ status: "cancelled", finished_at: now })
1159- .where("status", "in", ["pending", "running"])
1219+ .where("status", "in", ["pending", "running", "queued"])
11601220 .execute();
11611221 await db
11621222 .updateTable("ci_steps")
Msrc/services/sshServer.ts
@@ -6,7 +6,88 @@ import { Server, utils } from "ssh2";
66 import config from "../config.ts";
77 import { ADMIN_USERNAME, paths } from "../constants.ts";
88 import { db } from "../db/index.ts";
9-import { invalidateRefCache } from "./git.ts";
9+import {
10+ parseCiConfig,
11+ shouldTriggerPush,
12+ shouldTriggerTag,
13+ triggerRun,
14+} from "./ci.ts";
15+import { git, invalidateRefCache } from "./git.ts";
16+
17+/**
18+ * Parse pkt-line ref updates from the leading bytes of a receive-pack
19+ * upload. Returns oldSha/newSha/refname triples. Mirrors `parseRefUpdates`
20+ * in `routes/git.ts` — kept duplicated to avoid coupling the route file
21+ * to the SSH path. Both only ever look at the first ~4 KB.
22+ */
23+function parsePktLineRefUpdates(
24+ text: string,
25+): Array<{ oldSha: string; newSha: string; refname: string }> {
26+ const refs: Array<{ oldSha: string; newSha: string; refname: string }> = [];
27+ let pos = 0;
28+ while (pos + 4 <= text.length) {
29+ const lenStr = text.slice(pos, pos + 4);
30+ const len = parseInt(lenStr, 16);
31+ if (Number.isNaN(len) || len === 0) break;
32+ if (len < 4 || pos + len > text.length) break;
33+ const line = text
34+ .slice(pos + 4, pos + len)
35+ .replace(/\0.*$/, "")
36+ .trim();
37+ pos += len;
38+ const parts = line.split(" ");
39+ if (parts.length >= 3) {
40+ const oldSha = parts[0] ?? "";
41+ const newSha = parts[1] ?? "";
42+ const refname = parts[2] ?? "";
43+ if (refname) refs.push({ oldSha, newSha, refname });
44+ }
45+ }
46+ return refs;
47+}
48+
49+async function triggerCiForPush(
50+ repoName: string,
51+ refUpdates: Array<{ oldSha: string; newSha: string; refname: string }>,
52+): Promise<void> {
53+ for (const { newSha, refname } of refUpdates) {
54+ if (/^0+$/.test(newSha)) continue;
55+
56+ const isBranch = refname.startsWith("refs/heads/");
57+ const isTag = refname.startsWith("refs/tags/");
58+ if (!isBranch && !isTag) continue;
59+
60+ const tomlBuf = await git
61+ .show(repoName, newSha, ".hearthforge-ci.toml")
62+ .catch(() => null);
63+ if (!tomlBuf) continue;
64+
65+ const cfg = parseCiConfig(tomlBuf.toString("utf-8"));
66+ if (!cfg) continue;
67+
68+ if (isBranch) {
69+ const branch = refname.slice("refs/heads/".length);
70+ if (shouldTriggerPush(cfg, branch)) {
71+ triggerRun(repoName, {
72+ triggerSource: "push",
73+ commitSha: newSha,
74+ commitBranch: branch,
75+ }).catch((e) =>
76+ console.error(`CI push trigger failed for ${repoName}:`, e),
77+ );
78+ }
79+ } else if (isTag && shouldTriggerTag(cfg)) {
80+ const tag = refname.slice("refs/tags/".length);
81+ triggerRun(repoName, {
82+ triggerSource: "tag",
83+ commitSha: newSha,
84+ commitTag: tag,
85+ }).catch((e) =>
86+ console.error(`CI tag trigger failed for ${repoName}:`, e),
87+ );
88+ }
89+ }
90+}
1091
1192 /** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */
1293 function fingerprintFromBytes(keyBytes: Buffer): string {
@@ -58,6 +139,7 @@ export async function startSshServer() {
58139 "ssh_keys.public_key",
59140 ])
60141 .where("ssh_keys.fingerprint", "=", fingerprint)
142+ .where("users.is_pending", "=", 0)
61143 .executeTakeFirst();
62144
63145 if (!sshKey) return ctx.reject();
@@ -113,7 +195,14 @@ export async function startSshServer() {
113195 }
114196 }
115197
116- if (repo.is_private && !authedUser) {
198+ if (
199+ repo.is_private &&
200+ authedUser?.username !== ADMIN_USERNAME
201+ ) {
202+ // Match the UI and HTTP smart-git: private repos
203+ // are admin-only. Without this, any user with a
204+ // registered SSH key could clone repos hidden from
205+ // them in the web UI.
117206 stream.stderr.write(
118207 "error: repository access denied\n",
119208 );
@@ -123,6 +212,24 @@ export async function startSshServer() {
123212 }
124213
125214 const proc: ChildProcess = spawn(command, [repoPath]);
215+
216+ // For receive-pack, capture the first ~4 KB of pkt-line
217+ // data so we can extract the ref updates after git
218+ // finishes (mirroring the HTTP path in routes/git.ts).
219+ // CI was previously not triggered on SSH pushes at all.
220+ let preamble: Buffer | null = null;
221+ if (command === "git-receive-pack") {
222+ preamble = Buffer.alloc(0);
223+ stream.on("data", (chunk: Buffer) => {
224+ if (preamble && preamble.length < 4096) {
225+ preamble = Buffer.concat([
226+ preamble,
227+ chunk.subarray(0, 4096 - preamble.length),
228+ ]);
229+ }
230+ });
231+ }
232+
126233 stream.pipe(proc.stdin!);
127234 proc.stdout?.pipe(stream, { end: false });
128235 proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, {
@@ -132,6 +239,14 @@ export async function startSshServer() {
132239 proc.on("close", (code: number | null) => {
133240 if (command === "git-receive-pack") {
134241 invalidateRefCache(repo.name);
242+ if (code === 0 && preamble) {
243+ const refUpdates = parsePktLineRefUpdates(
244+ preamble.toString("utf-8"),
245+ );
246+ triggerCiForPush(repo.name, refUpdates).catch(
247+ () => {},
248+ );
249+ }
135250 }
136251 stream.exit(code ?? 0);
137252 stream.end();
Msrc/styles/components.css
@@ -1942,6 +1942,10 @@
19421942 background: var(--color-border);
19431943 color: var(--color-text-muted);
19441944 }
1945+ .ci-status-queued {
1946+ background: #6e7781;
1947+ color: #fff;
1948+ }
19451949 .ci-status-running {
19461950 background: #0b5cab;
19471951 color: #fff;
Msrc/views/ci/CiHistory.tsx
@@ -20,6 +20,7 @@ interface RunSummary {
2020 created_at: string;
2121 triggered_by_username: string | null;
2222 artifact_count: number;
23+ queue_position: number | null;
2324 }
2425
2526 interface CiHistoryProps {
@@ -30,6 +31,12 @@ interface CiHistoryProps {
3031 manualTriggerDisabledReason: string | null;
3132 }
3233
34+function queueTitle(position: number | null): string {
35+ if (position === null) return "Waiting in the build queue";
36+ if (position === 1) return "Waiting in the build queue — next up";
37+ return `Waiting in the build queue — ${position - 1} run${position - 1 === 1 ? "" : "s"} ahead`;
38+}
39+
3340 function duration(start: string | null, end: string | null): string {
3441 if (!start || !end) return "";
3542 const ms = new Date(end).getTime() - new Date(start).getTime();
@@ -131,7 +138,10 @@ export function CiHistory({
131138 manualTriggerDisabledReason,
132139 }: CiHistoryProps) {
133140 const isRunning = runs.some(
134- (r) => r.status === "pending" || r.status === "running",
141+ (r) =>
142+ r.status === "pending" ||
143+ r.status === "running" ||
144+ r.status === "queued",
135145 );
136146 return (
137147 <Layout user={user} title={`Pipelines — ${repo.name}`}>
@@ -203,7 +213,16 @@ export function CiHistory({
203213 href={`/${repo.name}/ci/${run.id}`}
204214 class="release-item-title"
205215 >
206- <CiStatusPill status={run.status} />
216+ <CiStatusPill
217+ status={run.status}
218+ title={
219+ run.status === "queued"
220+ ? queueTitle(
221+ run.queue_position,
222+ )
223+ : undefined
224+ }
225+ />
207226 <span class="ci-run-id">
208227 #{run.repo_run_id ?? run.id}
209228 </span>
Msrc/views/ci/CiRunDetail.tsx
@@ -32,6 +32,13 @@ interface CiRunDetailProps {
3232 steps: CiStepRow[];
3333 artifacts: CiArtifactRow[];
3434 autoRefresh: boolean;
35+ queuePosition: number | null;
36+}
37+
38+function queueTitle(position: number | null): string {
39+ if (position === null) return "Waiting in the build queue";
40+ if (position === 1) return "Waiting in the build queue — next up";
41+ return `Waiting in the build queue — ${position - 1} run${position - 1 === 1 ? "" : "s"} ahead`;
3542 }
3643
3744 function duration(start: string | null, end: string | null): string {
@@ -58,8 +65,14 @@ export function CiRunDetail({
5865 steps,
5966 artifacts,
6067 autoRefresh,
68+ queuePosition,
6169 }: CiRunDetailProps) {
62- const isActive = run.status === "pending" || run.status === "running";
70+ const isActive =
71+ run.status === "pending" ||
72+ run.status === "running" ||
73+ run.status === "queued";
74+ const isQueued = run.status === "queued";
75+ const queueText = queueTitle(queuePosition);
6376 const displayId = run.repo_run_id ?? run.id;
6477
6578 let variableOverrides: Record<string, string> = {};
@@ -88,8 +101,11 @@ export function CiRunDetail({
88101 <div class="release-detail-header">
89102 <div>
90103 <h2 class="release-detail-title">
91- <CiStatusPill status={run.status} /> Pipeline #
92- {displayId}
104+ <CiStatusPill
105+ status={run.status}
106+ title={isQueued ? queueText : undefined}
107+ />{" "}
108+ Pipeline #{displayId}
93109 </h2>
94110 <div class="release-item-meta">
95111 {run.commit_sha && (
@@ -191,7 +207,9 @@ export function CiRunDetail({
191207 <h3 class="section-title">Steps</h3>
192208 {steps.length === 0 ? (
193209 <div class="ci-step-pending">
194- <span class="text-muted">Waiting to start…</span>
210+ <span class="text-muted">
211+ {isQueued ? queueText : "Waiting to start…"}
212+ </span>
195213 </div>
196214 ) : (
197215 steps.map((step) => (
Msrc/views/ci/CiStatusPill.tsx
@@ -1,5 +1,6 @@
11 const statusStyles: Record<string, string> = {
22 pending: "ci-status-pending",
3+ queued: "ci-status-queued",
34 running: "ci-status-running",
45 success: "ci-status-success",
56 failure: "ci-status-failure",
@@ -7,7 +8,16 @@ const statusStyles: Record<string, string> = {
78 skipped: "ci-status-skipped",
89 };
910
10-export function CiStatusPill({ status }: { status: string }) {
11+interface CiStatusPillProps {
12+ status: string;
13+ title?: string;
14+}
15+
16+export function CiStatusPill({ status, title }: CiStatusPillProps) {
1117 const cls = statusStyles[status] ?? "ci-status-pending";
12- return <span class={`ci-status-pill ${cls}`}>{status}</span>;
18+ return (
19+ <span class={`ci-status-pill ${cls}`} title={title}>
20+ {status}
21+ </span>
22+ );
1323 }
Msrc/views/repos/CommitDetail.tsx
@@ -15,6 +15,7 @@ interface CommitDetailProps {
1515 sha: string;
1616 meta: CommitMeta;
1717 files: RenderedDiffFile[];
18+ tooLarge?: number;
1819 }
1920
2021 export function CommitDetail({
@@ -23,6 +24,7 @@ export function CommitDetail({
2324 sha,
2425 meta,
2526 files,
27+ tooLarge,
2628 }: CommitDetailProps) {
2729 return (
2830 <Layout user={user} title={`${sha.slice(0, 7)} — ${repo.name}`}>
@@ -141,7 +143,17 @@ export function CommitDetail({
141143 </div>
142144 </div>
143145
144- <DiffView files={files} repo={repo} sha={sha} />
146+ {tooLarge !== undefined ? (
147+ <div class="file-download-notice">
148+ <p>
149+ Diff is too large to render inline (
150+ {(tooLarge / 1024 / 1024).toFixed(1)} MB). Browse
151+ individual files at the tree below.
152+ </p>
153+ </div>
154+ ) : (
155+ <DiffView files={files} repo={repo} sha={sha} />
156+ )}
145157 </div>
146158 </Layout>
147159 );