Files
awo-hamburg-intranet/module/knowledgecenter_update/Script/migrate_base64_to_files.php
Moritz Weinmann 743476f64a Module tasks + knowledgecenter_update aus Kundenprojekt übernehmen (bereinigt)
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>
2026-06-22 09:20:02 +02:00

432 lines
16 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);