Zeige Fortschritt bei der WebP-Migration
Teilt die Bestandsmigration in einen sicheren Request pro Bild auf, zeigt den tatsächlichen Fortschritt im Adminbereich und erlaubt die Wiederaufnahme nach Gateway- oder Netzwerkabbrüchen.
This commit is contained in:
+117
-66
@@ -189,6 +189,11 @@ function find_non_webp_uploads(): array {
|
||||
}
|
||||
|
||||
$name = $file->getFilename();
|
||||
// Angefangene Uploads und temporäre Konvertierungen niemals als
|
||||
// eigenständige Bestandsbilder behandeln.
|
||||
if (strncmp($name, '.', 1) === 0) {
|
||||
continue;
|
||||
}
|
||||
if (strtolower(pathinfo($name, PATHINFO_EXTENSION)) === 'webp') {
|
||||
continue;
|
||||
}
|
||||
@@ -240,67 +245,84 @@ function load_events_for_migration(): array {
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert Bestandsbilder nach WebP und aktualisiert anschließend ihre JSON-Verweise.
|
||||
* Originaldateien werden erst nach einer erfolgreichen Datenbankaktualisierung entfernt.
|
||||
* Konvertiert genau ein Bestandsbild. Dadurch kann der Adminbereich nach jeder
|
||||
* Datei einen echten Fortschritt anzeigen und lange Gateway-Timeouts vermeiden.
|
||||
*/
|
||||
function migrate_uploads_to_webp(): array {
|
||||
$uploads = find_non_webp_uploads();
|
||||
if (!$uploads) {
|
||||
return ['processed' => 0, 'references' => 0, 'removed' => 0, 'remaining' => []];
|
||||
function migrate_next_upload_to_webp(): array {
|
||||
ensure_storage();
|
||||
$lockPath = DATA_FILE . '.webp-migration.lock';
|
||||
$lockHandle = fopen($lockPath, 'c');
|
||||
if ($lockHandle === false) {
|
||||
throw new RuntimeException('Die WebP-Migration konnte nicht gesperrt werden.');
|
||||
}
|
||||
if (!flock($lockHandle, LOCK_EX | LOCK_NB)) {
|
||||
fclose($lockHandle);
|
||||
throw new RuntimeException('Eine WebP-Migration läuft bereits. Bitte kurz warten.');
|
||||
}
|
||||
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
$createdFiles = [];
|
||||
$temporaryFiles = [];
|
||||
$mapping = [];
|
||||
try {
|
||||
return migrate_next_upload_to_webp_unlocked();
|
||||
} finally {
|
||||
flock($lockHandle, LOCK_UN);
|
||||
fclose($lockHandle);
|
||||
}
|
||||
}
|
||||
|
||||
function migrate_next_upload_to_webp_unlocked(): array {
|
||||
$uploads = find_non_webp_uploads();
|
||||
if (!$uploads) {
|
||||
return [
|
||||
'processed' => 0,
|
||||
'references' => 0,
|
||||
'removed' => 0,
|
||||
'remaining_count' => 0,
|
||||
'source' => null,
|
||||
'target' => null,
|
||||
'deletion_failed' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$upload = $uploads[0];
|
||||
$events = load_events_for_migration();
|
||||
$uploadDir = rtrim(UPLOAD_DIR, '/\\');
|
||||
$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;
|
||||
$temporaryPath = null;
|
||||
$targetCreated = false;
|
||||
$preserveTargetOnFailure = false;
|
||||
$databaseBackup = null;
|
||||
|
||||
try {
|
||||
foreach ($uploads as $upload) {
|
||||
$contentHash = hash_file('sha256', $upload['path']);
|
||||
if ($contentHash === false) {
|
||||
throw new RuntimeException('Prüfsumme fehlgeschlagen: ' . $upload['name']);
|
||||
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';
|
||||
convert_image_to_webp($upload['path'], $upload['mime'], $temporaryPath, WEBP_QUALITY);
|
||||
|
||||
$stem = pathinfo($upload['name'], PATHINFO_FILENAME);
|
||||
$stem = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $stem);
|
||||
$stem = trim((string)$stem, '-_');
|
||||
if ($stem === '') {
|
||||
$stem = 'legacy-image';
|
||||
if (mime_content_type($temporaryPath) !== 'image/webp' || @getimagesize($temporaryPath) === false) {
|
||||
throw new RuntimeException('Konvertierung konnte nicht validiert werden: ' . $upload['name']);
|
||||
}
|
||||
$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;
|
||||
if (!@rename($temporaryPath, $targetPath)) {
|
||||
throw new RuntimeException('WebP-Datei konnte nicht gespeichert werden: ' . $targetName);
|
||||
}
|
||||
|
||||
$mapping[$upload['name']] = '/uploads/' . $targetName;
|
||||
$temporaryPath = null;
|
||||
$targetCreated = true;
|
||||
}
|
||||
|
||||
$updatedReferences = 0;
|
||||
@@ -312,8 +334,8 @@ function migrate_uploads_to_webp(): array {
|
||||
|
||||
$path = parse_url($image, PHP_URL_PATH);
|
||||
$sourceName = rawurldecode(basename(is_string($path) ? $path : $image));
|
||||
if (isset($mapping[$sourceName])) {
|
||||
$event['image'] = $mapping[$sourceName];
|
||||
if ($sourceName === $upload['name']) {
|
||||
$event['image'] = '/uploads/' . $targetName;
|
||||
$updatedReferences++;
|
||||
}
|
||||
}
|
||||
@@ -329,6 +351,7 @@ function migrate_uploads_to_webp(): array {
|
||||
save_events($events);
|
||||
} catch (Throwable $exception) {
|
||||
if (!@copy($databaseBackup, DATA_FILE)) {
|
||||
$preserveTargetOnFailure = true;
|
||||
$backupToKeep = $databaseBackup;
|
||||
$databaseBackup = null;
|
||||
throw new RuntimeException(
|
||||
@@ -341,10 +364,11 @@ function migrate_uploads_to_webp(): array {
|
||||
}
|
||||
}
|
||||
} catch (Throwable $exception) {
|
||||
foreach (array_merge($temporaryFiles, $createdFiles) as $path) {
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
if ($temporaryPath !== null && is_file($temporaryPath)) {
|
||||
@unlink($temporaryPath);
|
||||
}
|
||||
if ($targetCreated && !$preserveTargetOnFailure && is_file($targetPath)) {
|
||||
@unlink($targetPath);
|
||||
}
|
||||
throw $exception;
|
||||
} finally {
|
||||
@@ -353,20 +377,47 @@ function migrate_uploads_to_webp(): array {
|
||||
}
|
||||
}
|
||||
|
||||
$removed = 0;
|
||||
$remaining = [];
|
||||
foreach ($uploads as $upload) {
|
||||
if (@unlink($upload['path'])) {
|
||||
$removed++;
|
||||
} else {
|
||||
$remaining[] = $upload['name'];
|
||||
}
|
||||
}
|
||||
$removed = @unlink($upload['path']) ? 1 : 0;
|
||||
$remaining = find_non_webp_uploads();
|
||||
|
||||
return [
|
||||
'processed' => count($uploads),
|
||||
'processed' => 1,
|
||||
'references' => $updatedReferences,
|
||||
'removed' => $removed,
|
||||
'remaining_count' => count($remaining),
|
||||
'source' => $upload['name'],
|
||||
'target' => $targetName,
|
||||
'deletion_failed' => $removed === 0,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Kompatibler Komplettlauf für CLI-Aufrufe. Das Admin-Interface verwendet
|
||||
* migrate_next_upload_to_webp(), damit jeder Schritt separat bestätigt wird.
|
||||
*/
|
||||
function migrate_uploads_to_webp(): array {
|
||||
$processed = 0;
|
||||
$references = 0;
|
||||
$removed = 0;
|
||||
|
||||
do {
|
||||
$step = migrate_next_upload_to_webp();
|
||||
$processed += $step['processed'];
|
||||
$references += $step['references'];
|
||||
$removed += $step['removed'];
|
||||
} while ($step['processed'] > 0 && !$step['deletion_failed'] && $step['remaining_count'] > 0);
|
||||
|
||||
$remaining = array_map(
|
||||
static function (array $upload): string {
|
||||
return $upload['name'];
|
||||
},
|
||||
find_non_webp_uploads()
|
||||
);
|
||||
|
||||
return [
|
||||
'processed' => $processed,
|
||||
'references' => $references,
|
||||
'removed' => $removed,
|
||||
'remaining' => $remaining,
|
||||
];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user