fix relative markdown refs, so images and and links work correctly

AuthorKonata <konata@posteo.jp>
Date
Commitaf2df22163c908d21d602e6c1d11636eb29bf75d
Parent05c76b4
9 files changed, 219 insertions(+), 24 deletions(-)
▾Msrc/routes/patches.tsx
@@ -99,6 +99,7 @@ export const patchRoutes = new Elysia()
"patches.created_at",
"patches.updated_at",
"patches.edited_at",
"patches.version",
"users.username as author_username",
"users.avatar_version as author_avatar_version",
])
▾Msrc/routes/repos.tsx
@@ -2,14 +2,15 @@ import { readFileSync, rmSync } from "node:fs";
import path from "node:path";
import { Elysia, t } from "elysia";
import { fileTypeFromBuffer } from "file-type";
import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
import {
ALLOWED_SIGNERS_PATH,
COMMITS_PER_PAGE,
REPOS_PER_PAGE,
VALID_REPO_NAME_RE,
} from "../constants.ts";
import { COMMITTER_EMAIL, COMMITTER_NAME } from "../config.ts";
import { db } from "../db/index.ts";
import { redirect } from "../lib/redirect.ts";
import { requireAdmin, resolveSession } from "../middleware/session.ts";
import { git, repoPath } from "../services/git.ts";
import {
@@ -19,7 +20,6 @@ import {
} from "../services/highlightWorker.ts";
import { renderMarkdown } from "../services/markdown.ts";
import { ensureRepoRecord, repoDiskExists } from "../services/repoSync.ts";
import { redirect } from "../lib/redirect.ts";
import { html } from "../views/render.tsx";
import { CommitDetail } from "../views/repos/CommitDetail.tsx";
import { CommitLog } from "../views/repos/CommitLog.tsx";
@@ -38,9 +38,18 @@ async function getRepo(name: string, isAdmin: boolean) {
return repo;
}
async function mimeForContent(content: Buffer): Promise<string> {
async function mimeForContent(
filename: string,
content: Buffer,
): Promise<string> {
const result = await fileTypeFromBuffer(content);
if (result) return result.mime;
const typeFromName = Bun.file(filename).type;
if (typeFromName !== "application/octet-stream") {
return typeFromName;
}
return hasBinaryContent(content.subarray(0, 8000))
? "application/octet-stream"
: "text/plain; charset=utf-8";
@@ -90,8 +99,7 @@ export const repoRoutes = new Elysia()
const user = await resolveSession(cookie.session.value);
const search = query.q?.trim() || undefined;
const page = Math.max(1, query.page ?? 1);
const sort =
cookie.repo_sort.value === "name" ? "name" : "created";
const sort = cookie.repo_sort.value === "name" ? "name" : "created";
const isAdmin = user?.isAdmin ?? false;
@@ -280,7 +288,11 @@ export const repoRoutes = new Elysia()
const key = resolved
? `readme:${repo.name}:${resolved}:`
: undefined;
readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key);
readmeHtml = renderMarkdown(readmeBuf.toString("utf-8"), key, {
repo: repo.name,
ref: repo.default_branch,
dir: "",
});
}
}
@@ -361,6 +373,7 @@ export const repoRoutes = new Elysia()
? renderMarkdown(
readmeBuf.toString("utf-8"),
`readme:${repo.name}:${resolved}:`,
{ repo: repo.name, ref: params.ref, dir: "" },
)
: null;
return html(
@@ -403,6 +416,7 @@ export const repoRoutes = new Elysia()
? renderMarkdown(
readmeBuf.toString("utf-8"),
`readme:${repo.name}:${resolved}:${subpath}`,
{ repo: repo.name, ref: params.ref, dir: subpath },
)
: null;
return html(
@@ -460,7 +474,7 @@ export const repoRoutes = new Elysia()
if (!content) return new Response("Not found", { status: 404 });
const filename = path.basename(filePath);
const contentType = await mimeForContent(content);
const contentType = await mimeForContent(filename, content);
const total = content.length;
const rangeHeader = request.headers.get("Range");
@@ -535,7 +549,8 @@ export const repoRoutes = new Elysia()
if (!branches.includes(params.ref))
return new Response("Not found", { status: 404 });
const message = body.message?.trim() || `Edited ${path.basename(filePath)}`;
const message =
body.message?.trim() || `Edited ${path.basename(filePath)}`;
const content = (body.content ?? "").replaceAll("\r\n", "\n");
const commit = await git.editFile(
▾Msrc/services/diffHighlight.ts
@@ -177,7 +177,7 @@ export async function parseDiff(
return files;
}
function escapeHtml(s: string): string {
function _escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
@@ -206,7 +206,9 @@ async function highlightHunk(
// single newline and silently drops the \r from every line except the last.
const contents = hunk.lines.map((l) => l.content);
const trailingCR = contents.map((c) => c.endsWith("\r"));
const stripped = contents.map((c, i) => (trailingCR[i] ? c.slice(0, -1) : c));
const stripped = contents.map((c, i) =>
trailingCR[i] ? c.slice(0, -1) : c,
);
const code = stripped.join("\n");
try {
const h = await getHighlighter();
@@ -227,7 +229,7 @@ async function highlightHunk(
})
.join("");
return trailingCR[i]
? html + '<span class="diff-ctrl">^M</span>'
? `${html}<span class="diff-ctrl">^M</span>`
: html;
});
while (lines.length < hunk.lines.length) lines.push("");
▾Msrc/services/git.ts
@@ -507,9 +507,7 @@ export const git = {
await $`git -C ${p} hash-object -w ${tmpFile}`.text()
).trim();
await $`git -C ${p} update-index --add --cacheinfo 100644,${blobHash},${filePath}`;
const tree = (
await $`git -C ${p} write-tree`.text()
).trim();
const tree = (await $`git -C ${p} write-tree`.text()).trim();
const parent = (
await $`git -C ${p} rev-parse refs/heads/${branch}`.text()
).trim();
▾Msrc/services/markdown.ts
@@ -6,12 +6,67 @@ marked.setOptions({ gfm: true });
const mdCache = new Map<string, string>();
export function renderMarkdown(md: string, cacheKey?: string): string {
export interface MarkdownContext {
repo: string;
ref: string;
/** Directory of the markdown file relative to repo root, e.g. "" or "docs/subdir" */
dir: string;
}
/**
* Resolves a markdown href to a repo-root-relative path for rewriting.
* Returns null if the href should not be rewritten (protocol-absolute or anchor).
*
* - Protocol-absolute (http://, mailto:, data:, …): returns null
* - Anchor (#section): returns null
* - Root-relative (/subdir/img.png): strips leading slash → "subdir/img.png"
* - Path-relative (./img.png, ../img.png, subdir/img.png): resolved against dir
*/
export function resolveMarkdownHref(dir: string, href: string): string | null {
if (/^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(href)) return null; // protocol-absolute
if (href.startsWith("#")) return null; // anchor
if (href.startsWith("/")) return href.slice(1); // root-relative
// Path-relative: resolve against current directory using URL API
const base = new URL(`http://x/${dir ? `${dir}/` : ""}`);
return new URL(href, base).pathname.slice(1); // strip leading /
}
function makeContextualMarked(ctx: MarkdownContext): Marked {
const m = new Marked({ gfm: true });
m.use({
renderer: {
image({ href, title, text }) {
const resolved = resolveMarkdownHref(ctx.dir, href);
if (resolved !== null) {
href = `/${ctx.repo}/raw/${ctx.ref}/${resolved}`;
}
return `<img src="${href}" alt="${text}"${title ? ` title="${title}"` : ""}>`;
},
link({ href, title, tokens }) {
const resolved = resolveMarkdownHref(ctx.dir, href);
if (resolved !== null) {
href = `/${ctx.repo}/blob/${ctx.ref}/${resolved}`;
}
const text = String(this.parser.parseInline(tokens));
return `<a href="${href}"${title ? ` title="${title}"` : ""}>${text}</a>`;
},
},
});
return m;
}
export function renderMarkdown(
md: string,
cacheKey?: string,
ctx?: MarkdownContext,
): string {
if (cacheKey) {
const cached = mdCache.get(cacheKey);
if (cached) return cached;
}
const raw = marked(md) as string;
const raw = ctx
? (makeContextualMarked(ctx).parse(md) as string)
: (marked(md) as string);
const result = DOMPurify.sanitize(raw, {
ADD_TAGS: ["details", "summary"],
ADD_ATTR: ["class"],
▾Msrc/views/patches/PatchDetail.tsx
@@ -451,8 +451,12 @@ export function PatchDetail({
) : (
<div>
<div class="commit-card">
<h2 class={`commit-card-subject${patchMeta.subject ? "" : " commit-card-subject-empty"}`}>
{patchMeta.subject ? escapeHtml(patchMeta.subject) : "No commit message"}
<h2
class={`commit-card-subject${patchMeta.subject ? "" : " commit-card-subject-empty"}`}
>
{patchMeta.subject
? escapeHtml(patchMeta.subject)
: "No commit message"}
</h2>
{patchMeta.body && (
<pre class="commit-card-body">
▾Msrc/views/repos/CommitDetail.tsx
@@ -49,7 +49,9 @@ export function CommitDetail({
{/* Commit metadata card */}
<div class="commit-card">
<h2 class={`commit-card-subject${meta.subject ? "" : " commit-card-subject-empty"}`}>
<h2
class={`commit-card-subject${meta.subject ? "" : " commit-card-subject-empty"}`}
>
{meta.subject || "No commit message"}
</h2>
{meta.body && (
▾Msrc/views/repos/FileEdit.tsx
@@ -51,7 +51,9 @@ export function FileEdit({
);
})}
</div>
<p class="form-hint">WARNING: Line endings are normalized to LF (\n) on save.</p>
<p class="form-hint">
WARNING: Line endings are normalized to LF (\n) on save.
</p>
{error && <p class="form-error">{error}</p>}
<form
method="POST"
@@ -74,17 +76,19 @@ export function FileEdit({
class="file-edit-textarea"
rows="30"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
{...{ autocomplete: "off" }}
>
{content}
</textarea>
</div>
<div class="form-card">
<p class="form-hint" style="margin-bottom: var(--space-4);">
Committing directly to{" "}
<strong>{editRef}</strong>
<p
class="form-hint"
style="margin-bottom: var(--space-4);"
>
Committing directly to <strong>{editRef}</strong>
</p>
<div class="form-group">
<label for="message">Commit message</label>
▾Atests/markdown.test.ts
@@ -0,0 +1,114 @@
import { describe, expect, test } from "bun:test";
import { resolveMarkdownHref } from "../src/services/markdown.ts";
describe("resolveMarkdownHref", () => {
describe("protocol-absolute URLs (returns null)", () => {
test("http", () => {
expect(resolveMarkdownHref("", "http://example.com/img.png")).toBeNull();
expect(resolveMarkdownHref("docs", "http://example.com/img.png")).toBeNull();
});
test("https", () => {
expect(resolveMarkdownHref("", "https://example.com/img.png")).toBeNull();
});
test("ftp", () => {
expect(resolveMarkdownHref("", "ftp://files.example.com/")).toBeNull();
});
test("mailto", () => {
expect(resolveMarkdownHref("", "mailto:foo@bar.com")).toBeNull();
});
test("data", () => {
expect(resolveMarkdownHref("", "data:image/png;base64,abc123")).toBeNull();
});
test("ssh", () => {
expect(resolveMarkdownHref("", "ssh://git@example.com/repo.git")).toBeNull();
});
test("custom protocol", () => {
expect(resolveMarkdownHref("", "myapp://open/something")).toBeNull();
});
});
describe("anchor links (returns null)", () => {
test("simple anchor", () => {
expect(resolveMarkdownHref("", "#section-heading")).toBeNull();
});
test("anchor with any dir", () => {
expect(resolveMarkdownHref("docs/guide", "#toc")).toBeNull();
});
test("bare hash", () => {
expect(resolveMarkdownHref("", "#")).toBeNull();
});
});
describe("root-relative URLs (strips leading /)", () => {
test("single file at root", () => {
expect(resolveMarkdownHref("", "/img.png")).toBe("img.png");
});
test("subdir file — dir is ignored", () => {
expect(resolveMarkdownHref("docs", "/subdir/img.png")).toBe("subdir/img.png");
});
test("deep path — any dir is ignored", () => {
expect(resolveMarkdownHref("x/y/z", "/a/b/c.png")).toBe("a/b/c.png");
});
test("root-relative with no filename (trailing slash)", () => {
expect(resolveMarkdownHref("docs", "/assets/")).toBe("assets/");
});
});
describe("path-relative from root dir (dir = '')", () => {
test("bare filename", () => {
expect(resolveMarkdownHref("", "img.png")).toBe("img.png");
});
test("./filename", () => {
expect(resolveMarkdownHref("", "./img.png")).toBe("img.png");
});
test("subdir/file", () => {
expect(resolveMarkdownHref("", "subdir/img.png")).toBe("subdir/img.png");
});
test("./subdir/file", () => {
expect(resolveMarkdownHref("", "./subdir/img.png")).toBe("subdir/img.png");
});
test("../ from root clamps to root", () => {
// URL API resolves http://x/../img.png → http://x/img.png
expect(resolveMarkdownHref("", "../img.png")).toBe("img.png");
});
});
describe("path-relative from one-level dir (dir = 'docs')", () => {
test("bare filename", () => {
expect(resolveMarkdownHref("docs", "img.png")).toBe("docs/img.png");
});
test("./filename", () => {
expect(resolveMarkdownHref("docs", "./img.png")).toBe("docs/img.png");
});
test("../ traversal to root", () => {
expect(resolveMarkdownHref("docs", "../img.png")).toBe("img.png");
});
test("subdir/file", () => {
expect(resolveMarkdownHref("docs", "subdir/img.png")).toBe("docs/subdir/img.png");
});
test("./subdir/file", () => {
expect(resolveMarkdownHref("docs", "./subdir/img.png")).toBe("docs/subdir/img.png");
});
});
describe("path-relative from nested dir (dir = 'docs/guide')", () => {
test("bare filename", () => {
expect(resolveMarkdownHref("docs/guide", "img.png")).toBe("docs/guide/img.png");
});
test("one level up", () => {
expect(resolveMarkdownHref("docs/guide", "../img.png")).toBe("docs/img.png");
});
test("two levels up", () => {
expect(resolveMarkdownHref("docs/guide", "../../img.png")).toBe("img.png");
});
test("sibling directory", () => {
expect(resolveMarkdownHref("docs/guide", "../assets/img.png")).toBe("docs/assets/img.png");
});
test("./filename", () => {
expect(resolveMarkdownHref("docs/guide", "./img.png")).toBe("docs/guide/img.png");
});
test("deeper subdir", () => {
expect(resolveMarkdownHref("docs/guide", "sub/img.png")).toBe("docs/guide/sub/img.png");
});
});
});