Beide Module wurden aus einem anderen Kundenprojekt kopiert und bereinigt: Knowledgecenter: - Bild-Cache (1.985 Dateien) geleert, Ordnerstruktur mit .gitkeep behalten - Files-Gallery-Lizenzschlüssel entfernt (config.php) - Kundenspezifische Kategorien gelöscht: Teil I/II/III QM-Struktur, nummerierte QM-Kapitel, Gremien (Präsidium/Finanzausschuss/Landesausschuss), Sitzungsservice-Serie, Protokoll-Abteilungen, Testeinträge - 56 generische Kategorien bleiben (Downloads, Onboarding, Personalthemen …) Tasks: - Alle Betriebsdaten gelöscht (114 Tasks, 914 Submissions, 579 Assignments) - Task-Definitionen und Verlinkungen geleert - Kategoriestruktur bleibt (Allgemein, IT, Personal, Buchhaltung …) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
432 lines
16 KiB
PHP
432 lines
16 KiB
PHP
<?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);
|