self::currentVersion(), 'latest' => $latest, 'available' => $latest !== '' && version_compare($latest, self::currentVersion(), '>'), 'checked_at' => AppSettings::get('update_last_check_at'), 'notes_url' => AppSettings::get('update_latest_notes_url'), 'error' => AppSettings::get('update_last_error'), ]; } public static function refreshIfDue(int $minimumSeconds = 86400): array { $last = strtotime(AppSettings::get('update_last_check_at')) ?: 0; if ($last > 0 && time() - $last < $minimumSeconds) { return self::cachedStatus(); } try { return self::check(); } catch (Throwable $e) { AppSettings::save([ 'update_last_check_at' => date('Y-m-d H:i:s'), 'update_last_error' => $e->getMessage(), ], Auth::id()); return self::cachedStatus(); } } public static function check(): array { $release = self::fetchLatestRelease(); AppSettings::save([ 'update_last_check_at' => date('Y-m-d H:i:s'), 'update_latest_version' => $release['version'], 'update_latest_notes_url' => $release['notes_url'], 'update_last_error' => '', ], Auth::id()); return self::cachedStatus() + ['release' => $release]; } public static function preflight(?int $archiveSize = null): array { $root = self::root(); $checks = [ ['label' => t('updates.check_php'), 'ok' => version_compare(PHP_VERSION, '8.1.0', '>='), 'detail' => PHP_VERSION], ['label' => t('updates.check_zip'), 'ok' => class_exists(ZipArchive::class), 'detail' => 'ZipArchive'], ['label' => t('updates.check_phar'), 'ok' => class_exists(PharData::class), 'detail' => 'PharData'], ['label' => t('updates.check_root'), 'ok' => is_writable($root), 'detail' => $root], ['label' => t('updates.check_public'), 'ok' => is_writable($root . '/public'), 'detail' => $root . '/public'], ['label' => t('updates.check_storage'), 'ok' => is_writable($root . '/storage'), 'detail' => $root . '/storage'], ]; $required = max(100_000_000, ($archiveSize ?? 30_000_000) * 4); $free = @disk_free_space($root . '/storage'); $checks[] = [ 'label' => t('updates.check_disk'), 'ok' => $free !== false && $free >= $required, 'detail' => $free === false ? t('updates.unknown') : self::formatBytes((int) $free), ]; return $checks; } public static function installFromRepository(): array { $release = self::fetchLatestRelease(); $temporary = tempnam(sys_get_temp_dir(), 'globinours-release-'); if ($temporary === false) { throw new RuntimeException(t('updates.temp_failed')); } try { self::download($release['url'], $temporary); return self::install($temporary, $release['sha256'], 'online'); } finally { @unlink($temporary); } } public static function install(string $archive, string $sha256, string $source = 'upload'): array { if (!is_file($archive) || filesize($archive) === false || filesize($archive) > self::MAX_ARCHIVE_BYTES) { throw new RuntimeException(t('updates.archive_invalid')); } $sha256 = strtolower(trim($sha256)); if (!preg_match('/^[a-f0-9]{64}$/', $sha256) || !hash_equals($sha256, hash_file('sha256', $archive))) { throw new RuntimeException(t('updates.hash_mismatch')); } foreach (self::preflight((int) filesize($archive)) as $check) { if (!$check['ok']) { throw new RuntimeException(t('updates.preflight_failed', ['check' => $check['label']])); } } $root = self::root(); $releaseDir = $root . '/storage/releases'; self::ensureDirectory($releaseDir, 0700); $lockHandle = fopen($root . '/storage/update.lock', 'c+'); if (!$lockHandle || !flock($lockHandle, LOCK_EX | LOCK_NB)) { throw new RuntimeException(t('updates.locked')); } $stage = $root . '/storage/update-stage-' . bin2hex(random_bytes(6)); self::ensureDirectory($stage, 0700); $backup = null; $snapshot = null; $maintenance = $root . '/storage/maintenance.flag'; try { $sourceRoot = self::extractRelease($archive, $stage); $newVersion = self::validateRelease($sourceRoot); $currentVersion = self::currentVersion(); if (!version_compare($newVersion, $currentVersion, '>')) { throw new RuntimeException(t('updates.not_newer', ['version' => $newVersion])); } $backup = BackupService::create('pre-update'); BackupService::verify($backup['name']); $snapshot = self::snapshotCode($currentVersion); self::writeLog('start', compact('currentVersion', 'newVersion', 'source')); if (@file_put_contents($maintenance, date(DATE_ATOM)) === false) { throw new RuntimeException(t('updates.maintenance_failed')); } self::replaceCode($sourceRoot, $root); DB::close(); Migrations::run($root . '/migrations'); self::postInstallCheck($root, $newVersion); $state = [ 'from' => $currentVersion, 'to' => $newVersion, 'installed_at' => date(DATE_ATOM), 'backup' => $backup['name'], 'snapshot' => basename($snapshot), ]; self::writeJson($releaseDir . '/last-update.json', $state); self::writeLog('success', $state); self::pruneSnapshots($releaseDir, basename($snapshot)); @unlink($maintenance); return $state; } catch (Throwable $e) { self::writeLog('failure', ['error' => $e->getMessage()]); if ($snapshot && is_dir($snapshot)) { try { self::restoreCodeSnapshot($snapshot, $root); if ($backup && ($backupPath = BackupService::path($backup['name']))) { BackupService::restore($backupPath); } self::writeLog('automatic_rollback', ['snapshot' => basename($snapshot)]); } catch (Throwable $rollbackError) { self::writeLog('rollback_failure', ['error' => $rollbackError->getMessage()]); throw new RuntimeException( t('updates.rollback_failed', ['update' => $e->getMessage(), 'rollback' => $rollbackError->getMessage()]), 0, $e, ); } } throw $e; } finally { @unlink($maintenance); self::removeTree($stage); flock($lockHandle, LOCK_UN); fclose($lockHandle); } } public static function rollbackLast(): array { $root = self::root(); $statePath = $root . '/storage/releases/last-update.json'; $state = json_decode((string) @file_get_contents($statePath), true); if (!is_array($state) || empty($state['snapshot']) || empty($state['backup'])) { throw new RuntimeException(t('updates.no_rollback')); } $snapshot = $root . '/storage/releases/' . basename((string) $state['snapshot']); $backup = BackupService::path((string) $state['backup']); if (!is_dir($snapshot) || !$backup) { throw new RuntimeException(t('updates.no_rollback')); } $emergency = BackupService::create('pre-rollback'); @file_put_contents($root . '/storage/maintenance.flag', date(DATE_ATOM)); try { self::restoreCodeSnapshot($snapshot, $root); BackupService::restore($backup); @rename($statePath, $statePath . '.used-' . date('Ymd-His')); self::writeLog('manual_rollback', $state); return ['version' => (string) $state['from'], 'emergency' => $emergency['name']]; } finally { @unlink($root . '/storage/maintenance.flag'); } } public static function lastUpdate(): ?array { $value = json_decode((string) @file_get_contents(self::root() . '/storage/releases/last-update.json'), true); return is_array($value) ? $value : null; } public static function logPath(): ?string { $path = self::root() . '/storage/logs/update.log'; return is_file($path) ? $path : null; } private static function fetchLatestRelease(): array { $repository = self::repositoryUrl(); self::assertHttpsUrl($repository); $raw = self::request(self::repositoryApiUrl($repository) . '?limit=20', 2_000_000); $releases = json_decode($raw, true, 64, JSON_THROW_ON_ERROR); if (!is_array($releases)) { throw new RuntimeException(t('updates.release_invalid')); } $acceptPrereleases = str_contains(self::currentVersion(), '-') || getenv('GLOBINOURS_RELEASES_PRERELEASES') === '1'; foreach ($releases as $release) { if (!is_array($release) || !empty($release['draft']) || (!empty($release['prerelease']) && !$acceptPrereleases)) { continue; } $version = ltrim(trim((string) ($release['tag_name'] ?? '')), 'vV'); if (!preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version)) { continue; } $assets = is_array($release['assets'] ?? null) ? $release['assets'] : []; $archive = null; $checksums = null; foreach ($assets as $asset) { if (!is_array($asset)) { continue; } $name = basename((string) ($asset['name'] ?? '')); $url = trim((string) ($asset['browser_download_url'] ?? '')); if ($url === '') { continue; } if (preg_match('/^globinours-(?:v)?' . preg_quote($version, '/') . '\.(?:tar\.gz|tgz)$/i', $name)) { $archive = ['name' => $name, 'url' => $url]; } if (strcasecmp($name, 'SHA256SUMS') === 0) { $checksums = ['name' => $name, 'url' => $url]; } } if (!$archive) { continue; } if (!$checksums) { foreach ($assets as $asset) { $name = basename((string) ($asset['name'] ?? '')); if (strcasecmp($name, $archive['name'] . '.sha256') === 0) { $checksums = ['name' => $name, 'url' => trim((string) ($asset['browser_download_url'] ?? ''))]; break; } } } if (!$checksums || $checksums['url'] === '') { throw new RuntimeException(t('updates.checksum_missing')); } self::assertHttpsUrl($archive['url']); self::assertHttpsUrl($checksums['url']); $sha256 = self::checksumFor(self::request($checksums['url'], 131072), $archive['name']); $notesUrl = trim((string) ($release['html_url'] ?? $repository . '/releases/tag/' . rawurlencode((string) $release['tag_name']))); self::assertHttpsUrl($notesUrl); return [ 'version' => $version, 'url' => $archive['url'], 'sha256' => $sha256, 'notes_url' => $notesUrl, 'published_at' => (string) ($release['published_at'] ?? ''), ]; } throw new RuntimeException(t('updates.no_release')); } private static function repositoryApiUrl(string $repository): string { $parts = parse_url($repository); $segments = array_values(array_filter(explode('/', trim((string) ($parts['path'] ?? ''), '/')), 'strlen')); if (count($segments) < 2) { throw new RuntimeException(t('updates.repository_invalid')); } $repo = preg_replace('/\.git$/i', '', (string) array_pop($segments)); $owner = array_pop($segments); $basePath = $segments ? '/' . implode('/', $segments) : ''; $authority = (string) $parts['host'] . (isset($parts['port']) ? ':' . (int) $parts['port'] : ''); return 'https://' . $authority . $basePath . '/api/v1/repos/' . rawurlencode($owner) . '/' . rawurlencode($repo) . '/releases'; } private static function checksumFor(string $contents, string $archiveName): string { foreach (preg_split('/\R/', $contents) ?: [] as $line) { if (preg_match('/^([a-fA-F0-9]{64})(?:\s+\*?([^\s]+))?$/', trim($line), $matches)) { if (!isset($matches[2]) || basename($matches[2]) === $archiveName) { return strtolower($matches[1]); } } } throw new RuntimeException(t('updates.checksum_invalid')); } private static function request(string $url, int $limit): string { if (!function_exists('curl_init')) { throw new RuntimeException(t('updates.curl_missing')); } $body = ''; $curl = curl_init($url); curl_setopt_array($curl, [ CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 4, CURLOPT_TIMEOUT => 10, CURLOPT_USERAGENT => 'Globinours/' . self::currentVersion(), CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use (&$body, $limit): int { if (strlen($body) + strlen($chunk) > $limit) { return 0; } $body .= $chunk; return strlen($chunk); }, ]); $ok = curl_exec($curl); $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $error = curl_error($curl); curl_close($curl); if ($ok === false || $status !== 200) { throw new RuntimeException(t('updates.network_failed', ['error' => $error ?: (string) $status])); } return $body; } private static function download(string $url, string $target): void { self::assertHttpsUrl($url); if (!function_exists('curl_init')) { throw new RuntimeException(t('updates.curl_missing')); } $output = fopen($target, 'wb'); if (!$output) { throw new RuntimeException(t('updates.temp_failed')); } $received = 0; $curl = curl_init($url); curl_setopt_array($curl, [ CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 5, CURLOPT_TIMEOUT => 120, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_PROTOCOLS => CURLPROTO_HTTPS, CURLOPT_WRITEFUNCTION => static function ($handle, string $chunk) use ($output, &$received): int { $received += strlen($chunk); if ($received > self::MAX_ARCHIVE_BYTES) { return 0; } return (int) fwrite($output, $chunk); }, ]); $ok = curl_exec($curl); $status = (int) curl_getinfo($curl, CURLINFO_RESPONSE_CODE); $error = curl_error($curl); curl_close($curl); fclose($output); if ($ok === false || $status !== 200) { throw new RuntimeException(t('updates.network_failed', ['error' => $error ?: (string) $status])); } } private static function extractRelease(string $archive, string $stage): string { try { $phar = new PharData($archive); } catch (Throwable $e) { throw new RuntimeException(t('updates.archive_invalid'), 0, $e); } $prefix = 'phar://' . $archive . '/'; $count = 0; $total = 0; foreach (new RecursiveIteratorIterator($phar) as $file) { $path = str_replace('\\', '/', (string) $file->getPathname()); $relative = str_starts_with($path, $prefix) ? substr($path, strlen($prefix)) : ''; if (!self::safeRelativePath($relative) || $file->isLink()) { throw new RuntimeException(t('updates.archive_unsafe')); } $count++; $total += (int) $file->getSize(); if ($count > self::MAX_FILES || $total > self::MAX_EXTRACTED_BYTES) { throw new RuntimeException(t('updates.archive_too_large')); } $target = $stage . '/' . $relative; self::ensureDirectory(dirname($target), 0700); if (!copy($path, $target)) { throw new RuntimeException(t('updates.extract_failed')); } @chmod($target, 0644); } if ($count === 0) { throw new RuntimeException(t('updates.archive_invalid')); } if (is_file($stage . '/public/index.php')) { return $stage; } $children = array_values(array_filter(scandir($stage) ?: [], static fn(string $name): bool => !in_array($name, ['.', '..'], true))); if (count($children) === 1 && is_file($stage . '/' . $children[0] . '/public/index.php')) { return $stage . '/' . $children[0]; } throw new RuntimeException(t('updates.archive_invalid')); } private static function validateRelease(string $source): string { foreach (['VERSION', 'public/index.php', 'app/Services/DB.php', 'app/Services/Migrations.php', 'migrations'] as $required) { if (!file_exists($source . '/' . $required)) { throw new RuntimeException(t('updates.release_missing', ['path' => $required])); } } $version = trim((string) file_get_contents($source . '/VERSION')); if (!preg_match('/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/', $version)) { throw new RuntimeException(t('updates.archive_invalid')); } return $version; } private static function snapshotCode(string $version): string { $root = self::root(); $target = $root . '/storage/releases/code-' . preg_replace('/[^0-9A-Za-z._-]/', '-', $version) . '-' . date('Ymd-His'); self::ensureDirectory($target, 0700); self::copyManagedCode($root, $target); return $target; } private static function replaceCode(string $source, string $root): void { self::clearManagedCode($root); self::copyManagedCode($source, $root); foreach (['data', 'storage', 'public/media'] as $path) { self::ensureDirectory($root . '/' . $path, 0770); } } private static function restoreCodeSnapshot(string $snapshot, string $root): void { self::clearManagedCode($root); self::copyManagedCode($snapshot, $root); } private static function copyManagedCode(string $source, string $target): void { foreach (scandir($source) ?: [] as $name) { if (in_array($name, ['.', '..'], true) || in_array($name, self::PRESERVED_ROOT, true)) { continue; } if ($name === 'public') { self::ensureDirectory($target . '/public', 0755); foreach (scandir($source . '/public') ?: [] as $publicName) { if (in_array($publicName, ['.', '..', 'media'], true)) { continue; } self::copyPath($source . '/public/' . $publicName, $target . '/public/' . $publicName); } continue; } self::copyPath($source . '/' . $name, $target . '/' . $name); } } private static function clearManagedCode(string $root): void { foreach (scandir($root) ?: [] as $name) { if (in_array($name, ['.', '..'], true) || in_array($name, self::PRESERVED_ROOT, true)) { continue; } if ($name === 'public') { foreach (scandir($root . '/public') ?: [] as $publicName) { if (!in_array($publicName, ['.', '..', 'media'], true)) { self::removeTree($root . '/public/' . $publicName); } } continue; } self::removeTree($root . '/' . $name); } } private static function copyPath(string $source, string $target): void { if (is_link($source)) { throw new RuntimeException(t('updates.symlink_refused', ['path' => basename($source)])); } if (is_dir($source)) { self::ensureDirectory($target, fileperms($source) & 0777 ?: 0755); foreach (scandir($source) ?: [] as $name) { if (!in_array($name, ['.', '..'], true)) { self::copyPath($source . '/' . $name, $target . '/' . $name); } } return; } self::ensureDirectory(dirname($target), 0755); if (!copy($source, $target)) { throw new RuntimeException(t('updates.copy_failed', ['path' => basename($source)])); } @chmod($target, fileperms($source) & 0777 ?: 0644); } private static function postInstallCheck(string $root, string $version): void { if (!is_file($root . '/public/index.php') || trim((string) @file_get_contents($root . '/VERSION')) !== $version) { throw new RuntimeException(t('updates.postcheck_failed')); } DB::close(); if (DB::pdo()->query('PRAGMA integrity_check')->fetchColumn() !== 'ok') { throw new RuntimeException(t('updates.postcheck_failed')); } } private static function assertHttpsUrl(string $url): void { $parts = parse_url($url); if (!is_array($parts) || strtolower((string) ($parts['scheme'] ?? '')) !== 'https' || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])) { throw new RuntimeException(t('updates.url_invalid')); } } private static function safeRelativePath(string $path): bool { return $path !== '' && !str_starts_with($path, '/') && !str_contains($path, "\0") && !preg_match('#(^|/)\.\.(/|$)#', $path); } private static function ensureDirectory(string $path, int $mode): void { if (!is_dir($path) && !mkdir($path, $mode, true) && !is_dir($path)) { throw new RuntimeException(t('updates.directory_failed', ['path' => $path])); } } private static function removeTree(string $path): void { if (is_link($path) || is_file($path)) { @unlink($path); return; } if (!is_dir($path)) { return; } foreach (scandir($path) ?: [] as $name) { if (!in_array($name, ['.', '..'], true)) { self::removeTree($path . '/' . $name); } } @rmdir($path); } private static function writeJson(string $path, array $data): void { file_put_contents($path, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)); @chmod($path, 0600); } private static function writeLog(string $event, array $context): void { $path = self::root() . '/storage/logs/update.log'; self::ensureDirectory(dirname($path), 0700); @file_put_contents($path, json_encode(['at' => date(DATE_ATOM), 'event' => $event, 'context' => $context], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . PHP_EOL, FILE_APPEND | LOCK_EX); @chmod($path, 0600); } private static function pruneSnapshots(string $directory, string $keep): void { $snapshots = glob($directory . '/code-*', GLOB_ONLYDIR) ?: []; usort($snapshots, static fn(string $a, string $b): int => filemtime($b) <=> filemtime($a)); foreach (array_slice($snapshots, 3) as $snapshot) { if (basename($snapshot) !== $keep) { self::removeTree($snapshot); } } } private static function formatBytes(int $bytes): string { return number_format($bytes / 1_048_576, 0, ',', ' ') . ' Mo'; } }