Ergänze sichere WebP-Bestandsmigration
Zeigt bei älteren Upload-Bildern automatisch einen geschützten Migrationsbutton im Adminbereich an und aktualisiert die JSON-Verweise transaktionssicher. Ergänzt außerdem einen Server-Updater, der Datenbank, Uploads und Konfiguration beim Wechsel von älteren Versionen sichert und wiederherstellt.
This commit is contained in:
+237
-6
@@ -3,6 +3,11 @@
|
||||
declare(strict_types=1);
|
||||
require_once __DIR__ . '/config.php';
|
||||
|
||||
// Erlaubt ein sicheres Update mit einer älteren, serverspezifischen config.php.
|
||||
if (!defined('WEBP_QUALITY')) {
|
||||
define('WEBP_QUALITY', 85);
|
||||
}
|
||||
|
||||
function ensure_storage(): void {
|
||||
if (!is_dir(dirname(DATA_FILE))) {
|
||||
@mkdir(dirname(DATA_FILE), 0775, true);
|
||||
@@ -24,16 +29,42 @@ function load_events(): array {
|
||||
|
||||
function save_events(array $events): void {
|
||||
ensure_storage();
|
||||
$json = json_encode(
|
||||
['events' => array_values($events)],
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR
|
||||
);
|
||||
$fp = fopen(DATA_FILE, 'c+');
|
||||
if ($fp === false) {
|
||||
throw new RuntimeException('Kann Datei nicht öffnen: ' . DATA_FILE);
|
||||
}
|
||||
flock($fp, LOCK_EX);
|
||||
ftruncate($fp, 0);
|
||||
fwrite($fp, json_encode(['events' => array_values($events)], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
fclose($fp);
|
||||
throw new RuntimeException('Kann Datenbank nicht sperren: ' . DATA_FILE);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!ftruncate($fp, 0) || !rewind($fp)) {
|
||||
throw new RuntimeException('Kann Datenbank nicht leeren: ' . DATA_FILE);
|
||||
}
|
||||
|
||||
$length = strlen($json);
|
||||
$offset = 0;
|
||||
while ($offset < $length) {
|
||||
$written = fwrite($fp, substr($json, $offset));
|
||||
if ($written === false || $written === 0) {
|
||||
throw new RuntimeException('Kann Datenbank nicht schreiben: ' . DATA_FILE);
|
||||
}
|
||||
$offset += $written;
|
||||
}
|
||||
|
||||
if (!fflush($fp)) {
|
||||
throw new RuntimeException('Kann Datenbank nicht speichern: ' . DATA_FILE);
|
||||
}
|
||||
} finally {
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
|
||||
function by_datetime_asc(array $a, array $b): int {
|
||||
@@ -140,6 +171,206 @@ function handle_image_upload(?array $file): ?string {
|
||||
return '/uploads/'.$basename; // Pfad relativ zur Webroot
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet vorhandene Bilddateien im Upload-Ordner, deren Name nicht auf .webp endet.
|
||||
*/
|
||||
function find_non_webp_uploads(): array {
|
||||
ensure_storage();
|
||||
$finfo = finfo_open(FILEINFO_MIME_TYPE);
|
||||
if ($finfo === false) {
|
||||
throw new RuntimeException('Die vorhandenen Uploads konnten nicht geprüft werden.');
|
||||
}
|
||||
|
||||
$uploads = [];
|
||||
try {
|
||||
foreach (new DirectoryIterator(UPLOAD_DIR) as $file) {
|
||||
if (!$file->isFile() || $file->isDot()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$name = $file->getFilename();
|
||||
if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'webp') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mime = finfo_file($finfo, $file->getPathname());
|
||||
if (!is_string($mime) || !in_array($mime, ['image/jpeg', 'image/png', 'image/webp'], true)) {
|
||||
continue;
|
||||
}
|
||||
if (@getimagesize($file->getPathname()) === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$uploads[] = [
|
||||
'name' => $name,
|
||||
'path' => $file->getPathname(),
|
||||
'mime' => $mime,
|
||||
'size' => $file->getSize(),
|
||||
];
|
||||
}
|
||||
} finally {
|
||||
finfo_close($finfo);
|
||||
}
|
||||
|
||||
usort($uploads, static function (array $left, array $right): int {
|
||||
return strcasecmp($left['name'], $right['name']);
|
||||
});
|
||||
|
||||
return $uploads;
|
||||
}
|
||||
|
||||
function load_events_for_migration(): array {
|
||||
ensure_storage();
|
||||
$raw = file_get_contents(DATA_FILE);
|
||||
if ($raw === false) {
|
||||
throw new RuntimeException('Die Datenbank konnte nicht gelesen werden.');
|
||||
}
|
||||
|
||||
try {
|
||||
$data = json_decode($raw, true, 512, JSON_THROW_ON_ERROR);
|
||||
} catch (Throwable $exception) {
|
||||
throw new RuntimeException('Die Datenbank enthält ungültiges JSON. Migration wurde abgebrochen.', 0, $exception);
|
||||
}
|
||||
|
||||
if (!is_array($data) || !isset($data['events']) || !is_array($data['events'])) {
|
||||
throw new RuntimeException('Die Datenbank hat ein ungültiges Format. Migration wurde abgebrochen.');
|
||||
}
|
||||
|
||||
return array_values($data['events']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert Bestandsbilder nach WebP und aktualisiert anschließend ihre JSON-Verweise.
|
||||
* Originaldateien werden erst nach einer erfolgreichen Datenbankaktualisierung entfernt.
|
||||
*/
|
||||
function migrate_uploads_to_webp(): array {
|
||||
$uploads = find_non_webp_uploads();
|
||||
if (!$uploads) {
|
||||
return ['processed' => 0, 'references' => 0, 'removed' => 0, 'remaining' => []];
|
||||
}
|
||||
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
$createdFiles = [];
|
||||
$temporaryFiles = [];
|
||||
$mapping = [];
|
||||
$events = load_events_for_migration();
|
||||
$databaseBackup = null;
|
||||
|
||||
try {
|
||||
foreach ($uploads as $upload) {
|
||||
$contentHash = hash_file('sha256', $upload['path']);
|
||||
if ($contentHash === false) {
|
||||
throw new RuntimeException('Prüfsumme fehlgeschlagen: ' . $upload['name']);
|
||||
}
|
||||
|
||||
$stem = pathinfo($upload['name'], PATHINFO_FILENAME);
|
||||
$stem = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $stem);
|
||||
$stem = trim((string)$stem, '-_');
|
||||
if ($stem === '') {
|
||||
$stem = 'legacy-image';
|
||||
}
|
||||
$stem = substr($stem, 0, 160);
|
||||
|
||||
$targetName = $stem . '-converted-' . substr($contentHash, 0, 10) . '.webp';
|
||||
$targetPath = $uploadDir . '/' . $targetName;
|
||||
|
||||
if (is_file($targetPath)) {
|
||||
$targetMime = mime_content_type($targetPath);
|
||||
if ($targetMime !== 'image/webp' || @getimagesize($targetPath) === false) {
|
||||
throw new RuntimeException('Vorhandene Zieldatei ist kein gültiges WebP: ' . $targetName);
|
||||
}
|
||||
} else {
|
||||
$temporaryPath = $uploadDir . '/.' . $targetName . '.' . bin2hex(random_bytes(4)) . '.tmp';
|
||||
$temporaryFiles[] = $temporaryPath;
|
||||
convert_image_to_webp($upload['path'], $upload['mime'], $temporaryPath, WEBP_QUALITY);
|
||||
|
||||
if (mime_content_type($temporaryPath) !== 'image/webp' || @getimagesize($temporaryPath) === false) {
|
||||
throw new RuntimeException('Konvertierung konnte nicht validiert werden: ' . $upload['name']);
|
||||
}
|
||||
if (!@rename($temporaryPath, $targetPath)) {
|
||||
throw new RuntimeException('WebP-Datei konnte nicht gespeichert werden: ' . $targetName);
|
||||
}
|
||||
|
||||
$temporaryFiles = array_values(array_filter(
|
||||
$temporaryFiles,
|
||||
static function (string $path) use ($temporaryPath): bool {
|
||||
return $path !== $temporaryPath;
|
||||
}
|
||||
));
|
||||
$createdFiles[] = $targetPath;
|
||||
}
|
||||
|
||||
$mapping[$upload['name']] = '/uploads/' . $targetName;
|
||||
}
|
||||
|
||||
$updatedReferences = 0;
|
||||
foreach ($events as &$event) {
|
||||
$image = (string)($event['image'] ?? '');
|
||||
if ($image === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$path = parse_url($image, PHP_URL_PATH);
|
||||
$sourceName = rawurldecode(basename(is_string($path) ? $path : $image));
|
||||
if (isset($mapping[$sourceName])) {
|
||||
$event['image'] = $mapping[$sourceName];
|
||||
$updatedReferences++;
|
||||
}
|
||||
}
|
||||
unset($event);
|
||||
|
||||
if ($updatedReferences > 0) {
|
||||
$databaseBackup = DATA_FILE . '.migration-' . bin2hex(random_bytes(6)) . '.bak';
|
||||
if (!@copy(DATA_FILE, $databaseBackup)) {
|
||||
throw new RuntimeException('Die Datenbank konnte vor der Migration nicht gesichert werden.');
|
||||
}
|
||||
|
||||
try {
|
||||
save_events($events);
|
||||
} catch (Throwable $exception) {
|
||||
if (!@copy($databaseBackup, DATA_FILE)) {
|
||||
$backupToKeep = $databaseBackup;
|
||||
$databaseBackup = null;
|
||||
throw new RuntimeException(
|
||||
'Datenbankaktualisierung und Wiederherstellung sind fehlgeschlagen. Backup: ' . $backupToKeep,
|
||||
0,
|
||||
$exception
|
||||
);
|
||||
}
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
foreach (array_merge($temporaryFiles, $createdFiles) as $path) {
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
throw $exception;
|
||||
} finally {
|
||||
if ($databaseBackup !== null && is_file($databaseBackup)) {
|
||||
@unlink($databaseBackup);
|
||||
}
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
$remaining = [];
|
||||
foreach ($uploads as $upload) {
|
||||
if (@unlink($upload['path'])) {
|
||||
$removed++;
|
||||
} else {
|
||||
$remaining[] = $upload['name'];
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'processed' => count($uploads),
|
||||
'references' => $updatedReferences,
|
||||
'removed' => $removed,
|
||||
'remaining' => $remaining,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert ein geprüftes JPG-, PNG- oder WebP-Bild in eine WebP-Datei.
|
||||
* Unterstützt GD mit WebP-Support sowie Imagick als Alternative.
|
||||
|
||||
Reference in New Issue
Block a user