present(...), $this->db->fetchAllAssociative('SELECT * FROM media ORDER BY created_at DESC, id DESC')); } public function find(string $id): ?array { $row = $this->db->fetchAssociative('SELECT * FROM media WHERE id = ?', [$id]); return $row ? $this->present($row) : null; } 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); foreach (array_unique([min(480, $width), min(960, $width), min(1600, $width)]) 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); 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']]); if (1 !== $count) { throw new StaleWrite(); } return [...$data, 'id' => $id, 'version' => $data['version'] + 1]; } 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; } }