a2ca0db9ca
Security:
- cms-images / cms-files now share resolveCmsSource (cms-source.server.ts)
which enforces an allowlist: asset filename, absolute path on the CMS
host, or full URL exact-matching the configured CMS host+protocol.
Foreign hosts, protocol-relative URLs and path traversal return 400.
- markdown-safe.ts is the single configured marked entrypoint; it
disables raw HTML rendering globally (renderer.html() returns ''),
removing the inline script/iframe injection vector via markdown.
All 8 marked imports across components and loaders now go through it.
- sanitize-blocks.server.ts walks resolved page/post content and runs
HtmlBlock html fields through sanitize-html with a tight allowlist
(no script/style/on*, only youtube/vimeo/spotify iframes, mailto/tel
plus http(s) schemes, target=_blank gets rel=noopener).
- Webhook tokens (revalidate, cache/clear) compared via
crypto.timingSafeEqual through auth-token.server.ts.
- cms.ts wraps every fetch in cmsFetch(): 5 s default timeout via
AbortSignal.timeout, override per call or globally via
PUBLIC_CMS_FETCH_TIMEOUT_MS. Stops slow CMS responses from pinning
SSR workers indefinitely.
Observability:
- log.server.ts gives logWarn / logError / logInfo with scope + context;
swallowed catch {} blocks in hooks, layout.server, +page.server,
[...slug]/+page.server and sitemap now log instead of going silent.
Routing:
- Legacy /blog, /blog/page/:n, /blog/tag/:t and /p/:slug stub routes
deleted; their 301 redirects moved into hooks.server.ts so the
request never enters the SvelteKit page pipeline.
Tests (vitest, server-side):
- auth-token.server.test.ts (6 cases for constant-time compare)
- cms-source.server.test.ts (8 cases for SSRF allowlist)
- sanitize-blocks.server.test.ts (9 cases for sanitizer + walker)
All 23 green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
323 lines
11 KiB
Svelte
323 lines
11 KiB
Svelte
<script lang="ts">
|
|
import { marked } from "$lib/markdown-safe";
|
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
|
import type {
|
|
SearchableTextBlockData,
|
|
SearchableTextFragment,
|
|
} from "$lib/block-types";
|
|
import "$lib/iconify-offline";
|
|
import Icon from "@iconify/svelte";
|
|
import Tag from "../Tag.svelte";
|
|
import Tags from "../Tags.svelte";
|
|
import Tooltip from "$lib/ui/Tooltip.svelte";
|
|
import { t as tStatic, T } from "$lib/translations";
|
|
import type { Translations } from "$lib/translations";
|
|
|
|
let {
|
|
block,
|
|
class: className = "",
|
|
translations = {},
|
|
}: {
|
|
block: SearchableTextBlockData;
|
|
class?: string;
|
|
translations?: Translations | null;
|
|
} = $props();
|
|
|
|
function t(key: string, replacements?: Record<string, string | number>) {
|
|
return tStatic(translations ?? null, key, replacements);
|
|
}
|
|
const helpTooltipText = $derived(t(T.searchable_text_help));
|
|
|
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
|
|
|
const fragments = $derived.by(() => {
|
|
const raw = block.textFragments ?? [];
|
|
return raw
|
|
.filter(
|
|
(f): f is SearchableTextFragment =>
|
|
typeof f === "object" && f !== null && ("title" in f || "text" in f),
|
|
)
|
|
.map((f) => {
|
|
const tagsRaw = f.tags ?? [];
|
|
const tags = tagsRaw
|
|
.map((t) =>
|
|
typeof t === "string" ? t : ((t as { name?: string })?.name ?? ""),
|
|
)
|
|
.filter(Boolean);
|
|
return {
|
|
title: (f.title ?? "").trim(),
|
|
text: (f.text ?? "").trim(),
|
|
tags,
|
|
textHtml: (f.text ?? "").trim()
|
|
? (marked.parse((f.text ?? "").trim()) as string)
|
|
: "",
|
|
};
|
|
});
|
|
});
|
|
|
|
const allTags = $derived(
|
|
[...new Set(fragments.flatMap((f) => f.tags))].sort((a, b) =>
|
|
a.localeCompare(b),
|
|
),
|
|
);
|
|
|
|
let searchQuery = $state("");
|
|
let selectedTag = $state("all");
|
|
let copiedIndex = $state<number | null>(null);
|
|
|
|
async function copyFragmentContent(
|
|
fragment: { title: string; text: string },
|
|
index: number,
|
|
) {
|
|
const plain = [fragment.title, fragment.text].filter(Boolean).join("\n\n");
|
|
try {
|
|
await navigator.clipboard.writeText(plain);
|
|
copiedIndex = index;
|
|
setTimeout(() => (copiedIndex = null), 2000);
|
|
} catch {
|
|
try {
|
|
const ta = document.createElement("textarea");
|
|
ta.value = plain;
|
|
ta.setAttribute("readonly", "");
|
|
ta.style.position = "fixed";
|
|
ta.style.opacity = "0";
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand("copy");
|
|
document.body.removeChild(ta);
|
|
copiedIndex = index;
|
|
setTimeout(() => (copiedIndex = null), 2000);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
function getVisibleFragments() {
|
|
const q = searchQuery.toLowerCase().trim().replace(/\s+/g, " ");
|
|
const tag = selectedTag;
|
|
return fragments.filter((f) => {
|
|
const tagMatch = tag === "all" || f.tags.includes(tag);
|
|
if (!tagMatch) return false;
|
|
if (q === "") return true;
|
|
const titleNorm = (f.title ?? "").toLowerCase().replace(/\s+/g, " ");
|
|
const textNorm = (f.text ?? "")
|
|
.toLowerCase()
|
|
.replace(/<[^>]+>/g, " ")
|
|
.replace(/\s+/g, " ");
|
|
return titleNorm.includes(q) || textNorm.includes(q);
|
|
});
|
|
}
|
|
|
|
const visibleFragments = $derived(getVisibleFragments());
|
|
|
|
const HIGHLIGHT_CLASS = "bg-wald-200 text-wald-900 rounded px-0.5";
|
|
|
|
function highlightInText(text: string, term: string): string {
|
|
if (!term.trim()) return escapeHtml(text);
|
|
const escaped = escapeRegex(term);
|
|
const re = new RegExp(escaped, "gi");
|
|
return escapeHtml(text).replace(
|
|
re,
|
|
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
|
|
);
|
|
}
|
|
|
|
function highlightInHtml(html: string, term: string): string {
|
|
if (!term.trim()) return html;
|
|
const escaped = escapeRegex(term);
|
|
const re = new RegExp(escaped, "gi");
|
|
const parts = html.split(/(<[^>]+>)/g);
|
|
return parts
|
|
.map((part) => {
|
|
if (part.startsWith("<")) return part;
|
|
return part.replace(
|
|
re,
|
|
(match) => `<mark class="${HIGHLIGHT_CLASS}">${match}</mark>`,
|
|
);
|
|
})
|
|
.join("");
|
|
}
|
|
|
|
function escapeHtml(s: string): string {
|
|
return s
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """);
|
|
}
|
|
|
|
function escapeRegex(s: string): string {
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
|
|
const descriptionHtml = $derived(
|
|
block.description && typeof block.description === "string"
|
|
? (marked.parse(block.description) as string)
|
|
: "",
|
|
);
|
|
</script>
|
|
|
|
<div
|
|
class="searchable-text flex flex-col gap-0 border border-stein-200 bg-stein-50 {layoutClasses} {className}"
|
|
data-block-type="searchable_text"
|
|
data-block-slug={block._slug}
|
|
>
|
|
<div class="p-4 md:p-6">
|
|
{#if block.title}
|
|
<h2 class="text-xl md:text-2xl font-bold text-stein-900 mb-2">
|
|
{block.title}
|
|
</h2>
|
|
{/if}
|
|
{#if descriptionHtml}
|
|
<div class="markdown text-stein-600 max-w-none prose prose-zinc prose-sm">
|
|
{@html descriptionHtml}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<header
|
|
class="sticky top-14 z-10 px-4 md:px-6 py-3 border-b border-stein-200 bg-stein-0/95 backdrop-blur-sm md:static md:bg-stein-100 md:backdrop-blur-none shadow-sm md:shadow-none"
|
|
>
|
|
<div class="flex flex-col gap-2">
|
|
<div class="relative flex items-center gap-2">
|
|
<Tooltip content={helpTooltipText} placement="top">
|
|
<button
|
|
type="button"
|
|
class="shrink-0 p-1.5 rounded-md text-himmel-500 hover:text-himmel-700 hover:bg-himmel-50 transition-colors focus:outline-none focus:ring-2 focus:ring-wald-500 focus:ring-offset-1"
|
|
aria-label={t(T.searchable_text_help_aria)}
|
|
>
|
|
<Icon
|
|
icon="mdi:help-circle-outline"
|
|
class="size-5"
|
|
aria-hidden="true"
|
|
/>
|
|
</button>
|
|
</Tooltip>
|
|
<input
|
|
type="search"
|
|
placeholder={t(T.searchable_text_placeholder)}
|
|
class="w-full text-sm px-4 py-2.5 pr-10 border border-stein-200 rounded-md focus:outline-none focus:ring-2 focus:ring-wald-500 focus:border-wald-400 bg-stein-0 text-stein-900 placeholder:text-stein-400"
|
|
bind:value={searchQuery}
|
|
oninput={(e) => (searchQuery = (e.target as HTMLInputElement).value)}
|
|
aria-label={t(T.searchable_text_search_aria)}
|
|
/>
|
|
{#if searchQuery}
|
|
<button
|
|
type="button"
|
|
class="absolute right-9 top-1/2 -translate-y-1/2 p-1.5 rounded-md hover:bg-stein-200 text-stein-500 hover:text-stein-700 transition-colors"
|
|
onclick={() => (searchQuery = "")}
|
|
aria-label={t(T.searchable_text_clear_search)}
|
|
>
|
|
✕
|
|
</button>
|
|
{/if}
|
|
<Icon
|
|
icon="mdi:magnify"
|
|
class="absolute right-3 top-1/2 -translate-y-1/2 text-stein-400 pointer-events-none size-5"
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
{#if allTags.length > 0}
|
|
<div class="overflow-x-auto -mx-1 px-1 pt-2 pb-2">
|
|
<div class="flex gap-2 flex-wrap min-w-max md:flex-wrap md:w-auto">
|
|
<Tag
|
|
label={t(T.searchable_text_tag_all)}
|
|
variant={selectedTag === "all" ? "green" : "white"}
|
|
active={selectedTag === "all"}
|
|
onclick={() => (selectedTag = "all")}
|
|
/>
|
|
{#each allTags as tag}
|
|
<Tag
|
|
label={tag}
|
|
variant={selectedTag === tag ? "green" : "white"}
|
|
active={selectedTag === tag}
|
|
onclick={() => (selectedTag = tag)}
|
|
/>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
|
|
<div
|
|
class="text-xs text-stein-600 px-4 md:px-6 py-2.5 bg-stein-100 border-b border-stein-200"
|
|
aria-live="polite"
|
|
>
|
|
{#if visibleFragments.length === 0}
|
|
<span>{t(T.searchable_text_no_results)}</span>
|
|
{:else}
|
|
<span>
|
|
{visibleFragments.length === fragments.length
|
|
? t(T.searchable_text_count_all, { count: visibleFragments.length })
|
|
: t(T.searchable_text_count_filtered, {
|
|
visible: visibleFragments.length,
|
|
total: fragments.length,
|
|
})}
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-0 bg-stein-100">
|
|
{#each visibleFragments as fragment, i}
|
|
<details class="group border-b border-stein-200 last:border-b-0">
|
|
<summary
|
|
class="cursor-pointer list-none px-4 md:px-6 py-3.5 bg-stein-50 hover:bg-wald-50 text-sm font-medium text-stein-800 flex flex-col gap-1.5 transition-colors"
|
|
>
|
|
<div class="flex items-start gap-2 min-w-0">
|
|
<div class="grow min-w-0">
|
|
{@html highlightInText(
|
|
fragment.title || t(T.searchable_text_untitled),
|
|
searchQuery.trim(),
|
|
)}
|
|
</div>
|
|
<Icon
|
|
icon="mdi:chevron-down"
|
|
class="shrink-0 text-stein-500 group-open:rotate-180 transition-transform"
|
|
aria-hidden="true"
|
|
/>
|
|
</div>
|
|
{#if fragment.tags.length > 0}
|
|
<span class="flex flex-wrap gap-1">
|
|
<Tags tags={fragment.tags} variant="white" />
|
|
</span>
|
|
{/if}
|
|
</summary>
|
|
<div
|
|
class="px-4 md:px-6 py-4 bg-stein-0 markdown prose prose-zinc prose-sm max-w-none text-xs"
|
|
>
|
|
<div class="flex justify-end mb-3 -mt-1">
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border border-stein-200 bg-stein-50 text-stein-700 hover:bg-wald-50 hover:border-wald-200 hover:text-wald-800 transition-colors"
|
|
onclick={(e) => {
|
|
e.preventDefault();
|
|
copyFragmentContent(fragment, i);
|
|
}}
|
|
aria-label={t(T.searchable_text_copy_aria)}
|
|
>
|
|
{#if copiedIndex === i}
|
|
<Icon
|
|
icon="mdi:check"
|
|
class="size-4 text-wald-600"
|
|
aria-hidden="true"
|
|
/>
|
|
<span>{t(T.searchable_text_copied)}</span>
|
|
{:else}
|
|
<Icon
|
|
icon="mdi:content-copy"
|
|
class="size-4"
|
|
aria-hidden="true"
|
|
/>
|
|
<span>{t(T.searchable_text_copy_button)}</span>
|
|
{/if}
|
|
</button>
|
|
</div>
|
|
{@html highlightInHtml(fragment.textHtml, searchQuery.trim())}
|
|
</div>
|
|
</details>
|
|
{/each}
|
|
</div>
|
|
</div>
|