PDFs go through the same upload field as images, are checked by their content and kept unchanged in their own table. Every link field can pick one, and a page that links a PDF opens it in a dialog with the browser's own viewer; touch devices and browsers without a viewer get a new tab instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
123 lines
4.8 KiB
PHP
123 lines
4.8 KiB
PHP
<?php
|
|
|
|
namespace App\Content;
|
|
|
|
use App\Domain\StaleWrite;
|
|
use Doctrine\DBAL\Connection;
|
|
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
|
|
|
/**
|
|
* PDFs neben den Bildern der Mediathek.
|
|
*
|
|
* Ein Bild wird beim Hochladen neu berechnet und nur in dieser Fassung ausgeliefert - was auch
|
|
* immer in der Datei steckte, ist danach weg. Bei einem PDF geht das nicht: es wird so
|
|
* ausgeliefert, wie es kam. Deshalb die Pruefung am Anfang der Datei statt an der Endung, und
|
|
* beim Ausliefern Kopfzeilen, die den Browser nichts anderes darin sehen lassen als ein PDF.
|
|
*
|
|
* Die Dateien liegen im Medienordner, als <id>.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<array<string, mixed>> 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',
|
|
];
|
|
}
|
|
}
|