.pdf - so gehen sie mit jeder Sicherung mit. */ final class DocumentLibrary { /** Ein Prospekt oder eine Preisliste, kein Katalog mit tausend Fotos. */ public const MAX_BYTES = 20 * 1024 * 1024; /** Die Adresse eines Dokuments. So steht sie auch in einem Link-Feld. */ public const PATH = '#^/media/([a-f0-9]{32})\.pdf$#D'; public function __construct(private Connection $db, private string $directory) { } /** @return list> neueste zuerst */ public function list(bool $withArchived = true): array { return array_map($this->present(...), $this->db->fetchAllAssociative( 'SELECT * FROM document'.($withArchived ? '' : ' WHERE archived = 0').' ORDER BY created_at DESC, id DESC', )); } public function find(string $id): ?array { if (!preg_match('/^[a-f0-9]{32}$/D', $id)) { return null; } $row = $this->db->fetchAssociative('SELECT * FROM document WHERE id = ?', [$id]); return $row ? $this->present($row) : null; } public function upload(UploadedFile $file): array { if (!$file->isValid() || $file->getSize() > self::MAX_BYTES) { throw new \InvalidArgumentException('Upload fehlgeschlagen. Maximal 20 MB pro PDF; das PHP-Hosting kann ein kleineres Limit setzen.'); } // Ein PDF beginnt mit %PDF- - in den ersten 1024 Bytes, so lesen es auch die Betrachter. // Die Endung oder der Typ, den der Browser mitschickt, sagen darueber nichts. $head = (string) file_get_contents($file->getPathname(), false, null, 0, 1024); if (!str_contains($head, '%PDF-')) { throw new \InvalidArgumentException('Das ist kein PDF. Bitte eine PDF-Datei wählen.'); } 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)); $path = $this->path($id); if (!@copy($file->getPathname(), $path) || !is_file($path)) { throw new \RuntimeException('Das Dokument konnte nicht gespeichert werden.'); } try { $name = pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME); $title = mb_strcut(preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?: 'Dokument', 0, 160, 'UTF-8'); $row = ['id' => $id, 'title' => $title, 'bytes' => (int) filesize($path), 'archived' => 0, 'version' => 1, 'created_at' => gmdate('Y-m-d H:i:s')]; $this->db->insert('document', $row); } catch (\Throwable $error) { unlink($path); throw $error; } return $this->present($row); } public function update(string $id, array $data): array { if (!is_string($data['title'] ?? null) || '' === trim($data['title']) || strlen($data['title']) > 160 || !is_bool($data['archived'] ?? null) || !is_int($data['version'] ?? null)) { throw new \InvalidArgumentException('Titel (max. 160 Bytes) und Version prüfen.'); } $count = $this->db->executeStatement('UPDATE document SET title = ?, archived = ?, version = version + 1 WHERE id = ? AND version = ?', [$data['title'], (int) $data['archived'], $id, $data['version']]); if (1 !== $count) { throw new StaleWrite(); } return $this->find($id) ?? throw new StaleWrite(); } /** Die Datei eines Dokuments, oder null. Archivierte werden weiter ausgeliefert, wie Bilder. */ public function file(string $id): ?string { if (null === $this->find($id)) { return null; } $path = $this->path($id); return is_file($path) ? $path : null; } private function path(string $id): string { return $this->directory.'/'.$id.'.pdf'; } private function present(array $row): array { return [ 'id' => $row['id'], 'title' => $row['title'], 'bytes' => (int) $row['bytes'], 'archived' => (bool) $row['archived'], 'version' => (int) $row['version'], 'created_at' => $row['created_at'], 'src' => '/media/'.$row['id'].'.pdf', ]; } }