> Found existing database at: " . basename($dbPath) . "\n"; $size = filesize($dbPath); if (file_exists($archivePath)) { $archiveSize = filesize($archivePath); echo ">> Found frozen index archive: " . basename($archivePath) . " (" . number_format($archiveSize / 1048576, 1) . " MB)\n"; $size += $archiveSize; } if (count($clientDbPaths) > 0) { $clientTotal = 0; foreach ($clientDbPaths as $clientDb) { $clientTotal += filesize($clientDb); } echo ">> Found " . count($clientDbPaths) . " per-client index database(s) (" . number_format($clientTotal / 1048576, 1) . " MB)\n"; $size += $clientTotal; } else { // Silence here was the whole problem: an operator could not tell a correct // "this install has not been split yet" from a broken "the updater cannot // see the client databases". Say which it is, and where we looked. echo ">> No per-client index databases found in: " . $clientsDir . "\n"; echo " If this install HAS been split into per-client databases, stop now —\n"; echo " the backup below would cover the console database only.\n"; } $free = disk_free_space(__DIR__); // Safety check: Require at least 2x the DB size for safety, plus 250MB for the unzipped payload $requiredSpace = ($size * 2) + 250000000; if ($free < $requiredSpace) { echo "\n[ERROR] Insufficient Storage Quota\n"; echo "You need at least " . number_format($requiredSpace / 1024 / 1024, 2) . " MB of free disk space to backup the database and extract the update.\n"; echo "Please delete some old files or upgrade your cPanel storage, then try again.\n"; die(1); } $backupDir = __DIR__ . '/db_backups'; if (!is_dir($backupDir)) { mkdir($backupDir, 0755, true); } // Append precise timestamp to prevent backup collisions $timestamp = date('Y-m-d_H-i-s'); $zipBackupName = $backupDir . '/backup_db_' . $timestamp . '.zip'; echo ">> Creating compressed database backup (including WAL/SHM files)...\n"; $backupZip = new ZipArchive(); if ($backupZip->open($zipBackupName, ZipArchive::CREATE | ZipArchive::OVERWRITE) === TRUE) { // Every file that makes up the database, not just the main one: // - the -wal holds committed pages not yet checkpointed, so the .db alone // can be minutes out of date; // - the -archive.db holds the FROZEN index of retired destinations, which // includes items that exist nowhere else and are never regenerated. // Missing either one means the "backup" cannot restore the install. // - clients/.db is that client's entire backup catalogue (§7 R1). foreach (array($dbPath, $archivePath) as $file) { if (!file_exists($file)) continue; $backupZip->addFile($file, basename($file)); foreach (array('-wal', '-shm') as $sidecar) { if (file_exists($file . $sidecar)) { $backupZip->addFile($file . $sidecar, basename($file) . $sidecar); } } } foreach ($clientDbPaths as $file) { $backupZip->addFile($file, 'clients/' . basename($file)); foreach (array('-wal', '-shm') as $sidecar) { if (file_exists($file . $sidecar)) { $backupZip->addFile($file . $sidecar, 'clients/' . basename($file) . $sidecar); } } } $backupZip->close(); echo "[OK] Compressed database backup generated at: $zipBackupName\n\n"; } else { echo "\n[ERROR] Database Backup Failed\n"; echo "The script was unable to create the ZIP archive. Please check directory permissions or PHP ZipArchive extension.\n"; die(1); } } else { echo ">> [WARNING] Existing database not found. Treating as fresh installation or database is stored elsewhere.\n\n"; } // 2. Locate the delivery payload // // Found by pattern rather than a fixed name, because every release ships as // gwsbackup---app.zip. The version in the filename is not // decoration: it is checked against this installer's own version below, so a // payload can never be extracted by the wrong installer. $candidates = glob(__DIR__ . '/gwsbackup-*-app.zip'); if (!$candidates) { // Releases before versioned naming. $legacy = __DIR__ . '/update.zip'; if (file_exists($legacy)) $candidates = array($legacy); } if (!$candidates) { echo "[ERROR] No release payload found\n"; echo "Expected a file named gwsbackup-" . GWSB_PLATFORM . "-" . GWSB_VERSION . "-app.zip\n"; echo "in this folder (" . __DIR__ . "). Upload it and run this again.\n"; die(1); } if (count($candidates) > 1) { // Guessing here is how the wrong build gets installed. Stop and let a human // decide which one they meant. echo "[ERROR] More than one payload is present\n"; foreach ($candidates as $c) echo " - " . basename($c) . "\n"; echo "Delete the ones you do not want, leaving a single .zip, then run this again.\n"; die(1); } $zipFile = $candidates[0]; $payloadName = basename($zipFile); // Version gate. The whole point of the versioned filenames is that this check // can exist: an operator with several releases in their app root cannot run an // old installer against a new payload without being told. if (preg_match('/^gwsbackup-([a-z0-9]+)-([0-9][0-9.]*)-app\.zip$/i', $payloadName, $m)) { if (strcasecmp($m[1], GWSB_PLATFORM) !== 0) { echo "[ERROR] Wrong platform\n"; echo "This installer is for '" . GWSB_PLATFORM . "', but " . $payloadName . " is for '" . $m[1] . "'.\n"; die(1); } if ($m[2] !== GWSB_VERSION) { echo "[ERROR] Installer and payload do not match\n"; echo " payload: " . $payloadName . " (version " . $m[2] . ")\n"; echo " installer: " . basename(__FILE__) . " (version " . GWSB_VERSION . ")\n\n"; echo "Run the installer that came with that payload:\n"; echo " php gwsbackup-" . $m[1] . "-" . $m[2] . "-installer.php\n\n"; echo "Both files are on the download page, and both are listed with a checksum.\n"; die(1); } } else { echo ">> Payload '" . $payloadName . "' predates versioned naming; extracting without a version check.\n"; } echo ">> Payload: " . $payloadName . "\n"; $zip = new ZipArchive; if ($zip->open($zipFile) === TRUE) { echo ">> Extracting payload into production scope...\n"; echo ">> Removing stale .next build cache...\n"; function rmrf($dir) { if (!is_dir($dir)) return; foreach (scandir($dir) as $item) { if ($item == '.' || $item == '..') continue; is_dir("$dir/$item") ? rmrf("$dir/$item") : unlink("$dir/$item"); } rmdir($dir); } rmrf(__DIR__ . '/.next'); // Extract over current files (the SQLite DB is preserved natively because it does not exist in the .zip) if (!$zip->extractTo(__DIR__)) { echo "\n[ERROR] Extraction Failed!\n"; echo "Unable to extract the ZIP. Please check cPanel permissions or manually unzip.\n"; die(1); } $zip->close(); echo "\n>> Running database backup inventory...\n"; $backupPath = __DIR__ . '/db_backups'; $dbFiles = array_merge((array)glob($backupPath . '/*.db'), (array)glob($backupPath . '/*.zip')); $totalDBSize = 0; $dbCount = 0; if (!empty($dbFiles)) { foreach ($dbFiles as $file) { if (is_file($file)) { $totalDBSize += filesize($file); $dbCount++; } } } if ($dbCount > 0) { echo " [!] Notice: You currently have $dbCount database backup(s) stored in: '$backupPath'\n"; echo " [!] Total historical backup footprint: " . number_format($totalDBSize / 1024 / 1024, 2) . " MB.\n"; echo " [!] Reminder: These backups consume host server space logarithmically. Once you have verified the system is stable, delete older backups via SSH or FTP.\n\n"; } /** * The payload goes; the updater STAYS. * * It used to delete itself, which meant every update needed a freshly * downloaded update.php — and reusing an old copy that was still lying around * silently ran old behaviour. That is exactly how an install ended up backing * up the console database and none of the per-client ones: the script that ran * predated per-client support, and nothing said so. * * update.php now ships INSIDE update.zip, so the extraction above has just * replaced this file with the version that matches the release. Leaving it in * place means the next update is always run by a current script. It is inert * without an update.zip beside it, and refuses to run over HTTP. */ @unlink($zipFile); echo "[OK] Payload '" . $payloadName . "' removed.\n"; /** * Sweep up older installers. * * Extraction has just written this release's installer beside us. Any OTHER * gwsbackup-*-installer.php in this folder is from a previous release, and * leaving them is how somebody ends up running last month's script against * this month's payload — the version gate would catch it, but only after * they had wasted a step. The current one stays; the rest go. * * Pre-versioned update.php goes too, for the same reason. */ $keep = __DIR__ . '/gwsbackup-' . GWSB_PLATFORM . '-' . GWSB_VERSION . '-installer.php'; $stale = glob(__DIR__ . '/gwsbackup-*-installer.php'); if (file_exists(__DIR__ . '/update.php')) $stale[] = __DIR__ . '/update.php'; $removed = 0; foreach ($stale as $old) { if (realpath($old) === realpath($keep)) continue; if (realpath($old) === realpath(__FILE__)) continue; // never the running script if (@unlink($old)) { $removed++; echo "[OK] Removed superseded installer: " . basename($old) . "\n"; } } echo "[OK] Kept " . basename($keep) . " for the next update.\n"; if ($removed === 0) echo " No superseded installers to clear.\n"; echo "\n"; echo "----------------------------------------\n"; echo "[SUCCESS] Update Pipeline Concluded!\n"; echo "----------------------------------------\n"; echo "The application layer has been upgraded. Your native SQLite polyfill will attach during the next boot sequence.\n\n"; echo "*** ACTION REQUIRED ***\n"; echo "Please restart the Node.js process from your cPanel App Manager or CLI PM2 instance to load the patched compiler nodes into memory.\n\n"; } else { echo "\n[ERROR] Corrupt Zip File\n"; echo "PHP was unable to read the update archive. Please re-upload it.\n"; die(1); } ?>