CSP, various small improvements
Apublic/assets/app.js
| @@ -0,0 +1,25 @@ | |||
|---|---|---|---|
| 1 | + | // Auto-submit selects marked with data-autosubmit | |
| 2 | + | document.querySelectorAll("[data-autosubmit]").forEach((el) => { | |
| 3 | + | el.addEventListener("change", () => el.closest("form").submit()); | |
| 4 | + | }); | |
| 5 | + | ||
| 6 | + | // Confirm dialogs: <button data-confirm="Are you sure?"> | |
| 7 | + | document.querySelectorAll("[data-confirm]").forEach((el) => { | |
| 8 | + | el.addEventListener("click", (e) => { | |
| 9 | + | if (!confirm(el.dataset.confirm)) e.preventDefault(); | |
| 10 | + | }); | |
| 11 | + | }); | |
| 12 | + | ||
| 13 | + | // Paste-to-upload: paste an image anywhere on the settings page to fill the avatar input | |
| 14 | + | document.addEventListener("paste", (e) => { | |
| 15 | + | const input = document.querySelector('input[name="avatar"]'); | |
| 16 | + | if (!input) return; | |
| 17 | + | const file = [...(e.clipboardData?.items ?? [])] | |
| 18 | + | .find((i) => i.kind === "file" && i.type.startsWith("image/")) | |
| 19 | + | ?.getAsFile(); | |
| 20 | + | if (!file) return; | |
| 21 | + | const dt = new DataTransfer(); | |
| 22 | + | dt.items.add(file); | |
| 23 | + | input.files = dt.files; | |
| 24 | + | input.closest("form").submit(); | |
| 25 | + | }); | |
Apublic/assets/passkey-login.js
| @@ -0,0 +1,50 @@ | |||
|---|---|---|---|
| 1 | + | import { startAuthentication } from "/assets/simplewebauthn-browser.js"; | |
| 2 | + | ||
| 3 | + | const section = document.getElementById("passkey-section"); | |
| 4 | + | const btn = document.getElementById("passkey-btn"); | |
| 5 | + | const errEl = document.getElementById("passkey-error"); | |
| 6 | + | ||
| 7 | + | if (section) section.style.display = "block"; | |
| 8 | + | ||
| 9 | + | function showError(msg) { | |
| 10 | + | if (!errEl) return; | |
| 11 | + | errEl.textContent = msg; | |
| 12 | + | errEl.className = msg ? "form-error" : ""; | |
| 13 | + | errEl.style.display = msg ? "" : "none"; | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | if (btn) { | |
| 17 | + | const originalText = btn.textContent; | |
| 18 | + | btn.addEventListener("click", async () => { | |
| 19 | + | btn.disabled = true; | |
| 20 | + | btn.textContent = "Waiting for authenticator…"; | |
| 21 | + | showError(""); | |
| 22 | + | try { | |
| 23 | + | const optsResp = await fetch("/auth/passkey/login/options", { | |
| 24 | + | method: "POST", | |
| 25 | + | }); | |
| 26 | + | if (!optsResp.ok) throw new Error("Failed to get options"); | |
| 27 | + | const opts = await optsResp.json(); | |
| 28 | + | const result = await startAuthentication({ optionsJSON: opts }); | |
| 29 | + | const verResp = await fetch("/auth/passkey/login/verify", { | |
| 30 | + | method: "POST", | |
| 31 | + | headers: { "Content-Type": "application/json" }, | |
| 32 | + | body: JSON.stringify(result), | |
| 33 | + | }); | |
| 34 | + | if (verResp.ok) { | |
| 35 | + | window.location.href = "/"; | |
| 36 | + | } else { | |
| 37 | + | const err = await verResp.json().catch(() => ({})); | |
| 38 | + | showError(err.error ?? "Passkey sign in failed"); | |
| 39 | + | } | |
| 40 | + | } catch (e) { | |
| 41 | + | showError( | |
| 42 | + | "Passkey sign in failed: " + | |
| 43 | + | (e instanceof Error ? e.message : String(e)), | |
| 44 | + | ); | |
| 45 | + | } finally { | |
| 46 | + | btn.disabled = false; | |
| 47 | + | btn.textContent = originalText; | |
| 48 | + | } | |
| 49 | + | }); | |
| 50 | + | } | |
Apublic/assets/passkey-register.js
| @@ -0,0 +1,97 @@ | |||
|---|---|---|---|
| 1 | + | import { startRegistration } from "/assets/simplewebauthn-browser.js"; | |
| 2 | + | ||
| 3 | + | const usernameInput = document.getElementById("username"); | |
| 4 | + | const applicationInput = document.getElementById("application"); | |
| 5 | + | const section = document.getElementById("passkey-section"); | |
| 6 | + | const btn = document.getElementById("passkey-register-btn"); | |
| 7 | + | const errEl = document.getElementById("passkey-error"); | |
| 8 | + | ||
| 9 | + | if (section) section.style.display = "block"; | |
| 10 | + | ||
| 11 | + | function showError(msg) { | |
| 12 | + | if (!errEl) return; | |
| 13 | + | errEl.textContent = msg; | |
| 14 | + | errEl.className = msg ? "form-error" : ""; | |
| 15 | + | errEl.style.display = msg ? "" : "none"; | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | if (btn) { | |
| 19 | + | const originalText = btn.textContent; | |
| 20 | + | btn.addEventListener("click", async () => { | |
| 21 | + | const username = usernameInput?.value.trim() ?? ""; | |
| 22 | + | if (!username) { | |
| 23 | + | usernameInput?.focus(); | |
| 24 | + | return; | |
| 25 | + | } | |
| 26 | + | if (!/^[a-zA-Z0-9_-]+$/.test(username)) { | |
| 27 | + | showError( | |
| 28 | + | "Username may only contain letters, numbers, hyphens, and underscores", | |
| 29 | + | ); | |
| 30 | + | return; | |
| 31 | + | } | |
| 32 | + | if (applicationInput && !applicationInput.value.trim()) { | |
| 33 | + | applicationInput.focus(); | |
| 34 | + | return; | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | btn.disabled = true; | |
| 38 | + | btn.textContent = "Creating account…"; | |
| 39 | + | showError(""); | |
| 40 | + | try { | |
| 41 | + | const createResp = await fetch("/auth/passkey/create-user", { | |
| 42 | + | method: "POST", | |
| 43 | + | headers: { "Content-Type": "application/json" }, | |
| 44 | + | body: JSON.stringify({ | |
| 45 | + | username, | |
| 46 | + | application: applicationInput | |
| 47 | + | ? applicationInput.value.trim() | |
| 48 | + | : undefined, | |
| 49 | + | }), | |
| 50 | + | }); | |
| 51 | + | if (!createResp.ok) { | |
| 52 | + | const err = await createResp.json().catch(() => ({})); | |
| 53 | + | showError(err.error ?? "Failed to create account"); | |
| 54 | + | return; | |
| 55 | + | } | |
| 56 | + | const data = await createResp.json(); | |
| 57 | + | if (data.pending) { | |
| 58 | + | const container = document.querySelector(".auth-container"); | |
| 59 | + | if (container) { | |
| 60 | + | container.innerHTML = | |
| 61 | + | '<h1 class="page-title">Create account</h1>' + | |
| 62 | + | '<p class="form-success">Your account has been submitted for review. You will be able to log in once an admin approves it.</p>' + | |
| 63 | + | '<p class="auth-footer">Already have an account? <a href="/login">Sign in</a></p>'; | |
| 64 | + | } | |
| 65 | + | return; | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | btn.textContent = "Waiting for authenticator…"; | |
| 69 | + | const optsResp = await fetch("/auth/passkey/register/options", { | |
| 70 | + | method: "POST", | |
| 71 | + | }); | |
| 72 | + | if (!optsResp.ok) | |
| 73 | + | throw new Error("Failed to get registration options"); | |
| 74 | + | const opts = await optsResp.json(); | |
| 75 | + | const result = await startRegistration({ optionsJSON: opts }); | |
| 76 | + | const verResp = await fetch("/auth/passkey/register/verify", { | |
| 77 | + | method: "POST", | |
| 78 | + | headers: { "Content-Type": "application/json" }, | |
| 79 | + | body: JSON.stringify(result), | |
| 80 | + | }); | |
| 81 | + | if (verResp.ok) { | |
| 82 | + | window.location.href = "/"; | |
| 83 | + | } else { | |
| 84 | + | const err = await verResp.json().catch(() => ({})); | |
| 85 | + | showError(err.error ?? "Passkey registration failed"); | |
| 86 | + | } | |
| 87 | + | } catch (e) { | |
| 88 | + | showError( | |
| 89 | + | "Passkey registration failed: " + | |
| 90 | + | (e instanceof Error ? e.message : String(e)), | |
| 91 | + | ); | |
| 92 | + | } finally { | |
| 93 | + | btn.disabled = false; | |
| 94 | + | btn.textContent = originalText; | |
| 95 | + | } | |
| 96 | + | }); | |
| 97 | + | } | |
Apublic/assets/passkey-settings.js
| @@ -0,0 +1,45 @@ | |||
|---|---|---|---|
| 1 | + | import { startRegistration } from "/assets/simplewebauthn-browser.js"; | |
| 2 | + | ||
| 3 | + | const section = document.getElementById("passkey-section"); | |
| 4 | + | const btn = document.getElementById("add-passkey-btn"); | |
| 5 | + | const status = document.getElementById("passkey-status"); | |
| 6 | + | ||
| 7 | + | if (section) section.style.display = "block"; | |
| 8 | + | ||
| 9 | + | if (btn) { | |
| 10 | + | const originalText = btn.textContent; | |
| 11 | + | btn.addEventListener("click", async () => { | |
| 12 | + | btn.disabled = true; | |
| 13 | + | btn.textContent = "Waiting for authenticator…"; | |
| 14 | + | if (status) status.textContent = ""; | |
| 15 | + | try { | |
| 16 | + | const optsResp = await fetch("/auth/passkey/register/options", { | |
| 17 | + | method: "POST", | |
| 18 | + | }); | |
| 19 | + | if (!optsResp.ok) throw new Error("Failed to get options"); | |
| 20 | + | const opts = await optsResp.json(); | |
| 21 | + | const result = await startRegistration({ optionsJSON: opts }); | |
| 22 | + | const verResp = await fetch("/auth/passkey/register/verify", { | |
| 23 | + | method: "POST", | |
| 24 | + | headers: { "Content-Type": "application/json" }, | |
| 25 | + | body: JSON.stringify(result), | |
| 26 | + | }); | |
| 27 | + | if (verResp.ok) { | |
| 28 | + | window.location.reload(); | |
| 29 | + | } else { | |
| 30 | + | const err = await verResp.json().catch(() => ({})); | |
| 31 | + | if (status) | |
| 32 | + | status.textContent = | |
| 33 | + | "Error: " + (err.error ?? "Registration failed"); | |
| 34 | + | } | |
| 35 | + | } catch (e) { | |
| 36 | + | if (status) | |
| 37 | + | status.textContent = | |
| 38 | + | "Error: " + | |
| 39 | + | (e instanceof Error ? e.message : String(e)); | |
| 40 | + | } finally { | |
| 41 | + | btn.disabled = false; | |
| 42 | + | btn.textContent = originalText; | |
| 43 | + | } | |
| 44 | + | }); | |
| 45 | + | } | |
Msrc/app.ts
| @@ -2,6 +2,7 @@ import path from "node:path"; | |||
|---|---|---|---|
| 2 | 2 | import { staticPlugin } from "@elysiajs/static"; | |
| 3 | 3 | import { Elysia } from "elysia"; | |
| 4 | 4 | import config from "./config.ts"; | |
| 5 | + | import { db } from "./db/index.ts"; | |
| 5 | 6 | import { authRoutes } from "./routes/auth.tsx"; | |
| 6 | 7 | import { avatarRoutes } from "./routes/avatars.ts"; | |
| 7 | 8 | import { ciRoutes } from "./routes/ci.tsx"; | |
| @@ -14,9 +15,31 @@ import { settingsRoutes } from "./routes/settings.tsx"; | |||
|---|---|---|---|
| 14 | 15 | import { cancelStaleRuns } from "./services/ci.ts"; | |
| 15 | 16 | import { syncStartup } from "./services/repoSync.ts"; | |
| 16 | 17 | ||
| 18 | + | const CSP = [ | |
| 19 | + | "default-src 'self'", | |
| 20 | + | "script-src 'self'", | |
| 21 | + | "style-src 'self' 'unsafe-inline'", | |
| 22 | + | "img-src 'self'", | |
| 23 | + | "connect-src 'self'", | |
| 24 | + | "frame-ancestors 'none'", | |
| 25 | + | "form-action 'self'", | |
| 26 | + | "object-src 'none'", | |
| 27 | + | ].join("; "); | |
| 28 | + | ||
| 29 | + | async function cleanupSessions() { | |
| 30 | + | await db | |
| 31 | + | .deleteFrom("sessions") | |
| 32 | + | .where("expires_at", "<", new Date().toISOString()) | |
| 33 | + | .execute(); | |
| 34 | + | } | |
| 35 | + | ||
| 17 | 36 | export async function createApp(port: number) { | |
| 18 | 37 | await syncStartup(); | |
| 19 | 38 | await cancelStaleRuns(); | |
| 39 | + | ||
| 40 | + | // Recurring session cleanup — runs every 24 hours | |
| 41 | + | setInterval(cleanupSessions, 24 * 60 * 60 * 1000); | |
| 42 | + | ||
| 20 | 43 | return new Elysia({ | |
| 21 | 44 | serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES }, | |
| 22 | 45 | }) | |
| @@ -26,6 +49,31 @@ export async function createApp(port: number) { | |||
|---|---|---|---|
| 26 | 49 | prefix: "/", | |
| 27 | 50 | }), | |
| 28 | 51 | ) | |
| 52 | + | .onAfterHandle(({ response }) => { | |
| 53 | + | if (response instanceof Response) { | |
| 54 | + | response.headers.set("Content-Security-Policy", CSP); | |
| 55 | + | response.headers.set("X-Frame-Options", "DENY"); | |
| 56 | + | } | |
| 57 | + | }) | |
| 58 | + | .get("/health", async () => { | |
| 59 | + | try { | |
| 60 | + | await db.selectFrom("users").select("id").limit(1).execute(); | |
| 61 | + | return new Response(JSON.stringify({ ok: true }), { | |
| 62 | + | headers: { "Content-Type": "application/json" }, | |
| 63 | + | }); | |
| 64 | + | } catch (e) { | |
| 65 | + | return new Response( | |
| 66 | + | JSON.stringify({ | |
| 67 | + | ok: false, | |
| 68 | + | error: e instanceof Error ? e.message : "DB error", | |
| 69 | + | }), | |
| 70 | + | { | |
| 71 | + | status: 503, | |
| 72 | + | headers: { "Content-Type": "application/json" }, | |
| 73 | + | }, | |
| 74 | + | ); | |
| 75 | + | } | |
| 76 | + | }) | |
| 29 | 77 | .use(gitRoutes) | |
| 30 | 78 | .use(settingsRoutes) | |
| 31 | 79 | .use(authRoutes) | |
Msrc/db/index.ts
| @@ -372,6 +372,41 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels ( | |||
|---|---|---|---|
| 372 | 372 | PRIMARY KEY (patch_id, label_id) | |
| 373 | 373 | )`); | |
| 374 | 374 | ||
| 375 | + | // Indexes for common query patterns (safe to run repeatedly) | |
| 376 | + | sqlite.run( | |
| 377 | + | "CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id)", | |
| 378 | + | ); | |
| 379 | + | sqlite.run( | |
| 380 | + | "CREATE INDEX IF NOT EXISTS idx_issues_author_id ON issues(author_id)", | |
| 381 | + | ); | |
| 382 | + | sqlite.run( | |
| 383 | + | "CREATE INDEX IF NOT EXISTS idx_patches_repo_id ON patches(repo_id)", | |
| 384 | + | ); | |
| 385 | + | sqlite.run( | |
| 386 | + | "CREATE INDEX IF NOT EXISTS idx_patches_author_id ON patches(author_id)", | |
| 387 | + | ); | |
| 388 | + | sqlite.run( | |
| 389 | + | "CREATE INDEX IF NOT EXISTS idx_ci_runs_repo_id ON ci_runs(repo_id)", | |
| 390 | + | ); | |
| 391 | + | sqlite.run( | |
| 392 | + | "CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id)", | |
| 393 | + | ); | |
| 394 | + | sqlite.run( | |
| 395 | + | "CREATE INDEX IF NOT EXISTS idx_ssh_keys_user_id ON ssh_keys(user_id)", | |
| 396 | + | ); | |
| 397 | + | sqlite.run( | |
| 398 | + | "CREATE INDEX IF NOT EXISTS idx_issue_labels_label_id ON issue_labels(label_id)", | |
| 399 | + | ); | |
| 400 | + | sqlite.run( | |
| 401 | + | "CREATE INDEX IF NOT EXISTS idx_patch_labels_label_id ON patch_labels(label_id)", | |
| 402 | + | ); | |
| 403 | + | sqlite.run( | |
| 404 | + | "CREATE INDEX IF NOT EXISTS idx_labels_repo_id ON labels(repo_id)", | |
| 405 | + | ); | |
| 406 | + | ||
| 407 | + | // Clean up expired sessions on startup | |
| 408 | + | sqlite.run("DELETE FROM sessions WHERE expires_at < datetime('now')"); | |
| 409 | + | ||
| 375 | 410 | export async function getRepo(name: string, isAdmin: boolean) { | |
| 376 | 411 | const repo = await db | |
| 377 | 412 | .selectFrom("repositories") | |
Msrc/lib/rateLimiter.ts
| @@ -6,6 +6,16 @@ interface Bucket { | |||
|---|---|---|---|
| 6 | 6 | } | |
| 7 | 7 | ||
| 8 | 8 | const buckets = new Map<string, Bucket>(); | |
| 9 | + | let sweepCounter = 0; | |
| 10 | + | ||
| 11 | + | function maybeSweep() { | |
| 12 | + | if (++sweepCounter < 1000) return; | |
| 13 | + | sweepCounter = 0; | |
| 14 | + | const now = Date.now(); | |
| 15 | + | for (const [key, bucket] of buckets) { | |
| 16 | + | if (now > bucket.resetAt) buckets.delete(key); | |
| 17 | + | } | |
| 18 | + | } | |
| 9 | 19 | ||
| 10 | 20 | export function checkRateLimit( | |
| 11 | 21 | ip: string | null, | |
| @@ -17,6 +27,7 @@ export function checkRateLimit( | |||
|---|---|---|---|
| 17 | 27 | const bucket = buckets.get(ip); | |
| 18 | 28 | if (!bucket || now > bucket.resetAt) { | |
| 19 | 29 | buckets.set(ip, { count: 1, resetAt: now + windowMs }); | |
| 30 | + | maybeSweep(); | |
| 20 | 31 | return true; | |
| 21 | 32 | } | |
| 22 | 33 | if (bucket.count >= maxRequests) return false; | |
Msrc/routes/auth.tsx
| @@ -451,9 +451,15 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 451 | 451 | headers: { "Content-Type": "application/json" }, | |
| 452 | 452 | }); | |
| 453 | 453 | } catch (e) { | |
| 454 | - | return new Response(JSON.stringify({ error: String(e) }), { | |
| 455 | - | status: 400, | |
| 456 | - | }); | |
| 454 | + | return new Response( | |
| 455 | + | JSON.stringify({ | |
| 456 | + | error: | |
| 457 | + | e instanceof Error | |
| 458 | + | ? e.message | |
| 459 | + | : "Verification failed", | |
| 460 | + | }), | |
| 461 | + | { status: 400 }, | |
| 462 | + | ); | |
| 457 | 463 | } | |
| 458 | 464 | }, | |
| 459 | 465 | { | |
| @@ -585,9 +591,15 @@ export const authRoutes = new Elysia() | |||
|---|---|---|---|
| 585 | 591 | }, | |
| 586 | 592 | }); | |
| 587 | 593 | } catch (e) { | |
| 588 | - | return new Response(JSON.stringify({ error: String(e) }), { | |
| 589 | - | status: 400, | |
| 590 | - | }); | |
| 594 | + | return new Response( | |
| 595 | + | JSON.stringify({ | |
| 596 | + | error: | |
| 597 | + | e instanceof Error | |
| 598 | + | ? e.message | |
| 599 | + | : "Verification failed", | |
| 600 | + | }), | |
| 601 | + | { status: 400 }, | |
| 602 | + | ); | |
| 591 | 603 | } | |
| 592 | 604 | }, | |
| 593 | 605 | { | |
Msrc/routes/git.ts
| @@ -69,7 +69,9 @@ async function triggerCiForPush( | |||
|---|---|---|---|
| 69 | 69 | triggerSource: "push", | |
| 70 | 70 | commitSha: newSha, | |
| 71 | 71 | commitBranch: branch, | |
| 72 | - | }).catch(() => {}); | |
| 72 | + | }).catch((e) => | |
| 73 | + | console.error(`CI push trigger failed for ${repoName}:`, e), | |
| 74 | + | ); | |
| 73 | 75 | } | |
| 74 | 76 | } else if (isTag && shouldTriggerTag(cfg)) { | |
| 75 | 77 | const tag = refname.slice("refs/tags/".length); | |
| @@ -77,7 +79,9 @@ async function triggerCiForPush( | |||
|---|---|---|---|
| 77 | 79 | triggerSource: "tag", | |
| 78 | 80 | commitSha: newSha, | |
| 79 | 81 | commitTag: tag, | |
| 80 | - | }).catch(() => {}); | |
| 82 | + | }).catch((e) => | |
| 83 | + | console.error(`CI tag trigger failed for ${repoName}:`, e), | |
| 84 | + | ); | |
| 81 | 85 | } | |
| 82 | 86 | } | |
| 83 | 87 | } | |
Msrc/routes/repos.tsx
| @@ -832,7 +832,14 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 832 | 832 | ||
| 833 | 833 | // Keep git HEAD in sync if the branch actually exists | |
| 834 | 834 | if (branches.includes(newBranch)) { | |
| 835 | - | await git.setHead(repo.name, newBranch).catch(() => {}); | |
| 835 | + | await git | |
| 836 | + | .setHead(repo.name, newBranch) | |
| 837 | + | .catch((e) => | |
| 838 | + | console.error( | |
| 839 | + | `setHead failed for ${repo.name}/${newBranch}:`, | |
| 840 | + | e, | |
| 841 | + | ), | |
| 842 | + | ); | |
| 836 | 843 | } | |
| 837 | 844 | ||
| 838 | 845 | return redirect(`/${repo.name}/settings?success=Settings+saved.`); | |
| @@ -1106,7 +1113,14 @@ export const repoRoutes = new Elysia() | |||
|---|---|---|---|
| 1106 | 1113 | .set({ default_branch: newName }) | |
| 1107 | 1114 | .where("id", "=", repo.id) | |
| 1108 | 1115 | .execute(); | |
| 1109 | - | await git.setHead(repo.name, newName).catch(() => {}); | |
| 1116 | + | await git | |
| 1117 | + | .setHead(repo.name, newName) | |
| 1118 | + | .catch((e) => | |
| 1119 | + | console.error( | |
| 1120 | + | `setHead failed for ${repo.name}/${newName}:`, | |
| 1121 | + | e, | |
| 1122 | + | ), | |
| 1123 | + | ); | |
| 1110 | 1124 | } | |
| 1111 | 1125 | return redirect( | |
| 1112 | 1126 | `/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`, | |
Msrc/views/Settings.tsx
| @@ -225,7 +225,7 @@ export function Settings({ | |||
|---|---|---|---|
| 225 | 225 | <button | |
| 226 | 226 | class="btn btn-danger btn-sm" | |
| 227 | 227 | type="submit" | |
| 228 | - | onclick="return confirm('Remove password? You will need a passkey to sign in.')" | |
| 228 | + | data-confirm="Remove password? You will need a passkey to sign in." | |
| 229 | 229 | > | |
| 230 | 230 | Remove password | |
| 231 | 231 | </button> | |
| @@ -541,32 +541,10 @@ export function Settings({ | |||
|---|---|---|---|
| 541 | 541 | )} | |
| 542 | 542 | </div> | |
| 543 | 543 | ||
| 544 | - | <script type="module">{` | |
| 545 | - | import { startRegistration } from '/assets/simplewebauthn-browser.js'; | |
| 546 | - | document.getElementById('passkey-section').style.display = 'block'; | |
| 547 | - | document.getElementById('add-passkey-btn').addEventListener('click', async () => { | |
| 548 | - | const status = document.getElementById('passkey-status'); | |
| 549 | - | try { | |
| 550 | - | status.textContent = 'Starting...'; | |
| 551 | - | const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' }); | |
| 552 | - | const opts = await optsResp.json(); | |
| 553 | - | const result = await startRegistration({ optionsJSON: opts }); | |
| 554 | - | const verResp = await fetch('/auth/passkey/register/verify', { | |
| 555 | - | method: 'POST', | |
| 556 | - | headers: { 'Content-Type': 'application/json' }, | |
| 557 | - | body: JSON.stringify(result), | |
| 558 | - | }); | |
| 559 | - | if (verResp.ok) { | |
| 560 | - | window.location.reload(); | |
| 561 | - | } else { | |
| 562 | - | const err = await verResp.json(); | |
| 563 | - | status.textContent = 'Error: ' + (err.error ?? 'Registration failed'); | |
| 564 | - | } | |
| 565 | - | } catch (e) { | |
| 566 | - | status.textContent = 'Error: ' + e.message; | |
| 567 | - | } | |
| 568 | - | }); | |
| 569 | - | `}</script> | |
| 544 | + | <script | |
| 545 | + | type="module" | |
| 546 | + | src="/assets/passkey-settings.js" | |
| 547 | + | ></script> | |
| 570 | 548 | </Layout> | |
| 571 | 549 | ); | |
| 572 | 550 | } | |
Msrc/views/auth/Login.tsx
| @@ -29,6 +29,7 @@ export function Login({ error }: LoginProps) { | |||
|---|---|---|---|
| 29 | 29 | > | |
| 30 | 30 | Sign in with passkey | |
| 31 | 31 | </button> | |
| 32 | + | <p id="passkey-error" style="display:none"></p> | |
| 32 | 33 | <div class="auth-divider"> | |
| 33 | 34 | <span>or</span> | |
| 34 | 35 | </div> | |
| @@ -51,30 +52,10 @@ export function Login({ error }: LoginProps) { | |||
|---|---|---|---|
| 51 | 52 | Don't have an account? <a href="/register">Register</a> | |
| 52 | 53 | </p> | |
| 53 | 54 | </div> | |
| 54 | - | <script type="module">{` | |
| 55 | - | import { startAuthentication } from '/assets/simplewebauthn-browser.js'; | |
| 56 | - | document.getElementById('passkey-section').style.display = 'block'; | |
| 57 | - | document.getElementById('passkey-btn').addEventListener('click', async () => { | |
| 58 | - | try { | |
| 59 | - | const optsResp = await fetch('/auth/passkey/login/options', { method: 'POST' }); | |
| 60 | - | const opts = await optsResp.json(); | |
| 61 | - | const result = await startAuthentication({ optionsJSON: opts }); | |
| 62 | - | const verResp = await fetch('/auth/passkey/login/verify', { | |
| 63 | - | method: 'POST', | |
| 64 | - | headers: { 'Content-Type': 'application/json' }, | |
| 65 | - | body: JSON.stringify(result), | |
| 66 | - | }); | |
| 67 | - | if (verResp.ok) { | |
| 68 | - | window.location.href = '/'; | |
| 69 | - | } else { | |
| 70 | - | const err = await verResp.json(); | |
| 71 | - | alert(err.error ?? 'Passkey sign in failed'); | |
| 72 | - | } | |
| 73 | - | } catch (e) { | |
| 74 | - | alert('Passkey sign in failed: ' + e.message); | |
| 75 | - | } | |
| 76 | - | }); | |
| 77 | - | `}</script> | |
| 55 | + | <script | |
| 56 | + | type="module" | |
| 57 | + | src="/assets/passkey-login.js" | |
| 58 | + | ></script> | |
| 78 | 59 | </Layout> | |
| 79 | 60 | ); | |
| 80 | 61 | } | |
Msrc/views/auth/Register.tsx
| @@ -64,6 +64,7 @@ export function Register({ error, question, pending }: RegisterProps) { | |||
|---|---|---|---|
| 64 | 64 | > | |
| 65 | 65 | Register with passkey | |
| 66 | 66 | </button> | |
| 67 | + | <p id="passkey-error" style="display:none"></p> | |
| 67 | 68 | <div class="auth-divider"> | |
| 68 | 69 | <span>or</span> | |
| 69 | 70 | </div> | |
| @@ -98,63 +99,10 @@ export function Register({ error, question, pending }: RegisterProps) { | |||
|---|---|---|---|
| 98 | 99 | Already have an account? <a href="/login">Sign in</a> | |
| 99 | 100 | </p> | |
| 100 | 101 | </div> | |
| 101 | - | <script type="module">{` | |
| 102 | - | import { startRegistration } from '/assets/simplewebauthn-browser.js'; | |
| 103 | - | const usernameInput = document.getElementById('username'); | |
| 104 | - | const applicationInput = document.getElementById('application'); | |
| 105 | - | document.getElementById('passkey-section').style.display = 'block'; | |
| 106 | - | document.getElementById('passkey-register-btn').addEventListener('click', async () => { | |
| 107 | - | const username = usernameInput.value.trim(); | |
| 108 | - | if (!username) { usernameInput.focus(); return; } | |
| 109 | - | if (!/^[a-zA-Z0-9_-]+$/.test(username)) { | |
| 110 | - | alert('Username may only contain letters, numbers, hyphens, and underscores'); | |
| 111 | - | return; | |
| 112 | - | } | |
| 113 | - | if (applicationInput && !applicationInput.value.trim()) { | |
| 114 | - | applicationInput.focus(); | |
| 115 | - | return; | |
| 116 | - | } | |
| 117 | - | try { | |
| 118 | - | const createResp = await fetch('/auth/passkey/create-user', { | |
| 119 | - | method: 'POST', | |
| 120 | - | headers: { 'Content-Type': 'application/json' }, | |
| 121 | - | body: JSON.stringify({ | |
| 122 | - | username, | |
| 123 | - | application: applicationInput ? applicationInput.value.trim() : undefined, | |
| 124 | - | }), | |
| 125 | - | }); | |
| 126 | - | if (!createResp.ok) { | |
| 127 | - | const err = await createResp.json(); | |
| 128 | - | alert(err.error ?? 'Failed to create account'); | |
| 129 | - | return; | |
| 130 | - | } | |
| 131 | - | const data = await createResp.json(); | |
| 132 | - | if (data.pending) { | |
| 133 | - | document.querySelector('.auth-container').innerHTML = | |
| 134 | - | '<h1 class="page-title">Create account</h1>' + | |
| 135 | - | '<p class="form-success">Your account has been submitted for review. You will be able to log in once an admin approves it.</p>' + | |
| 136 | - | '<p class="auth-footer">Already have an account? <a href="/login">Sign in</a></p>'; | |
| 137 | - | return; | |
| 138 | - | } | |
| 139 | - | const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' }); | |
| 140 | - | const opts = await optsResp.json(); | |
| 141 | - | const result = await startRegistration({ optionsJSON: opts }); | |
| 142 | - | const verResp = await fetch('/auth/passkey/register/verify', { | |
| 143 | - | method: 'POST', | |
| 144 | - | headers: { 'Content-Type': 'application/json' }, | |
| 145 | - | body: JSON.stringify(result), | |
| 146 | - | }); | |
| 147 | - | if (verResp.ok) { | |
| 148 | - | window.location.href = '/'; | |
| 149 | - | } else { | |
| 150 | - | const err = await verResp.json(); | |
| 151 | - | alert(err.error ?? 'Passkey registration failed'); | |
| 152 | - | } | |
| 153 | - | } catch (e) { | |
| 154 | - | alert('Passkey registration failed: ' + e.message); | |
| 155 | - | } | |
| 156 | - | }); | |
| 157 | - | `}</script> | |
| 102 | + | <script | |
| 103 | + | type="module" | |
| 104 | + | src="/assets/passkey-register.js" | |
| 105 | + | ></script> | |
| 158 | 106 | </Layout> | |
| 159 | 107 | ); | |
| 160 | 108 | } | |
Msrc/views/ci/CiRunDetail.tsx
| @@ -62,9 +62,14 @@ export function CiRunDetail({ | |||
|---|---|---|---|
| 62 | 62 | const isActive = run.status === "pending" || run.status === "running"; | |
| 63 | 63 | const displayId = run.repo_run_id ?? run.id; | |
| 64 | 64 | ||
| 65 | - | const variableOverrides: Record<string, string> = run.variable_overrides | |
| 66 | - | ? JSON.parse(run.variable_overrides) | |
| 67 | - | : {}; | |
| 65 | + | let variableOverrides: Record<string, string> = {}; | |
| 66 | + | if (run.variable_overrides) { | |
| 67 | + | try { | |
| 68 | + | variableOverrides = JSON.parse(run.variable_overrides); | |
| 69 | + | } catch { | |
| 70 | + | // corrupted DB value — treat as empty | |
| 71 | + | } | |
| 72 | + | } | |
| 68 | 73 | const hasOverrides = Object.keys(variableOverrides).length > 0; | |
| 69 | 74 | ||
| 70 | 75 | return ( | |
Msrc/views/layout.tsx
| @@ -25,6 +25,7 @@ export function Layout({ user, title, children }: LayoutProps) { | |||
|---|---|---|---|
| 25 | 25 | /> | |
| 26 | 26 | <link rel="stylesheet" href="/assets/main.css" /> | |
| 27 | 27 | <script src="/assets/jxl-polyfill.js" defer></script> | |
| 28 | + | <script src="/assets/app.js" defer></script> | |
| 28 | 29 | </head> | |
| 29 | 30 | <body> | |
| 30 | 31 | <header class="site-header"> | |
Msrc/views/repos/BranchSelector.tsx
| @@ -35,14 +35,13 @@ export function BranchSelector({ | |||
|---|---|---|---|
| 35 | 35 | <select | |
| 36 | 36 | name="rev" | |
| 37 | 37 | class="branch-select" | |
| 38 | - | onchange="this.form.submit()" | |
| 38 | + | data-autosubmit | |
| 39 | 39 | > | |
| 40 | 40 | {isDetached && ( | |
| 41 | 41 | <option value={currentRef} selected> | |
| 42 | 42 | {shortRef} (detached) | |
| 43 | 43 | </option> | |
| 44 | 44 | )} | |
| 45 | - | ( | |
| 46 | 45 | <optgroup label="Branches"> | |
| 47 | 46 | {branches.map((b) => ( | |
| 48 | 47 | <option | |
| @@ -53,17 +52,18 @@ export function BranchSelector({ | |||
|---|---|---|---|
| 53 | 52 | </option> | |
| 54 | 53 | ))} | |
| 55 | 54 | </optgroup> | |
| 56 | - | <optgroup label="Tags"> | |
| 57 | - | {tags.map((t) => ( | |
| 58 | - | <option | |
| 59 | - | value={t} | |
| 60 | - | selected={t === currentRef ? true : undefined} | |
| 61 | - | > | |
| 62 | - | {t} | |
| 63 | - | </option> | |
| 64 | - | ))} | |
| 65 | - | </optgroup> | |
| 66 | - | ) | |
| 55 | + | {tags.length > 0 && ( | |
| 56 | + | <optgroup label="Tags"> | |
| 57 | + | {tags.map((t) => ( | |
| 58 | + | <option | |
| 59 | + | value={t} | |
| 60 | + | selected={t === currentRef ? true : undefined} | |
| 61 | + | > | |
| 62 | + | {t} | |
| 63 | + | </option> | |
| 64 | + | ))} | |
| 65 | + | </optgroup> | |
| 66 | + | )} | |
| 67 | 67 | </select> | |
| 68 | 68 | <noscript> | |
| 69 | 69 | <button type="submit" class="btn btn-sm"> | |
Msrc/views/repos/FileBlob.tsx
| @@ -29,10 +29,6 @@ export function FileBlob({ | |||
|---|---|---|---|
| 29 | 29 | }: FileBlobProps) { | |
| 30 | 30 | const parts = filePath.split("/"); | |
| 31 | 31 | const filename = parts[parts.length - 1] ?? filePath; | |
| 32 | - | const dir = parts.slice(0, -1).join("/"); | |
| 33 | - | const _backHref = dir | |
| 34 | - | ? `/${repo.name}/tree/${blobRef}/${dir}` | |
| 35 | - | : `/${repo.name}/tree/${blobRef}`; | |
| 36 | 32 | return ( | |
| 37 | 33 | <Layout user={user} title={`${repo.name}/${filePath}`}> | |
| 38 | 34 | <div class="container"> | |
| @@ -137,7 +133,7 @@ export function FileBlob({ | |||
|---|---|---|---|
| 137 | 133 | ) : view.mimeType.startsWith("audio/") ? ( | |
| 138 | 134 | // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files | |
| 139 | 135 | <audio | |
| 140 | - | controls="true" | |
| 136 | + | controls | |
| 141 | 137 | src={`/${repo.name}/raw/${blobRef}/${filePath}`} | |
| 142 | 138 | class="file-media-audio" | |
| 143 | 139 | /> | |