Files

432 lines
16 KiB
PHP
Raw Permalink Normal View History

<?php
/**
* Migrationsskript: Wandelt inline Base64-Bilder in
* knowledgecenter_post_versions.content in echte Dateien um.
*
* CLI-only. Idempotent. Per-Version-Transaktion mit Backup-Tabelle.
*
* Aufruf:
* php migrate_base64_to_files.php --dry-run --post-id=417 --verbose
* php migrate_base64_to_files.php --apply --post-id=417
* php migrate_base64_to_files.php --apply
*
* Optionen:
* --dry-run Standard. Nur analysieren, nichts schreiben.
* --apply Wirklich migrieren (Dateien + DB).
* --post-id=N Nur Versionen dieses Posts.
* --version-id=N Genau diese Version.
* --limit=N Maximal N Versionen verarbeiten.
* --verbose Mehr Details ins Log.
* --help Diese Hilfe.
*/
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, "Dieses Skript ist CLI-only.\n");
exit(2);
}
$currDir = __DIR__;
$rootDir = dirname(dirname(dirname($currDir)));
$mysydeDir = $rootDir . '/mysyde';
require_once($rootDir . '/vendor/autoload.php');
$envDir = $rootDir . '/config';
$envFilePath = $envDir . '/.env';
if (is_dir($envDir) && file_exists($envFilePath) && is_file($envFilePath) && is_readable($envFilePath)) {
$dotenv = new \Dotenv\Dotenv($envDir);
$dotenv->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 = '<?xml encoding="UTF-8"?><div id="kc_root">' . $content . '</div>';
$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 <img srcset=...> 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 <img> 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);