escape jsx strings

AuthorKonata <konata@posteo.jp>
Date
Commit29b661e78340456621106b5c63b44c4e5960a944
Parent97defc1
42 files changed, 643 insertions(+), 211 deletions(-)
▾Mbun.lock
@@ -24,6 +24,7 @@
},
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@kitajs/ts-html-plugin": "^4.1.4",
"@simplewebauthn/browser": "^13.3.0",
"@types/argon2": "^0.15.4",
"@types/bun": "latest",
@@ -144,6 +145,8 @@
"@kitajs/html": ["@kitajs/html@4.2.13", "", { "dependencies": { "csstype": "^3.1.3" } }, "sha512-o+8e61EsoLDPTP7rsPkYolca1YFybHuxU2Lr5fWDZCUkYT/6uBlVkvnZUdCXMQKentJL9dxwpR8/xK2Q+U4LhA=="],
"@kitajs/ts-html-plugin": ["@kitajs/ts-html-plugin@4.1.4", "", { "dependencies": { "chalk": "^5.6.2", "tslib": "^2.8.1", "yargs": "^18.0.0" }, "peerDependencies": { "@kitajs/html": "^4.2.10", "typescript": "^5.9.3" }, "bin": { "xss-scan": "dist/cli.js", "ts-html-plugin": "dist/cli.js" } }, "sha512-xK5mNrhnIy73kJFKx5yVGChJyWFRGmIaE0sjlVxVYllk5dyaEYVCrIh1N8AfnseEHka8gAqzPGW95HlkhDvnJA=="],
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
@@ -218,6 +221,10 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"argon2": ["argon2@0.44.0", "", { "dependencies": { "@phc/format": "^1.0.0", "cross-env": "^10.0.0", "node-addon-api": "^8.5.0", "node-gyp-build": "^4.8.4" } }, "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig=="],
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
@@ -234,10 +241,14 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
@@ -270,8 +281,12 @@
"elysia": ["elysia@1.4.27", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-2UlmNEjPJVA/WZVPYKy+KdsrfFwwNlqSBW1lHz6i2AHc75k7gV4Rhm01kFeotH7PDiHIX2G8X3KnRPc33SGVIg=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"exact-mirror": ["exact-mirror@0.2.7", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-+MeEmDcLA4o/vjK2zujgk+1VTxPR4hdp23qLqkWfStbECtAq9gmsvQa3LW6z/0GXZyHJobrCnmy1cdeE7BjsYg=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
@@ -280,6 +295,10 @@
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
@@ -416,8 +435,12 @@
"ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="],
@@ -474,10 +497,18 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="],
"xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="],
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
"yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"bun-types/@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
▾Mpackage.json
@@ -19,6 +19,7 @@
"private": true,
"devDependencies": {
"@biomejs/biome": "^2.4.7",
"@kitajs/ts-html-plugin": "^4.1.4",
"@simplewebauthn/browser": "^13.3.0",
"@types/argon2": "^0.15.4",
"@types/bun": "latest",
▾Msrc/routes/repos.tsx
@@ -1,4 +1,4 @@
import { readFileSync, rmSync } from "node:fs";
import { existsSync, readFileSync, renameSync, rmSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { fileTypeFromBuffer } from "file-type";
@@ -20,7 +20,12 @@ import { db } from "../db/index.ts";
import { contentDisposition } from "../lib/contentDisposition.ts";
import { redirect } from "../lib/redirect.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { git, repoPath, type TreeEntry } from "../services/git.ts";
import {
git,
invalidateRefCache,
repoPath,
type TreeEntry,
} from "../services/git.ts";
import {
hasBinaryContent,
prepareDiff,
@@ -1057,6 +1062,90 @@ export const repoRoutes = new Elysia()
return new Response(null, { status: 302, headers: { Location: "/" } });
})
.post(
"/:repo/settings/rename",
async ({ params, body, cookie }) => {
const user = await resolveSession(cookie.session.value);
const deny = requireAdmin(user);
if (deny) return deny;
const repo = await getRepo(params.repo, true);
if (!repo) return new Response("Not found", { status: 404 });
const oldName = repo.name;
const newName = body.new_name?.trim() ?? "";
const confirm = body.confirm_name?.trim() ?? "";
const back = (msg: string) =>
redirect(
`/${oldName}/settings?error=${encodeURIComponent(msg)}`,
);
if (confirm !== oldName) {
return back("Confirmation did not match the current name.");
}
if (newName === oldName) {
return back("New name is the same as the current name.");
}
if (newName.toLowerCase() === oldName.toLowerCase()) {
return back("Case-only renames are not supported.");
}
if (!VALID_REPO_NAME_RE.test(newName)) {
return back("Invalid repository name.");
}
const clash = await db
.selectFrom("repositories")
.select("id")
.where("name", "=", newName)
.executeTakeFirst();
if (clash) return back("Repository name already taken.");
const fromPath = repoPath(oldName);
const toPath = repoPath(newName);
if (existsSync(toPath)) {
return back(
"A directory for that name already exists on disk.",
);
}
try {
renameSync(fromPath, toPath);
} catch (err) {
console.error(
`rename ${fromPath} -> ${toPath} failed`,
err,
);
return back("Failed to rename repository on disk.");
}
try {
await db
.updateTable("repositories")
.set({ name: newName })
.where("id", "=", repo.id)
.execute();
} catch (err) {
console.error("db rename failed; rolling back disk", err);
try {
renameSync(toPath, fromPath);
} catch (rb) {
console.error("rollback rename failed", rb);
}
return back("Failed to update repository record.");
}
invalidateRefCache(oldName);
return redirect(
`/${newName}/settings?success=${encodeURIComponent("Repository renamed.")}`,
);
},
{
body: t.Object({
new_name: t.String(),
confirm_name: t.String(),
}),
},
)
.post(
"/:repo/settings/labels",
async ({ params, body, cookie }) => {
▾Msrc/views/CommentThread.tsx
@@ -45,7 +45,9 @@ export function CommentThread({
version={comment.author_avatar_version}
size={24}
/>
<strong>{displayName(comment.author_username)}</strong>
<strong safe>
{displayName(comment.author_username)}
</strong>
<div class="timeline-author-right">
{canEditComment(comment) && (
<details class="inline-edit-details">
@@ -76,6 +78,7 @@ export function CommentThread({
name="edit_body"
rows="6"
maxlength={config.MAX_TEXT_BODY_BYTES}
safe
>
{comment.body}
</textarea>
@@ -92,7 +95,7 @@ export function CommentThread({
</div>
)}
<div class="timeline-body markdown-body">
{comment.bodyHtml}
{comment.bodyHtml as "safe"}
</div>
<ReactionBar
reactions={commentReactions.get(comment.id) ?? []}
@@ -103,7 +106,7 @@ export function CommentThread({
</div>
))}
{user && (
{!!user && (
<div class="timeline-item timeline-item-new">
<h3 class="section-title">Add a comment</h3>
<form method="POST" action={`${baseUrl}/comments`}>
▾Msrc/views/DateWithEdited.tsx
@@ -8,12 +8,18 @@ export function DateWithEdited({
editedAt: string | null;
}) {
const label = formatDate(date);
if (!editedAt) return <time datetime={date}>{label}</time>;
if (!editedAt)
return (
<time datetime={date} safe>
{label}
</time>
);
return (
<time
datetime={date}
class="edited-indicator"
title={`Edited ${formatDateTime(editedAt)}`}
safe
>
*{label}
</time>
▾Msrc/views/DiffView.tsx
@@ -57,7 +57,7 @@ export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element {
<details open>
<summary class="file-nav-dir-toggle">
<span class="file-nav-toggle-icon">▾</span>
{name}/
<span safe>{name}/</span>
</summary>
{renderFileTree(node.children)}
</details>
@@ -71,10 +71,13 @@ export function renderFileTree(tree: Map<string, FileTreeNode>): JSX.Element {
>
<span
class={`file-nav-status file-status-${node.file.status}`}
safe
>
{statusLetter(node.file.status)}
</span>
<span class="file-nav-name">{name}</span>
<span class="file-nav-name" safe>
{name}
</span>
<span class="file-nav-stat">
{node.file.isBinary ? (
<span class="nav-binary">
@@ -148,7 +151,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
<>
{files.length > 0 && (
<div class="commit-stats-bar">
<span class="commit-stats-text">{changedStr}</span>
<span class="commit-stats-text" safe>{changedStr}</span>
</div>
)}
@@ -185,15 +188,16 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
</span>
<span
class={`diff-status-badge diff-status-${f.status}`}
safe
>
{sl}
</span>
<span class="diff-file-path mono">
<span class="diff-file-path mono" safe>
{displayPath}
</span>
{f.status === "renamed" &&
f.oldPath !== f.newPath && (
<span class="diff-rename-arrow">
<span class="diff-rename-arrow" safe>
← {f.oldPath}
</span>
)}
@@ -239,11 +243,12 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
)}
{f.status !== "deleted" && (
<>
{sha && (
{!!sha && (
<a
href={`/${repo.name}/blob/${sha}/${displayPath}`}
class="btn btn-xs btn-secondary"
title={`View file at ${sha.slice(0, 7)}`}
safe
>
@ {sha.slice(0, 7)}
</a>
@@ -252,6 +257,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
href={`/${repo.name}/blob/${repo.default_branch}/${displayPath}`}
class="btn btn-xs btn-secondary"
title={`View file at ${repo.default_branch}`}
safe
>
@ {repo.default_branch}
</a>
@@ -278,6 +284,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
<th
colspan="4"
class="diff-hunk-header"
safe
>
{
hunk.header
@@ -310,7 +317,7 @@ export function DiffView({ files, repo, sha }: DiffViewProps) {
</td>
<td class="diff-code">
{
row.html
row.html as "safe"
}
</td>
</tr>
▾Msrc/views/EditableTitle.tsx
@@ -25,7 +25,9 @@ export function EditableTitle({
/>
)}
<div class="title-with-edit">
<h2 class="issue-detail-title">{title}</h2>
<h2 class="issue-detail-title" safe>
{title}
</h2>
{canEdit && (
<>
<div class="title-edit-form-area">
▾Msrc/views/LabelBadges.tsx
@@ -25,6 +25,7 @@ export function LabelBadges({
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
safe
>
{label.name}
</span>
@@ -58,7 +59,7 @@ export function LabelBadges({
>
<select name="label_id" class="label-select">
{available.map((label) => (
<option value={String(label.id)}>
<option value={String(label.id)} safe>
{label.name}
</option>
))}
▾Msrc/views/ReactionBar.tsx
@@ -25,26 +25,30 @@ export function ReactionBar({
const all = [...ALLOWED_REACTIONS];
return (
<div class="reaction-bar">
{reactions.map((r) => (
<form method="POST" action={postUrl} class="reaction-form">
{commentId != null && (
<input
type="hidden"
name="comment_id"
value={String(commentId)}
/>
)}
<input type="hidden" name="emoji" value={r.emoji} />
<button
type="submit"
class={`reaction-btn${r.userReacted ? " reacted" : ""}`}
disabled={!user}
>
{r.emoji} {r.count}
</button>
</form>
))}
{user && (
{reactions.map((r) => {
const unsafeEmoji = r.emoji;
return (
<form method="POST" action={postUrl} class="reaction-form">
{commentId != null && (
<input
type="hidden"
name="comment_id"
value={String(commentId)}
/>
)}
<input type="hidden" name="emoji" value={r.emoji} />
<button
type="submit"
class={`reaction-btn${r.userReacted ? " reacted" : ""}`}
disabled={!user}
safe
>
{unsafeEmoji + " " + r.count}
</button>
</form>
);
})}
{!!user && (
<details class="reaction-picker">
<summary class="reaction-add-btn">+</summary>
<div class="reaction-picker-dropdown">
@@ -71,6 +75,7 @@ export function ReactionBar({
<button
type="submit"
class="reaction-picker-btn"
safe
>
{e}
</button>
▾Msrc/views/Settings.tsx
@@ -57,8 +57,16 @@ export function Settings({
<div class="container container-narrow">
<h1 class="page-title">Settings</h1>
{successMsg && <p class="form-success">{successMsg}</p>}
{error && <p class="form-error">{error}</p>}
{!!successMsg && (
<p class="form-success" safe>
{successMsg}
</p>
)}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
{/* Avatar */}
<div class="form-card">
@@ -240,7 +248,7 @@ export function Settings({
<div class="passkey-list">
{passkeys.map((pk) => (
<div class="passkey-item">
<span class="passkey-date">
<span class="passkey-date" safe>
Added {formatDate(pk.created_at)}
</span>
<form
@@ -300,13 +308,13 @@ export function Settings({
{sshKeys.map((key) => (
<div class="passkey-item">
<div class="ssh-key-info">
<span class="ssh-key-name">
<span class="ssh-key-name" safe>
{key.name}
</span>
<span class="passkey-date ssh-key-fingerprint">
<span class="passkey-date ssh-key-fingerprint" safe>
{key.fingerprint}
</span>
<span class="passkey-date">
<span class="passkey-date" safe>
Added {formatDate(key.created_at)}
</span>
</div>
@@ -409,17 +417,17 @@ export function Settings({
<li class="queue-item">
<div class="queue-item-meta">
<div class="queue-item-header">
<strong>
<strong safe>
{u.username}
</strong>
<span class="queue-item-date">
<span class="queue-item-date" safe>
{formatDateTime(
u.created_at,
)}
</span>
</div>
{u.register_application && (
<p class="queue-item-answer">
{!!u.register_application && (
<p class="queue-item-answer" safe>
{u.register_application}
</p>
)}
▾Msrc/views/auth/Login.tsx
@@ -9,7 +9,11 @@ export function Login({ error }: LoginProps) {
<Layout user={null} title="Sign in">
<div class="auth-container">
<h1 class="page-title">Sign in</h1>
{error && <p class="form-error">{error}</p>}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<form method="POST" action="/login" class="auth-form">
<div class="form-group">
<label for="username">Username</label>
▾Msrc/views/auth/Register.tsx
@@ -29,7 +29,11 @@ export function Register({ error, question, pending }: RegisterProps) {
<Layout user={null} title="Register">
<div class="auth-container">
<h1 class="page-title">Create account</h1>
{error && <p class="form-error">{error}</p>}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<form method="POST" action="/register" class="auth-form">
<div class="form-group">
<label for="username">Username</label>
@@ -44,9 +48,11 @@ export function Register({ error, question, pending }: RegisterProps) {
title="Letters, numbers, hyphens and underscores only"
/>
</div>
{question && (
{!!question && (
<div class="form-group">
<label for="application">{question}</label>
<label for="application" safe>
{question}
</label>
<textarea
id="application"
name="application"
▾Msrc/views/ci/CiHistory.tsx
@@ -90,9 +90,9 @@ function CiHelp({ repo }: { repo: RepositoryRow }) {
{CI_VARIABLES.map(([name, desc]) => (
<>
<dt>
<code>{name}</code>
<code safe>{name}</code>
</dt>
<dd>{desc}</dd>
<dd safe>{desc}</dd>
</>
))}
</dl>
@@ -100,7 +100,7 @@ function CiHelp({ repo }: { repo: RepositoryRow }) {
<div class="ci-help-section">
<h4 class="ci-help-section-title">Status badge</h4>
<p class="ci-help-badge-desc">Embed in your README:</p>
<code class="ci-help-badge-code">{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
<code class="ci-help-badge-code" safe>{`![pipeline](/${repo.name}/ci/badge.svg)`}</code>
<h4
class="ci-help-section-title"
style="margin-top: var(--space-4)"
@@ -117,9 +117,9 @@ function CiHelp({ repo }: { repo: RepositoryRow }) {
].map(([k, v]) => (
<>
<dt>
<code>{k}</code>
<code safe>{k}</code>
</dt>
<dd>{v}</dd>
<dd safe>{v}</dd>
</>
))}
</dl>
@@ -145,13 +145,9 @@ export function CiHistory({
);
return (
<Layout user={user} title={`Pipelines — ${repo.name}`}>
{
(isRunning ? (
<meta http-equiv="refresh" content="4" />
) : (
""
)) as unknown as JSX.Element
}
{isRunning ? (
<meta http-equiv="refresh" content="4" />
) : null}
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="ci" user={user} />
@@ -228,28 +224,27 @@ export function CiHistory({
</span>
</a>
<div class="release-item-meta">
{run.commit_sha && (
<code class="ci-sha">
{!!run.commit_sha && (
<code class="ci-sha" safe>
{run.commit_sha.slice(0, 8)}
</code>
)}
{run.commit_branch && (
<span class="badge">
{!!run.commit_branch && (
<span class="badge" safe>
{run.commit_branch}
</span>
)}
{run.commit_tag && (
<span class="badge">
{!!run.commit_tag && (
<span class="badge" safe>
{run.commit_tag}
</span>
)}
<span class="text-muted">
<span class="text-muted" safe>
{run.trigger_source}
</span>
{run.triggered_by_username && (
<span class="text-muted">
by{" "}
{run.triggered_by_username}
{!!run.triggered_by_username && (
<span class="text-muted" safe>
by {run.triggered_by_username}
</span>
)}
{run.artifact_count > 0 && (
@@ -261,9 +256,9 @@ export function CiHistory({
: ""}
</span>
)}
{run.started_at &&
run.finished_at && (
<span class="text-muted">
{!!run.started_at &&
!!run.finished_at && (
<span class="text-muted" safe>
{duration(
run.started_at,
run.finished_at,
@@ -273,7 +268,7 @@ export function CiHistory({
</div>
</div>
<div class="release-item-date">
<time datetime={run.created_at}>
<time datetime={run.created_at} safe>
{formatDateTime(run.created_at)}
</time>
</div>
▾Msrc/views/ci/CiRunDetail.tsx
@@ -87,13 +87,9 @@ export function CiRunDetail({
return (
<Layout user={user} title={`Pipeline #${displayId} — ${repo.name}`}>
{
(isActive && autoRefresh ? (
<meta http-equiv="refresh" content="3" />
) : (
""
)) as unknown as JSX.Element
}
{isActive && autoRefresh ? (
<meta http-equiv="refresh" content="3" />
) : null}
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="ci" user={user} />
@@ -108,38 +104,40 @@ export function CiRunDetail({
Pipeline #{displayId}
</h2>
<div class="release-item-meta">
{run.commit_sha && (
<code class="ci-sha">
{!!run.commit_sha && (
<code class="ci-sha" safe>
{run.commit_sha.slice(0, 8)}
</code>
)}
{run.commit_branch && (
{!!run.commit_branch && (
<a
href={`/${repo.name}/tree/${run.commit_branch}`}
class="badge"
safe
>
{run.commit_branch}
</a>
)}
{run.commit_tag && (
{!!run.commit_tag && (
<a
href={`/${repo.name}/tree/${run.commit_tag}`}
class="badge"
safe
>
{run.commit_tag}
</a>
)}
<span class="text-muted">
<span class="text-muted" safe>
triggered by {run.trigger_source}
{run.triggered_by_username &&
{!!run.triggered_by_username &&
` (${run.triggered_by_username})`}
</span>
{run.started_at && run.finished_at && (
<span class="text-muted">
{!!run.started_at && !!run.finished_at && (
<span class="text-muted" safe>
{duration(run.started_at, run.finished_at)}
</span>
)}
<time datetime={run.created_at} class="text-muted">
<time datetime={run.created_at} class="text-muted" safe>
{formatDateTime(run.created_at)}
</time>
</div>
@@ -194,9 +192,9 @@ export function CiRunDetail({
{Object.entries(variableOverrides).map(([k, v]) => (
<>
<dt>
<code>{k}</code>
<code safe>{k}</code>
</dt>
<dd>{v}</dd>
<dd safe>{v}</dd>
</>
))}
</dl>
@@ -221,11 +219,11 @@ export function CiRunDetail({
>
<summary class="ci-step-summary">
<CiStatusPill status={step.status} />
<span class="ci-step-name">
<span class="ci-step-name" safe>
{step.name}
</span>
{step.started_at && step.finished_at && (
<span class="ci-step-duration text-muted">
{!!step.started_at && !!step.finished_at && (
<span class="ci-step-duration text-muted" safe>
{duration(
step.started_at,
step.finished_at,
@@ -234,7 +232,9 @@ export function CiRunDetail({
)}
</summary>
{step.log ? (
<pre class="ci-step-log">{step.log}</pre>
<pre class="ci-step-log" safe>
{step.log}
</pre>
) : step.status === "running" ? (
<div class="ci-step-running-indicator text-muted">
Running…
@@ -254,10 +254,11 @@ export function CiRunDetail({
<a
href={`/${repo.name}/ci/${run.id}/artifacts/${artifact.id}`}
class="ci-artifact-name"
safe
>
{artifact.filename}
</a>
<span class="ci-artifact-size text-muted">
<span class="ci-artifact-size text-muted" safe>
{formatBytes(artifact.size)}
</span>
</li>
▾Msrc/views/ci/CiStatusPill.tsx
@@ -16,7 +16,7 @@ interface CiStatusPillProps {
export function CiStatusPill({ status, title }: CiStatusPillProps) {
const cls = statusStyles[status] ?? "ci-status-pending";
return (
<span class={`ci-status-pill ${cls}`} title={title}>
<span class={`ci-status-pill ${cls}`} title={title} safe>
{status}
</span>
);
▾Msrc/views/issues/IssueDetail.tsx
@@ -71,7 +71,7 @@ export function IssueDetail({
bodyFieldName="edit_body"
bodyValue={issue.body}
/>
<span class={`issue-badge ${issue.status}`}>
<span class={`issue-badge ${issue.status}`} safe>
{issue.status}
</span>
{canEditIssue && (
@@ -144,7 +144,7 @@ export function IssueDetail({
version={issue.author_avatar_version}
size={24}
/>
<strong>
<strong safe>
{displayName(issue.author_username)}
</strong>
<div class="timeline-author-right">
@@ -187,6 +187,7 @@ export function IssueDetail({
maxlength={
config.MAX_TEXT_BODY_BYTES
}
safe
>
{issue.body}
</textarea>
@@ -203,7 +204,7 @@ export function IssueDetail({
</div>
)}
<div class="timeline-body markdown-body">
{bodyHtml || (
{(bodyHtml as "safe") || (
<em class="text-muted">
No description provided.
</em>
▾Msrc/views/issues/IssueList.tsx
@@ -115,6 +115,7 @@ export function IssueList({
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
safe
>
{label.name}
</span>
@@ -139,7 +140,7 @@ export function IssueList({
</div>
</details>
)}
{user && (
{!!user && (
<a
href={`/${repo.name}/issues/new`}
class="btn btn-primary btn-sm"
@@ -157,6 +158,9 @@ export function IssueList({
<ul class="issue-list">
{issues.map((issue) => {
const labels = labelsByIssueId.get(issue.id) ?? [];
const unsafeAuthor = displayName(
issue.author_username,
);
return (
<li class="issue-item">
<div class="issue-main">
@@ -166,6 +170,7 @@ export function IssueList({
<a
href={`/${repo.name}/issues/${issue.number}`}
class="issue-title"
safe
>
{issue.title}
</a>
@@ -179,6 +184,7 @@ export function IssueList({
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
safe
>
{label.name}
</span>
@@ -193,11 +199,10 @@ export function IssueList({
}
size={20}
/>
<span class="issue-author">
opened by{" "}
{displayName(issue.author_username)}
<span class="issue-author" safe>
opened by {unsafeAuthor}
</span>
<time datetime={issue.created_at}>
<time datetime={issue.created_at} safe>
{formatDate(issue.created_at)}
</time>
</div>
▾Msrc/views/issues/NewIssue.tsx
@@ -27,7 +27,11 @@ export function NewIssue({
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="issues" user={user} />
<h2 class="section-title">New issue</h2>
{error && <p class="form-error">{error}</p>}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<form
method="POST"
action={`/${repo.name}/issues`}
@@ -57,6 +61,7 @@ export function NewIssue({
rows="10"
maxlength={config.MAX_TEXT_BODY_BYTES}
placeholder="Describe the issue..."
safe
>
{template ?? ""}
</textarea>
@@ -76,6 +81,7 @@ export function NewIssue({
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
safe
>
{label.name}
</span>
▾Msrc/views/layout.tsx
@@ -1,3 +1,4 @@
import type { Children } from "@kitajs/html";
import config from "../config.ts";
import { displayName } from "../lib/users.ts";
import type { SessionUser } from "../middleware/session.ts";
@@ -6,7 +7,7 @@ import { Avatar } from "./Avatar.tsx";
interface LayoutProps {
user: SessionUser | null;
title?: string;
children?: JSX.Element | JSX.Element[] | string | null;
children: Children;
}
export function Layout({ user, title, children }: LayoutProps) {
@@ -16,7 +17,7 @@ export function Layout({ user, title, children }: LayoutProps) {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=500" />
<title>{pageTitle}</title>
<title safe>{pageTitle}</title>
<script src="/assets/theme.js"></script>
<link
rel="icon"
@@ -48,7 +49,9 @@ export function Layout({ user, title, children }: LayoutProps) {
version={user.avatar_version}
size={24}
/>
{displayName(user.username)}
<span safe>
{displayName(user.username)}
</span>
</a>
<form
method="POST"
@@ -81,7 +84,9 @@ export function Layout({ user, title, children }: LayoutProps) {
</header>
<main class="site-main">{children}</main>
<footer class="site-footer">
<p>Hearthforge — hosted by {config.OWNER_DISPLAY_NAME}</p>
<p safe>
Hearthforge — hosted by {config.OWNER_DISPLAY_NAME}
</p>
</footer>
</body>
</html>
▾Msrc/views/patches/NewPatch.tsx
@@ -27,7 +27,11 @@ export function NewPatch({
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="patches" user={user} />
<h2 class="section-title">Upload patch</h2>
{error && <p class="form-error">{error}</p>}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<form
method="POST"
action={`/${repo.name}/patches`}
@@ -57,6 +61,7 @@ export function NewPatch({
name="description"
rows="5"
maxlength={config.MAX_TEXT_BODY_BYTES}
safe
>
{template ?? ""}
</textarea>
@@ -88,6 +93,7 @@ export function NewPatch({
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
safe
>
{label.name}
</span>
▾Msrc/views/patches/PatchDetail.tsx
@@ -91,7 +91,7 @@ export function PatchDetail({
bodyFieldName="edit_description"
bodyValue={patch.description}
/>
<span class={`patch-badge ${patch.status}`}>
<span class={`patch-badge ${patch.status}`} safe>
{patch.status}
</span>
{canEdit && (
@@ -225,7 +225,7 @@ export function PatchDetail({
version={patch.author_avatar_version}
size={24}
/>
<strong>
<strong safe>
{displayName(patch.author_username)}
</strong>
<div class="timeline-author-right">
@@ -268,6 +268,7 @@ export function PatchDetail({
maxlength={
config.MAX_TEXT_BODY_BYTES
}
safe
>
{patch.description}
</textarea>
@@ -284,7 +285,7 @@ export function PatchDetail({
</div>
)}
<div class="timeline-body markdown-body">
{descriptionHtml || (
{(descriptionHtml as "safe") || (
<em class="text-muted">
No description provided.
</em>
@@ -298,7 +299,7 @@ export function PatchDetail({
</div>
{/* Apply status */}
{applyResult && (
{!!applyResult && (
<div class="timeline-item">
<div
class={`apply-result apply-${applyResult.status}`}
@@ -315,8 +316,8 @@ export function PatchDetail({
: "Has conflicts"}
</span>
</div>
{applyResult.output && (
<pre class="apply-output">
{!!applyResult.output && (
<pre class="apply-output" safe>
{applyResult.output}
</pre>
)}
@@ -342,7 +343,7 @@ export function PatchDetail({
? escapeHtml(patchMeta.subject)
: "No commit message"}
</h2>
{patchMeta.body && (
{!!patchMeta.body && (
<pre class="commit-card-body">
{escapeHtml(patchMeta.body)}
</pre>
@@ -363,6 +364,7 @@ export function PatchDetail({
<time
class="commit-meta-value"
datetime={patchMeta.date}
safe
>
{formatDateTime(patchMeta.date)}
</time>
▾Msrc/views/patches/PatchList.tsx
@@ -113,6 +113,7 @@ export function PatchList({
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
safe
>
{label.name}
</span>
@@ -137,7 +138,7 @@ export function PatchList({
</div>
</details>
)}
{user && (
{!!user && (
<a
href={`/${repo.name}/patches/new`}
class="btn btn-primary btn-sm"
@@ -149,12 +150,15 @@ export function PatchList({
</div>
{patches.length === 0 ? (
<div class="empty-state">
<p>No {status} patches.</p>
<p safe>No {status} patches.</p>
</div>
) : (
<ul class="issue-list">
{patches.map((patch) => {
const labels = labelsByPatchId.get(patch.id) ?? [];
const unsafeAuthor = displayName(
patch.author_username,
);
return (
<li class="issue-item">
<div class="issue-main">
@@ -164,6 +168,7 @@ export function PatchList({
<a
href={`/${repo.name}/patches/${patch.number}`}
class="issue-title"
safe
>
{patch.title}
</a>
@@ -177,6 +182,7 @@ export function PatchList({
<span
class="label-badge"
style={`background:${label.color};color:${labelTextColor(label.color)}`}
safe
>
{label.name}
</span>
@@ -191,11 +197,8 @@ export function PatchList({
}
size={20}
/>
<span>
by{" "}
{displayName(patch.author_username)}
</span>
<time datetime={patch.created_at}>
<span safe>by {unsafeAuthor}</span>
<time datetime={patch.created_at} safe>
{formatDate(patch.created_at)}
</time>
</div>
▾Msrc/views/releases/NewRelease.tsx
@@ -30,7 +30,11 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
<RepoNav repo={repo} active="releases" user={user} />
<div class="form-page">
<h2 class="page-title">New Release</h2>
{error && <div class="form-error">{error}</div>}
{!!error && (
<div class="form-error" safe>
{error}
</div>
)}
<form
method="post"
action={`/${repo.name}/releases`}
@@ -115,6 +119,7 @@ export function NewRelease({ user, repo, error, values }: NewReleaseProps) {
class="form-input form-textarea"
rows="8"
maxlength={config.MAX_TEXT_BODY_BYTES}
safe
>
{values?.notes ?? ""}
</textarea>
▾Msrc/views/releases/ReleaseDetail.tsx
@@ -55,29 +55,31 @@ export function ReleaseDetail({
<h2
class="page-title"
style={`view-transition-name: release-title-${release.id}`}
safe
>
{release.name}
</h2>
</div>
<div class="release-item-date">
{release.tag_name && (
{!!release.tag_name && (
<a
href={`/${repo.name}/tree/${release.tag_name}`}
class="badge"
safe
>
{release.tag_name}
</a>
)}
<time datetime={release.created_at}>
<time datetime={release.created_at} safe>
{formatDate(release.created_at)}
</time>
</div>
</div>
</div>
{notesHtml && (
{!!notesHtml && (
<div class="markdown-body release-notes">
{notesHtml}
{notesHtml as "safe"}
</div>
)}
@@ -90,10 +92,11 @@ export function ReleaseDetail({
<a
href={`/${repo.name}/releases/${release.id}/assets/${asset.filename}`}
class="asset-name"
safe
>
{asset.filename}
</a>
<span class="asset-size">
<span class="asset-size" safe>
{formatBytes(asset.size)}
</span>
</li>
@@ -103,13 +106,14 @@ export function ReleaseDetail({
<a
href={`/${repo.name}/releases/${release.id}/source/${archive.filename}`}
class="asset-name"
safe
>
{archive.filename}
</a>
<span class="asset-meta">
<span class="asset-meta" safe>
Source code ({archive.format})
</span>
<span class="asset-size">
<span class="asset-size" safe>
{formatBytes(archive.size)}
</span>
</li>
@@ -130,7 +134,10 @@ export function ReleaseDetail({
)
.map(([format, ext]) => (
<li class="asset-item asset-item-pending">
<span class="asset-name asset-name-pending">
<span
class="asset-name asset-name-pending"
safe
>
{`${repo.name}-${release.tag_name}${ext}`}
</span>
<span class="asset-meta">
▾Msrc/views/releases/ReleaseList.tsx
@@ -22,10 +22,12 @@ function NotesPreview({ notes }: { notes: string }) {
return (
<details class="notes-expand">
<summary class="notes-toggle">
<div class="notes-preview">{plaintext}</div>
<div class="notes-preview" safe>
{plaintext}
</div>
<span class="notes-toggle-label" />
</summary>
<div class="notes-full markdown-body">{renderMarkdown(notes)}</div>
<div class="notes-full markdown-body">{renderMarkdown(notes) as "safe"}</div>
</details>
);
}
@@ -66,6 +68,7 @@ export function ReleaseList({
href={`/${repo.name}/releases/${release.id}`}
class="release-item-title"
style={`view-transition-name: release-title-${release.id}`}
safe
>
{release.name}
</a>
@@ -85,20 +88,21 @@ export function ReleaseList({
</div>
</div>
<div class="release-item-date">
{release.tag_name && (
{!!release.tag_name && (
<a
href={`/${repo.name}/tree/${release.tag_name}`}
class="badge"
safe
>
{release.tag_name}
</a>
)}
<time datetime={release.created_at}>
<time datetime={release.created_at} safe>
{formatDate(release.created_at)}
</time>
</div>
</div>
{release.notes && (
{!!release.notes && (
<div class="release-notes-section">
<p class="release-notes-label">
Release Notes
▾Msrc/views/repos/BranchList.tsx
@@ -32,8 +32,16 @@ export function BranchList({
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="branches" user={user} />
{success && <p class="form-success">{success}</p>}
{error && <p class="form-error">{error}</p>}
{!!success && (
<p class="form-success" safe>
{success}
</p>
)}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<div class="list-header">
<h2 class="list-heading">Branches</h2>
{user?.isAdmin && (
@@ -97,6 +105,7 @@ export function BranchList({
<a
href={`/${repo.name}/tree/${b.name}`}
class="ref-name"
safe
>
{b.name}
</a>
@@ -105,28 +114,30 @@ export function BranchList({
)}
</div>
<div class="ref-meta-row">
{b.authorName && (
<span class="ref-author">
{!!b.authorName && (
<span class="ref-author" safe>
{b.authorName}
</span>
)}
{b.shortHash && (
{!!b.shortHash && (
<a
href={`/${repo.name}/commit/${b.shortHash}`}
class="ref-hash mono"
safe
>
{b.shortHash}
</a>
)}
{b.subject && (
<span class="ref-subject">
{!!b.subject && (
<span class="ref-subject" safe>
{b.subject}
</span>
)}
{b.date && (
{!!b.date && (
<time
class="ref-date"
datetime={b.date}
safe
>
{formatDateTime(b.date)}
</time>
@@ -174,7 +185,7 @@ export function BranchList({
</summary>
<div class="confirm-popup">
Delete branch{" "}
<strong>
<strong safe>
{b.name}
</strong>
?
▾Msrc/views/repos/BranchSelector.tsx
@@ -30,11 +30,11 @@ export function BranchSelector({
class="branch-selector"
>
<input type="hidden" name="view" value={view} />
{path && <input type="hidden" name="path" value={path} />}
{!!path && <input type="hidden" name="path" value={path} />}
<span class="branch-selector-icon">⎇</span>
<select name="rev" class="branch-select" data-autosubmit>
{isDetached && (
<option value={currentRef} selected>
<option value={currentRef} selected safe>
{shortRef} (detached)
</option>
)}
@@ -43,6 +43,7 @@ export function BranchSelector({
<option
value={b}
selected={b === currentRef ? true : undefined}
safe
>
{b}
</option>
@@ -54,6 +55,7 @@ export function BranchSelector({
<option
value={t}
selected={t === currentRef ? true : undefined}
safe
>
{t}
</option>
▾Msrc/views/repos/CommitDetail.tsx
@@ -44,6 +44,7 @@ export function CommitDetail({
href={`/${repo.name}/tree/${sha}`}
class="btn btn-secondary btn-sm"
title={`Browse tree at ${sha.slice(0, 7)}`}
safe
>
@ {sha.slice(0, 7)}
</a>
@@ -53,11 +54,14 @@ export function CommitDetail({
<div class="commit-card">
<h2
class={`commit-card-subject${meta.subject ? "" : " commit-card-subject-empty"}`}
safe
>
{meta.subject || "No commit message"}
</h2>
{meta.body && (
<pre class="commit-card-body">{meta.body}</pre>
{!!meta.body && (
<pre class="commit-card-body" safe>
{meta.body}
</pre>
)}
<div class="commit-card-meta">
<div class="commit-card-meta-row">
@@ -71,6 +75,7 @@ export function CommitDetail({
<time
class="commit-meta-value"
datetime={meta.date}
safe
>
{formatDateTime(meta.date)}
</time>
@@ -95,6 +100,7 @@ export function CommitDetail({
<time
class="commit-meta-value"
datetime={meta.committerDate}
safe
>
{formatDateTime(meta.committerDate)}
</time>
@@ -103,7 +109,7 @@ export function CommitDetail({
)}
<div class="commit-card-meta-row">
<span class="commit-meta-label">Commit</span>
<code class="commit-meta-value commit-sha-full mono">
<code class="commit-meta-value commit-sha-full mono" safe>
{meta.hash}
</code>
</div>
@@ -117,6 +123,7 @@ export function CommitDetail({
<a
href={`/${repo.name}/commit/${p}`}
class="commit-hash mono"
safe
>
{p.slice(0, 7)}
</a>
@@ -145,7 +152,7 @@ export function CommitDetail({
{tooLarge !== undefined ? (
<div class="file-download-notice">
<p>
<p safe>
Diff is too large to render inline (
{(tooLarge / 1024 / 1024).toFixed(1)} MB). Browse
individual files at the tree below.
▾Msrc/views/repos/CommitLog.tsx
@@ -66,11 +66,12 @@ export function CommitLog({
<a
href={`/${repo.name}/commit/${c.hash}`}
class="commit-subject"
safe
>
{c.subject}
</a>
<div class="commit-meta">
<span class="commit-author">
<span class="commit-author" safe>
{c.author}
</span>
<span class="commit-meta-right">
@@ -87,12 +88,14 @@ export function CommitLog({
<a
href={`/${repo.name}/commit/${c.hash}`}
class="commit-hash mono"
safe
>
{c.hash.slice(0, 7)}
</a>
<time
class="commit-date"
datetime={c.date}
safe
>
{formatDateTime(c.date)}
</time>
@@ -103,20 +106,20 @@ export function CommitLog({
</ul>
</>
)}
{(newerUrl || olderUrl) && (
{(!!newerUrl || !!olderUrl) && (
<nav
class="commit-cursor-nav"
aria-label="Commit history navigation"
>
<div class="commit-cursor-prev">
{newerUrl && (
{!!newerUrl && (
<a href={newerUrl} class="pagination-btn">
← Newer
</a>
)}
</div>
<div class="commit-cursor-next">
{olderUrl && (
{!!olderUrl && (
<a href={olderUrl} class="pagination-btn">
Older →
</a>
▾Msrc/views/repos/FileBlob.tsx
@@ -35,7 +35,9 @@ export function FileBlob({
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="code" user={user} />
<div class="breadcrumb">
<a href={`/${repo.name}/tree/${blobRef}`}>{repo.name}</a>
<a href={`/${repo.name}/tree/${blobRef}`} safe>
{repo.name}
</a>
{parts.map((part, i) => {
const partPath = parts.slice(0, i + 1).join("/");
const isLast = i === parts.length - 1;
@@ -43,12 +45,13 @@ export function FileBlob({
<>
<span class="breadcrumb-sep">/</span>
{isLast ? (
<span class="breadcrumb-current">
<span class="breadcrumb-current" safe>
{part}
</span>
) : (
<a
href={`/${repo.name}/tree/${blobRef}/${partPath}`}
safe
>
{part}
</a>
@@ -58,7 +61,9 @@ export function FileBlob({
})}
</div>
<div class="file-blob-header">
<span class="file-blob-name">{filename}</span>
<span class="file-blob-name" safe>
{filename}
</span>
<div class="file-blob-actions file-blob-actions-wrap">
<BranchSelector
repoName={repo.name}
@@ -90,7 +95,7 @@ export function FileBlob({
Delete
</summary>
<div class="confirm-popup">
Delete <strong>{filename}</strong>?
Delete <strong safe>{filename}</strong>?
<form
method="POST"
action={`/${repo.name}/delete-file/${blobRef}/${filePath}`}
@@ -119,9 +124,9 @@ export function FileBlob({
</div>
<div class="file-blob-body">
{markdownHtml ? (
<div class="markdown-body">{markdownHtml}</div>
<div class="markdown-body">{markdownHtml as "safe"}</div>
) : view.type === "inline" ? (
<div class="shiki-wrapper">{view.html}</div>
<div class="shiki-wrapper">{view.html as "safe"}</div>
) : view.type === "media" ? (
<div class="file-media">
{view.mimeType.startsWith("image/") ? (
@@ -148,7 +153,7 @@ export function FileBlob({
</div>
) : view.type === "binary" ? (
<div class="file-download-notice">
<p>Binary file ({formatSize(view.size)})</p>
<p safe>Binary file ({formatSize(view.size)})</p>
<a
href={`/${repo.name}/raw/${blobRef}/${filePath}`}
class="btn btn-primary"
@@ -158,7 +163,7 @@ export function FileBlob({
</div>
) : (
<div class="file-download-notice">
<p>
<p safe>
File too large to display inline (
{formatSize(view.size)})
</p>
▾Msrc/views/repos/FileEdit.tsx
@@ -32,7 +32,9 @@ export function FileEdit({
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="code" user={user} />
<div class="breadcrumb">
<a href={`/${repo.name}/tree/${editRef}`}>{repo.name}</a>
<a href={`/${repo.name}/tree/${editRef}`} safe>
{repo.name}
</a>
{parts.map((part, i) => {
const partPath = parts.slice(0, i + 1).join("/");
const isLast = i === parts.length - 1;
@@ -40,12 +42,13 @@ export function FileEdit({
<>
<span class="breadcrumb-sep">/</span>
{isLast ? (
<span class="breadcrumb-current">
<span class="breadcrumb-current" safe>
{part}
</span>
) : (
<a
href={`/${repo.name}/tree/${editRef}/${partPath}`}
safe
>
{part}
</a>
@@ -57,15 +60,19 @@ export function FileEdit({
<p class="form-hint">
WARNING: Line endings are normalized to LF (\n) on save.
</p>
{(error || queryError) && (
<p class="form-error">{error ?? queryError}</p>
{(!!error || !!queryError) && (
<p class="form-error" safe>
{error ?? queryError}
</p>
)}
<form
method="POST"
action={`/${repo.name}/edit/${editRef}/${filePath}`}
>
<div class="file-blob-header">
<span class="file-blob-name">{filename}</span>
<span class="file-blob-name" safe>
{filename}
</span>
<div class="file-blob-actions">
<a
href={`/${repo.name}/blob/${editRef}/${filePath}`}
@@ -84,6 +91,7 @@ export function FileEdit({
autocorrect="off"
autocapitalize="off"
{...{ autocomplete: "off" }}
safe
>
{content}
</textarea>
@@ -93,7 +101,8 @@ export function FileEdit({
class="form-hint"
style="margin-bottom: var(--space-4);"
>
Committing directly to <strong>{editRef}</strong>
Committing directly to{" "}
<strong safe>{editRef}</strong>
</p>
<div class="form-group">
<label for="file-path">File path</label>
@@ -113,6 +122,7 @@ export function FileEdit({
name="message"
rows="3"
required
safe
>
{`Edited ${filename}`}
</textarea>
▾Msrc/views/repos/FileTree.tsx
@@ -57,9 +57,9 @@ export function FileTree({
</a>
)}
</div>
{subpath && (
{!!subpath && (
<div class="breadcrumb">
<a href={`/${repo.name}/tree/${treeRef}`}>
<a href={`/${repo.name}/tree/${treeRef}`} safe>
{repo.name}
</a>
{parts.map((part, i) => {
@@ -69,6 +69,7 @@ export function FileTree({
<span class="breadcrumb-sep">/</span>
<a
href={`/${repo.name}/tree/${treeRef}/${partPath}`}
safe
>
{part}
</a>
@@ -83,11 +84,11 @@ export function FileTree({
subpath={subpath}
entries={entries}
/>
{readmeHtml && (
{!!readmeHtml && (
<div class="readme-section">
<div class="readme-header">
<span>README</span>
{readmePath && (
{!!readmePath && (
<a
href={`/${repo.name}/raw/${treeRef}/${readmePath}`}
class="btn btn-sm"
@@ -96,7 +97,7 @@ export function FileTree({
</a>
)}
</div>
<div class="markdown-body">{readmeHtml}</div>
<div class="markdown-body">{readmeHtml as "safe"}</div>
</div>
)}
</div>
▾Msrc/views/repos/FileTreeTable.tsx
@@ -27,7 +27,7 @@ export function FileTreeTable({
return (
<table class="file-tree">
<tbody>
{subpath && (
{!!subpath && (
<tr class="file-tree-row file-tree-row-up">
<td class="file-icon file-icon-dir">{DirIcon()}</td>
<td class="file-name" colspan="2">
@@ -58,6 +58,7 @@ export function FileTreeTable({
? "file-name-dir"
: undefined
}
safe
>
{entry.name}
</a>
▾Msrc/views/repos/NewFileForm.tsx
@@ -29,7 +29,11 @@ export function NewFileForm({
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="code" user={user} />
{error && <p class="form-error">{error}</p>}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<form
method="POST"
action={`/${repo.name}/new-file/${treeRef}`}
@@ -59,7 +63,7 @@ export function NewFileForm({
class="form-hint"
style="margin-bottom: var(--space-4);"
>
Creating file on <strong>{treeRef}</strong>
Creating file on <strong safe>{treeRef}</strong>
</p>
<div class="form-group">
<label for="file-path">File path</label>
▾Msrc/views/repos/NewRepo.tsx
@@ -11,7 +11,11 @@ export function NewRepo({ user, error }: NewRepoProps) {
<Layout user={user} title="New repository">
<div class="container container-narrow">
<h1 class="page-title">Create new repository</h1>
{error && <p class="form-error">{error}</p>}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<form method="POST" action="/new" class="form-card">
<div class="form-group">
<label for="name">Repository name</label>
▾Msrc/views/repos/RepoHeader.tsx
@@ -9,7 +9,9 @@ export function RepoHeader({ repo }: RepoHeaderProps) {
<div class="repo-header">
<div class="repo-title-row">
<h1 class="page-title">
<a href={`/${repo.name}`}>{repo.name}</a>
<a href={`/${repo.name}`} safe>
{repo.name}
</a>
</h1>
{repo.is_private ? (
<span class="badge badge-private">Private</span>
▾Msrc/views/repos/RepoHome.tsx
@@ -33,8 +33,10 @@ export function RepoHome({
<Layout user={user} title={repo.name}>
<div class="container">
<RepoHeader repo={repo} />
{repo.description && (
<p class="repo-description">{repo.description}</p>
{!!repo.description && (
<p class="repo-description" safe>
{repo.description}
</p>
)}
<RepoNav repo={repo} active="code" user={user} />
{!hasContent ? (
@@ -42,7 +44,7 @@ export function RepoHome({
<h2>This repository is empty.</h2>
<p>Push your first commit to get started:</p>
<pre class="code-setup">
<code>{`git clone ${sshUrl(repo.name)}
<code safe>{`git clone ${sshUrl(repo.name)}
cd ${repo.name}
echo "# ${repo.name}" > README.md
git add .
@@ -109,11 +111,11 @@ git push origin main`}</code>
subpath=""
entries={entries}
/>
{readmeHtml && (
{!!readmeHtml && (
<div class="readme-section">
<div class="readme-header">
<span>README</span>
{readmePath && (
{!!readmePath && (
<a
href={`/${repo.name}/raw/${repo.default_branch}/${readmePath}`}
class="btn btn-sm"
@@ -122,7 +124,7 @@ git push origin main`}</code>
</a>
)}
</div>
<div class="markdown-body">{readmeHtml}</div>
<div class="markdown-body">{readmeHtml as "safe"}</div>
</div>
)}
</>
▾Msrc/views/repos/RepoList.tsx
@@ -76,7 +76,8 @@ export function RepoList({
<div class="empty-state">
{search ? (
<p>
No repositories match <strong>{search}</strong>.
No repositories match{" "}
<strong safe>{search}</strong>.
</p>
) : (
<p>No repositories yet.</p>
@@ -91,6 +92,7 @@ export function RepoList({
<a
href={`/${repo.name}`}
class="repo-name"
safe
>
{repo.name}
</a>
@@ -105,8 +107,8 @@ export function RepoList({
</span>
) : null}
</div>
{repo.description && (
<p class="repo-description">
{!!repo.description && (
<p class="repo-description" safe>
{repo.description}
</p>
)}
@@ -115,6 +117,7 @@ export function RepoList({
<time
class="repo-date"
datetime={repo.created_at}
safe
>
{formatDate(repo.created_at)}
</time>
▾Msrc/views/repos/RepoSettings.tsx
@@ -36,8 +36,16 @@ export function RepoSettings({
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="settings" user={user} />
{success && <p class="form-success">{success}</p>}
{error && <p class="form-error">{error}</p>}
{!!success && (
<p class="form-success" safe>
{success}
</p>
)}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<form
method="POST"
action={`/${repo.name}/settings`}
@@ -69,6 +77,7 @@ export function RepoSettings({
? true
: undefined
}
safe
>
{b}
</option>
@@ -128,6 +137,7 @@ export function RepoSettings({
name="issue_template"
rows="8"
placeholder="## Description&#10;&#10;## Steps to reproduce&#10;&#10;## Expected behavior"
safe
>
{repo.issue_template ?? ""}
</textarea>
@@ -145,6 +155,7 @@ export function RepoSettings({
name="patch_template"
rows="8"
placeholder="## Summary&#10;&#10;## Testing"
safe
>
{repo.patch_template ?? ""}
</textarea>
@@ -163,7 +174,7 @@ export function RepoSettings({
class="label-settings-swatch"
style={`background:${label.color}`}
/>
<span class="label-settings-name">
<span class="label-settings-name" safe>
{label.name}
</span>
<form
@@ -230,10 +241,10 @@ export function RepoSettings({
{secrets.map((secret) => (
<div class="label-settings-item">
<span class="label-settings-name">
<code>{secret.name}</code>
<code safe>{secret.name}</code>
</span>
{secret.description && (
<span class="text-muted">
{!!secret.description && (
<span class="text-muted" safe>
{secret.description}
</span>
)}
@@ -293,6 +304,61 @@ export function RepoSettings({
<div class="danger-zone">
<h2 class="section-title danger-title">Danger zone</h2>
<div class="form-card danger-card">
<div class="danger-item">
<div>
<strong>Rename this repository</strong>
<p class="text-muted">
Changing the name breaks existing clone
URLs and links to this repo. Collaborators
will need to update their remotes.
</p>
</div>
<details class="confirm-details">
<summary class="btn btn-danger">
Rename repository
</summary>
<div class="confirm-popup">
<form
method="POST"
action={`/${repo.name}/settings/rename`}
class="inline-form"
>
<div class="form-group">
<label for="new_name">
New name
</label>
<input
id="new_name"
name="new_name"
type="text"
required
pattern="[A-Za-z0-9._\-]+"
autocomplete="off"
/>
</div>
<div class="form-group">
<label for="confirm_name">
Type <code safe>{repo.name}</code>{" "}
to confirm
</label>
<input
id="confirm_name"
name="confirm_name"
type="text"
required
autocomplete="off"
/>
</div>
<button
type="submit"
class="btn btn-danger"
>
Rename repository
</button>
</form>
</div>
</details>
</div>
<div class="danger-item">
<div>
<strong>Delete this repository</strong>
@@ -305,7 +371,8 @@ export function RepoSettings({
Delete repository
</summary>
<div class="confirm-popup">
Delete {repo.name}? This cannot be undone.
Delete <span safe>{repo.name}</span>? This
cannot be undone.
<form
method="POST"
action={`/${repo.name}/settings/delete`}
▾Msrc/views/repos/TagList.tsx
@@ -37,8 +37,16 @@ export function TagList({
<div class="container">
<RepoHeader repo={repo} />
<RepoNav repo={repo} active="tags" user={user} />
{success && <p class="form-success">{success}</p>}
{error && <p class="form-error">{error}</p>}
{!!success && (
<p class="form-success" safe>
{success}
</p>
)}
{!!error && (
<p class="form-error" safe>
{error}
</p>
)}
<div class="list-header">
<h2 class="list-heading">Tags</h2>
{user?.isAdmin && (
@@ -117,6 +125,7 @@ export function TagList({
<a
href={`/${repo.name}/tree/${tag.name}`}
class="ref-name"
safe
>
{tag.name}
</a>
@@ -130,28 +139,30 @@ export function TagList({
)}
</div>
<div class="ref-meta-row">
{tag.taggerName && (
<span class="ref-author">
{!!tag.taggerName && (
<span class="ref-author" safe>
{tag.taggerName}
</span>
)}
{tag.shortHash && (
{!!tag.shortHash && (
<a
href={`/${repo.name}/commit/${tag.shortHash}`}
class="ref-hash mono"
safe
>
{tag.shortHash}
</a>
)}
{tag.subject && (
<span class="ref-subject">
{!!tag.subject && (
<span class="ref-subject" safe>
{tag.subject}
</span>
)}
{tag.date && (
{!!tag.date && (
<time
class="ref-date"
datetime={tag.date}
safe
>
{formatDateTime(tag.date)}
</time>
@@ -166,7 +177,7 @@ export function TagList({
{releaseId ? (
<p class="confirm-warning">
Tag{" "}
<strong>
<strong safe>
{tag.name}
</strong>{" "}
is linked to{" "}
@@ -184,7 +195,7 @@ export function TagList({
) : (
<>
Delete tag{" "}
<strong>
<strong safe>
{tag.name}
</strong>
?
▾Mtests/e2e.settings.test.ts
@@ -338,6 +338,101 @@ describe('repository deletion', () => {
});
});
// ─── Repository rename ───────────────────────────────────────────────────────
describe('repository rename', () => {
let adminCtx: BrowserContext;
beforeAll(async () => {
adminCtx = await loggedInContext();
const page = await adminCtx.newPage();
try {
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'renameme-repo');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/renameme-repo`);
await page.goto(`${BASE}/new`);
await page.fill('[name=name]', 'rename-other');
await page.click('form[action="/new"] button[type=submit]');
await page.waitForURL(`${BASE}/rename-other`);
} finally { await page.close(); }
});
afterAll(async () => { await adminCtx.close(); });
test('rejects wrong confirmation', async () => {
const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'whatever', confirm_name: 'wrong' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const loc = resp.headers()['location']!;
expect(loc).toContain('/renameme-repo/settings?error=');
expect(decodeURIComponent(loc)).toContain('Confirmation');
const check = await adminCtx.request.get(`${BASE}/renameme-repo`);
expect(check.status()).toBe(200);
});
test('rejects invalid name', async () => {
const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'bad name', confirm_name: 'renameme-repo' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const loc = resp.headers()['location']!;
expect(loc).toContain('/renameme-repo/settings?error=');
expect(decodeURIComponent(loc)).toContain('Invalid');
});
test('rejects no-op rename', async () => {
const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'renameme-repo', confirm_name: 'renameme-repo' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(decodeURIComponent(resp.headers()['location']!)).toContain('same as the current name');
});
test('rejects duplicate name', async () => {
const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'rename-other', confirm_name: 'renameme-repo' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
expect(decodeURIComponent(resp.headers()['location']!)).toContain('already taken');
});
test('non-admin cannot rename', async () => {
const aliceCtx = await loggedInContext('alice', 'password123');
try {
const resp = await aliceCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'hijack', confirm_name: 'renameme-repo' },
maxRedirects: 0,
});
expect(resp.status()).toBe(403);
} finally { await aliceCtx.close(); }
});
test('admin can rename repository', async () => {
const resp = await adminCtx.request.post(`${BASE}/renameme-repo/settings/rename`, {
form: { new_name: 'renamed-repo', confirm_name: 'renameme-repo' },
maxRedirects: 0,
});
expect(resp.status()).toBe(302);
const loc = resp.headers()['location']!;
expect(loc).toContain('/renamed-repo/settings?success=');
const old = await adminCtx.request.get(`${BASE}/renameme-repo`);
expect(old.status()).toBe(404);
const next = await adminCtx.request.get(`${BASE}/renamed-repo`);
expect(next.status()).toBe(200);
});
});
// ─── 404 handling ─────────────────────────────────────────────────────────────
describe('404 handling', () => {
▾Mtsconfig.json
@@ -7,6 +7,7 @@
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "@kitajs/html",
"plugins": [{ "name": "@kitajs/ts-html-plugin" }],
"allowJs": true,
// Bundler mode