$s === null ? 'NULL' : "'" . str_replace(["\\", "'", "\n", "\r", "\0", "\x1a"], ["\\\\", "\\'", "\\n", "\\r", "\\0", "\\Z"], (string) $s) . "'"; $ticketTable = [ // id, number, user, status, dept, topic, staff, source, created, closed, lastupdate, overdue [1, '100001', 1, 1, 1, 1, 1, 'Web', '2024-01-10 08:00:00', null, '2024-01-10 09:00:00', 1], [2, '100002', 2, 2, 5, 2, 0, 'Email', '2024-01-11 08:00:00', '2024-01-12 10:00:00', '2024-01-12 10:00:00', 0], [3, '100003', 3, 3, 1, 0, 1, 'Phone', '2024-01-12 08:00:00', '2024-01-13 10:00:00', '2024-01-13 10:00:00', 0], [4, '100004', 1, 5, 1, 1, 0, 'API', '2024-01-13 08:00:00', null, '2024-01-13 08:00:00', 0], // gelöscht [5, '100005', 2, 4, 1, 1, 0, 'Other', '2024-01-14 08:00:00', '2024-01-15 08:00:00', '2024-01-15 08:00:00', 0], // archiviert ]; $subjects = [1 => "Drucker 'kaputt', (dringend)", 2 => 'Rechnung \\ fehlt', 3 => 'Ünïcödé Ticket ✓', 4 => 'Gelöscht', 5 => 'Archiviert']; $prioOfTicket = [1 => 4, 2 => 1, 3 => 2, 4 => 3, 5 => 3]; $bigBody = "

Hallo,

ich habe ein Problem mit dem Gerät (Modell X-1, 'Pro'), siehe \\\\server\\share.

Zeile 1
Zeile 2),(1,'x',

zitierter Text
"; $entries = [ // id, thread, staff, user, type, poster, body, format, created [1, 1, 0, 1, 'M', 'Kunde Eins', $bigBody, 'html', '2024-01-10 08:00:00'], [2, 1, 1, 0, 'R', 'Support Admin', "

Wir kümmern uns.

\n

Gruß

", 'html', '2024-01-10 09:00:00'], [3, 1, 1, 0, 'N', 'Support Admin', 'Interne Notiz: Kunde ist VIP', 'text', '2024-01-10 09:05:00'], [4, 2, 0, 2, 'M', 'Kunde Zwei', 'Wo ist meine Rechnung?', 'text', '2024-01-11 08:00:00'], [5, 2, 0, 2, 'M', 'Kunde Zwei', '', 'html', '2024-01-11 08:30:00'], // leer, mit Anhang [6, 2, 0, 2, 'M', 'Kunde Zwei', '

', 'html', '2024-01-11 08:40:00'], // leer, ohne Anhang [7, 3, 0, 3, 'M', 'Kunde Drei', 'Hilfe', 'text', '2024-01-12 08:00:00'], [8, 4, 0, 1, 'M', 'Kunde Eins', 'Gehört zu gelöschtem Ticket', 'text', '2024-01-13 08:00:00'], ]; $collab = [[1, 1, 1, 3]]; // id, flags, thread, user $users = [[1, 'Kunde Eins', 1], [2, "O'Brien, Pat (Kunde)", 2], [3, '', 3], [4, 'Ohne Adresse', 0]]; $emails = [[1, 1, 'eins@example.test'], [2, 2, 'pat@example.test'], [3, 3, 'drei@example.test']]; $file1 = "\x89PNG\r\n" . str_repeat("A'B\\C\0D", 40); // enthält Anführungszeichen, Backslash, NUL $file2 = random_bytes(3000); $files = [[1, 'T', 'image/png', strlen($file1), 'logo.png'], [2, 'T', 'application/pdf', strlen($file2), 'bericht.pdf'], [3, 'T', 'image/png', 10, 'inline.png']]; $attachments = [[1, 5, 'H', 1, null, 0], [2, 4, 'H', 2, 'Rechnung 4711.pdf', 0], [3, 2, 'H', 3, null, 1], [4, 99, 'T', 3, null, 1]]; /** Dump im gewünschten Format schreiben. */ $makeDump = function (string $format, string $prefix) use ($q, $ticketTable, $subjects, $prioOfTicket, $entries, $collab, $users, $emails, $files, $attachments, $file1, $file2): string { $path = tempnam(sys_get_temp_dir(), 'osdump') . '.sql'; $out = "-- Dump ($format)\nSET SQL_MODE = \"NO_AUTO_VALUE_ON_ZERO\";\nSTART TRANSACTION;\n\n"; $table = function (string $name, array $cols, array $rows) use (&$out, $format, $prefix, $q): void { $t = $prefix . $name; $out .= "CREATE TABLE `{$t}` (\n `id` int(11) NOT NULL\n) ENGINE=InnoDB;\n\n"; if (!$rows) return; $tuples = array_map(fn($r) => '(' . implode(', ', array_map(fn($v) => is_int($v) ? (string) $v : (is_string($v) && str_starts_with($v, '0x') ? $v : $q($v)), $r)) . ')', $rows); if ($format === 'phpmyadmin') { $out .= "INSERT INTO `{$t}` (`" . implode('`, `', $cols) . "`) VALUES\n" . implode(",\n", $tuples) . ";\n\n"; } else { $out .= "INSERT INTO `{$t}` VALUES " . implode(',', $tuples) . ";\n\n"; } }; $T = osticket_tables(); // Unrelated noise (Sitzungen mit Zeilenumbruch-freiem Datenmüll) darf nichts auslösen $out .= "INSERT INTO `{$prefix}session` (`session_id`, `session_data`) VALUES\n('abc', 'INSERT INTO `{$prefix}user` VALUES (999,1,1,0,\\'Falsch\\',NOW(),NOW())'),\n('def', '(1,2,3)');\n\n"; $table('department', $T['department'], [ [1, 0, 0, 0, 0, 0, 0, 0, 0, 'Support', '', 1, 0, 1, 1, '1', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [5, 0, 0, 0, 0, 0, 0, 0, 0, 'Buchhaltung', '', 1, 0, 1, 1, '5', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], ]); $table('help_topic', $T['help_topic'], [ [1, 0, 1, 1, 1, 0, 0, 0, 4, 1, 0, 0, 0, 0, 0, 0, 'Technisches Problem', '', '', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [2, 0, 1, 1, 1, 0, 0, 0, 2, 5, 0, 0, 0, 0, 0, 0, 'Rechnungsfrage', '', '', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], ]); $table('staff', $T['staff'], [[1, 1, 1, 'admin', 'Support', 'Admin', 'x', 'local', 'admin@example.com'], [2, 1, 1, 'weg', 'Ex', 'Kollege', 'x', 'local', 'weg@example.test']]); $table('ticket_priority', $T['ticket_priority'], [[1, 'low', 'Niedrig', '#ddd', 4, 1], [2, 'normal', 'Normal', '#ddd', 3, 1], [3, 'high', 'Hoch', '#ddd', 2, 1], [4, 'emergency', 'Notfall', '#ddd', 1, 1]]); $table('ticket_status', $T['ticket_status'], [[1, 'Offen', 'open', 3, 0, 1, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [2, 'Gelöst', 'closed', 1, 0, 2, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [3, 'Geschlossen', 'closed', 1, 0, 3, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [4, 'Archiviert', 'archived', 3, 0, 4, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], [5, 'Gelöscht', 'deleted', 3, 0, 5, '{}', '2024-01-01 00:00:00', '2024-01-01 00:00:00']]); $table('user', $T['user'], array_map(fn($u) => [$u[0], 0, $u[2], 0, $u[1], '2024-01-01 00:00:00', '2024-01-01 00:00:00'], $users)); $table('user_email', $T['user_email'], array_map(fn($e) => [$e[0], $e[1], 0, $e[2]], $emails)); $table('ticket', $T['ticket'], array_map(fn($t) => [$t[0], null, $t[1], $t[2], 0, $t[3], $t[4], 1, $t[5], $t[6], 0, 0, 0, 0, 0, '10.0.0.1', $t[7], null, $t[11], 0, null, null, null, $t[9], $t[10], $t[8], $t[10]], $ticketTable)); $table('ticket__cdata', $T['ticket__cdata'], array_map(fn($t) => [$t[0], $subjects[$t[0]], $prioOfTicket[$t[0]]], $ticketTable)); $table('thread', $T['thread'], array_map(fn($t) => [$t[0], $t[0], 'T', null, null, null, $t[8]], $ticketTable)); $table('thread_collaborator', $T['thread_collaborator'], array_map(fn($c) => [$c[0], $c[1], $c[2], $c[3], 'M', '2024-01-01 00:00:00', '2024-01-01 00:00:00'], $collab)); $table('thread_entry', $T['thread_entry'], array_map(fn($e) => [$e[0], 0, $e[1], $e[2], $e[3], $e[4], 0, $e[5], '', '', '', '', $e[6], $e[7], '10.0.0.1', null, null, $e[8], $e[8]], $entries)); $table('attachment', $T['attachment'], array_map(fn($a) => [$a[0], $a[1], $a[2], $a[3], $a[4], $a[5], null], $attachments)); $table('file', $T['file'], array_map(fn($f) => [$f[0], $f[1], 'D', $f[2], $f[3], 'key' . $f[0], 'sig' . $f[0], $f[4], null, '2024-01-01 00:00:00'], $files)); // Blob-Daten: phpMyAdmin als 0x…, mysqldump als maskierter String; Datei 1 in zwei Teilen $blob = fn(string $d) => $format === 'phpmyadmin' ? '0x' . bin2hex($d) : $q($d); $parts = [[1, 0, substr($file1, 0, 100)], [1, 1, substr($file1, 100)], [2, 0, $file2], [3, 0, 'x'], [99, 0, 'nicht gebraucht']]; foreach ($parts as $p) { if ($format === 'phpmyadmin') $out .= "INSERT INTO `{$prefix}file_chunk` (`file_id`, `chunk_id`, `filedata`) VALUES\n({$p[0]}, {$p[1]}, " . $blob($p[2]) . ");\n\n"; else $out .= "INSERT INTO `{$prefix}file_chunk` VALUES ({$p[0]},{$p[1]}," . $blob($p[2]) . ");\n"; } $out .= "\nCREATE TABLE `{$prefix}form` (\n `id` int\n);\nCOMMIT;\n"; file_put_contents($path, $out); return $path; }; // ── Import-Lauf ────────────────────────────────────────────────────────────── $runImport = function (string $dump, int $blockBytes, array $passes) use ($check): array { $pdo = get_db(); $dir = sys_get_temp_dir() . '/dh-os-state-' . bin2hex(random_bytes(4)); mkdir($dir, 0700, true); $offset = 0; $steps = 0; do { $a = osticket_analyze_step($dump, $dir, $offset, $blockBytes); $offset = $a['offset']; $steps++; } while (!$a['done'] && $steps < 100000); $results = ['analysis' => $a['counts'], 'steps' => $steps, 'passes' => [], 'notes' => []]; foreach ($passes as $pass) { $off = 0; $ins = 0; $skip = 0; $guard = 0; do { $r = osticket_import_step($pdo, $dir, $pass, $off, $dump); $ins += $r['inserted']; $skip += $r['skipped']; $off = $r['next_offset']; foreach ($r['notes'] as $n) $results['notes'][] = $n; } while (!$r['done'] && ++$guard < 10000); $results['passes'][$pass] = [$ins, $skip]; } osticket_state_clear($dir); return $results; }; $pdo = get_db(); $origUploads = config()['uploads']['path']; $allPasses = ['users', 'structure', 'tickets', 'threads', 'attachments']; foreach (['phpmyadmin' => 'ost_', 'mysqldump' => 'wf_'] as $format => $prefix) { // Frische Datenbank für jedes Format foreach (['ticket_attachments', 'ticket_collaborators', 'ticket_threads', 'tickets', 'help_topics', 'users'] as $t) $pdo->exec("DELETE FROM {$t}"); $pdo->exec("DELETE FROM departments WHERE name IN ('Support','Buchhaltung')"); $pdo->exec("DELETE FROM help_topics WHERE name IN ('Technisches Problem','Rechnungsfrage')"); $label = "[$format] "; $dump = $makeDump($format, $prefix); $res = $runImport($dump, 700, $allPasses); // winzige Blöcke: Zeilen- und Anweisungsgrenzen werden mehrfach überschritten $check($res['steps'] > 5, $label . 'Analyse läuft in vielen kleinen Schritten'); $an = $res['analysis']; $check($an['users'] === 3 && $an['departments'] === 2 && $an['topics'] === 2 && $an['tickets'] === 4 && $an['threads'] === 8 && $an['attachments'] === 2, $label . 'Analyse zählt richtig: ' . json_encode($an)); $check($res['passes']['users'] === [3, 1], $label . 'Kunden: 3 importiert, 1 ohne Adresse übersprungen: ' . json_encode($res['passes']['users'])); $check((int) $pdo->query("SELECT COUNT(*) FROM users WHERE name = 'Falsch'")->fetchColumn() === 0, $label . 'Text in anderen Tabellen wird nicht als Kunde gelesen'); $names = $pdo->query('SELECT email, name FROM users ORDER BY email')->fetchAll(PDO::FETCH_KEY_PAIR); $check(($names['pat@example.test'] ?? '') === "O'Brien, Pat (Kunde)" && ($names['eins@example.test'] ?? '') === 'Kunde Eins', $label . 'Kundennamen bleiben erhalten (auch mit Komma, Klammern, Apostroph)'); $check(($names['drei@example.test'] ?? '') === 'drei', $label . 'Kunde ohne Namen bekommt den Anfang der E-Mail-Adresse'); $check($res['passes']['structure'][0] === 2 + 2, $label . 'Abteilungen und Hilfethemen werden angelegt: ' . json_encode($res['passes']['structure'])); $dept = $pdo->query("SELECT id FROM departments WHERE name = 'Buchhaltung'")->fetchColumn(); $topic = $pdo->query("SELECT department_id, priority_id FROM help_topics WHERE name = 'Rechnungsfrage'")->fetch(); $check($dept && $topic && (int) $topic['department_id'] === (int) $dept, $label . 'Hilfethema gehört zur richtigen (importierten) Abteilung'); $check($res['passes']['tickets'] === [4, 1], $label . 'Tickets: 4 importiert (inkl. archiviertem), das gelöschte übersprungen: ' . json_encode($res['passes']['tickets'])); $tk = $pdo->query('SELECT t.*, d.name AS dept, h.name AS topic, p.name AS prio, s.email AS staff FROM tickets t JOIN departments d ON d.id = t.department_id LEFT JOIN help_topics h ON h.id = t.help_topic_id JOIN priorities p ON p.id = t.priority_id LEFT JOIN staff s ON s.id = t.assigned_staff_id')->fetchAll(); $by = array_column($tk, null, 'ticket_number'); $val = fn(string $n, string $k) => array_key_exists($n, $by) ? $by[$n][$k] : '<>'; $check(isset($by['100001'], $by['100002'], $by['100003']) && !isset($by['100004']), $label . 'gelöschtes Ticket fehlt, übrige sind da'); $check(($by['100001']['subject'] ?? '') === "Drucker 'kaputt', (dringend)" && ($by['100003']['subject'] ?? '') === 'Ünïcödé Ticket ✓' && ($by['100002']['subject'] ?? '') === 'Rechnung \\ fehlt', $label . 'Betreffs mit Sonderzeichen stimmen'); $check(($by['100001']['dept'] ?? '') === 'Support' && ($by['100002']['dept'] ?? '') === 'Buchhaltung', $label . 'Abteilung wird übernommen'); $check(($by['100001']['topic'] ?? '') === 'Technisches Problem' && ($by['100002']['topic'] ?? '') === 'Rechnungsfrage' && $val('100003', 'topic') === null, $label . 'Hilfethema wird übernommen'); $check(($by['100001']['prio'] ?? '') === 'Dringend' && ($by['100002']['prio'] ?? '') === 'Niedrig' && ($by['100003']['prio'] ?? '') === 'Normal', $label . 'Priorität wird über die Rangfolge zugeordnet'); $check(($by['100001']['status'] ?? '') === 'open' && ($by['100002']['status'] ?? '') === 'resolved' && ($by['100003']['status'] ?? '') === 'closed', $label . 'Status wird zugeordnet (offen, gelöst, geschlossen)'); $check(($by['100001']['staff'] ?? '') === 'admin@example.com' && $val('100002', 'staff') === null, $label . 'Zuweisung über gleiche E-Mail-Adresse'); $check(($by['100001']['source'] ?? '') === 'web' && ($by['100002']['source'] ?? '') === 'email' && ($by['100003']['source'] ?? '') === 'phone', $label . 'Quelle wird übernommen'); $check(($by['100002']['resolved_at'] ?? '') === '2024-01-12 10:00:00' && $val('100001', 'resolved_at') === null && ($by['100001']['updated_at'] ?? '') === '2024-01-10 09:00:00', $label . 'Zeitstempel (gelöst, zuletzt geändert)'); $check(($by['100001']['first_responded_at'] ?? '') === '2024-01-10 09:00:00' && $val('100001', 'first_response_due_at') === null, $label . 'erste Antwort wird aus der ersten Mitarbeiter-Antwort abgeleitet'); $check((int) ($by['100001']['is_overdue'] ?? 0) === 1 && ($by['100001']['requester_name'] ?? '') === 'Kunde Eins' && ($by['100001']['requester_email'] ?? '') === 'eins@example.test', $label . 'Überfällig-Flag und Anfragender'); $check(in_array('Gelöschte osTicket-Tickets wurden nicht importiert.', $res['notes'], true), $label . 'Hinweis zu gelöschten Tickets'); $check(isset($by['100005']) === true && ($by['100005']['status'] ?? '') === 'closed', $label . 'archiviertes Ticket wird als geschlossen übernommen'); $th = $pdo->query('SELECT t.ticket_number, th.author_type, th.is_internal_note, th.message, th.created_at, th.staff_id FROM ticket_threads th JOIN tickets t ON t.id = th.ticket_id ORDER BY th.created_at, th.id')->fetchAll(); $check(count($th) === 6, $label . 'Nachrichten: 6 erwartet (1 leer ohne Anhang, 1 zu gelöschtem Ticket übersprungen), gefunden ' . count($th)); $first = $th[0] ?? []; $check(str_contains((string) ($first['message'] ?? ''), "'Pro'") && str_contains((string) $first['message'], '\\\\server\\share') && str_contains((string) $first['message'], "X-1") && str_contains((string) $first['message'], "Zeile 2),(1,'x',") && !str_contains((string) $first['message'], 'zitierter'), $label . 'Kundentext mit Anführungszeichen, Backslashes und "),(" bleibt heil, Zitat wird entfernt'); $check(($first['author_type'] ?? '') === 'customer', $label . 'erste Nachricht stammt vom Kunden'); $types = array_count_values(array_map(fn($t) => $t['author_type'] . ($t['is_internal_note'] ? '-note' : ''), $th)); $check(($types['staff'] ?? 0) === 1 && ($types['staff-note'] ?? 0) === 1 && ($types['customer'] ?? 0) === 4, $label . 'Antwort, interne Notiz und Kundennachrichten richtig getrennt: ' . json_encode($types)); $reply = array_values(array_filter($th, fn($t) => $t['author_type'] === 'staff' && !$t['is_internal_note']))[0] ?? []; $check(str_contains((string) ($reply['message'] ?? ''), 'Wir kümmern uns') && (int) ($reply['staff_id'] ?? 0) > 0, $label . 'Mitarbeiter-Antwort ist dem Mitarbeiter zugeordnet'); $noText = array_values(array_filter($th, fn($t) => str_contains((string) $t['message'], 'siehe Anhang'))); $check(count($noText) === 1, $label . 'Nachricht ohne Text, aber mit Anhang, bleibt als Platzhalter erhalten'); $check((int) $pdo->query('SELECT COUNT(*) FROM ticket_collaborators')->fetchColumn() === 1, $label . 'Mitlesende werden übernommen'); // Anhänge $att = $pdo->query('SELECT a.original_name, a.stored_name, a.filesize FROM ticket_attachments a ORDER BY a.original_name')->fetchAll(); $check(count($att) === 2, $label . 'zwei Anhänge (Inline-Bilder und Vorlagen-Logos zählen nicht): ' . count($att)); $upl = rtrim((string) config()['uploads']['path'], '/') . '/'; $byName = array_column($att, null, 'original_name'); $check(isset($byName['Rechnung 4711.pdf']) && is_file($upl . $byName['Rechnung 4711.pdf']['stored_name']) && file_get_contents($upl . $byName['Rechnung 4711.pdf']['stored_name']) === $file2, $label . 'Binärer Anhang kommt byte-genau an'); $check(isset($byName['logo.png']) && file_get_contents($upl . $byName['logo.png']['stored_name']) === $file1, $label . 'Anhang aus zwei Teilen mit Anführungszeichen, Backslash und NUL kommt byte-genau an'); $check(isset($byName['Rechnung 4711.pdf']) && str_ends_with($byName['Rechnung 4711.pdf']['stored_name'], '.pdf') && preg_match('/^[a-f0-9]{32}\.(pdf|png)$/', $byName['logo.png']['stored_name'] ?? ''), $label . 'gespeicherte Dateinamen sind zufällig, Endung bleibt'); $check(!glob(sys_get_temp_dir() . '/dh-os-state-*/files/*.part') , $label . 'keine Reste von Zwischendateien'); foreach ($att as $a) @unlink($upl . $a['stored_name']); // Zweiter Lauf: nichts wird doppelt angelegt $before = [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM ticket_threads')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn()]; $res2 = $runImport($dump, 5000, ['users', 'structure', 'tickets', 'threads']); $after = [(int) $pdo->query('SELECT COUNT(*) FROM tickets')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM ticket_threads')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn(), (int) $pdo->query('SELECT COUNT(*) FROM departments')->fetchColumn()]; $check($before === $after, $label . 'wiederholter Import legt nichts doppelt an: ' . json_encode([$before, $after])); @unlink($dump); } // ── Parser im Detail ───────────────────────────────────────────────────────── $rows = iterator_to_array(osticket_rows("(1, 'a,b', NULL, 0xAB), (2, 'it\\'s (x),(y)', '''q''', 3.5, 'a\\\\b', 'zeile\\nzwei')"), false); $check($rows === [['1', 'a,b', null, '0xAB'], ['2', "it's (x),(y)", "'q'", '3.5', 'a\\b', "zeile\nzwei"]], 'Zeilenparser: Kommas, Klammern, Apostrophe, Backslashes, NULL, hex'); $check(osticket_table_key('myprefix_thread_entry') === 'thread_entry' && osticket_table_key('ost_thread_entry_email') === null && osticket_table_key('ost_user__cdata') === null && osticket_table_key('ost_ticket__cdata') === 'ticket__cdata', 'Tabellennamen mit beliebigem Präfix werden erkannt, ähnliche Tabellen nicht verwechselt'); $h = osticket_parse_header("INSERT INTO `ost_x_ticket` (`a`, `b`) VALUES (1, 'x'),(2,'y');"); $check($h[0] === 'ost_x_ticket' && $h[1] === ['a', 'b'] && str_starts_with($h[2], '(1,'), 'Kopfzeile mit Spaltenliste und Daten in derselben Zeile'); if ($failures) { fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); exit(1); } echo "osTicket import checks passed ({$total} Prüfungen).\n";