Files
KantSites/src/Controller/MediaController.php
T
kantarciandClaude Opus 5 aaea385422 Die Verwaltung kann umziehen: /admin ist frei waehlbar
php bin/console cms:admin:path <neu> legt die Verwaltung auf eine
andere Adresse. Geprueft wird, dass der Name frei ist und keine Seite
verdeckt; der Wert steht in .env.local und fuellt einen Parameter, der
als Praefix in jeder Verwaltungsroute steht, in der Firewall, in den
Schutzkopfzeilen und in der Statistik.

Die robots.txt nennt einen geaenderten Namen nicht - sie ist oeffentlich
und waere sonst ein Wegweiser. Beim Standardnamen bleibt die Zeile,
weil diese Adresse ohnehin jeder kennt.

Die Auffangroute fuer Seiten steht jetzt zuletzt: sie kann einen frei
gewaehlten Namen nicht per Regex ausschliessen, und die Reihenfolge ist
ohnehin die richtige. Der Befehl raeumt den Zwischenspeicher auf, aber
nicht den, in dem er selbst laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 20:51:55 +02:00

168 lines
8.0 KiB
PHP

<?php
namespace App\Controller;
use App\Content\MediaLibrary;
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\Routing\Attribute\Route;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
final class MediaController extends AbstractController
{
public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf)
{
}
#[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');
return $this->render('admin/media.html.twig', [
'library' => $this->media->page($request->query->getInt('seite', 1), $archived),
'archived' => $archived,
]);
}
#[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 {
$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) : ''));
} 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('%d Bild(er) 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');
}
/** 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,
]);
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()]);
}
#[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 beiden Größen, die Browser abfragen.
*
* Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten
* favicon.svg, auf das die Seiten weiterhin verweisen.
*/
#[Route('/favicon.png', name: 'favicon_png', methods: ['GET'])]
#[Route('/apple-touch-icon.png', name: 'favicon_touch', methods: ['GET'])]
public function favicon(Request $request): Response
{
$size = 'favicon_touch' === $request->attributes->get('_route') ? 180 : 32;
$path = $this->media->faviconFile($size) ?? 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'"]);
}
#[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'"]);
}
}