feat(security+obs): SSRF allowlist, HTML sanitization, timing-safe tokens, fetch timeouts, structured logger
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>
This commit is contained in:
+29
-2
@@ -2,6 +2,8 @@ import { getTranslationBundleBySlug } from '$lib/cms';
|
|||||||
import { isCmsAvailable } from '$lib/cms-health.server';
|
import { isCmsAvailable } from '$lib/cms-health.server';
|
||||||
import { maintenanceResponse } from '$lib/maintenance.server';
|
import { maintenanceResponse } from '$lib/maintenance.server';
|
||||||
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
|
import { TRANSLATION_BUNDLE_SLUG } from '$lib/constants';
|
||||||
|
import { env as envPublic } from '$env/dynamic/public';
|
||||||
|
import { logWarn } from '$lib/log.server';
|
||||||
import type { Handle } from '@sveltejs/kit';
|
import type { Handle } from '@sveltejs/kit';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,6 +39,22 @@ function isMaintenanceSimulation(pathname: string, search: URLSearchParams): boo
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy /blog/* + /p/* Pfade auf das neue /posts/* Schema mappen.
|
||||||
|
* Liefert Ziel-Pfad oder null. Permanente 301-Redirects → werden vom
|
||||||
|
* Browser/CDN gecacht, daher kein Render-Aufruf nötig.
|
||||||
|
*/
|
||||||
|
function legacyRedirectTarget(pathname: string): string | null {
|
||||||
|
if (pathname === '/blog' || pathname === '/blog/') return '/posts/';
|
||||||
|
const m1 = pathname.match(/^\/blog\/page\/([^/]+)\/?$/);
|
||||||
|
if (m1) return `/posts/page/${m1[1]}/`;
|
||||||
|
const m2 = pathname.match(/^\/blog\/tag\/([^/]+)\/?$/);
|
||||||
|
if (m2) return `/posts/tag/${m2[1]}/`;
|
||||||
|
const m3 = pathname.match(/^\/p\/([^/]+)\/?$/);
|
||||||
|
if (m3) return `/post/${m3[1]}/`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
// Simulation: erlaubt manuelles Triggern der Maintenance-Seite ohne
|
// Simulation: erlaubt manuelles Triggern der Maintenance-Seite ohne
|
||||||
// CMS abzuschalten. /maintenance oder ?cms_down=1.
|
// CMS abzuschalten. /maintenance oder ?cms_down=1.
|
||||||
@@ -44,6 +62,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
return maintenanceResponse(200);
|
return maintenanceResponse(200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Legacy-Pfade (/blog, /p) → /posts, /post. Vor Health-Check, weil
|
||||||
|
// SvelteKit-Routing/CMS-Calls dafür unnötig.
|
||||||
|
const redirectTo = legacyRedirectTarget(event.url.pathname);
|
||||||
|
if (redirectTo) {
|
||||||
|
const dest = `${redirectTo}${event.url.search}`;
|
||||||
|
return new Response(null, { status: 301, headers: { location: dest } });
|
||||||
|
}
|
||||||
|
|
||||||
// Echter Ausfall: vor dem ersten CMS-Call prüfen ob die API antwortet.
|
// Echter Ausfall: vor dem ersten CMS-Call prüfen ob die API antwortet.
|
||||||
// Cached, daher kein Roundtrip pro Request.
|
// Cached, daher kein Roundtrip pro Request.
|
||||||
if (event.request.method === 'GET' && shouldHealthCheck(event.url.pathname)) {
|
if (event.request.method === 'GET' && shouldHealthCheck(event.url.pathname)) {
|
||||||
@@ -69,8 +95,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
} else {
|
} else {
|
||||||
event.locals.translations = {};
|
event.locals.translations = {};
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
event.locals.translations = {};
|
event.locals.translations = {};
|
||||||
|
logWarn('hooks.translations', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await resolve(event, {
|
const response = await resolve(event, {
|
||||||
@@ -116,7 +143,7 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
// headers alone so production hardening (X-Frame-Options / CSP from Caddy)
|
// headers alone so production hardening (X-Frame-Options / CSP from Caddy)
|
||||||
// stays intact.
|
// stays intact.
|
||||||
if (isPreviewDraft) {
|
if (isPreviewDraft) {
|
||||||
const parent = (process.env.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim();
|
const parent = (envPublic.PUBLIC_PREVIEW_PARENT_ORIGIN ?? '').trim();
|
||||||
response.headers.set(
|
response.headers.set(
|
||||||
'content-security-policy',
|
'content-security-policy',
|
||||||
`frame-ancestors 'self' ${parent || '*'}`,
|
`frame-ancestors 'self' ${parent || '*'}`,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { constantTimeEqual } from './auth-token.server';
|
||||||
|
|
||||||
|
describe('constantTimeEqual', () => {
|
||||||
|
it('matches identical strings', () => {
|
||||||
|
expect(constantTimeEqual('abc123', 'abc123')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects different strings of same length', () => {
|
||||||
|
expect(constantTimeEqual('abc123', 'abc124')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects different lengths', () => {
|
||||||
|
expect(constantTimeEqual('abc', 'abcd')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects null/undefined input', () => {
|
||||||
|
expect(constantTimeEqual(null, 'x')).toBe(false);
|
||||||
|
expect(constantTimeEqual('x', undefined)).toBe(false);
|
||||||
|
expect(constantTimeEqual(null, null)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty strings', () => {
|
||||||
|
expect(constantTimeEqual('', '')).toBe(true);
|
||||||
|
expect(constantTimeEqual('', 'x')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles unicode', () => {
|
||||||
|
expect(constantTimeEqual('für', 'für')).toBe(true);
|
||||||
|
expect(constantTimeEqual('für', 'fur')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* Konstantzeitvergleich für Auth-Tokens (Webhook-Secrets, API-Keys).
|
||||||
|
*
|
||||||
|
* `a !== b` leakt timing-Information; `crypto.timingSafeEqual` vergleicht in
|
||||||
|
* O(n) immer alle Bytes. Längen-Mismatch wird vorab geprüft, weil
|
||||||
|
* `timingSafeEqual` bei ungleicher Länge wirft.
|
||||||
|
*/
|
||||||
|
import { timingSafeEqual } from 'node:crypto';
|
||||||
|
|
||||||
|
export function constantTimeEqual(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||||
|
if (a == null || b == null) return false;
|
||||||
|
const ab = Buffer.from(a, 'utf8');
|
||||||
|
const bb = Buffer.from(b, 'utf8');
|
||||||
|
if (ab.length !== bb.length) return false;
|
||||||
|
return timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const ORIG_ENV = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
process.env.PUBLIC_CMS_URL = 'https://cms.example.com';
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...ORIG_ENV };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadResolver() {
|
||||||
|
const mod = await import('./cms-source.server');
|
||||||
|
return mod.resolveCmsSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveCmsSource', () => {
|
||||||
|
it('accepts asset filename', async () => {
|
||||||
|
const resolve = await loadResolver();
|
||||||
|
expect(resolve('photo.jpg')).toBe('https://cms.example.com/api/assets/photo.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts absolute path on CMS host', async () => {
|
||||||
|
const resolve = await loadResolver();
|
||||||
|
expect(resolve('/api/assets/foo.png')).toBe('https://cms.example.com/api/assets/foo.png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts full URL on CMS host', async () => {
|
||||||
|
const resolve = await loadResolver();
|
||||||
|
expect(resolve('https://cms.example.com/some/path')).toBe('https://cms.example.com/some/path');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects foreign host', async () => {
|
||||||
|
const resolve = await loadResolver();
|
||||||
|
expect(resolve('https://attacker.com/exploit.jpg')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects http when CMS uses https', async () => {
|
||||||
|
const resolve = await loadResolver();
|
||||||
|
expect(resolve('http://cms.example.com/foo')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects protocol-relative URLs', async () => {
|
||||||
|
const resolve = await loadResolver();
|
||||||
|
expect(resolve('//attacker.com/x.jpg')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects path traversal in filename', async () => {
|
||||||
|
const resolve = await loadResolver();
|
||||||
|
expect(resolve('../etc/passwd')).toBeNull();
|
||||||
|
expect(resolve('./hidden')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes "javascript:" payloads as harmless asset filename', async () => {
|
||||||
|
const resolve = await loadResolver();
|
||||||
|
// Kein Schema-Marker (kein "://") → wird als Filename behandelt und
|
||||||
|
// url-encoded an /api/assets angehängt. Server liefert 404 zurück,
|
||||||
|
// keine Code-Ausführung möglich.
|
||||||
|
expect(resolve('javascript:alert(1)')).toContain('/api/assets/javascript%3Aalert');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Allowlist-Resolver für `?src=...`-Query-Parameter in den Image- und File-Proxy-
|
||||||
|
* Routen. Verhindert SSRF: Angreifer könnten sonst beliebige URLs an die
|
||||||
|
* CMS-API durchreichen lassen (interne Hosts, Cloud-Metadata-Endpunkte,
|
||||||
|
* Cache-Pollution).
|
||||||
|
*
|
||||||
|
* Erlaubt:
|
||||||
|
* - Asset-Dateiname ("foo.jpg") → {CMS}/api/assets/foo.jpg
|
||||||
|
* - Absoluter Pfad ("/api/assets/...") → {CMS}/api/assets/...
|
||||||
|
* - Vollständige URL exakt unter CMS-Host → unverändert
|
||||||
|
*
|
||||||
|
* Abgelehnt:
|
||||||
|
* - Fremde Hosts, andere Schemata
|
||||||
|
* - Protokoll-relative URLs ("//host/...")
|
||||||
|
* - Pfad-Traversal ("..", führender Punkt)
|
||||||
|
*/
|
||||||
|
import { env } from '$env/dynamic/public';
|
||||||
|
|
||||||
|
export function getCmsBaseUrl(): string {
|
||||||
|
return (
|
||||||
|
env.PUBLIC_CMS_URL ||
|
||||||
|
import.meta.env.PUBLIC_CMS_URL ||
|
||||||
|
'http://localhost:3000'
|
||||||
|
).replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveCmsSource(src: string): string | null {
|
||||||
|
if (src.startsWith('//')) return null;
|
||||||
|
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
|
||||||
|
if (src.startsWith('http://') || src.startsWith('https://')) {
|
||||||
|
let candidate: URL;
|
||||||
|
let cmsHost: URL;
|
||||||
|
try {
|
||||||
|
candidate = new URL(src);
|
||||||
|
cmsHost = new URL(getCmsBaseUrl());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (candidate.host !== cmsHost.host) return null;
|
||||||
|
if (candidate.protocol !== cmsHost.protocol) return null;
|
||||||
|
return src;
|
||||||
|
}
|
||||||
|
if (src.includes('://') || src.includes('..') || src.startsWith('.')) return null;
|
||||||
|
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
|
||||||
|
}
|
||||||
+40
-22
@@ -15,6 +15,24 @@ const getBaseUrl = (): string => {
|
|||||||
return (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
|
return (typeof url === "string" && url.trim() ? url : "http://localhost:3000").replace(/\/$/, "");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default-Timeout für CMS-Calls (ms). Verhindert hängende SSR-Worker bei
|
||||||
|
* langsamem oder unerreichbarem CMS. Override per Call via `init.signal`
|
||||||
|
* oder global per `PUBLIC_CMS_FETCH_TIMEOUT_MS`.
|
||||||
|
*/
|
||||||
|
const FETCH_TIMEOUT_MS =
|
||||||
|
Number(env.PUBLIC_CMS_FETCH_TIMEOUT_MS) || 5_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper um `fetch` mit Default-Timeout. Wenn ein eigener `signal`
|
||||||
|
* mitgegeben wird, bleibt der unverändert; sonst wird `AbortSignal.timeout`
|
||||||
|
* gesetzt. Fehler-Mapping wie nativer fetch — Timeout wirft `AbortError`.
|
||||||
|
*/
|
||||||
|
async function cmsFetch(input: string | URL, init?: RequestInit): Promise<Response> {
|
||||||
|
const signal = init?.signal ?? AbortSignal.timeout(FETCH_TIMEOUT_MS);
|
||||||
|
return fetch(input, { ...init, signal });
|
||||||
|
}
|
||||||
|
|
||||||
/** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */
|
/** Page-Eintrag aus der API (aus OpenAPI-Spec generiert). */
|
||||||
export type PageEntry = components["schemas"]["page"];
|
export type PageEntry = components["schemas"]["page"];
|
||||||
|
|
||||||
@@ -125,7 +143,7 @@ export function cacheStats(): { size: number; keys: string[] } {
|
|||||||
export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
||||||
if (openApiCache && !import.meta.env.DEV) return openApiCache;
|
if (openApiCache && !import.meta.env.DEV) return openApiCache;
|
||||||
const base = getBaseUrl();
|
const base = getBaseUrl();
|
||||||
const res = await fetch(`${base}/api-docs/openapi.json`);
|
const res = await cmsFetch(`${base}/api-docs/openapi.json`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
|
`RustyCMS OpenAPI fetch failed: ${res.status} ${res.statusText}. Is the CMS running at ${base}?`,
|
||||||
@@ -142,7 +160,7 @@ export async function fetchOpenApi(): Promise<OpenApiSpec> {
|
|||||||
export async function getPages(): Promise<PageEntry[]> {
|
export async function getPages(): Promise<PageEntry[]> {
|
||||||
return cached("page", "page:list:", async () => {
|
return cached("page", "page:list:", async () => {
|
||||||
const base = getBaseUrl();
|
const base = getBaseUrl();
|
||||||
const res = await fetch(`${base}/api/content/page`);
|
const res = await cmsFetch(`${base}/api/content/page`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
|
`RustyCMS list page failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||||
@@ -168,7 +186,7 @@ export async function getPageConfigs(options?: {
|
|||||||
const url = new URL(`${base}/api/content/page_config`);
|
const url = new URL(`${base}/api/content/page_config`);
|
||||||
if (locale) url.searchParams.set("_locale", locale);
|
if (locale) url.searchParams.set("_locale", locale);
|
||||||
url.searchParams.set("_per_page", String(per));
|
url.searchParams.set("_per_page", String(per));
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
|
`RustyCMS list page_config failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||||
@@ -195,7 +213,7 @@ export async function getPageConfigBySlug(
|
|||||||
);
|
);
|
||||||
if (opts.locale) url.searchParams.set("_locale", opts.locale);
|
if (opts.locale) url.searchParams.set("_locale", opts.locale);
|
||||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -279,7 +297,7 @@ export type BatchResponse = { results: Record<string, BatchResult> };
|
|||||||
export async function batchFetch(requests: BatchRequest[]): Promise<BatchResponse> {
|
export async function batchFetch(requests: BatchRequest[]): Promise<BatchResponse> {
|
||||||
if (requests.length === 0) return { results: {} };
|
if (requests.length === 0) return { results: {} };
|
||||||
const base = getBaseUrl();
|
const base = getBaseUrl();
|
||||||
const res = await fetch(`${base}/api/content/_batch`, {
|
const res = await cmsFetch(`${base}/api/content/_batch`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ requests }),
|
body: JSON.stringify({ requests }),
|
||||||
@@ -328,7 +346,7 @@ export async function getPageBySlug(
|
|||||||
u.searchParams.set("_fields", options.fields.join(","));
|
u.searchParams.set("_fields", options.fields.join(","));
|
||||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||||
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||||
return fetch(u.toString());
|
return cmsFetch(u.toString());
|
||||||
};
|
};
|
||||||
const normSlug = normalizePageSlug(slug);
|
const normSlug = normalizePageSlug(slug);
|
||||||
let res = await trySlug(normSlug);
|
let res = await trySlug(normSlug);
|
||||||
@@ -383,7 +401,7 @@ export async function getPageStubs(options?: { locale?: string }): Promise<PageS
|
|||||||
url.searchParams.set("_fields", "_slug,slug,linkName,name,headline");
|
url.searchParams.set("_fields", "_slug,slug,linkName,name,headline");
|
||||||
url.searchParams.set("_per_page", "1000");
|
url.searchParams.set("_per_page", "1000");
|
||||||
if (locale) url.searchParams.set("_locale", locale);
|
if (locale) url.searchParams.set("_locale", locale);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (!res.ok) throw new Error(`RustyCMS list page (stubs) failed: ${res.status}`);
|
if (!res.ok) throw new Error(`RustyCMS list page (stubs) failed: ${res.status}`);
|
||||||
const data = (await res.json()) as { items?: PageStub[] };
|
const data = (await res.json()) as { items?: PageStub[] };
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
@@ -406,7 +424,7 @@ export async function getPostStubs(options?: { locale?: string }): Promise<PostS
|
|||||||
url.searchParams.set("_fields", "_slug,slug,linkName,headline");
|
url.searchParams.set("_fields", "_slug,slug,linkName,headline");
|
||||||
url.searchParams.set("_per_page", "1000");
|
url.searchParams.set("_per_page", "1000");
|
||||||
if (locale) url.searchParams.set("_locale", locale);
|
if (locale) url.searchParams.set("_locale", locale);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (!res.ok) throw new Error(`RustyCMS list post (stubs) failed: ${res.status}`);
|
if (!res.ok) throw new Error(`RustyCMS list post (stubs) failed: ${res.status}`);
|
||||||
const data = (await res.json()) as { items?: PostStub[] };
|
const data = (await res.json()) as { items?: PostStub[] };
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
@@ -449,7 +467,7 @@ export async function getNavigations(options?: {
|
|||||||
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
if (opts.resolve) url.searchParams.set("_resolve", opts.resolve);
|
||||||
url.searchParams.set("_page", String(opts.page ?? 1));
|
url.searchParams.set("_page", String(opts.page ?? 1));
|
||||||
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
|
url.searchParams.set("_per_page", String(opts.per_page ?? 50));
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
|
`RustyCMS list navigation failed: ${res.status}. Is the CMS running at ${base}?`,
|
||||||
@@ -509,7 +527,7 @@ export async function getNavigationBySlug(
|
|||||||
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
|
`${base}/api/content/navigation/${encodeURIComponent(slug)}`,
|
||||||
);
|
);
|
||||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return (await res.json()) as NavigationEntry;
|
return (await res.json()) as NavigationEntry;
|
||||||
@@ -534,7 +552,7 @@ export async function getFooterBySlug(
|
|||||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||||
if (options?.resolve?.length)
|
if (options?.resolve?.length)
|
||||||
url.searchParams.set("_resolve", options.resolve.join(","));
|
url.searchParams.set("_resolve", options.resolve.join(","));
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -577,7 +595,7 @@ export async function getPostBySlug(
|
|||||||
u.searchParams.set("_fields", options.fields.join(","));
|
u.searchParams.set("_fields", options.fields.join(","));
|
||||||
if (options?.preview) u.searchParams.set("preview", options.preview);
|
if (options?.preview) u.searchParams.set("preview", options.preview);
|
||||||
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
if (options?.previewDraft) u.searchParams.set("preview_draft", options.previewDraft);
|
||||||
return fetch(u.toString());
|
return cmsFetch(u.toString());
|
||||||
};
|
};
|
||||||
const normSlug = normalizePostSlug(slug);
|
const normSlug = normalizePostSlug(slug);
|
||||||
let res = await trySlug(normSlug);
|
let res = await trySlug(normSlug);
|
||||||
@@ -645,7 +663,7 @@ export async function getPosts(
|
|||||||
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
|
if (resolveVal) url.searchParams.set("_resolve", resolveVal);
|
||||||
if (typeof opts.depth === "number") url.searchParams.set("_depth", String(opts.depth));
|
if (typeof opts.depth === "number") url.searchParams.set("_depth", String(opts.depth));
|
||||||
if (fieldsVal) url.searchParams.set("_fields", fieldsVal);
|
if (fieldsVal) url.searchParams.set("_fields", fieldsVal);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (!res.ok) throw new Error("RustyCMS list post failed");
|
if (!res.ok) throw new Error("RustyCMS list post failed");
|
||||||
const data = (await res.json()) as { items?: PostEntry[] };
|
const data = (await res.json()) as { items?: PostEntry[] };
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
@@ -659,7 +677,7 @@ export type TagEntry = components["schemas"]["tag"];
|
|||||||
export async function getTags(): Promise<TagEntry[]> {
|
export async function getTags(): Promise<TagEntry[]> {
|
||||||
return cached("tag", "tag:list:", async () => {
|
return cached("tag", "tag:list:", async () => {
|
||||||
const base = getBaseUrl();
|
const base = getBaseUrl();
|
||||||
const res = await fetch(`${base}/api/content/tag`);
|
const res = await cmsFetch(`${base}/api/content/tag`);
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = (await res.json()) as { items?: TagEntry[] };
|
const data = (await res.json()) as { items?: TagEntry[] };
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
@@ -681,7 +699,7 @@ export async function getTextFragmentBySlug(
|
|||||||
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
|
`${base}/api/content/text_fragment/${encodeURIComponent(slug)}`,
|
||||||
);
|
);
|
||||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -707,7 +725,7 @@ export async function getFullwidthBannerBySlug(
|
|||||||
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
|
`${base}/api/content/fullwidth_banner/${encodeURIComponent(slug)}`,
|
||||||
);
|
);
|
||||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -733,7 +751,7 @@ export async function getTranslationBySlug(
|
|||||||
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
|
`${base}/api/content/translation/${encodeURIComponent(slug)}`,
|
||||||
);
|
);
|
||||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return (await res.json()) as TranslationEntry;
|
return (await res.json()) as TranslationEntry;
|
||||||
@@ -761,7 +779,7 @@ export async function getTranslationBundleBySlug(
|
|||||||
);
|
);
|
||||||
url.searchParams.set("_resolve", "all");
|
url.searchParams.set("_resolve", "all");
|
||||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return (await res.json()) as TranslationBundleEntry;
|
return (await res.json()) as TranslationBundleEntry;
|
||||||
@@ -787,7 +805,7 @@ export async function getCalendarBySlug(
|
|||||||
);
|
);
|
||||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||||
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
|
if (options?.resolve) url.searchParams.set("_resolve", options.resolve);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return (await res.json()) as CalendarEntry;
|
return (await res.json()) as CalendarEntry;
|
||||||
@@ -806,7 +824,7 @@ export async function getCalendarItemBySlug(
|
|||||||
`${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
|
`${base}/api/content/calendar_item/${encodeURIComponent(slug)}`,
|
||||||
);
|
);
|
||||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
return (await res.json()) as CalendarItemEntry;
|
return (await res.json()) as CalendarItemEntry;
|
||||||
@@ -824,7 +842,7 @@ export async function getCalendarItems(
|
|||||||
url.searchParams.set("_per_page", "1000");
|
url.searchParams.set("_per_page", "1000");
|
||||||
url.searchParams.set("_resolve", "all");
|
url.searchParams.set("_resolve", "all");
|
||||||
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
if (options?.locale) url.searchParams.set("_locale", options.locale);
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
const data = (await res.json()) as { items?: CalendarItemEntry[] };
|
const data = (await res.json()) as { items?: CalendarItemEntry[] };
|
||||||
return data.items ?? [];
|
return data.items ?? [];
|
||||||
@@ -853,7 +871,7 @@ export async function getCommentCounts(
|
|||||||
url.searchParams.set("_environment", options.environment);
|
url.searchParams.set("_environment", options.environment);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url.toString());
|
const res = await cmsFetch(url.toString());
|
||||||
if (!res.ok) return {};
|
if (!res.ok) return {};
|
||||||
const data = (await res.json()) as { counts?: Record<string, number> };
|
const data = (await res.json()) as { counts?: Record<string, number> };
|
||||||
return data.counts ?? {};
|
return data.counts ?? {};
|
||||||
|
|||||||
@@ -4,11 +4,9 @@
|
|||||||
* und Titel/Subtitle der aktuellen Page (Markdown). Bevorzugt `<picture>` mit WebP-Sources
|
* und Titel/Subtitle der aktuellen Page (Markdown). Bevorzugt `<picture>` mit WebP-Sources
|
||||||
* und srcset, wenn `responsive` geliefert wird; sonst Fallback auf einzelnes `<img>` (legacy).
|
* und srcset, wenn `responsive` geliefert wird; sonst Fallback auf einzelnes `<img>` (legacy).
|
||||||
*/
|
*/
|
||||||
import { marked } from "marked";
|
import { marked } from "$lib/markdown-safe";
|
||||||
import type { FullwidthBannerEntry } from "$lib/cms";
|
import type { FullwidthBannerEntry } from "$lib/cms";
|
||||||
|
|
||||||
marked.setOptions({ gfm: true });
|
|
||||||
|
|
||||||
interface ResponsiveBannerImage {
|
interface ResponsiveBannerImage {
|
||||||
sources: { type: string; srcset: string }[];
|
sources: { type: string; srcset: string }[];
|
||||||
fallback: string;
|
fallback: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { marked } from "marked";
|
import { marked } from "$lib/markdown-safe";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type { ImageGalleryBlockData, ImageGalleryImage } from "$lib/block-types";
|
import type { ImageGalleryBlockData, ImageGalleryImage } from "$lib/block-types";
|
||||||
@@ -29,8 +29,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
marked.setOptions({ gfm: true });
|
|
||||||
|
|
||||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
const descriptionHtml = $derived(
|
const descriptionHtml = $derived(
|
||||||
block.description && typeof block.description === "string"
|
block.description && typeof block.description === "string"
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { marked } from "marked";
|
import { marked } from "$lib/markdown-safe";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type { MarkdownBlockData } from "$lib/block-types";
|
import type { MarkdownBlockData } from "$lib/block-types";
|
||||||
|
|
||||||
let { block, class: className = "" }: { block: MarkdownBlockData; class?: string } = $props();
|
let { block, class: className = "" }: { block: MarkdownBlockData; class?: string } = $props();
|
||||||
|
|
||||||
marked.setOptions({ gfm: true });
|
|
||||||
const html = $derived(
|
const html = $derived(
|
||||||
block.resolvedContent ??
|
block.resolvedContent ??
|
||||||
(block.content && typeof block.content === "string"
|
(block.content && typeof block.content === "string"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { marked } from "marked";
|
import { marked } from "$lib/markdown-safe";
|
||||||
import Icon from "@iconify/svelte";
|
import Icon from "@iconify/svelte";
|
||||||
import "$lib/iconify-offline";
|
import "$lib/iconify-offline";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
@@ -10,8 +10,6 @@
|
|||||||
import RustyImage from "$lib/components/RustyImage.svelte";
|
import RustyImage from "$lib/components/RustyImage.svelte";
|
||||||
import Button from "$lib/ui/Button.svelte";
|
import Button from "$lib/ui/Button.svelte";
|
||||||
|
|
||||||
marked.setOptions({ gfm: true });
|
|
||||||
|
|
||||||
function organisationLogoField(o: OrganisationEntry) {
|
function organisationLogoField(o: OrganisationEntry) {
|
||||||
return extractCmsImageField(o.logo);
|
return extractCmsImageField(o.logo);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { marked } from "marked";
|
import { marked } from "$lib/markdown-safe";
|
||||||
import PostCard from "../PostCard.svelte";
|
import PostCard from "../PostCard.svelte";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type { PostOverviewBlockData } from "$lib/block-types";
|
import type { PostOverviewBlockData } from "$lib/block-types";
|
||||||
@@ -11,7 +11,6 @@
|
|||||||
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
|
let { block, class: className = "" }: { block: PostOverviewBlockData; class?: string } = $props();
|
||||||
const t = useTranslate();
|
const t = useTranslate();
|
||||||
|
|
||||||
marked.setOptions({ gfm: true });
|
|
||||||
const textHtml = $derived(
|
const textHtml = $derived(
|
||||||
block.text && typeof block.text === "string"
|
block.text && typeof block.text === "string"
|
||||||
? (marked.parse(block.text) as string)
|
? (marked.parse(block.text) as string)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { marked } from "marked";
|
import { marked } from "$lib/markdown-safe";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type {
|
import type {
|
||||||
SearchableTextBlockData,
|
SearchableTextBlockData,
|
||||||
@@ -28,8 +28,6 @@
|
|||||||
}
|
}
|
||||||
const helpTooltipText = $derived(t(T.searchable_text_help));
|
const helpTooltipText = $derived(t(T.searchable_text_help));
|
||||||
|
|
||||||
marked.setOptions({ gfm: true });
|
|
||||||
|
|
||||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
|
|
||||||
const fragments = $derived.by(() => {
|
const fragments = $derived.by(() => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { marked } from "marked";
|
import { marked } from "$lib/markdown-safe";
|
||||||
import { fade } from "svelte/transition";
|
import { fade } from "svelte/transition";
|
||||||
import { getBlockLayoutClasses } from "$lib/block-layout";
|
import { getBlockLayoutClasses } from "$lib/block-layout";
|
||||||
import type {
|
import type {
|
||||||
@@ -71,8 +71,6 @@
|
|||||||
return id ? `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg` : null;
|
return id ? `https://i.ytimg.com/vi/${encodeURIComponent(id)}/hqdefault.jpg` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
marked.setOptions({ gfm: true });
|
|
||||||
|
|
||||||
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
const layoutClasses = $derived(getBlockLayoutClasses(block.layout));
|
||||||
const descriptionHtml = $derived(
|
const descriptionHtml = $derived(
|
||||||
block.description && typeof block.description === "string"
|
block.description && typeof block.description === "string"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* Minimaler strukturierter Logger für Server-Code. Stdout/stderr in einem
|
||||||
|
* konsistenten Format, sodass Caddy/Docker-Aggregation davor zurechtkommt.
|
||||||
|
*
|
||||||
|
* Bewusst keine externe Lib (pino, winston) — Adapter-Node läuft lean,
|
||||||
|
* und das kleine Volumen rechtfertigt keinen extra Dep + Bundle-Overhead.
|
||||||
|
*
|
||||||
|
* Nutzung in `catch`-Blöcken statt swallowen:
|
||||||
|
*
|
||||||
|
* } catch (err) {
|
||||||
|
* logWarn('cms.batchFetch', err, { collection });
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* Der Scope (`'cms.batchFetch'`) ist freier Text — Konvention: `area.action`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type LogContext = Record<string, unknown>;
|
||||||
|
|
||||||
|
function format(level: string, scope: string, err: unknown, ctx?: LogContext): string {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const stack = err instanceof Error && err.stack ? err.stack : undefined;
|
||||||
|
const parts: string[] = [`[${level}] ${scope}: ${message}`];
|
||||||
|
if (ctx && Object.keys(ctx).length) {
|
||||||
|
parts.push(JSON.stringify(ctx));
|
||||||
|
}
|
||||||
|
if (stack && level === 'error') {
|
||||||
|
parts.push(stack);
|
||||||
|
}
|
||||||
|
return parts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logWarn(scope: string, err: unknown, ctx?: LogContext): void {
|
||||||
|
console.warn(format('warn', scope, err, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logError(scope: string, err: unknown, ctx?: LogContext): void {
|
||||||
|
console.error(format('error', scope, err, ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logInfo(scope: string, message: string, ctx?: LogContext): void {
|
||||||
|
const parts: string[] = [`[info] ${scope}: ${message}`];
|
||||||
|
if (ctx && Object.keys(ctx).length) parts.push(JSON.stringify(ctx));
|
||||||
|
console.log(parts.join(' '));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Zentral konfigurierter `marked`-Parser. Wichtig: deaktiviert das
|
||||||
|
* Rendern von **rohem HTML** in Markdown-Quellen — sonst kann ein
|
||||||
|
* Editor (oder ein kompromittiertes Editor-Konto) `<script>` oder
|
||||||
|
* `<iframe>`-Tags direkt einschleusen. Marked rendert sie standardmäßig
|
||||||
|
* wörtlich durch.
|
||||||
|
*
|
||||||
|
* `marked` ist Singleton: einmaliger Side-Effect-Import setzt die
|
||||||
|
* Renderer-Hooks global. Komponenten und Loader importieren `marked`
|
||||||
|
* von hier statt direkt von "marked".
|
||||||
|
*
|
||||||
|
* Komponenten, die `marked.parse(...)` aufrufen, sollten das Resultat
|
||||||
|
* weiterhin nur über `{@html}` einsetzen — der Sanitizer hier deckt das
|
||||||
|
* Markdown-→-HTML-Mapping ab. Roh-HTML-Felder (HtmlBlock) werden zusätzlich
|
||||||
|
* über `$lib/sanitize-blocks.server` gefiltert.
|
||||||
|
*/
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
let configured = false;
|
||||||
|
|
||||||
|
function configure() {
|
||||||
|
if (configured) return;
|
||||||
|
marked.setOptions({ gfm: true });
|
||||||
|
marked.use({
|
||||||
|
renderer: {
|
||||||
|
// Inline raw HTML: `<...>` → bleibt wörtlich als Text stehen.
|
||||||
|
html() {
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
configured = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
configure();
|
||||||
|
|
||||||
|
export { marked };
|
||||||
@@ -199,8 +199,7 @@ export async function resolveContentImages(layout: RowContentLayoutLike, transfo
|
|||||||
...transformParams,
|
...transformParams,
|
||||||
};
|
};
|
||||||
|
|
||||||
const { marked } = await import('marked');
|
const { marked } = await import('./markdown-safe');
|
||||||
marked.setOptions({ gfm: true });
|
|
||||||
|
|
||||||
for (const content of rows) {
|
for (const content of rows) {
|
||||||
for (const item of content) {
|
for (const item of content) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { sanitizeBlocks, sanitizeUntrustedHtml } from './sanitize-blocks.server';
|
||||||
|
|
||||||
|
describe('sanitizeUntrustedHtml', () => {
|
||||||
|
it('strips <script>', () => {
|
||||||
|
const out = sanitizeUntrustedHtml('<p>ok</p><script>alert(1)</script>');
|
||||||
|
expect(out).not.toContain('script');
|
||||||
|
expect(out).toContain('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips on* event handlers', () => {
|
||||||
|
const out = sanitizeUntrustedHtml('<a href="#" onclick="alert(1)">x</a>');
|
||||||
|
expect(out).not.toContain('onclick');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips javascript: URLs', () => {
|
||||||
|
const out = sanitizeUntrustedHtml('<a href="javascript:alert(1)">x</a>');
|
||||||
|
expect(out).not.toContain('javascript');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps allowed tags + attributes', () => {
|
||||||
|
const out = sanitizeUntrustedHtml('<p class="foo"><strong>bold</strong></p>');
|
||||||
|
expect(out).toContain('<p class="foo">');
|
||||||
|
expect(out).toContain('<strong>bold</strong>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows iframes only from approved hosts', () => {
|
||||||
|
const ok = sanitizeUntrustedHtml('<iframe src="https://www.youtube.com/embed/x"></iframe>');
|
||||||
|
expect(ok).toContain('youtube.com');
|
||||||
|
const bad = sanitizeUntrustedHtml('<iframe src="https://evil.com/x"></iframe>');
|
||||||
|
expect(bad).not.toContain('evil');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adds rel=noopener on target=_blank', () => {
|
||||||
|
const out = sanitizeUntrustedHtml('<a href="https://x.com" target="_blank">x</a>');
|
||||||
|
expect(out).toContain('rel="noopener noreferrer"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeBlocks (walker)', () => {
|
||||||
|
it('mutates html-block fields in place', () => {
|
||||||
|
const tree = {
|
||||||
|
row1Content: [
|
||||||
|
{ _type: 'html', html: '<p>safe</p><script>x</script>' },
|
||||||
|
{ _type: 'markdown', content: 'untouched' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
sanitizeBlocks(tree);
|
||||||
|
expect(tree.row1Content[0].html).not.toContain('script');
|
||||||
|
expect(tree.row1Content[0].html).toContain('safe');
|
||||||
|
expect((tree.row1Content[1] as { content: string }).content).toBe('untouched');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks nested arrays', () => {
|
||||||
|
const tree = {
|
||||||
|
sections: [
|
||||||
|
{ items: [{ _type: 'html', html: '<img src=x onerror=alert(1)>' }] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
sanitizeBlocks(tree);
|
||||||
|
const html = (tree.sections[0].items[0] as { html: string }).html;
|
||||||
|
expect(html).not.toContain('onerror');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-object inputs gracefully', () => {
|
||||||
|
expect(() => sanitizeBlocks(null)).not.toThrow();
|
||||||
|
expect(() => sanitizeBlocks('string')).not.toThrow();
|
||||||
|
expect(() => sanitizeBlocks([1, 2, 3])).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Walkt einen vom CMS gelieferten Page-/Post-Content-Tree und sanitisiert
|
||||||
|
* Felder, die später roh über `{@html}` gerendert werden — primär das
|
||||||
|
* `html`-Feld von HtmlBlocks.
|
||||||
|
*
|
||||||
|
* Wird in den Server-Loads aufgerufen (`+layout.server.ts`, `+page.server.ts`),
|
||||||
|
* **nachdem** `resolveContentImages` etc. den Tree fertig aufgelöst haben.
|
||||||
|
* Die Daten landen anschließend als sanitiertes HTML in der Page-Data, sodass
|
||||||
|
* Svelte-Komponenten beim Render (Server + Client) keine zusätzliche Filter-
|
||||||
|
* Logik brauchen.
|
||||||
|
*
|
||||||
|
* Server-only (`sanitize-html` braucht Node-APIs). Komponenten dürfen das
|
||||||
|
* Modul daher nicht importieren — der `.server.ts`-Suffix erzwingt das.
|
||||||
|
*/
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
|
||||||
|
const HTML_BLOCK_OPTIONS: sanitizeHtml.IOptions = {
|
||||||
|
// Allowlist: bewusst restriktiv. Alles, was nicht in `allowedTags` steht,
|
||||||
|
// wird entfernt. `allowedSchemes` blockiert javascript:, data: usw.
|
||||||
|
allowedTags: [
|
||||||
|
'p', 'br', 'hr',
|
||||||
|
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||||
|
'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup', 'mark',
|
||||||
|
'a', 'blockquote', 'cite', 'q',
|
||||||
|
'ul', 'ol', 'li',
|
||||||
|
'pre', 'code', 'kbd', 'samp',
|
||||||
|
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td',
|
||||||
|
'div', 'span', 'section', 'article', 'aside', 'figure', 'figcaption',
|
||||||
|
'img', 'picture', 'source',
|
||||||
|
'iframe',
|
||||||
|
],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ['href', 'name', 'target', 'rel', 'title'],
|
||||||
|
img: ['src', 'srcset', 'sizes', 'alt', 'width', 'height', 'loading', 'decoding'],
|
||||||
|
source: ['src', 'srcset', 'sizes', 'type', 'media'],
|
||||||
|
iframe: [
|
||||||
|
'src', 'width', 'height', 'allow', 'allowfullscreen',
|
||||||
|
'referrerpolicy', 'loading', 'title', 'frameborder',
|
||||||
|
],
|
||||||
|
'*': ['class', 'id', 'style', 'data-*', 'aria-*'],
|
||||||
|
},
|
||||||
|
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
||||||
|
allowedSchemesByTag: {
|
||||||
|
img: ['http', 'https', 'data'],
|
||||||
|
},
|
||||||
|
allowedIframeHostnames: [
|
||||||
|
'www.youtube.com',
|
||||||
|
'youtube.com',
|
||||||
|
'www.youtube-nocookie.com',
|
||||||
|
'player.vimeo.com',
|
||||||
|
'open.spotify.com',
|
||||||
|
],
|
||||||
|
allowProtocolRelative: false,
|
||||||
|
transformTags: {
|
||||||
|
a: (tagName, attribs) => ({
|
||||||
|
tagName,
|
||||||
|
attribs: {
|
||||||
|
...attribs,
|
||||||
|
rel: attribs.target === '_blank' ? 'noopener noreferrer' : (attribs.rel ?? ''),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sanitizeUntrustedHtml(html: string): string {
|
||||||
|
return sanitizeHtml(html, HTML_BLOCK_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Block = { _type?: string; html?: unknown } & Record<string, unknown>;
|
||||||
|
|
||||||
|
function isBlock(value: unknown): value is Block {
|
||||||
|
return typeof value === 'object' && value !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visit(node: unknown): void {
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
for (const item of node) visit(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isBlock(node)) return;
|
||||||
|
|
||||||
|
if (node._type === 'html' && typeof node.html === 'string') {
|
||||||
|
node.html = sanitizeUntrustedHtml(node.html);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(node)) {
|
||||||
|
if (key === '_type' || key === 'html') continue;
|
||||||
|
visit(node[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mutiert den Tree in-place: alle HtmlBlocks bekommen sanitisiertes `html`. */
|
||||||
|
export function sanitizeBlocks(root: unknown): void {
|
||||||
|
visit(root);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
SOCIAL_IMAGE_TRANSFORM,
|
SOCIAL_IMAGE_TRANSFORM,
|
||||||
} from '$lib/constants';
|
} from '$lib/constants';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
|
import { logWarn } from '$lib/log.server';
|
||||||
|
|
||||||
interface NavLink {
|
interface NavLink {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -94,8 +95,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
|
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
|
||||||
bootstrapFooter = batchData<FooterEntry>(batch.results.footer);
|
bootstrapFooter = batchData<FooterEntry>(batch.results.footer);
|
||||||
bootstrapConfig = batchData<PageConfigEntry>(batch.results.config);
|
bootstrapConfig = batchData<PageConfigEntry>(batch.results.config);
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */
|
/* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */
|
||||||
|
logWarn('layout.bootstrap', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback für config: Slug "default" probieren (legacy), wenn die kanonische
|
// Fallback für config: Slug "default" probieren (legacy), wenn die kanonische
|
||||||
@@ -112,8 +114,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
bootstrapConfig = batchData<PageConfigEntry>(fallback.results.config);
|
bootstrapConfig = batchData<PageConfigEntry>(fallback.results.config);
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* optional */
|
logWarn('layout.config-fallback', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,8 +193,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
const href = postHref ?? pageHref;
|
const href = postHref ?? pageHref;
|
||||||
headerLinks.push({ href, label });
|
headerLinks.push({ href, label });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// CMS nicht erreichbar
|
logWarn('layout.headerLinks', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Social-Media-Links (aus Batch übernommen — Fallback falls Batch nichts
|
// Social-Media-Links (aus Batch übernommen — Fallback falls Batch nichts
|
||||||
@@ -220,8 +222,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
const label = asObj.linkName ?? asObj.name ?? '';
|
const label = asObj.linkName ?? asObj.name ?? '';
|
||||||
socialLinks.push({ href, icon, label });
|
socialLinks.push({ href, icon, label });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* Social-Links optional */
|
logWarn('layout.socialLinks', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Footer + page_config kommen direkt aus dem Batch (siehe oben).
|
// Footer + page_config kommen direkt aus dem Batch (siehe oben).
|
||||||
@@ -257,7 +259,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
const text = (await res.text()).trim();
|
const text = (await res.text()).trim();
|
||||||
if (text.includes('<svg')) logoSvgHtml = text;
|
if (text.includes('<svg')) logoSvgHtml = text;
|
||||||
}
|
}
|
||||||
} catch { /* Fallback: img mit logoUrl */ }
|
} catch (err) { logWarn('layout.logoSvgFetch', err, { url: u }); }
|
||||||
} else {
|
} else {
|
||||||
logoUrl = await ensureTransformedImage(u, {
|
logoUrl = await ensureTransformedImage(u, {
|
||||||
height: 56,
|
height: 56,
|
||||||
@@ -266,8 +268,8 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* Logo optional */
|
logWarn('layout.logo', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteName =
|
const siteName =
|
||||||
@@ -282,8 +284,9 @@ export const load: LayoutServerLoad = async ({ locals }) => {
|
|||||||
logoSocialImage = transformed.startsWith('/')
|
logoSocialImage = transformed.startsWith('/')
|
||||||
? `${siteBaseUrl}${transformed}`
|
? `${siteBaseUrl}${transformed}`
|
||||||
: transformed;
|
: transformed;
|
||||||
} catch {
|
} catch (err) {
|
||||||
logoSocialImage = null;
|
logoSocialImage = null;
|
||||||
|
logWarn('layout.socialImage', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
resolveDeadlineBannerBlocks,
|
resolveDeadlineBannerBlocks,
|
||||||
} from '$lib/blog-utils';
|
} from '$lib/blog-utils';
|
||||||
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
|
import { DEFAULT_HOME_PAGE_SLUG, PAGE_RESOLVE, SITE_NAME } from '$lib/constants';
|
||||||
|
import { sanitizeBlocks } from '$lib/sanitize-blocks.server';
|
||||||
|
import { logWarn } from '$lib/log.server';
|
||||||
|
|
||||||
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920];
|
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920];
|
||||||
const BANNER_AR = '16/9';
|
const BANNER_AR = '16/9';
|
||||||
@@ -27,8 +29,8 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
|
|||||||
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
|
let homeSlug = DEFAULT_HOME_PAGE_SLUG;
|
||||||
try {
|
try {
|
||||||
homeSlug = (await getHomePageSlugFromConfig({ locale: 'de' })) ?? DEFAULT_HOME_PAGE_SLUG;
|
homeSlug = (await getHomePageSlugFromConfig({ locale: 'de' })) ?? DEFAULT_HOME_PAGE_SLUG;
|
||||||
} catch {
|
} catch (err) {
|
||||||
// fallback
|
logWarn('home.slug', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
let page = null;
|
let page = null;
|
||||||
@@ -47,9 +49,10 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
|
|||||||
await resolveSearchableTextBlocks(page, tagsMap);
|
await resolveSearchableTextBlocks(page, tagsMap);
|
||||||
await resolveCalendarBlocks(page);
|
await resolveCalendarBlocks(page);
|
||||||
await resolveDeadlineBannerBlocks(page);
|
await resolveDeadlineBannerBlocks(page);
|
||||||
|
sanitizeBlocks(page);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
// CMS nicht erreichbar
|
logWarn('home.page', err, { homeSlug });
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteName = (import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
|
const siteName = (import.meta.env.PUBLIC_SITE_NAME as string | undefined) || SITE_NAME;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '$lib/blog-utils';
|
} from '$lib/blog-utils';
|
||||||
import { PAGE_RESOLVE } from '$lib/constants';
|
import { PAGE_RESOLVE } from '$lib/constants';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
|
import { sanitizeBlocks } from '$lib/sanitize-blocks.server';
|
||||||
|
|
||||||
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920];
|
const BANNER_WIDTHS = [640, 960, 1280, 1600, 1920];
|
||||||
const BANNER_AR = '16/9';
|
const BANNER_AR = '16/9';
|
||||||
@@ -55,6 +56,7 @@ export const load: PageServerLoad = async ({ params, locals, fetch, url, setHead
|
|||||||
await resolveSearchableTextBlocks(page, tagsMap);
|
await resolveSearchableTextBlocks(page, tagsMap);
|
||||||
await resolveCalendarBlocks(page);
|
await resolveCalendarBlocks(page);
|
||||||
await resolveDeadlineBannerBlocks(page);
|
await resolveDeadlineBannerBlocks(page);
|
||||||
|
sanitizeBlocks(page);
|
||||||
|
|
||||||
// Resolve top banner if present
|
// Resolve top banner if present
|
||||||
let topBannerResolvedImages: string[] = [];
|
let topBannerResolvedImages: string[] = [];
|
||||||
|
|||||||
+2
-1
@@ -4,6 +4,7 @@ import { env } from '$env/dynamic/private';
|
|||||||
import { env as envPublic } from '$env/dynamic/public';
|
import { env as envPublic } from '$env/dynamic/public';
|
||||||
import { invalidateAll } from '$lib/cms';
|
import { invalidateAll } from '$lib/cms';
|
||||||
import { clearImageCache } from '$lib/image-cache.server';
|
import { clearImageCache } from '$lib/image-cache.server';
|
||||||
|
import { constantTimeEqual } from '$lib/auth-token.server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manual full-cache purge:
|
* Manual full-cache purge:
|
||||||
@@ -21,7 +22,7 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
|||||||
request.headers.get('x-revalidate-token') ??
|
request.headers.get('x-revalidate-token') ??
|
||||||
request.headers.get('x-webhook-secret') ??
|
request.headers.get('x-webhook-secret') ??
|
||||||
url.searchParams.get('token');
|
url.searchParams.get('token');
|
||||||
if (provided !== token) throw error(401, 'invalid token');
|
if (!constantTimeEqual(provided, token)) throw error(401, 'invalid token');
|
||||||
|
|
||||||
invalidateAll();
|
invalidateAll();
|
||||||
const img = await clearImageCache();
|
const img = await clearImageCache();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { json, error } from '@sveltejs/kit';
|
|||||||
import { env } from '$env/dynamic/private';
|
import { env } from '$env/dynamic/private';
|
||||||
import { invalidateAll, invalidateCollection } from '$lib/cms';
|
import { invalidateAll, invalidateCollection } from '$lib/cms';
|
||||||
import { clearImageCache } from '$lib/image-cache.server';
|
import { clearImageCache } from '$lib/image-cache.server';
|
||||||
|
import { constantTimeEqual } from '$lib/auth-token.server';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RustyCMS Webhook Receiver.
|
* RustyCMS Webhook Receiver.
|
||||||
@@ -29,7 +30,7 @@ export const POST: RequestHandler = async ({ request, url }) => {
|
|||||||
request.headers.get('x-revalidate-token') ??
|
request.headers.get('x-revalidate-token') ??
|
||||||
request.headers.get('x-webhook-secret') ??
|
request.headers.get('x-webhook-secret') ??
|
||||||
url.searchParams.get('token');
|
url.searchParams.get('token');
|
||||||
if (provided !== token) {
|
if (!constantTimeEqual(provided, token)) {
|
||||||
throw error(401, 'invalid token');
|
throw error(401, 'invalid token');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async () => {
|
|
||||||
redirect(301, '/posts');
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
|
||||||
redirect(301, `/posts/page/${encodeURIComponent(params.page)}/`);
|
|
||||||
};
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
|
||||||
redirect(301, `/posts/tag/${encodeURIComponent(params.tag)}/`);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { env } from '$env/dynamic/public';
|
|
||||||
import {
|
import {
|
||||||
buildFileCacheKey,
|
buildFileCacheKey,
|
||||||
cacheFile,
|
cacheFile,
|
||||||
@@ -7,26 +6,12 @@ import {
|
|||||||
extFromSrc,
|
extFromSrc,
|
||||||
getCachedFile,
|
getCachedFile,
|
||||||
} from '$lib/file-cache.server';
|
} from '$lib/file-cache.server';
|
||||||
|
import { resolveCmsSource } from '$lib/cms-source.server';
|
||||||
|
|
||||||
const IMMUTABLE_HEADERS = {
|
const IMMUTABLE_HEADERS = {
|
||||||
'cache-control': 'public, max-age=31536000, immutable',
|
'cache-control': 'public, max-age=31536000, immutable',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function getCmsBaseUrl(): string {
|
|
||||||
return (
|
|
||||||
env.PUBLIC_CMS_URL ||
|
|
||||||
import.meta.env.PUBLIC_CMS_URL ||
|
|
||||||
'http://localhost:3000'
|
|
||||||
).replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSourceUrl(src: string): string {
|
|
||||||
if (src.startsWith('http://') || src.startsWith('https://')) return src;
|
|
||||||
if (src.startsWith('//')) return `https:${src}`;
|
|
||||||
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
|
|
||||||
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResponseHeaders(
|
function buildResponseHeaders(
|
||||||
contentType: string,
|
contentType: string,
|
||||||
download: string | null,
|
download: string | null,
|
||||||
@@ -73,7 +58,10 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceUrl = resolveSourceUrl(src);
|
const sourceUrl = resolveCmsSource(src);
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return new Response('Source not allowed', { status: 400 });
|
||||||
|
}
|
||||||
let res: Response;
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
res = await fetch(sourceUrl);
|
res = await fetch(sourceUrl);
|
||||||
|
|||||||
@@ -1,36 +1,16 @@
|
|||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import { env } from '$env/dynamic/public';
|
|
||||||
import {
|
import {
|
||||||
buildCacheKey,
|
buildCacheKey,
|
||||||
getCachedImage,
|
getCachedImage,
|
||||||
cacheImage,
|
cacheImage,
|
||||||
type ImageTransformParams,
|
type ImageTransformParams,
|
||||||
} from '$lib/image-cache.server';
|
} from '$lib/image-cache.server';
|
||||||
|
import { getCmsBaseUrl, resolveCmsSource } from '$lib/cms-source.server';
|
||||||
|
|
||||||
const IMMUTABLE_HEADERS = {
|
const IMMUTABLE_HEADERS = {
|
||||||
'cache-control': 'public, max-age=31536000, immutable',
|
'cache-control': 'public, max-age=31536000, immutable',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
function getCmsBaseUrl(): string {
|
|
||||||
return (
|
|
||||||
env.PUBLIC_CMS_URL ||
|
|
||||||
import.meta.env.PUBLIC_CMS_URL ||
|
|
||||||
'http://localhost:3000'
|
|
||||||
).replace(/\/$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wenn src kein vollständiger URL ist, wird er als CMS-Asset-Dateiname behandelt
|
|
||||||
* und zu {CMS_URL}/api/assets/{src} aufgelöst.
|
|
||||||
* Absolute Pfade (z.B. /api/assets/logo/logo3.svg) werden direkt an die CMS-URL angehängt.
|
|
||||||
*/
|
|
||||||
function resolveSourceUrl(src: string): string {
|
|
||||||
if (src.startsWith('http://') || src.startsWith('https://')) return src;
|
|
||||||
if (src.startsWith('//')) return `https:${src}`;
|
|
||||||
if (src.startsWith('/')) return `${getCmsBaseUrl()}${src}`;
|
|
||||||
return `${getCmsBaseUrl()}/api/assets/${encodeURIComponent(src)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pick output format based on client Accept header.
|
* Pick output format based on client Accept header.
|
||||||
* Priority: avif > webp > jpeg. Used when `format=auto` is requested so the
|
* Priority: avif > webp > jpeg. Used when `format=auto` is requested so the
|
||||||
@@ -102,7 +82,10 @@ export const GET: RequestHandler = async ({ url, request }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceUrl = resolveSourceUrl(src);
|
const sourceUrl = resolveCmsSource(src);
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return new Response('Source not allowed', { status: 400 });
|
||||||
|
}
|
||||||
const cmsBase = getCmsBaseUrl();
|
const cmsBase = getCmsBaseUrl();
|
||||||
const transformParams = new URLSearchParams();
|
const transformParams = new URLSearchParams();
|
||||||
transformParams.set('url', sourceUrl);
|
transformParams.set('url', sourceUrl);
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
|
||||||
import { redirect } from '@sveltejs/kit';
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
|
||||||
const { slug } = params;
|
|
||||||
redirect(301, `/post/${encodeURIComponent(slug)}`);
|
|
||||||
};
|
|
||||||
@@ -16,9 +16,8 @@ import {
|
|||||||
import { POST_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM, SITE_NAME } from '$lib/constants';
|
import { POST_RESOLVE, POST_SOCIAL_IMAGE_TRANSFORM, SITE_NAME } from '$lib/constants';
|
||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error } from '@sveltejs/kit';
|
||||||
import { marked } from 'marked';
|
import { marked } from '$lib/markdown-safe';
|
||||||
|
import { sanitizeBlocks } from '$lib/sanitize-blocks.server';
|
||||||
marked.setOptions({ gfm: true });
|
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params, locals, url, setHeaders }) => {
|
export const load: PageServerLoad = async ({ params, locals, url, setHeaders }) => {
|
||||||
const { slug } = params;
|
const { slug } = params;
|
||||||
@@ -51,6 +50,7 @@ export const load: PageServerLoad = async ({ params, locals, url, setHeaders })
|
|||||||
await resolveSearchableTextBlocks(post, tagsMap);
|
await resolveSearchableTextBlocks(post, tagsMap);
|
||||||
await resolveDeadlineBannerBlocks(post);
|
await resolveDeadlineBannerBlocks(post);
|
||||||
resolvePostTagsInPost(post, tagsMap);
|
resolvePostTagsInPost(post, tagsMap);
|
||||||
|
sanitizeBlocks(post);
|
||||||
|
|
||||||
const postImageField = getPostImageField(post.postImage);
|
const postImageField = getPostImageField(post.postImage);
|
||||||
const postImageUrl = postImageField
|
const postImageUrl = postImageField
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { RequestHandler } from './$types';
|
|||||||
import { env } from '$env/dynamic/public';
|
import { env } from '$env/dynamic/public';
|
||||||
import { getPages, getPosts } from '$lib/cms';
|
import { getPages, getPosts } from '$lib/cms';
|
||||||
import { filterHiddenPosts, postHref } from '$lib/blog-utils';
|
import { filterHiddenPosts, postHref } from '$lib/blog-utils';
|
||||||
|
import { logWarn } from '$lib/log.server';
|
||||||
|
|
||||||
function normalizeSlug(s: string | undefined): string {
|
function normalizeSlug(s: string | undefined): string {
|
||||||
return (s ?? '').replace(/^\//, '').trim();
|
return (s ?? '').replace(/^\//, '').trim();
|
||||||
@@ -64,8 +65,8 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
priority: '0.6',
|
priority: '0.6',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* CMS down */
|
logWarn('sitemap.pages', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -85,8 +86,8 @@ export const GET: RequestHandler = async ({ url }) => {
|
|||||||
priority: '0.5',
|
priority: '0.5',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (err) {
|
||||||
/* CMS down */
|
logWarn('sitemap.posts', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
|||||||
Reference in New Issue
Block a user