diff --git a/.claude/launch.json b/.claude/launch.json index 2363248..cee3fe1 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -25,9 +25,9 @@ "runtimeArgs": [ "-NoProfile", "-Command", - "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-3fc9a10171d3/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8091 -t public bin/dev-router.php" + "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-a1c96276da5c/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=24M -d post_max_size=26M -d memory_limit=256M -S 127.0.0.1:8092 -t public bin/dev-router.php" ], - "port": 8091 + "port": 8092 }, { "name": "elektro-bernhard", diff --git a/config/services.yaml b/config/services.yaml index 7ccc8ab..b8db3c4 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -38,6 +38,9 @@ services: App\Content\MediaLibrary: arguments: $directory: '%env(projectdir:MEDIA_DIRECTORY)%' + App\Content\DocumentLibrary: + arguments: + $directory: '%env(projectdir:MEDIA_DIRECTORY)%' # Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert # werden, ohne PHP dazwischen - das ist der Punkt der Uebung. # Die Stellen, die wissen muessen, wie die Verwaltung heisst. diff --git a/docs/STATUS.md b/docs/STATUS.md index fd815cf..869e8d9 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,17 @@ # Projektstatus +## PDFs in der Mediathek, als Linkziel und im Betrachter (22.09.2026) + +**Anlass:** Umut: „kann man aktuell PDFs hochladen und als Button verlinken? … und ein Inline PDF Viewer wie Bild-Popup“, danach: „ja setz um, einfacher Browser-Betrachter“. + +- **Hochladen:** Das Upload-Feld der Mediathek nimmt jetzt neben JPEG, PNG und WebP auch PDFs bis 20 MB an. Ob eine Datei ein PDF ist, entscheidet ihr Inhalt (`%PDF-` in den ersten 1024 Bytes), nicht die Endung. Die PDFs stehen in einer eigenen Tabelle `document` (Migration `20260917000100`) und nicht in `media`. Jede Bildauswahl erwartet dort Varianten mit Breite und Höhe. Die Datei liegt unverändert als `.pdf` im Medienordner und geht deshalb mit jeder Sicherung mit. Die Mediathek zeigt die PDFs unter den Bildern, mit Titel und Archivieren. +- **Ausliefern:** `/media/.pdf` kommt mit `Content-Type: application/pdf`, `nosniff`, `inline` und dem Titel als Dateinamen, `X-Robots-Tag: noindex` und `frame-ancestors 'self'`: Einbetten darf es nur die eigene Website. Archivierte PDFs bleiben erreichbar, wie Bilder. +- **Verlinken:** Jedes Linkziel (Button, Bild, Symbolkarte, CTA, Symbol) hat den Knopf „PDF aus Mediathek wählen“ mit einer Liste, Suche und Hochladen direkt im Dialog. Ins Feld kommt die Adresse `/media/.pdf`, ein gewöhnlicher lokaler Pfad, also ohne neue Schemaversion. +- **Betrachter:** `public/assets/lightbox.js` (dieselbe Datei wie der Bildbetrachter, damit eine Seite kein Skript mehr lädt) zeigt ein verlinktes PDF in einem `` mit ` + `; + document.body.append(dialog); + rahmen = dialog.querySelector('.pdf-frame'); + titel = dialog.querySelector('.pdf-title'); + laden = [dialog.querySelector('.pdf-download'), dialog.querySelector('.pdf-open')]; + dialog.querySelector('.pdf-close').addEventListener('click', () => dialog.close()); + dialog.addEventListener('click', event => { + if (event.target === dialog) { + dialog.close(); + } + }); + // Der Rahmen wird geleert, sonst läuft ein eingebettetes Video oder Audio im PDF weiter + // und das nächste Öffnen zeigt kurz das vorige Dokument. + dialog.addEventListener('close', () => rahmen.removeAttribute('src')); + }; + + for (const link of links) { + link.addEventListener('click', event => { + if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || 1 === event.button) { + return; + } + event.preventDefault(); + if (!eingebettet) { + window.open(link.href, '_blank', 'noopener'); + + return; + } + if (null === dialog) { + baue(); + } + const name = (link.getAttribute('aria-label') || link.textContent || '').trim(); + titel.textContent = name || 'Dokument'; + rahmen.title = name ? `PDF: ${name}` : 'PDF-Dokument'; + for (const aktion of laden) { + aktion.href = link.href; + } + rahmen.src = link.href; + dialog.showModal(); + }); + } +})(); diff --git a/public/assets/media-dialog.css b/public/assets/media-dialog.css index d166fc9..84e0066 100644 --- a/public/assets/media-dialog.css +++ b/public/assets/media-dialog.css @@ -28,3 +28,13 @@ .media-dialog> .panel-hint{margin-top:20px} .media-details>p{font-size:12px} @media(max-width:767px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}} +/* PDFs: eine Liste statt eines Rasters - es gibt nichts zu sehen außer dem Titel. */ +.document-dialog{max-width:720px} +.document-list{display:grid;gap:8px;max-height:360px;overflow:auto;padding:4px} +.document-card{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:12px;border:2px solid var(--line);border-radius:9px;padding:10px 12px;background:white;color:var(--ink);text-align:left} +.document-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc} +.document-card strong{font-size:13px;overflow-wrap:anywhere} +.document-card span{font-size:11px;color:var(--muted)} +.document-card .document-badge{font-weight:700;color:#8b2f22;background:#fff0eb;border-radius:5px;padding:4px 6px;letter-spacing:.04em} +.media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px} +.href-document{margin:6px 0 4px} diff --git a/public/assets/media-drop.js b/public/assets/media-drop.js index 680dfcd..c18351b 100644 --- a/public/assets/media-drop.js +++ b/public/assets/media-drop.js @@ -1,5 +1,5 @@ /** - * Dragging images onto the upload field, and saying which files are about to go up. + * Dragging images and PDFs onto the upload field, and saying which files are about to go up. * * Everything here is an addition. Without this script the field is still a file input with a * label: clicking it opens the file picker and the form submits the same way. The script never @@ -9,19 +9,19 @@ const zone = document.querySelector('[data-dropzone]'); const input = zone?.querySelector('input[type="file"]'); const chosen = document.querySelector('[data-chosen]'); if (zone && input && chosen) { - const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp']; + const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']; const describe = (files, ignored = 0) => { if (!files.length) { chosen.textContent = ignored - ? `Keine Bilddatei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG und WebP.` + ? `Keine passende Datei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG, WebP und PDF.` : 'Noch keine Datei gewählt.'; return; } const names = [...files].map(file => file.name).join(', '); const size = [...files].reduce((total, file) => total + file.size, 0); chosen.textContent = `${files.length} Datei(en) gewählt (${(size / 1048576).toFixed(1)} MB): ${names}` - + (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG und WebP.` : ''); + + (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG, WebP und PDF.` : ''); }; input.addEventListener('change', () => describe(input.files)); diff --git a/public/assets/media.js b/public/assets/media.js index 430592b..1f7a02a 100644 --- a/public/assets/media.js +++ b/public/assets/media.js @@ -140,4 +140,70 @@ export function openMedia({ multiple = false, onChoose, needsAlt = true } = {}) api().then(data => { items = data.items; token = data.csrfToken; lock(false); render(); }).catch(err => { lock(false); error(err.message || 'Medien konnten nicht geladen werden. Bitte erneut öffnen.'); }); } +/** + * Ein PDF aus der Mediathek wählen - oder eines hochladen und gleich wählen. + * + * Ein eigener, schmaler Dialog statt einer Betriebsart des Bilderdialogs: ein PDF hat keine + * Vorschau, keinen Alternativtext und keine Mehrfachauswahl. Gewählt wird die Adresse, die dann + * im Linkziel steht. + */ +export function openDocuments({ onChoose } = {}) { + const opener = document.activeElement; + const dialog = element('dialog', '', 'media-dialog document-dialog'); + dialog.setAttribute('aria-labelledby', 'document-title'); + dialog.innerHTML = `

DOKUMENTE

PDF wählen

+

PDF bis 20 MB. Es wird unverändert abgelegt und ist über seine Adresse öffentlich erreichbar – nichts Vertrauliches hochladen. Ohne „In neuem Tab öffnen“ zeigt die Website es in einem Betrachter über der Seite.

+
+

Wird geladen …

+
+ `; + document.body.append(dialog); + const $ = selector => dialog.querySelector(selector); + let items = [], chosen = null, token; + const error = message => { $('#document-error').textContent = message; $('#document-error').hidden = !message; }; + const size = bytes => `${(bytes / 1048576).toLocaleString('de', { maximumFractionDigits: 1, minimumFractionDigits: 1 })} MB`; + const base = document.querySelector('meta[name="admin-base"]').content + '/api/dokumente'; + function render() { + const query = $('#document-search').value.toLocaleLowerCase('de'); + const visible = items.filter(item => !item.archived && item.title.toLocaleLowerCase('de').includes(query)); + $('#document-list').replaceChildren(); + for (const item of visible) { + const card = element('button', '', 'document-card'); card.type = 'button'; + card.setAttribute('aria-pressed', String(chosen?.id === item.id)); + card.append(element('span', 'PDF', 'document-badge'), element('strong', item.title), element('span', size(item.bytes))); + card.addEventListener('click', () => { chosen = item; render(); card.focus(); }); + card.addEventListener('dblclick', () => { chosen = item; $('#document-insert').click(); }); + $('#document-list').append(card); + } + if (!visible.length) $('#document-list').append(element('p', items.length ? 'Kein PDF mit diesem Titel.' : 'Noch keine PDFs. Lade oben eines hoch.', 'muted')); + $('#document-status').textContent = `${items.filter(item => !item.archived).length} PDFs${chosen ? ` · gewählt: ${chosen.title}` : ''}`; + $('#document-insert').disabled = !chosen; + } + $('#document-search').addEventListener('input', render); + $('#document-close').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => { dialog.remove(); if (opener?.isConnected) opener.focus(); }); + $('#document-upload').addEventListener('change', async () => { + const file = $('#document-upload').files[0]; if (!file) return; + error(''); + if (file.size > 20 * 1024 * 1024) { error(`${file.name}: größer als 20 MB`); $('#document-upload').value = ''; return; } + $('#document-status').textContent = `Wird hochgeladen: ${file.name}`; + try { + const form = new FormData(); form.append('file', file); + const response = await fetch(base + '/upload', { method: 'POST', headers: { 'X-CSRF-Token': token }, body: form }); + if (response.redirected) throw new Error('Sitzung abgelaufen. Bitte erneut anmelden.'); + const data = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status}).`); + items.unshift(data.item); chosen = data.item; + } catch (err) { error(`${file.name}: ${err.message || 'Verbindung unterbrochen'}`); } + $('#document-upload').value = ''; render(); + }); + $('#document-insert').addEventListener('click', () => { if (chosen) { onChoose?.(chosen); dialog.close(); } }); + dialog.showModal(); + fetch(base, { credentials: 'same-origin' }).then(async response => { + if (!response.ok || response.redirected) throw new Error('PDFs konnten nicht geladen werden. Bitte neu anmelden.'); + // Hochladen erst mit Token - sonst weist der Server eine zu früh gewählte Datei ab. + const data = await response.json(); items = data.items; token = data.csrfToken; $('#document-upload').disabled = false; render(); + }).catch(err => { $('#document-status').textContent = ''; error(err.message); }); +} + document.querySelector('#open-media-library')?.addEventListener('click', () => openMedia()); diff --git a/public/assets/site.css b/public/assets/site.css index 148936c..f53396a 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -557,6 +557,19 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran .zoom-close,.zoom-prev,.zoom-next{width:46px;height:46px} .zoom-prev{left:8px}.zoom-next{right:8px} } +/* Der PDF-Betrachter: fast bildschirmfüllend, mit einer schmalen Leiste darüber. Den Rest macht + der Betrachter des Browsers - Blättern, Zoom, Drucken. */ +.pdf-dialog{border:0;padding:0;width:min(96vw,1200px);height:94vh;max-width:none;max-height:none;background:#1b212c;color:#fff;border-radius:12px;overflow:hidden} +.pdf-dialog[open]{display:flex;flex-direction:column} +.pdf-dialog::backdrop{background:rgba(8,12,20,.88)} +.pdf-bar{display:flex;align-items:center;gap:10px;padding:10px 10px 10px 18px} +.pdf-title{flex:1;min-width:0;margin:0;font-size:15px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#fff} +.pdf-action{color:#fff;font-size:13px;text-decoration:none;padding:9px 14px;border-radius:999px;background:rgba(255,255,255,.12);white-space:nowrap} +.pdf-action:hover{background:rgba(255,255,255,.24)} +.pdf-close{display:grid;place-items:center;width:40px;height:40px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:24px;line-height:1;cursor:pointer} +.pdf-close:hover{background:rgba(255,255,255,.26)} +.pdf-action:focus-visible,.pdf-close:focus-visible{outline:3px solid #fff;outline-offset:2px} +.pdf-frame{flex:1;width:100%;border:0;background:#525659} .has-anchor{position:relative}.block-anchor{position:absolute;top:0;left:0;width:1px;height:1px;pointer-events:none;scroll-margin-top:110px}.nav-label{display:inline-flex;align-items:center;gap:.5em}.nav-label>.block-icon{width:1.1em;height:1.1em;color:inherit;flex:none} diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index e87ea28..d73ead1 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -360,6 +360,22 @@ try { const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling; return Boolean(previous?.classList.contains('button')); }, headingId); + // Ein PDF als Linkziel: im Dialog hochladen, übernehmen, die Adresse steht im Feld. + await page.getByRole('button', { name: 'PDF aus Mediathek wählen', exact: true }).click(); + const documentDialog = page.locator('dialog.document-dialog'); + await documentDialog.waitFor(); + await documentDialog.locator('#document-upload:enabled').waitFor({ state: 'attached' }); + await documentDialog.locator('#document-upload').setInputFiles({ name: 'Preisliste.pdf', mimeType: 'application/pdf', buffer: Buffer.from('%PDF-1.4\n%%EOF\n') }); + await documentDialog.locator('.document-card[aria-pressed="true"]').waitFor(); + assert.equal(await documentDialog.locator('#document-error').isHidden(), true, 'the PDF upload reports no error'); + const pdfAxe = await new AxeBuilder({ page }).include('dialog.document-dialog').withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze(); + assert.deepEqual(pdfAxe.violations.map(v => v.id), [], 'the PDF dialog has no accessibility violations'); + await documentDialog.getByRole('button', { name: 'PDF übernehmen', exact: true }).click(); + await documentDialog.waitFor({ state: 'detached' }); + assert.match(await page.locator('#property-href').inputValue(), /^\/media\/[a-f0-9]{32}\.pdf$/); + await page.waitForFunction(() => /^\/media\/[a-f0-9]{32}\.pdf$/.test(document.querySelector('#preview').contentDocument?.querySelector('a.button')?.getAttribute('href') ?? '')); + checks.push({ name: 'a PDF from the library as a link target', passed: true }); + await page.getByRole('button', { name: 'Rückgängig', exact: true }).click(); await page.getByRole('button', { name: 'Rückgängig', exact: true }).click(); await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons); checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true }); diff --git a/src/Content/DocumentLibrary.php b/src/Content/DocumentLibrary.php new file mode 100644 index 0000000..c343a4f --- /dev/null +++ b/src/Content/DocumentLibrary.php @@ -0,0 +1,122 @@ +.pdf - so gehen sie mit jeder Sicherung mit. + */ +final class DocumentLibrary +{ + /** Ein Prospekt oder eine Preisliste, kein Katalog mit tausend Fotos. */ + public const MAX_BYTES = 20 * 1024 * 1024; + + /** Die Adresse eines Dokuments. So steht sie auch in einem Link-Feld. */ + public const PATH = '#^/media/([a-f0-9]{32})\.pdf$#D'; + + public function __construct(private Connection $db, private string $directory) + { + } + + /** @return list> neueste zuerst */ + public function list(bool $withArchived = true): array + { + return array_map($this->present(...), $this->db->fetchAllAssociative( + 'SELECT * FROM document'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC', + )); + } + + public function find(string $id): ?array + { + if (!preg_match('/^[a-f0-9]{32}$/D', $id)) { + return null; + } + $row = $this->db->fetchAssociative('SELECT * FROM document WHERE id = ?', [$id]); + + return $row ? $this->present($row) : null; + } + + public function upload(UploadedFile $file): array + { + if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) { + throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 20 MB pro PDF; das PHP-Hosting kann ein kleineres Limit setzen.'); + } + // Ein PDF beginnt mit %PDF- - in den ersten 1024 Bytes, so lesen es auch die Betrachter. + // Die Endung oder der Typ, den der Browser mitschickt, sagen darueber nichts. + $head = (string) file_get_contents($file->getPathname(), false, null, 0, 1024); + if (!str_contains($head, '%PDF-')) { + throw new \InvalidArgumentException('Das ist kein PDF. Bitte eine PDF-Datei wählen.'); + } + if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) { + throw new \RuntimeException('Medienordner ist nicht beschreibbar.'); + } + $id = bin2hex(random_bytes(16)); + $path = $this->path($id); + if (!@copy($file->getPathname(), $path) || !is_file($path)) { + throw new \RuntimeException('Das Dokument konnte nicht gespeichert werden.'); + } + try { + $name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); + $title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Dokument', 0, 160, 'UTF-8'); + $row = ['id' => $id, 'title' => $title, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]; + $this->db->insert('document', $row); + } catch (\Throwable $error) { + unlink($path); + throw $error; + } + + return $this->present($row); + } + + public function update(string $id, array $data): array + { + if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) { + throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.'); + } + $count = $this->db->executeStatement('UPDATE document SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]); + if (1 !== $count) { + throw new StaleWrite(); + } + + return $this->find($id) ?? throw new StaleWrite(); + } + + /** Die Datei eines Dokuments, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */ + public function file(string $id): ?string + { + if (null === $this->find($id)) { + return null; + } + $path = $this->path($id); + + return is_file($path) ? $path : null; + } + + private function path(string $id): string + { + return $this->directory.'/'.$id.'.pdf'; + } + + private function present(array $row): array + { + return [ + 'id' => $row['id'], + 'title' => $row['title'], + 'bytes' => (int) $row['bytes'], + 'archived' => (bool) $row['archived'], + 'version' => (int) $row['version'], + 'created_at' => $row['created_at'], + 'src' => '/media/'.$row['id'].'.pdf', + ]; + } +} diff --git a/src/Controller/MediaController.php b/src/Controller/MediaController.php index 75d9908..091b7cd 100644 --- a/src/Controller/MediaController.php +++ b/src/Controller/MediaController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Content\DocumentLibrary; use App\Content\MediaLibrary; use App\Content\SiteSettings; use App\Domain\StaleWrite; @@ -10,12 +11,13 @@ use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\File\UploadedFile; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\ResponseHeaderBag; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; final class MediaController extends AbstractController { - public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings) + public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings, private DocumentLibrary $documents) { } @@ -27,7 +29,7 @@ final class MediaController extends AbstractController $archived = $request->query->getBoolean('archiv'); $query = mb_substr(trim($request->query->getString('q')), 0, 160); $library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query); - $view = ['library' => $library, 'archived' => $archived, 'query' => $query]; + $view = ['library' => $library, 'archived' => $archived, 'query' => $query, 'documents' => $this->documents->list($archived)]; if ($request->isXmlHttpRequest()) { return $this->json([ 'html' => $this->renderView('admin/media-results.html.twig', $view), @@ -53,16 +55,18 @@ final class MediaController extends AbstractController $done = 0; foreach ($files as $file) { try { - $this->media->upload($file); + // Bilder und PDFs gehen durch dasselbe Feld. Welche Art es ist, entscheidet der + // Inhalt der Datei; die Endung zaehlt nur, wenn der Inhalt nichts sagt. + $this->isPdf($file) ? $this->documents->upload($file) : $this->media->upload($file); ++$done; } catch (\InvalidArgumentException $error) { - return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Bild(er) davor wurden hochgeladen.', $done) : '')); + return $this->back($request, sprintf('%s (%s)%s', $error->getMessage(), $file->getClientOriginalName(), $done > 0 ? sprintf(' %d Datei(en) davor wurden hochgeladen.', $done) : '')); } catch (\RuntimeException) { - return $this->back($request, 'Das Bild konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.'); + return $this->back($request, sprintf('%s konnte nicht verarbeitet werden. GD/WebP und die Schreibrechte des Medienordners prüfen.', $file->getClientOriginalName())); } } - return $this->back($request, sprintf('%d Bild(er) hochgeladen.', $done), 'success'); + return $this->back($request, sprintf('%d Datei(en) hochgeladen.', $done), 'success'); } #[Route('/%kant.admin_path%/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])] @@ -91,6 +95,38 @@ final class MediaController extends AbstractController return $this->back($request, 'Die Bildangaben wurden gespeichert.', 'success'); } + #[Route('/%kant.admin_path%/media/dokument/{id}', name: 'admin_document_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])] + public function formUpdateDocument(Request $request, string $id): Response + { + $this->denyAccessUnlessGranted('ROLE_EDITOR'); + if (!$this->isCsrfTokenValid('media-form', $request->request->getString('_csrf_token'))) { + return $this->back($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.'); + } + if (!$this->documents->find($id)) { + throw $this->createNotFoundException(); + } + try { + $this->documents->update($id, [ + 'title' => $request->request->getString('title'), + 'archived' => $request->request->getBoolean('archived'), + 'version' => $request->request->getInt('version'), + ]); + } catch (StaleWrite) { + return $this->back($request, 'Das Dokument wurde inzwischen anderswo geändert. Lade die Seite neu und trage die Angaben erneut ein.'); + } catch (\InvalidArgumentException $error) { + return $this->back($request, $error->getMessage()); + } + + return $this->back($request, 'Die Angaben zum Dokument wurden gespeichert.', 'success'); + } + + private function isPdf(UploadedFile $file): bool + { + $mime = $file->getMimeType(); + + return 'application/pdf' === $mime || (!str_starts_with((string) $mime, 'image/') && 'pdf' === strtolower($file->getClientOriginalExtension())); + } + /** Back to the page the form was on, so a change does not jump to the first page. */ private function back(Request $request, string $message, string $type = 'error'): Response { @@ -112,6 +148,53 @@ final class MediaController extends AbstractController return $this->json(['items' => $this->media->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]); } + /** Die PDFs fuer die Auswahl im Link-Feld. Wenige genug, um sie auf einmal zu schicken. */ + #[Route('/%kant.admin_path%/api/dokumente', methods: ['GET'])] + public function listDocuments(): Response + { + $this->denyAccessUnlessGranted('ROLE_EDITOR'); + + return $this->json(['items' => $this->documents->list(), 'csrfToken' => $this->csrf->getToken('media')->getValue()]); + } + + #[Route('/%kant.admin_path%/api/dokumente/upload', methods: ['POST'])] + #[Route('/%kant.admin_path%/api/dokumente/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])] + public function writeDocument(Request $request, ?string $id = null): Response + { + $this->denyAccessUnlessGranted('ROLE_EDITOR'); + if (!$this->isCsrfTokenValid('media', $request->headers->get('X-CSRF-Token'))) { + return $this->json(['error' => 'Sitzung abgelaufen. Bitte erneut anmelden.'], 403); + } + try { + if (null === $id) { + $file = $request->files->get('file'); + if (!$file instanceof UploadedFile) { + throw new \InvalidArgumentException('Keine Datei empfangen. Bitte das PHP-Uploadlimit und die Dateigröße prüfen.'); + } + + return $this->json(['item' => $this->documents->upload($file)], 201); + } + if (!$this->documents->find($id)) { + throw $this->createNotFoundException(); + } + if (strlen($request->getContent()) > 4096) { + throw new \InvalidArgumentException('Angaben zum Dokument sind zu groß.'); + } + $data = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR); + if (!is_array($data)) { + throw new \InvalidArgumentException('Ungültige Angaben zum Dokument.'); + } + + return $this->json(['item' => $this->documents->update($id, $data)]); + } catch (StaleWrite) { + return $this->json(['error' => 'Das Dokument wurde inzwischen geändert. Die Auswahl bitte neu öffnen.'], 409); + } catch (\InvalidArgumentException|\JsonException $error) { + return $this->json(['error' => $error->getMessage()], 422); + } catch (\RuntimeException) { + return $this->json(['error' => 'Das Dokument konnte nicht gespeichert werden. Schreibrechte des Medienordners prüfen.'], 503); + } + } + #[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])] public function read(string $id): Response { @@ -240,4 +323,29 @@ final class MediaController extends AbstractController return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/webp', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=31536000, immutable', 'Content-Security-Policy' => "default-src 'none'"]); } + + /** + * Ein PDF, so wie es hochgeladen wurde. + * + * inline, damit der Browser es anzeigt statt es abzulegen - mit dem Titel als Dateinamen fuer + * den Fall, dass jemand es speichert. Einbetten darf es nur die eigene Website: der + * Betrachter zeigt es in einem Rahmen, eine fremde Seite soll das nicht koennen. + */ + #[Route('/media/{id}.pdf', name: 'document_serve', requirements: ['id' => '[a-f0-9]{32}'], methods: ['GET'])] + public function serveDocument(string $id): Response + { + $path = $this->documents->file($id) ?? throw $this->createNotFoundException(); + $name = trim(preg_replace('/[\\\\\/\x00-\x1F\x7F"]+/u', ' ', $this->documents->find($id)['title'])).'.pdf'; + $fallback = trim(preg_replace('/[^A-Za-z0-9 ._-]+/', '-', (string) iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $name)), '- '); + $response = new BinaryFileResponse($path, headers: [ + 'Content-Type' => 'application/pdf', + 'X-Content-Type-Options' => 'nosniff', + 'Cache-Control' => 'public, max-age=86400', + 'Content-Security-Policy' => "frame-ancestors 'self'", + 'X-Robots-Tag' => 'noindex', + ]); + $response->setContentDisposition(ResponseHeaderBag::DISPOSITION_INLINE, $name, str_ends_with($fallback, '.pdf') && '.pdf' !== $fallback ? $fallback : 'dokument.pdf'); + + return $response; + } } diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index d00e91c..cab416c 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -130,6 +130,10 @@ final class PageController extends AbstractController // Stylesheet und Skript eines Plugin-Bausteins kommen nur dorthin, wo er vorkommt. $pluginAssets = $this->plugins->assetsFor($documents); $needsZoom = (bool) array_filter($documents, fn (array $doc): bool => $this->hasZoom($doc)); + // Ein Link auf ein PDF der Mediathek - in einem Button, einem Bild oder im Fliesstext. + // Gesucht wird im gespeicherten Dokument als Text: so findet sich auch ein Link, den + // jemand in einen formatierten Absatz geschrieben hat. + $needsDocument = (bool) array_filter($documents, static fn (array $doc): bool => 1 === preg_match('#/media/[a-f0-9]{32}\.pdf#', json_encode($doc, JSON_UNESCAPED_SLASHES) ?: '')); // Only the header changes with the scroll position; a scrolled value anywhere in it asks // for the small script that marks the page as scrolled. // Den Nach-oben-Knopf blendet dasselbe Skript ein; ohne es bleibt er dauerhaft sichtbar. @@ -198,6 +202,9 @@ final class PageController extends AbstractController // Ein Bild, das sich vergroessern laesst, ist ohne Skript ein Verweis auf die // grosse Fassung. Das Skript kommt nur dazu, wo es etwas zu tun hat. 'needsZoom' => $needsZoom, + // Der PDF-Betrachter, nach demselben Muster und im selben Skript: ohne es ein + // gewoehnlicher Link. + 'needsDocument' => $needsDocument, // Own scripts arrive as one file per page, never inline, so the policy can stay // hash based and no page without a script loses its strict setting. 'pageScript' => [] === $this->code->scriptsOf($page['document']) ? null : '/assets/seite/'.$page['id'].'.js', @@ -228,7 +235,7 @@ final class PageController extends AbstractController // Die Pruefung braucht ein Skript von Cloudflare und einen Rahmen dafuer. Beides wird nur // dort erlaubt, wo tatsaechlich ein Formular steht - nicht auf jeder Seite der Website. $mitTurnstile = $this->turnstile->isActive() && (bool) array_filter($documents, fn (array $doc): bool => $this->hasType($doc, 'form')); - $wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || [] !== $this->code->scriptsOf($page['document'])); + $wantsScript = ($mitTurnstile || $needsTabs || $needsFormScript || [] !== $pluginAssets['scripts'] || [] !== $areaScripts || $needsSlider || $needsMotion || $needsScrolled || $needsZoom || $needsDocument || [] !== $this->code->scriptsOf($page['document'])); $frames = $this->frameHosts($request, $page['document'], ...array_values($areas), ...array_values($sections)); if ($mitTurnstile) { $frames[] = 'challenges.cloudflare.com'; @@ -238,7 +245,7 @@ final class PageController extends AbstractController $response->headers->set('X-Robots-Tag', 'noindex, follow'); } - return $this->shell($response, $wantsScript, $styles, $frames); + return $this->shell($response, $wantsScript, $styles, $frames, $needsDocument); } /** @@ -252,8 +259,9 @@ final class PageController extends AbstractController * @param list $frameHosts Hosts of embeds that are actually rendered on this response. * Nothing is added to the policy for an embed still waiting * for consent. + * @param bool $frameSelf whether the page may frame its own PDFs (the viewer) */ - private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = []): Response + private function shell(Response $response, bool $allowScript = false, string $styles = '', array $frameHosts = [], bool $frameSelf = false): Response { $styleSources = ["'self'"]; if ('' !== $styles) { @@ -280,7 +288,9 @@ final class PageController extends AbstractController implode(' ', $scriptSources), implode(' ', $styleSources), implode(' ', $fontSources), - [] === $frameHosts ? "'none'" : implode(' ', array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)), + // Der PDF-Betrachter zeigt eine Datei dieser Website in einem Rahmen - nur dort, wo + // eine Seite auf ein PDF verlinkt, und nur von hier. + [] === $frameHosts && !$frameSelf ? "'none'" : implode(' ', [...($frameSelf ? ["'self'"] : []), ...array_map(static fn (string $host): string => 'https://'.$host, $frameHosts)]), )); return $response; diff --git a/templates/admin/media.html.twig b/templates/admin/media.html.twig index ff0dc82..2868a8b 100644 --- a/templates/admin/media.html.twig +++ b/templates/admin/media.html.twig @@ -3,7 +3,7 @@ {% block title %}Mediathek{% endblock %} {% block scripts %}{% endblock %} {% block heading %}Mediathek{% endblock %} -{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %} +{% block lead %}Alle Bilder und PDFs, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus. PDFs bleiben, wie sie sind, und lassen sich in jedem Linkziel wählen.{% endblock %} {% block content %} {% if is_granted('ROLE_EDITOR') %} @@ -19,13 +19,13 @@
- +

Noch keine Datei gewählt.

-

JPEG, PNG oder WebP, höchstens 8 MB je Bild. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen.

+

JPEG, PNG oder WebP bis 8 MB je Bild, PDF bis 20 MB. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen. Hochgeladene Dateien sind über ihre Adresse öffentlich – nichts Vertrauliches hochladen.

{% endif %} @@ -38,4 +38,51 @@ {% include 'admin/search-feedback.html.twig' %}
{% include 'admin/media-results.html.twig' %}
+ +{# Die PDFs stehen unter den Bildern und ohne Suche: es sind wenige, und ein Titel reicht, um + eines wiederzufinden. Der Schalter für das Archiv oben gilt auch hier. #} +
+
+

Dokumente {{ documents|length }}

+
+ {% if documents is empty %} +
+

Keine PDFs in der Mediathek.

+

{{ is_granted('ROLE_EDITOR') ? 'Lade oben ein PDF hoch. Danach lässt es sich in jedem Linkziel wählen – etwa für einen Button „Preisliste“.' : 'Ein Redakteur kann PDFs hochladen.' }}

+
+ {% else %} +
+ {% for item in documents %} +
+
+ +
+

{{ item.title }}

+

{{ (item.bytes / 1048576)|number_format(1, ',') }} MB · {{ item.src }}{% if item.archived %} · archiviert{% endif %}

+
+
+ {% if is_granted('ROLE_EDITOR') %} +
+ Angaben +
+
+ + + + + + +

Wird zum Dateinamen, wenn jemand das PDF speichert, und steht über dem Betrachter.

+ +

Archivierte PDFs erscheinen nicht mehr in der Auswahl, bleiben aber erreichbar – ein Link darauf geht nicht ins Leere.

+ +
+
+
+ {% endif %} +
+ {% endfor %} +
+ {% endif %} +
{% endblock %} diff --git a/templates/page.html.twig b/templates/page.html.twig index 196cd41..de8e02d 100644 --- a/templates/page.html.twig +++ b/templates/page.html.twig @@ -62,7 +62,7 @@ {% if needsSlider|default(false) %}{% endif %} {% if needsMotion|default(false) %}{% endif %} {% if needsScrolled|default(false) %}{% endif %} - {% if needsZoom|default(false) %}{% endif %} + {% if needsZoom|default(false) or needsDocument|default(false) %}{% endif %} {% if not editing|default(false) %}{% for src in pluginScripts|default([]) %}{% endfor %}{% endif %} {# Own scripts of this page, as a file. Never rendered in the editor preview: the preview must show the layout, not run code that could change the page under the editor's hands. #} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 85fa571..d523000 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -5,6 +5,7 @@ namespace App\Tests; use App\Content\Consent; use App\Content\CustomCode; use App\Content\DemoContent; +use App\Content\DocumentLibrary; use App\Content\DocumentValidator; use App\Content\FontLibrary; use App\Content\FormRepository; @@ -1153,6 +1154,61 @@ final class CmsTest extends TestCase self::assertStringContainsString("script-src 'none'", (string) $zweite->headers->get('Content-Security-Policy')); } + public function testAPdfFromTheLibraryIsALinkTargetAndOpensInTheViewer(): void + { + // Umut: „PDFs hochladen und als Button verlinken - und ein Inline-PDF-Viewer wie das + // Bild-Popup." Das PDF bleibt, wie es kam; geprüft wird, dass es eines ist. + $documents = new DocumentLibrary($this->db, $this->path.'-media'); + $file = tempnam(sys_get_temp_dir(), 'kant-pdf-'); + try { + file_put_contents($file, ''); + try { + $documents->upload(new UploadedFile($file, 'preisliste.pdf', test: true)); + self::fail('Eine Datei, die kein PDF ist, darf nicht als PDF durchgehen.'); + } catch (\InvalidArgumentException) { + } + self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM document')); + + file_put_contents($file, "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n"); + $item = $documents->upload(new UploadedFile($file, 'Preisliste 2026.pdf', test: true)); + } finally { + @unlink($file); + } + self::assertSame('Preisliste 2026', $item['title']); + self::assertSame('/media/'.$item['id'].'.pdf', $item['src']); + + // Ausgeliefert als PDF, zum Anzeigen, mit dem Titel als Dateinamen, und einbetten darf es + // nur die eigene Website. + $antwort = $this->request($item['src']); + self::assertSame(200, $antwort->getStatusCode()); + self::assertSame('application/pdf', $antwort->headers->get('Content-Type')); + self::assertSame('nosniff', $antwort->headers->get('X-Content-Type-Options')); + self::assertSame("frame-ancestors 'self'", $antwort->headers->get('Content-Security-Policy')); + self::assertStringStartsWith('inline;', (string) $antwort->headers->get('Content-Disposition')); + self::assertStringContainsString('Preisliste 2026.pdf', (string) $antwort->headers->get('Content-Disposition')); + self::assertSame(404, $this->request('/media/'.str_repeat('a', 32).'.pdf')->getStatusCode()); + + // Ein Button zeigt auf das PDF - ein gewöhnlicher lokaler Pfad, ohne neues Schema. + $document = DemoContent::starter('Startseite'); + $document['blocks'][] = ['id' => 'preise', 'type' => 'button', 'props' => ['text' => 'Preisliste', 'href' => $item['src']]]; + $this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1); + $seite = $this->request('/'); + $html = $seite->getContent(); + self::assertStringContainsString('href="'.$item['src'].'"', $html); + self::assertStringContainsString('