PHP Classes

File: scripts/rescan_kept_packets.php

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   scripts/rescan_kept_packets.php   Download  
File: scripts/rescan_kept_packets.php
Role: Example script
Content type: text/plain
Description: Example script
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 17,183 bytes
 

Contents

Class file image Download
#!/usr/bin/env php <?php /** * rescan_kept_packets.php * * Scans the kept packet directory for messages not present in the database * and optionally reimports them. * * Also supports --preview mode which runs both the production null-terminated * parser and the experimental fixed-width parser side by side, showing field * values and MSGIDs from each without importing anything. Use this to validate * the fixed-width parser before enabling it in production. * * Usage: * php scripts/rescan_kept_packets.php [options] * * Options: * --reimport Import missing messages found during the scan. * date_received is set to the .pkt file's mtime. * --dir=PATH Override the kept packet directory (default: data/inbound/keep). * --file=PATH Scan a single specific .pkt file instead of a directory. * --dry-run Report only; do not import even if --reimport is given. * --verbose Show details for every message checked, not just missing ones. * --preview Show field values from both parsers for packets with missing * messages. Does not import. Use to validate the fixed-width parser. * --help Show this help text. */ require_once __DIR__ . '/../vendor/autoload.php'; require_once __DIR__ . '/../src/functions.php'; use BinktermPHP\Database; use BinktermPHP\BinkdProcessor; // --------------------------------------------------------------------------- // Argument parsing // --------------------------------------------------------------------------- $opts = [ 'reimport' => false, 'dry-run' => false, 'verbose' => false, 'preview' => false, 'dir' => null, 'file' => null, ]; foreach (array_slice($argv, 1) as $arg) { if ($arg === '--reimport') { $opts['reimport'] = true; } elseif ($arg === '--dry-run') { $opts['dry-run'] = true; } elseif ($arg === '--verbose') { $opts['verbose'] = true; } elseif ($arg === '--preview') { $opts['preview'] = true; } elseif ($arg === '--help') { showHelp(); exit(0); } elseif (str_starts_with($arg, '--dir=')) { $opts['dir'] = substr($arg, 6); } elseif (str_starts_with($arg, '--file=')) { $opts['file'] = substr($arg, 7); } else { fwrite(STDERR, "Unknown option: $arg\n"); showHelp(); exit(1); } } function showHelp(): void { global $argv; echo "Usage: {$argv[0]} [--reimport] [--dry-run] [--verbose] [--dir=PATH] [--file=PATH]\n"; echo "\n"; echo " --reimport Import messages missing from the database.\n"; echo " date_received is set to the .pkt file modification time.\n"; echo " --dry-run Report without importing (overrides --reimport).\n"; echo " --verbose Show all messages checked, not just missing ones.\n"; echo " --preview For packets with missing messages, show what the fixed-width\n"; echo " parser would import (field values, MSGID) without importing.\n"; echo " --dir=PATH Kept packet directory (default: data/inbound/keep).\n"; echo " --file=PATH Scan a single specific .pkt file.\n"; } $baseDir = dirname(__DIR__); $db = Database::getInstance()->getPdo(); // --------------------------------------------------------------------------- // Discover .pkt files ? either a single file or a directory tree // --------------------------------------------------------------------------- $packets = []; if ($opts['file'] !== null) { $filePath = $opts['file']; if (!file_exists($filePath)) { fwrite(STDERR, "File not found: $filePath\n"); exit(1); } if (strtolower(pathinfo($filePath, PATHINFO_EXTENSION)) !== 'pkt') { fwrite(STDERR, "File does not appear to be a .pkt file: $filePath\n"); exit(1); } $packets[] = realpath($filePath); } else { $keepDir = $opts['dir'] ?? ($baseDir . '/data/inbound/keep'); if (!is_dir($keepDir)) { fwrite(STDERR, "Kept packet directory not found: $keepDir\n"); exit(1); } $iter = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($keepDir)); foreach ($iter as $file) { if ($file->isFile() && strtolower($file->getExtension()) === 'pkt') { $packets[] = $file->getPathname(); } } sort($packets); } $scanTarget = $opts['file'] ?? ($opts['dir'] ?? ($baseDir . '/data/inbound/keep')); if (empty($packets)) { echo "No .pkt files found in $scanTarget\n"; exit(0); } echo "Found " . count($packets) . " packet(s) in $scanTarget\n"; // --------------------------------------------------------------------------- // Per-packet scan // --------------------------------------------------------------------------- $totalMissing = 0; $totalFound = 0; $affectedPkts = 0; $reimportStats = ['imported' => 0, 'failed' => 0]; foreach ($packets as $pktPath) { $pktName = basename($pktPath); $pktMtime = filemtime($pktPath); $pktDate = date('Y-m-d H:i:s', $pktMtime); // UTC for display (server stores UTC) $messages = scanPacket($pktPath); if ($messages === null) { echo "[$pktName] ERROR: Could not parse packet ? skipping\n"; continue; } $missing = []; foreach ($messages as $msg) { $inDb = messageExistsInDb($db, $msg); if ($inDb) { $totalFound++; if ($opts['verbose']) { echo "[$pktName] OK " . formatMsgSummary($msg) . "\n"; } } else { $totalMissing++; $missing[] = $msg; echo "[$pktName] MISS " . formatMsgSummary($msg) . "\n"; } } if (!empty($missing)) { $affectedPkts++; echo " -> " . count($missing) . " missing / " . count($messages) . " total" . " | pkt mtime: $pktDate UTC\n"; if ($opts['preview']) { $fwMessages = scanPacket($pktPath, 'fixed-width'); $gdMessages = scanPacket($pktPath, 'gap-detect'); $count = max(count($messages), count($fwMessages ?? []), count($gdMessages ?? [])); echo " -> Parser comparison (null-term / fixed-width / gap-detect):\n"; $fields = ['area', 'fromName', 'toName', 'subject', 'msgid']; for ($i = 0; $i < $count; $i++) { $nt = $messages[$i] ?? null; $fw = ($fwMessages ?? [])[$i] ?? null; $gd = ($gdMessages ?? [])[$i] ?? null; $hasDiff = false; foreach ($fields as $f) { $ntv = $nt[$f] ?? '(missing)'; $fwv = $fw[$f] ?? '(missing)'; $gdv = $gd[$f] ?? '(missing)'; if ($ntv !== $fwv || $ntv !== $gdv || $opts['verbose']) { if (!$hasDiff) { echo sprintf(" #%d\n", $i + 1); $hasDiff = true; } $marker = ($ntv !== $fwv || $ntv !== $gdv) ? ' ***' : ''; echo sprintf(" %-10s nt=%-30s fw=%-30s gd=%s%s\n", $f . ':', mb_substr(json_encode($ntv), 0, 30), mb_substr(json_encode($fwv), 0, 30), mb_substr(json_encode($gdv), 0, 30), $marker); } } if ($hasDiff) { $allAgree = true; foreach ($fields as $f) { if (($nt[$f] ?? null) !== ($gd[$f] ?? null)) { $allAgree = false; break; } } echo $allAgree ? " (nt and gd agree)\n" : " ^^^ PARSERS DIFFER\n"; } } } if ($opts['reimport'] && !$opts['dry-run']) { $receivedDate = gmdate('Y-m-d H:i:s', $pktMtime); echo " -> Reimporting packet with date_received=$receivedDate ?\n"; try { $processor = new BinkdProcessor(); $processor->receivedDateOverride = $receivedDate; $result = $processor->processKeptPacket($pktPath); $reimportStats['imported'] += $result['imported']; $reimportStats['failed'] += $result['failed']; echo " -> Done: {$result['imported']} imported, {$result['failed']} failed\n"; } catch (\Exception $e) { echo " -> FAILED: " . $e->getMessage() . "\n"; } } elseif ($opts['reimport'] && $opts['dry-run']) { echo " -> (dry-run: would reimport)\n"; } echo "\n"; } } // --------------------------------------------------------------------------- // Summary // --------------------------------------------------------------------------- echo str_repeat('-', 60) . "\n"; echo "Packets scanned : " . count($packets) . "\n"; echo "Packets affected: $affectedPkts\n"; echo "Messages found : $totalFound\n"; echo "Messages missing: $totalMissing\n"; if ($opts['reimport'] && !$opts['dry-run']) { echo "Messages imported: {$reimportStats['imported']}\n"; echo "Import failures : {$reimportStats['failed']}\n"; } // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- /** * Parse a .pkt file and return an array of message summaries for DB lookup. * * @param string $mode 'null-term' (default), 'fixed-width', or 'gap-detect' * @return array[]|null Array of message arrays, or null on parse failure. */ function scanPacket(string $path, string $mode = 'null-term'): ?array { $handle = fopen($path, 'rb'); if (!$handle) { return null; } // Skip 58-byte packet header $header = fread($handle, 58); if (strlen($header) < 58) { fclose($handle); return null; } $messages = []; while (!feof($handle)) { $typeRaw = fread($handle, 2); if (strlen($typeRaw) < 2) { break; } $type = unpack('v', $typeRaw)[1]; if ($type === 0) { break; // End of packet } if ($type !== 2) { break; // Unexpected type ? stop } // Fixed 12-byte message header (origNode, destNode, origNet, destNet, attr, cost) $fixedHeader = fread($handle, 12); if (strlen($fixedHeader) < 12) { break; } if ($mode === 'fixed-width') { $dateTime = readFixedWidth($handle, 20); $toName = readFixedWidth($handle, 36); $fromName = readFixedWidth($handle, 36); $subject = readFixedWidth($handle, 72); } elseif ($mode === 'gap-detect') { [$dateTime, $toName, $fromName, $subject] = readFieldsGapDetect($handle); } else { $dateTime = readFixed($handle); $toName = readFixed($handle); $fromName = readFixed($handle); $subject = readFixed($handle); } // Variable-length null-terminated message body $body = ''; while (($ch = fread($handle, 1)) !== false && $ch !== '' && ord($ch) !== 0) { $body .= $ch; } // Extract AREA: tag (first line of body for echomail) $area = null; $msgid = null; $lines = preg_split('/[\r\n]+/', $body); if (isset($lines[0]) && str_starts_with($lines[0], 'AREA:')) { $area = strtoupper(trim(substr($lines[0], 5))); } foreach ($lines as $line) { if (str_starts_with($line, "\x01MSGID:")) { $msgid = trim(substr($line, 7)); break; } } $messages[] = [ 'msgid' => $msgid, 'area' => $area, 'fromName' => $fromName, 'toName' => $toName, 'subject' => $subject, 'dateTime' => $dateTime, ]; } fclose($handle); return $messages; } /** * Read a null-terminated string field. * Matches the parser BinkdProcessor uses for import so MSGID comparisons align. */ function readFixed($handle): string { $string = ''; while (($char = fread($handle, 1)) !== false && $char !== '' && ord($char) !== 0) { $string .= $char; } return $string; } /** * Read an FTS-0001 fixed-width string field (experimental fixed-width parser). * Reads exactly $len bytes, trims at the first null, and seeks back if bytes * after the null are non-zero (non-padded mailer whose next field follows immediately). */ function readFixedWidth($handle, int $len): string { $raw = fread($handle, $len); if ($raw === false || $raw === '') { return ''; } $pos = strpos($raw, "\0"); if ($pos === false) { return $raw; } $afterNull = substr($raw, $pos + 1); if ($afterNull !== '' && ltrim($afterNull, "\0") !== '') { fseek($handle, -strlen($afterNull), SEEK_CUR); } return substr($raw, 0, $pos); } /** * Read all four FTS-0001 fixed-size string fields using gap-detection. * * Reads each field null-terminated, then checks whether the bytes between the * consumed position and the 164-byte field block boundary are all zeros. If so, * they are zero-padding from a spec-compliant mailer and are consumed. If any * byte is non-zero, they are body content (non-padded mailer) and are left in * the stream. Returns [dateTime, toName, fromName, subject]. */ function readFieldsGapDetect($handle): array { $prePos = ftell($handle); $dateTime = readFixed($handle); $toName = readFixed($handle); $fromName = readFixed($handle); $subject = readFixed($handle); $consumed = ftell($handle) - $prePos; $expected = 20 + 36 + 36 + 72; // 164 $gap = $expected - $consumed; if ($gap > 0) { $gapBytes = fread($handle, $gap); if ($gapBytes !== false && ltrim($gapBytes, "\0") !== '') { // Non-zero bytes in gap ? body content, not padding; seek back fseek($handle, -strlen($gapBytes), SEEK_CUR); } // All zeros ? padding consumed correctly } return [$dateTime, $toName, $fromName, $subject]; } /** * Strip bytes that would cause PostgreSQL UTF-8 encoding errors. * Packets may contain raw CP437/Latin-1 bytes; we only need these strings * for lookup comparison, not for storage. */ function sanitizeForDb(string $s): string { // Replace invalid UTF-8 sequences with '?' $clean = mb_convert_encoding($s, 'UTF-8', 'UTF-8'); // Strip control characters (except tab/newline) that Postgres rejects return preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $clean); } /** * Check whether a scanned message exists in the database. * Matches by MSGID when available, otherwise by (from_name, subject, area/type). */ function messageExistsInDb(\PDO $db, array $msg): bool { if (!empty($msg['msgid'])) { $msgid = sanitizeForDb($msg['msgid']); if ($msg['area'] !== null) { // Echomail: MSGID + echoarea tag $stmt = $db->prepare(" SELECT 1 FROM echomail em JOIN echoareas ea ON ea.id = em.echoarea_id WHERE em.message_id = ? AND UPPER(ea.tag) = UPPER(?) LIMIT 1 "); $stmt->execute([$msgid, $msg['area']]); } else { // Netmail: MSGID only (no area) $stmt = $db->prepare(" SELECT 1 FROM netmail WHERE message_id = ? LIMIT 1 "); $stmt->execute([$msgid]); } return (bool) $stmt->fetchColumn(); } // No MSGID ? fall back to from_name + subject match (less reliable) $fromName = sanitizeForDb($msg['fromName']); $subject = sanitizeForDb($msg['subject']); if ($msg['area'] !== null) { $stmt = $db->prepare(" SELECT 1 FROM echomail em JOIN echoareas ea ON ea.id = em.echoarea_id WHERE em.from_name = ? AND em.subject = ? AND UPPER(ea.tag) = UPPER(?) LIMIT 1 "); $stmt->execute([$fromName, $subject, $msg['area']]); } else { $stmt = $db->prepare(" SELECT 1 FROM netmail WHERE from_name = ? AND subject = ? LIMIT 1 "); $stmt->execute([$fromName, $subject]); } return (bool) $stmt->fetchColumn(); } /** * Format a one-line message summary for console output. */ function formatMsgSummary(array $msg): string { $area = $msg['area'] ? '[' . $msg['area'] . '] ' : '[netmail] '; $msgid = $msg['msgid'] ? 'MSGID:' . sanitizeForDb($msg['msgid']) : '(no msgid)'; $fromName = preg_replace('/[^\x20-\x7E]/', '?', $msg['fromName']); $subject = preg_replace('/[^\x20-\x7E]/', '?', $msg['subject']); return sprintf('%s%-20s %-40s %s', $area, mb_substr($fromName, 0, 20), mb_substr($subject, 0, 40), $msgid ); }