load(); } require_once($mysydeDir . '/common/common_functions.inc.php'); $connEnv = (local_environment()) ? $mysydeDir . '/dc.config.php' : $mysydeDir . '/dc-server.config.php'; require_once($connEnv); db_connect(); // ------- Args ------- $opts = [ 'dry-run' => true, 'apply' => false, 'post-id' => null, 'version-id' => null, 'limit' => null, 'verbose' => false, ]; foreach (array_slice($argv, 1) as $arg) { if ($arg === '--help' || $arg === '-h') { echo "Usage: php migrate_base64_to_files.php [--dry-run|--apply] [--post-id=N] [--version-id=N] [--limit=N] [--verbose]\n"; exit(0); } if ($arg === '--apply') { $opts['apply'] = true; $opts['dry-run'] = false; continue; } if ($arg === '--dry-run') { $opts['dry-run'] = true; $opts['apply'] = false; continue; } if ($arg === '--verbose') { $opts['verbose'] = true; continue; } if (preg_match('/^--post-id=(\d+)$/', $arg, $m)) { $opts['post-id'] = (int) $m[1]; continue; } if (preg_match('/^--version-id=(\d+)$/', $arg, $m)) { $opts['version-id'] = (int) $m[1]; continue; } if (preg_match('/^--limit=(\d+)$/', $arg, $m)) { $opts['limit'] = (int) $m[1]; continue; } fwrite(STDERR, "Unbekannte Option: $arg\n"); exit(2); } $dryRun = !$opts['apply']; $verbose = $opts['verbose']; $runId = bin2hex(random_bytes(8)); // ------- Logging ------- $logDir = $rootDir . '/userdata/knowledgecenter_update/migrations'; if (!is_dir($logDir) && !@mkdir($logDir, 0775, true)) { fwrite(STDERR, "Log-Verzeichnis nicht anlegbar: $logDir\n"); exit(3); } $logFile = $logDir . '/base64_to_files_' . gmdate('Ymd_His') . '_' . $runId . '.log'; $logFp = fopen($logFile, 'w'); if (!$logFp) { fwrite(STDERR, "Log-Datei nicht öffenbar: $logFile\n"); exit(3); } function logmsg($msg, $level = 'INFO') { global $logFp, $verbose; $line = '[' . gmdate('Y-m-d H:i:s') . " UTC] [$level] $msg\n"; fwrite($logFp, $line); if ($verbose || in_array($level, ['ERROR', 'FAIL', 'OK', 'WARN', 'HEAD'], true)) { fwrite(STDOUT, $line); } } logmsg("Migration-Run gestartet. run_id=$runId mode=" . ($dryRun ? 'DRY-RUN' : 'APPLY') . " post-id=" . ($opts['post-id'] ?? 'ALL') . " version-id=" . ($opts['version-id'] ?? 'ALL') . " limit=" . ($opts['limit'] ?? 'unbeschränkt'), 'HEAD'); // ------- Backup-Tabelle anlegen ------- $createBackup = "CREATE TABLE IF NOT EXISTS knowledgecenter_post_versions_migration_backup ( id INT AUTO_INCREMENT PRIMARY KEY, version_id INT NOT NULL, post_id INT NOT NULL, language_id INT NOT NULL, version_number INT NOT NULL, original_content LONGTEXT NOT NULL, original_length INT NOT NULL, images_extracted INT NOT NULL, migrated_at DATETIME NOT NULL, script_run_id VARCHAR(32) NOT NULL, UNIQUE KEY uniq_version_run (version_id, script_run_id), KEY idx_run (script_run_id), KEY idx_post (post_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"; if (!mysqli_query($GLOBALS['mysql_con'], $createBackup)) { logmsg('Backup-Tabelle konnte nicht angelegt werden: ' . mysqli_error($GLOBALS['mysql_con']), 'ERROR'); exit(4); } // ------- Kandidaten finden ------- $where = ["content LIKE '%data:image/%;base64,%'"]; if ($opts['post-id'] !== null) $where[] = "post_id = " . (int) $opts['post-id']; if ($opts['version-id'] !== null) $where[] = "id = " . (int) $opts['version-id']; $sql = "SELECT id, post_id, language_id, version_number, content FROM knowledgecenter_post_versions WHERE " . implode(' AND ', $where) . " ORDER BY post_id, language_id, version_number"; if ($opts['limit'] !== null) $sql .= " LIMIT " . (int) $opts['limit']; $res = mysqli_query($GLOBALS['mysql_con'], $sql); if (!$res) { logmsg('Query fehlgeschlagen: ' . mysqli_error($GLOBALS['mysql_con']), 'ERROR'); exit(5); } $candidateCount = mysqli_num_rows($res); logmsg("Gefundene Kandidaten: $candidateCount", 'HEAD'); // ------- Helper ------- function mime_to_ext($mime) { static $map = [ 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', ]; return isset($map[$mime]) ? $map[$mime] : null; } function ensure_post_inline_dir($rootDir, $postId, $dryRun) { $hash = substr(sha1('kc_post_' . (int) $postId), 0, 24); $relBase = 'knowledgecenter_update/posts/' . $hash; $absBase = $rootDir . '/userdata/' . $relBase; $absInline = $absBase . '/inline'; if ($dryRun) { return ['hash' => $hash, 'relBase' => $relBase, 'absBase' => $absBase, 'absInline' => $absInline, 'created' => false]; } if (!is_dir($absBase) && !@mkdir($absBase, 0775, true)) { throw new RuntimeException("Post-Ordner nicht anlegbar: $absBase"); } $sharedLink = $absBase . '/shared'; if (!is_link($sharedLink) && !file_exists($sharedLink)) { @symlink('../../shared', $sharedLink); } if (!is_dir($absInline) && !@mkdir($absInline, 0775, true)) { throw new RuntimeException("Inline-Ordner nicht anlegbar: $absInline"); } return ['hash' => $hash, 'relBase' => $relBase, 'absBase' => $absBase, 'absInline' => $absInline, 'created' => true]; } function process_version($row, $rootDir, $dryRun, $runId, $verbose) { $versionId = (int) $row['id']; $postId = (int) $row['post_id']; $langId = (int) $row['language_id']; $verNum = (int) $row['version_number']; $content = (string) $row['content']; $origLen = strlen($content); $tag = "version_id=$versionId post_id=$postId lang=$langId v=$verNum"; // DOM laden $wrapped = '
' . $content . '
'; $prev = libxml_use_internal_errors(true); $dom = new DOMDocument(); $loaded = $dom->loadHTML($wrapped, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD | LIBXML_NOERROR | LIBXML_NOWARNING); libxml_clear_errors(); libxml_use_internal_errors($prev); if (!$loaded) { logmsg("[FAIL] $tag DOM load fehlgeschlagen", 'FAIL'); return ['ok' => false, 'rewrote' => 0]; } $root = $dom->getElementById('kc_root'); if (!$root) { logmsg("[FAIL] $tag wrapper nicht gefunden", 'FAIL'); return ['ok' => false, 'rewrote' => 0]; } $imgs = $dom->getElementsByTagName('img'); $imgList = []; foreach ($imgs as $img) { $imgList[] = $img; } $rewrote = 0; $skippedNoBase64 = 0; $skippedBadDecode = 0; $skippedBadMime = 0; $skippedTooBig = 0; $skippedSrcset = 0; $totalBytesIn = 0; foreach ($imgList as $img) { if ($img->hasAttribute('srcset')) { $skippedSrcset++; logmsg("[WARN] $tag belassen", 'WARN'); continue; } $src = $img->getAttribute('src'); if ($src === '' || strncmp($src, 'data:', 5) !== 0) continue; if (!preg_match('#^data:(image/[a-z0-9.+-]+)?(;charset=[^;,]+)?(;base64)?,(.*)$#is', $src, $m)) { $skippedNoBase64++; logmsg("[WARN] $tag data-URI nicht parsbar, belassen", 'WARN'); continue; } $declaredMime = strtolower($m[1] ?? ''); $isBase64 = !empty($m[3]); $payload = $m[4]; if (!$isBase64) { $skippedNoBase64++; logmsg("[WARN] $tag data-URI ohne ;base64, belassen", 'WARN'); continue; } $decoded = base64_decode($payload, true); if ($decoded === false || $decoded === '') { $skippedBadDecode++; logmsg("[WARN] $tag base64 nicht dekodierbar, belassen", 'WARN'); continue; } $size = strlen($decoded); $totalBytesIn += $size; if ($size > 5 * 1024 * 1024) { $skippedTooBig++; logmsg("[WARN] $tag Bild > 5MB ($size bytes), belassen", 'WARN'); continue; } $finfo = finfo_open(FILEINFO_MIME_TYPE); $detectedMime = $finfo ? finfo_buffer($finfo, $decoded) : false; if ($finfo) finfo_close($finfo); $ext = mime_to_ext($detectedMime); if (!$ext) { $skippedBadMime++; logmsg("[WARN] $tag Mime '$detectedMime' nicht erlaubt (declared='$declaredMime'), belassen", 'WARN'); continue; } $dir = ensure_post_inline_dir($rootDir, $postId, $dryRun); $sha = hash('sha256', $decoded); $filename = $sha . '.' . $ext; $finalPath = $dir['absInline'] . '/' . $filename; $publicUrl = '/userdata/' . $dir['relBase'] . '/inline/' . $filename; if ($dryRun) { if ($verbose) logmsg("[DRY ] $tag would write $finalPath ($size bytes) → $publicUrl", 'INFO'); } else { $writeNeeded = true; if (file_exists($finalPath)) { $existingSize = filesize($finalPath); if ($existingSize === $size) { $writeNeeded = false; if ($verbose) logmsg("[INFO] $tag dedup hit $finalPath", 'INFO'); } else { logmsg("[WARN] $tag SHA-Kollision?! $finalPath bestehend=$existingSize bytes, neu=$size bytes – überspringe", 'WARN'); $writeNeeded = false; } } if ($writeNeeded) { $tmp = $dir['absInline'] . '/.tmp_' . bin2hex(random_bytes(6)); if (file_put_contents($tmp, $decoded) === false) { logmsg("[FAIL] $tag konnte tmp-Datei nicht schreiben", 'FAIL'); return ['ok' => false, 'rewrote' => $rewrote]; } @chmod($tmp, 0664); if (!rename($tmp, $finalPath)) { @unlink($tmp); logmsg("[FAIL] $tag rename failed", 'FAIL'); return ['ok' => false, 'rewrote' => $rewrote]; } } } $img->setAttribute('src', $publicUrl); $rewrote++; } // Serialisieren $newContent = ''; foreach ($root->childNodes as $child) { $newContent .= $dom->saveHTML($child); } $newLen = strlen($newContent); $report = sprintf( "%s images_rewritten=%d skipped(no_b64=%d bad_decode=%d bad_mime=%d too_big=%d srcset=%d) from_kb=%d to_kb=%d", $tag, $rewrote, $skippedNoBase64, $skippedBadDecode, $skippedBadMime, $skippedTooBig, $skippedSrcset, (int) round($origLen / 1024), (int) round($newLen / 1024) ); return [ 'ok' => true, 'rewrote' => $rewrote, 'newContent'=> $newContent, 'origLen' => $origLen, 'newLen' => $newLen, 'report' => $report, ]; } // ------- Verarbeitung ------- $totals = ['ok' => 0, 'rewritten_images' => 0, 'fail' => 0, 'no_change' => 0, 'bytes_saved' => 0]; while ($row = mysqli_fetch_assoc($res)) { $versionId = (int) $row['id']; $postId = (int) $row['post_id']; if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'START TRANSACTION'); try { $result = process_version($row, $rootDir, $dryRun, $runId, $verbose); } catch (Throwable $e) { if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK'); logmsg("[FAIL] version_id=$versionId Exception: " . $e->getMessage(), 'FAIL'); $totals['fail']++; continue; } if (!$result['ok']) { if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK'); $totals['fail']++; continue; } if ($result['rewrote'] === 0) { if (!$dryRun) mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK'); logmsg("[SKIP] " . $result['report'] . " (kein umgeschrieben)", 'WARN'); $totals['no_change']++; continue; } if ($dryRun) { logmsg("[DRY ] " . $result['report'], 'OK'); $totals['ok']++; $totals['rewritten_images'] += $result['rewrote']; $totals['bytes_saved'] += max(0, $result['origLen'] - $result['newLen']); continue; } // Backup + UPDATE $stmt = mysqli_prepare( $GLOBALS['mysql_con'], "INSERT INTO knowledgecenter_post_versions_migration_backup (version_id, post_id, language_id, version_number, original_content, original_length, images_extracted, migrated_at, script_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, NOW(), ?)" ); if (!$stmt) { mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK'); logmsg("[FAIL] version_id=$versionId backup-prepare: " . mysqli_error($GLOBALS['mysql_con']), 'FAIL'); $totals['fail']++; continue; } $verId = (int) $row['id']; $pId = (int) $row['post_id']; $lId = (int) $row['language_id']; $vNum = (int) $row['version_number']; $origCt = (string) $row['content']; $origLen = $result['origLen']; $rewrote = $result['rewrote']; mysqli_stmt_bind_param($stmt, 'iiiisiis', $verId, $pId, $lId, $vNum, $origCt, $origLen, $rewrote, $runId); if (!mysqli_stmt_execute($stmt)) { mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK'); logmsg("[FAIL] version_id=$versionId backup-insert: " . mysqli_stmt_error($stmt), 'FAIL'); mysqli_stmt_close($stmt); $totals['fail']++; continue; } mysqli_stmt_close($stmt); $stmt2 = mysqli_prepare( $GLOBALS['mysql_con'], "UPDATE knowledgecenter_post_versions SET content = ? WHERE id = ?" ); if (!$stmt2) { mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK'); logmsg("[FAIL] version_id=$versionId update-prepare: " . mysqli_error($GLOBALS['mysql_con']), 'FAIL'); $totals['fail']++; continue; } $newCt = $result['newContent']; mysqli_stmt_bind_param($stmt2, 'si', $newCt, $verId); if (!mysqli_stmt_execute($stmt2)) { mysqli_query($GLOBALS['mysql_con'], 'ROLLBACK'); logmsg("[FAIL] version_id=$versionId update-exec: " . mysqli_stmt_error($stmt2), 'FAIL'); mysqli_stmt_close($stmt2); $totals['fail']++; continue; } mysqli_stmt_close($stmt2); if (!mysqli_query($GLOBALS['mysql_con'], 'COMMIT')) { logmsg("[FAIL] version_id=$versionId COMMIT-Fehler: " . mysqli_error($GLOBALS['mysql_con']), 'FAIL'); $totals['fail']++; continue; } logmsg("[OK ] " . $result['report'], 'OK'); $totals['ok']++; $totals['rewritten_images'] += $result['rewrote']; $totals['bytes_saved'] += max(0, $result['origLen'] - $result['newLen']); } logmsg(sprintf( "Fertig. ok=%d fail=%d no_change=%d rewritten_images=%d bytes_saved=%d (%.2f MB) mode=%s", $totals['ok'], $totals['fail'], $totals['no_change'], $totals['rewritten_images'], $totals['bytes_saved'], $totals['bytes_saved'] / 1024 / 1024, $dryRun ? 'DRY-RUN' : 'APPLY' ), 'HEAD'); logmsg("Log-Datei: $logFile", 'HEAD'); fclose($logFp); exit($totals['fail'] > 0 ? 1 : 0);