various small improvements
Msrc/app.ts
| @@ -49,6 +49,30 @@ export async function createApp(port: number) { | |||
|---|---|---|---|
| 49 | 49 | prefix: "/", | |
| 50 | 50 | }), | |
| 51 | 51 | ) | |
| 52 | + | .onRequest(({ request }) => { | |
| 53 | + | // CSRF defense-in-depth: reject mutating requests whose Origin | |
| 54 | + | // header is present but does not match the Host. Modern browsers | |
| 55 | + | // attach Origin to all cross-site mutating requests, so this | |
| 56 | + | // catches what SameSite=Lax cookies would have allowed (top-level | |
| 57 | + | // POSTs from same-origin contexts are unaffected). Non-browser | |
| 58 | + | // clients (git push, curl) typically omit Origin and pass through. | |
| 59 | + | const m = request.method; | |
| 60 | + | if (m !== "POST" && m !== "PUT" && m !== "PATCH" && m !== "DELETE") | |
| 61 | + | return; | |
| 62 | + | const origin = request.headers.get("origin"); | |
| 63 | + | if (!origin) return; | |
| 64 | + | const host = request.headers.get("host"); | |
| 65 | + | let originHost: string; | |
| 66 | + | try { | |
| 67 | + | originHost = new URL(origin).host; | |
| 68 | + | } catch { | |
| 69 | + | return new Response("Bad Origin", { status: 403 }); | |
| 70 | + | } | |
| 71 | + | if (!host || originHost !== host) | |
| 72 | + | return new Response("Cross-origin request rejected", { | |
| 73 | + | status: 403, | |
| 74 | + | }); | |
| 75 | + | }) | |
| 52 | 76 | .onAfterHandle(({ response }) => { | |
| 53 | 77 | if (response instanceof Response) { | |
| 54 | 78 | response.headers.set("Content-Security-Policy", CSP); | |
Msrc/constants.ts
| @@ -73,6 +73,7 @@ export const MAX_MD_CACHE = 50; | |||
|---|---|---|---|
| 73 | 73 | export const MAX_FILE_CACHE = 500; | |
| 74 | 74 | export const MAX_DIFF_CACHE = 500; | |
| 75 | 75 | export const MAX_PATCH_CACHE = 100; | |
| 76 | + | export const PATCH_CACHE_TTL_MS = 60 * 60 * 1000; | |
| 76 | 77 | export const MAX_BRANCH_CACHE = 200; | |
| 77 | 78 | export const MAX_TAG_CACHE = 200; | |
| 78 | 79 | 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 ( | |||
|---|---|---|---|
| 373 | 373 | )`); | |
| 374 | 374 | ||
| 375 | 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 | - | ); | |
| 376 | + | sqlite.run("CREATE INDEX IF NOT EXISTS idx_issues_repo_id ON issues(repo_id)"); | |
| 379 | 377 | sqlite.run( | |
| 380 | 378 | "CREATE INDEX IF NOT EXISTS idx_issues_author_id ON issues(author_id)", | |
| 381 | 379 | ); | |
| @@ -400,8 +398,15 @@ sqlite.run( | |||
|---|---|---|---|
| 400 | 398 | sqlite.run( | |
| 401 | 399 | "CREATE INDEX IF NOT EXISTS idx_patch_labels_label_id ON patch_labels(label_id)", | |
| 402 | 400 | ); | |
| 401 | + | sqlite.run("CREATE INDEX IF NOT EXISTS idx_labels_repo_id ON labels(repo_id)"); | |
| 402 | + | sqlite.run( | |
| 403 | + | "CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at)", | |
| 404 | + | ); | |
| 405 | + | sqlite.run( | |
| 406 | + | "CREATE INDEX IF NOT EXISTS idx_issues_repo_status ON issues(repo_id, status)", | |
| 407 | + | ); | |
| 403 | 408 | sqlite.run( | |
| 404 | - | "CREATE INDEX IF NOT EXISTS idx_labels_repo_id ON labels(repo_id)", | |
| 409 | + | "CREATE INDEX IF NOT EXISTS idx_patches_repo_status ON patches(repo_id, status)", | |
| 405 | 410 | ); | |
| 406 | 411 | ||
| 407 | 412 | // Clean up expired sessions on startup | |
Asrc/lib/commentAuth.ts
| @@ -0,0 +1,47 @@ | |||
|---|---|---|---|
| 1 | + | import { db } from "../db/index.ts"; | |
| 2 | + | import type { SessionUser } from "../middleware/session.ts"; | |
| 3 | + | ||
| 4 | + | /** | |
| 5 | + | * Authorise an edit on an issue or patch comment. | |
| 6 | + | * | |
| 7 | + | * Returns a Response if the request must be denied, or null if it may proceed. | |
| 8 | + | * | |
| 9 | + | * Verifies, in one query, that: | |
| 10 | + | * 1. the comment exists, and | |
| 11 | + | * 2. its parent issue/patch belongs to the requested repo (prevents | |
| 12 | + | * cross-repo bypasses where the URL repo and the comment's repo differ), | |
| 13 | + | * 3. the user is the comment author or an admin, | |
| 14 | + | * 4. the parent issue/patch is open (admins may edit on closed parents). | |
| 15 | + | */ | |
| 16 | + | export async function authorizeCommentEdit( | |
| 17 | + | kind: "issue" | "patch", | |
| 18 | + | commentId: number, | |
| 19 | + | repoId: number, | |
| 20 | + | user: SessionUser | null, | |
| 21 | + | ): Promise<Response | null> { | |
| 22 | + | if (!user) return new Response("Unauthorized", { status: 401 }); | |
| 23 | + | ||
| 24 | + | const row = | |
| 25 | + | kind === "issue" | |
| 26 | + | ? await db | |
| 27 | + | .selectFrom("issue_comments") | |
| 28 | + | .innerJoin("issues", "issues.id", "issue_comments.issue_id") | |
| 29 | + | .select(["issue_comments.author_id", "issues.status"]) | |
| 30 | + | .where("issue_comments.id", "=", commentId) | |
| 31 | + | .where("issues.repo_id", "=", repoId) | |
| 32 | + | .executeTakeFirst() | |
| 33 | + | : await db | |
| 34 | + | .selectFrom("patch_comments") | |
| 35 | + | .innerJoin("patches", "patches.id", "patch_comments.patch_id") | |
| 36 | + | .select(["patch_comments.author_id", "patches.status"]) | |
| 37 | + | .where("patch_comments.id", "=", commentId) | |
| 38 | + | .where("patches.repo_id", "=", repoId) | |
| 39 | + | .executeTakeFirst(); | |
| 40 | + | ||
| 41 | + | if (!row) return new Response("Not found", { status: 404 }); | |
| 42 | + | if (row.author_id !== user.id && !user.isAdmin) | |
| 43 | + | return new Response("Forbidden", { status: 403 }); | |
| 44 | + | if (row.status !== "open" && !user.isAdmin) | |
| 45 | + | return new Response("Forbidden", { status: 403 }); | |
| 46 | + | return null; | |
| 47 | + | } | |
Asrc/lib/pagination.ts
| @@ -0,0 +1,15 @@ | |||
|---|---|---|---|
| 1 | + | /** | |
| 2 | + | * Compute pagination state for a list view. | |
| 3 | + | * | |
| 4 | + | * Clamps `page` into [1, totalPages] so an out-of-range query string | |
| 5 | + | * (?page=999) lands on the last page instead of an empty offset. | |
| 6 | + | */ | |
| 7 | + | export function paginate( | |
| 8 | + | rawPage: number | undefined, | |
| 9 | + | totalCount: number, | |
| 10 | + | perPage: number, | |
| 11 | + | ): { page: number; totalPages: number; offset: number } { | |
| 12 | + | const totalPages = Math.max(1, Math.ceil(totalCount / perPage)); | |
| 13 | + | const page = Math.min(Math.max(1, rawPage ?? 1), totalPages); | |
| 14 | + | return { page, totalPages, offset: (page - 1) * perPage }; | |
| 15 | + | } | |
Msrc/routes/ci.tsx
| @@ -3,6 +3,7 @@ import path from "node:path"; | |||
|---|---|---|---|
| 3 | 3 | import { Elysia, t } from "elysia"; | |
| 4 | 4 | import { CI_RUNS_PER_PAGE, paths } from "../constants.ts"; | |
| 5 | 5 | import { db, getRepo } from "../db/index.ts"; | |
| 6 | + | import { paginate } from "../lib/pagination.ts"; | |
| 6 | 7 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 7 | 8 | import { | |
| 8 | 9 | cancelRun, | |
| @@ -87,19 +88,20 @@ export const ciRoutes = new Elysia() | |||
|---|---|---|---|
| 87 | 88 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 88 | 89 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 89 | 90 | ||
| 90 | - | const page = Math.max(1, query.page ?? 1); | |
| 91 | - | ||
| 92 | 91 | const countRow = await db | |
| 93 | 92 | .selectFrom("ci_runs") | |
| 94 | 93 | .select(db.fn.countAll<number>().as("count")) | |
| 95 | 94 | .where("repo_id", "=", repo.id) | |
| 96 | 95 | .executeTakeFirst(); | |
| 97 | - | const totalPages = Math.max( | |
| 98 | - | 1, | |
| 99 | - | Math.ceil(Number(countRow?.count ?? 0) / CI_RUNS_PER_PAGE), | |
| 96 | + | const { | |
| 97 | + | page: safePage, | |
| 98 | + | totalPages, | |
| 99 | + | offset, | |
| 100 | + | } = paginate( | |
| 101 | + | query.page, | |
| 102 | + | Number(countRow?.count ?? 0), | |
| 103 | + | CI_RUNS_PER_PAGE, | |
| 100 | 104 | ); | |
| 101 | - | const safePage = Math.min(page, totalPages); | |
| 102 | - | const offset = (safePage - 1) * CI_RUNS_PER_PAGE; | |
| 103 | 105 | ||
| 104 | 106 | const runs = await db | |
| 105 | 107 | .selectFrom("ci_runs") | |
Msrc/routes/issues.tsx
| @@ -4,6 +4,8 @@ import config from "../config.ts"; | |||
|---|---|---|---|
| 4 | 4 | import { ALLOWED_REACTIONS, ISSUES_PER_PAGE } from "../constants.ts"; | |
| 5 | 5 | import { issuesLabelFilter } from "../db/helpers.ts"; | |
| 6 | 6 | import { db, getRepo, type LabelRow } from "../db/index.ts"; | |
| 7 | + | import { authorizeCommentEdit } from "../lib/commentAuth.ts"; | |
| 8 | + | import { paginate } from "../lib/pagination.ts"; | |
| 7 | 9 | import { | |
| 8 | 10 | requireAdmin, | |
| 9 | 11 | requireAuth, | |
| @@ -33,7 +35,6 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 33 | 35 | : query.status === "completed" | |
| 34 | 36 | ? ("completed" as const) | |
| 35 | 37 | : ("open" as const); | |
| 36 | - | const page = Math.max(1, query.page ?? 1); | |
| 37 | 38 | ||
| 38 | 39 | // Parse label filter: query.labels may be a string or array of strings | |
| 39 | 40 | const rawLabels = query.labels; | |
| @@ -69,12 +70,11 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 69 | 70 | const counts: Record<string, number> = Object.fromEntries( | |
| 70 | 71 | allCounts.map((r) => [r.status, Number(r.count)]), | |
| 71 | 72 | ); | |
| 72 | - | const totalPages = Math.max( | |
| 73 | - | 1, | |
| 74 | - | Math.ceil((counts[status] ?? 0) / ISSUES_PER_PAGE), | |
| 75 | - | ); | |
| 76 | - | const safePage = Math.min(page, totalPages); | |
| 77 | - | const offset = (safePage - 1) * ISSUES_PER_PAGE; | |
| 73 | + | const { | |
| 74 | + | page: safePage, | |
| 75 | + | totalPages, | |
| 76 | + | offset, | |
| 77 | + | } = paginate(query.page, counts[status] ?? 0, ISSUES_PER_PAGE); | |
| 78 | 78 | ||
| 79 | 79 | let listQuery = db | |
| 80 | 80 | .selectFrom("issues") | |
| @@ -694,21 +694,13 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 694 | 694 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 695 | 695 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 696 | 696 | ||
| 697 | - | const comment = await db | |
| 698 | - | .selectFrom("issue_comments") | |
| 699 | - | .select(["id", "author_id", "issue_id"]) | |
| 700 | - | .where("id", "=", params.id) | |
| 701 | - | .executeTakeFirst(); | |
| 702 | - | if (!comment) return new Response("Not found", { status: 404 }); | |
| 703 | - | if (comment.author_id !== user?.id && !user?.isAdmin) | |
| 704 | - | return new Response("Forbidden", { status: 403 }); | |
| 705 | - | const parentIssue = await db | |
| 706 | - | .selectFrom("issues") | |
| 707 | - | .select("status") | |
| 708 | - | .where("id", "=", comment.issue_id) | |
| 709 | - | .executeTakeFirst(); | |
| 710 | - | if (parentIssue?.status !== "open" && !user?.isAdmin) | |
| 711 | - | return new Response("Forbidden", { status: 403 }); | |
| 697 | + | const denyComment = await authorizeCommentEdit( | |
| 698 | + | "issue", | |
| 699 | + | params.id, | |
| 700 | + | repo.id, | |
| 701 | + | user, | |
| 702 | + | ); | |
| 703 | + | if (denyComment) return denyComment; | |
| 712 | 704 | ||
| 713 | 705 | const issueNum = parseInt(params.number, 10); | |
| 714 | 706 | await db | |
| @@ -717,7 +709,7 @@ export const issueRoutes = new Elysia() | |||
|---|---|---|---|
| 717 | 709 | body: body.edit_body.trim(), | |
| 718 | 710 | edited_at: new Date().toISOString(), | |
| 719 | 711 | }) | |
| 720 | - | .where("id", "=", comment.id) | |
| 712 | + | .where("id", "=", params.id) | |
| 721 | 713 | .execute(); | |
| 722 | 714 | ||
| 723 | 715 | return new Response(null, { | |
Msrc/routes/patches.tsx
| @@ -4,6 +4,8 @@ import config from "../config.ts"; | |||
|---|---|---|---|
| 4 | 4 | import { ALLOWED_REACTIONS, PATCHES_PER_PAGE } from "../constants.ts"; | |
| 5 | 5 | import { patchesLabelFilter } from "../db/helpers.ts"; | |
| 6 | 6 | import { db, getRepo, type LabelRow } from "../db/index.ts"; | |
| 7 | + | import { authorizeCommentEdit } from "../lib/commentAuth.ts"; | |
| 8 | + | import { paginate } from "../lib/pagination.ts"; | |
| 7 | 9 | import { | |
| 8 | 10 | requireAdmin, | |
| 9 | 11 | requireAuth, | |
| @@ -61,7 +63,6 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 61 | 63 | ) | |
| 62 | 64 | ? query.status! | |
| 63 | 65 | : "open"; | |
| 64 | - | const page = Math.max(1, query.page ?? 1); | |
| 65 | 66 | ||
| 66 | 67 | // Parse label filter | |
| 67 | 68 | const rawLabels = query.labels; | |
| @@ -100,12 +101,11 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 100 | 101 | const counts: Record<string, number> = Object.fromEntries( | |
| 101 | 102 | allCounts.map((r) => [r.status, Number(r.count)]), | |
| 102 | 103 | ); | |
| 103 | - | const totalPages = Math.max( | |
| 104 | - | 1, | |
| 105 | - | Math.ceil((counts[status] ?? 0) / PATCHES_PER_PAGE), | |
| 106 | - | ); | |
| 107 | - | const safePage = Math.min(page, totalPages); | |
| 108 | - | const offset = (safePage - 1) * PATCHES_PER_PAGE; | |
| 104 | + | const { | |
| 105 | + | page: safePage, | |
| 106 | + | totalPages, | |
| 107 | + | offset, | |
| 108 | + | } = paginate(query.page, counts[status] ?? 0, PATCHES_PER_PAGE); | |
| 109 | 109 | ||
| 110 | 110 | let listQuery = db | |
| 111 | 111 | .selectFrom("patches") | |
| @@ -933,21 +933,13 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 933 | 933 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 934 | 934 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 935 | 935 | ||
| 936 | - | const comment = await db | |
| 937 | - | .selectFrom("patch_comments") | |
| 938 | - | .select(["id", "author_id", "patch_id"]) | |
| 939 | - | .where("id", "=", params.id) | |
| 940 | - | .executeTakeFirst(); | |
| 941 | - | if (!comment) return new Response("Not found", { status: 404 }); | |
| 942 | - | if (comment.author_id !== user?.id && !user?.isAdmin) | |
| 943 | - | return new Response("Forbidden", { status: 403 }); | |
| 944 | - | const parentPatch = await db | |
| 945 | - | .selectFrom("patches") | |
| 946 | - | .select("status") | |
| 947 | - | .where("id", "=", comment.patch_id) | |
| 948 | - | .executeTakeFirst(); | |
| 949 | - | if (parentPatch?.status !== "open" && !user?.isAdmin) | |
| 950 | - | return new Response("Forbidden", { status: 403 }); | |
| 936 | + | const denyComment = await authorizeCommentEdit( | |
| 937 | + | "patch", | |
| 938 | + | params.id, | |
| 939 | + | repo.id, | |
| 940 | + | user, | |
| 941 | + | ); | |
| 942 | + | if (denyComment) return denyComment; | |
| 951 | 943 | ||
| 952 | 944 | const patchNum = parseInt(params.number, 10); | |
| 953 | 945 | await db | |
| @@ -956,7 +948,7 @@ export const patchRoutes = new Elysia() | |||
|---|---|---|---|
| 956 | 948 | body: body.edit_body.trim(), | |
| 957 | 949 | edited_at: new Date().toISOString(), | |
| 958 | 950 | }) | |
| 959 | - | .where("id", "=", comment.id) | |
| 951 | + | .where("id", "=", params.id) | |
| 960 | 952 | .execute(); | |
| 961 | 953 | ||
| 962 | 954 | return new Response(null, { | |
Msrc/routes/releases.tsx
| @@ -4,6 +4,7 @@ import { Elysia, t } from "elysia"; | |||
|---|---|---|---|
| 4 | 4 | import config from "../config.ts"; | |
| 5 | 5 | import { paths, RELEASES_PER_PAGE } from "../constants.ts"; | |
| 6 | 6 | import { db, getRepo } from "../db/index.ts"; | |
| 7 | + | import { paginate } from "../lib/pagination.ts"; | |
| 7 | 8 | import { requireAdmin, resolveSession } from "../middleware/session.ts"; | |
| 8 | 9 | import { archiveRepo, git } from "../services/git.ts"; | |
| 9 | 10 | import { renderMarkdown } from "../services/markdown.ts"; | |
| @@ -35,19 +36,20 @@ export const releasesRoutes = new Elysia() | |||
|---|---|---|---|
| 35 | 36 | const repo = await getRepo(params.repo, user?.isAdmin ?? false); | |
| 36 | 37 | if (!repo) return new Response("Not found", { status: 404 }); | |
| 37 | 38 | ||
| 38 | - | const page = Math.max(1, query.page ?? 1); | |
| 39 | - | ||
| 40 | 39 | const countRow = await db | |
| 41 | 40 | .selectFrom("releases") | |
| 42 | 41 | .select(db.fn.countAll<number>().as("count")) | |
| 43 | 42 | .where("repo_id", "=", repo.id) | |
| 44 | 43 | .executeTakeFirst(); | |
| 45 | - | const totalPages = Math.max( | |
| 46 | - | 1, | |
| 47 | - | Math.ceil(Number(countRow?.count ?? 0) / RELEASES_PER_PAGE), | |
| 44 | + | const { | |
| 45 | + | page: safePage, | |
| 46 | + | totalPages, | |
| 47 | + | offset, | |
| 48 | + | } = paginate( | |
| 49 | + | query.page, | |
| 50 | + | Number(countRow?.count ?? 0), | |
| 51 | + | RELEASES_PER_PAGE, | |
| 48 | 52 | ); | |
| 49 | - | const safePage = Math.min(page, totalPages); | |
| 50 | - | const offset = (safePage - 1) * RELEASES_PER_PAGE; | |
| 51 | 53 | ||
| 52 | 54 | const releasesRaw = await db | |
| 53 | 55 | .selectFrom("releases") | |
Msrc/services/ci.ts
| @@ -1007,13 +1007,34 @@ export async function triggerRun( | |||
|---|---|---|---|
| 1007 | 1007 | runningTasks.set(runId.id, { controller }); | |
| 1008 | 1008 | ||
| 1009 | 1009 | // Fire and forget — like release archiving | |
| 1010 | - | (async () => { | |
| 1011 | - | await executeRun(runId.id, controller.signal); | |
| 1012 | - | })(); | |
| 1010 | + | spawnRun(runId.id, controller.signal); | |
| 1013 | 1011 | ||
| 1014 | 1012 | return runId.id; | |
| 1015 | 1013 | } | |
| 1016 | 1014 | ||
| 1015 | + | // Wraps the fire-and-forget executeRun so unhandled exceptions (e.g. a throw | |
| 1016 | + | // before/after its own try/finally) are logged and the run is reconciled to | |
| 1017 | + | // "failure" instead of staying pending forever. | |
| 1018 | + | function spawnRun(runId: number, signal: AbortSignal): void { | |
| 1019 | + | void executeRun(runId, signal).catch(async (err) => { | |
| 1020 | + | console.error(`[ci] executeRun threw for run ${runId}:`, err); | |
| 1021 | + | runningTasks.delete(runId); | |
| 1022 | + | try { | |
| 1023 | + | await db | |
| 1024 | + | .updateTable("ci_runs") | |
| 1025 | + | .set({ | |
| 1026 | + | status: "failure", | |
| 1027 | + | finished_at: new Date().toISOString(), | |
| 1028 | + | }) | |
| 1029 | + | .where("id", "=", runId) | |
| 1030 | + | .where("status", "in", ["pending", "running"]) | |
| 1031 | + | .execute(); | |
| 1032 | + | } catch (dbErr) { | |
| 1033 | + | console.error(`[ci] failed to mark run ${runId} failed:`, dbErr); | |
| 1034 | + | } | |
| 1035 | + | }); | |
| 1036 | + | } | |
| 1037 | + | ||
| 1017 | 1038 | export async function retryRun( | |
| 1018 | 1039 | runId: number, | |
| 1019 | 1040 | retriedBy: number, | |
| @@ -1050,9 +1071,7 @@ export async function retryRun( | |||
|---|---|---|---|
| 1050 | 1071 | const controller = new AbortController(); | |
| 1051 | 1072 | runningTasks.set(runId, { controller }); | |
| 1052 | 1073 | ||
| 1053 | - | (async () => { | |
| 1054 | - | await executeRun(runId, controller.signal); | |
| 1055 | - | })(); | |
| 1074 | + | spawnRun(runId, controller.signal); | |
| 1056 | 1075 | } | |
| 1057 | 1076 | ||
| 1058 | 1077 | export async function cancelRun(runId: number): Promise<void> { | |
Msrc/services/markdown.ts
| @@ -194,9 +194,9 @@ export function plaintextPreview( | |||
|---|---|---|---|
| 194 | 194 | if (firstLine.length <= maxLen) return firstLine; | |
| 195 | 195 | const truncated = firstLine.slice(0, maxLen); | |
| 196 | 196 | const lastSpace = truncated.lastIndexOf(" "); | |
| 197 | - | return ( | |
| 198 | - | (lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD | |
| 197 | + | return `${ | |
| 198 | + | lastSpace > maxLen * PREVIEW_TRUNCATION_THRESHOLD | |
| 199 | 199 | ? truncated.slice(0, lastSpace) | |
| 200 | - | : truncated) + "…" | |
| 201 | - | ); | |
| 200 | + | : truncated | |
| 201 | + | }…`; | |
| 202 | 202 | } | |
Msrc/services/patchCache.ts
| @@ -1,15 +1,24 @@ | |||
|---|---|---|---|
| 1 | - | import { MAX_PATCH_CACHE } from "../constants.ts"; | |
| 1 | + | import { MAX_PATCH_CACHE, PATCH_CACHE_TTL_MS } from "../constants.ts"; | |
| 2 | 2 | ||
| 3 | 3 | export type ApplyResult = { status: "clean" | "conflict"; output: string }; | |
| 4 | - | const cache = new Map<number, ApplyResult>(); | |
| 4 | + | type Entry = { result: ApplyResult; expiresAt: number }; | |
| 5 | + | const cache = new Map<number, Entry>(); | |
| 5 | 6 | ||
| 6 | 7 | export const patchCache = { | |
| 7 | - | get: (id: number): ApplyResult | undefined => cache.get(id), | |
| 8 | + | get: (id: number): ApplyResult | undefined => { | |
| 9 | + | const entry = cache.get(id); | |
| 10 | + | if (!entry) return undefined; | |
| 11 | + | if (entry.expiresAt <= Date.now()) { | |
| 12 | + | cache.delete(id); | |
| 13 | + | return undefined; | |
| 14 | + | } | |
| 15 | + | return entry.result; | |
| 16 | + | }, | |
| 8 | 17 | set: (id: number, result: ApplyResult) => { | |
| 9 | - | if (cache.size >= MAX_PATCH_CACHE) { | |
| 18 | + | if (cache.size >= MAX_PATCH_CACHE && !cache.has(id)) { | |
| 10 | 19 | cache.delete(cache.keys().next().value!); | |
| 11 | 20 | } | |
| 12 | - | cache.set(id, result); | |
| 21 | + | cache.set(id, { result, expiresAt: Date.now() + PATCH_CACHE_TTL_MS }); | |
| 13 | 22 | }, | |
| 14 | 23 | invalidate: (id: number) => cache.delete(id), | |
| 15 | 24 | }; | |
Asrc/views/CommentThread.tsx
| @@ -0,0 +1,134 @@ | |||
|---|---|---|---|
| 1 | + | import config from "../config.ts"; | |
| 2 | + | import { displayName } from "../lib/users.ts"; | |
| 3 | + | import type { SessionUser } from "../middleware/session.ts"; | |
| 4 | + | import { Avatar } from "./Avatar.tsx"; | |
| 5 | + | import { DateWithEdited } from "./DateWithEdited.tsx"; | |
| 6 | + | import { ReactionBar, type ReactionCount } from "./ReactionBar.tsx"; | |
| 7 | + | ||
| 8 | + | export interface ThreadComment { | |
| 9 | + | id: number; | |
| 10 | + | author_id: number | null; | |
| 11 | + | author_username: string; | |
| 12 | + | author_avatar_version: number | null; | |
| 13 | + | body: string; | |
| 14 | + | bodyHtml: string; | |
| 15 | + | created_at: string; | |
| 16 | + | edited_at: string | null; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | interface CommentThreadProps { | |
| 20 | + | user: SessionUser | null; | |
| 21 | + | comments: ThreadComment[]; | |
| 22 | + | commentReactions: Map<number, ReactionCount[]>; | |
| 23 | + | baseUrl: string; | |
| 24 | + | parentStatus: string; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | export function CommentThread({ | |
| 28 | + | user, | |
| 29 | + | comments, | |
| 30 | + | commentReactions, | |
| 31 | + | baseUrl, | |
| 32 | + | parentStatus, | |
| 33 | + | }: CommentThreadProps) { | |
| 34 | + | const reactUrl = `${baseUrl}/react`; | |
| 35 | + | const canEditComment = (c: ThreadComment) => | |
| 36 | + | user != null && | |
| 37 | + | (user.isAdmin || (user.id === c.author_id && parentStatus === "open")); | |
| 38 | + | return ( | |
| 39 | + | <> | |
| 40 | + | {comments.map((comment) => ( | |
| 41 | + | <div class="timeline-item"> | |
| 42 | + | <div class="timeline-author"> | |
| 43 | + | <Avatar | |
| 44 | + | userId={comment.author_id} | |
| 45 | + | version={comment.author_avatar_version} | |
| 46 | + | size={24} | |
| 47 | + | /> | |
| 48 | + | <strong>{displayName(comment.author_username)}</strong> | |
| 49 | + | <div class="timeline-author-right"> | |
| 50 | + | {canEditComment(comment) && ( | |
| 51 | + | <details class="inline-edit-details"> | |
| 52 | + | <summary class="btn btn-xs"> | |
| 53 | + | <span class="when-closed">Edit</span> | |
| 54 | + | <span class="when-open"> | |
| 55 | + | Stop editing | |
| 56 | + | </span> | |
| 57 | + | </summary> | |
| 58 | + | </details> | |
| 59 | + | )} | |
| 60 | + | <DateWithEdited | |
| 61 | + | date={comment.created_at} | |
| 62 | + | editedAt={comment.edited_at} | |
| 63 | + | /> | |
| 64 | + | </div> | |
| 65 | + | </div> | |
| 66 | + | {canEditComment(comment) && ( | |
| 67 | + | <div class="inline-edit-form-area"> | |
| 68 | + | <form | |
| 69 | + | method="POST" | |
| 70 | + | action={`${baseUrl}/comments/${comment.id}/edit`} | |
| 71 | + | class="inline-edit-form" | |
| 72 | + | > | |
| 73 | + | <div class="form-group"> | |
| 74 | + | <textarea | |
| 75 | + | class="form-input" | |
| 76 | + | name="edit_body" | |
| 77 | + | rows="6" | |
| 78 | + | maxlength={config.MAX_TEXT_BODY_BYTES} | |
| 79 | + | > | |
| 80 | + | {comment.body} | |
| 81 | + | </textarea> | |
| 82 | + | </div> | |
| 83 | + | <div class="form-actions"> | |
| 84 | + | <button | |
| 85 | + | type="submit" | |
| 86 | + | class="btn btn-sm btn-primary" | |
| 87 | + | > | |
| 88 | + | Save | |
| 89 | + | </button> | |
| 90 | + | </div> | |
| 91 | + | </form> | |
| 92 | + | </div> | |
| 93 | + | )} | |
| 94 | + | <div class="timeline-body markdown-body"> | |
| 95 | + | {comment.bodyHtml} | |
| 96 | + | </div> | |
| 97 | + | <ReactionBar | |
| 98 | + | reactions={commentReactions.get(comment.id) ?? []} | |
| 99 | + | postUrl={reactUrl} | |
| 100 | + | commentId={comment.id} | |
| 101 | + | user={user} | |
| 102 | + | /> | |
| 103 | + | </div> | |
| 104 | + | ))} | |
| 105 | + | ||
| 106 | + | {user && ( | |
| 107 | + | <div class="timeline-item timeline-item-new"> | |
| 108 | + | <h3 class="section-title">Add a comment</h3> | |
| 109 | + | <form method="POST" action={`${baseUrl}/comments`}> | |
| 110 | + | <div class="form-group"> | |
| 111 | + | <textarea | |
| 112 | + | name="body" | |
| 113 | + | rows="6" | |
| 114 | + | maxlength={config.MAX_TEXT_BODY_BYTES} | |
| 115 | + | placeholder="Leave a comment (Markdown supported)" | |
| 116 | + | required | |
| 117 | + | /> | |
| 118 | + | </div> | |
| 119 | + | <div class="form-actions"> | |
| 120 | + | <button type="submit" class="btn btn-primary"> | |
| 121 | + | Comment | |
| 122 | + | </button> | |
| 123 | + | </div> | |
| 124 | + | </form> | |
| 125 | + | </div> | |
| 126 | + | )} | |
| 127 | + | {!user && ( | |
| 128 | + | <p class="text-muted"> | |
| 129 | + | <a href="/login">Sign in</a> to leave a comment. | |
| 130 | + | </p> | |
| 131 | + | )} | |
| 132 | + | </> | |
| 133 | + | ); | |
| 134 | + | } | |
Asrc/views/EditableTitle.tsx
| @@ -0,0 +1,76 @@ | |||
|---|---|---|---|
| 1 | + | import config from "../config.ts"; | |
| 2 | + | ||
| 3 | + | interface EditableTitleProps { | |
| 4 | + | title: string; | |
| 5 | + | canEdit: boolean; | |
| 6 | + | editAction: string; | |
| 7 | + | bodyFieldName: string; | |
| 8 | + | bodyValue: string; | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | export function EditableTitle({ | |
| 12 | + | title, | |
| 13 | + | canEdit, | |
| 14 | + | editAction, | |
| 15 | + | bodyFieldName, | |
| 16 | + | bodyValue, | |
| 17 | + | }: EditableTitleProps) { | |
| 18 | + | return ( | |
| 19 | + | <> | |
| 20 | + | {canEdit && ( | |
| 21 | + | <input | |
| 22 | + | type="checkbox" | |
| 23 | + | id="title-edit-toggle" | |
| 24 | + | class="title-edit-toggle" | |
| 25 | + | /> | |
| 26 | + | )} | |
| 27 | + | <div class="title-with-edit"> | |
| 28 | + | <h2 class="issue-detail-title">{title}</h2> | |
| 29 | + | {canEdit && ( | |
| 30 | + | <> | |
| 31 | + | <div class="title-edit-form-area"> | |
| 32 | + | <form | |
| 33 | + | method="POST" | |
| 34 | + | action={editAction} | |
| 35 | + | class="title-edit-form" | |
| 36 | + | > | |
| 37 | + | <input | |
| 38 | + | class="form-input" | |
| 39 | + | name="title" | |
| 40 | + | value={title} | |
| 41 | + | required | |
| 42 | + | maxlength={config.MAX_TITLE_BYTES} | |
| 43 | + | /> | |
| 44 | + | <input | |
| 45 | + | type="hidden" | |
| 46 | + | name={bodyFieldName} | |
| 47 | + | value={bodyValue} | |
| 48 | + | /> | |
| 49 | + | <div class="title-edit-actions"> | |
| 50 | + | <button | |
| 51 | + | type="submit" | |
| 52 | + | class="btn btn-sm btn-primary" | |
| 53 | + | > | |
| 54 | + | Save | |
| 55 | + | </button> | |
| 56 | + | <label | |
| 57 | + | for="title-edit-toggle" | |
| 58 | + | class="btn btn-sm" | |
| 59 | + | > | |
| 60 | + | Cancel | |
| 61 | + | </label> | |
| 62 | + | </div> | |
| 63 | + | </form> | |
| 64 | + | </div> | |
| 65 | + | <label | |
| 66 | + | for="title-edit-toggle" | |
| 67 | + | class="btn btn-xs title-edit-open" | |
| 68 | + | > | |
| 69 | + | Edit title | |
| 70 | + | </label> | |
| 71 | + | </> | |
| 72 | + | )} | |
| 73 | + | </div> | |
| 74 | + | </> | |
| 75 | + | ); | |
| 76 | + | } | |
Asrc/views/LabelBadges.tsx
| @@ -0,0 +1,73 @@ | |||
|---|---|---|---|
| 1 | + | import type { LabelRow } from "../db/index.ts"; | |
| 2 | + | import { labelTextColor } from "../lib/labelColor.ts"; | |
| 3 | + | ||
| 4 | + | interface LabelBadgesProps { | |
| 5 | + | labels: LabelRow[]; | |
| 6 | + | repoLabels: LabelRow[]; | |
| 7 | + | canManage: boolean; | |
| 8 | + | baseUrl: string; | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | export function LabelBadges({ | |
| 12 | + | labels, | |
| 13 | + | repoLabels, | |
| 14 | + | canManage, | |
| 15 | + | baseUrl, | |
| 16 | + | }: LabelBadgesProps) { | |
| 17 | + | if (labels.length === 0 && !canManage) return null; | |
| 18 | + | const available = repoLabels.filter( | |
| 19 | + | (l) => !labels.some((al) => al.id === l.id), | |
| 20 | + | ); | |
| 21 | + | return ( | |
| 22 | + | <div class="issue-labels-row"> | |
| 23 | + | {labels.map((label) => ( | |
| 24 | + | <span class="label-badge-wrap"> | |
| 25 | + | <span | |
| 26 | + | class="label-badge" | |
| 27 | + | style={`background:${label.color};color:${labelTextColor(label.color)}`} | |
| 28 | + | > | |
| 29 | + | {label.name} | |
| 30 | + | </span> | |
| 31 | + | {canManage && ( | |
| 32 | + | <form | |
| 33 | + | method="POST" | |
| 34 | + | action={`${baseUrl}/labels/remove`} | |
| 35 | + | class="label-remove-form" | |
| 36 | + | > | |
| 37 | + | <input | |
| 38 | + | type="hidden" | |
| 39 | + | name="label_id" | |
| 40 | + | value={String(label.id)} | |
| 41 | + | /> | |
| 42 | + | <button | |
| 43 | + | type="submit" | |
| 44 | + | class="label-remove-btn" | |
| 45 | + | title="Remove label" | |
| 46 | + | > | |
| 47 | + | × | |
| 48 | + | </button> | |
| 49 | + | </form> | |
| 50 | + | )} | |
| 51 | + | </span> | |
| 52 | + | ))} | |
| 53 | + | {canManage && available.length > 0 && ( | |
| 54 | + | <form | |
| 55 | + | method="POST" | |
| 56 | + | action={`${baseUrl}/labels/add`} | |
| 57 | + | class="label-add-inline-form" | |
| 58 | + | > | |
| 59 | + | <select name="label_id" class="label-select"> | |
| 60 | + | {available.map((label) => ( | |
| 61 | + | <option value={String(label.id)}> | |
| 62 | + | {label.name} | |
| 63 | + | </option> | |
| 64 | + | ))} | |
| 65 | + | </select> | |
| 66 | + | <button type="submit" class="btn btn-sm btn-secondary"> | |
| 67 | + | Add label | |
| 68 | + | </button> | |
| 69 | + | </form> | |
| 70 | + | )} | |
| 71 | + | </div> | |
| 72 | + | ); | |
| 73 | + | } | |
Msrc/views/Settings.tsx
| @@ -541,10 +541,7 @@ export function Settings({ | |||
|---|---|---|---|
| 541 | 541 | )} | |
| 542 | 542 | </div> | |
| 543 | 543 | ||
| 544 | - | <script | |
| 545 | - | type="module" | |
| 546 | - | src="/assets/passkey-settings.js" | |
| 547 | - | ></script> | |
| 544 | + | <script type="module" src="/assets/passkey-settings.js"></script> | |
| 548 | 545 | </Layout> | |
| 549 | 546 | ); | |
| 550 | 547 | } | |
Msrc/views/auth/Login.tsx
| @@ -52,10 +52,7 @@ export function Login({ error }: LoginProps) { | |||
|---|---|---|---|
| 52 | 52 | Don't have an account? <a href="/register">Register</a> | |
| 53 | 53 | </p> | |
| 54 | 54 | </div> | |
| 55 | - | <script | |
| 56 | - | type="module" | |
| 57 | - | src="/assets/passkey-login.js" | |
| 58 | - | ></script> | |
| 55 | + | <script type="module" src="/assets/passkey-login.js"></script> | |
| 59 | 56 | </Layout> | |
| 60 | 57 | ); | |
| 61 | 58 | } | |
Msrc/views/auth/Register.tsx
| @@ -99,10 +99,7 @@ export function Register({ error, question, pending }: RegisterProps) { | |||
|---|---|---|---|
| 99 | 99 | Already have an account? <a href="/login">Sign in</a> | |
| 100 | 100 | </p> | |
| 101 | 101 | </div> | |
| 102 | - | <script | |
| 103 | - | type="module" | |
| 104 | - | src="/assets/passkey-register.js" | |
| 105 | - | ></script> | |
| 102 | + | <script type="module" src="/assets/passkey-register.js"></script> | |
| 106 | 103 | </Layout> | |
| 107 | 104 | ); | |
| 108 | 105 | } | |
Msrc/views/issues/IssueDetail.tsx
| @@ -5,11 +5,13 @@ import type { | |||
|---|---|---|---|
| 5 | 5 | LabelRow, | |
| 6 | 6 | RepositoryRow, | |
| 7 | 7 | } from "../../db/index.ts"; | |
| 8 | - | import { labelTextColor } from "../../lib/labelColor.ts"; | |
| 9 | 8 | import { displayName } from "../../lib/users.ts"; | |
| 10 | 9 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 11 | 10 | import { Avatar } from "../Avatar.tsx"; | |
| 11 | + | import { CommentThread } from "../CommentThread.tsx"; | |
| 12 | 12 | import { DateWithEdited } from "../DateWithEdited.tsx"; | |
| 13 | + | import { EditableTitle } from "../EditableTitle.tsx"; | |
| 14 | + | import { LabelBadges } from "../LabelBadges.tsx"; | |
| 13 | 15 | import { Layout } from "../layout.tsx"; | |
| 14 | 16 | import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx"; | |
| 15 | 17 | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| @@ -45,6 +47,7 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 45 | 47 | issueLabels, | |
| 46 | 48 | repoLabels, | |
| 47 | 49 | }: IssueDetailProps) { | |
| 50 | + | const baseUrl = `/${repo.name}/issues/${issue.number}`; | |
| 48 | 51 | const canEditIssue = | |
| 49 | 52 | user != null && | |
| 50 | 53 | (user.isAdmin || | |
| @@ -53,9 +56,6 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 53 | 56 | user != null && | |
| 54 | 57 | (user.isAdmin || | |
| 55 | 58 | (repo.allow_user_labels === 1 && user.id === issue.author_id)); | |
| 56 | - | const canEditComment = (c: IssueCommentRow) => | |
| 57 | - | user != null && | |
| 58 | - | (user.isAdmin || (user.id === c.author_id && issue.status === "open")); | |
| 59 | 59 | return ( | |
| 60 | 60 | <Layout user={user} title={`${issue.title} — ${repo.name}`}> | |
| 61 | 61 | <div class="container"> | |
| @@ -64,62 +64,13 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 64 | 64 | <div class="issue-detail"> | |
| 65 | 65 | <div class="issue-detail-header"> | |
| 66 | 66 | <span class="issue-number">#{issue.number}</span> | |
| 67 | - | {canEditIssue && ( | |
| 68 | - | <input | |
| 69 | - | type="checkbox" | |
| 70 | - | id="title-edit-toggle" | |
| 71 | - | class="title-edit-toggle" | |
| 72 | - | /> | |
| 73 | - | )} | |
| 74 | - | <div class="title-with-edit"> | |
| 75 | - | <h2 class="issue-detail-title">{issue.title}</h2> | |
| 76 | - | {canEditIssue && ( | |
| 77 | - | <> | |
| 78 | - | <div class="title-edit-form-area"> | |
| 79 | - | <form | |
| 80 | - | method="POST" | |
| 81 | - | action={`/${repo.name}/issues/${issue.number}/edit`} | |
| 82 | - | class="title-edit-form" | |
| 83 | - | > | |
| 84 | - | <input | |
| 85 | - | class="form-input" | |
| 86 | - | name="title" | |
| 87 | - | value={issue.title} | |
| 88 | - | required | |
| 89 | - | maxlength={ | |
| 90 | - | config.MAX_TITLE_BYTES | |
| 91 | - | } | |
| 92 | - | /> | |
| 93 | - | <input | |
| 94 | - | type="hidden" | |
| 95 | - | name="edit_body" | |
| 96 | - | value={issue.body} | |
| 97 | - | /> | |
| 98 | - | <div class="title-edit-actions"> | |
| 99 | - | <button | |
| 100 | - | type="submit" | |
| 101 | - | class="btn btn-sm btn-primary" | |
| 102 | - | > | |
| 103 | - | Save | |
| 104 | - | </button> | |
| 105 | - | <label | |
| 106 | - | for="title-edit-toggle" | |
| 107 | - | class="btn btn-sm" | |
| 108 | - | > | |
| 109 | - | Cancel | |
| 110 | - | </label> | |
| 111 | - | </div> | |
| 112 | - | </form> | |
| 113 | - | </div> | |
| 114 | - | <label | |
| 115 | - | for="title-edit-toggle" | |
| 116 | - | class="btn btn-xs title-edit-open" | |
| 117 | - | > | |
| 118 | - | Edit title | |
| 119 | - | </label> | |
| 120 | - | </> | |
| 121 | - | )} | |
| 122 | - | </div> | |
| 67 | + | <EditableTitle | |
| 68 | + | title={issue.title} | |
| 69 | + | canEdit={canEditIssue} | |
| 70 | + | editAction={`${baseUrl}/edit`} | |
| 71 | + | bodyFieldName="edit_body" | |
| 72 | + | bodyValue={issue.body} | |
| 73 | + | /> | |
| 123 | 74 | <span class={`issue-badge ${issue.status}`}> | |
| 124 | 75 | {issue.status} | |
| 125 | 76 | </span> | |
| @@ -128,7 +79,7 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 128 | 79 | {user?.isAdmin && issue.status === "open" && ( | |
| 129 | 80 | <form | |
| 130 | 81 | method="POST" | |
| 131 | - | action={`/${repo.name}/issues/${issue.number}/complete`} | |
| 82 | + | action={`${baseUrl}/complete`} | |
| 132 | 83 | class="inline-form" | |
| 133 | 84 | > | |
| 134 | 85 | <button | |
| @@ -142,7 +93,7 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 142 | 93 | {user?.isAdmin && ( | |
| 143 | 94 | <form | |
| 144 | 95 | method="POST" | |
| 145 | - | action={`/${repo.name}/issues/${issue.number}/close`} | |
| 96 | + | action={`${baseUrl}/close`} | |
| 146 | 97 | class="inline-form" | |
| 147 | 98 | > | |
| 148 | 99 | <button | |
| @@ -163,7 +114,7 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 163 | 114 | Permanently delete this issue? | |
| 164 | 115 | <form | |
| 165 | 116 | method="POST" | |
| 166 | - | action={`/${repo.name}/issues/${issue.number}/delete`} | |
| 117 | + | action={`${baseUrl}/delete`} | |
| 167 | 118 | class="inline-form" | |
| 168 | 119 | > | |
| 169 | 120 | <button | |
| @@ -179,80 +130,12 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 179 | 130 | )} | |
| 180 | 131 | </div> | |
| 181 | 132 | ||
| 182 | - | {(issueLabels.length > 0 || canManageLabels) && ( | |
| 183 | - | <div class="issue-labels-row"> | |
| 184 | - | {issueLabels.map((label) => ( | |
| 185 | - | <span class="label-badge-wrap"> | |
| 186 | - | <span | |
| 187 | - | class="label-badge" | |
| 188 | - | style={`background:${label.color};color:${labelTextColor(label.color)}`} | |
| 189 | - | > | |
| 190 | - | {label.name} | |
| 191 | - | </span> | |
| 192 | - | {canManageLabels && ( | |
| 193 | - | <form | |
| 194 | - | method="POST" | |
| 195 | - | action={`/${repo.name}/issues/${issue.number}/labels/remove`} | |
| 196 | - | class="label-remove-form" | |
| 197 | - | > | |
| 198 | - | <input | |
| 199 | - | type="hidden" | |
| 200 | - | name="label_id" | |
| 201 | - | value={String(label.id)} | |
| 202 | - | /> | |
| 203 | - | <button | |
| 204 | - | type="submit" | |
| 205 | - | class="label-remove-btn" | |
| 206 | - | title="Remove label" | |
| 207 | - | > | |
| 208 | - | × | |
| 209 | - | </button> | |
| 210 | - | </form> | |
| 211 | - | )} | |
| 212 | - | </span> | |
| 213 | - | ))} | |
| 214 | - | {canManageLabels && | |
| 215 | - | repoLabels.filter( | |
| 216 | - | (l) => | |
| 217 | - | !issueLabels.some( | |
| 218 | - | (il) => il.id === l.id, | |
| 219 | - | ), | |
| 220 | - | ).length > 0 && ( | |
| 221 | - | <form | |
| 222 | - | method="POST" | |
| 223 | - | action={`/${repo.name}/issues/${issue.number}/labels/add`} | |
| 224 | - | class="label-add-inline-form" | |
| 225 | - | > | |
| 226 | - | <select | |
| 227 | - | name="label_id" | |
| 228 | - | class="label-select" | |
| 229 | - | > | |
| 230 | - | {repoLabels | |
| 231 | - | .filter( | |
| 232 | - | (l) => | |
| 233 | - | !issueLabels.some( | |
| 234 | - | (il) => | |
| 235 | - | il.id === l.id, | |
| 236 | - | ), | |
| 237 | - | ) | |
| 238 | - | .map((label) => ( | |
| 239 | - | <option | |
| 240 | - | value={String(label.id)} | |
| 241 | - | > | |
| 242 | - | {label.name} | |
| 243 | - | </option> | |
| 244 | - | ))} | |
| 245 | - | </select> | |
| 246 | - | <button | |
| 247 | - | type="submit" | |
| 248 | - | class="btn btn-sm btn-secondary" | |
| 249 | - | > | |
| 250 | - | Add label | |
| 251 | - | </button> | |
| 252 | - | </form> | |
| 253 | - | )} | |
| 254 | - | </div> | |
| 255 | - | )} | |
| 133 | + | <LabelBadges | |
| 134 | + | labels={issueLabels} | |
| 135 | + | repoLabels={repoLabels} | |
| 136 | + | canManage={canManageLabels} | |
| 137 | + | baseUrl={baseUrl} | |
| 138 | + | /> | |
| 256 | 139 | ||
| 257 | 140 | <div class="timeline-item"> | |
| 258 | 141 | <div class="timeline-author"> | |
| @@ -287,7 +170,7 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 287 | 170 | <div class="inline-edit-form-area"> | |
| 288 | 171 | <form | |
| 289 | 172 | method="POST" | |
| 290 | - | action={`/${repo.name}/issues/${issue.number}/edit`} | |
| 173 | + | action={`${baseUrl}/edit`} | |
| 291 | 174 | class="inline-edit-form" | |
| 292 | 175 | > | |
| 293 | 176 | <input | |
| @@ -328,117 +211,18 @@ export function IssueDetail({ | |||
|---|---|---|---|
| 328 | 211 | </div> | |
| 329 | 212 | <ReactionBar | |
| 330 | 213 | reactions={reactions} | |
| 331 | - | postUrl={`/${repo.name}/issues/${issue.number}/react`} | |
| 214 | + | postUrl={`${baseUrl}/react`} | |
| 332 | 215 | user={user} | |
| 333 | 216 | /> | |
| 334 | 217 | </div> | |
| 335 | 218 | ||
| 336 | - | {comments.map((comment) => ( | |
| 337 | - | <div class="timeline-item"> | |
| 338 | - | <div class="timeline-author"> | |
| 339 | - | <Avatar | |
| 340 | - | userId={comment.author_id} | |
| 341 | - | version={comment.author_avatar_version} | |
| 342 | - | size={24} | |
| 343 | - | /> | |
| 344 | - | <strong> | |
| 345 | - | {displayName(comment.author_username)} | |
| 346 | - | </strong> | |
| 347 | - | <div class="timeline-author-right"> | |
| 348 | - | {canEditComment(comment) && ( | |
| 349 | - | <details class="inline-edit-details"> | |
| 350 | - | <summary class="btn btn-xs"> | |
| 351 | - | <span class="when-closed"> | |
| 352 | - | Edit | |
| 353 | - | </span> | |
| 354 | - | <span class="when-open"> | |
| 355 | - | Stop editing | |
| 356 | - | </span> | |
| 357 | - | </summary> | |
| 358 | - | </details> | |
| 359 | - | )} | |
| 360 | - | <DateWithEdited | |
| 361 | - | date={comment.created_at} | |
| 362 | - | editedAt={comment.edited_at} | |
| 363 | - | /> | |
| 364 | - | </div> | |
| 365 | - | </div> | |
| 366 | - | {canEditComment(comment) && ( | |
| 367 | - | <div class="inline-edit-form-area"> | |
| 368 | - | <form | |
| 369 | - | method="POST" | |
| 370 | - | action={`/${repo.name}/issues/${issue.number}/comments/${comment.id}/edit`} | |
| 371 | - | class="inline-edit-form" | |
| 372 | - | > | |
| 373 | - | <div class="form-group"> | |
| 374 | - | <textarea | |
| 375 | - | class="form-input" | |
| 376 | - | name="edit_body" | |
| 377 | - | rows="6" | |
| 378 | - | maxlength={ | |
| 379 | - | config.MAX_TEXT_BODY_BYTES | |
| 380 | - | } | |
| 381 | - | > | |
| 382 | - | {comment.body} | |
| 383 | - | </textarea> | |
| 384 | - | </div> | |
| 385 | - | <div class="form-actions"> | |
| 386 | - | <button | |
| 387 | - | type="submit" | |
| 388 | - | class="btn btn-sm btn-primary" | |
| 389 | - | > | |
| 390 | - | Save | |
| 391 | - | </button> | |
| 392 | - | </div> | |
| 393 | - | </form> | |
| 394 | - | </div> | |
| 395 | - | )} | |
| 396 | - | <div class="timeline-body markdown-body"> | |
| 397 | - | {comment.bodyHtml} | |
| 398 | - | </div> | |
| 399 | - | <ReactionBar | |
| 400 | - | reactions={ | |
| 401 | - | commentReactions.get(comment.id) ?? [] | |
| 402 | - | } | |
| 403 | - | postUrl={`/${repo.name}/issues/${issue.number}/react`} | |
| 404 | - | commentId={comment.id} | |
| 405 | - | user={user} | |
| 406 | - | /> | |
| 407 | - | </div> | |
| 408 | - | ))} | |
| 409 | - | ||
| 410 | - | {user && ( | |
| 411 | - | <div class="timeline-item timeline-item-new"> | |
| 412 | - | <h3 class="section-title">Add a comment</h3> | |
| 413 | - | <form | |
| 414 | - | method="POST" | |
| 415 | - | action={`/${repo.name}/issues/${issue.number}/comments`} | |
| 416 | - | > | |
| 417 | - | <div class="form-group"> | |
| 418 | - | <textarea | |
| 419 | - | name="body" | |
| 420 | - | rows="6" | |
| 421 | - | maxlength={config.MAX_TEXT_BODY_BYTES} | |
| 422 | - | placeholder="Leave a comment (Markdown supported)" | |
| 423 | - | required | |
| 424 | - | /> | |
| 425 | - | </div> | |
| 426 | - | <div class="form-actions"> | |
| 427 | - | <button | |
| 428 | - | type="submit" | |
| 429 | - | class="btn btn-primary" | |
| 430 | - | > | |
| 431 | - | Comment | |
| 432 | - | </button> | |
| 433 | - | </div> | |
| 434 | - | </form> | |
| 435 | - | </div> | |
| 436 | - | )} | |
| 437 | - | {!user && ( | |
| 438 | - | <p class="text-muted"> | |
| 439 | - | <a href="/login">Sign in</a> to leave a comment. | |
| 440 | - | </p> | |
| 441 | - | )} | |
| 219 | + | <CommentThread | |
| 220 | + | user={user} | |
| 221 | + | comments={comments} | |
| 222 | + | commentReactions={commentReactions} | |
| 223 | + | baseUrl={baseUrl} | |
| 224 | + | parentStatus={issue.status} | |
| 225 | + | /> | |
| 442 | 226 | </div> | |
| 443 | 227 | </div> | |
| 444 | 228 | </Layout> | |
Msrc/views/patches/PatchDetail.tsx
| @@ -7,15 +7,17 @@ import type { | |||
|---|---|---|---|
| 7 | 7 | RepositoryRow, | |
| 8 | 8 | } from "../../db/index.ts"; | |
| 9 | 9 | import { formatDateTime } from "../../lib/formatDate.ts"; | |
| 10 | - | import { labelTextColor } from "../../lib/labelColor.ts"; | |
| 11 | 10 | import { displayName } from "../../lib/users.ts"; | |
| 12 | 11 | import type { SessionUser } from "../../middleware/session.ts"; | |
| 13 | 12 | import type { RenderedDiffFile } from "../../services/diffHighlight.ts"; | |
| 14 | 13 | import type { PatchMeta } from "../../services/git.ts"; | |
| 15 | 14 | import type { ApplyResult } from "../../services/patchCache.ts"; | |
| 16 | 15 | import { Avatar } from "../Avatar.tsx"; | |
| 16 | + | import { CommentThread } from "../CommentThread.tsx"; | |
| 17 | 17 | import { DateWithEdited } from "../DateWithEdited.tsx"; | |
| 18 | 18 | import { DiffView } from "../DiffView.tsx"; | |
| 19 | + | import { EditableTitle } from "../EditableTitle.tsx"; | |
| 20 | + | import { LabelBadges } from "../LabelBadges.tsx"; | |
| 19 | 21 | import { Layout } from "../layout.tsx"; | |
| 20 | 22 | import { ReactionBar, type ReactionCount } from "../ReactionBar.tsx"; | |
| 21 | 23 | import { RepoHeader } from "../repos/RepoHeader.tsx"; | |
| @@ -69,9 +71,6 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 69 | 71 | user != null && | |
| 70 | 72 | (user.isAdmin || | |
| 71 | 73 | (repo.allow_user_labels === 1 && user.id === patch.author_id)); | |
| 72 | - | const canEditComment = (c: PatchCommentRow) => | |
| 73 | - | user != null && | |
| 74 | - | (user.isAdmin || (user.id === c.author_id && patch.status === "open")); | |
| 75 | 74 | ||
| 76 | 75 | const baseUrl = `/${repo.name}/patches/${patch.number}`; | |
| 77 | 76 | const reactUrl = `${baseUrl}/react`; | |
| @@ -85,62 +84,13 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 85 | 84 | <div class="issue-detail"> | |
| 86 | 85 | <div class="issue-detail-header"> | |
| 87 | 86 | <span class="issue-number">#{patch.number}</span> | |
| 88 | - | {canEdit && ( | |
| 89 | - | <input | |
| 90 | - | type="checkbox" | |
| 91 | - | id="title-edit-toggle" | |
| 92 | - | class="title-edit-toggle" | |
| 93 | - | /> | |
| 94 | - | )} | |
| 95 | - | <div class="title-with-edit"> | |
| 96 | - | <h2 class="issue-detail-title">{patch.title}</h2> | |
| 97 | - | {canEdit && ( | |
| 98 | - | <> | |
| 99 | - | <div class="title-edit-form-area"> | |
| 100 | - | <form | |
| 101 | - | method="POST" | |
| 102 | - | action={`${baseUrl}/edit`} | |
| 103 | - | class="title-edit-form" | |
| 104 | - | > | |
| 105 | - | <input | |
| 106 | - | class="form-input" | |
| 107 | - | name="title" | |
| 108 | - | value={patch.title} | |
| 109 | - | required | |
| 110 | - | maxlength={ | |
| 111 | - | config.MAX_TITLE_BYTES | |
| 112 | - | } | |
| 113 | - | /> | |
| 114 | - | <input | |
| 115 | - | type="hidden" | |
| 116 | - | name="edit_description" | |
| 117 | - | value={patch.description} | |
| 118 | - | /> | |
| 119 | - | <div class="title-edit-actions"> | |
| 120 | - | <button | |
| 121 | - | type="submit" | |
| 122 | - | class="btn btn-sm btn-primary" | |
| 123 | - | > | |
| 124 | - | Save | |
| 125 | - | </button> | |
| 126 | - | <label | |
| 127 | - | for="title-edit-toggle" | |
| 128 | - | class="btn btn-sm" | |
| 129 | - | > | |
| 130 | - | Cancel | |
| 131 | - | </label> | |
| 132 | - | </div> | |
| 133 | - | </form> | |
| 134 | - | </div> | |
| 135 | - | <label | |
| 136 | - | for="title-edit-toggle" | |
| 137 | - | class="btn btn-xs title-edit-open" | |
| 138 | - | > | |
| 139 | - | Edit title | |
| 140 | - | </label> | |
| 141 | - | </> | |
| 142 | - | )} | |
| 143 | - | </div> | |
| 87 | + | <EditableTitle | |
| 88 | + | title={patch.title} | |
| 89 | + | canEdit={canEdit} | |
| 90 | + | editAction={`${baseUrl}/edit`} | |
| 91 | + | bodyFieldName="edit_description" | |
| 92 | + | bodyValue={patch.description} | |
| 93 | + | /> | |
| 144 | 94 | <span class={`patch-badge ${patch.status}`}> | |
| 145 | 95 | {patch.status} | |
| 146 | 96 | </span> | |
| @@ -235,80 +185,12 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 235 | 185 | </div> | |
| 236 | 186 | )} | |
| 237 | 187 | </div> | |
| 238 | - | {(patchLabels.length > 0 || canManageLabels) && ( | |
| 239 | - | <div class="issue-labels-row"> | |
| 240 | - | {patchLabels.map((label) => ( | |
| 241 | - | <span class="label-badge-wrap"> | |
| 242 | - | <span | |
| 243 | - | class="label-badge" | |
| 244 | - | style={`background:${label.color};color:${labelTextColor(label.color)}`} | |
| 245 | - | > | |
| 246 | - | {label.name} | |
| 247 | - | </span> | |
| 248 | - | {canManageLabels && ( | |
| 249 | - | <form | |
| 250 | - | method="POST" | |
| 251 | - | action={`${baseUrl}/labels/remove`} | |
| 252 | - | class="label-remove-form" | |
| 253 | - | > | |
| 254 | - | <input | |
| 255 | - | type="hidden" | |
| 256 | - | name="label_id" | |
| 257 | - | value={String(label.id)} | |
| 258 | - | /> | |
| 259 | - | <button | |
| 260 | - | type="submit" | |
| 261 | - | class="label-remove-btn" | |
| 262 | - | title="Remove label" | |
| 263 | - | > | |
| 264 | - | × | |
| 265 | - | </button> | |
| 266 | - | </form> | |
| 267 | - | )} | |
| 268 | - | </span> | |
| 269 | - | ))} | |
| 270 | - | {canManageLabels && | |
| 271 | - | repoLabels.filter( | |
| 272 | - | (l) => | |
| 273 | - | !patchLabels.some( | |
| 274 | - | (pl) => pl.id === l.id, | |
| 275 | - | ), | |
| 276 | - | ).length > 0 && ( | |
| 277 | - | <form | |
| 278 | - | method="POST" | |
| 279 | - | action={`${baseUrl}/labels/add`} | |
| 280 | - | class="label-add-inline-form" | |
| 281 | - | > | |
| 282 | - | <select | |
| 283 | - | name="label_id" | |
| 284 | - | class="label-select" | |
| 285 | - | > | |
| 286 | - | {repoLabels | |
| 287 | - | .filter( | |
| 288 | - | (l) => | |
| 289 | - | !patchLabels.some( | |
| 290 | - | (pl) => | |
| 291 | - | pl.id === l.id, | |
| 292 | - | ), | |
| 293 | - | ) | |
| 294 | - | .map((label) => ( | |
| 295 | - | <option | |
| 296 | - | value={String(label.id)} | |
| 297 | - | > | |
| 298 | - | {label.name} | |
| 299 | - | </option> | |
| 300 | - | ))} | |
| 301 | - | </select> | |
| 302 | - | <button | |
| 303 | - | type="submit" | |
| 304 | - | class="btn btn-sm btn-secondary" | |
| 305 | - | > | |
| 306 | - | Add label | |
| 307 | - | </button> | |
| 308 | - | </form> | |
| 309 | - | )} | |
| 310 | - | </div> | |
| 311 | - | )} | |
| 188 | + | <LabelBadges | |
| 189 | + | labels={patchLabels} | |
| 190 | + | repoLabels={repoLabels} | |
| 191 | + | canManage={canManageLabels} | |
| 192 | + | baseUrl={baseUrl} | |
| 193 | + | /> | |
| 312 | 194 | </div> | |
| 313 | 195 | ||
| 314 | 196 | {/* Subview tabs */} | |
| @@ -442,115 +324,13 @@ export function PatchDetail({ | |||
|---|---|---|---|
| 442 | 324 | </div> | |
| 443 | 325 | )} | |
| 444 | 326 | ||
| 445 | - | {/* Comments */} | |
| 446 | - | {comments.map((comment) => ( | |
| 447 | - | <div class="timeline-item"> | |
| 448 | - | <div class="timeline-author"> | |
| 449 | - | <Avatar | |
| 450 | - | userId={comment.author_id} | |
| 451 | - | version={comment.author_avatar_version} | |
| 452 | - | size={24} | |
| 453 | - | /> | |
| 454 | - | <strong> | |
| 455 | - | {displayName(comment.author_username)} | |
| 456 | - | </strong> | |
| 457 | - | <div class="timeline-author-right"> | |
| 458 | - | {canEditComment(comment) && ( | |
| 459 | - | <details class="inline-edit-details"> | |
| 460 | - | <summary class="btn btn-xs"> | |
| 461 | - | <span class="when-closed"> | |
| 462 | - | Edit | |
| 463 | - | </span> | |
| 464 | - | <span class="when-open"> | |
| 465 | - | Stop editing | |
| 466 | - | </span> | |
| 467 | - | </summary> | |
| 468 | - | </details> | |
| 469 | - | )} | |
| 470 | - | <DateWithEdited | |
| 471 | - | date={comment.created_at} | |
| 472 | - | editedAt={comment.edited_at} | |
| 473 | - | /> | |
| 474 | - | </div> | |
| 475 | - | </div> | |
| 476 | - | {canEditComment(comment) && ( | |
| 477 | - | <div class="inline-edit-form-area"> | |
| 478 | - | <form | |
| 479 | - | method="POST" | |
| 480 | - | action={`${baseUrl}/comments/${comment.id}/edit`} | |
| 481 | - | class="inline-edit-form" | |
| 482 | - | > | |
| 483 | - | <div class="form-group"> | |
| 484 | - | <textarea | |
| 485 | - | class="form-input" | |
| 486 | - | name="edit_body" | |
| 487 | - | rows="6" | |
| 488 | - | maxlength={ | |
| 489 | - | config.MAX_TEXT_BODY_BYTES | |
| 490 | - | } | |
| 491 | - | > | |
| 492 | - | {comment.body} | |
| 493 | - | </textarea> | |
| 494 | - | </div> | |
| 495 | - | <div class="form-actions"> | |
| 496 | - | <button | |
| 497 | - | type="submit" | |
| 498 | - | class="btn btn-sm btn-primary" | |
| 499 | - | > | |
| 500 | - | Save | |
| 501 | - | </button> | |
| 502 | - | </div> | |
| 503 | - | </form> | |
| 504 | - | </div> | |
| 505 | - | )} | |
| 506 | - | <div class="timeline-body markdown-body"> | |
| 507 | - | {comment.bodyHtml} | |
| 508 | - | </div> | |
| 509 | - | <ReactionBar | |
| 510 | - | reactions={ | |
| 511 | - | commentReactions.get(comment.id) ?? [] | |
| 512 | - | } | |
| 513 | - | postUrl={reactUrl} | |
| 514 | - | commentId={comment.id} | |
| 515 | - | user={user} | |
| 516 | - | /> | |
| 517 | - | </div> | |
| 518 | - | ))} | |
| 519 | - | ||
| 520 | - | {user && ( | |
| 521 | - | <div class="timeline-item timeline-item-new"> | |
| 522 | - | <h3 class="section-title">Add a comment</h3> | |
| 523 | - | <form | |
| 524 | - | method="POST" | |
| 525 | - | action={`${baseUrl}/comments`} | |
| 526 | - | > | |
| 527 | - | <div class="form-group"> | |
| 528 | - | <textarea | |
| 529 | - | name="body" | |
| 530 | - | rows="6" | |
| 531 | - | maxlength={ | |
| 532 | - | config.MAX_TEXT_BODY_BYTES | |
| 533 | - | } | |
| 534 | - | placeholder="Leave a comment (Markdown supported)" | |
| 535 | - | required | |
| 536 | - | /> | |
| 537 | - | </div> | |
| 538 | - | <div class="form-actions"> | |
| 539 | - | <button | |
| 540 | - | type="submit" | |
| 541 | - | class="btn btn-primary" | |
| 542 | - | > | |
| 543 | - | Comment | |
| 544 | - | </button> | |
| 545 | - | </div> | |
| 546 | - | </form> | |
| 547 | - | </div> | |
| 548 | - | )} | |
| 549 | - | {!user && ( | |
| 550 | - | <p class="text-muted"> | |
| 551 | - | <a href="/login">Sign in</a> to leave a comment. | |
| 552 | - | </p> | |
| 553 | - | )} | |
| 327 | + | <CommentThread | |
| 328 | + | user={user} | |
| 329 | + | comments={comments} | |
| 330 | + | commentReactions={commentReactions} | |
| 331 | + | baseUrl={baseUrl} | |
| 332 | + | parentStatus={patch.status} | |
| 333 | + | /> | |
| 554 | 334 | </div> | |
| 555 | 335 | ) : ( | |
| 556 | 336 | <div> | |
Msrc/views/repos/BranchSelector.tsx
| @@ -32,11 +32,7 @@ export function BranchSelector({ | |||
|---|---|---|---|
| 32 | 32 | <input type="hidden" name="view" value={view} /> | |
| 33 | 33 | {path && <input type="hidden" name="path" value={path} />} | |
| 34 | 34 | <span class="branch-selector-icon">⎇</span> | |
| 35 | - | <select | |
| 36 | - | name="rev" | |
| 37 | - | class="branch-select" | |
| 38 | - | data-autosubmit | |
| 39 | - | > | |
| 35 | + | <select name="rev" class="branch-select" data-autosubmit> | |
| 40 | 36 | {isDetached && ( | |
| 41 | 37 | <option value={currentRef} selected> | |
| 42 | 38 | {shortRef} (detached) | |
Msrc/views/repos/FileBlob.tsx
| @@ -133,7 +133,7 @@ export function FileBlob({ | |||
|---|---|---|---|
| 133 | 133 | ) : view.mimeType.startsWith("audio/") ? ( | |
| 134 | 134 | // biome-ignore lint/a11y/useMediaCaption: captions unavailable for arbitrary repo files | |
| 135 | 135 | <audio | |
| 136 | - | controls | |
| 136 | + | controls="" | |
| 137 | 137 | src={`/${repo.name}/raw/${blobRef}/${filePath}`} | |
| 138 | 138 | class="file-media-audio" | |
| 139 | 139 | /> | |
Mtests/e2e.auth.test.ts
| @@ -99,6 +99,20 @@ describe('auth', () => { | |||
|---|---|---|---|
| 99 | 99 | } finally { await ctx.close(); } | |
| 100 | 100 | }); | |
| 101 | 101 | ||
| 102 | + | test('cross-origin POST is rejected (CSRF defense)', async () => { | |
| 103 | + | const ctx = await browser.newContext(); | |
| 104 | + | try { | |
| 105 | + | // Forge an Origin from a different host; server should refuse the POST. | |
| 106 | + | const resp = await ctx.request.post(`${BASE}/login`, { | |
| 107 | + | headers: { Origin: 'http://evil.example' }, | |
| 108 | + | form: { username: 'admin', password: ADMIN_PASS }, | |
| 109 | + | maxRedirects: 0, | |
| 110 | + | }); | |
| 111 | + | expect(resp.status()).toBe(403); | |
| 112 | + | // Other tests (login, register) cover the same-origin success path. | |
| 113 | + | } finally { await ctx.close(); } | |
| 114 | + | }); | |
| 115 | + | ||
| 102 | 116 | test('logout clears session', async () => { | |
| 103 | 117 | const ctx = await browser.newContext(); | |
| 104 | 118 | const page = await ctx.newPage(); | |
Mtests/e2e.issues.test.ts
| @@ -340,6 +340,42 @@ describe('issue editing', () => { | |||
|---|---|---|---|
| 340 | 340 | } finally { await page.close(); } | |
| 341 | 341 | }); | |
| 342 | 342 | ||
| 343 | + | test('cannot edit comment via wrong repo url (cross-repo bypass)', async () => { | |
| 344 | + | // Create a second repo | |
| 345 | + | const setupPage = await adminCtx.newPage(); | |
| 346 | + | try { | |
| 347 | + | await setupPage.goto(`${BASE}/new`); | |
| 348 | + | await setupPage.fill('[name=name]', 'other-repo'); | |
| 349 | + | await setupPage.click('form[action="/new"] button[type=submit]'); | |
| 350 | + | await setupPage.waitForURL(`${BASE}/other-repo`); | |
| 351 | + | } finally { await setupPage.close(); } | |
| 352 | + | ||
| 353 | + | // Pull a comment id from the existing my-repo issue | |
| 354 | + | const issueNum = issueUrl.split('/issues/')[1]; | |
| 355 | + | const page = await adminCtx.newPage(); | |
| 356 | + | try { | |
| 357 | + | await page.goto(issueUrl); | |
| 358 | + | const formAction = await page | |
| 359 | + | .locator(`form[action*="/my-repo/issues/${issueNum}/comments/"][action$="/edit"]`) | |
| 360 | + | .first() | |
| 361 | + | .getAttribute('action'); | |
| 362 | + | expect(formAction).toBeTruthy(); | |
| 363 | + | const commentId = formAction!.split('/comments/')[1]!.split('/')[0]; | |
| 364 | + | ||
| 365 | + | // Edit the same comment via /other-repo/... — must 404, not 200/302 | |
| 366 | + | const resp = await page.request.post( | |
| 367 | + | `${BASE}/other-repo/issues/${issueNum}/comments/${commentId}/edit`, | |
| 368 | + | { form: { edit_body: 'cross-repo bypass attempt' }, maxRedirects: 0 }, | |
| 369 | + | ); | |
| 370 | + | expect(resp.status()).toBe(404); | |
| 371 | + | ||
| 372 | + | // And the original comment must be unchanged | |
| 373 | + | await page.goto(issueUrl); | |
| 374 | + | const bodies = await page.locator('.timeline-body').allTextContents(); | |
| 375 | + | expect(bodies.every(b => !b.includes('cross-repo bypass attempt'))).toBe(true); | |
| 376 | + | } finally { await page.close(); } | |
| 377 | + | }); | |
| 378 | + | ||
| 343 | 379 | test('admin can delete issue', async () => { | |
| 344 | 380 | const issueNum = issueUrl.split('/issues/')[1]; | |
| 345 | 381 | const resp = await adminCtx.request.post(`${BASE}/my-repo/issues/${issueNum}/delete`, { | |