diff --git a/CHANGELOG.md b/CHANGELOG.md index effc47c..3232696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ Toutes les modifications importantes du projet sont consignées ici. Le format s’inspire de [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet suit le [versionnage sémantique](https://semver.org/lang/fr/). +## [1.0.0-rc.16] — 2026-09-08 + +### Mise à jour autonome + +- ajout d’un centre de mises à jour pour les installations hors YunoHost ; +- détection directe des releases stables publiées sur le dépôt Forgejo configurable ; +- vérification SHA-256 obligatoire des archives téléchargées ou téléversées ; +- sauvegarde complète vérifiée et copie du code avant chaque installation ; +- restauration automatique du code et des données lorsqu’une migration ou un contrôle échoue ; +- retour manuel vers la version précédente depuis l’interface et la ligne de commande ; +- préservation stricte de `.env`, `data`, `storage` et `public/media` ; +- journal technique téléchargeable et diagnostic préalable des permissions. + ## [1.0.0-rc.15] — 2026-09-08 ### Navigation dans le catalogue vétérinaire diff --git a/HISTORIQUE.md b/HISTORIQUE.md index fb343d8..39588f0 100644 --- a/HISTORIQUE.md +++ b/HISTORIQUE.md @@ -69,3 +69,7 @@ Allègement de la colonne d’actions grâce à des commandes compactes et acces ## `1.0.0-rc.15` — Navigation dans les tarifs Intégration de la modification directement dans les lignes du catalogue pour retrouver les habitudes de navigation de Globinours. + +## `1.0.0-rc.16` — Mise à jour autonome + +Création d’un parcours de mise à jour commun à l’interface et à la ligne de commande, avec releases Forgejo, sauvegarde obligatoire, contrôles d’intégrité et retour arrière complet. diff --git a/VERSION b/VERSION index ceb7a60..e384ff5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0-rc.15 +1.0.0-rc.16 diff --git a/app/Controllers/SettingsController.php b/app/Controllers/SettingsController.php index 8b60de1..bac8d80 100644 --- a/app/Controllers/SettingsController.php +++ b/app/Controllers/SettingsController.php @@ -1114,6 +1114,105 @@ final class SettingsController } exit(); } + public static function updates(): void + { + self::admin(); + render('settings_updates.php', [ + 'title' => t('updates.title'), + 'updateStatus' => UpdateService::cachedStatus(), + 'checks' => UpdateService::preflight(), + 'lastUpdate' => UpdateService::lastUpdate(), + 'status' => (string) ($_GET['status'] ?? ''), + 'error' => (string) ($_GET['error'] ?? ''), + ]); + } + public static function checkUpdates(): void + { + self::admin(); + self::postOnly(); + try { + UpdateService::check(); + header('Location: /settings/updates?status=checked'); + } catch (Throwable $e) { + header('Location: /settings/updates?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function installUpdate(): void + { + self::admin(); + self::postOnly(); + if (!Auth::verifyCurrentPassword((string) ($_POST['password'] ?? ''))) { + header('Location: /settings/updates?error=' . rawurlencode(t('updates.password_invalid'))); + exit(); + } + try { + @set_time_limit(0); + $result = UpdateService::installFromRepository(); + AuditService::log('application_updated', '/settings/updates/install', 'Globinours mis à jour vers ' . $result['to']); + header('Location: /settings/updates?status=installed&version=' . rawurlencode($result['to'])); + } catch (Throwable $e) { + header('Location: /settings/updates?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function uploadUpdate(): void + { + self::admin(); + self::postOnly(); + $upload = $_FILES['release'] ?? null; + if (!Auth::verifyCurrentPassword((string) ($_POST['password'] ?? ''))) { + header('Location: /settings/updates?error=' . rawurlencode(t('updates.password_invalid'))); + exit(); + } + if (!is_array($upload) || ($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($upload['tmp_name'] ?? ''))) { + header('Location: /settings/updates?error=' . rawurlencode(t('updates.archive_invalid'))); + exit(); + } + try { + @set_time_limit(0); + $result = UpdateService::install((string) $upload['tmp_name'], (string) ($_POST['sha256'] ?? ''), 'upload'); + AuditService::log('application_updated', '/settings/updates/upload', 'Globinours mis à jour vers ' . $result['to']); + header('Location: /settings/updates?status=installed&version=' . rawurlencode($result['to'])); + } catch (Throwable $e) { + header('Location: /settings/updates?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function rollbackUpdate(): void + { + self::admin(); + self::postOnly(); + if (!Auth::verifyCurrentPassword((string) ($_POST['password'] ?? ''))) { + header('Location: /settings/updates?error=' . rawurlencode(t('updates.password_invalid'))); + exit(); + } + try { + @set_time_limit(0); + $result = UpdateService::rollbackLast(); + Auth::logout(); + header('Location: /login?rollback=1&version=' . rawurlencode($result['version'])); + } catch (Throwable $e) { + header('Location: /settings/updates?error=' . rawurlencode(SecurityService::publicError($e))); + } + exit(); + } + public static function downloadUpdateLog(): void + { + self::admin(); + $path = UpdateService::logPath(); + if (!$path) { + http_response_code(404); + echo h(t('updates.log_missing')); + return; + } + header('Content-Type: text/plain; charset=utf-8'); + header('Content-Disposition: attachment; filename="globinours-update.log"'); + header('Content-Length: ' . filesize($path)); + header('Cache-Control: private, no-store'); + header('X-Content-Type-Options: nosniff'); + readfile($path); + } private static function postOnly(): void { if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { diff --git a/app/Routes/web.php b/app/Routes/web.php index 3205883..552b61c 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -434,6 +434,24 @@ switch ($path) { case '/settings/backups/restore': SettingsController::restoreBackup(); break; + case '/settings/updates': + SettingsController::updates(); + break; + case '/settings/updates/check': + SettingsController::checkUpdates(); + break; + case '/settings/updates/install': + SettingsController::installUpdate(); + break; + case '/settings/updates/upload': + SettingsController::uploadUpdate(); + break; + case '/settings/updates/rollback': + SettingsController::rollbackUpdate(); + break; + case '/settings/updates/log': + SettingsController::downloadUpdateLog(); + break; case '/settings/asm3': Asm3ImportController::index(); break; diff --git a/app/Services/AppSettings.php b/app/Services/AppSettings.php index 52fddac..d704200 100644 --- a/app/Services/AppSettings.php +++ b/app/Services/AppSettings.php @@ -56,6 +56,10 @@ final class AppSettings 'backup_schedule_frequency' => 'daily', 'backup_last_run_at' => '', 'backup_last_verified_at' => '', + 'update_last_check_at' => '', + 'update_latest_version' => '', + 'update_latest_notes_url' => '', + 'update_last_error' => '', 'public_site_legal_text' => '', 'public_site_privacy_text' => '', ]; diff --git a/app/Services/Auth.php b/app/Services/Auth.php index 2812863..0e4694e 100644 --- a/app/Services/Auth.php +++ b/app/Services/Auth.php @@ -168,6 +168,17 @@ final class Auth { return (self::user()['role'] ?? '') === $role; } + public static function verifyCurrentPassword(string $password): bool + { + $id = self::id(); + if (!$id || $password === '') { + return false; + } + $stmt = DB::pdo()->prepare('SELECT password_hash FROM users WHERE id=:id AND active=1'); + $stmt->execute([':id' => $id]); + $hash = $stmt->fetchColumn(); + return is_string($hash) && password_verify($password, $hash); + } public static function canWrite(string $path): bool { $module = PermissionService::moduleForPath($path); diff --git a/app/Services/BackupService.php b/app/Services/BackupService.php index 6063626..1bd11cd 100644 --- a/app/Services/BackupService.php +++ b/app/Services/BackupService.php @@ -17,8 +17,16 @@ final class BackupService throw new RuntimeException(t('service.backup.directory_failed')); } $stamp = (new DateTimeImmutable('now', new DateTimeZone('Europe/Paris')))->format('Ymd-His'); - $suffix = $reason === 'pre-restore' ? '-avant-restauration' : ''; + $suffix = match ($reason) { + 'pre-restore' => '-avant-restauration', + 'pre-update' => '-avant-mise-a-jour', + 'pre-rollback' => '-avant-retour-arriere', + default => '', + }; $target = $backupDir . '/globinours-' . $stamp . $suffix . '.zip'; + if (is_file($target)) { + $target = $backupDir . '/globinours-' . $stamp . $suffix . '-' . bin2hex(random_bytes(2)) . '.zip'; + } // Certains volumes synchronisés acceptent les ZIP mais SQLite ne peut pas // y créer de base avec VACUUM INTO. Le snapshot transitoire appartient au // répertoire temporaire système ; seule l'archive finale va dans backups/. @@ -85,7 +93,7 @@ final class BackupService { if ( $name !== basename($name) || - !preg_match('/^globinours-[0-9]{8}-[0-9]{6}(?:-avant-restauration)?\.zip$/', $name) + !preg_match('/^globinours-[0-9]{8}-[0-9]{6}(?:-avant-(?:restauration|mise-a-jour|retour-arriere))?(?:-[a-f0-9]{4})?\.zip$/', $name) ) { return null; } @@ -445,7 +453,15 @@ final class BackupService $files = glob($dir . '/globinours-*.zip') ?: []; usort($files, static fn($a, $b) => filemtime($b) <=> filemtime($a)); $keep = class_exists(AppSettings::class) ? AppSettings::int('backup_retention', 1, 50) : 10; - foreach (array_slice($files, $keep) as $file) { + $regularKept = 0; + foreach ($files as $file) { + $protectedRollback = + (str_contains(basename($file), '-avant-mise-a-jour') || + str_contains(basename($file), '-avant-retour-arriere')) && + time() - (filemtime($file) ?: 0) < 7 * 86400; + if ($protectedRollback || ++$regularKept <= $keep) { + continue; + } @unlink($file); } } diff --git a/app/Services/NotificationService.php b/app/Services/NotificationService.php index b1ca82a..6e56133 100644 --- a/app/Services/NotificationService.php +++ b/app/Services/NotificationService.php @@ -100,6 +100,18 @@ final class NotificationService 'url' => '/settings/backups', ]; } + if (class_exists(UpdateService::class)) { + $update = UpdateService::cachedStatus(); + if ($update['available']) { + $alerts[] = [ + 'key' => 'application-update', + 'severity' => 'warning', + 'label' => t('updates.available', ['version' => $update['latest']]), + 'count' => 1, + 'url' => '/settings/updates', + ]; + } + } return $alerts; } public static function send(bool $force = false): array diff --git a/app/Services/UpdateService.php b/app/Services/UpdateService.php new file mode 100644 index 0000000..1137697 --- /dev/null +++ b/app/Services/UpdateService.php @@ -0,0 +1,606 @@ + 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')); + } + foreach ($releases as $release) { + if (!is_array($release) || !empty($release['draft']) || !empty($release['prerelease'])) { + 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'; + } +} diff --git a/app/Views/_layout_topbar.php b/app/Views/_layout_topbar.php index 776aec7..926ddf4 100644 --- a/app/Views/_layout_topbar.php +++ b/app/Views/_layout_topbar.php @@ -89,7 +89,7 @@ class="btn = $accountingActive ? 'btn-teal' : 'btn-outline-teal' ?> btn-sm me-2" href="/accounting?year=0">🧾 Comptabilité -