bun 1.4 upgrade, major simplification/refactor

AuthorKonata <konata@posteo.jp>
Date
Commita76277f2b0959af41866b4c2a0ccf0dbd870f9a9
Parent9ff943d
29 files changed, 890 insertions(+), 541 deletions(-)
▾M.gitignore
@@ -36,7 +36,7 @@ data/
.env
# Test data
data-test/
data-test*/
# Generated CSS
public/assets/main.css
▾MContainerfile
@@ -10,7 +10,7 @@ FROM oven/bun:1.4 AS base
WORKDIR /app
RUN apt-get --update install -y --no-install-recommends \
tini openssh-client git-core zstd \
tini openssh-client git-core \
&& rm -rf /var/lib/apt/lists/*
# Copy public first so postinstall can write vendor files into it
▾Mbun.lock
@@ -16,10 +16,8 @@
"kysely": "^0.28.14",
"kysely-bun-sqlite": "^0.4.0",
"linguist-languages": "^9.3.1",
"marked": "^17.0.4",
"sharp": "^0.34.5",
"shiki": "^4.0.2",
"smol-toml": "^1.6.1",
"ssh2": "^1.17.0",
},
"devDependencies": {
@@ -27,7 +25,7 @@
"@kitajs/ts-html-plugin": "^4.1.4",
"@simplewebauthn/browser": "^13.3.0",
"@types/argon2": "^0.15.4",
"@types/bun": "latest",
"@types/bun": "^1.4.0",
"@types/ssh2": "^1.15.5",
"jxl-rs-polyfill": "^0.1.1",
"lightningcss": "^1.32.0",
@@ -203,7 +201,7 @@
"@types/argon2": ["@types/argon2@0.15.4", "", { "dependencies": { "argon2": "*" } }, "sha512-fZPJNvZTvoyt/okKhbyj99UsDOwqzEXYPvKqfK26mViH9JB/hOwzUwDUn5qbwq9XJkLw0kDcFNu6+bnb3u5a0A=="],
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
@@ -237,7 +235,7 @@
"buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -355,8 +353,6 @@
"lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="],
"marked": ["marked@17.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-NOmVMM+KAokHMvjWmC5N/ZOvgmSWuqJB8FoYI019j4ogb/PeRMKoKIjReZ2w3376kkA8dSJIP8uD993Kxc0iRQ=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
@@ -427,8 +423,6 @@
"shiki": ["shiki@4.0.2", "", { "dependencies": { "@shikijs/core": "4.0.2", "@shikijs/engine-javascript": "4.0.2", "@shikijs/engine-oniguruma": "4.0.2", "@shikijs/langs": "4.0.2", "@shikijs/themes": "4.0.2", "@shikijs/types": "4.0.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-eAVKTMedR5ckPo4xne/PjYQYrU3qx78gtJZ+sHlXEg5IHhhoQhMfZVzetTYuaJS0L2Ef3AcCRzCHV8T0WI6nIQ=="],
"smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
@@ -513,8 +507,14 @@
"bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"kysely-bun-sqlite/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
"bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"kysely-bun-sqlite/bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
"kysely-bun-sqlite/bun-types/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
}
}
▾Abunfig.toml
@@ -0,0 +1,11 @@
[test]
# src/db/index.ts opens and migrates the database in its module body, before
# any test file can override DATA_DIR. The preload repoints DATA_DIR at a
# per-worker directory first. It lives here rather than only in the
# package.json "test" script so that a bare `bun test` — what an IDE runs, and
# what you type for a single file — cannot run against the real ./data.
preload = ["./tests/preload.ts"]
# Note: `parallel` and `isolate` are CLI-only, so a bare `bun test` runs the
# whole suite in one process where the e2e files share a data directory and
# stomp each other. Use `bun run test`. Single files work either way.
▾Mpackage.json
@@ -7,8 +7,7 @@
"start": "bun run src/index.tsx",
"css:build": "bun scripts/build-css.ts",
"db:init": "bun run src/db/init.ts",
"db:seed": "bun run src/db/seed.ts",
"test": "bun scripts/test.ts",
"test": "bun test --parallel --isolate --timeout 30000",
"vendor:simplewebauthn": "bun build node_modules/@simplewebauthn/browser/esm/index.js --outfile public/assets/simplewebauthn-browser.js --format esm",
"vendor:jxl-polyfill": "cp node_modules/jxl-rs-polyfill/dist/auto.js public/assets/jxl-polyfill.js",
"postinstall": "bun run vendor:simplewebauthn && bun run vendor:jxl-polyfill && bun run css:build",
@@ -22,7 +21,7 @@
"@kitajs/ts-html-plugin": "^4.1.4",
"@simplewebauthn/browser": "^13.3.0",
"@types/argon2": "^0.15.4",
"@types/bun": "latest",
"@types/bun": "^1.4.0",
"@types/ssh2": "^1.15.5",
"jxl-rs-polyfill": "^0.1.1",
"lightningcss": "^1.32.0",
@@ -43,10 +42,8 @@
"kysely": "^0.28.14",
"kysely-bun-sqlite": "^0.4.0",
"linguist-languages": "^9.3.1",
"marked": "^17.0.4",
"sharp": "^0.34.5",
"shiki": "^4.0.2",
"smol-toml": "^1.6.1",
"ssh2": "^1.17.0"
}
}
▾Dscripts/test.ts
-75
@@ -1,75 +0,0 @@
import { readdirSync } from 'fs';
import { spawn } from 'child_process';
import path from 'path';
const testsDir = path.resolve('tests');
const files = readdirSync(testsDir)
.filter(f => f.endsWith('.test.ts'))
.sort();
const STALL_TIMEOUT = 20_000; // kill if no output for 20s
const MAX_RETRIES = 3;
// retry logic needed because tests get randomly get stuck on startup with bun
// strace shows bun completely spinning in futex and not doing anything else.
// Only a stall-kill is retried — a genuine assertion failure returns
// {ok: false, stalled: false} and must NOT be retried, or a real product bug
// that fails intermittently would be laundered into a pass.
function runTest(filePath: string): Promise<{ ok: boolean; stalled: boolean }> {
return new Promise((resolve) => {
const child = spawn('bun', ['test', '--bail=1', '--timeout', '30000', filePath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
let stalled = false;
let timer = setTimeout(onStall, STALL_TIMEOUT);
function onStall() {
stalled = true;
console.error(`\n[test-runner] stall detected, killing ${path.basename(filePath)} (no output for ${STALL_TIMEOUT / 1000}s)`);
child.kill('SIGKILL');
}
function resetTimer() {
clearTimeout(timer);
timer = setTimeout(onStall, STALL_TIMEOUT);
}
child.stdout!.on('data', (chunk: Buffer) => {
process.stdout.write(chunk);
resetTimer();
});
child.stderr!.on('data', (chunk: Buffer) => {
process.stderr.write(chunk);
resetTimer();
});
child.on('close', (code) => {
clearTimeout(timer);
resolve({ ok: code === 0, stalled });
});
});
}
let passed = 0;
let failed = 0;
for (const file of files) {
const filePath = path.join(testsDir, file);
let ok = false;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 1) {
console.log(`[test-runner] retrying ${file} (attempt ${attempt}/${MAX_RETRIES})`);
}
const result = await runTest(filePath);
ok = result.ok;
// Retry only the futex stall — a genuine failure is final.
if (ok || !result.stalled) break;
}
if (ok) passed++;
else failed++;
}
console.log(`\n${passed + failed} test files: ${passed} passed${failed ? `, ${failed} failed` : ''}`);
if (failed > 0) process.exit(1);
▾Msrc/config.ts
@@ -2,59 +2,67 @@ import path from "node:path";
const env = process.env;
/** parseInt with a default that distinguishes "unset" from "explicit 0". */
function intEnv(value: string | undefined, defaultValue: number): number {
/**
* parseInt with a default that distinguishes "unset" from "explicit 0".
*
* `min` clamps the result. Pass it for any value where 0 is not a coherent
* setting — a pool of 0 workers, 0 concurrent jobs or a 0-second timeout
* wedges or crashes the feature rather than disabling it. Values that fail
* closed at 0 (byte caps) deliberately have no minimum.
*/
function intEnv(
value: string | undefined,
defaultValue: number,
min = Number.NEGATIVE_INFINITY,
): number {
if (value === undefined || value === "") return defaultValue;
const parsed = parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : defaultValue;
return Number.isFinite(parsed) ? Math.max(min, parsed) : defaultValue;
}
const PORT = intEnv(env.PORT, 3000, 1);
const config = {
OWNER_DISPLAY_NAME: env.OWNER_DISPLAY_NAME ?? "Admin",
INLINE_MAX_BYTES: parseInt(env.INLINE_MAX_BYTES ?? "", 10) || 524288,
MAX_UPLOAD_BYTES:
parseInt(env.MAX_UPLOAD_BYTES ?? "", 10) || 10 * 1024 * 1024,
MAX_USER_UPLOAD_BYTES:
parseInt(env.MAX_USER_UPLOAD_BYTES ?? "", 10) || 2 * 1024 * 1024,
INLINE_MAX_BYTES: intEnv(env.INLINE_MAX_BYTES, 524288),
MAX_UPLOAD_BYTES: intEnv(env.MAX_UPLOAD_BYTES, 10 * 1024 * 1024),
MAX_USER_UPLOAD_BYTES: intEnv(env.MAX_USER_UPLOAD_BYTES, 2 * 1024 * 1024),
TRUSTED_PROXY: !!env.TRUSTED_PROXY,
RATE_LIMIT_DISABLED: !!env.RATE_LIMIT_DISABLED,
SSH_DISABLED: !!env.SSH_DISABLED,
SCANNED_REPO_PRIVATE:
env.SCANNED_REPO_PRIVATE !== "0" &&
env.SCANNED_REPO_PRIVATE !== "false",
PORT: parseInt(env.PORT ?? "", 10) || 3000,
SSH_PORT: parseInt(env.SSH_PORT ?? "", 10) || 2222,
PORT,
SSH_PORT: intEnv(env.SSH_PORT, 2222, 1),
REGISTRATION_TYPE: (env.REGISTRATION_TYPE ?? "enabled") as
| "enabled"
| "disabled"
| "queue",
REGISTER_QUESTION: env.REGISTER_QUESTION ?? "",
BASE_URL:
env.BASE_URL ??
`http://localhost:${parseInt(env.PORT ?? "", 10) || 3000}`,
BASE_URL: env.BASE_URL ?? `http://localhost:${PORT}`,
// Derived from BASE_URL after the literal closes (see below). Declared
// here so consumers get a typed config object.
PUBLIC_HTTPS: false,
PUBLIC_ORIGIN: "",
DATA_DIR: path.resolve(env.DATA_DIR ?? "./data"),
HIGHLIGHT_WORKERS: parseInt(env.HIGHLIGHT_WORKERS ?? "", 10) || 4,
HIGHLIGHT_WORKERS: intEnv(env.HIGHLIGHT_WORKERS, 4, 1),
COMMITTER_NAME: env.COMMITTER_NAME ?? env.OWNER_DISPLAY_NAME ?? "Admin",
COMMITTER_EMAIL: "",
EXTRA_ALLOWED_SIGNERS_PATH: env.EXTRA_ALLOWED_SIGNERS_PATH ?? null,
MAX_TITLE_BYTES: parseInt(env.MAX_TITLE_BYTES ?? "", 10) || 500,
MAX_TEXT_BODY_BYTES: parseInt(env.MAX_TEXT_BODY_BYTES ?? "", 10) || 100_000,
MAX_USERNAME_BYTES: parseInt(env.MAX_USERNAME_BYTES ?? "", 10) || 64,
MAX_PASSWORD_BYTES: parseInt(env.MAX_PASSWORD_BYTES ?? "", 10) || 1024,
MAX_TITLE_BYTES: intEnv(env.MAX_TITLE_BYTES, 500),
MAX_TEXT_BODY_BYTES: intEnv(env.MAX_TEXT_BODY_BYTES, 100_000),
MAX_USERNAME_BYTES: intEnv(env.MAX_USERNAME_BYTES, 64),
MAX_PASSWORD_BYTES: intEnv(env.MAX_PASSWORD_BYTES, 1024),
CI_DOCKER_SOCKET: env.CI_DOCKER_SOCKET ?? "",
CI_MAX_HISTORY: parseInt(env.CI_MAX_HISTORY ?? "", 10) || 50,
CI_MAX_CONCURRENT: parseInt(env.CI_MAX_CONCURRENT ?? "", 10) || 2,
CI_DEFAULT_TIMEOUT: parseInt(env.CI_DEFAULT_TIMEOUT ?? "", 10) || 3600,
CI_MAX_HISTORY: intEnv(env.CI_MAX_HISTORY, 50),
CI_MAX_CONCURRENT: intEnv(env.CI_MAX_CONCURRENT, 2, 1),
CI_DEFAULT_TIMEOUT: intEnv(env.CI_DEFAULT_TIMEOUT, 3600, 1),
// Cap on simultaneous source archive generation jobs (one release with
// include_source_code spawns three git-archive + compressor pipelines
// back-to-back). Without this cap, an admin firing several releases in
// quick succession can saturate CPU. Excess jobs are queued in memory.
MAX_CONCURRENT_ARCHIVE_JOBS:
parseInt(env.MAX_CONCURRENT_ARCHIVE_JOBS ?? "", 10) || 2,
MAX_CONCURRENT_ARCHIVE_JOBS: intEnv(env.MAX_CONCURRENT_ARCHIVE_JOBS, 2, 1),
// Cap on any server-side render that holds the whole content in
// RAM and runs synchronous CPU work on it (blob view, commit/patch
// diff, markdown). Above this, the UI shows a "too large to
▾Msrc/constants.ts
@@ -44,13 +44,9 @@ export const COMMENT_MAX_PER_MIN = 30;
export const REACTION_MAX_PER_MIN = 60;
export const ISSUE_CREATE_MAX_PER_MIN = 10;
export const PATCH_CREATE_MAX_PER_MIN = 10;
export const REPO_CREATE_MAX_PER_HOUR = 30;
export const FILE_EDIT_MAX_PER_MIN = 30;
export const RELEASE_WRITE_MAX_PER_MIN = 20;
export const LABEL_WRITE_MAX_PER_MIN = 30;
export const UPLOAD_MAX_PER_MIN = 10;
export const RATE_WINDOW_MIN_MS = 60_000;
export const RATE_WINDOW_HOUR_MS = 60 * 60_000;
// Session
export const SESSION_ID_BYTES = 32;
@@ -67,10 +63,6 @@ export const BINARY_DETECT_BYTES = 8000;
// Git ref limits
export const MAX_REF_LIST = 1000;
// Text preview
export const PREVIEW_MAX_LENGTH = 180;
export const PREVIEW_TRUNCATION_THRESHOLD = 0.6;
// String length limits
export const MAX_BRANCH_NAME_LENGTH = 255;
export const MAX_TAG_NAME_LENGTH = 255;
▾Msrc/db/index.ts
@@ -1,4 +1,6 @@
import { Database as BunDatabase } from "bun:sqlite";
import { mkdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { type Generated, Kysely, type Selectable } from "kysely";
import { BunSqliteDialect } from "kysely-bun-sqlite";
@@ -231,208 +233,98 @@ export interface Database {
}
// Selectable row types (id is plain number, as returned by queries)
export type UserRow = Selectable<UserTable>;
export type PasskeyRow = Selectable<PasskeyTable>;
export type SessionRow = Selectable<SessionTable>;
export type RepositoryRow = Selectable<RepositoryTable>;
export type IssueRow = Selectable<IssueTable>;
export type IssueCommentRow = Selectable<IssueCommentTable>;
export type IssueReactionRow = Selectable<IssueReactionTable>;
export type PatchRow = Selectable<PatchTable>;
export type PatchCommentRow = Selectable<PatchCommentTable>;
export type PatchReactionRow = Selectable<PatchReactionTable>;
export type SshKeyRow = Selectable<SshKeyTable>;
export type ReleaseRow = Selectable<ReleaseTable>;
export type ReleaseAssetRow = Selectable<ReleaseAssetTable>;
export type LabelRow = Selectable<LabelTable>;
export type CiRunRow = Selectable<CiRunTable>;
export type CiStepRow = Selectable<CiStepTable>;
export type CiArtifactRow = Selectable<CiArtifactTable>;
export type CiSecretRow = Selectable<CiSecretTable>;
let sqlite = new BunDatabase(paths.DB_PATH);
sqlite.run("PRAGMA journal_mode=WAL");
sqlite.run("PRAGMA foreign_keys=ON");
// Migration: add is_pending and register_application columns to users if missing
const userCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(users)")
.all();
if (!userCols.some((c) => c.name === "is_pending")) {
sqlite.run(
"ALTER TABLE users ADD COLUMN is_pending INTEGER NOT NULL DEFAULT 0",
const SCHEMA = readFileSync(path.join(import.meta.dir, "schema.sql"), "utf-8");
/**
* Bring a database file up to date.
*
* `schema.sql` is the single source of truth: every statement in it is
* `IF NOT EXISTS`, so running it on an existing database only fills in
* whatever is missing. Column-level changes cannot be expressed that way,
* so they follow as explicit `ALTER TABLE` steps — and they run after the
* schema, which guarantees the table they alter exists.
*/
function migrate(s: InstanceType<typeof BunDatabase>) {
s.run("PRAGMA journal_mode=WAL");
s.run("PRAGMA foreign_keys=ON");
s.run(SCHEMA);
const hasColumn = (table: string, column: string) =>
s
.query<{ name: string }, []>(`PRAGMA table_info(${table})`)
.all()
.some((c) => c.name === column);
if (!hasColumn("users", "is_pending")) {
s.run(
"ALTER TABLE users ADD COLUMN is_pending INTEGER NOT NULL DEFAULT 0",
);
}
if (!hasColumn("users", "register_application")) {
s.run("ALTER TABLE users ADD COLUMN register_application TEXT");
}
if (!hasColumn("patches", "version")) {
s.run(
"ALTER TABLE patches ADD COLUMN version TEXT NOT NULL DEFAULT ''",
);
}
if (!hasColumn("repositories", "allow_user_labels")) {
s.run(
"ALTER TABLE repositories ADD COLUMN allow_user_labels INTEGER NOT NULL DEFAULT 0",
);
}
if (!hasColumn("ci_runs", "repo_run_id")) {
s.run("ALTER TABLE ci_runs ADD COLUMN repo_run_id INTEGER");
}
// Backfill patch versions left empty by the column migration above.
s.run(
"UPDATE patches SET version = lower(hex(randomblob(16))) WHERE version = ''",
);
}
if (!userCols.some((c) => c.name === "register_application")) {
sqlite.run("ALTER TABLE users ADD COLUMN register_application TEXT");
// Expired sessions serve no purpose after a restart.
s.run("DELETE FROM sessions WHERE expires_at < datetime('now')");
}
// Migration: add version column if missing, then populate any empty values
const patchCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(patches)")
.all();
if (!patchCols.some((c) => c.name === "version")) {
sqlite.run(
"ALTER TABLE patches ADD COLUMN version TEXT NOT NULL DEFAULT ''",
);
/**
* Open the database, creating its directory first — SQLite reports a bare
* SQLITE_CANTOPEN if the parent directory is missing, which is what a fresh
* DATA_DIR looks like before db:init has ever run.
*/
function openDb(): InstanceType<typeof BunDatabase> {
mkdirSync(path.dirname(paths.DB_PATH), { recursive: true });
const s = new BunDatabase(paths.DB_PATH);
migrate(s);
return s;
}
sqlite.run(
"UPDATE patches SET version = lower(hex(randomblob(16))) WHERE version = ''",
);
let sqlite = openDb();
export let db = new Kysely<Database>({
dialect: new BunSqliteDialect({ database: sqlite }),
});
function runMigrations(s: InstanceType<typeof BunDatabase>) {
const ciRunCols = s
.query<{ name: string }, []>("PRAGMA table_info(ci_runs)")
.all();
if (!ciRunCols.some((c) => c.name === "repo_run_id")) {
s.run("ALTER TABLE ci_runs ADD COLUMN repo_run_id INTEGER");
}
}
// NOTE: runMigrations() alters ci_runs, so it must run *after* the
// `CREATE TABLE IF NOT EXISTS ci_runs` block below — otherwise upgrading a
// database created before the CI tables existed would ALTER a missing table
// and throw at import. The call is intentionally placed at the end of the
// migration section, not here.
/** Close the current DB and reopen from disk (used by tests after data wipe). */
export function resetDb() {
try {
sqlite.close();
} catch {}
sqlite = new BunDatabase(paths.DB_PATH);
sqlite.run("PRAGMA journal_mode=WAL");
sqlite.run("PRAGMA foreign_keys=ON");
runMigrations(sqlite);
sqlite = openDb();
db = new Kysely<Database>({
dialect: new BunSqliteDialect({ database: sqlite }),
});
}
// Migration: create CI tables if missing
sqlite.run(`CREATE TABLE IF NOT EXISTS ci_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
triggered_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
trigger_source TEXT NOT NULL,
commit_sha TEXT,
commit_branch TEXT,
commit_tag TEXT,
status TEXT NOT NULL DEFAULT 'pending',
variable_overrides TEXT,
started_at TEXT,
finished_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS ci_steps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
exit_code INTEGER,
started_at TEXT,
finished_at TEXT,
log TEXT NOT NULL DEFAULT ''
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS ci_artifacts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL REFERENCES ci_runs(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
size INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS ci_secrets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
value TEXT NOT NULL,
description TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_id, name)
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS ci_run_counters (
repo_id INTEGER PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
last_run_id INTEGER NOT NULL DEFAULT 0
)`);
// Run column-level migrations now that the CI tables are guaranteed to exist
// (see the note above runMigrations).
runMigrations(sqlite);
// Migration: add allow_user_labels column to repositories if missing
const repoCols = sqlite
.query<{ name: string }, []>("PRAGMA table_info(repositories)")
.all();
if (!repoCols.some((c) => c.name === "allow_user_labels")) {
sqlite.run(
"ALTER TABLE repositories ADD COLUMN allow_user_labels INTEGER NOT NULL DEFAULT 0",
);
}
// Migration: create labels tables if missing
sqlite.run(`CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#808080',
created_at TEXT NOT NULL,
UNIQUE(repo_id, name)
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS issue_labels (
issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (issue_id, label_id)
)`);
sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
patch_id INTEGER NOT NULL REFERENCES patches(id) ON DELETE CASCADE,
label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
PRIMARY KEY (patch_id, label_id)
)`);
// Indexes for common query patterns (safe to run repeatedly)
sqlite.run("CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id)");
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_issues_author_id ON issues(author_id)",
);
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_patches_repo_id ON patches(repo_id)",
);
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_patches_author_id ON patches(author_id)",
);
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_ci_runs_repo_id ON ci_runs(repo_id)",
);
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)",
);
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_ssh_keys_user_id ON ssh_keys(user_id)",
);
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_issue_labels_label_id ON issue_labels(label_id)",
);
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_patch_labels_label_id ON patch_labels(label_id)",
);
sqlite.run("CREATE INDEX IF NOT EXISTS idx_labels_repo_id ON labels(repo_id)");
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)",
);
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_issues_repo_status ON issues(repo_id, status)",
);
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_patches_repo_status ON patches(repo_id, status)",
);
// Clean up expired sessions on startup
sqlite.run("DELETE FROM sessions WHERE expires_at < datetime('now')");
export async function getRepo(name: string, isAdmin: boolean) {
const repo = await db
.selectFrom("repositories")
▾Msrc/db/schema.sql
@@ -205,3 +205,18 @@ CREATE TABLE IF NOT EXISTS ci_run_counters (
repo_id INTEGER PRIMARY KEY REFERENCES repositories(id) ON DELETE CASCADE,
last_run_id INTEGER NOT NULL DEFAULT 0
);
-- Indexes for common query patterns
CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id);
CREATE INDEX IF NOT EXISTS idx_issues_author_id ON issues(author_id);
CREATE INDEX IF NOT EXISTS idx_issues_repo_status ON issues(repo_id, status);
CREATE INDEX IF NOT EXISTS idx_patches_repo_id ON patches(repo_id);
CREATE INDEX IF NOT EXISTS idx_patches_author_id ON patches(author_id);
CREATE INDEX IF NOT EXISTS idx_patches_repo_status ON patches(repo_id, status);
CREATE INDEX IF NOT EXISTS idx_ci_runs_repo_id ON ci_runs(repo_id);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
CREATE INDEX IF NOT EXISTS idx_ssh_keys_user_id ON ssh_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_labels_repo_id ON labels(repo_id);
CREATE INDEX IF NOT EXISTS idx_issue_labels_label_id ON issue_labels(label_id);
CREATE INDEX IF NOT EXISTS idx_patch_labels_label_id ON patch_labels(label_id);
▾Msrc/lib/rateLimiter.ts
@@ -13,12 +13,9 @@ export type RateLimitKind =
| "reaction"
| "upload"
| "register"
| "repo-create"
| "issue-create"
| "patch-create"
| "label-write"
| "release-write"
| "file-edit";
| "label-write";
const buckets = new Map<string, Bucket>();
@@ -29,17 +26,9 @@ function sweep() {
}
}
// Periodic sweep so memory doesn't grow unboundedly when traffic is low
// and the request-driven sweep never reaches its threshold.
// Periodic sweep so expired buckets do not accumulate.
setInterval(sweep, 60 * 1000).unref();
let sweepCounter = 0;
function maybeSweep() {
if (++sweepCounter < 1000) return;
sweepCounter = 0;
sweep();
}
export function checkRateLimit(
ip: string | null,
kind: RateLimitKind,
@@ -52,7 +41,6 @@ export function checkRateLimit(
const bucket = buckets.get(key);
if (!bucket || now > bucket.resetAt) {
buckets.set(key, { count: 1, resetAt: now + windowMs });
maybeSweep();
return true;
}
if (bucket.count >= maxRequests) return false;
▾Msrc/middleware/session.ts
@@ -1,5 +1,6 @@
import { ADMIN_USERNAME } from "../constants.ts";
import { db } from "../db";
import { redirect } from "../lib/redirect.ts";
export interface SessionUser {
id: number;
@@ -36,22 +37,11 @@ export async function resolveSession(
}
export function requireAuth(user: SessionUser | null): Response | null {
if (!user) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
return null;
return user ? null : redirect("/login");
}
export function requireAdmin(user: SessionUser | null): Response | null {
if (!user) {
return new Response(null, {
status: 302,
headers: { Location: "/login" },
});
}
if (!user) return redirect("/login");
if (!user.isAdmin) {
return new Response("Forbidden", {
status: 403,
▾Msrc/routes/repos.tsx
@@ -27,11 +27,8 @@ import {
repoPath,
type TreeEntry,
} from "../services/git.ts";
import {
hasBinaryContent,
prepareDiff,
serveFile,
} from "../services/highlightWorker.ts";
import { hasBinaryContent } from "../services/highlight.ts";
import { prepareDiff, serveFile } from "../services/highlightWorker.ts";
import { renderMarkdown } from "../services/markdown.ts";
import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
import { html } from "../views/render.tsx";
@@ -600,7 +597,7 @@ export const repoRoutes = new Elysia()
const filePath = decodeURIComponent(params["*"]);
// Size-gate before reading the blob into memory: holding a
// huge content buffer (and then running shiki/marked over it)
// huge content buffer (and then running shiki/Bun.markdown over it)
// is the cheapest DOS vector against unauthenticated users on
// a public repo.
const size = await git.getFileSize(repo.name, params.ref, filePath);
@@ -934,7 +931,7 @@ export const repoRoutes = new Elysia()
]);
if (!meta) return new Response("Commit not found", { status: 404 });
// Reject oversized diffs after generation but before highlighting.
// Avoids running marked / shiki / DOMPurify over a multi-megabyte
// Avoids running Bun.markdown / shiki / DOMPurify over a multi-megabyte
// diff which would synchronously stall the worker pool.
if (rawDiff.length > config.MAX_RENDER_BYTES) {
return html(
▾Msrc/services/ci.ts
@@ -1,6 +1,5 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import path from "node:path";
import { parse as parseToml } from "smol-toml";
import config from "../config.ts";
import { CI_MAX_LOG_BYTES, paths } from "../constants.ts";
import { db } from "../db/index.ts";
@@ -109,7 +108,7 @@ export function ciQueuePosition(runId: number): number | null {
export function parseCiConfig(tomlStr: string): CiConfig | null {
let raw: Record<string, unknown>;
try {
raw = parseToml(tomlStr) as Record<string, unknown>;
raw = Bun.TOML.parse(tomlStr) as Record<string, unknown>;
} catch {
return null;
}
@@ -1298,16 +1297,3 @@ export async function cancelStaleRuns(): Promise<void> {
export function resetDockerSocket(): void {
resolvedSocket = null;
}
/** Check if CI can connect to the container socket. */
export async function ciAvailable(): Promise<boolean> {
try {
const socket = await getSocket();
const resp = await fetch("http://localhost/v1.47/info", {
unix: socket,
});
return resp.ok;
} catch {
return false;
}
}
▾Msrc/services/diffHighlight.ts
@@ -2,7 +2,7 @@ import path from "node:path";
import type { BundledLanguage } from "shiki";
import config from "../config.ts";
import { git } from "./git.ts";
import { detectLang, getHighlighter } from "./highlight.ts";
import { detectLang, ensureLang, getHighlighter } from "./highlight.ts";
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -265,19 +265,24 @@ export async function highlightFile(
file: ParsedFile,
): Promise<RenderedDiffFile> {
const displayPath = file.newPath || file.oldPath;
const lang = detectLang(path.basename(displayPath));
const totalBytes = file.hunks.reduce(
(sum, h) => sum + h.lines.reduce((s, l) => s + l.content.length, 0),
0,
);
const highlightedHunks =
totalBytes > config.INLINE_MAX_BYTES
? file.hunks.map((hunk) =>
hunk.lines.map((l) => escapeHtmlAndCtrl(l.content)),
)
: await Promise.all(
file.hunks.map((hunk) => highlightHunk(hunk, lang)),
);
const tooBig = totalBytes > config.INLINE_MAX_BYTES;
// Load the grammar once per file, before the per-hunk try/catch that
// would otherwise swallow the "language not loaded" error and emit
// unhighlighted lines. Skipped entirely when we are not highlighting.
const lang = tooBig
? "text"
: await ensureLang(detectLang(path.basename(displayPath)));
const highlightedHunks = tooBig
? file.hunks.map((hunk) =>
hunk.lines.map((l) => escapeHtmlAndCtrl(l.content)),
)
: await Promise.all(
file.hunks.map((hunk) => highlightHunk(hunk, lang)),
);
const hunks: RenderedHunk[] = file.hunks.map((hunk, i) => ({
header: hunk.header,
rows: buildRows(hunk, highlightedHunks[i]!),
▾Msrc/services/git.ts
@@ -104,20 +104,6 @@ async function treeFileMode(
}
}
export async function validateCommit(
repoName: string,
hash: string,
): Promise<boolean> {
const p = repoPath(repoName);
try {
const out =
await $`git -C ${p} cat-file -t --end-of-options ${hash}`.text();
return out.trim() === "commit";
} catch {
return false;
}
}
export async function archiveRepo(
repoName: string,
ref: string,
@@ -159,10 +145,9 @@ export async function archiveRepo(
if ((await tgz.exited) !== 0)
throw new Error("git archive (tar.gz) failed");
// The .tar.zst is a best-effort bonus format: if zstd is missing the spawn
// throws and we skip it. But if zstd IS present and fails (disk full,
// refusing to overwrite, …) we must not leave a truncated artifact behind
// silently — log it and remove the partial file.
// Compressed in-process via CompressionStream("zstd") rather than piping
// to a `zstd` binary, so the container needs no zstd package. A failure
// here must not leave a truncated artifact behind — log it and unlink.
const zstPath = path.join(outDir, `${base}.tar.zst`);
try {
const tar = Bun.spawn(
@@ -177,20 +162,30 @@ export async function archiveRepo(
],
{ signal, env: gitEnv, stdout: "pipe" },
);
const zst = Bun.spawn(["zstd", "-f", "-o", zstPath], {
signal,
env: gitEnv,
stdin: tar.stdout,
});
const [tarCode, zstCode] = await Promise.all([tar.exited, zst.exited]);
if (tarCode !== 0 || zstCode !== 0) {
console.error(
`[git] archive (tar.zst) failed for ${repoName}@${ref}: tar=${tarCode} zstd=${zstCode}`,
);
await $`rm -f ${zstPath}`.quiet().nothrow();
// Streamed through a FileSink rather than buffered: a source archive
// can be hundreds of megabytes. (Bun.write() with a Response wrapping
// the compressed stream hangs, so do not "simplify" this to that.)
const compressed = tar.stdout.pipeThrough(
new CompressionStream("zstd"),
) as unknown as AsyncIterable<Uint8Array>;
const sink = Bun.file(zstPath).writer();
// Inner finally, so the fd is closed on a mid-stream write error or an
// abort. It must nest inside the catch rather than sit beside it: a
// trailing `finally` would run after the cleanup below and flush the
// sink back onto the file that was just unlinked.
try {
for await (const chunk of compressed) sink.write(chunk);
} finally {
await sink.end();
}
} catch {
// zstd not available — skip silently
const tarCode = await tar.exited;
if (tarCode !== 0) throw new Error(`git archive exited ${tarCode}`);
} catch (err) {
console.error(
`[git] archive (tar.zst) failed for ${repoName}@${ref}:`,
err,
);
await $`rm -f ${zstPath}`.quiet().nothrow();
}
}
▾Msrc/services/highlight.ts
@@ -2,7 +2,7 @@ import { fileTypeFromBuffer } from "file-type";
import type { Language } from "linguist-languages";
import * as linguistLangs from "linguist-languages";
import {
bundledLanguages,
type BundledLanguage,
bundledLanguagesInfo,
createHighlighter,
type Highlighter,
@@ -56,9 +56,12 @@ export async function highlightStartup(): Promise<void> {
extToLangId = extMap;
filenameToLangId = fnMap;
// Grammars are loaded on first use by ensureLang(). Preloading all ~350
// bundled languages costs ~4s and ~265MB per worker, and a given repo
// only ever touches a handful.
highlighter = await createHighlighter({
themes: ["github-light", "github-dark"],
langs: Object.keys(bundledLanguages),
langs: [],
});
console.log(
@@ -71,6 +74,27 @@ export function getHighlighter(): Highlighter {
return highlighter;
}
/**
* Load `lang`'s grammar if it is not loaded yet, and return the language to
* actually highlight with — falling back to "text" if the grammar is missing
* or fails to load.
*
* Callers MUST await this before codeToHtml/codeToTokensWithThemes. Those
* throw on an unloaded language, and both call sites catch that and degrade
* to unhighlighted plain text — so skipping this step turns highlighting off
* silently rather than loudly.
*/
export async function ensureLang(lang: string): Promise<string> {
const h = getHighlighter();
if (h.getLoadedLanguages().includes(lang)) return lang;
try {
await h.loadLanguage(lang as BundledLanguage);
return lang;
} catch {
return "text";
}
}
export function hasBinaryContent(buf: Buffer): boolean {
return buf.subarray(0, BINARY_DETECT_BYTES).includes(0);
}
@@ -175,10 +199,12 @@ export async function serveFile(
}
const text = content.toString("utf-8");
const lineCount = text.split("\n").length;
// Outside the try: a load failure must fall back to "text", not to the
// unhighlighted plainTextToTable path below.
const lang = await ensureLang(detectLang(filename));
let view: FileView;
try {
const h = getHighlighter();
const lang = detectLang(filename);
const shikiHtml = h.codeToHtml(text, {
lang,
themes: { light: "github-light", dark: "github-dark" },
▾Msrc/services/highlightWorker.ts
@@ -3,9 +3,6 @@ import { MAX_DIFF_CACHE } from "../constants.ts";
import type { ParsedFile, RenderedDiffFile } from "./diffHighlight.ts";
import { parseDiff } from "./diffHighlight.ts";
import type { FileView } from "./highlight.ts";
import { hasBinaryContent } from "./highlight.ts";
export { hasBinaryContent };
// ─── Worker pool ──────────────────────────────────────────────────────────────
@@ -58,12 +55,10 @@ function onMessage(handle: WorkerHandle, event: MessageEvent) {
else p.resolve(result);
}
const pool: WorkerHandle[] = Array.from(
{ length: config.HIGHLIGHT_WORKERS },
createHandle,
);
let pool: WorkerHandle[] | null = null;
function leastBusy(): WorkerHandle {
pool ??= Array.from({ length: config.HIGHLIGHT_WORKERS }, createHandle);
return pool.reduce((a, b) => (a.pending <= b.pending ? a : b));
}
▾Msrc/services/markdown.ts
@@ -1,12 +1,9 @@
import DOMPurify from "isomorphic-dompurify";
import { Marked, marked, type Tokens } from "marked";
import {
MAX_MD_CACHE,
PREVIEW_MAX_LENGTH,
PREVIEW_TRUNCATION_THRESHOLD,
} from "../constants.ts";
import { MAX_MD_CACHE } from "../constants.ts";
marked.setOptions({ gfm: true });
// GFM parity with the previous `marked` configuration: tables, strikethrough
// and task lists are on by default in Bun.markdown; autolinks are not.
const MD_OPTIONS: Bun.markdown.Options = { autolinks: true };
const mdCache = new Map<string, string>();
@@ -35,28 +32,30 @@ export function resolveMarkdownHref(dir: string, href: string): string | null {
return new URL(href, base).pathname.slice(1); // strip leading /
}
function makeContextualMarked(ctx: MarkdownContext): Marked {
const m = new Marked({ gfm: true });
m.use({
renderer: {
image({ href, title, text }) {
const resolved = resolveMarkdownHref(ctx.dir, href);
if (resolved !== null) {
href = `/${ctx.repo}/raw/${ctx.ref}/${resolved}`;
}
return `<img src="${href}" alt="${text}"${title ? ` title="${title}"` : ""}>`;
},
link({ href, title, tokens }) {
const resolved = resolveMarkdownHref(ctx.dir, href);
if (resolved !== null) {
href = `/${ctx.repo}/blob/${ctx.ref}/${resolved}`;
}
const text = String(this.parser.parseInline(tokens));
return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
},
/**
* Point relative links and images at the repo's blob/raw endpoints.
*
* Done as a post-pass over the rendered HTML rather than inside the parser:
* Bun.markdown has no per-element renderer override for `html()` output, and
* HTMLRewriter only touches the two attributes we care about.
*/
function rewriteRepoUrls(html: string, ctx: MarkdownContext): string {
const rewrite = (attr: "href" | "src", route: "blob" | "raw") => ({
element(el: HTMLRewriterTypes.Element) {
const value = el.getAttribute(attr);
if (value === null) return;
const resolved = resolveMarkdownHref(ctx.dir, value);
if (resolved === null) return;
el.setAttribute(
attr,
`/${ctx.repo}/${route}/${ctx.ref}/${resolved}`,
);
},
});
return m;
return new HTMLRewriter()
.on("a[href]", rewrite("href", "blob"))
.on("img[src]", rewrite("src", "raw"))
.transform(html);
}
export function renderMarkdown(
@@ -68,9 +67,8 @@ export function renderMarkdown(
const cached = mdCache.get(cacheKey);
if (cached) return cached;
}
const raw = ctx
? (makeContextualMarked(ctx).parse(md) as string)
: (marked(md) as string);
let raw = Bun.markdown.html(md, MD_OPTIONS);
if (ctx) raw = rewriteRepoUrls(raw, ctx);
const result = DOMPurify.sanitize(raw, {
ADD_TAGS: ["details", "summary"],
ADD_ATTR: ["class"],
@@ -83,120 +81,64 @@ export function renderMarkdown(
return result;
}
const _plaintextMarked = new Marked({ gfm: true });
_plaintextMarked.use({
renderer: {
// Block renderers
heading({ tokens }) {
return `${String(this.parser.parseInline(tokens))}\n\n`;
},
paragraph({ tokens }) {
return `${String(this.parser.parseInline(tokens))}\n\n`;
},
blockquote({ tokens }) {
return `${String(this.parser.parse(tokens))
// Sentinels used to hand structure from a child callback up to its parent,
// which is the only place that knows the numbering (list) or the separator
// (table row). Both are stripped before the result is returned.
const ITEM = "\u0000";
const CELL = "\u0001";
/** Convert markdown to plaintext, preserving structure (list prefixes, headings, etc.) */
export function markdownToPlaintext(md: string): string {
const text = Bun.markdown.render(md, {
heading: (c) => `${c}\n\n`,
paragraph: (c) => `${c}\n\n`,
code: (c) => `${c}\n\n`,
blockquote: (c) =>
`${c
.trim()
.split("\n")
.map((l) => `> ${l}`)
.join("\n")}\n\n`;
},
code({ text }) {
return `${text}\n\n`;
},
list(token) {
const start = typeof token.start === "number" ? token.start : 1;
let body = "";
for (let i = 0; i < token.items.length; i++) {
const item = token.items[i]!;
const prefix = token.ordered ? `${start + i}. ` : "- ";
const checkedPrefix = item.task
? item.checked
? "[x] "
: "[ ] "
: "";
const content = String(this.parser.parse(item.tokens))
.trim()
.replace(/\n+/g, " ");
body += `${prefix + checkedPrefix + content}\n`;
}
.join("\n")}\n\n`,
listItem: (c, meta) => {
const task =
meta?.checked === undefined
? ""
: meta.checked
? "[x] "
: "[ ] ";
return `${ITEM}${task}${c.trim().replace(/\n+/g, " ")}\n`;
},
list: (c, meta) => {
const start = meta.start ?? 1;
const items = c.split(ITEM).slice(1);
const body = items
.map(
(item, i) =>
(meta.ordered ? `${start + i}. ` : "- ") + item,
)
.join("");
return `${body}\n`;
},
listitem() {
// Handled entirely inside list()
return "";
},
table(token: Tokens.Table) {
const cells = (row: Tokens.TableCell[]) =>
row
.map((c) => String(this.parser.parseInline(c.tokens)))
.join(" | ");
let out = `${cells(token.header)}\n`;
for (const row of token.rows) {
out += `${cells(row)}\n`;
}
return `${out}\n`;
},
hr() {
return "---\n\n";
},
html() {
return "";
},
// Inline renderers
strong({ tokens }) {
return String(this.parser.parseInline(tokens));
},
em({ tokens }) {
return String(this.parser.parseInline(tokens));
},
del({ tokens }) {
return String(this.parser.parseInline(tokens));
},
codespan({ text }) {
return `\`${text}\``;
},
link({ tokens }) {
return String(this.parser.parseInline(tokens));
},
image({ text, title }) {
const label = (title || text || "").trim();
th: (c) => `${c}${CELL}`,
td: (c) => `${c}${CELL}`,
tr: (c) => `${c.split(CELL).slice(0, -1).join(" | ")}\n`,
table: (c) => `${c}\n`,
hr: () => "---\n\n",
html: () => "",
link: (c) => c,
image: (c, meta) => {
const label = (meta.title || c || "").trim();
return label ? `[Image: ${label}]` : "[Image]";
},
br() {
return "\n";
},
text(token) {
if ("tokens" in token && token.tokens) {
return String(this.parser.parseInline(token.tokens));
}
return token.text;
},
},
});
/** Convert markdown to plaintext, preserving structure (list prefixes, headings, etc.) */
export function markdownToPlaintext(md: string): string {
return (_plaintextMarked.parse(md) as string)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/**
* Returns a short single-line preview of a plaintext string:
* the first paragraph/heading line, truncated to maxLen chars.
*/
export function plaintextPreview(
text: string,
maxLen = PREVIEW_MAX_LENGTH,
): string {
const firstBlock = text.split("\n\n")[0]?.trim() ?? "";
const firstLine = firstBlock.split("\n")[0] ?? "";
if (firstLine.length <= maxLen) return firstLine;
const truncated = firstLine.slice(0, maxLen);
const lastSpace = truncated.lastIndexOf(" ");
return `${
lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD
? truncated.slice(0, lastSpace)
: truncated
}…`;
codespan: (c) => `\`${c}\``,
});
return (
text
// The `html` callback only fires for block-level raw HTML. Inline
// spans (`<b>x</b>`) reach the output verbatim, so drop tag-shaped
// runs here — this string is escaped and shown as a preview.
.replace(/<\/?[a-zA-Z][^>]*>/g, "")
.replace(/\n{3,}/g, "\n\n")
.trim()
);
}
▾Msrc/styles/components.css
@@ -1009,6 +1009,13 @@
.markdown-body li {
margin-bottom: var(--space-1);
}
.markdown-body .task-list-item {
list-style: none;
}
.markdown-body .task-list-item-checkbox {
margin-right: var(--space-2);
}
.markdown-body blockquote {
border-left: 3px solid var(--color-border);
padding-left: var(--space-4);
▾Msrc/views/Pagination.tsx
@@ -5,13 +5,11 @@ export interface PaginationInfo {
pageUrlTemplate: string;
}
interface PaginationProps extends PaginationInfo {}
export function Pagination({
page,
totalPages,
pageUrlTemplate,
}: PaginationProps) {
}: PaginationInfo) {
if (totalPages <= 1) return null;
const url = (p: number) => pageUrlTemplate.replace("{page}", String(p));
▾Atests/archive.unit.test.ts
@@ -0,0 +1,94 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdirSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
import config from "../src/config.ts";
const TMP = path.resolve(`./data-test-archive-${process.pid}`);
config.DATA_DIR = TMP;
const { archiveRepo } = await import("../src/services/git.ts");
const REPO = "archivetest";
beforeAll(() => {
rmSync(TMP, { recursive: true, force: true });
mkdirSync(path.join(TMP, "repos"), { recursive: true });
const work = path.join(TMP, "work");
mkdirSync(work, { recursive: true });
const run = (...args: string[]) =>
Bun.spawnSync(args, { cwd: work, stdio: ["ignore", "ignore", "ignore"] });
run("git", "init", "-q", "-b", "main");
run("git", "config", "user.email", "t@example.com");
run("git", "config", "user.name", "Test");
Bun.write(path.join(work, "hello.txt"), "hello archive\n");
run("git", "add", "-A");
run("git", "commit", "-q", "-m", "init");
Bun.spawnSync(
["git", "clone", "-q", "--bare", work, path.join(TMP, "repos", `${REPO}.git`)],
{ stdio: ["ignore", "ignore", "ignore"] },
);
});
afterAll(() => {
rmSync(TMP, { recursive: true, force: true });
});
describe("archiveRepo", () => {
// The .tar.zst used to be produced by piping git into a `zstd` binary.
// It is now compressed in-process, so the container ships without zstd.
test("writes all three source archive formats", async () => {
const out = path.join(TMP, "out");
mkdirSync(out, { recursive: true });
await archiveRepo(REPO, "main", "slug", out);
expect(readdirSync(out).sort()).toEqual([
"slug-main.tar.gz",
"slug-main.tar.zst",
"slug-main.zip",
]);
});
test("the .tar.zst is real zstd and round-trips to the source tar", async () => {
const out = path.join(TMP, "out2");
mkdirSync(out, { recursive: true });
await archiveRepo(REPO, "main", "slug", out);
const compressed = await Bun.file(
path.join(out, "slug-main.tar.zst"),
).bytes();
// Zstandard magic number.
expect(Array.from(compressed.slice(0, 4))).toEqual([0x28, 0xb5, 0x2f, 0xfd]);
const tar = Bun.zstdDecompressSync(compressed);
// tar stores filenames as plain text in each 512-byte header block.
expect(new TextDecoder().decode(tar)).toContain("hello.txt");
expect(tar.length % 512).toBe(0);
});
test("a bad ref fails before writing anything", async () => {
const out = path.join(TMP, "out3");
mkdirSync(out, { recursive: true });
// The zip stage throws first, so this never reaches the zst path —
// that one is covered separately below.
await expect(
archiveRepo(REPO, "no-such-ref", "slug", out),
).rejects.toThrow();
expect(readdirSync(out)).not.toContain("slug-no-such-ref.tar.zst");
});
test("an abort during the zst stage leaves no partial file", async () => {
const out = path.join(TMP, "out4");
mkdirSync(out, { recursive: true });
const ac = new AbortController();
// Abort once the two earlier formats exist, i.e. while the zst stage
// is the one in flight. This is the path the inner finally guards:
// the sink must be closed before the catch unlinks the file.
const done = archiveRepo(REPO, "main", "slug", out, ac.signal);
await Bun.sleep(1);
ac.abort();
await done.catch(() => {});
expect(readdirSync(out)).not.toContain("slug-main.tar.zst");
});
});
▾Atests/config.unit.test.ts
@@ -0,0 +1,65 @@
import { describe, expect, test } from "bun:test";
// config.ts reads process.env once at import time, so these run the same
// parsing logic against a fresh module instance per case.
async function loadConfig(env: Record<string, string>) {
const saved: Record<string, string | undefined> = {};
for (const k of Object.keys(env)) {
saved[k] = process.env[k];
process.env[k] = env[k];
}
try {
// Cache-bust so the module body re-runs against the patched env.
const mod = await import(`../src/config.ts?t=${Math.random()}`);
return mod.default as typeof import("../src/config.ts").default;
} finally {
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
}
}
describe("config env parsing", () => {
test("unset falls back to the default", async () => {
const c = await loadConfig({});
expect(c.MAX_TITLE_BYTES).toBe(500);
expect(c.CI_MAX_HISTORY).toBe(50);
});
test("empty string falls back to the default", async () => {
const c = await loadConfig({ MAX_TITLE_BYTES: "" });
expect(c.MAX_TITLE_BYTES).toBe(500);
});
test("garbage falls back to the default", async () => {
const c = await loadConfig({ MAX_TITLE_BYTES: "abc" });
expect(c.MAX_TITLE_BYTES).toBe(500);
});
test("a valid number is used", async () => {
const c = await loadConfig({ MAX_TITLE_BYTES: "120" });
expect(c.MAX_TITLE_BYTES).toBe(120);
});
test("explicit 0 is honoured, not replaced by the default", async () => {
expect((await loadConfig({ MAX_RENDER_BYTES: "0" })).MAX_RENDER_BYTES).toBe(0);
expect((await loadConfig({ INLINE_MAX_BYTES: "0" })).INLINE_MAX_BYTES).toBe(0);
expect((await loadConfig({ CI_MAX_HISTORY: "0" })).CI_MAX_HISTORY).toBe(0);
});
test("BASE_URL default tracks PORT", async () => {
const c = await loadConfig({ PORT: "8080" });
expect(c.PORT).toBe(8080);
expect(c.BASE_URL).toBe("http://localhost:8080");
expect(c.PUBLIC_ORIGIN).toBe("http://localhost:8080");
expect(c.PUBLIC_HTTPS).toBe(false);
});
test("an https BASE_URL sets the derived origin fields", async () => {
const c = await loadConfig({ BASE_URL: "https://forge.example.com" });
expect(c.PUBLIC_HTTPS).toBe(true);
expect(c.PUBLIC_ORIGIN).toBe("https://forge.example.com");
expect(c.COMMITTER_EMAIL).toContain("@forge.example.com");
});
});
▾Atests/db-schema.unit.test.ts
@@ -0,0 +1,122 @@
import { Database } from "bun:sqlite";
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import path from "node:path";
const SCHEMA = readFileSync(
path.resolve(import.meta.dir, "../src/db/schema.sql"),
"utf-8",
);
function open() {
const db = new Database(":memory:");
db.run("PRAGMA foreign_keys=ON");
return db;
}
function tables(db: Database): string[] {
return db
.query<{ name: string }, []>(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
)
.all()
.map((r) => r.name);
}
function columns(db: Database, table: string): string[] {
return db
.query<{ name: string }, []>(`PRAGMA table_info(${table})`)
.all()
.map((r) => r.name)
.sort();
}
describe("schema.sql", () => {
test("creates every table the Kysely Database interface declares", () => {
const db = open();
db.run(SCHEMA);
expect(tables(db)).toEqual([
"ci_artifacts",
"ci_run_counters",
"ci_runs",
"ci_secrets",
"ci_steps",
"issue_comments",
"issue_labels",
"issue_reactions",
"issues",
"labels",
"passkeys",
"patch_comments",
"patch_labels",
"patch_reactions",
"patches",
"release_assets",
"releases",
"repositories",
"sessions",
"ssh_keys",
"users",
]);
db.close();
});
test("includes the columns that used to be added by ALTER migrations", () => {
const db = open();
db.run(SCHEMA);
expect(columns(db, "users")).toContain("is_pending");
expect(columns(db, "users")).toContain("register_application");
expect(columns(db, "patches")).toContain("version");
expect(columns(db, "repositories")).toContain("allow_user_labels");
expect(columns(db, "ci_runs")).toContain("repo_run_id");
db.close();
});
test("creates the query indexes", () => {
const db = open();
db.run(SCHEMA);
const idx = db
.query<{ name: string }, []>(
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'",
)
.all()
.map((r) => r.name);
expect(idx).toContain("idx_issues_repo_status");
expect(idx).toContain("idx_patches_repo_status");
expect(idx).toContain("idx_sessions_expires_at");
expect(idx).toContain("idx_ci_runs_repo_id");
db.close();
});
test("is idempotent — re-running changes nothing", () => {
const db = open();
db.run(SCHEMA);
const before = tables(db);
const userCols = columns(db, "users");
db.run(SCHEMA);
db.run(SCHEMA);
expect(tables(db)).toEqual(before);
expect(columns(db, "users")).toEqual(userCols);
db.close();
});
test("applies cleanly over a pre-CI database, as an upgrade would", () => {
const db = open();
// Only the oldest core tables, mimicking a database created before
// the CI and label features existed.
db.run(`CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT,
created_at TEXT NOT NULL,
avatar_version INTEGER NOT NULL DEFAULT 0
)`);
db.run(SCHEMA);
expect(tables(db)).toContain("ci_runs");
expect(tables(db)).toContain("labels");
// The pre-existing table keeps its old shape; index.ts ALTERs the
// missing columns in.
expect(columns(db, "users")).not.toContain("is_pending");
db.close();
});
});
▾Mtests/helpers.ts
@@ -7,11 +7,24 @@ import config from '../src/config.ts';
import { createApp } from '../src/app.ts';
import { resetDb } from '../src/db/index.ts';
export const DATA_DIR = './data-test';
// Set by tests/preload.ts before any module reads it — see the comment
// there for why it cannot be assigned from this file.
export const DATA_DIR = process.env.DATA_DIR ?? './data-test';
export const ADMIN_PASS = 'testpass123';
// Override config for tests — this runs at import time, before any app code
// reads these values, so no preload script is needed.
// If the preload did not run, DATA_DIR is still ./data and src/db/index.ts has
// already migrated the real database in its module body. Stop before any test
// also authenticates against the live admin row. Checked here rather than in
// the preload, which by definition cannot catch its own absence.
if (path.resolve(DATA_DIR) === path.resolve('./data')) {
throw new Error(
'Tests are pointed at the production DATA_DIR. tests/preload.ts did not run — ' +
'check that bunfig.toml is present and that bun was started from the repo root.',
);
}
// DATA_DIR is already in place via the preload; these two only affect code
// that reads them per request, so assigning here is soon enough.
config.DATA_DIR = path.resolve(DATA_DIR);
config.RATE_LIMIT_DISABLED = true;
config.SSH_DISABLED = true;
▾Mtests/highlight.test.ts
@@ -127,3 +127,77 @@ describe("diff control character rendering", () => {
expect(html).not.toContain("diff-ctrl");
});
});
describe("lazy grammar loading", () => {
// codeToHtml emits `color:...;--shiki-dark:...`, codeToTokensWithThemes
// emits `--shiki-light:...;--shiki-dark:...`. Both carry --shiki-dark.
const shikiSpan = /<span style="[^"]*--shiki-dark:#/;
test("startup preloads no grammars", async () => {
// A fresh highlighter carries only Shiki's built-in special langs.
const { getHighlighter } = await import("../src/services/highlight.ts");
expect(getHighlighter().getLoadedLanguages().length).toBeLessThan(20);
});
test("ensureLang loads a real grammar and reports it", async () => {
const { ensureLang, getHighlighter } = await import(
"../src/services/highlight.ts"
);
expect(await ensureLang("rust")).toBe("rust");
expect(getHighlighter().getLoadedLanguages()).toContain("rust");
});
test("ensureLang falls back to text for a language outside the bundle", async () => {
const { ensureLang } = await import("../src/services/highlight.ts");
expect(await ensureLang("definitely-not-a-language")).toBe("text");
});
test("ensureLang is idempotent", async () => {
const { ensureLang } = await import("../src/services/highlight.ts");
expect(await ensureLang("python")).toBe("python");
expect(await ensureLang("python")).toBe("python");
});
test("serveFile actually highlights, for a grammar loaded on demand", async () => {
const { serveFile } = await import("../src/services/highlight.ts");
const view = await serveFile(
Buffer.from("def greet():\n return 42\n"),
"sample.py",
"",
);
expect(view.type).toBe("inline");
if (view.type !== "inline") throw new Error("expected inline");
// Regression guard: without ensureLang this silently degrades to the
// plain-text table, which has no per-token colour spans.
expect(view.html).toMatch(shikiSpan);
});
test("serveFile still renders an unknown extension as plain text", async () => {
const { serveFile } = await import("../src/services/highlight.ts");
const view = await serveFile(
Buffer.from("just some words\n"),
"notes.xyz",
"",
);
expect(view.type).toBe("inline");
if (view.type !== "inline") throw new Error("expected inline");
expect(view.html).toContain("just some words");
});
test("highlightFile colours diff hunks for an on-demand grammar", async () => {
const diff = [
"diff --git a/x.go b/x.go",
"index 111..222 100644",
"--- a/x.go",
"+++ b/x.go",
"@@ -1,2 +1,2 @@",
" package main",
"-const a = 1",
"+const a = 2",
].join("\n");
const [file] = await parseDiff(diff);
const rendered = await highlightFile(file!);
const html = rendered.hunks.flatMap((h) => h.rows.map((r) => r.html)).join("");
expect(html).toMatch(shikiSpan);
});
});
▾Mtests/markdown.test.ts
@@ -1,5 +1,9 @@
import { describe, expect, test } from "bun:test";
import { resolveMarkdownHref } from "../src/services/markdown.ts";
import {
markdownToPlaintext,
renderMarkdown,
resolveMarkdownHref,
} from "../src/services/markdown.ts";
describe("resolveMarkdownHref", () => {
describe("protocol-absolute URLs (returns null)", () => {
@@ -112,3 +116,115 @@ describe("resolveMarkdownHref", () => {
});
});
});
describe("renderMarkdown", () => {
test("GFM table with column alignment", () => {
expect(renderMarkdown("| a | b |\n|---|:-:|\n| 1 | 2 |")).toBe(
'<table>\n<thead>\n<tr><th>a</th><th align="center">b</th></tr>\n</thead>\n' +
"<tbody>\n<tr><td>1</td><td align=\"center\">2</td></tr>\n</tbody>\n</table>\n",
);
});
test("GFM strikethrough", () => {
expect(renderMarkdown("~~gone~~")).toBe("<p><del>gone</del></p>\n");
});
test("GFM task list keeps the checkbox state through the sanitizer", () => {
const html = renderMarkdown("- [x] done\n- [ ] todo");
expect(html).toContain('<input type="checkbox"');
expect(html).toContain("checked");
expect(html).toContain("disabled");
// The CSS hooks these class names for spacing.
expect(html).toContain('class="task-list-item"');
expect(html).toContain("task-list-item-checkbox");
});
test("bare URLs are autolinked", () => {
expect(renderMarkdown("see https://example.com ok")).toBe(
'<p>see <a href="https://example.com">https://example.com</a> ok</p>\n',
);
});
test("fenced code keeps its language class", () => {
expect(renderMarkdown("```js\nlet x=1;\n```")).toBe(
'<pre><code class="language-js">let x=1;\n</code></pre>\n',
);
});
test("details/summary survive sanitization", () => {
expect(renderMarkdown("<details><summary>s</summary>body</details>")).toBe(
"<details><summary>s</summary>body</details>\n",
);
});
test("script tags and event handlers are stripped", () => {
expect(renderMarkdown("<script>alert(1)</script><img src=x onerror=alert(1)>")).toBe(
'<img src="x">\n',
);
});
test("javascript: links are stripped", () => {
expect(renderMarkdown("[x](javascript:alert(1))")).not.toContain("javascript:");
});
test("repo context rewrites relative links and images only", () => {
const ctx = { repo: "myrepo", ref: "main", dir: "docs" };
expect(
renderMarkdown(
"[r](./a.md) [abs](/b.md) [ext](https://e.com) [an](#s) ![i](./x.png)",
undefined,
ctx,
),
).toBe(
'<p><a href="/myrepo/blob/main/docs/a.md">r</a>' +
' <a href="/myrepo/blob/main/b.md">abs</a>' +
' <a href="https://e.com">ext</a>' +
' <a href="#s">an</a>' +
' <img src="/myrepo/raw/main/docs/x.png" alt="i"></p>\n',
);
});
test("without repo context relative URLs are left alone", () => {
expect(renderMarkdown("[r](./a.md)")).toBe('<p><a href="./a.md">r</a></p>\n');
});
});
describe("markdownToPlaintext", () => {
test("unordered list", () => {
expect(markdownToPlaintext("- a\n- b")).toBe("- a\n- b");
});
test("ordered list keeps its start number", () => {
expect(markdownToPlaintext("3. a\n4. b")).toBe("3. a\n4. b");
});
test("task list renders markers, not raw checkbox HTML", () => {
expect(markdownToPlaintext("- [x] done\n- [ ] todo\n- plain")).toBe(
"- [x] done\n- [ ] todo\n- plain",
);
});
test("table cells are pipe-joined", () => {
expect(markdownToPlaintext("| a | b |\n|---|---|\n| 1 | 2 |")).toBe("a | b\n1 | 2");
});
test("blockquote keeps its marker", () => {
expect(markdownToPlaintext("> one\n> two")).toBe("> one\n> two");
});
test("inline markup is unwrapped, images become a label", () => {
expect(
markdownToPlaintext(
"# Title\n\nSome **bold** [link](https://e.com) and `code`.\n\n---\n\n![alt](x.png)",
),
).toBe("Title\n\nSome bold link and `code`.\n\n---\n\n[Image: alt]");
});
test("block and inline raw HTML are stripped", () => {
expect(markdownToPlaintext("a <b>b</b> c\n\n<div>d</div>")).toBe("a b c");
});
test("empty input", () => {
expect(markdownToPlaintext("")).toBe("");
});
});
▾Atests/preload.ts
@@ -0,0 +1,16 @@
// Runs before any test module is evaluated (see the --preload flag in the
// "test" script).
//
// src/db/index.ts opens the database and runs migrations in its module body,
// and src/config.ts reads DATA_DIR in its own. Both happen while imports are
// still being hoisted, i.e. before any statement in a test file or in
// helpers.ts can assign to config. Pointing DATA_DIR at a per-worker path
// here is the only way to get in front of that — otherwise every parallel
// worker opens the real ./data/hearthforge.db and they contend for the write
// lock ("SQLiteError: database is locked").
const worker = process.env.BUN_TEST_WORKER_ID;
process.env.DATA_DIR = worker ? `./data-test-${worker}` : "./data-test";
// One highlight worker per test process is plenty, and the pool is only
// built if a test actually highlights something.
process.env.HIGHLIGHT_WORKERS ??= "1";
▾Atests/rateLimiter.unit.test.ts
@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test";
import config from "../src/config.ts";
import { checkRateLimit, getClientIp } from "../src/lib/rateLimiter.ts";
describe("checkRateLimit", () => {
test("allows up to the limit, then blocks", () => {
const ip = `1.1.1.${Math.random()}`;
for (let i = 0; i < 3; i++) {
expect(checkRateLimit(ip, "login", 3, 60_000)).toBe(true);
}
expect(checkRateLimit(ip, "login", 3, 60_000)).toBe(false);
});
test("buckets are per kind", () => {
const ip = `2.2.2.${Math.random()}`;
expect(checkRateLimit(ip, "login", 1, 60_000)).toBe(true);
expect(checkRateLimit(ip, "login", 1, 60_000)).toBe(false);
expect(checkRateLimit(ip, "comment", 1, 60_000)).toBe(true);
});
test("buckets are per key", () => {
const kind = "reaction" as const;
expect(checkRateLimit("3.3.3.1", kind, 1, 60_000)).toBe(true);
expect(checkRateLimit("3.3.3.1", kind, 1, 60_000)).toBe(false);
expect(checkRateLimit("3.3.3.2", kind, 1, 60_000)).toBe(true);
});
test("the window resets", async () => {
const ip = `4.4.4.${Math.random()}`;
expect(checkRateLimit(ip, "upload", 1, 20)).toBe(true);
expect(checkRateLimit(ip, "upload", 1, 20)).toBe(false);
await Bun.sleep(40);
expect(checkRateLimit(ip, "upload", 1, 20)).toBe(true);
});
test("counting stays correct past the old 1000-call sweep threshold", () => {
const ip = `5.5.5.${Math.random()}`;
for (let i = 0; i < 1200; i++) {
checkRateLimit(`${ip}-${i}`, "register", 5, 60_000);
}
for (let i = 0; i < 5; i++) {
expect(checkRateLimit(ip, "register", 5, 60_000)).toBe(true);
}
expect(checkRateLimit(ip, "register", 5, 60_000)).toBe(false);
});
test("a null key is always allowed", () => {
expect(checkRateLimit(null, "login", 0, 60_000)).toBe(true);
});
test("RATE_LIMIT_DISABLED short-circuits", () => {
const saved = config.RATE_LIMIT_DISABLED;
config.RATE_LIMIT_DISABLED = true;
try {
const ip = `6.6.6.${Math.random()}`;
for (let i = 0; i < 10; i++) {
expect(checkRateLimit(ip, "login", 1, 60_000)).toBe(true);
}
} finally {
config.RATE_LIMIT_DISABLED = saved;
}
});
});
describe("getClientIp", () => {
test("uses X-Forwarded-For only when TRUSTED_PROXY is set", () => {
const req = new Request("http://x/", {
headers: { "x-forwarded-for": "9.9.9.9, 10.0.0.1" },
});
const saved = config.TRUSTED_PROXY;
try {
config.TRUSTED_PROXY = true;
expect(getClientIp(req, null)).toBe("9.9.9.9");
config.TRUSTED_PROXY = false;
expect(getClientIp(req, null)).toBeNull();
} finally {
config.TRUSTED_PROXY = saved;
}
});
});