- 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>
320 lines
14 KiB
PHP
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;
|
|
}
|
|
}
|