app/Services/SelfUpdater.php
Rama main · réplica de lectura del repositorio original.
<?php
namespace App\Services;
use App\Support\Bytes;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Replaces the application's own files from a signed release archive. For the
* shared-hosting case, where there is no Docker, no root and no daemon — which
* is most of the market this is aimed at.
*
* Refuses to do anything it cannot verify, and keeps the previous version on
* disk so a failed update is a rename away from being undone.
*/
class SelfUpdater
{
/** Never replaced: this is the customer's data and configuration. */
public const KEEP = ['.env', 'storage', 'database/database.sqlite', 'public/storage'];
public function __construct(protected Updater $updater) {}
public function canWrite(): bool
{
return is_writable(base_path()) && is_writable(base_path('app'));
}
public function backupsDir(): string
{
return rtrim((string) config('pharos.update.backups_dir'), '/');
}
/** Backup folders are named "<version>-<Ymd-His>" 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 "<name>/…": 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<array{name:string,version:string,created_at:Carbon,size:int}>
*/
public function backups(): array
{
$dir = $this->backupsDir();
if (! is_dir($dir)) {
return [];
}
$backups = array_map(function (string $path) {
$name = basename($path);
// Folders are named "<version>-<Ymd-His>" 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<string> 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<string>
*/
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)),
));
}
}