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 @@
// Auto-submit selects marked with data-autosubmit
document.querySelectorAll("[data-autosubmit]").forEach((el) => {
el.addEventListener("change", () => el.closest("form").submit());
});
// Confirm dialogs: <button data-confirm="Are you sure?">
document.querySelectorAll("[data-confirm]").forEach((el) => {
el.addEventListener("click", (e) => {
if (!confirm(el.dataset.confirm)) e.preventDefault();
});
});
// Paste-to-upload: paste an image anywhere on the settings page to fill the avatar input
document.addEventListener("paste", (e) => {
const input = document.querySelector('input[name="avatar"]');
if (!input) return;
const file = [...(e.clipboardData?.items ?? [])]
.find((i) => i.kind === "file" && i.type.startsWith("image/"))
?.getAsFile();
if (!file) return;
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
input.closest("form").submit();
});
▾Apublic/assets/passkey-login.js
@@ -0,0 +1,50 @@
import { startAuthentication } from "/assets/simplewebauthn-browser.js";
const section = document.getElementById("passkey-section");
const btn = document.getElementById("passkey-btn");
const errEl = document.getElementById("passkey-error");
if (section) section.style.display = "block";
function showError(msg) {
if (!errEl) return;
errEl.textContent = msg;
errEl.className = msg ? "form-error" : "";
errEl.style.display = msg ? "" : "none";
}
if (btn) {
const originalText = btn.textContent;
btn.addEventListener("click", async () => {
btn.disabled = true;
btn.textContent = "Waiting for authenticator…";
showError("");
try {
const optsResp = await fetch("/auth/passkey/login/options", {
method: "POST",
});
if (!optsResp.ok) throw new Error("Failed to get options");
const opts = await optsResp.json();
const result = await startAuthentication({ optionsJSON: opts });
const verResp = await fetch("/auth/passkey/login/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result),
});
if (verResp.ok) {
window.location.href = "/";
} else {
const err = await verResp.json().catch(() => ({}));
showError(err.error ?? "Passkey sign in failed");
}
} catch (e) {
showError(
"Passkey sign in failed: " +
(e instanceof Error ? e.message : String(e)),
);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
});
}
▾Apublic/assets/passkey-register.js
@@ -0,0 +1,97 @@
import { startRegistration } from "/assets/simplewebauthn-browser.js";
const usernameInput = document.getElementById("username");
const applicationInput = document.getElementById("application");
const section = document.getElementById("passkey-section");
const btn = document.getElementById("passkey-register-btn");
const errEl = document.getElementById("passkey-error");
if (section) section.style.display = "block";
function showError(msg) {
if (!errEl) return;
errEl.textContent = msg;
errEl.className = msg ? "form-error" : "";
errEl.style.display = msg ? "" : "none";
}
if (btn) {
const originalText = btn.textContent;
btn.addEventListener("click", async () => {
const username = usernameInput?.value.trim() ?? "";
if (!username) {
usernameInput?.focus();
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
showError(
"Username may only contain letters, numbers, hyphens, and underscores",
);
return;
}
if (applicationInput && !applicationInput.value.trim()) {
applicationInput.focus();
return;
}
btn.disabled = true;
btn.textContent = "Creating account…";
showError("");
try {
const createResp = await fetch("/auth/passkey/create-user", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username,
application: applicationInput
? applicationInput.value.trim()
: undefined,
}),
});
if (!createResp.ok) {
const err = await createResp.json().catch(() => ({}));
showError(err.error ?? "Failed to create account");
return;
}
const data = await createResp.json();
if (data.pending) {
const container = document.querySelector(".auth-container");
if (container) {
container.innerHTML =
'<h1 class="page-title">Create account</h1>' +
'<p class="form-success">Your account has been submitted for review. You will be able to log in once an admin approves it.</p>' +
'<p class="auth-footer">Already have an account? <a href="/login">Sign in</a></p>';
}
return;
}
btn.textContent = "Waiting for authenticator…";
const optsResp = await fetch("/auth/passkey/register/options", {
method: "POST",
});
if (!optsResp.ok)
throw new Error("Failed to get registration options");
const opts = await optsResp.json();
const result = await startRegistration({ optionsJSON: opts });
const verResp = await fetch("/auth/passkey/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result),
});
if (verResp.ok) {
window.location.href = "/";
} else {
const err = await verResp.json().catch(() => ({}));
showError(err.error ?? "Passkey registration failed");
}
} catch (e) {
showError(
"Passkey registration failed: " +
(e instanceof Error ? e.message : String(e)),
);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
});
}
▾Apublic/assets/passkey-settings.js
@@ -0,0 +1,45 @@
import { startRegistration } from "/assets/simplewebauthn-browser.js";
const section = document.getElementById("passkey-section");
const btn = document.getElementById("add-passkey-btn");
const status = document.getElementById("passkey-status");
if (section) section.style.display = "block";
if (btn) {
const originalText = btn.textContent;
btn.addEventListener("click", async () => {
btn.disabled = true;
btn.textContent = "Waiting for authenticator…";
if (status) status.textContent = "";
try {
const optsResp = await fetch("/auth/passkey/register/options", {
method: "POST",
});
if (!optsResp.ok) throw new Error("Failed to get options");
const opts = await optsResp.json();
const result = await startRegistration({ optionsJSON: opts });
const verResp = await fetch("/auth/passkey/register/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(result),
});
if (verResp.ok) {
window.location.reload();
} else {
const err = await verResp.json().catch(() => ({}));
if (status)
status.textContent =
"Error: " + (err.error ?? "Registration failed");
}
} catch (e) {
if (status)
status.textContent =
"Error: " +
(e instanceof Error ? e.message : String(e));
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
});
}
▾Msrc/app.ts
@@ -2,6 +2,7 @@ import path from "node:path";
import { staticPlugin } from "@elysiajs/static";
import { Elysia } from "elysia";
import config from "./config.ts";
import { db } from "./db/index.ts";
import { authRoutes } from "./routes/auth.tsx";
import { avatarRoutes } from "./routes/avatars.ts";
import { ciRoutes } from "./routes/ci.tsx";
@@ -14,9 +15,31 @@ import { settingsRoutes } from "./routes/settings.tsx";
import { cancelStaleRuns } from "./services/ci.ts";
import { syncStartup } from "./services/repoSync.ts";
const CSP = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"form-action 'self'",
"object-src 'none'",
].join("; ");
async function cleanupSessions() {
await db
.deleteFrom("sessions")
.where("expires_at", "<", new Date().toISOString())
.execute();
}
export async function createApp(port: number) {
await syncStartup();
await cancelStaleRuns();
// Recurring session cleanup — runs every 24 hours
setInterval(cleanupSessions, 24 * 60 * 60 * 1000);
return new Elysia({
serve: { maxRequestBodySize: config.MAX_UPLOAD_BYTES },
})
@@ -26,6 +49,31 @@ export async function createApp(port: number) {
prefix: "/",
}),
)
.onAfterHandle(({ response }) => {
if (response instanceof Response) {
response.headers.set("Content-Security-Policy", CSP);
response.headers.set("X-Frame-Options", "DENY");
}
})
.get("/health", async () => {
try {
await db.selectFrom("users").select("id").limit(1).execute();
return new Response(JSON.stringify({ ok: true }), {
headers: { "Content-Type": "application/json" },
});
} catch (e) {
return new Response(
JSON.stringify({
ok: false,
error: e instanceof Error ? e.message : "DB error",
}),
{
status: 503,
headers: { "Content-Type": "application/json" },
},
);
}
})
.use(gitRoutes)
.use(settingsRoutes)
.use(authRoutes)
▾Msrc/db/index.ts
@@ -372,6 +372,41 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
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)",
);
// 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/lib/rateLimiter.ts
@@ -6,6 +6,16 @@ interface Bucket {
}
const buckets = new Map<string, Bucket>();
let sweepCounter = 0;
function maybeSweep() {
if (++sweepCounter < 1000) return;
sweepCounter = 0;
const now = Date.now();
for (const [key, bucket] of buckets) {
if (now > bucket.resetAt) buckets.delete(key);
}
}
export function checkRateLimit(
ip: string | null,
@@ -17,6 +27,7 @@ export function checkRateLimit(
const bucket = buckets.get(ip);
if (!bucket || now > bucket.resetAt) {
buckets.set(ip, { count: 1, resetAt: now + windowMs });
maybeSweep();
return true;
}
if (bucket.count >= maxRequests) return false;
▾Msrc/routes/auth.tsx
@@ -451,9 +451,15 @@ export const authRoutes = new Elysia()
headers: { "Content-Type": "application/json" },
});
} catch (e) {
return new Response(JSON.stringify({ error: String(e) }), {
status: 400,
});
return new Response(
JSON.stringify({
error:
e instanceof Error
? e.message
: "Verification failed",
}),
{ status: 400 },
);
}
},
{
@@ -585,9 +591,15 @@ export const authRoutes = new Elysia()
},
});
} catch (e) {
return new Response(JSON.stringify({ error: String(e) }), {
status: 400,
});
return new Response(
JSON.stringify({
error:
e instanceof Error
? e.message
: "Verification failed",
}),
{ status: 400 },
);
}
},
{
▾Msrc/routes/git.ts
@@ -69,7 +69,9 @@ async function triggerCiForPush(
triggerSource: "push",
commitSha: newSha,
commitBranch: branch,
}).catch(() => {});
}).catch((e) =>
console.error(`CI push trigger failed for ${repoName}:`, e),
);
}
} else if (isTag && shouldTriggerTag(cfg)) {
const tag = refname.slice("refs/tags/".length);
@@ -77,7 +79,9 @@ async function triggerCiForPush(
triggerSource: "tag",
commitSha: newSha,
commitTag: tag,
}).catch(() => {});
}).catch((e) =>
console.error(`CI tag trigger failed for ${repoName}:`, e),
);
}
}
}
▾Msrc/routes/repos.tsx
@@ -832,7 +832,14 @@ export const repoRoutes = new Elysia()
// Keep git HEAD in sync if the branch actually exists
if (branches.includes(newBranch)) {
await git.setHead(repo.name, newBranch).catch(() => {});
await git
.setHead(repo.name, newBranch)
.catch((e) =>
console.error(
`setHead failed for ${repo.name}/${newBranch}:`,
e,
),
);
}
return redirect(`/${repo.name}/settings?success=Settings+saved.`);
@@ -1106,7 +1113,14 @@ export const repoRoutes = new Elysia()
.set({ default_branch: newName })
.where("id", "=", repo.id)
.execute();
await git.setHead(repo.name, newName).catch(() => {});
await git
.setHead(repo.name, newName)
.catch((e) =>
console.error(
`setHead failed for ${repo.name}/${newName}:`,
e,
),
);
}
return redirect(
`/${repo.name}/branches?success=${encodeURIComponent(`Branch renamed to "${newName}".`)}`,
▾Msrc/views/Settings.tsx
@@ -225,7 +225,7 @@ export function Settings({
<button
class="btn btn-danger btn-sm"
type="submit"
onclick="return confirm('Remove password? You will need a passkey to sign in.')"
data-confirm="Remove password? You will need a passkey to sign in."
>
Remove password
</button>
@@ -541,32 +541,10 @@ export function Settings({
)}
</div>
<script type="module">{`
import { startRegistration } from '/assets/simplewebauthn-browser.js';
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('add-passkey-btn').addEventListener('click', async () => {
const status = document.getElementById('passkey-status');
try {
status.textContent = 'Starting...';
const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
const opts = await optsResp.json();
const result = await startRegistration({ optionsJSON: opts });
const verResp = await fetch('/auth/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
});
if (verResp.ok) {
window.location.reload();
} else {
const err = await verResp.json();
status.textContent = 'Error: ' + (err.error ?? 'Registration failed');
}
} catch (e) {
status.textContent = 'Error: ' + e.message;
}
});
`}</script>
<script
type="module"
src="/assets/passkey-settings.js"
></script>
</Layout>
);
}
▾Msrc/views/auth/Login.tsx
@@ -29,6 +29,7 @@ export function Login({ error }: LoginProps) {
>
Sign in with passkey
</button>
<p id="passkey-error" style="display:none"></p>
<div class="auth-divider">
<span>or</span>
</div>
@@ -51,30 +52,10 @@ export function Login({ error }: LoginProps) {
Don't have an account? <a href="/register">Register</a>
</p>
</div>
<script type="module">{`
import { startAuthentication } from '/assets/simplewebauthn-browser.js';
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('passkey-btn').addEventListener('click', async () => {
try {
const optsResp = await fetch('/auth/passkey/login/options', { method: 'POST' });
const opts = await optsResp.json();
const result = await startAuthentication({ optionsJSON: opts });
const verResp = await fetch('/auth/passkey/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
});
if (verResp.ok) {
window.location.href = '/';
} else {
const err = await verResp.json();
alert(err.error ?? 'Passkey sign in failed');
}
} catch (e) {
alert('Passkey sign in failed: ' + e.message);
}
});
`}</script>
<script
type="module"
src="/assets/passkey-login.js"
></script>
</Layout>
);
}
▾Msrc/views/auth/Register.tsx
@@ -64,6 +64,7 @@ export function Register({ error, question, pending }: RegisterProps) {
>
Register with passkey
</button>
<p id="passkey-error" style="display:none"></p>
<div class="auth-divider">
<span>or</span>
</div>
@@ -98,63 +99,10 @@ export function Register({ error, question, pending }: RegisterProps) {
Already have an account? <a href="/login">Sign in</a>
</p>
</div>
<script type="module">{`
import { startRegistration } from '/assets/simplewebauthn-browser.js';
const usernameInput = document.getElementById('username');
const applicationInput = document.getElementById('application');
document.getElementById('passkey-section').style.display = 'block';
document.getElementById('passkey-register-btn').addEventListener('click', async () => {
const username = usernameInput.value.trim();
if (!username) { usernameInput.focus(); return; }
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
alert('Username may only contain letters, numbers, hyphens, and underscores');
return;
}
if (applicationInput && !applicationInput.value.trim()) {
applicationInput.focus();
return;
}
try {
const createResp = await fetch('/auth/passkey/create-user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
application: applicationInput ? applicationInput.value.trim() : undefined,
}),
});
if (!createResp.ok) {
const err = await createResp.json();
alert(err.error ?? 'Failed to create account');
return;
}
const data = await createResp.json();
if (data.pending) {
document.querySelector('.auth-container').innerHTML =
'<h1 class="page-title">Create account</h1>' +
'<p class="form-success">Your account has been submitted for review. You will be able to log in once an admin approves it.</p>' +
'<p class="auth-footer">Already have an account? <a href="/login">Sign in</a></p>';
return;
}
const optsResp = await fetch('/auth/passkey/register/options', { method: 'POST' });
const opts = await optsResp.json();
const result = await startRegistration({ optionsJSON: opts });
const verResp = await fetch('/auth/passkey/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result),
});
if (verResp.ok) {
window.location.href = '/';
} else {
const err = await verResp.json();
alert(err.error ?? 'Passkey registration failed');
}
} catch (e) {
alert('Passkey registration failed: ' + e.message);
}
});
`}</script>
<script
type="module"
src="/assets/passkey-register.js"
></script>
</Layout>
);
}
▾Msrc/views/ci/CiRunDetail.tsx
@@ -62,9 +62,14 @@ export function CiRunDetail({
const isActive = run.status === "pending" || run.status === "running";
const displayId = run.repo_run_id ?? run.id;
const variableOverrides: Record<string, string> = run.variable_overrides
? JSON.parse(run.variable_overrides)
: {};
let variableOverrides: Record<string, string> = {};
if (run.variable_overrides) {
try {
variableOverrides = JSON.parse(run.variable_overrides);
} catch {
// corrupted DB value — treat as empty
}
}
const hasOverrides = Object.keys(variableOverrides).length > 0;
return (
▾Msrc/views/layout.tsx
@@ -25,6 +25,7 @@ export function Layout({ user, title, children }: LayoutProps) {
/>
<link rel="stylesheet" href="/assets/main.css" />
<script src="/assets/jxl-polyfill.js" defer></script>
<script src="/assets/app.js" defer></script>
</head>
<body>
<header class="site-header">
▾Msrc/views/repos/BranchSelector.tsx
@@ -35,14 +35,13 @@ export function BranchSelector({
<select
name="rev"
class="branch-select"
onchange="this.form.submit()"
data-autosubmit
>
{isDetached && (
<option value={currentRef} selected>
{shortRef} (detached)
</option>
)}
(
<optgroup label="Branches">
{branches.map((b) => (
<option
@@ -53,17 +52,18 @@ export function BranchSelector({
</option>
))}
</optgroup>
<optgroup label="Tags">
{tags.map((t) => (
<option
value={t}
selected={t === currentRef ? true : undefined}
>
{t}
</option>
))}
</optgroup>
)
{tags.length > 0 && (
<optgroup label="Tags">
{tags.map((t) => (
<option
value={t}
selected={t === currentRef ? true : undefined}
>
{t}
</option>
))}
</optgroup>
)}
</select>
<noscript>
<button type="submit" class="btn btn-sm">
▾Msrc/views/repos/FileBlob.tsx
@@ -29,10 +29,6 @@ export function FileBlob({
}: FileBlobProps) {
const parts = filePath.split("/");
const filename = parts[parts.length - 1] ?? filePath;
const dir = parts.slice(0, -1).join("/");
const _backHref = dir
? `/${repo.name}/tree/${blobRef}/${dir}`
: `/${repo.name}/tree/${blobRef}`;
return (
<Layout user={user} title={`${repo.name}/${filePath}`}>
<div class="container">
@@ -137,7 +133,7 @@ export function FileBlob({
) : view.mimeType.startsWith("audio/") ? (
// biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
<audio
controls="true"
controls
src={`/${repo.name}/raw/${blobRef}/${filePath}`}
class="file-media-audio"
/>
▾Msrc/views/repos/RepoList.tsx
@@ -50,7 +50,7 @@ export function RepoList({
<select
name="sort"
class="repo-sort-select"
onchange="this.form.submit()"
data-autosubmit
>
<option
value="created"