-" and nothing else. */ public const NAME_PATTERN = '[A-Za-z0-9._-]+-\d{8}-\d{6}'; /** * The folder for a backup name that came in over HTTP, or null. The name is * checked against the pattern *and* the resolved path is checked to sit * inside the backups folder: the second guard holds even if the first is * ever loosened. */ public function backupPath(string $name): ?string { if (! preg_match('/^'.self::NAME_PATTERN.'$/', $name)) { return null; } $dir = realpath($this->backupsDir()); $path = realpath($this->backupsDir().'/'.$name); if ($dir === false || $path === false || ! is_dir($path) || ! str_starts_with($path, $dir.DIRECTORY_SEPARATOR)) { return null; } return $path; } /** * One backup as a zip in storage/app, for the operator to take off the * server. The caller deletes it once sent. Everything under the folder goes * in as "/…": the backup was already filtered when it was taken. */ public function zipBackup(string $name): string { $path = $this->backupPath($name); if ($path === null) { throw new \RuntimeException("No backup named {$name}."); } $archive = storage_path('app/update-dl-'.Str::random(8).'.zip'); $zip = new \ZipArchive; if ($zip->open($archive, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { throw new \RuntimeException('Could not create the archive.'); } $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)); foreach ($files as $file) { if ($file->isFile() && ! $file->isLink()) { $zip->addFile($file->getPathname(), $name.'/'.Str::after($file->getPathname(), $path.DIRECTORY_SEPARATOR)); } } $zip->close(); return $archive; } /** * The previous versions still on disk, newest first. The newest few are kept * (Settings → General, PHAROS_KEEP_BACKUPS); the oldest go when a new one is made. * * @return list */ public function backups(): array { $dir = $this->backupsDir(); if (! is_dir($dir)) { return []; } $backups = array_map(function (string $path) { $name = basename($path); // Folders are named "-" by apply(); anything else is // shown as-is rather than hidden. preg_match('/^(.*)-(\d{8}-\d{6})$/', $name, $m); $created = isset($m[2]) ? Carbon::createFromFormat('Ymd-His', $m[2]) : Carbon::createFromTimestamp(filemtime($path)); return ['name' => $name, 'version' => $m[1] ?? $name, 'created_at' => $created, 'size' => $this->treeSize($path)]; }, File::directories($dir)); usort($backups, fn ($a, $b) => $b['created_at'] <=> $a['created_at']); return $backups; } /** * The version about to be replaced, kept whole so it can be put back as one * piece: code and vendor together, plus a consistent copy of the SQLite * database — the migrations that follow are what make old code unusable. * Secrets, storage and uploads are left out: an update never touches them, * and storage is where the backups themselves live. */ /** Names removed by the last prune, for the flash and the audit line. */ protected array $pruned = []; public function pruned(): array { return $this->pruned; } /** * Keep the newest N backups (Settings → General, else PHAROS_KEEP_BACKUPS; 0 = all). $keep names are * never removed whatever their age — a rollback protects its own target, * otherwise its safety copy could push it out of the window mid-restore. * * @return list what was removed, oldest last */ public function prune(array $keep = []): array { $limit = InstallSettings::keepBackups(); $this->pruned = []; if ($limit < 1) { return []; } $kept = 0; foreach ($this->backups() as $backup) { // newest first if (in_array($backup['name'], $keep, true) || $kept++ < $limit) { continue; } File::deleteDirectory($this->backupsDir().'/'.$backup['name']); $this->pruned[] = $backup['name']; } return $this->pruned; } public function backupCurrent(?string $from = null, ?string $database = null, array $keep = []): string { $from ??= config('pharos.update.backup_source') ?? base_path(); $skip = ['.env', 'storage', 'public/storage', 'node_modules', 'database/database.sqlite']; // The first sample replaces whatever the previous backup left behind. $sample = ['state' => 'running', 'stage' => 'counting', 'done' => 0, 'total' => 0, 'name' => null, 'message' => null, 'started_at' => now()->toIso8601String()]; $this->report($sample); try { $backup = $this->backupsDir().'/'.$this->updater->current().'-'.now()->format('Ymd-His'); File::ensureDirectoryExists($backup); // One tick per file, plus one for the database step: the bar only // fills once the copy is consistent, not once the files are in. $sample = [...$sample, 'name' => basename($backup), 'total' => $this->countTree($from, $skip) + 1]; [$tick, $stage] = $this->ticker($sample); // vendor/ is most of the files, so it is a stage of its own. $stage('code'); $this->copyTree($from, $backup, [...$skip, 'vendor'], $tick); if (is_dir($from.'/vendor')) { $stage('vendor'); $this->copyTree($from.'/vendor', $backup.'/vendor', [], $tick); } $stage('database'); $this->backupDatabase($backup, $database); $sample['done']++; $stage('finishing'); $this->report([...$sample, 'state' => 'done', 'message' => Bytes::human($this->treeSize($backup))]); $this->prune($keep); return $backup; } catch (\Throwable $e) { $this->report([...$sample, 'state' => 'failed', 'message' => $e->getMessage()]); throw $e; } } /** Where the Updates screen reads how far a running backup is. */ public const PROGRESS_KEY = 'pharos.backup.progress'; /** Seconds; outlives any backup that is still going, then the entry can go. */ public const PROGRESS_TTL = 600; /** Files between two samples: often enough to move, rare enough to be free. */ protected const TICK_EVERY = 25; /** * The last sample: running, done or failed — or idle when there is none. * * @return array{state:string,stage?:string,done?:int,total?:int,name?:?string,message?:?string,started_at?:string} */ public function progress(): array { return Cache::get(self::PROGRESS_KEY) ?? ['state' => 'idle']; } /** Set while the safety copy runs inside a rollback: its samples show as that one stage. */ protected ?string $within = null; protected function report(array $sample): void { if ($this->within !== null && $sample['state'] !== 'failed') { $sample = [...$sample, 'state' => 'running', 'stage' => $this->within]; } Cache::put(self::PROGRESS_KEY, $sample, self::PROGRESS_TTL); } /** * The two hands that move a sample along: tick() once per file, stage() * when a new part begins. Both write into the array they were given. * * @return array{0:\Closure,1:\Closure} */ protected function ticker(array &$sample): array { $tick = function () use (&$sample) { if (++$sample['done'] % self::TICK_EVERY === 0) { $this->report($sample); } }; $stage = function (string $stage) use (&$sample) { $sample['stage'] = $stage; $this->report($sample); }; return [$tick, $stage]; } /** * SQLite only: VACUUM INTO on a connection of its own writes a self-contained, * consistent file even mid-write (WAL included) — the app's own connection may * sit inside a transaction, where VACUUM is refused. MySQL and Postgres are the * operator's to dump; the screen says so. */ protected function backupDatabase(string $backup, ?string $live = null): void { $live ??= DB::getDatabaseName(); if (DB::getDriverName() !== 'sqlite' || ! is_file($live)) { return; } File::ensureDirectoryExists($backup.'/database'); $target = $backup.'/database/database.sqlite'; try { $pdo = new \PDO('sqlite:'.$live); $pdo->exec('VACUUM INTO '.$pdo->quote($target)); } catch (\Throwable $e) { throw new \RuntimeException('Could not create a consistent SQLite backup. Update stopped.', 0, $e); } } /** * A kept backup put back over the running install: code and vendor, and on * SQLite the database copy too. The state being replaced is backed up first, * so a rollback is itself a rollback away from being undone. * * @return array{ok:bool,message:string,safety?:string} */ public function rollback(string $name, ?string $target = null, ?string $database = null): array { $backup = $this->backupPath($name); if ($backup === null || ! is_file($backup.'/artisan')) { return ['ok' => false, 'message' => 'No such backup.']; } // The test-only source override stands in for base_path() here as well. $target ??= config('pharos.update.backup_source') ?? base_path(); $database ??= DB::getDatabaseName(); preg_match('/^(.*)-\d{8}-\d{6}$/', $name, $m); $version = $m[1]; $sample = ['state' => 'running', 'stage' => 'safety', 'done' => 0, 'total' => 0, 'name' => $name, 'message' => null, 'started_at' => now()->toIso8601String()]; $resetNeeded = false; try { $this->within = 'safety'; $safety = basename($this->backupCurrent($target, $database, keep: [$name])); $this->within = null; // One tick per file, plus one for the database step — as in backupCurrent(). $sample['total'] = $this->countTree($backup, self::KEEP) + 1; [$tick, $stage] = $this->ticker($sample); $stage('code'); $resetNeeded = true; $this->copyTree($backup, $target, [...self::KEEP, 'vendor'], $tick); if (is_dir($backup.'/vendor')) { $stage('vendor'); $this->copyTree($backup.'/vendor', $target.'/vendor', [], $tick); } $stage('database'); $note = $this->restoreDatabase($backup, $database); $sample['done']++; $stage('finishing'); app(PublicFiles::class)->sync($target, $backup.'/public'); Artisan::call('optimize:clear'); $cacheNote = $this->resetOpcodeCache(); $resetNeeded = false; $message = "Rolled back to {$version}".($note ? ": code restored; {$note}" : '.')." The state you just left is kept as {$safety}.".$cacheNote; // After optimize:clear, which empties the cache the screen is polling. $this->report([...$sample, 'state' => 'done', 'message' => $message]); return ['ok' => true, 'message' => $message, 'safety' => $safety]; } catch (\Throwable $e) { $this->within = null; $this->report([...$sample, 'state' => 'failed', 'message' => 'Rollback failed: '.$e->getMessage()]); return ['ok' => false, 'message' => 'Rollback failed: '.$e->getMessage()]; } finally { if ($resetNeeded) { $this->resetOpcodeCache(); } } } /** * The backup's SQLite copy over the live file. Returns what was *not* done, * for the message, or null when the database is back too. MySQL and Postgres * are the operator's to restore, as they were theirs to dump. */ protected function restoreDatabase(string $backup, string $live): ?string { $copy = $backup.'/database/database.sqlite'; // Not a file: another driver, or a database in memory with nothing to put the copy over. if (DB::getDriverName() !== 'sqlite' || ! is_file($live)) { return 'restore your database dump yourself.'; } if (! is_file($copy)) { return 'the database was not in this backup.'; } // Only the connection's own file gets it closed first; an open handle // would keep reading pages of the file being replaced. if ($live === DB::getDatabaseName()) { DB::disconnect(); } // Leftover journal files would be replayed over the restored file. File::delete([$live.'-wal', $live.'-shm']); File::copy($copy, $live); return null; } /** du-style: the sum of every file underneath, symlinks not followed. */ public function treeSize(string $path): int { $total = 0; $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS)); foreach ($files as $file) { if ($file->isFile() && ! $file->isLink()) { $total += $file->getSize(); } } return $total; } /** * @return array{ok:bool,message:string,version?:string,backup?:string} */ public function apply(?array $manifest = null): array { if ($this->updater->managed()) { return ['ok' => false, 'message' => 'This installation is managed externally. Update the Docker image on the host.']; } $manifest ??= $this->updater->latest(fresh: true); if ($manifest === null) { return ['ok' => false, 'message' => 'No verified release available.']; } if (version_compare($manifest['version'], $this->updater->current(), '<=')) { // Refusing a downgrade is not politeness: an older build may not // survive the migrations the current one already ran. return ['ok' => false, 'message' => "Already on {$this->updater->current()}; the release server offers {$manifest['version']}."]; } if (! $this->canWrite()) { return ['ok' => false, 'message' => 'The application directory is not writable, so it cannot update itself. Update by hand, or give the web user write access.']; } $work = storage_path('app/update-'.Str::random(8)); File::ensureDirectoryExists($work); // The screen polls the same samples a backup writes: one stage at a time, // file counts where there are files to count. $sample = ['state' => 'running', 'stage' => 'download', 'done' => 0, 'total' => 0, 'name' => null, 'message' => null, 'started_at' => now()->toIso8601String()]; $this->report($sample); $refuse = function (string $message) use (&$sample): array { $this->report([...$sample, 'state' => 'failed', 'message' => $message]); return ['ok' => false, 'message' => $message]; }; $resetNeeded = false; try { $archive = $work.'/release.zip'; $response = Http::timeout(120)->sink($archive)->get($manifest['url']); if (! $response->successful() || ! is_file($archive)) { return $refuse('Could not download the release.'); } $sample['stage'] = 'verify'; $this->report($sample); $actual = hash_file('sha256', $archive); if (! hash_equals(strtolower($manifest['sha256']), strtolower($actual))) { // The manifest is signed, so a mismatch means the archive was // swapped or truncated. Either way it does not get unpacked. return $refuse('The download does not match the signed checksum. Nothing was installed.'); } $zip = new \ZipArchive; if ($zip->open($archive) !== true) { return $refuse('The release archive could not be opened.'); } if (($problem = $this->unsafeEntry($zip)) !== null) { $zip->close(); return $refuse("The release archive was refused: {$problem}. Nothing was installed."); } $sample['stage'] = 'unpack'; $this->report($sample); $extracted = $work.'/files'; File::ensureDirectoryExists($extracted); $zip->extractTo($extracted); $zip->close(); $root = $this->archiveRoot($extracted); if (! is_file($root.'/artisan')) { return $refuse('That archive does not look like a Pharos release.'); } // The backup's own samples show as this one stage, counts included. $this->within = 'backup'; $backup = $this->backupCurrent(); $this->within = null; $sample = [...$sample, 'stage' => 'install', 'done' => 0, 'total' => $this->countTree($root, self::KEEP), 'name' => basename($backup)]; [$tick, $stage] = $this->ticker($sample); // The test-only source override stands in for base_path() here as well. $target = config('pharos.update.backup_source') ?? base_path(); try { $stage('install'); $resetNeeded = true; $this->copyTree($root, $target, skip: self::KEEP, tick: $tick); $stage('migrate'); $this->migrate(); app(PublicFiles::class)->sync($target, $root.'/public'); } catch (\Throwable $e) { // From here on the site is half new: new files on the old schema, // or a copy that stopped midway. Put the version we just backed // up straight back rather than leave that for a person to find. $this->within = 'restore'; // Files the new version added have no counterpart in the backup, // so the copy back would leave them; a new migration among them // would run on the next attempt. Off they go first. $this->removeAdded($root, $backup, $target); $restored = $this->rollback(basename($backup), $target); $this->within = null; return $refuse('Update failed: '.$e->getMessage().($restored['ok'] ? ' The previous version was put back from '.basename($backup).'.' : ' Restoring the previous version failed too: '.$restored['message'].' Roll back to '.basename($backup).' by hand.')); } $stage('finishing'); Artisan::call('optimize:clear'); $cacheNote = $this->resetOpcodeCache(); $resetNeeded = false; $message = "Updated to {$manifest['version']}. The previous version is in {$backup}.".$cacheNote; // After optimize:clear, which empties the cache the screen is polling. $this->report([...$sample, 'done' => $sample['total'], 'state' => 'done', 'message' => $message]); return [ 'ok' => true, 'version' => $manifest['version'], 'backup' => $backup, 'message' => $message, ]; } catch (\Throwable $e) { $this->within = null; return $refuse('Update failed: '.$e->getMessage()); } finally { if ($resetNeeded) { $this->resetOpcodeCache(); } File::deleteDirectory($work); } } /** Reset after the last write, including failed installs and restores. */ protected function resetOpcodeCache(): string { clearstatcache(true); // CLI and web workers do not share an opcode cache. Do not promise that // a reset in Artisan refreshed PHP-FPM or Apache. if (PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') { $note = ' Restart the web PHP service or container if it uses OPcache; a command-line update cannot clear the web cache.'; } elseif (! filter_var(ini_get('opcache.enable'), FILTER_VALIDATE_BOOLEAN)) { return ''; } else { try { if (function_exists('opcache_reset') && @opcache_reset()) { return ''; } // A nested rollback may already have scheduled the reset. $status = function_exists('opcache_get_status') ? @opcache_get_status(false) : false; if (is_array($status) && ($status['restart_pending'] ?? false)) { return ''; } } catch (\Throwable) { // Hosts may disable or restrict the API. Files have already // changed; report the required restart instead of rolling back. } $note = ' OPcache could not be reset. Restart the web PHP service or container to load the installed code.'; } Log::warning(trim($note)); return $note; } /** Delete from $target what $root brought in and $backup never had. */ protected function removeAdded(string $root, string $backup, string $target): void { foreach ($this->walk($root, self::KEEP) as $path) { if (! str_ends_with($path, '/') && ! file_exists($backup.'/'.$path) && is_file($target.'/'.$path)) { File::delete($target.'/'.$path); } } } /** Its own method so a test can make it fail; the real thing has no knobs. */ protected function migrate(): void { if (Artisan::call('migrate', ['--force' => true]) !== 0) { throw new \RuntimeException('Database migration failed.'); } } /** * An entry that would land outside the folder it is unpacked into, or a * symlink, which the copy that follows would walk through. PHP strips ".." * on extraction, but nothing we built carries either, so the whole archive * is refused before a byte of it is written. */ protected function unsafeEntry(\ZipArchive $zip): ?string { for ($i = 0; $i < $zip->numFiles; $i++) { $name = (string) $zip->getNameIndex($i); $segments = preg_split('#[\\\\/]+#', $name) ?: []; if (str_starts_with($name, '/') || in_array('..', $segments, true) || preg_match('/^[a-z]:/i', $name)) { return "{$name} points outside the archive"; } $zip->getExternalAttributesIndex($i, $opsys, $attributes); if ($opsys === \ZipArchive::OPSYS_UNIX && (($attributes >> 16) & 0170000) === 0120000) { return "{$name} is a symlink"; } } return null; } /** Release archives usually wrap everything in one folder. */ protected function archiveRoot(string $extracted): string { $entries = array_values(array_diff(scandir($extracted) ?: [], ['.', '..'])); return count($entries) === 1 && is_dir($extracted.'/'.$entries[0]) ? $extracted.'/'.$entries[0] : $extracted; } /** * @param string[] $skip paths relative to $from * @param ?callable $tick called once per file copied */ protected function copyTree(string $from, string $to, array $skip = [], ?callable $tick = null): void { File::ensureDirectoryExists($to); foreach ($this->walk($from, $skip) as $path) { if (str_ends_with($path, '/')) { File::ensureDirectoryExists($to.'/'.$path); // empty folders travel too continue; } File::copy($from.'/'.$path, $to.'/'.$path); if ($tick) { $tick(); } } } /** How many files copyTree() would copy: the same walk, so the two never disagree. */ protected function countTree(string $from, array $skip = []): int { $count = 0; foreach ($this->walk($from, $skip) as $path) { $count += str_ends_with($path, '/') ? 0 : 1; } return $count; } /** * Everything under $from that is not skipped, relative to it: each folder * (with a trailing slash) before what is inside it, files after subfolders. * * @param string[] $skip * @return \Generator */ protected function walk(string $from, array $skip = []): \Generator { foreach (File::directories($from) as $directory) { $name = basename($directory); if (in_array($name, $skip, true)) { continue; } yield $name.'/'; foreach ($this->walk($directory, $this->descend($skip, $name)) as $path) { yield $name.'/'.$path; } } foreach (File::files($from) as $file) { $name = $file->getFilename(); if (! in_array($name, $skip, true)) { yield $name; } } } /** Turns "database/database.sqlite" into "database.sqlite" once inside database/. */ protected function descend(array $skip, string $directory): array { $prefix = $directory.'/'; return array_values(array_map( fn ($path) => Str::after($path, $prefix), array_filter($skip, fn ($path) => str_starts_with($path, $prefix)), )); } }