-app.zip and this file, * gwsbackup-cpanel--installer.php, to your application root folder * (the one containing gws-backup.db). * 2. Connect via SSH or cPanel Terminal and navigate to that folder. * 3. Run: php gwsbackup-cpanel--installer.php * 4. Restart your Next.js application in cPanel. * * The zip is deleted afterwards. The installer is KEPT: extraction writes the * copy shipped with the release beside it, so the next update is always run by * a script that matches the payload. * * This file is the source template. Its version reads 0.00 until `npm run pack` * stamps it; never upload it unstamped. */ /** * Updater version. Printed on every run. * * This exists because "the updater only backed up the console database" is * indistinguishable from "an older updater ran" without it. Bump it whenever the * backup or extraction behaviour changes. * * Defined BEFORE it is used: PHP 8 treats an undefined constant as a fatal error, * so a define() sitting below the banner that prints it takes the whole script * down on line one. */ /** * Stamped at pack time by apps/main-app/scripts/pack-cpanel.mjs, from the * single source of truth in lib/branding.ts. Do not edit by hand. */ define('GWSB_VERSION', '1.00'); define('GWSB_PLATFORM', 'cpanel'); define('UPDATER_VERSION', GWSB_PLATFORM . ' ' . GWSB_VERSION); /** * Terminal only, and checked before anything else runs. * * The install belongs outside public_html, so this should never be web-reachable * — but it is now left on disk between updates rather than deleted, and the * database backup runs BEFORE the payload check. A stray HTTP request would * otherwise mint a full backup zip every time it was hit, which is a way to fill * a disk from the outside. Cheap to refuse; expensive to leave open. */ if (php_sapi_name() !== 'cli') { http_response_code(404); exit; } /** * Keep the console awake (cPanel). * * Passenger starts a Node app only when a web request arrives. cPanel's nightly * maintenance restarts the web server, which stops the app, and nothing starts * it again until somebody opens a page. Until then nothing runs: no scheduled * backups, no licence checks. On staging on 23 October 2026 it stopped at about * 04:16 and stayed stopped until 05:44. * * So the installer adds one cron job that asks this console's /api/health every * five minutes, which starts it again within five minutes. The line ends with a * marker naming this app folder, so it is added once per install and only ever * replaced by this installer. Every other cron job in the account is left * exactly as it is, and a copy of them all is kept in db_backups/ first. * * Never fails the install: anything it cannot do, it prints for the operator to * do by hand. Only runs on a cPanel server, so running the installer anywhere * else (the release tests, for one) never touches that machine's cron jobs. */ function gwsb_run($command, $stdin) { $proc = @proc_open($command, array(0 => array('pipe', 'r'), 1 => array('pipe', 'w'), 2 => array('pipe', 'w')), $pipes); if (!is_resource($proc)) return array(127, '', 'could not run ' . $command); fwrite($pipes[0], $stdin); fclose($pipes[0]); $out = stream_get_contents($pipes[1]); $err = stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); return array(proc_close($proc), $out, $err); } /** This console's /api/health address, or null when it cannot be worked out. */ function gwsb_health_url() { $here = realpath(__DIR__); $uapi = getenv('GWSB_UAPI') !== false ? getenv('GWSB_UAPI') : 'uapi'; // 1. cPanel's Application Manager knows each Node app's folder and address. list($code, $out) = gwsb_run($uapi . ' --output=json PassengerApps list_applications', ''); $data = $code === 0 ? json_decode($out, true) : null; $apps = isset($data['result']['data']) ? $data['result']['data'] : (isset($data['data']) ? $data['data'] : array()); if (is_array($apps)) { foreach ($apps as $app) { if (!is_array($app) || empty($app['path']) || empty($app['domain'])) continue; if (realpath($app['path']) !== $here) continue; $base = isset($app['base_uri']) ? rtrim($app['base_uri'], '/') : ''; return 'https://' . $app['domain'] . $base . '/api/health'; } } // 2. "Setup Node.js App" (CloudLinux) writes the app folder into the // .htaccess of the domain it serves; cPanel knows each domain's folder. list($code, $out) = gwsb_run($uapi . ' --output=json DomainInfo domains_data', ''); $data = $code === 0 ? json_decode($out, true) : null; $d = isset($data['result']['data']) ? $data['result']['data'] : (isset($data['data']) ? $data['data'] : array()); $domains = array(); if (isset($d['main_domain']) && is_array($d['main_domain'])) $domains[] = $d['main_domain']; foreach (array('sub_domains', 'addon_domains') as $k) { if (isset($d[$k]) && is_array($d[$k])) $domains = array_merge($domains, $d[$k]); } foreach ($domains as $dom) { if (!is_array($dom) || empty($dom['domain']) || empty($dom['documentroot'])) continue; $ht = $dom['documentroot'] . '/.htaccess'; if (!is_readable($ht)) continue; if (!preg_match('/^\s*PassengerAppRoot\s+"?([^"\r\n]+?)"?\s*$/mi', file_get_contents($ht), $m)) continue; if (realpath(trim($m[1])) !== $here) continue; $base = preg_match('/^\s*PassengerBaseURI\s+"?([^"\r\n]+?)"?\s*$/mi', file_get_contents($ht), $b) ? rtrim(trim($b[1]), '/') : ''; return 'https://' . $dom['domain'] . $base . '/api/health'; } // 3. The console's own address, if .env pins one. Only that line is read. $env = __DIR__ . '/.env'; if (is_readable($env) && preg_match('/^\s*NEXTAUTH_URL\s*=\s*["\']?(https?:\/\/[^"\'\s]+)/m', file_get_contents($env), $m)) { return rtrim($m[1], '/') . '/api/health'; } return null; } function gwsb_keep_awake_by_hand($reason) { echo ">> Keep-awake: NOT set up ($reason).\n"; echo " Add this cron job yourself, in cPanel -> Cron Jobs, to run every 5 minutes:\n\n"; echo " curl -fsS -o /dev/null --max-time 60 https://YOUR-CONSOLE-ADDRESS/api/health\n\n"; echo " Without it, the console stops whenever cPanel restarts the web server, and\n"; echo " scheduled backups wait until somebody opens it.\n\n"; } function gwsb_keep_awake() { if (!is_dir('/usr/local/cpanel') && getenv('GWSB_KEEPAWAKE_FORCE') !== '1') { echo ">> Keep-awake: not a cPanel server, so there is no cron job to add.\n\n"; return; } if (!function_exists('proc_open')) { gwsb_keep_awake_by_hand('PHP may not run commands here'); return; } $url = gwsb_health_url(); if ($url === null) { gwsb_keep_awake_by_hand("this console's web address could not be found"); return; } if (strpos($url, '%') !== false || strpos($url, "'") !== false) { gwsb_keep_awake_by_hand('the address has characters cron cannot take'); return; } $marker = '# gws-backup keep-awake ' . realpath(__DIR__); $line = "*/5 * * * * curl -fsS -o /dev/null --max-time 60 '" . $url . "' >/dev/null 2>&1 " . $marker; $crontab = getenv('GWSB_CRONTAB') !== false ? getenv('GWSB_CRONTAB') : 'crontab'; list($code, $current, $err) = gwsb_run($crontab . ' -l', ''); if ($code !== 0) { // An account with no cron jobs at all answers "no crontab for ". if (stripos($err . $current, 'no crontab') === false) { gwsb_keep_awake_by_hand("the account's cron jobs could not be read"); return; } $current = ''; } $lines = $current === '' ? array() : explode("\n", rtrim($current, "\n")); $others = array(); $ours = array(); foreach ($lines as $l) { if (substr(rtrim($l), -strlen($marker)) === $marker) $ours[] = $l; else $others[] = $l; } if (count($ours) === 1 && rtrim($ours[0]) === $line) { echo ">> Keep-awake: already set up (every 5 minutes, $url).\n\n"; return; } $backupDir = __DIR__ . '/db_backups'; if (!is_dir($backupDir)) @mkdir($backupDir, 0755, true); $copy = $backupDir . '/crontab-before-' . date('Y-m-d_H-i-s') . '.txt'; @file_put_contents($copy, $current); @chmod($copy, 0600); $next = implode("\n", array_merge($others, array($line))) . "\n"; list($code, , $err) = gwsb_run($crontab . ' -', $next); if ($code !== 0) { gwsb_keep_awake_by_hand('crontab refused the change: ' . trim($err)); return; } list($code, $after) = gwsb_run($crontab . ' -l', ''); if ($code !== 0 || rtrim($after, "\n") !== rtrim($next, "\n")) { echo ">> Keep-awake: [WARNING] the cron jobs read back differently from what was written.\n"; echo " Check them in cPanel -> Cron Jobs. The previous set is saved in " . basename($copy) . ".\n\n"; return; } echo ">> Keep-awake: " . (count($ours) ? 'updated' : 'added') . " a cron job asking $url every 5 minutes,\n"; echo " so the console starts again within 5 minutes if the web server stops it.\n"; echo " Your other cron jobs are unchanged; the previous set is saved in db_backups/" . basename($copy) . ".\n\n"; } echo "========================================\n"; echo " SYSTEM UPDATE WIZARD \n"; echo "========================================\n"; echo " updater " . UPDATER_VERSION . "\n"; echo "========================================\n\n"; // 1. Locate the native persistent database $dbPath = __DIR__ . '/gws-backup.db'; if (!file_exists($dbPath)) { $dbPath = __DIR__ . '/dev.db'; } if (!file_exists($dbPath)) { $dbPath = __DIR__ . '/apps/main-app/dev.db'; } if (!file_exists($dbPath)) { $dbPath = __DIR__ . '/apps/main-app/prod.db'; } // The frozen index archive sits beside the main database and is part of it. $archivePath = preg_replace('/\.db$/', '-archive.db', $dbPath); // Since ROADMAP §7 R1 the backup index is one file per client under clients/. // After the split the console database holds barely any backup data at all, so a // "database backup" that omits these is a backup of the settings and nothing else. $clientDbPaths = array(); $clientsDir = dirname($dbPath) . '/clients'; if (is_dir($clientsDir)) { foreach (glob($clientsDir . '/*.db') as $clientDb) { $clientDbPaths[] = $clientDb; } } if (file_exists($dbPath)) { echo ">> 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. * * The installer now ships INSIDE the payload, so the extraction above has * just written 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 a payload 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"; gwsb_keep_awake(); echo "----------------------------------------\n"; echo "[SUCCESS] GWS Backup " . GWSB_VERSION . " is unpacked.\n"; echo "----------------------------------------\n"; echo "Your databases were backed up first. Your data, licence and settings are unchanged.\n\n"; echo "*** ONE THING LEFT ***\n"; echo "Restart the app so the new version runs: Restart on this Node.js application in cPanel\n"; echo "(Setup Node.js App or Application Manager). An update installed from the console restarts by itself.\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); } ?>