From 5b5ec7c6592147876104dfb13f9e4cc509564394 Mon Sep 17 00:00:00 2001 From: Peter Meier Date: Wed, 22 Apr 2026 10:23:20 +0200 Subject: [PATCH] feat: add FilesBlock interface and component integration - Introduced FilesBlockItem and FilesBlockData interfaces to support file handling in the CMS. - Integrated FilesBlock component into ContentRows.svelte for rendering file blocks. - Updated block type handling to include support for files, enhancing content versatility. --- src/lib/block-types.ts | 23 ++++ src/lib/components/ContentRows.svelte | 4 + src/lib/components/blocks/FilesBlock.svelte | 126 +++++++++++++++++++ src/lib/file-cache.server.ts | 128 ++++++++++++++++++++ src/lib/rusty-file.ts | 97 +++++++++++++++ src/routes/cms-files/+server.ts | 103 ++++++++++++++++ 6 files changed, 481 insertions(+) create mode 100644 src/lib/components/blocks/FilesBlock.svelte create mode 100644 src/lib/file-cache.server.ts create mode 100644 src/lib/rusty-file.ts create mode 100644 src/routes/cms-files/+server.ts diff --git a/src/lib/block-types.ts b/src/lib/block-types.ts index ec0e2e9..5f8667b 100644 --- a/src/lib/block-types.ts +++ b/src/lib/block-types.ts @@ -119,6 +119,29 @@ export interface ImageGalleryBlockData { layout?: BlockLayout; } +/** Einzelne Datei in einem files-Block (CMS file-Field). */ +export interface FilesBlockItem { + _type?: "file"; + src?: string; + title?: string; + description?: string; + /** Aus Sidecar gemerged (size in Bytes). */ + size?: number; + mime?: string; + filename?: string; +} + +/** Datei-Block (_type: "files"). items = Array von file-Feldern. */ +export interface FilesBlockData { + _type?: "files"; + _slug?: string; + headline?: string; + description?: string; + items?: Array; + forceDownload?: boolean; + layout?: BlockLayout; +} + /** YouTube-Video (_type: "youtube_video"). */ export interface YoutubeVideoBlockData { _type?: "youtube_video"; diff --git a/src/lib/components/ContentRows.svelte b/src/lib/components/ContentRows.svelte index f01333f..5f9538b 100644 --- a/src/lib/components/ContentRows.svelte +++ b/src/lib/components/ContentRows.svelte @@ -5,6 +5,7 @@ import IframeBlock from "./blocks/IframeBlock.svelte"; import ImageBlock from "./blocks/ImageBlock.svelte"; import ImageGalleryBlock from "./blocks/ImageGalleryBlock.svelte"; + import FilesBlock from "./blocks/FilesBlock.svelte"; import YoutubeVideoBlock from "./blocks/YoutubeVideoBlock.svelte"; import QuoteBlock from "./blocks/QuoteBlock.svelte"; import QuoteCarouselBlock from "./blocks/QuoteCarouselBlock.svelte"; @@ -27,6 +28,7 @@ IframeBlockData, ImageBlockData, ImageGalleryBlockData, + FilesBlockData, YoutubeVideoBlockData, QuoteBlockData, QuoteCarouselBlockData, @@ -112,6 +114,8 @@ {:else if blockType(item) === "image_gallery"} + {:else if blockType(item) === "files"} + {:else if blockType(item) === "youtube_video"} {:else if blockType(item) === "quote"} diff --git a/src/lib/components/blocks/FilesBlock.svelte b/src/lib/components/blocks/FilesBlock.svelte new file mode 100644 index 0000000..9ec7363 --- /dev/null +++ b/src/lib/components/blocks/FilesBlock.svelte @@ -0,0 +1,126 @@ + + +
+ {#if block.headline} +

{block.headline}

+ {/if} + {#if block.description} +

{block.description}

+ {/if} + {#if resolved.length === 0} +

Keine Dateien verfügbar.

+ {:else} + + {/if} +
diff --git a/src/lib/file-cache.server.ts b/src/lib/file-cache.server.ts new file mode 100644 index 0000000..53d8630 --- /dev/null +++ b/src/lib/file-cache.server.ts @@ -0,0 +1,128 @@ +/** + * Persistenter Disk-Cache für CMS-Dateien (PDF, DOCX, ...). + * Analog zu image-cache.server.ts: Datei einmal vom CMS holen, auf Disk + * speichern, danach direkt von dort ausliefern. + */ + +import { createHash } from 'node:crypto'; +import { existsSync, mkdirSync } from 'node:fs'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { extname, join } from 'node:path'; + +const CACHE_DIR = process.env.FILE_CACHE_DIR?.trim() || '.cache/files'; +const MAX_MEMORY_ENTRIES = Number(process.env.FILE_CACHE_MAX_MEMORY) || 50; + +export interface CachedFile { + buffer: Buffer; + contentType: string; + ext: string; +} + +const memoryCache = new Map(); +let resolvedDir: string | null = null; + +function ensureCacheDir(): string { + const dir = resolvedDir ?? join(process.cwd(), CACHE_DIR); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + resolvedDir = dir; + return dir; +} + +export function buildFileCacheKey(src: string): string { + return createHash('sha256').update(src).digest('hex').slice(0, 24); +} + +const EXT_TO_CT: Record = { + pdf: 'application/pdf', + doc: 'application/msword', + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + xls: 'application/vnd.ms-excel', + xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ppt: 'application/vnd.ms-powerpoint', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + odt: 'application/vnd.oasis.opendocument.text', + ods: 'application/vnd.oasis.opendocument.spreadsheet', + zip: 'application/zip', + txt: 'text/plain; charset=utf-8', + csv: 'text/csv; charset=utf-8', + json: 'application/json', + xml: 'application/xml', + rtf: 'application/rtf', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + webp: 'image/webp', + gif: 'image/gif', + svg: 'image/svg+xml', +}; + +export function contentTypeFromExt(ext: string): string { + return EXT_TO_CT[ext.toLowerCase()] ?? 'application/octet-stream'; +} + +export function extFromContentType(ct: string): string | null { + const lower = ct.toLowerCase(); + for (const [ext, mime] of Object.entries(EXT_TO_CT)) { + if (lower.startsWith(mime.split(';')[0])) return ext; + } + return null; +} + +export function extFromSrc(src: string): string | null { + try { + const path = src.split('?')[0].split('#')[0]; + const e = extname(path).slice(1).toLowerCase(); + return e || null; + } catch { + return null; + } +} + +function rememberInMemory(key: string, cached: CachedFile): CachedFile { + if (memoryCache.size >= MAX_MEMORY_ENTRIES) { + const oldest = memoryCache.keys().next().value; + if (oldest !== undefined) memoryCache.delete(oldest); + } + memoryCache.set(key, cached); + return cached; +} + +export async function getCachedFile(key: string): Promise { + const mem = memoryCache.get(key); + if (mem) return mem; + + const dir = ensureCacheDir(); + try { + const files = await readdir(dir); + const prefix = `${key}.`; + const name = files.find((f) => f.startsWith(prefix)); + if (!name) return null; + const buf = await readFile(join(dir, name)); + const ext = name.slice(prefix.length); + return rememberInMemory(key, { + buffer: buf, + contentType: contentTypeFromExt(ext), + ext, + }); + } catch { + return null; + } +} + +export async function cacheFile( + key: string, + buffer: ArrayBuffer | Buffer, + contentType: string, + srcHint?: string, +): Promise { + const dir = ensureCacheDir(); + const buf = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer); + const ext = + extFromContentType(contentType) ?? + (srcHint ? extFromSrc(srcHint) : null) ?? + 'bin'; + const cached: CachedFile = { buffer: buf, contentType, ext }; + rememberInMemory(key, cached); + await writeFile(join(dir, `${key}.${ext}`), buf); + return cached; +} diff --git a/src/lib/rusty-file.ts b/src/lib/rusty-file.ts new file mode 100644 index 0000000..c66f680 --- /dev/null +++ b/src/lib/rusty-file.ts @@ -0,0 +1,97 @@ +/** + * Datei-URLs für SvelteKit: /cms-files mit persistentem Server-Disk-Cache. + * Analog zu rusty-image.ts, aber für generische Dateien (PDF, DOCX, ...). + */ + +export interface CmsFileField { + url: string; + title?: string; + description?: string; + size?: number; + mime?: string; + filename?: string; +} + +/** + * Extrahiert URL/Title/Description aus einem CMS-File-Field + * (string oder { src, title?, description?, mime?, size?, ... }). + */ +export function extractCmsFileField(field: unknown): CmsFileField | null { + if (typeof field === 'string') { + const s = field.trim(); + if (!s) return null; + return { url: s.startsWith('//') ? `https:${s}` : s }; + } + if (field && typeof field === 'object') { + const o = field as { + src?: string; + file?: { url?: string }; + title?: string; + description?: string; + size?: number; + mime?: string; + filename?: string; + }; + const rawUrl = + typeof o.src === 'string' && o.src.trim() + ? o.src + : typeof o.file?.url === 'string' && o.file.url.trim() + ? o.file.url + : undefined; + if (!rawUrl) return null; + const url = rawUrl.startsWith('//') ? `https:${rawUrl}` : rawUrl; + return { + url, + ...(o.title ? { title: o.title } : {}), + ...(o.description ? { description: o.description } : {}), + ...(typeof o.size === 'number' ? { size: o.size } : {}), + ...(o.mime ? { mime: o.mime } : {}), + ...(o.filename ? { filename: o.filename } : {}), + }; + } + return null; +} + +export interface CmsFileUrlOptions { + /** Filename hint for Content-Disposition header. */ + filename?: string; + /** Force download (Content-Disposition: attachment). */ + download?: boolean; +} + +/** + * Lokale Datei-URL: /cms-files?src=…&name=…&dl=… + * `src` = volle Datei-URL oder CMS-Asset-Pfad (`/api/assets/...`). + */ +export function getCmsFileUrl(src: string, opts: CmsFileUrlOptions = {}): string { + const params = new URLSearchParams(); + params.set('src', src); + if (opts.filename) params.set('name', opts.filename); + if (opts.download) params.set('dl', '1'); + return `/cms-files?${params.toString()}`; +} + +const SIZE_UNITS = ['B', 'KB', 'MB', 'GB']; + +export function formatFileSize(bytes?: number): string | null { + if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return null; + let b = bytes; + let i = 0; + while (b >= 1024 && i < SIZE_UNITS.length - 1) { + b /= 1024; + i++; + } + const rounded = b >= 10 || i === 0 ? Math.round(b) : Math.round(b * 10) / 10; + return `${rounded} ${SIZE_UNITS[i]}`; +} + +export function fileExtFromUrl(url: string): string | null { + try { + const path = url.split('?')[0].split('#')[0]; + const last = path.split('/').pop() ?? ''; + const dot = last.lastIndexOf('.'); + return dot >= 0 ? last.slice(dot + 1).toLowerCase() : null; + } catch { + return null; + } +} diff --git a/src/routes/cms-files/+server.ts b/src/routes/cms-files/+server.ts new file mode 100644 index 0000000..ebe477b --- /dev/null +++ b/src/routes/cms-files/+server.ts @@ -0,0 +1,103 @@ +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/public'; +import { + buildFileCacheKey, + cacheFile, + contentTypeFromExt, + extFromSrc, + getCachedFile, +} from '$lib/file-cache.server'; + +const IMMUTABLE_HEADERS = { + 'cache-control': 'public, max-age=31536000, immutable', +} 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( + contentType: string, + download: string | null, + filename?: string, +): HeadersInit { + const headers: Record = { + 'content-type': contentType, + ...IMMUTABLE_HEADERS, + }; + if (download === '1' || download === 'true') { + const safe = (filename ?? 'download').replace(/"/g, ''); + headers['content-disposition'] = `attachment; filename="${safe}"`; + } else if (filename) { + const safe = filename.replace(/"/g, ''); + headers['content-disposition'] = `inline; filename="${safe}"`; + } + return headers; +} + +export const GET: RequestHandler = async ({ url }) => { + const src = url.searchParams.get('src'); + if (!src) { + return new Response('Missing "src" parameter', { status: 400 }); + } + const download = url.searchParams.get('dl'); + const filenameParam = url.searchParams.get('name') ?? undefined; + const inferredName = (() => { + try { + const path = src.split('?')[0].split('#')[0]; + const last = path.split('/').pop(); + return last ? decodeURIComponent(last) : undefined; + } catch { + return undefined; + } + })(); + const filename = filenameParam ?? inferredName; + + const key = buildFileCacheKey(src); + + const cached = await getCachedFile(key); + if (cached) { + return new Response(new Uint8Array(cached.buffer), { + headers: buildResponseHeaders(cached.contentType, download, filename), + }); + } + + const sourceUrl = resolveSourceUrl(src); + let res: Response; + try { + res = await fetch(sourceUrl); + } catch (err) { + console.error('[cms-files] CMS fetch failed:', err); + return new Response('CMS not reachable', { status: 502 }); + } + + if (!res.ok) { + return new Response('File fetch failed', { status: res.status }); + } + + const buffer = await res.arrayBuffer(); + const upstreamCt = res.headers.get('content-type') ?? ''; + const ext = extFromSrc(src); + const contentType = upstreamCt && !upstreamCt.startsWith('application/octet-stream') + ? upstreamCt + : ext + ? contentTypeFromExt(ext) + : upstreamCt || 'application/octet-stream'; + + const result = await cacheFile(key, buffer, contentType, src); + + return new Response(new Uint8Array(result.buffer), { + headers: buildResponseHeaders(result.contentType, download, filename), + }); +};