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
M.gitignore
| @@ -40,3 +40,5 @@ data-test/ | |||
|---|---|---|---|
| 40 | 40 | ||
| 41 | 41 | # Generated CSS | |
| 42 | 42 | public/assets/main.css | |
| 43 | + | ||
| 44 | + | .claude | |
MREADME.md
| @@ -80,7 +80,7 @@ All settings are environment variables: | |||
|---|---|---|---|
| 80 | 80 | | `MAX_USER_UPLOAD_BYTES` | `2097152` | Max upload size for non-admin users (2 MB) | | |
| 81 | 81 | | `INLINE_MAX_BYTES` | `524288` | Max file size rendered inline in the code view (512 KB) | | |
| 82 | 82 | | `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 \*\* | | |
| 84 | 84 | | `RATE_LIMIT_DISABLED` | `0` | Set to `1` to disable rate limiting | | |
| 85 | 85 | | `HIGHLIGHT_WORKERS` | `4` | Syntax highlighting worker threads \* | | |
| 86 | 86 | | `COMMITTER_NAME` | `$OWNER_DISPLAY_NAME` | Git committer name for merges and UI edits | | |
| @@ -98,6 +98,8 @@ All settings are environment variables: | |||
|---|---|---|---|
| 98 | 98 | ||
| 99 | 99 | \* Each highlighting worker loads its own copy of the language grammars and uses ~200 MB of memory. Increase with care. | |
| 100 | 100 | ||
| 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 | + | ||
| 101 | 103 | ## CI/CD Pipelines | |
| 102 | 104 | ||
| 103 | 105 | 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) { | |||
|---|---|---|---|
| 37 | 37 | await syncStartup(); | |
| 38 | 38 | await cancelStaleRuns(); | |
| 39 | 39 | ||
| 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); | |
| 42 | 43 | ||
| 43 | 44 | return new Elysia({ | |
| 44 | 45 | serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES }, | |
Msrc/config.ts
| @@ -2,6 +2,13 @@ import path from "node:path"; | |||
|---|---|---|---|
| 2 | 2 | ||
| 3 | 3 | const env = process.env; | |
| 4 | 4 | ||
| 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 | + | ||
| 5 | 12 | const config = { | |
| 6 | 13 | OWNER_DISPLAY_NAME: env.OWNER_DISPLAY_NAME ?? "Admin", | |
| 7 | 14 | INLINE_MAX_BYTES: parseInt(env.INLINE_MAX_BYTES ?? "", 10) || 524288, | |
| @@ -35,6 +42,23 @@ const config = { | |||
|---|---|---|---|
| 35 | 42 | CI_MAX_HISTORY: parseInt(env.CI_MAX_HISTORY ?? "", 10) || 50, | |
| 36 | 43 | CI_MAX_CONCURRENT: parseInt(env.CI_MAX_CONCURRENT ?? "", 10) || 2, | |
| 37 | 44 | 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), | |
| 38 | 62 | }; | |
| 39 | 63 | ||
| 40 | 64 | // Derived values that depend on other config fields | |
Msrc/constants.ts
| @@ -31,6 +31,26 @@ export const LOGIN_MAX_ATTEMPTS = 10; | |||
|---|---|---|---|
| 31 | 31 | export const LOGIN_RATE_WINDOW_MS = 60_000; | |
| 32 | 32 | export const REGISTRATION_MAX_ATTEMPTS = 3; | |
| 33 | 33 | 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; | |
| 34 | 54 | ||
| 35 | 55 | // Session | |
| 36 | 56 | 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 { | |||
|---|---|---|---|
| 5 | 5 | resetAt: number; | |
| 6 | 6 | } | |
| 7 | 7 | ||
| 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 | + | ||
| 8 | 23 | const buckets = new Map<string, Bucket>(); | |
| 9 | - | let sweepCounter = 0; | |
| 10 | 24 | ||
| 11 | - | function maybeSweep() { | |
| 12 | - | if (++sweepCounter < 1000) return; | |
| 13 | - | sweepCounter = 0; | |
| 25 | + | function sweep() { | |
| 14 | 26 | const now = Date.now(); | |
| 15 | 27 | for (const [key, bucket] of buckets) { | |
| 16 | 28 | if (now > bucket.resetAt) buckets.delete(key); | |
| 17 | 29 | } | |
| 18 | 30 | } | |
| 19 | 31 | ||
| 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 | + | ||
| 20 | 43 | export function checkRateLimit( | |
| 21 | 44 | ip: string | null, | |
| 45 | + | kind: RateLimitKind, | |
| 22 | 46 | maxRequests: number, | |
| 23 | 47 | windowMs: number, | |
| 24 | 48 | ): boolean { | |
| 25 | 49 | if (config.RATE_LIMIT_DISABLED || !ip) return true; | |
| 50 | + | const key = `${ip}|${kind}`; | |
| 26 | 51 | const now = Date.now(); | |
| 27 | - | const bucket = buckets.get(ip); | |
| 52 | + | const bucket = buckets.get(key); | |
| 28 | 53 | if (!bucket || now > bucket.resetAt) { | |
| 29 | - | buckets.set(ip, { count: 1, resetAt: now + windowMs }); | |
| 54 | + | buckets.set(key, { count: 1, resetAt: now + windowMs }); | |
| 30 | 55 | maybeSweep(); | |
| 31 | 56 | return true; | |
| 32 | 57 | } | |
| @@ -47,3 +72,25 @@ export function getClientIp( | |||
|---|---|---|---|
| 47 | 72 | } | |
| 48 | 73 | return server?.requestIP(request)?.address ?? null; | |
| 49 | 74 | } | |
| 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() | |||
|---|---|---|---|
| 102 | 102 | "/login", | |
| 103 | 103 | async ({ body, request, server }) => { | |
| 104 | 104 | 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 | + | ) { | |
| 106 | 113 | return html( | |
| 107 | 114 | <Login error="Too many login attempts. Please try again later." />, | |
| 108 | 115 | ); | |
| @@ -154,6 +161,7 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 154 | 161 | if ( | |
| 155 | 162 | !checkRateLimit( | |
| 156 | 163 | ip, | |
| 164 | + | "register", | |
| 157 | 165 | REGISTRATION_MAX_ATTEMPTS, | |
| 158 | 166 | REGISTRATION_RATE_WINDOW_MS, | |
| 159 | 167 | ) | |
Msrc/routes/avatars.ts
| @@ -1,7 +1,12 @@ | |||
|---|---|---|---|
| 1 | 1 | import { Elysia, t } from "elysia"; | |
| 2 | 2 | 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"; | |
| 4 | 8 | import { db } from "../db/index.ts"; | |
| 9 | + | import { rateLimit } from "../lib/rateLimiter.ts"; | |
| 5 | 10 | import { requireAuth, resolveSession } from "../middleware/session.ts"; | |
| 6 | 11 | import { | |
| 7 | 12 | avatarJxlPath, | |
| @@ -52,10 +57,19 @@ export const avatarRoutes = new Elysia() | |||
|---|---|---|---|
| 52 | 57 | ||
| 53 | 58 | .post( | |
| 54 | 59 | "/settings/avatar", | |
| 55 | - | async ({ body, cookie }) => { | |
| 60 | + | async ({ body, cookie, request, server }) => { | |
| 56 | 61 | const user = await resolveSession(cookie.session.value); | |
| 57 | 62 | const deny = requireAuth(user); | |
| 58 | 63 | 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; | |
| 59 | 73 | if (body.avatar.size > config.MAX_USER_UPLOAD_BYTES) { | |
| 60 | 74 | return new Response("Avatar file too large", { status: 400 }); | |
| 61 | 75 | } | |
Msrc/routes/ci.tsx
| @@ -3,10 +3,12 @@ import path from "node:path"; | |||
|---|---|---|---|
| 3 | 3 | import { Elysia, t } from "elysia"; | |
| 4 | 4 | import { CI_RUNS_PER_PAGE, paths } from "../constants.ts"; | |
| 5 | 5 | import { db, getRepo } from "../db/index.ts"; | |
| 6 | + | import { contentDisposition } from "../lib/contentDisposition.ts"; | |
| 6 | 7 | import { paginate } from "../lib/pagination.ts"; | |
| 7 | 8 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 8 | 9 | import { | |
| 9 | 10 | cancelRun, | |
| 11 | + | ciQueuePosition, | |
| 10 | 12 | parseCiConfig, | |
| 11 | 13 | purgeRepoCaches, | |
| 12 | 14 | retryRun, | |
| @@ -146,6 +148,8 @@ export const ciRoutes = new Elysia() | |||
|---|---|---|---|
| 146 | 148 | const runsWithCounts = runs.map((r) => ({ | |
| 147 | 149 | ...r, | |
| 148 | 150 | artifact_count: artifactCountMap.get(r.id) ?? 0, | |
| 151 | + | queue_position: | |
| 152 | + | r.status === "queued" ? ciQueuePosition(r.id) : null, | |
| 149 | 153 | })); | |
| 150 | 154 | ||
| 151 | 155 | // Determine why manual trigger may be unavailable (admin-only check) | |
| @@ -252,6 +256,9 @@ export const ciRoutes = new Elysia() | |||
|---|---|---|---|
| 252 | 256 | steps={steps} | |
| 253 | 257 | artifacts={artifacts} | |
| 254 | 258 | autoRefresh={query.refresh !== "off"} | |
| 259 | + | queuePosition={ | |
| 260 | + | run.status === "queued" ? ciQueuePosition(run.id) : null | |
| 261 | + | } | |
| 255 | 262 | />, | |
| 256 | 263 | ); | |
| 257 | 264 | }, | |
| @@ -508,7 +515,10 @@ export const ciRoutes = new Elysia() | |||
|---|---|---|---|
| 508 | 515 | ||
| 509 | 516 | return new Response(Bun.file(filePath), { | |
| 510 | 517 | headers: { | |
| 511 | - | "Content-Disposition": `attachment; filename="${artifact.filename}"`, | |
| 518 | + | "Content-Disposition": contentDisposition( | |
| 519 | + | "attachment", | |
| 520 | + | artifact.filename, | |
| 521 | + | ), | |
| 512 | 522 | "Content-Type": "application/octet-stream", | |
| 513 | 523 | "Content-Length": String(artifact.size), | |
| 514 | 524 | }, | |
Msrc/routes/git.ts
| @@ -2,8 +2,15 @@ import { existsSync } from "node:fs"; | |||
|---|---|---|---|
| 2 | 2 | import path from "node:path"; | |
| 3 | 3 | import * as argon2 from "argon2"; | |
| 4 | 4 | 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"; | |
| 6 | 12 | import { db } from "../db"; | |
| 13 | + | import { checkRateLimit, getClientIp } from "../lib/rateLimiter.ts"; | |
| 7 | 14 | import { | |
| 8 | 15 | parseCiConfig, | |
| 9 | 16 | shouldTriggerPush, | |
| @@ -110,6 +117,7 @@ async function verifyBasicAuth( | |||
|---|---|---|---|
| 110 | 117 | .selectFrom("users") | |
| 111 | 118 | .select("password_hash") | |
| 112 | 119 | .where("username", "=", username) | |
| 120 | + | .where("is_pending", "=", 0) | |
| 113 | 121 | .executeTakeFirst(); | |
| 114 | 122 | if (!user?.password_hash) return false; | |
| 115 | 123 | try { | |
| @@ -129,6 +137,37 @@ function unauthorized(): Response { | |||
|---|---|---|---|
| 129 | 137 | }); | |
| 130 | 138 | } | |
| 131 | 139 | ||
| 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 | + | ||
| 132 | 171 | async function getRepo( | |
| 133 | 172 | slug: string, | |
| 134 | 173 | ): Promise<{ name: string; repoPath: string; isPrivate: boolean } | null> { | |
| @@ -162,7 +201,7 @@ export const gitRoutes = new Elysia() | |||
|---|---|---|---|
| 162 | 201 | // info/refs — serves both upload-pack (clone/fetch) and receive-pack (push) | |
| 163 | 202 | .get( | |
| 164 | 203 | "/:repo/info/refs", | |
| 165 | - | async ({ params, query, request }) => { | |
| 204 | + | async ({ params, query, request, server }) => { | |
| 166 | 205 | const service = query.service; | |
| 167 | 206 | if ( | |
| 168 | 207 | service !== "git-upload-pack" && | |
| @@ -175,12 +214,18 @@ export const gitRoutes = new Elysia() | |||
|---|---|---|---|
| 175 | 214 | if (!repo) return new Response("Not Found", { status: 404 }); | |
| 176 | 215 | ||
| 177 | 216 | 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. | |
| 179 | 227 | if (!(await verifyBasicAuth(authHeader, true))) | |
| 180 | 228 | return unauthorized(); | |
| 181 | - | } else if (repo.isPrivate) { | |
| 182 | - | if (!(await verifyBasicAuth(authHeader, false))) | |
| 183 | - | return unauthorized(); | |
| 184 | 229 | } | |
| 185 | 230 | ||
| 186 | 231 | const gitCmd = | |
| @@ -211,14 +256,16 @@ export const gitRoutes = new Elysia() | |||
|---|---|---|---|
| 211 | 256 | ) | |
| 212 | 257 | ||
| 213 | 258 | // 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 }) => { | |
| 215 | 260 | const repo = await getRepo(params.repo); | |
| 216 | 261 | if (!repo) return new Response("Not Found", { status: 404 }); | |
| 217 | 262 | if (repo.isPrivate) { | |
| 263 | + | // Admin-only: see info/refs branch above. | |
| 264 | + | if (!checkGitAuthLimit(request, server)) return tooManyRequests(); | |
| 218 | 265 | if ( | |
| 219 | 266 | !(await verifyBasicAuth( | |
| 220 | 267 | request.headers.get("Authorization"), | |
| 221 | - | false, | |
| 268 | + | true, | |
| 222 | 269 | )) | |
| 223 | 270 | ) | |
| 224 | 271 | return unauthorized(); | |
| @@ -237,7 +284,8 @@ export const gitRoutes = new Elysia() | |||
|---|---|---|---|
| 237 | 284 | }) | |
| 238 | 285 | ||
| 239 | 286 | // 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(); | |
| 241 | 289 | if ( | |
| 242 | 290 | !(await verifyBasicAuth(request.headers.get("Authorization"), true)) | |
| 243 | 291 | ) | |
Msrc/routes/issues.tsx
| @@ -1,11 +1,20 @@ | |||
|---|---|---|---|
| 1 | 1 | import { Elysia, t } from "elysia"; | |
| 2 | 2 | import { sql } from "kysely"; | |
| 3 | 3 | 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"; | |
| 5 | 13 | import { issuesLabelFilter } from "../db/helpers.ts"; | |
| 6 | 14 | import { db, getRepo, type LabelRow } from "../db/index.ts"; | |
| 7 | 15 | import { authorizeCommentEdit } from "../lib/commentAuth.ts"; | |
| 8 | 16 | import { paginate } from "../lib/pagination.ts"; | |
| 17 | + | import { rateLimit } from "../lib/rateLimiter.ts"; | |
| 9 | 18 | import { | |
| 10 | 19 | requireAdmin, | |
| 11 | 20 | requireAuth, | |
| @@ -200,10 +209,19 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 200 | 209 | ||
| 201 | 210 | .post( | |
| 202 | 211 | "/:repo/issues", | |
| 203 | - | async ({ params, body, cookie }) => { | |
| 212 | + | async ({ params, body, cookie, request, server }) => { | |
| 204 | 213 | const user = await resolveSession(cookie.session.value); | |
| 205 | 214 | const deny = requireAuth(user); | |
| 206 | 215 | 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; | |
| 207 | 225 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 208 | 226 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 209 | 227 | ||
| @@ -412,10 +430,19 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 412 | 430 | ||
| 413 | 431 | .post( | |
| 414 | 432 | "/:repo/issues/:number/comments", | |
| 415 | - | async ({ params, body, cookie }) => { | |
| 433 | + | async ({ params, body, cookie, request, server }) => { | |
| 416 | 434 | const user = await resolveSession(cookie.session.value); | |
| 417 | 435 | const deny = requireAuth(user); | |
| 418 | 436 | 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; | |
| 419 | 446 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 420 | 447 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 421 | 448 | ||
| @@ -476,10 +503,19 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 476 | 503 | ||
| 477 | 504 | .post( | |
| 478 | 505 | "/:repo/issues/:number/react", | |
| 479 | - | async ({ params, body, cookie }) => { | |
| 506 | + | async ({ params, body, cookie, request, server }) => { | |
| 480 | 507 | const user = await resolveSession(cookie.session.value); | |
| 481 | 508 | const deny = requireAuth(user); | |
| 482 | 509 | 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; | |
| 483 | 519 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 484 | 520 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 485 | 521 | ||
| @@ -639,10 +675,19 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 639 | 675 | ||
| 640 | 676 | .post( | |
| 641 | 677 | "/:repo/issues/:number/edit", | |
| 642 | - | async ({ params, body, cookie }) => { | |
| 678 | + | async ({ params, body, cookie, request, server }) => { | |
| 643 | 679 | const user = await resolveSession(cookie.session.value); | |
| 644 | 680 | const deny = requireAuth(user); | |
| 645 | 681 | 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; | |
| 646 | 691 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 647 | 692 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 648 | 693 | ||
| @@ -687,10 +732,19 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 687 | 732 | ||
| 688 | 733 | .post( | |
| 689 | 734 | "/:repo/issues/:number/comments/:id/edit", | |
| 690 | - | async ({ params, body, cookie }) => { | |
| 735 | + | async ({ params, body, cookie, request, server }) => { | |
| 691 | 736 | const user = await resolveSession(cookie.session.value); | |
| 692 | 737 | const deny = requireAuth(user); | |
| 693 | 738 | 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; | |
| 694 | 748 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 695 | 749 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 696 | 750 | ||
| @@ -731,9 +785,18 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 731 | 785 | ||
| 732 | 786 | .post( | |
| 733 | 787 | "/:repo/issues/:number/labels/add", | |
| 734 | - | async ({ params, body, cookie }) => { | |
| 788 | + | async ({ params, body, cookie, request, server }) => { | |
| 735 | 789 | const user = await resolveSession(cookie.session.value); | |
| 736 | 790 | 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; | |
| 737 | 800 | const repo = await getRepo(params.repo, user.isAdmin); | |
| 738 | 801 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 739 | 802 | ||
| @@ -783,9 +846,18 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 783 | 846 | ||
| 784 | 847 | .post( | |
| 785 | 848 | "/:repo/issues/:number/labels/remove", | |
| 786 | - | async ({ params, body, cookie }) => { | |
| 849 | + | async ({ params, body, cookie, request, server }) => { | |
| 787 | 850 | const user = await resolveSession(cookie.session.value); | |
| 788 | 851 | 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; | |
| 789 | 861 | const repo = await getRepo(params.repo, user.isAdmin); | |
| 790 | 862 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 791 | 863 | ||
Msrc/routes/patches.tsx
| @@ -1,11 +1,20 @@ | |||
|---|---|---|---|
| 1 | 1 | import { Elysia, t } from "elysia"; | |
| 2 | 2 | import { sql } from "kysely"; | |
| 3 | 3 | 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"; | |
| 5 | 13 | import { patchesLabelFilter } from "../db/helpers.ts"; | |
| 6 | 14 | import { db, getRepo, type LabelRow } from "../db/index.ts"; | |
| 7 | 15 | import { authorizeCommentEdit } from "../lib/commentAuth.ts"; | |
| 8 | 16 | import { paginate } from "../lib/pagination.ts"; | |
| 17 | + | import { rateLimit } from "../lib/rateLimiter.ts"; | |
| 9 | 18 | import { | |
| 10 | 19 | requireAdmin, | |
| 11 | 20 | requireAuth, | |
| @@ -235,10 +244,19 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 235 | 244 | ||
| 236 | 245 | .post( | |
| 237 | 246 | "/:repo/patches", | |
| 238 | - | async ({ params, body, cookie }) => { | |
| 247 | + | async ({ params, body, cookie, request, server }) => { | |
| 239 | 248 | const user = await resolveSession(cookie.session.value); | |
| 240 | 249 | const deny = requireAuth(user); | |
| 241 | 250 | 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; | |
| 242 | 260 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 243 | 261 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 244 | 262 | ||
| @@ -649,10 +667,19 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 649 | 667 | ||
| 650 | 668 | .post( | |
| 651 | 669 | "/:repo/patches/:number/upload", | |
| 652 | - | async ({ params, body, cookie }) => { | |
| 670 | + | async ({ params, body, cookie, request, server }) => { | |
| 653 | 671 | const user = await resolveSession(cookie.session.value); | |
| 654 | 672 | const deny = requireAuth(user); | |
| 655 | 673 | 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; | |
| 656 | 683 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 657 | 684 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 658 | 685 | ||
| @@ -793,10 +820,19 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 793 | 820 | ||
| 794 | 821 | .post( | |
| 795 | 822 | "/:repo/patches/:number/comments", | |
| 796 | - | async ({ params, body, cookie }) => { | |
| 823 | + | async ({ params, body, cookie, request, server }) => { | |
| 797 | 824 | const user = await resolveSession(cookie.session.value); | |
| 798 | 825 | const deny = requireAuth(user); | |
| 799 | 826 | 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; | |
| 800 | 836 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 801 | 837 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 802 | 838 | ||
| @@ -849,10 +885,19 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 849 | 885 | ||
| 850 | 886 | .post( | |
| 851 | 887 | "/:repo/patches/:number/react", | |
| 852 | - | async ({ params, body, cookie }) => { | |
| 888 | + | async ({ params, body, cookie, request, server }) => { | |
| 853 | 889 | const user = await resolveSession(cookie.session.value); | |
| 854 | 890 | const deny = requireAuth(user); | |
| 855 | 891 | 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; | |
| 856 | 901 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 857 | 902 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 858 | 903 | ||
| @@ -926,10 +971,19 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 926 | 971 | ||
| 927 | 972 | .post( | |
| 928 | 973 | "/:repo/patches/:number/comments/:id/edit", | |
| 929 | - | async ({ params, body, cookie }) => { | |
| 974 | + | async ({ params, body, cookie, request, server }) => { | |
| 930 | 975 | const user = await resolveSession(cookie.session.value); | |
| 931 | 976 | const deny = requireAuth(user); | |
| 932 | 977 | 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; | |
| 933 | 987 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 934 | 988 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 935 | 989 | ||
| @@ -970,10 +1024,19 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 970 | 1024 | ||
| 971 | 1025 | .post( | |
| 972 | 1026 | "/:repo/patches/:number/edit", | |
| 973 | - | async ({ params, body, cookie }) => { | |
| 1027 | + | async ({ params, body, cookie, request, server }) => { | |
| 974 | 1028 | const user = await resolveSession(cookie.session.value); | |
| 975 | 1029 | const deny = requireAuth(user); | |
| 976 | 1030 | 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; | |
| 977 | 1040 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 978 | 1041 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 979 | 1042 | ||
| @@ -1018,9 +1081,18 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 1018 | 1081 | ||
| 1019 | 1082 | .post( | |
| 1020 | 1083 | "/:repo/patches/:number/labels/add", | |
| 1021 | - | async ({ params, body, cookie }) => { | |
| 1084 | + | async ({ params, body, cookie, request, server }) => { | |
| 1022 | 1085 | const user = await resolveSession(cookie.session.value); | |
| 1023 | 1086 | 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; | |
| 1024 | 1096 | const repo = await getRepo(params.repo, user.isAdmin); | |
| 1025 | 1097 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 1026 | 1098 | ||
| @@ -1070,9 +1142,18 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 1070 | 1142 | ||
| 1071 | 1143 | .post( | |
| 1072 | 1144 | "/:repo/patches/:number/labels/remove", | |
| 1073 | - | async ({ params, body, cookie }) => { | |
| 1145 | + | async ({ params, body, cookie, request, server }) => { | |
| 1074 | 1146 | const user = await resolveSession(cookie.session.value); | |
| 1075 | 1147 | 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; | |
| 1076 | 1157 | const repo = await getRepo(params.repo, user.isAdmin); | |
| 1077 | 1158 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 1078 | 1159 | ||
Msrc/routes/releases.tsx
| @@ -4,6 +4,7 @@ import { Elysia, t } from "elysia"; | |||
|---|---|---|---|
| 4 | 4 | import config from "../config.ts"; | |
| 5 | 5 | import { paths, RELEASES_PER_PAGE } from "../constants.ts"; | |
| 6 | 6 | import { db, getRepo } from "../db/index.ts"; | |
| 7 | + | import { contentDisposition } from "../lib/contentDisposition.ts"; | |
| 7 | 8 | import { paginate } from "../lib/pagination.ts"; | |
| 8 | 9 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 9 | 10 | import { archiveRepo, git } from "../services/git.ts"; | |
| @@ -18,6 +19,57 @@ import { html } from "../views/render.tsx"; | |||
|---|---|---|---|
| 18 | 19 | // immediately when the corresponding release is deleted. | |
| 19 | 20 | const archivingTasks = new Map<number, AbortController>(); | |
| 20 | 21 | ||
| 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 | + | ||
| 21 | 73 | function sanitizeFilename(name: string): string { | |
| 22 | 74 | const safe = path.basename(name).replace(/[^a-zA-Z0-9._-]/g, "_"); | |
| 23 | 75 | if (!safe || /^\.+$/.test(safe)) return "_"; | |
| @@ -312,40 +364,21 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 312 | 364 | // Kick off source archive generation in the background so the | |
| 313 | 365 | // response can be sent immediately. The .pending sentinel written | |
| 314 | 366 | // 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. | |
| 320 | 370 | if (includeSource) { | |
| 321 | 371 | const sourceDir = path.join( | |
| 322 | 372 | paths.RELEASES_DIR, | |
| 323 | 373 | String(releaseId), | |
| 324 | 374 | "source", | |
| 325 | 375 | ); | |
| 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 | + | }); | |
| 349 | 382 | } | |
| 350 | 383 | ||
| 351 | 384 | return new Response(null, { | |
| @@ -475,9 +508,14 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 475 | 508 | if (!release) return new Response("Not found", { status: 404 }); | |
| 476 | 509 | ||
| 477 | 510 | // 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. | |
| 479 | 513 | archivingTasks.get(release.id)?.abort(); | |
| 480 | 514 | archivingTasks.delete(release.id); | |
| 515 | + | const qIdx = queuedArchives.findIndex( | |
| 516 | + | (j) => j.releaseId === release.id, | |
| 517 | + | ); | |
| 518 | + | if (qIdx >= 0) queuedArchives.splice(qIdx, 1); | |
| 481 | 519 | ||
| 482 | 520 | // Remove files from disk before the DB record so that a crash | |
| 483 | 521 | // between the two leaves a broken-but-visible repo rather than a | |
| @@ -541,7 +579,10 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 541 | 579 | ||
| 542 | 580 | return new Response(file, { | |
| 543 | 581 | headers: { | |
| 544 | - | "Content-Disposition": `attachment; filename="${safeFilename}"`, | |
| 582 | + | "Content-Disposition": contentDisposition( | |
| 583 | + | "attachment", | |
| 584 | + | safeFilename, | |
| 585 | + | ), | |
| 545 | 586 | "Content-Type": "application/octet-stream", | |
| 546 | 587 | }, | |
| 547 | 588 | }); | |
| @@ -584,7 +625,10 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 584 | 625 | ||
| 585 | 626 | return new Response(file, { | |
| 586 | 627 | headers: { | |
| 587 | - | "Content-Disposition": `attachment; filename="${safeFilename}"`, | |
| 628 | + | "Content-Disposition": contentDisposition( | |
| 629 | + | "attachment", | |
| 630 | + | safeFilename, | |
| 631 | + | ), | |
| 588 | 632 | "Content-Type": "application/octet-stream", | |
| 589 | 633 | }, | |
| 590 | 634 | }); | |
Msrc/routes/repos.tsx
| @@ -17,6 +17,7 @@ import { | |||
|---|---|---|---|
| 17 | 17 | YEAR_SECONDS, | |
| 18 | 18 | } from "../constants.ts"; | |
| 19 | 19 | import { db } from "../db/index.ts"; | |
| 20 | + | import { contentDisposition } from "../lib/contentDisposition.ts"; | |
| 20 | 21 | import { redirect } from "../lib/redirect.ts"; | |
| 21 | 22 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 22 | 23 | import { git, repoPath, type TreeEntry } from "../services/git.ts"; | |
| @@ -48,6 +49,99 @@ async function getRepo(name: string, isAdmin: boolean) { | |||
|---|---|---|---|
| 48 | 49 | return repo; | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 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 | + | ||
| 51 | 145 | async function mimeForContent( | |
| 52 | 146 | filename: string, | |
| 53 | 147 | content: Buffer, | |
| @@ -477,6 +571,30 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 477 | 571 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 478 | 572 | ||
| 479 | 573 | 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 | + | } | |
| 480 | 598 | const [content, branches, tags, commitSHA] = await Promise.all([ | |
| 481 | 599 | git.show(repo.name, params.ref, filePath), | |
| 482 | 600 | git.branches(repo.name), | |
| @@ -486,7 +604,6 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 486 | 604 | if (!content || !commitSHA) | |
| 487 | 605 | return new Response("Not found", { status: 404 }); | |
| 488 | 606 | ||
| 489 | - | const filename = path.basename(filePath); | |
| 490 | 607 | const [view, markdownHtml] = await Promise.all([ | |
| 491 | 608 | serveFile( | |
| 492 | 609 | content, | |
| @@ -530,36 +647,88 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 530 | 647 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 531 | 648 | ||
| 532 | 649 | 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 | + | } | |
| 535 | 665 | ||
| 536 | 666 | 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); | |
| 539 | 694 | ||
| 540 | 695 | const rangeHeader = request.headers.get("Range"); | |
| 696 | + | const fullProc = Bun.spawn(blobArgs, { | |
| 697 | + | stdout: "pipe", | |
| 698 | + | stderr: "ignore", | |
| 699 | + | }); | |
| 541 | 700 | if (rangeHeader) { | |
| 542 | 701 | const match = rangeHeader.match(/bytes=(\d*)-(\d*)/); | |
| 543 | 702 | if (match) { | |
| 544 | 703 | const start = match[1] ? parseInt(match[1], 10) : 0; | |
| 545 | 704 | const end = match[2] ? parseInt(match[2], 10) : total - 1; | |
| 546 | 705 | 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, { | |
| 548 | 714 | status: 206, | |
| 549 | 715 | headers: { | |
| 550 | 716 | "Content-Type": contentType, | |
| 551 | 717 | "Content-Range": `bytes ${start}-${clampedEnd}/${total}`, | |
| 552 | 718 | "Accept-Ranges": "bytes", | |
| 553 | - | "Content-Length": String(clampedEnd - start + 1), | |
| 719 | + | "Content-Length": String(length), | |
| 554 | 720 | }, | |
| 555 | 721 | }); | |
| 556 | 722 | } | |
| 557 | 723 | } | |
| 558 | 724 | ||
| 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), { | |
| 560 | 729 | headers: { | |
| 561 | 730 | "Content-Type": contentType, | |
| 562 | - | "Content-Disposition": `inline; filename="${filename}"`, | |
| 731 | + | "Content-Disposition": contentDisposition("inline", filename), | |
| 563 | 732 | "Content-Length": String(total), | |
| 564 | 733 | "Accept-Ranges": "bytes", | |
| 565 | 734 | }, | |
| @@ -734,6 +903,21 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 734 | 903 | git.diff(repo.name, params.sha), | |
| 735 | 904 | ]); | |
| 736 | 905 | 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 | + | } | |
| 737 | 921 | const files = await prepareDiff( | |
| 738 | 922 | rawDiff, | |
| 739 | 923 | `commit:${repo.name}:${params.sha}`, | |
Msrc/services/avatar.ts
| @@ -52,7 +52,13 @@ export async function processAndStoreAvatar( | |||
|---|---|---|---|
| 52 | 52 | if (!type?.mime.startsWith("image/")) { | |
| 53 | 53 | throw new Error("Invalid image type"); | |
| 54 | 54 | } | |
| 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 | + | }) | |
| 56 | 62 | .resize(128, 128, { fit: "cover", position: "center" }) | |
| 57 | 63 | .ensureAlpha() | |
| 58 | 64 | .raw() | |
Msrc/services/ci.ts
| @@ -66,6 +66,44 @@ const runningTasks = new Map< | |||
|---|---|---|---|
| 66 | 66 | { controller: AbortController; containerId?: string } | |
| 67 | 67 | >(); | |
| 68 | 68 | ||
| 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 | + | ||
| 69 | 107 | // --- TOML Parsing --- | |
| 70 | 108 | ||
| 71 | 109 | export function parseCiConfig(tomlStr: string): CiConfig | null { | |
| @@ -515,7 +553,6 @@ async function collectArtifacts( | |||
|---|---|---|---|
| 515 | 553 | containerId: string, | |
| 516 | 554 | step: CiStep, | |
| 517 | 555 | _shell: string[], | |
| 518 | - | workDir: string | undefined, | |
| 519 | 556 | envVars: string[], | |
| 520 | 557 | ): Promise<void> { | |
| 521 | 558 | const artifactDir = path.join(paths.CI_ARTIFACTS_DIR, String(runId)); | |
| @@ -545,62 +582,43 @@ async function collectArtifacts( | |||
|---|---|---|---|
| 545 | 582 | } | |
| 546 | 583 | } | |
| 547 | 584 | ||
| 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. | |
| 548 | 592 | type ArchiveType = "tar" | "gzip" | "zip" | "zstd"; | |
| 549 | 593 | const archiveFormats: Array<{ | |
| 550 | 594 | type: ArchiveType; | |
| 551 | 595 | paths: string[]; | |
| 552 | 596 | ext: string; | |
| 553 | - | cmd: (src: string, dst: string) => string[]; | |
| 597 | + | cmd: (basename: string, dst: string) => string[]; | |
| 554 | 598 | }> = [ | |
| 555 | 599 | { | |
| 556 | 600 | type: "tar", | |
| 557 | 601 | paths: toArray(step.publish_tar), | |
| 558 | 602 | 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], | |
| 567 | 604 | }, | |
| 568 | 605 | { | |
| 569 | 606 | type: "gzip", | |
| 570 | 607 | paths: toArray(step.publish_gzip), | |
| 571 | 608 | 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], | |
| 580 | 610 | }, | |
| 581 | 611 | { | |
| 582 | 612 | type: "zstd", | |
| 583 | 613 | paths: toArray(step.publish_zstd), | |
| 584 | 614 | 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], | |
| 594 | 616 | }, | |
| 595 | 617 | { | |
| 596 | 618 | type: "zip", | |
| 597 | 619 | paths: toArray(step.publish_zip), | |
| 598 | 620 | 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], | |
| 604 | 622 | }, | |
| 605 | 623 | ]; | |
| 606 | 624 | ||
| @@ -609,11 +627,13 @@ async function collectArtifacts( | |||
|---|---|---|---|
| 609 | 627 | for (const srcPath of archivePaths) { | |
| 610 | 628 | archiveIndex++; | |
| 611 | 629 | 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. | |
| 613 | 633 | const execResult = await execInContainer( | |
| 614 | 634 | containerId, | |
| 615 | - | cmd(srcPath, tmpPath), | |
| 616 | - | workDir, | |
| 635 | + | cmd(path.basename(srcPath), tmpPath), | |
| 636 | + | path.dirname(srcPath), | |
| 617 | 637 | envVars, | |
| 618 | 638 | ).catch(() => null); | |
| 619 | 639 | if (!execResult || execResult.exitCode !== 0) continue; | |
| @@ -861,7 +881,6 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> { | |||
|---|---|---|---|
| 861 | 881 | containerId, | |
| 862 | 882 | step, | |
| 863 | 883 | shell, | |
| 864 | - | cfg.work_dir, | |
| 865 | 884 | envArray, | |
| 866 | 885 | ).catch(() => {}); | |
| 867 | 886 | } | |
| @@ -959,6 +978,8 @@ async function executeRun(runId: number, signal: AbortSignal): Promise<void> { | |||
|---|---|---|---|
| 959 | 978 | .where("id", "=", runId) | |
| 960 | 979 | .executeTakeFirst(); | |
| 961 | 980 | if (run) await pruneHistory(run.repo_id).catch(() => {}); | |
| 981 | + | // A slot just freed up — start the next queued run if any. | |
| 982 | + | pumpQueue(); | |
| 962 | 983 | } | |
| 963 | 984 | } | |
| 964 | 985 | ||
| @@ -1003,11 +1024,22 @@ export async function triggerRun( | |||
|---|---|---|---|
| 1003 | 1024 | .where("id", "=", runId.id) | |
| 1004 | 1025 | .execute(); | |
| 1005 | 1026 | ||
| 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(); | |
| 1011 | 1043 | ||
| 1012 | 1044 | return runId.id; | |
| 1013 | 1045 | } | |
| @@ -1015,24 +1047,40 @@ export async function triggerRun( | |||
|---|---|---|---|
| 1015 | 1047 | // Wraps the fire-and-forget executeRun so unhandled exceptions (e.g. a throw | |
| 1016 | 1048 | // before/after its own try/finally) are logged and the run is reconciled to | |
| 1017 | 1049 | // "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 () => { | |
| 1022 | 1056 | 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(); | |
| 1034 | 1082 | } | |
| 1035 | - | }); | |
| 1083 | + | })(); | |
| 1036 | 1084 | } | |
| 1037 | 1085 | ||
| 1038 | 1086 | export async function retryRun( | |
| @@ -1056,7 +1104,12 @@ export async function retryRun( | |||
|---|---|---|---|
| 1056 | 1104 | } | |
| 1057 | 1105 | await db.deleteFrom("ci_artifacts").where("run_id", "=", runId).execute(); | |
| 1058 | 1106 | ||
| 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. | |
| 1060 | 1113 | await db | |
| 1061 | 1114 | .updateTable("ci_runs") | |
| 1062 | 1115 | .set({ | |
| @@ -1068,10 +1121,15 @@ export async function retryRun( | |||
|---|---|---|---|
| 1068 | 1121 | .where("id", "=", runId) | |
| 1069 | 1122 | .execute(); | |
| 1070 | 1123 | ||
| 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(); | |
| 1075 | 1133 | } | |
| 1076 | 1134 | ||
| 1077 | 1135 | export async function cancelRun(runId: number): Promise<void> { | |
| @@ -1083,11 +1141,13 @@ export async function cancelRun(runId: number): Promise<void> { | |||
|---|---|---|---|
| 1083 | 1141 | await removeContainer(containerId).catch(() => {}); | |
| 1084 | 1142 | } | |
| 1085 | 1143 | } | |
| 1144 | + | const queueIdx = queuedRunIds.indexOf(runId); | |
| 1145 | + | if (queueIdx >= 0) queuedRunIds.splice(queueIdx, 1); | |
| 1086 | 1146 | await db | |
| 1087 | 1147 | .updateTable("ci_runs") | |
| 1088 | 1148 | .set({ status: "cancelled", finished_at: new Date().toISOString() }) | |
| 1089 | 1149 | .where("id", "=", runId) | |
| 1090 | - | .where("status", "in", ["pending", "running"]) | |
| 1150 | + | .where("status", "in", ["pending", "running", "queued"]) | |
| 1091 | 1151 | .execute(); | |
| 1092 | 1152 | } | |
| 1093 | 1153 | ||
| @@ -1142,7 +1202,7 @@ export async function cancelStaleRuns(): Promise<void> { | |||
|---|---|---|---|
| 1142 | 1202 | const stale = await db | |
| 1143 | 1203 | .selectFrom("ci_runs") | |
| 1144 | 1204 | .select("id") | |
| 1145 | - | .where("status", "in", ["pending", "running"]) | |
| 1205 | + | .where("status", "in", ["pending", "running", "queued"]) | |
| 1146 | 1206 | .execute(); | |
| 1147 | 1207 | ||
| 1148 | 1208 | await Promise.allSettled( | |
| @@ -1156,7 +1216,7 @@ export async function cancelStaleRuns(): Promise<void> { | |||
|---|---|---|---|
| 1156 | 1216 | await db | |
| 1157 | 1217 | .updateTable("ci_runs") | |
| 1158 | 1218 | .set({ status: "cancelled", finished_at: now }) | |
| 1159 | - | .where("status", "in", ["pending", "running"]) | |
| 1219 | + | .where("status", "in", ["pending", "running", "queued"]) | |
| 1160 | 1220 | .execute(); | |
| 1161 | 1221 | await db | |
| 1162 | 1222 | .updateTable("ci_steps") | |
Msrc/services/sshServer.ts
| @@ -6,7 +6,88 @@ import { Server, utils } from "ssh2"; | |||
|---|---|---|---|
| 6 | 6 | import config from "../config.ts"; | |
| 7 | 7 | import { ADMIN_USERNAME, paths } from "../constants.ts"; | |
| 8 | 8 | 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 | + | } | |
| 10 | 91 | ||
| 11 | 92 | /** Compute SHA256 fingerprint from raw SSH public key bytes (the wire-format bytes). */ | |
| 12 | 93 | function fingerprintFromBytes(keyBytes: Buffer): string { | |
| @@ -58,6 +139,7 @@ export async function startSshServer() { | |||
|---|---|---|---|
| 58 | 139 | "ssh_keys.public_key", | |
| 59 | 140 | ]) | |
| 60 | 141 | .where("ssh_keys.fingerprint", "=", fingerprint) | |
| 142 | + | .where("users.is_pending", "=", 0) | |
| 61 | 143 | .executeTakeFirst(); | |
| 62 | 144 | ||
| 63 | 145 | if (!sshKey) return ctx.reject(); | |
| @@ -113,7 +195,14 @@ export async function startSshServer() { | |||
|---|---|---|---|
| 113 | 195 | } | |
| 114 | 196 | } | |
| 115 | 197 | ||
| 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. | |
| 117 | 206 | stream.stderr.write( | |
| 118 | 207 | "error: repository access denied\n", | |
| 119 | 208 | ); | |
| @@ -123,6 +212,24 @@ export async function startSshServer() { | |||
|---|---|---|---|
| 123 | 212 | } | |
| 124 | 213 | ||
| 125 | 214 | 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 | + | ||
| 126 | 233 | stream.pipe(proc.stdin!); | |
| 127 | 234 | proc.stdout?.pipe(stream, { end: false }); | |
| 128 | 235 | proc.stderr?.pipe(stream.stderr as NodeJS.WritableStream, { | |
| @@ -132,6 +239,14 @@ export async function startSshServer() { | |||
|---|---|---|---|
| 132 | 239 | proc.on("close", (code: number | null) => { | |
| 133 | 240 | if (command === "git-receive-pack") { | |
| 134 | 241 | 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 | + | } | |
| 135 | 250 | } | |
| 136 | 251 | stream.exit(code ?? 0); | |
| 137 | 252 | stream.end(); | |
Msrc/styles/components.css
| @@ -1942,6 +1942,10 @@ | |||
|---|---|---|---|
| 1942 | 1942 | background: var(--color-border); | |
| 1943 | 1943 | color: var(--color-text-muted); | |
| 1944 | 1944 | } | |
| 1945 | + | .ci-status-queued { | |
| 1946 | + | background: #6e7781; | |
| 1947 | + | color: #fff; | |
| 1948 | + | } | |
| 1945 | 1949 | .ci-status-running { | |
| 1946 | 1950 | background: #0b5cab; | |
| 1947 | 1951 | color: #fff; | |
Msrc/views/ci/CiHistory.tsx
| @@ -20,6 +20,7 @@ interface RunSummary { | |||
|---|---|---|---|
| 20 | 20 | created_at: string; | |
| 21 | 21 | triggered_by_username: string | null; | |
| 22 | 22 | artifact_count: number; | |
| 23 | + | queue_position: number | null; | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | interface CiHistoryProps { | |
| @@ -30,6 +31,12 @@ interface CiHistoryProps { | |||
|---|---|---|---|
| 30 | 31 | manualTriggerDisabledReason: string | null; | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 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 | + | ||
| 33 | 40 | function duration(start: string | null, end: string | null): string { | |
| 34 | 41 | if (!start || !end) return ""; | |
| 35 | 42 | const ms = new Date(end).getTime() - new Date(start).getTime(); | |
| @@ -131,7 +138,10 @@ export function CiHistory({ | |||
|---|---|---|---|
| 131 | 138 | manualTriggerDisabledReason, | |
| 132 | 139 | }: CiHistoryProps) { | |
| 133 | 140 | 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", | |
| 135 | 145 | ); | |
| 136 | 146 | return ( | |
| 137 | 147 | <Layout user={user} title={`Pipelines — ${repo.name}`}> | |
| @@ -203,7 +213,16 @@ export function CiHistory({ | |||
|---|---|---|---|
| 203 | 213 | href={`/${repo.name}/ci/${run.id}`} | |
| 204 | 214 | class="release-item-title" | |
| 205 | 215 | > | |
| 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 | + | /> | |
| 207 | 226 | <span class="ci-run-id"> | |
| 208 | 227 | #{run.repo_run_id ?? run.id} | |
| 209 | 228 | </span> | |
Msrc/views/ci/CiRunDetail.tsx
| @@ -32,6 +32,13 @@ interface CiRunDetailProps { | |||
|---|---|---|---|
| 32 | 32 | steps: CiStepRow[]; | |
| 33 | 33 | artifacts: CiArtifactRow[]; | |
| 34 | 34 | 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`; | |
| 35 | 42 | } | |
| 36 | 43 | ||
| 37 | 44 | function duration(start: string | null, end: string | null): string { | |
| @@ -58,8 +65,14 @@ export function CiRunDetail({ | |||
|---|---|---|---|
| 58 | 65 | steps, | |
| 59 | 66 | artifacts, | |
| 60 | 67 | autoRefresh, | |
| 68 | + | queuePosition, | |
| 61 | 69 | }: 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); | |
| 63 | 76 | const displayId = run.repo_run_id ?? run.id; | |
| 64 | 77 | ||
| 65 | 78 | let variableOverrides: Record<string, string> = {}; | |
| @@ -88,8 +101,11 @@ export function CiRunDetail({ | |||
|---|---|---|---|
| 88 | 101 | <div class="release-detail-header"> | |
| 89 | 102 | <div> | |
| 90 | 103 | <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} | |
| 93 | 109 | </h2> | |
| 94 | 110 | <div class="release-item-meta"> | |
| 95 | 111 | {run.commit_sha && ( | |
| @@ -191,7 +207,9 @@ export function CiRunDetail({ | |||
|---|---|---|---|
| 191 | 207 | <h3 class="section-title">Steps</h3> | |
| 192 | 208 | {steps.length === 0 ? ( | |
| 193 | 209 | <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> | |
| 195 | 213 | </div> | |
| 196 | 214 | ) : ( | |
| 197 | 215 | steps.map((step) => ( | |
Msrc/views/ci/CiStatusPill.tsx
| @@ -1,5 +1,6 @@ | |||
|---|---|---|---|
| 1 | 1 | const statusStyles: Record<string, string> = { | |
| 2 | 2 | pending: "ci-status-pending", | |
| 3 | + | queued: "ci-status-queued", | |
| 3 | 4 | running: "ci-status-running", | |
| 4 | 5 | success: "ci-status-success", | |
| 5 | 6 | failure: "ci-status-failure", | |
| @@ -7,7 +8,16 @@ const statusStyles: Record<string, string> = { | |||
|---|---|---|---|
| 7 | 8 | skipped: "ci-status-skipped", | |
| 8 | 9 | }; | |
| 9 | 10 | ||
| 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) { | |
| 11 | 17 | 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 | + | ); | |
| 13 | 23 | } | |
Msrc/views/repos/CommitDetail.tsx
| @@ -15,6 +15,7 @@ interface CommitDetailProps { | |||
|---|---|---|---|
| 15 | 15 | sha: string; | |
| 16 | 16 | meta: CommitMeta; | |
| 17 | 17 | files: RenderedDiffFile[]; | |
| 18 | + | tooLarge?: number; | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | export function CommitDetail({ | |
| @@ -23,6 +24,7 @@ export function CommitDetail({ | |||
|---|---|---|---|
| 23 | 24 | sha, | |
| 24 | 25 | meta, | |
| 25 | 26 | files, | |
| 27 | + | tooLarge, | |
| 26 | 28 | }: CommitDetailProps) { | |
| 27 | 29 | return ( | |
| 28 | 30 | <Layout user={user} title={`${sha.slice(0, 7)} — ${repo.name}`}> | |
| @@ -141,7 +143,17 @@ export function CommitDetail({ | |||
|---|---|---|---|
| 141 | 143 | </div> | |
| 142 | 144 | </div> | |
| 143 | 145 | ||
| 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 | + | )} | |
| 145 | 157 | </div> | |
| 146 | 158 | </Layout> | |
| 147 | 159 | ); | |