Compare commits

..

2 commits

Author SHA1 Message Date
8ac1d8a0e6 fix(nav): clicking "Vinterliste" / "Min liste" while already there refreshes the list
Before: goHome() and goPublicHome() did pushUrl(path) + view=value.
When the user was already on /hjem (or /) both calls were no-ops —
clicking the wordmark or "Min liste" appeared to do nothing.

Add a monotonic reloadKey counter in App.svelte. Both nav handlers
bump it. Home.svelte takes reloadKey as a prop and runs load() in a
$effect when the value changes relative to what it last saw.

Skipping the first run is necessary because onMount() already kicks
off the initial load — the effect skips on init by comparing
reloadKey to lastSeenReloadKey, which both start equal. load()
doesn't touch reloadKey so the effect can't self-fire (the previous
$effect bug from the archive/hide toggles is documented inline).

Search query, edit-in-progress form, and other local state in Home
survive the refresh — only the activity fetch re-runs.
2026-05-25 21:21:39 +02:00
1c95dbed00 feat(activity): Markdown rendering for descriptions
Activity descriptions now render through a small marked + DOMPurify
pipeline. Client-side only — the server keeps storing raw markdown
source, private descriptions stay inside the encrypted payload.

frontend/src/lib/markdown.ts exposes a single renderMarkdown(src)
helper. Allowlist: p / br / hr / strong / em / del / s / code / pre /
ul / ol / li / blockquote / a / h3–h6. URL scheme allowlist:
http(s) and mailto. Images are deliberately stripped — external
image URLs leak the viewer's IP to the linker's host. Raw HTML
pass-through is off. A DOMPurify afterSanitizeAttributes hook forces
target="_blank" rel="noopener noreferrer ugc" on every <a>, matching
the existing external-link pattern in PublicList. h1/h2 in the
source get downshifted to h3 via walkTokens so the description's
heading hierarchy doesn't collide with the SPA's own <h1>/<h2>.

Render sites: the two description spots in ActivityRow.svelte (one
for the decrypted private branch, one for non-private). New
.md class in styles.css gives the rendered block tight spacing,
discreet code/pre/blockquote treatment, accent-coloured links.

UX: a "Du kan bruke Markdown — **fet**, *kursiv*, [lenke](https://…),
lister med -" hint under the textarea in ActivityForm. No preview
pane to keep scope contained.

Tests: 14 cases in tests/markdown.test.ts covering the allowlist
(bold/italic/strike/lists/links/mailto), the sanitisation surface
(javascript: / data: / script / iframe / on* handlers / images),
and the h1/h2 → h3 downshift. happy-dom is registered locally in
beforeAll (and the markdown module dynamic-imported) rather than as
a project-wide preload — the latter overrides fetch/Request/Response
and breaks Bun-fetch-based API tests in other files.

Bundle impact: marked + dompurify add ~60KB to the SPA bundle.
2026-05-25 21:15:31 +02:00
9 changed files with 304 additions and 5 deletions

View file

@ -5,14 +5,18 @@
"": {
"name": "vinterliste",
"dependencies": {
"dompurify": "^3.4.5",
"hono": "^4.6.0",
"libsodium-wrappers-sumo": "^0.7.15",
"marked": "^18.0.4",
"svelte-dnd-action": "^0.9.69",
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tsconfig/svelte": "^5.0.4",
"@types/bun": "^1.1.0",
"@types/dompurify": "^3.2.0",
"@types/libsodium-wrappers-sumo": "^0.7.8",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
@ -74,6 +78,8 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@ -144,6 +150,8 @@
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/dompurify": ["@types/dompurify@3.2.0", "", { "dependencies": { "dompurify": "*" } }, "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
"@types/libsodium-wrappers": ["@types/libsodium-wrappers@0.7.14", "", {}, "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ=="],
@ -154,6 +162,10 @@
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"aria-query": ["aria-query@5.3.1", "", {}, "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g=="],
@ -172,6 +184,10 @@
"devalue": ["devalue@5.8.1", "", {}, "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw=="],
"dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="],
@ -182,6 +198,8 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="],
"hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
"is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
@ -196,6 +214,8 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@ -232,6 +252,10 @@
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
"zimmerframe": ["zimmerframe@1.1.4", "", {}, "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ=="],
"rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],

View file

@ -203,13 +203,22 @@
view = 'public-home';
}
// Bumped every time goHome / goPublicHome is invoked. Home.svelte
// watches it via a $effect and re-loads when it changes. This makes
// clicking the wordmark or "Min liste" while ALREADY on /hjem or /
// refresh the list — without it, pushUrl is a no-op (same path) and
// view = 'home' is a no-op (same value), so nothing happened before.
let reloadKey = $state(0);
function goHome() {
pushUrl('/hjem');
reloadKey++;
view = 'home';
}
function goPublicHome() {
pushUrl('/');
reloadKey++;
view = 'public-home';
}
</script>
@ -261,7 +270,7 @@
{:else if view === 'permalink'}
<ActivityPermalink id={activityId} onBack={backToCallerOrHome} />
{:else if view === 'public-home'}
<Home publicOnly={true} />
<Home publicOnly={true} reloadKey={reloadKey} />
{:else if view === 'login'}
<Login
defaultEmail={defaultEmail}
@ -291,7 +300,7 @@
{:else if view === 'tag'}
<TagPage tag={tagName} onBack={backToCallerOrHome} />
{:else}
<Home publicOnly={false} />
<Home publicOnly={false} reloadKey={reloadKey} />
{/if}
<footer style="margin-top: 3rem; padding-top: 1rem; border-top: 1px solid var(--border); text-align: center;">

View file

@ -209,6 +209,10 @@
<label for="desc">Beskrivelse (valgfritt)</label>
<textarea id="desc" rows="4" bind:value={description}
placeholder="Detaljer, lenker, hva som helst"></textarea>
<p class="muted" style="font-size: 0.8rem; margin: 0.2rem 0 0;">
Du kan bruke Markdown — <code>**fet**</code>, <code>*kursiv*</code>,
<code>[lenke](https://…)</code>, lister med <code>-</code>.
</p>
<div id="tags-label" style="margin: 0.75rem 0 0.25rem; font-weight: 500;">Etiketter</div>
<div role="group" aria-labelledby="tags-label">

View file

@ -6,6 +6,7 @@
} from '../lib/crypto';
import { api } from '../lib/api';
import { onSpaLink } from '../lib/navigate';
import { renderMarkdown } from '../lib/markdown';
import { privateTagIndex } from '../lib/tagIndex';
import type { Activity } from '../../../shared/types';
@ -293,7 +294,7 @@
{/if}
</h3>
{#if decrypted.description}
<p style="white-space: pre-wrap; margin: 0.25rem 0;">{decrypted.description}</p>
<div class="md" style="margin: 0.25rem 0;">{@html renderMarkdown(decrypted.description)}</div>
{/if}
{#if decrypted.tags.length}
<div>
@ -341,7 +342,7 @@
{/if}
</h3>
{#if activity.description}
<p style="white-space: pre-wrap; margin: 0.25rem 0;">{activity.description}</p>
<div class="md" style="margin: 0.25rem 0;">{@html renderMarkdown(activity.description)}</div>
{/if}
{#if activity.tags.length}
<div>

View file

@ -12,8 +12,12 @@
/** When true, render only public+semi (the "/" landing). When false, show
* the full authenticated dashboard including the viewer's own private rows. */
publicOnly?: boolean;
/** Monotonic counter from App. Changes (relative to the value at mount)
* trigger a re-fetch. Lets the wordmark / "Min liste" buttons refresh
* this view even when the user is already on /hjem or /. */
reloadKey?: number;
}
let { publicOnly = false }: Props = $props();
let { publicOnly = false, reloadKey = 0 }: Props = $props();
let activities: Activity[] = $state([]);
let loading = $state(true);
@ -31,8 +35,20 @@
let showArchived = $state(false);
let showHidden = $state(false);
// Track the reloadKey we've already reacted to so the $effect below
// only triggers when the prop CHANGES — not on first paint, where
// onMount already runs the initial load. load() doesn't touch reloadKey
// so this can't self-fire.
let lastSeenReloadKey = reloadKey;
onMount(() => load());
$effect(() => {
if (reloadKey !== lastSeenReloadKey) {
lastSeenReloadKey = reloadKey;
load();
}
});
async function load() {
loading = true;
try {

View file

@ -0,0 +1,86 @@
/**
* Markdown rendering for activity descriptions.
*
* Pipeline: marked (CommonMark + GFM) DOMPurify (strict allowlist).
*
* Why this shape:
* - Client-side only. Server stores raw markdown source that's the
* canonical form. For private rows the source lives inside the
* encrypted payload; we decrypt then render here.
* - Allowlist over blocklist. The default DOMPurify config strips
* `javascript:` etc, but we tighten further: no images (external
* image URLs leak the viewer's IP), no raw HTML pass-through, only
* http/https/mailto in links.
* - All links get `target="_blank" rel="noopener noreferrer ugc"`
* via a DOMPurify hook. We treat description content as user-
* generated, untrusted text the rel attributes match the
* existing external-link pattern in PublicList.svelte.
*
* The hook registration runs at module load; safe because DOMPurify
* hooks are global to the DOMPurify instance and idempotent.
*/
import { marked } from 'marked';
import DOMPurify from 'dompurify';
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.nodeName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer ugc');
}
});
const ALLOWED_TAGS = [
'p', 'br', 'hr',
'strong', 'em', 'del', 's',
'code', 'pre',
'ul', 'ol', 'li',
'blockquote',
'a',
// Headings deliberately limited to h3-h6 — h1/h2 would compete with the
// SPA's own heading hierarchy. Markdown's `#` and `##` get downshifted by
// marked's `headerIds: false` config plus our renderer override below.
'h3', 'h4', 'h5', 'h6',
];
const ALLOWED_ATTR = ['href', 'target', 'rel'];
// Configure marked once. `breaks: true` makes a single newline render as
// <br>, which matches the existing pre-wrap behaviour users are used to.
// `gfm: true` enables strikethrough + autolinks + tables, but tables are
// stripped at the DOMPurify layer (not in the allowlist).
marked.use({
gfm: true,
breaks: true,
// Downshift h1/h2 to h3 to avoid colliding with the SPA's <h1>/<h2>.
// h3..h6 pass through unchanged. walkTokens mutates the depth before
// the default heading renderer runs, which gets us the level shift
// without having to re-implement inline parsing in a custom renderer.
walkTokens(token) {
if (token.type === 'heading' && token.depth <= 2) {
token.depth = 3;
}
},
});
/**
* Render markdown source to sanitised HTML.
*
* Returns an empty string for empty/whitespace-only input so callers can
* cheaply check whether there's anything to display.
*/
export function renderMarkdown(src: string | null | undefined): string {
if (!src || !src.trim()) return '';
const html = marked.parse(src, { async: false }) as string;
return DOMPurify.sanitize(html, {
ALLOWED_TAGS,
ALLOWED_ATTR,
// Only http(s) and mailto. Default DOMPurify also strips javascript:
// but being explicit avoids surprises on protocol additions.
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
// KEEP_CONTENT defaults to true: text content of STRIPPED tags is
// preserved as plain text (so `<script>alert(1)</script>` becomes the
// harmless string `alert(1)`). Script-type tag CONTENTS are still
// stripped via DOMPurify's internal FORBID_CONTENTS list. Setting
// KEEP_CONTENT:false was inexplicably wiping content of *allowed* tags
// too — confirmed via tests/_probe — so the default wins here.
});
}

View file

@ -346,6 +346,43 @@ nav.top h1::after {
[role="listitem"] article.card button,
[role="listitem"] article.card a { cursor: pointer; }
/* Markdown-rendered description blocks. Keep the rhythm tight so a list
doesn't bloat the activity card; cap link colour to --accent and pre/code
blocks to a discreet tinted background. The renderer enforces an
allowlist (see lib/markdown.ts) so we only need to style what we let
through. */
.md p { margin: 0.25rem 0; }
.md p:first-child { margin-top: 0; }
.md p:last-child { margin-bottom: 0; }
.md ul, .md ol { margin: 0.25rem 0 0.25rem 1.25rem; padding: 0; }
.md li { margin: 0.1rem 0; }
.md h3, .md h4, .md h5, .md h6 { margin: 0.5rem 0 0.25rem; font-size: 1rem; }
.md a { color: var(--accent); }
.md code {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
background: rgba(127, 127, 127, 0.12);
padding: 0.05rem 0.3rem;
border-radius: 4px;
font-size: 0.9em;
}
.md pre {
font-family: ui-monospace, "SF Mono", Menlo, monospace;
background: rgba(127, 127, 127, 0.10);
padding: 0.5rem 0.7rem;
border-radius: var(--radius-sm);
overflow-x: auto;
font-size: 0.9em;
margin: 0.4rem 0;
}
.md pre code { background: transparent; padding: 0; }
.md blockquote {
margin: 0.4rem 0;
padding-left: 0.7rem;
border-left: 3px solid var(--border);
color: var(--muted);
}
.md hr { border: 0; border-top: 1px solid var(--border); margin: 0.6rem 0; }
.error { color: var(--danger); margin-top: 0.5rem; }
footer {

View file

@ -14,14 +14,18 @@
"reset-password": "bun run server/reset-password.ts"
},
"dependencies": {
"dompurify": "^3.4.5",
"hono": "^4.6.0",
"libsodium-wrappers-sumo": "^0.7.15",
"marked": "^18.0.4",
"svelte-dnd-action": "^0.9.69"
},
"devDependencies": {
"@happy-dom/global-registrator": "^20.9.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@tsconfig/svelte": "^5.0.4",
"@types/bun": "^1.1.0",
"@types/dompurify": "^3.2.0",
"@types/libsodium-wrappers-sumo": "^0.7.8",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",

118
tests/markdown.test.ts Normal file
View file

@ -0,0 +1,118 @@
/**
* renderMarkdown sanitisation contract.
*
* The function is the only path between user-typed markdown source and
* what the browser will run, so the allowlist matters. Tests cover:
* - what passes through (bold, italic, lists, links, headings 3+, code,
* blockquote, hr, strikethrough, line breaks)
* - what gets stripped (script, iframe, on* handlers, javascript: URLs,
* data: URLs, vbscript:, raw HTML tags not on the list)
* - link decoration: target=_blank rel=noopener noreferrer ugc
* - h1/h2 downshift to h3
*/
// DOMPurify needs `window` at module-load time. happy-dom registers
// browser globals — but if loaded as a project-wide test preload it also
// overrides fetch/Request/Response, breaking Bun-fetch-based API tests in
// other files. Scope it here only: register happy-dom in beforeAll, then
// dynamic-import the markdown module so it sees `window`.
import { GlobalRegistrator } from '@happy-dom/global-registrator';
import { beforeAll, describe, expect, test } from 'bun:test';
type RenderFn = (src: string | null | undefined) => string;
let renderMarkdown: RenderFn;
beforeAll(async () => {
GlobalRegistrator.register();
({ renderMarkdown } = await import('../frontend/src/lib/markdown'));
});
describe('renderMarkdown — allowlist', () => {
test('empty / whitespace → empty string', () => {
expect(renderMarkdown('')).toBe('');
expect(renderMarkdown(' \n ')).toBe('');
expect(renderMarkdown(null)).toBe('');
expect(renderMarkdown(undefined)).toBe('');
});
test('bold / italic / strikethrough pass through', () => {
const html = renderMarkdown('**fet** og *kursiv* og ~~strøket~~');
expect(html).toContain('<strong>fet</strong>');
expect(html).toContain('<em>kursiv</em>');
expect(html).toContain('strøket');
expect(html).toMatch(/<(del|s)>/);
});
test('lists', () => {
const html = renderMarkdown('- en\n- to\n- tre');
expect(html).toContain('<ul>');
expect(html).toContain('<li>en</li>');
expect(html).toContain('<li>tre</li>');
});
test('http/https links get external-link decoration', () => {
const html = renderMarkdown('[Klikk](https://example.com)');
expect(html).toContain('href="https://example.com"');
expect(html).toContain('target="_blank"');
expect(html).toContain('rel="noopener noreferrer ugc"');
});
test('mailto links allowed', () => {
const html = renderMarkdown('[Skriv](mailto:test@example.org)');
expect(html).toContain('href="mailto:test@example.org"');
});
});
describe('renderMarkdown — sanitisation', () => {
test('javascript: URLs stripped', () => {
const html = renderMarkdown('[xss](javascript:alert(1))');
expect(html).not.toContain('javascript:');
expect(html).not.toContain('alert');
});
test('data: URLs stripped', () => {
const html = renderMarkdown('[xss](data:text/html,<script>alert(1)</script>)');
expect(html).not.toContain('data:text/html');
});
test('script tags in raw HTML stripped', () => {
const html = renderMarkdown('Hei <script>alert(1)</script> der');
expect(html).not.toContain('<script');
expect(html).not.toContain('alert(1)');
});
test('iframe stripped', () => {
const html = renderMarkdown('<iframe src="evil.com"></iframe>');
expect(html).not.toContain('<iframe');
});
test('on* event handlers stripped', () => {
const html = renderMarkdown('<a href="https://x.org" onclick="alert(1)">x</a>');
expect(html).not.toContain('onclick');
expect(html).not.toContain('alert(1)');
});
test('images stripped (privacy: external URLs leak viewer IP)', () => {
const html = renderMarkdown('![alt](https://tracker.example.org/pixel.png)');
expect(html).not.toContain('<img');
expect(html).not.toContain('tracker.example.org');
});
});
describe('renderMarkdown — heading downshift', () => {
test('# Heading → h3', () => {
const html = renderMarkdown('# Top');
expect(html).toContain('<h3>Top</h3>');
expect(html).not.toContain('<h1');
});
test('## Heading → h3', () => {
const html = renderMarkdown('## Sub');
expect(html).toContain('<h3>Sub</h3>');
expect(html).not.toContain('<h2');
});
test('### and below pass through', () => {
expect(renderMarkdown('### Sub')).toContain('<h3>Sub</h3>');
expect(renderMarkdown('#### Sub')).toContain('<h4>Sub</h4>');
});
});