Upload PDFs to the library, link them from any link target, show them in a viewer

PDFs go through the same upload field as images, are checked by their content and kept
unchanged in their own table. Every link field can pick one, and a page that links a PDF
opens it in a dialog with the browser's own viewer; touch devices and browsers without a
viewer get a new tab instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-22 07:56:29 +02:00
1 parent 04927b9563
commit e8a9dcdf5c
20 files changed
+667 -30

No files matched your search

+122
View File
@@ -0,0 +1,122 @@
<?php
namespace App\Content;
use App\Domain\StaleWrite;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\File\UploadedFile;
/**
* PDFs neben den Bildern der Mediathek.
*
* Ein Bild wird beim Hochladen neu berechnet und nur in dieser Fassung ausgeliefert - was auch
* immer in der Datei steckte, ist danach weg. Bei einem PDF geht das nicht: es wird so
* ausgeliefert, wie es kam. Deshalb die Pruefung am Anfang der Datei statt an der Endung, und
* beim Ausliefern Kopfzeilen, die den Browser nichts anderes darin sehen lassen als ein PDF.
*
* Die Dateien liegen im Medienordner, als <id>.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<array<string, mixed>> 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',
];
}
}
+114 -6
View File
@@ -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;
}
}
+14 -4
View File
@@ -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<string> $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;