various small improvements
Msrc/app.ts
@@ -49,6 +49,30 @@ export async function createApp(port: number) {
prefix: "/",
}),
)
.onRequest(({ request }) => {
// CSRF defense-in-depth: reject mutating requests whose Origin
// header is present but does not match the Host. Modern browsers
// attach Origin to all cross-site mutating requests, so this
// catches what SameSite=Lax cookies would have allowed (top-level
// POSTs from same-origin contexts are unaffected). Non-browser
// clients (git push, curl) typically omit Origin and pass through.
const m = request.method;
if (m !== "POST" && m !== "PUT" && m !== "PATCH" && m !== "DELETE")
return;
const origin = request.headers.get("origin");
if (!origin) return;
const host = request.headers.get("host");
let originHost: string;
try {
originHost = new URL(origin).host;
} catch {
return new Response("Bad Origin", { status: 403 });
}
if (!host || originHost !== host)
return new Response("Cross-origin request rejected", {
status: 403,
});
})
.onAfterHandle(({ response }) => {
if (response instanceof Response) {
response.headers.set("Content-Security-Policy", CSP);
Msrc/constants.ts
@@ -73,6 +73,7 @@ export const MAX_MD_CACHE = 50;
export const MAX_FILE_CACHE = 500;
export const MAX_DIFF_CACHE = 500;
export const MAX_PATCH_CACHE = 100;
export const PATCH_CACHE_TTL_MS = 60 * 60 * 1000;
export const MAX_BRANCH_CACHE = 200;
export const MAX_TAG_CACHE = 200;
export const REF_CACHE_TTL_MS = 30_000;
Msrc/db/index.ts
@@ -373,9 +373,7 @@ sqlite.run(`CREATE TABLE IF NOT EXISTS patch_labels (
)`);
// 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_repo_id ON issues(repo_id)");
sqlite.run(
"CREATE INDEX IF NOT EXISTS idx_issues_author_id ON issues(author_id)",
);
@@ -400,8 +398,15 @@ sqlite.run(
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_labels_repo_id ON labels(repo_id)",
"CREATE INDEX IF NOT EXISTS idx_patches_repo_status ON patches(repo_id, status)",
);
// Clean up expired sessions on startup
Asrc/lib/commentAuth.ts
@@ -0,0 +1,47 @@
import { db } from "../db/index.ts";
import type { SessionUser } from "../middleware/session.ts";
/**
* Authorise an edit on an issue or patch comment.
*
* Returns a Response if the request must be denied, or null if it may proceed.
*
* Verifies, in one query, that:
* 1. the comment exists, and
* 2. its parent issue/patch belongs to the requested repo (prevents
* cross-repo bypasses where the URL repo and the comment's repo differ),
* 3. the user is the comment author or an admin,
* 4. the parent issue/patch is open (admins may edit on closed parents).
*/
export async function authorizeCommentEdit(
kind: "issue" | "patch",
commentId: number,
repoId: number,
user: SessionUser | null,
): Promise<Response | null> {
if (!user) return new Response("Unauthorized", { status: 401 });
const row =
kind === "issue"
? await db
.selectFrom("issue_comments")
.innerJoin("issues", "issues.id", "issue_comments.issue_id")
.select(["issue_comments.author_id", "issues.status"])
.where("issue_comments.id", "=", commentId)
.where("issues.repo_id", "=", repoId)
.executeTakeFirst()
: await db
.selectFrom("patch_comments")
.innerJoin("patches", "patches.id", "patch_comments.patch_id")
.select(["patch_comments.author_id", "patches.status"])
.where("patch_comments.id", "=", commentId)
.where("patches.repo_id", "=", repoId)
.executeTakeFirst();
if (!row) return new Response("Not found", { status: 404 });
if (row.author_id !== user.id && !user.isAdmin)
return new Response("Forbidden", { status: 403 });
if (row.status !== "open" && !user.isAdmin)
return new Response("Forbidden", { status: 403 });
return null;
}
Asrc/lib/pagination.ts
@@ -0,0 +1,15 @@
/**
* Compute pagination state for a list view.
*
* Clamps `page` into [1, totalPages] so an out-of-range query string
* (?page=999) lands on the last page instead of an empty offset.
*/
export function paginate(
rawPage: number | undefined,
totalCount: number,
perPage: number,
): { page: number; totalPages: number; offset: number } {
const totalPages = Math.max(1, Math.ceil(totalCount / perPage));
const page = Math.min(Math.max(1, rawPage ?? 1), totalPages);
return { page, totalPages, offset: (page - 1) * perPage };
}
Msrc/routes/ci.tsx
@@ -3,6 +3,7 @@ import path from "node:path";
import { Elysia, t } from "elysia";
import { CI_RUNS_PER_PAGE, paths } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import { paginate } from "../lib/pagination.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import {
cancelRun,
@@ -87,19 +88,20 @@ export const ciRoutes = new Elysia()
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const page = Math.max(1, query.page ?? 1);
const countRow = await db
.selectFrom("ci_runs")
.select(db.fn.countAll<number>().as("count"))
.where("repo_id", "=", repo.id)
.executeTakeFirst();
const totalPages = Math.max(
1,
Math.ceil(Number(countRow?.count ?? 0) / CI_RUNS_PER_PAGE),
const {
page: safePage,
totalPages,
offset,
} = paginate(
query.page,
Number(countRow?.count ?? 0),
CI_RUNS_PER_PAGE,
);
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * CI_RUNS_PER_PAGE;
const runs = await db
.selectFrom("ci_runs")
Msrc/routes/issues.tsx
@@ -4,6 +4,8 @@ import config from "../config.ts";
import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts";
import { issuesLabelFilter } from "../db/helpers.ts";
import { db, getRepo, type LabelRow } from "../db/index.ts";
import { authorizeCommentEdit } from "../lib/commentAuth.ts";
import { paginate } from "../lib/pagination.ts";
import {
requireAdmin,
requireAuth,
@@ -33,7 +35,6 @@ export const issueRoutes = new Elysia()
: query.status === "completed"
? ("completed" as const)
: ("open" as const);
const page = Math.max(1, query.page ?? 1);
// Parse label filter: query.labels may be a string or array of strings
const rawLabels = query.labels;
@@ -69,12 +70,11 @@ export const issueRoutes = new Elysia()
const counts: Record<string, number> = Object.fromEntries(
allCounts.map((r) => [r.status, Number(r.count)]),
);
const totalPages = Math.max(
1,
Math.ceil((counts[status] ?? 0) / ISSUES_PER_PAGE),
);
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * ISSUES_PER_PAGE;
const {
page: safePage,
totalPages,
offset,
} = paginate(query.page, counts[status] ?? 0, ISSUES_PER_PAGE);
let listQuery = db
.selectFrom("issues")
@@ -694,21 +694,13 @@ export const issueRoutes = new Elysia()
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const comment = await db
.selectFrom("issue_comments")
.select(["id", "author_id", "issue_id"])
.where("id", "=", params.id)
.executeTakeFirst();
if (!comment) return new Response("Not found", { status: 404 });
if (comment.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const parentIssue = await db
.selectFrom("issues")
.select("status")
.where("id", "=", comment.issue_id)
.executeTakeFirst();
if (parentIssue?.status !== "open" && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const denyComment = await authorizeCommentEdit(
"issue",
params.id,
repo.id,
user,
);
if (denyComment) return denyComment;
const issueNum = parseInt(params.number, 10);
await db
@@ -717,7 +709,7 @@ export const issueRoutes = new Elysia()
body: body.edit_body.trim(),
edited_at: new Date().toISOString(),
})
.where("id", "=", comment.id)
.where("id", "=", params.id)
.execute();
return new Response(null, {
Msrc/routes/patches.tsx
@@ -4,6 +4,8 @@ import config from "../config.ts";
import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts";
import { patchesLabelFilter } from "../db/helpers.ts";
import { db, getRepo, type LabelRow } from "../db/index.ts";
import { authorizeCommentEdit } from "../lib/commentAuth.ts";
import { paginate } from "../lib/pagination.ts";
import {
requireAdmin,
requireAuth,
@@ -61,7 +63,6 @@ export const patchRoutes = new Elysia()
)
? query.status!
: "open";
const page = Math.max(1, query.page ?? 1);
// Parse label filter
const rawLabels = query.labels;
@@ -100,12 +101,11 @@ export const patchRoutes = new Elysia()
const counts: Record<string, number> = Object.fromEntries(
allCounts.map((r) => [r.status, Number(r.count)]),
);
const totalPages = Math.max(
1,
Math.ceil((counts[status] ?? 0) / PATCHES_PER_PAGE),
);
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * PATCHES_PER_PAGE;
const {
page: safePage,
totalPages,
offset,
} = paginate(query.page, counts[status] ?? 0, PATCHES_PER_PAGE);
let listQuery = db
.selectFrom("patches")
@@ -933,21 +933,13 @@ export const patchRoutes = new Elysia()
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const comment = await db
.selectFrom("patch_comments")
.select(["id", "author_id", "patch_id"])
.where("id", "=", params.id)
.executeTakeFirst();
if (!comment) return new Response("Not found", { status: 404 });
if (comment.author_id !== user?.id && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const parentPatch = await db
.selectFrom("patches")
.select("status")
.where("id", "=", comment.patch_id)
.executeTakeFirst();
if (parentPatch?.status !== "open" && !user?.isAdmin)
return new Response("Forbidden", { status: 403 });
const denyComment = await authorizeCommentEdit(
"patch",
params.id,
repo.id,
user,
);
if (denyComment) return denyComment;
const patchNum = parseInt(params.number, 10);
await db
@@ -956,7 +948,7 @@ export const patchRoutes = new Elysia()
body: body.edit_body.trim(),
edited_at: new Date().toISOString(),
})
.where("id", "=", comment.id)
.where("id", "=", params.id)
.execute();
return new Response(null, {
Msrc/routes/releases.tsx
@@ -4,6 +4,7 @@ import { Elysia, t } from "elysia";
import config from "../config.ts";
import { paths, RELEASES_PER_PAGE } from "../constants.ts";
import { db, getRepo } from "../db/index.ts";
import { paginate } from "../lib/pagination.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { archiveRepo, git } from "../services/git.ts";
import { renderMarkdown } from "../services/markdown.ts";
@@ -35,19 +36,20 @@ export const releasesRoutes = new Elysia()
const repo = await getRepo(params.repo, user?.isAdmin ?? false);
if (!repo) return new Response("Not found", { status: 404 });
const page = Math.max(1, query.page ?? 1);
const countRow = await db
.selectFrom("releases")
.select(db.fn.countAll<number>().as("count"))
.where("repo_id", "=", repo.id)
.executeTakeFirst();
const totalPages = Math.max(
1,
Math.ceil(Number(countRow?.count ?? 0) / RELEASES_PER_PAGE),
const {
page: safePage,
totalPages,
offset,
} = paginate(
query.page,
Number(countRow?.count ?? 0),
RELEASES_PER_PAGE,
);
const safePage = Math.min(page, totalPages);
const offset = (safePage - 1) * RELEASES_PER_PAGE;
const releasesRaw = await db
.selectFrom("releases")
Msrc/services/ci.ts
@@ -1007,13 +1007,34 @@ export async function triggerRun(
runningTasks.set(runId.id, { controller });
// Fire and forget — like release archiving
(async () => {
await executeRun(runId.id, controller.signal);
})();
spawnRun(runId.id, controller.signal);
return runId.id;
}
// Wraps the fire-and-forget executeRun so unhandled exceptions (e.g. a throw
// before/after its own try/finally) are logged and the run is reconciled to
// "failure" instead of staying pending forever.
function spawnRun(runId: number, signal: AbortSignal): void {
void executeRun(runId, signal).catch(async (err) => {
console.error(`[ci] executeRun threw for run ${runId}:`, err);
runningTasks.delete(runId);
try {
await db
.updateTable("ci_runs")
.set({
status: "failure",
finished_at: new Date().toISOString(),
})
.where("id", "=", runId)
.where("status", "in", ["pending", "running"])
.execute();
} catch (dbErr) {
console.error(`[ci] failed to mark run ${runId} failed:`, dbErr);
}
});
}
export async function retryRun(
runId: number,
retriedBy: number,
@@ -1050,9 +1071,7 @@ export async function retryRun(
const controller = new AbortController();
runningTasks.set(runId, { controller });
(async () => {
await executeRun(runId, controller.signal);
})();
spawnRun(runId, controller.signal);
}
export async function cancelRun(runId: number): Promise<void> {
Msrc/services/markdown.ts
@@ -194,9 +194,9 @@ export function plaintextPreview(
if (firstLine.length <= maxLen) return firstLine;
const truncated = firstLine.slice(0, maxLen);
const lastSpace = truncated.lastIndexOf(" ");
return (
(lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD
return `${
lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD
? truncated.slice(0, lastSpace)
: truncated) + "…"
);
: truncated
}…`;
}
Msrc/services/patchCache.ts
@@ -1,15 +1,24 @@
import { MAX_PATCH_CACHE } from "../constants.ts";
import { MAX_PATCH_CACHE, PATCH_CACHE_TTL_MS } from "../constants.ts";
export type ApplyResult = { status: "clean" | "conflict"; output: string };
const cache = new Map<number, ApplyResult>();
type Entry = { result: ApplyResult; expiresAt: number };
const cache = new Map<number, Entry>();
export const patchCache = {
get: (id: number): ApplyResult | undefined => cache.get(id),
get: (id: number): ApplyResult | undefined => {
const entry = cache.get(id);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
cache.delete(id);
return undefined;
}
return entry.result;
},
set: (id: number, result: ApplyResult) => {
if (cache.size >= MAX_PATCH_CACHE) {
if (cache.size >= MAX_PATCH_CACHE && !cache.has(id)) {
cache.delete(cache.keys().next().value!);
}
cache.set(id, result);
cache.set(id, { result, expiresAt: Date.now() + PATCH_CACHE_TTL_MS });
},
invalidate: (id: number) => cache.delete(id),
};
Asrc/views/CommentThread.tsx
@@ -0,0 +1,134 @@
import config from "../config.ts";
import { displayName } from "../lib/users.ts";
import type { SessionUser } from "../middleware/session.ts";
import { Avatar } from "./Avatar.tsx";
import { DateWithEdited } from "./DateWithEdited.tsx";
import { ReactionBar, type ReactionCount } from "./ReactionBar.tsx";
export interface ThreadComment {
id: number;
author_id: number | null;
author_username: string;
author_avatar_version: number | null;
body: string;
bodyHtml: string;
created_at: string;
edited_at: string | null;
}
interface CommentThreadProps {
user: SessionUser | null;
comments: ThreadComment[];
commentReactions: Map<number, ReactionCount[]>;
baseUrl: string;
parentStatus: string;
}
export function CommentThread({
user,
comments,
commentReactions,
baseUrl,
parentStatus,
}: CommentThreadProps) {
const reactUrl = `${baseUrl}/react`;
const canEditComment = (c: ThreadComment) =>
user != null &&
(user.isAdmin || (user.id === c.author_id && parentStatus === "open"));
return (
<>
{comments.map((comment) => (
<div class="timeline-item">
<div class="timeline-author">
<Avatar
userId={comment.author_id}
version={comment.author_avatar_version}
size={24}
/>
<strong>{displayName(comment.author_username)}</strong>
<div class="timeline-author-right">
{canEditComment(comment) && (
<details class="inline-edit-details">
<summary class="btn btn-xs">
<span class="when-closed">Edit</span>
<span class="when-open">
Stop editing
</span>
</summary>
</details>
)}
<DateWithEdited
date={comment.created_at}
editedAt={comment.edited_at}
/>
</div>
</div>
{canEditComment(comment) && (
<div class="inline-edit-form-area">
<form
method="POST"
action={`${baseUrl}/comments/${comment.id}/edit`}
class="inline-edit-form"
>
<div class="form-group">
<textarea
class="form-input"
name="edit_body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
>
{comment.body}
</textarea>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
</div>
</form>
</div>
)}
<div class="timeline-body markdown-body">
{comment.bodyHtml}
</div>
<ReactionBar
reactions={commentReactions.get(comment.id) ?? []}
postUrl={reactUrl}
commentId={comment.id}
user={user}
/>
</div>
))}
{user && (
<div class="timeline-item timeline-item-new">
<h3 class="section-title">Add a comment</h3>
<form method="POST" action={`${baseUrl}/comments`}>
<div class="form-group">
<textarea
name="body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
placeholder="Leave a comment (Markdown supported)"
required
/>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
Comment
</button>
</div>
</form>
</div>
)}
{!user && (
<p class="text-muted">
<a href="/login">Sign in</a> to leave a comment.
</p>
)}
</>
);
}
Asrc/views/EditableTitle.tsx
@@ -0,0 +1,76 @@
import config from "../config.ts";
interface EditableTitleProps {
title: string;
canEdit: boolean;
editAction: string;
bodyFieldName: string;
bodyValue: string;
}
export function EditableTitle({
title,
canEdit,
editAction,
bodyFieldName,
bodyValue,
}: EditableTitleProps) {
return (
<>
{canEdit && (
<input
type="checkbox"
id="title-edit-toggle"
class="title-edit-toggle"
/>
)}
<div class="title-with-edit">
<h2 class="issue-detail-title">{title}</h2>
{canEdit && (
<>
<div class="title-edit-form-area">
<form
method="POST"
action={editAction}
class="title-edit-form"
>
<input
class="form-input"
name="title"
value={title}
required
maxlength={config.MAX_TITLE_BYTES}
/>
<input
type="hidden"
name={bodyFieldName}
value={bodyValue}
/>
<div class="title-edit-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
<label
for="title-edit-toggle"
class="btn btn-sm"
>
Cancel
</label>
</div>
</form>
</div>
<label
for="title-edit-toggle"
class="btn btn-xs title-edit-open"
>
Edit title
</label>
</>
)}
</div>
</>
);
}
Asrc/views/LabelBadges.tsx
@@ -0,0 +1,73 @@
import type { LabelRow } from "../db/index.ts";
import { labelTextColor } from "../lib/labelColor.ts";
interface LabelBadgesProps {
labels: LabelRow[];
repoLabels: LabelRow[];
canManage: boolean;
baseUrl: string;
}
export function LabelBadges({
labels,
repoLabels,
canManage,
baseUrl,
}: LabelBadgesProps) {
if (labels.length === 0 && !canManage) return null;
const available = repoLabels.filter(
(l) => !labels.some((al) => al.id === l.id),
);
return (
<div class="issue-labels-row">
{labels.map((label) => (
<span class="label-badge-wrap">
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
{canManage && (
<form
method="POST"
action={`${baseUrl}/labels/remove`}
class="label-remove-form"
>
<input
type="hidden"
name="label_id"
value={String(label.id)}
/>
<button
type="submit"
class="label-remove-btn"
title="Remove label"
>
×
</button>
</form>
)}
</span>
))}
{canManage && available.length > 0 && (
<form
method="POST"
action={`${baseUrl}/labels/add`}
class="label-add-inline-form"
>
<select name="label_id" class="label-select">
{available.map((label) => (
<option value={String(label.id)}>
{label.name}
</option>
))}
</select>
<button type="submit" class="btn btn-sm btn-secondary">
Add label
</button>
</form>
)}
</div>
);
}
Msrc/views/Settings.tsx
@@ -541,10 +541,7 @@ export function Settings({
)}
</div>
<script
type="module"
src="/assets/passkey-settings.js"
></script>
<script type="module" src="/assets/passkey-settings.js"></script>
</Layout>
);
}
Msrc/views/auth/Login.tsx
@@ -52,10 +52,7 @@ export function Login({ error }: LoginProps) {
Don't have an account? <a href="/register">Register</a>
</p>
</div>
<script
type="module"
src="/assets/passkey-login.js"
></script>
<script type="module" src="/assets/passkey-login.js"></script>
</Layout>
);
}
Msrc/views/auth/Register.tsx
@@ -99,10 +99,7 @@ export function Register({ error, question, pending }: RegisterProps) {
Already have an account? <a href="/login">Sign in</a>
</p>
</div>
<script
type="module"
src="/assets/passkey-register.js"
></script>
<script type="module" src="/assets/passkey-register.js"></script>
</Layout>
);
}
Msrc/views/issues/IssueDetail.tsx
@@ -5,11 +5,13 @@ import type {
LabelRow,
RepositoryRow,
} from "../../db/index.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import { Avatar } from "../Avatar.tsx";
import { CommentThread } from "../CommentThread.tsx";
import { DateWithEdited } from "../DateWithEdited.tsx";
import { EditableTitle } from "../EditableTitle.tsx";
import { LabelBadges } from "../LabelBadges.tsx";
import { Layout } from "../layout.tsx";
import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
@@ -45,6 +47,7 @@ export function IssueDetail({
issueLabels,
repoLabels,
}: IssueDetailProps) {
const baseUrl = `/${repo.name}/issues/${issue.number}`;
const canEditIssue =
user != null &&
(user.isAdmin ||
@@ -53,9 +56,6 @@ export function IssueDetail({
user != null &&
(user.isAdmin ||
(repo.allow_user_labels === 1 && user.id === issue.author_id));
const canEditComment = (c: IssueCommentRow) =>
user != null &&
(user.isAdmin || (user.id === c.author_id && issue.status === "open"));
return (
<Layout user={user} title={`${issue.title} — ${repo.name}`}>
<div class="container">
@@ -64,62 +64,13 @@ export function IssueDetail({
<div class="issue-detail">
<div class="issue-detail-header">
<span class="issue-number">#{issue.number}</span>
{canEditIssue && (
<input
type="checkbox"
id="title-edit-toggle"
class="title-edit-toggle"
/>
)}
<div class="title-with-edit">
<h2 class="issue-detail-title">{issue.title}</h2>
{canEditIssue && (
<>
<div class="title-edit-form-area">
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/edit`}
class="title-edit-form"
>
<input
class="form-input"
name="title"
value={issue.title}
required
maxlength={
config.MAX_TITLE_BYTES
}
/>
<input
type="hidden"
name="edit_body"
value={issue.body}
/>
<div class="title-edit-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
<label
for="title-edit-toggle"
class="btn btn-sm"
>
Cancel
</label>
</div>
</form>
</div>
<label
for="title-edit-toggle"
class="btn btn-xs title-edit-open"
>
Edit title
</label>
</>
)}
</div>
<EditableTitle
title={issue.title}
canEdit={canEditIssue}
editAction={`${baseUrl}/edit`}
bodyFieldName="edit_body"
bodyValue={issue.body}
/>
<span class={`issue-badge ${issue.status}`}>
{issue.status}
</span>
@@ -128,7 +79,7 @@ export function IssueDetail({
{user?.isAdmin && issue.status === "open" && (
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/complete`}
action={`${baseUrl}/complete`}
class="inline-form"
>
<button
@@ -142,7 +93,7 @@ export function IssueDetail({
{user?.isAdmin && (
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/close`}
action={`${baseUrl}/close`}
class="inline-form"
>
<button
@@ -163,7 +114,7 @@ export function IssueDetail({
Permanently delete this issue?
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/delete`}
action={`${baseUrl}/delete`}
class="inline-form"
>
<button
@@ -179,80 +130,12 @@ export function IssueDetail({
)}
</div>
{(issueLabels.length > 0 || canManageLabels) && (
<div class="issue-labels-row">
{issueLabels.map((label) => (
<span class="label-badge-wrap">
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
{canManageLabels && (
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/labels/remove`}
class="label-remove-form"
>
<input
type="hidden"
name="label_id"
value={String(label.id)}
/>
<button
type="submit"
class="label-remove-btn"
title="Remove label"
>
×
</button>
</form>
)}
</span>
))}
{canManageLabels &&
repoLabels.filter(
(l) =>
!issueLabels.some(
(il) => il.id === l.id,
),
).length > 0 && (
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/labels/add`}
class="label-add-inline-form"
>
<select
name="label_id"
class="label-select"
>
{repoLabels
.filter(
(l) =>
!issueLabels.some(
(il) =>
il.id === l.id,
),
)
.map((label) => (
<option
value={String(label.id)}
>
{label.name}
</option>
))}
</select>
<button
type="submit"
class="btn btn-sm btn-secondary"
>
Add label
</button>
</form>
)}
</div>
)}
<LabelBadges
labels={issueLabels}
repoLabels={repoLabels}
canManage={canManageLabels}
baseUrl={baseUrl}
/>
<div class="timeline-item">
<div class="timeline-author">
@@ -287,7 +170,7 @@ export function IssueDetail({
<div class="inline-edit-form-area">
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/edit`}
action={`${baseUrl}/edit`}
class="inline-edit-form"
>
<input
@@ -328,117 +211,18 @@ export function IssueDetail({
</div>
<ReactionBar
reactions={reactions}
postUrl={`/${repo.name}/issues/${issue.number}/react`}
postUrl={`${baseUrl}/react`}
user={user}
/>
</div>
{comments.map((comment) => (
<div class="timeline-item">
<div class="timeline-author">
<Avatar
userId={comment.author_id}
version={comment.author_avatar_version}
size={24}
/>
<strong>
{displayName(comment.author_username)}
</strong>
<div class="timeline-author-right">
{canEditComment(comment) && (
<details class="inline-edit-details">
<summary class="btn btn-xs">
<span class="when-closed">
Edit
</span>
<span class="when-open">
Stop editing
</span>
</summary>
</details>
)}
<DateWithEdited
date={comment.created_at}
editedAt={comment.edited_at}
/>
</div>
</div>
{canEditComment(comment) && (
<div class="inline-edit-form-area">
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/comments/${comment.id}/edit`}
class="inline-edit-form"
>
<div class="form-group">
<textarea
class="form-input"
name="edit_body"
rows="6"
maxlength={
config.MAX_TEXT_BODY_BYTES
}
>
{comment.body}
</textarea>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
</div>
</form>
</div>
)}
<div class="timeline-body markdown-body">
{comment.bodyHtml}
</div>
<ReactionBar
reactions={
commentReactions.get(comment.id) ?? []
}
postUrl={`/${repo.name}/issues/${issue.number}/react`}
commentId={comment.id}
user={user}
/>
</div>
))}
{user && (
<div class="timeline-item timeline-item-new">
<h3 class="section-title">Add a comment</h3>
<form
method="POST"
action={`/${repo.name}/issues/${issue.number}/comments`}
>
<div class="form-group">
<textarea
name="body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
placeholder="Leave a comment (Markdown supported)"
required
/>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
>
Comment
</button>
</div>
</form>
</div>
)}
{!user && (
<p class="text-muted">
<a href="/login">Sign in</a> to leave a comment.
</p>
)}
<CommentThread
user={user}
comments={comments}
commentReactions={commentReactions}
baseUrl={baseUrl}
parentStatus={issue.status}
/>
</div>
</div>
</Layout>
Msrc/views/patches/PatchDetail.tsx
@@ -7,15 +7,17 @@ import type {
RepositoryRow,
} from "../../db/index.ts";
import { formatDateTime } from "../../lib/formatDate.ts";
import { labelTextColor } from "../../lib/labelColor.ts";
import { displayName } from "../../lib/users.ts";
import type { SessionUser } from "../../middleware/session.ts";
import type { RenderedDiffFile } from "../../services/diffHighlight.ts";
import type { PatchMeta } from "../../services/git.ts";
import type { ApplyResult } from "../../services/patchCache.ts";
import { Avatar } from "../Avatar.tsx";
import { CommentThread } from "../CommentThread.tsx";
import { DateWithEdited } from "../DateWithEdited.tsx";
import { DiffView } from "../DiffView.tsx";
import { EditableTitle } from "../EditableTitle.tsx";
import { LabelBadges } from "../LabelBadges.tsx";
import { Layout } from "../layout.tsx";
import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx";
import { RepoHeader } from "../repos/RepoHeader.tsx";
@@ -69,9 +71,6 @@ export function PatchDetail({
user != null &&
(user.isAdmin ||
(repo.allow_user_labels === 1 && user.id === patch.author_id));
const canEditComment = (c: PatchCommentRow) =>
user != null &&
(user.isAdmin || (user.id === c.author_id && patch.status === "open"));
const baseUrl = `/${repo.name}/patches/${patch.number}`;
const reactUrl = `${baseUrl}/react`;
@@ -85,62 +84,13 @@ export function PatchDetail({
<div class="issue-detail">
<div class="issue-detail-header">
<span class="issue-number">#{patch.number}</span>
{canEdit && (
<input
type="checkbox"
id="title-edit-toggle"
class="title-edit-toggle"
/>
)}
<div class="title-with-edit">
<h2 class="issue-detail-title">{patch.title}</h2>
{canEdit && (
<>
<div class="title-edit-form-area">
<form
method="POST"
action={`${baseUrl}/edit`}
class="title-edit-form"
>
<input
class="form-input"
name="title"
value={patch.title}
required
maxlength={
config.MAX_TITLE_BYTES
}
/>
<input
type="hidden"
name="edit_description"
value={patch.description}
/>
<div class="title-edit-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
<label
for="title-edit-toggle"
class="btn btn-sm"
>
Cancel
</label>
</div>
</form>
</div>
<label
for="title-edit-toggle"
class="btn btn-xs title-edit-open"
>
Edit title
</label>
</>
)}
</div>
<EditableTitle
title={patch.title}
canEdit={canEdit}
editAction={`${baseUrl}/edit`}
bodyFieldName="edit_description"
bodyValue={patch.description}
/>
<span class={`patch-badge ${patch.status}`}>
{patch.status}
</span>
@@ -235,80 +185,12 @@ export function PatchDetail({
</div>
)}
</div>
{(patchLabels.length > 0 || canManageLabels) && (
<div class="issue-labels-row">
{patchLabels.map((label) => (
<span class="label-badge-wrap">
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
>
{label.name}
</span>
{canManageLabels && (
<form
method="POST"
action={`${baseUrl}/labels/remove`}
class="label-remove-form"
>
<input
type="hidden"
name="label_id"
value={String(label.id)}
/>
<button
type="submit"
class="label-remove-btn"
title="Remove label"
>
×
</button>
</form>
)}
</span>
))}
{canManageLabels &&
repoLabels.filter(
(l) =>
!patchLabels.some(
(pl) => pl.id === l.id,
),
).length > 0 && (
<form
method="POST"
action={`${baseUrl}/labels/add`}
class="label-add-inline-form"
>
<select
name="label_id"
class="label-select"
>
{repoLabels
.filter(
(l) =>
!patchLabels.some(
(pl) =>
pl.id === l.id,
),
)
.map((label) => (
<option
value={String(label.id)}
>
{label.name}
</option>
))}
</select>
<button
type="submit"
class="btn btn-sm btn-secondary"
>
Add label
</button>
</form>
)}
</div>
)}
<LabelBadges
labels={patchLabels}
repoLabels={repoLabels}
canManage={canManageLabels}
baseUrl={baseUrl}
/>
</div>
{/* Subview tabs */}
@@ -442,115 +324,13 @@ export function PatchDetail({
</div>
)}
{/* Comments */}
{comments.map((comment) => (
<div class="timeline-item">
<div class="timeline-author">
<Avatar
userId={comment.author_id}
version={comment.author_avatar_version}
size={24}
/>
<strong>
{displayName(comment.author_username)}
</strong>
<div class="timeline-author-right">
{canEditComment(comment) && (
<details class="inline-edit-details">
<summary class="btn btn-xs">
<span class="when-closed">
Edit
</span>
<span class="when-open">
Stop editing
</span>
</summary>
</details>
)}
<DateWithEdited
date={comment.created_at}
editedAt={comment.edited_at}
/>
</div>
</div>
{canEditComment(comment) && (
<div class="inline-edit-form-area">
<form
method="POST"
action={`${baseUrl}/comments/${comment.id}/edit`}
class="inline-edit-form"
>
<div class="form-group">
<textarea
class="form-input"
name="edit_body"
rows="6"
maxlength={
config.MAX_TEXT_BODY_BYTES
}
>
{comment.body}
</textarea>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-sm btn-primary"
>
Save
</button>
</div>
</form>
</div>
)}
<div class="timeline-body markdown-body">
{comment.bodyHtml}
</div>
<ReactionBar
reactions={
commentReactions.get(comment.id) ?? []
}
postUrl={reactUrl}
commentId={comment.id}
user={user}
/>
</div>
))}
{user && (
<div class="timeline-item timeline-item-new">
<h3 class="section-title">Add a comment</h3>
<form
method="POST"
action={`${baseUrl}/comments`}
>
<div class="form-group">
<textarea
name="body"
rows="6"
maxlength={
config.MAX_TEXT_BODY_BYTES
}
placeholder="Leave a comment (Markdown supported)"
required
/>
</div>
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
>
Comment
</button>
</div>
</form>
</div>
)}
{!user && (
<p class="text-muted">
<a href="/login">Sign in</a> to leave a comment.
</p>
)}
<CommentThread
user={user}
comments={comments}
commentReactions={commentReactions}
baseUrl={baseUrl}
parentStatus={patch.status}
/>
</div>
) : (
<div>
Msrc/views/repos/BranchSelector.tsx
@@ -32,11 +32,7 @@ export function BranchSelector({
<input type="hidden" name="view" value={view} />
{path && <input type="hidden" name="path" value={path} />}
<span class="branch-selector-icon">⎇</span>
<select
name="rev"
class="branch-select"
data-autosubmit
>
<select name="rev" class="branch-select" data-autosubmit>
{isDetached && (
<option value={currentRef} selected>
{shortRef} (detached)
Msrc/views/repos/FileBlob.tsx
@@ -133,7 +133,7 @@ export function FileBlob({
) : view.mimeType.startsWith("audio/") ? (
// biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files
<audio
controls
controls=""
src={`/${repo.name}/raw/${blobRef}/${filePath}`}
class="file-media-audio"
/>
Mtests/e2e.auth.test.ts
@@ -99,6 +99,20 @@ describe('auth', () => {
} finally { await ctx.close(); }
});
test('cross-origin POST is rejected (CSRF defense)', async () => {
const ctx = await browser.newContext();
try {
// Forge an Origin from a different host; server should refuse the POST.
const resp = await ctx.request.post(`${BASE}/login`, {
headers: { Origin: 'http://evil.example' },
form: { username: 'admin', password: ADMIN_PASS },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
// Other tests (login, register) cover the same-origin success path.
} finally { await ctx.close(); }
});
test('logout clears session', async () => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
Mtests/e2e.issues.test.ts
@@ -340,6 +340,42 @@ describe('issue editing', () => {
} finally { await page.close(); }
});
test('cannot edit comment via wrong repo url (cross-repo bypass)', async () => {
// Create a second repo
const setupPage = await adminCtx.newPage();
try {
await setupPage.goto(`${BASE}/new`);
await setupPage.fill('[name=name]', 'other-repo');
await setupPage.click('form[action="/new"] button[type=submit]');
await setupPage.waitForURL(`${BASE}/other-repo`);
} finally { await setupPage.close(); }
// Pull a comment id from the existing my-repo issue
const issueNum = issueUrl.split('/issues/')[1];
const page = await adminCtx.newPage();
try {
await page.goto(issueUrl);
const formAction = await page
.locator(`form[action*="/my-repo/issues/${issueNum}/comments/"][action$="/edit"]`)
.first()
.getAttribute('action');
expect(formAction).toBeTruthy();
const commentId = formAction!.split('/comments/')[1]!.split('/')[0];
// Edit the same comment via /other-repo/... — must 404, not 200/302
const resp = await page.request.post(
`${BASE}/other-repo/issues/${issueNum}/comments/${commentId}/edit`,
{ form: { edit_body: 'cross-repo bypass attempt' }, maxRedirects: 0 },
);
expect(resp.status()).toBe(404);
// And the original comment must be unchanged
await page.goto(issueUrl);
const bodies = await page.locator('.timeline-body').allTextContents();
expect(bodies.every(b => !b.includes('cross-repo bypass attempt'))).toBe(true);
} finally { await page.close(); }
});
test('admin can delete issue', async () => {
const issueNum = issueUrl.split('/issues/')[1];
const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, {