CSP, various small improvements

AuthorKonata <konata@posteo.jp>
Date
Commit45c693a8d04e9035688d2257984f07f83bf30a27
Parentf1416f5
18 files changed, 390 insertions(+), 140 deletions(-)
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";
22 import { staticPlugin } from "@elysiajs/static";
33 import { Elysia } from "elysia";
44 import config from "./config.ts";
5+import { db } from "./db/index.ts";
56 import { authRoutes } from "./routes/auth.tsx";
67 import { avatarRoutes } from "./routes/avatars.ts";
78 import { ciRoutes } from "./routes/ci.tsx";
@@ -14,9 +15,31 @@ import { settingsRoutes } from "./routes/settings.tsx";
1415 import { cancelStaleRuns } from "./services/ci.ts";
1516 import { syncStartup } from "./services/repoSync.ts";
1617
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+
1736 export async function createApp(port: number) {
1837 await syncStartup();
1938 await cancelStaleRuns();
39+
40+ // Recurring session cleanup — runs every 24 hours
41+ setInterval(cleanupSessions, 24 * 60 * 60 * 1000);
42+
2043 return new Elysia({
2144 serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES },
2245 })
@@ -26,6 +49,31 @@ export async function createApp(port: number) {
2649 prefix: "/",
2750 }),
2851 )
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+ })
2977 .use(gitRoutes)
3078 .use(settingsRoutes)
3179 .use(authRoutes)
Msrc/db/index.ts
@@ -372,6 +372,41 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
372372 PRIMARY KEY (patch_id, label_id)
373373 )`);
374374
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+
375410 export async function getRepo(name: string, isAdmin: boolean) {
376411 const repo = await db
377412 .selectFrom("repositories")
Msrc/lib/rateLimiter.ts
@@ -6,6 +6,16 @@ interface Bucket {
66 }
77
88 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+}
919
1020 export function checkRateLimit(
1121 ip: string | null,
@@ -17,6 +27,7 @@ export function checkRateLimit(
1727 const bucket = buckets.get(ip);
1828 if (!bucket || now > bucket.resetAt) {
1929 buckets.set(ip, { count: 1, resetAt: now + windowMs });
30+ maybeSweep();
2031 return true;
2132 }
2233 if (bucket.count >= maxRequests) return false;
Msrc/routes/auth.tsx
@@ -451,9 +451,15 @@ export const authRoutes = new Elysia()
451451 headers: { "Content-Type": "application/json" },
452452 });
453453 } 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+ );
457463 }
458464 },
459465 {
@@ -585,9 +591,15 @@ export const authRoutes = new Elysia()
585591 },
586592 });
587593 } 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+ );
591603 }
592604 },
593605 {
Msrc/routes/git.ts
@@ -69,7 +69,9 @@ async function triggerCiForPush(
6969 triggerSource: "push",
7070 commitSha: newSha,
7171 commitBranch: branch,
72- }).catch(() => {});
72+ }).catch((e) =>
73+ console.error(`CI push trigger failed for ${repoName}:`, e),
74+ );
7375 }
7476 } else if (isTag && shouldTriggerTag(cfg)) {
7577 const tag = refname.slice("refs/tags/".length);
@@ -77,7 +79,9 @@ async function triggerCiForPush(
7779 triggerSource: "tag",
7880 commitSha: newSha,
7981 commitTag: tag,
80- }).catch(() => {});
82+ }).catch((e) =>
83+ console.error(`CI tag trigger failed for ${repoName}:`, e),
84+ );
8185 }
8286 }
8387 }
Msrc/routes/repos.tsx
@@ -832,7 +832,14 @@ export const repoRoutes = new Elysia()
832832
833833 // Keep git HEAD in sync if the branch actually exists
834834 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+ );
836843 }
837844
838845 return redirect(`/${repo.name}/settings?success=Settings+saved.`);
@@ -1106,7 +1113,14 @@ export const repoRoutes = new Elysia()
11061113 .set({ default_branch: newName })
11071114 .where("id", "=", repo.id)
11081115 .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+ );
11101124 }
11111125 return redirect(
11121126 `/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`,
Msrc/views/Settings.tsx
@@ -225,7 +225,7 @@ export function Settings({
225225 <button
226226 class="btn btn-danger btn-sm"
227227 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."
229229 >
230230 Remove password
231231 </button>
@@ -541,32 +541,10 @@ export function Settings({
541541 )}
542542 </div>
543543
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>
570548 </Layout>
571549 );
572550 }
Msrc/views/auth/Login.tsx
@@ -29,6 +29,7 @@ export function Login({ error }: LoginProps) {
2929 >
3030 Sign in with passkey
3131 </button>
32+ <p id="passkey-error" style="display:none"></p>
3233 <div class="auth-divider">
3334 <span>or</span>
3435 </div>
@@ -51,30 +52,10 @@ export function Login({ error }: LoginProps) {
5152 Don't have an account? <a href="/register">Register</a>
5253 </p>
5354 </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>
7859 </Layout>
7960 );
8061 }
Msrc/views/auth/Register.tsx
@@ -64,6 +64,7 @@ export function Register({ error, question, pending }: RegisterProps) {
6464 >
6565 Register with passkey
6666 </button>
67+ <p id="passkey-error" style="display:none"></p>
6768 <div class="auth-divider">
6869 <span>or</span>
6970 </div>
@@ -98,63 +99,10 @@ export function Register({ error, question, pending }: RegisterProps) {
9899 Already have an account? <a href="/login">Sign in</a>
99100 </p>
100101 </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>
158106 </Layout>
159107 );
160108 }
Msrc/views/ci/CiRunDetail.tsx
@@ -62,9 +62,14 @@ export function CiRunDetail({
6262 const isActive = run.status === "pending" || run.status === "running";
6363 const displayId = run.repo_run_id ?? run.id;
6464
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+ }
6873 const hasOverrides = Object.keys(variableOverrides).length > 0;
6974
7075 return (
Msrc/views/layout.tsx
@@ -25,6 +25,7 @@ export function Layout({ user, title, children }: LayoutProps) {
2525 />
2626 <link rel="stylesheet" href="/assets/main.css" />
2727 <script src="/assets/jxl-polyfill.js" defer></script>
28+ <script src="/assets/app.js" defer></script>
2829 </head>
2930 <body>
3031 <header class="site-header">
Msrc/views/repos/BranchSelector.tsx
@@ -35,14 +35,13 @@ export function BranchSelector({
3535 <select
3636 name="rev"
3737 class="branch-select"
38- onchange="this.form.submit()"
38+ data-autosubmit
3939 >
4040 {isDetached && (
4141 <option value={currentRef} selected>
4242 {shortRef} (detached)
4343 </option>
4444 )}
45- (
4645 <optgroup label="Branches">
4746 {branches.map((b) => (
4847 <option
@@ -53,17 +52,18 @@ export function BranchSelector({
5352 </option>
5453 ))}
5554 </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+ )}
6767 </select>
6868 <noscript>
6969 <button type="submit" class="btn btn-sm">
Msrc/views/repos/FileBlob.tsx
@@ -29,10 +29,6 @@ export function FileBlob({
2929 }: FileBlobProps) {
3030 const parts = filePath.split("/");
3131 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}`;
3632 return (
3733 <Layout user={user} title={`${repo.name}/${filePath}`}>
3834 <div class="container">
@@ -137,7 +133,7 @@ export function FileBlob({
137133 ) : view.mimeType.startsWith("audio/") ? (
138134 // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
139135 <audio
140- controls="true"
136+ controls
141137 src={`/${repo.name}/raw/${blobRef}/${filePath}`}
142138 class="file-media-audio"
143139 />
Msrc/views/repos/RepoList.tsx
@@ -50,7 +50,7 @@ export function RepoList({
5050 <select
5151 name="sort"
5252 class="repo-sort-select"
53- onchange="this.form.submit()"
53+ data-autosubmit
5454 >
5555 <option
5656 value="created"