Files
KantSites/src/Content/MediaLibrary.php
T
kantarciandClaude Opus 5 45d1141951 Show the own site icon to Google under every address, and the KANTARCI landing page
- Google kept showing the KantSites K for Elektro Bernhard after the site
  icon was changed. The own icon only existed at 32 and 180 px, and Google
  takes multiples of 48 only; /favicon.svg was a file in public/, so the web
  server always delivered the bundled K.
- The icon is now also cut at 48 and 192 px. The head links /favicon-192.png,
  /favicon.ico serves the 48 px PNG, and /favicon.svg is answered by the
  application: the bundled mark without an own icon (now in
  public/assets/favicon.svg), otherwise a 302 to the 192 px PNG. Sites that
  set their icon earlier get the new sizes on first request. A removed icon
  no longer counts even though its files remain.
- The archive update only adds files to public/, so it now deletes the old
  public/favicon.svg explicitly.
- Seventh local reference site: the KANTARCI Immobilien landing page with a
  three-step rental request, own database, seed and reset scripts.

Tests: 582 PHPUnit on PHP 8.2 and 8.4, 35 node, test:editor and
test:browser green (test:browser reports only the known editor colour
contrast on /grundlagen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 07:37:04 +02:00

320 lines
14 KiB
PHP

<?php
namespace App\Content;
use App\Domain\StaleWrite;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\File\UploadedFile;
final class MediaLibrary
{
/** Enough to fill a screen, few enough that the browser does not decode a hundred images. */
public const PER_PAGE = 24;
/** The widths every upload is rendered to, in WebP. A source narrower than a step is skipped. */
public const WIDTHS = [480, 960, 1600, 2400];
/**
* Die Bilder, die in dieser Anfrage schon gesucht wurden.
*
* @var array<string, ?array>
*/
private array $gemerkt = [];
public function __construct(private Connection $db, private string $directory)
{
}
public function list(): array
{
return array_map($this->present(...), $this->db->fetchAllAssociative('SELECT * FROM media ORDER BY created_at DESC, id DESC'));
}
/**
* One page of the library, newest first.
*
* The media screen renders the images itself instead of fetching them, so it has to ask for a
* slice: a library of a few hundred images would otherwise be one very long page and a lot of
* thumbnails at once.
*
* @return array{items: list<array<string, mixed>>, total: int, page: int, pages: int, perPage: int}
*/
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE, string $query = ''): array
{
$qb = $this->db->createQueryBuilder()->from('media');
if (!$withArchived) {
$qb->andWhere('archived = 0');
}
if ('' !== trim($query)) {
$qb->andWhere('(LOWER(title) LIKE :query ESCAPE \'!\' OR LOWER(alt) LIKE :query ESCAPE \'!\')')
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
}
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
$perPage = max(1, min(96, $perPage));
$pages = max(1, (int) ceil($total / $perPage));
$page = max(1, min($pages, $page));
$items = $qb->select('*')->orderBy('created_at', 'DESC')->addOrderBy('id', 'DESC')
->setMaxResults($perPage)->setFirstResult(($page - 1) * $perPage)->executeQuery()->fetchAllAssociative();
return [
'items' => array_map($this->present(...), $items),
'total' => $total,
'page' => $page,
'pages' => $pages,
'perPage' => $perPage,
];
}
/**
* Ein Bild, gemerkt fuer die Dauer dieser Anfrage.
*
* Dasselbe Bild wird waehrend einer Seitenausgabe mehrfach gesucht: Ein Hintergrundbild
* bekommt je Geraet eine Regel, also dreimal dieselbe Abfrage, und der Kopf fragt dasselbe
* Bild noch einmal, um es vorab zu holen. Gemerkt wird nur innerhalb einer Anfrage - der
* Dienst lebt nicht laenger, also kann der Merkzettel nicht veralten.
*/
public function find(string $id): ?array
{
if (array_key_exists($id, $this->gemerkt)) {
return $this->gemerkt[$id];
}
$row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]);
return $this->gemerkt[$id] = $row ? $this->present($row) : null;
}
/**
* Mehrere Bilder auf einmal, nach Kennung sortiert.
*
* Eine Liste mit zwölf Beiträgen soll nicht zwölfmal nach je einem Bild fragen. Fehlende
* Kennungen fehlen auch im Ergebnis - der Aufrufer prüft ohnehin auf null.
*
* @param list<string> $ids
*
* @return array<string, array>
*/
public function findMany(array $ids): array
{
$ids = array_values(array_unique(array_filter($ids)));
if ([] === $ids) {
return [];
}
$found = [];
foreach ($this->db->fetchAllAssociative(
'SELECT * FROM media WHERE id IN ('.implode(',', array_fill(0, count($ids), '?')).')',
$ids,
) as $row) {
$found[$row['id']] = $this->present($row);
}
return $found;
}
private function present(array $row): array
{
$row['variants'] = json_decode($row['variants'], true, 512, JSON_THROW_ON_ERROR);
$row['version'] = (int) $row['version'];
$row['archived'] = (bool) $row['archived'];
return $row;
}
public function upload(UploadedFile $file): array
{
if (!$file->isValid() || $file->getSize() > 8 * 1024 * 1024) {
throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 8 MB pro Bild; das PHP-Hosting kann ein kleineres Limit setzen.');
}
if (!function_exists('imagewebp')) {
throw new \RuntimeException('Für Bildverarbeitung wird PHP GD mit WebP benötigt.');
}
$info = @getimagesize($file->getPathname());
if (!$info || !in_array($info['mime'], ['image/jpeg', 'image/png', 'image/webp'], true) || $info[0] * $info[1] > 12000000 || max($info[0], $info[1]) > 8000) {
throw new \InvalidArgumentException('Bitte ein JPEG-, PNG- oder WebP-Bild mit maximal 12 Megapixeln und 8000 px Kantenlänge wählen.');
}
$memoryLimit = ini_parse_quantity(ini_get('memory_limit'));
if ($memoryLimit > 0 && memory_get_usage(true) + $info[0] * $info[1] * 12 + 16777216 > $memoryLimit) {
throw new \InvalidArgumentException('Dieses Bild benötigt mehr Arbeitsspeicher als das Hosting bereitstellt. Bitte vor dem Upload verkleinern.');
}
$source = @imagecreatefromstring(file_get_contents($file->getPathname()));
if (!$source) {
throw new \InvalidArgumentException('Die Bilddatei konnte nicht gelesen werden.');
}
$paths = [];
try {
if ('image/jpeg' === $info['mime'] && function_exists('exif_read_data')) {
$orientation = (int) (@exif_read_data($file->getPathname())['Orientation'] ?? 1);
if (in_array($orientation, [2, 4, 5, 7], true)) {
imageflip($source, IMG_FLIP_HORIZONTAL);
}
$angle = match ($orientation) {
3, 4 => 180, 6, 7 => -90, 5, 8 => 90, default => 0,
};
if ($angle) {
$rotated = imagerotate($source, $angle, 0);
imagedestroy($source);
$source = $rotated;
}
}
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));
$variants = [];
$width = imagesx($source);
$height = imagesy($source);
// Four widths, and never larger than the original: 480 for a phone, 960 for a tablet
// or a column, 1600 for a full width section, 2400 for a full bleed image on a large
// display. A smaller source simply produces fewer variants.
foreach (array_unique(array_map(static fn (int $step): int => min($step, $width), self::WIDTHS)) as $targetWidth) {
$targetHeight = max(1, (int) round($height * $targetWidth / $width));
$target = imagecreatetruecolor($targetWidth, $targetHeight);
try {
imagealphablending($target, false);
imagesavealpha($target, true);
imagecopyresampled($target, $source, 0, 0, 0, 0, $targetWidth, $targetHeight, $width, $height);
$path = $this->directory.'/'.$id.'-'.$targetWidth.'.webp';
$paths[] = $path;
if (!imagewebp($target, $path, 82) || !is_file($path) || !filesize($path)) {
throw new \RuntimeException('Bildvariante konnte nicht gespeichert werden.');
}
$variants[] = ['src' => '/media/'.$id.'/'.$targetWidth, 'width' => $targetWidth, 'height' => $targetHeight, 'bytes' => filesize($path)];
} finally {
imagedestroy($target);
}
}
$name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME);
$title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Bild', 0, 160, 'UTF-8');
$row = ['id' => $id, 'title' => $title, 'alt' => '', 'variants' => json_encode($variants, JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')];
$this->db->insert('media', $row);
$this->gemerkt = [];
return $this->present($row);
} catch (\Throwable $error) {
foreach ($paths as $path) {
if (is_file($path)) {
unlink($path);
}
}
throw $error;
} finally {
imagedestroy($source);
}
}
public function update(string $id, array $data): array
{
if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_string($data['alt'] ?? null) || strlen($data['alt']) > 300 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) {
throw new \InvalidArgumentException('Titel (max. 160 Bytes), Alternativtext (max. 300 Bytes) und Version prüfen.');
}
$count = $this->db->executeStatement('UPDATE media SET title = ?, alt = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], $data['alt'], (int) $data['archived'], $id, $data['version']]);
unset($this->gemerkt[$id]);
if (1 !== $count) {
throw new StaleWrite();
}
return [...$data, 'id' => $id, 'version' => $data['version'] + 1];
}
/**
* Die Größen, die ein Favicon braucht: 32 für den Reiter im Browser, 180 für den
* Startbildschirm, 48 und 192 für die Google-Suche - Google nimmt nur Vielfache von 48 und
* behielt sonst das zuerst gefundene Symbol, bei Elektro Bernhard das mitgelieferte K.
*/
public const FAVICON_SIZES = [32, 48, 180, 192];
/**
* Erzeugt aus einem Bild der Mediathek die Symboldateien der Website.
*
* Ein Favicon ist quadratisch, winzig und wird von den Browsern bis heute am zuverlässigsten
* als PNG gelesen - deshalb hier PNG statt der WebP-Varianten und ein quadratischer
* Ausschnitt statt eines gestauchten Bildes. Erzeugt wird einmal beim Speichern der
* Einstellung, nicht bei jedem Aufruf.
*
* $crop ist der im Zuschnitt gewählte Ausschnitt als [links, oben, Kante]: links und oben als
* Anteil von Breite und Höhe, die Kante als Anteil der kürzeren Seite. Ohne Angabe die Mitte.
*
* @param array{0: float, 1: float, 2: float}|null $crop
*/
public function renderFavicon(string $mediaId, ?array $crop = null): void
{
// Ueber die Varianten, die dieses Bild tatsaechlich hat, nicht ueber die vier Regelbreiten:
// ein 192 px breites Zeichen hat genau eine Variante mit 192 - und die Suche nach 480
// ging ins Leere. Genau so ein quadratisches Zeichen ist der haeufigste Fall.
$item = $this->find($mediaId);
$source = null;
foreach (array_reverse($item['variants'] ?? []) as $variant) {
$path = $this->file($mediaId, $variant['width']);
if (null !== $path) {
$source = @imagecreatefromwebp($path);
break;
}
}
if (!$source) {
throw new \RuntimeException('Das Bild für das Symbol konnte nicht gelesen werden.');
}
try {
// Quadratischer Ausschnitt: ein breites Logo würde sonst zur Briefmarke. Die Anteile
// gelten für jede Variante gleich; gerechnet wird in der größten, die es gibt.
$breite = imagesx($source);
$hoehe = imagesy($source);
$kante = min($breite, $hoehe);
$links = (int) (($breite - $kante) / 2);
$oben = (int) (($hoehe - $kante) / 2);
if (null !== $crop) {
$kante = max(1, min($kante, (int) round($crop[2] * min($breite, $hoehe))));
$links = max(0, min($breite - $kante, (int) round($crop[0] * $breite)));
$oben = max(0, min($hoehe - $kante, (int) round($crop[1] * $hoehe)));
}
foreach (self::FAVICON_SIZES as $size) {
$target = imagecreatetruecolor($size, $size);
try {
imagealphablending($target, false);
imagesavealpha($target, true);
imagecopyresampled($target, $source, 0, 0, $links, $oben, $size, $size, $kante, $kante);
if (!imagepng($target, $this->faviconPath($size), 9)) {
throw new \RuntimeException('Das Symbol konnte nicht gespeichert werden.');
}
} finally {
imagedestroy($target);
}
}
} finally {
imagedestroy($source);
}
}
/** Der Pfad einer Symboldatei, oder null wenn sie (noch) nicht erzeugt wurde. */
public function faviconFile(int $size): ?string
{
if (!in_array($size, self::FAVICON_SIZES, true)) {
return null;
}
$path = $this->faviconPath($size);
return is_file($path) ? $path : null;
}
private function faviconPath(int $size): string
{
if (!is_dir($this->directory) && !mkdir($this->directory, 0775, true) && !is_dir($this->directory)) {
throw new \RuntimeException('Medienordner ist nicht beschreibbar.');
}
return $this->directory.'/favicon-'.$size.'.png';
}
public function file(string $id, int $width): ?string
{
if (!preg_match('/^[a-f0-9]{32}$/D', $id)) {
return null;
}
$media = $this->find($id);
if (!$media || !in_array($width, array_column($media['variants'], 'width'), true)) {
return null;
}
$path = $this->directory.'/'.$id.'-'.$width.'.webp';
return is_file($path) ? $path : null;
}
}