*/ 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>, 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 $ids * * @return 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; } }