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>
168 lines
8.0 KiB
PHP
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'"]);
|
|
}
|
|
}
|