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>
352 lines
17 KiB
PHP
352 lines
17 KiB
PHP
<?php
|
|
|
|
namespace App\Controller;
|
|
|
|
use App\Content\DocumentLibrary;
|
|
use App\Content\MediaLibrary;
|
|
use App\Content\SiteSettings;
|
|
use App\Domain\StaleWrite;
|
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
|
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, private DocumentLibrary $documents)
|
|
{
|
|
}
|
|
|
|
#[Route('/%kant.admin_path%/media', name: 'admin_media', methods: ['GET'])]
|
|
public function index(Request $request): Response
|
|
{
|
|
// The screen renders the images itself, page by page. The dialog stays what it is: the way
|
|
// to pick an image while editing, not the way to see what the library holds.
|
|
$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, 'documents' => $this->documents->list($archived)];
|
|
if ($request->isXmlHttpRequest()) {
|
|
return $this->json([
|
|
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
|
'total' => $library['total'],
|
|
'parameters' => ['q' => $query, 'archiv' => $archived ? '1' : '0', 'seite' => $library['page']],
|
|
]);
|
|
}
|
|
|
|
return $this->render('admin/media.html.twig', $view);
|
|
}
|
|
|
|
#[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])]
|
|
public function formUpload(Request $request): 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.');
|
|
}
|
|
$files = array_filter((array) $request->files->get('files'), static fn ($file): bool => $file instanceof UploadedFile);
|
|
if ([] === $files) {
|
|
return $this->back($request, 'Keine Datei empfangen. Bitte die Dateigröße und das PHP-Uploadlimit prüfen.');
|
|
}
|
|
$done = 0;
|
|
foreach ($files as $file) {
|
|
try {
|
|
// 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 Datei(en) davor wurden hochgeladen.', $done) : ''));
|
|
} catch (\RuntimeException) {
|
|
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 Datei(en) hochgeladen.', $done), 'success');
|
|
}
|
|
|
|
#[Route('/%kant.admin_path%/media/{id}', name: 'admin_media_save', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
|
public function formUpdate(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->media->find($id)) {
|
|
throw $this->createNotFoundException();
|
|
}
|
|
try {
|
|
$this->media->update($id, [
|
|
'title' => $request->request->getString('title'),
|
|
'alt' => $request->request->getString('alt'),
|
|
'archived' => $request->request->getBoolean('archived'),
|
|
'version' => $request->request->getInt('version'),
|
|
]);
|
|
} catch (StaleWrite) {
|
|
return $this->back($request, 'Das Bild 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 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
|
|
{
|
|
$this->addFlash($type, $message);
|
|
$query = array_filter([
|
|
'seite' => $request->request->getInt('seite', 1) > 1 ? $request->request->getInt('seite') : null,
|
|
'archiv' => $request->request->getBoolean('archiv') ? '1' : null,
|
|
'q' => mb_substr(trim($request->request->getString('q')), 0, 160),
|
|
], static fn ($value): bool => null !== $value && '' !== $value);
|
|
|
|
return $this->redirectToRoute('admin_media', $query);
|
|
}
|
|
|
|
#[Route('/%kant.admin_path%/api/media', methods: ['GET'])]
|
|
public function list(): Response
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
|
|
|
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
|
|
{
|
|
$this->denyAccessUnlessGranted('ROLE_EDITOR');
|
|
$item = $this->media->find($id);
|
|
|
|
return null === $item ? $this->json(['error' => 'Bild nicht gefunden.'], 404) : $this->json(['item' => $item]);
|
|
}
|
|
|
|
#[Route('/%kant.admin_path%/api/media/upload', methods: ['POST'])]
|
|
#[Route('/%kant.admin_path%/api/media/{id}', requirements: ['id' => '[a-f0-9]{32}'], methods: ['POST'])]
|
|
public function write(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->media->upload($file)], 201);
|
|
}
|
|
if (!$this->media->find($id)) {
|
|
throw $this->createNotFoundException();
|
|
}
|
|
if (strlen($request->getContent()) > 4096) {
|
|
throw new \InvalidArgumentException('Medienangaben sind zu groß.');
|
|
}
|
|
$data = json_decode($request->getContent(), true, 16, JSON_THROW_ON_ERROR);
|
|
if (!is_array($data)) {
|
|
throw new \InvalidArgumentException('Ungültige Medienangaben.');
|
|
}
|
|
|
|
return $this->json(['item' => $this->media->update($id, $data)]);
|
|
} catch (StaleWrite) {
|
|
return $this->json(['error' => 'Das Bild wurde inzwischen geändert. Angaben kopieren und die Mediathek neu öffnen.'], 409);
|
|
} catch (\InvalidArgumentException|\JsonException $error) {
|
|
return $this->json(['error' => $error->getMessage()], 422);
|
|
} catch (\RuntimeException $error) {
|
|
return $this->json(['error' => 'Das Bild konnte nicht gespeichert werden. GD/WebP und Schreibrechte des Medienordners prüfen.'], 503);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Das Symbol der Website, in den Größen, die Browser und Suchmaschinen abfragen.
|
|
*
|
|
* Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten
|
|
* Zeichen unter /favicon.svg. /favicon.ico fragen Crawler auf gut Glück ab; es bekommt das
|
|
* 48er-PNG, das liest jeder Browser auch unter diesem Namen.
|
|
*/
|
|
#[Route('/favicon.png', name: 'favicon_png', defaults: ['size' => 32], methods: ['GET'])]
|
|
#[Route('/favicon-192.png', name: 'favicon_192', defaults: ['size' => 192], methods: ['GET'])]
|
|
#[Route('/favicon.ico', name: 'favicon_ico', defaults: ['size' => 48], methods: ['GET'])]
|
|
#[Route('/apple-touch-icon.png', name: 'favicon_touch', defaults: ['size' => 180], methods: ['GET'])]
|
|
public function favicon(int $size): Response
|
|
{
|
|
$path = $this->faviconFile($size);
|
|
if (null === $path) {
|
|
if (48 === $size) {
|
|
return $this->redirect('/favicon.svg');
|
|
}
|
|
throw $this->createNotFoundException();
|
|
}
|
|
|
|
return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/png', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=604800', 'Content-Security-Policy' => "default-src 'none'"]);
|
|
}
|
|
|
|
/**
|
|
* Das mitgelieferte Zeichen - aber nur, solange kein eigenes Symbol gesetzt ist.
|
|
*
|
|
* Früher lag es als Datei in public/ und kam damit immer, auch nach dem Wechsel. Google hatte
|
|
* es beim ersten Besuch gefunden und zeigte es weiter. Jetzt führt die Adresse zum eigenen
|
|
* Symbol, vorübergehend (302), damit ein Zurückwechseln im Browser wieder ankommt.
|
|
*/
|
|
#[Route('/favicon.svg', name: 'favicon_svg', methods: ['GET'])]
|
|
public function faviconSvg(): Response
|
|
{
|
|
if (null !== $this->faviconFile(192)) {
|
|
$response = $this->redirect('/favicon-192.png?v='.$this->siteSettings->load()['faviconVersion'], 302);
|
|
$response->headers->set('Cache-Control', 'public, max-age=3600');
|
|
|
|
return $response;
|
|
}
|
|
|
|
return new BinaryFileResponse($this->getParameter('kernel.project_dir').'/public/assets/favicon.svg', headers: ['Content-Type' => 'image/svg+xml', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=3600']);
|
|
}
|
|
|
|
/**
|
|
* Eine Symboldatei, oder null ohne eigenes Symbol.
|
|
*
|
|
* Die Größen 48 und 192 kamen später dazu. Wer sein Symbol vorher gesetzt hat, hat nur 32
|
|
* und 180 - die fehlenden entstehen beim ersten Abruf aus derselben Einstellung, statt dass
|
|
* erst jemand die Einstellungen neu speichern muss.
|
|
*/
|
|
private function faviconFile(int $size): ?string
|
|
{
|
|
// Zuerst die Einstellung: wird das Symbol entfernt, bleiben die Dateien liegen, gelten
|
|
// aber nicht mehr.
|
|
$settings = $this->siteSettings->load();
|
|
$favicon = (string) ($settings['faviconMediaId'] ?? '');
|
|
if ('' === $favicon) {
|
|
return null;
|
|
}
|
|
$path = $this->media->faviconFile($size);
|
|
if (null !== $path || !in_array($size, MediaLibrary::FAVICON_SIZES, true) || null === $this->media->find($favicon)) {
|
|
return $path;
|
|
}
|
|
$crop = (string) ($settings['faviconCrop'] ?? '');
|
|
try {
|
|
$this->media->renderFavicon($favicon, '' === $crop ? null : array_map('floatval', explode(',', $crop)));
|
|
} catch (\RuntimeException) {
|
|
return null;
|
|
}
|
|
|
|
return $this->media->faviconFile($size);
|
|
}
|
|
|
|
#[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])]
|
|
public function serve(string $id, int $width): Response
|
|
{
|
|
$path = $this->media->file($id, $width) ?? throw $this->createNotFoundException();
|
|
|
|
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;
|
|
}
|
|
}
|