Ajoute les mises à jour autonomes

Les installations hors YunoHost peuvent désormais vérifier et installer une release Forgejo avec sauvegarde et retour arrière complet.
This commit is contained in:
Alexandre NOEL 2026-09-08 20:13:55 +02:00
commit 53e79a72d7
25 changed files with 1146 additions and 61 deletions

View file

@ -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') {

View file

@ -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;

View file

@ -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' => '',
];

View file

@ -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);

View file

@ -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);
}
}

View file

@ -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

View file

@ -0,0 +1,606 @@
<?php
declare(strict_types=1);
final class UpdateService
{
private const DEFAULT_REPOSITORY = 'https://nnsprod.com/git/oversu/Globinours';
private const MAX_ARCHIVE_BYTES = 300_000_000;
private const MAX_EXTRACTED_BYTES = 800_000_000;
private const MAX_FILES = 20_000;
private const PRESERVED_ROOT = ['.env', 'data', 'storage'];
public static function root(): string
{
return dirname(__DIR__, 2);
}
public static function currentVersion(): string
{
return trim((string) @file_get_contents(self::root() . '/VERSION'));
}
public static function repositoryUrl(): string
{
return rtrim(trim((string) (getenv('GLOBINOURS_RELEASES_REPOSITORY') ?: self::DEFAULT_REPOSITORY)), '/');
}
public static function cachedStatus(): array
{
$latest = AppSettings::get('update_latest_version');
return [
'current' => 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';
}
}

View file

@ -89,7 +89,7 @@
class="btn <?= $accountingActive ? 'btn-teal' : 'btn-outline-teal' ?> btn-sm me-2"
href="/accounting?year=0">🧾 Comptabilité</a><?php endif; ?>
<div class="dropdown settings-dropdown"><button class="btn btn-dark btn-sm me-2 settings-trigger"
<div class="dropdown settings-dropdown"><button class="btn btn-dark btn-sm me-2 settings-trigger position-relative"
data-bs-toggle="dropdown" aria-expanded="false" title="<?= h(t('account.settings')) ?>"
aria-label="<?= h(t('account.settings')) ?>"><svg xmlns="http://www.w3.org/2000/svg"
class="icon m-0" width="22" height="22" viewBox="0 0 24 24" stroke-width="2"
@ -98,7 +98,7 @@
<path d="M12 15.5a3.5 3.5 0 1 0 0 -7a3.5 3.5 0 0 0 0 7z" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06 .06a2 2 0 1 1 -2.83 2.83l-.06 -.06a1.65 1.65 0 0 0 -1.82 -.33a1.65 1.65 0 0 0 -1 1.51v.17a2 2 0 1 1 -4 0v-.09a1.65 1.65 0 0 0 -1.08 -1.51a1.65 1.65 0 0 0 -1.82 .33l-.06 .06a2 2 0 1 1 -2.83 -2.83l.06 -.06a1.65 1.65 0 0 0 .33 -1.82a1.65 1.65 0 0 0 -1.51 -1h-.17a2 2 0 1 1 0 -4h.09a1.65 1.65 0 0 0 1.51 -1.08a1.65 1.65 0 0 0 -.33 -1.82l-.06 -.06a2 2 0 1 1 2.83 -2.83l.06 .06a1.65 1.65 0 0 0 1.82 .33h.08a1.65 1.65 0 0 0 1 -1.51v-.17a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51a1.65 1.65 0 0 0 1.82 -.33l.06 -.06a2 2 0 1 1 2.83 2.83l-.06 .06a1.65 1.65 0 0 0 -.33 1.82v.08a1.65 1.65 0 0 0 1.51 1h.17a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0 -1.51 1z" />
</svg></button>
</svg><?php if ($updateAvailable): ?><span class="position-absolute top-0 start-100 translate-middle p-1 bg-orange border border-light rounded-circle"><span class="visually-hidden"><?= h(t('updates.available_short')) ?></span></span><?php endif; ?></button>
<div class="dropdown-menu dropdown-menu-end">
<div class="dropdown-item-text">
<div class="fw-bold">👤 <?= h($currentUser['display_name'] ?: $currentUser['username']) ?>

View file

@ -21,6 +21,7 @@ if (!function_exists('settingsNavigationLinks')) {
'permissions' => ['/settings/permissions','🔐',t('settings.permissions'),'access'],
'privacy' => ['/settings/privacy','🔏',t('settings.privacy'),'access'],
'backups' => ['/settings/backups','💾',t('settings.backups'),'maintenance'],
'updates' => ['/settings/updates','⬆️',t('settings.updates'),'maintenance'],
'health' => ['/settings/health','🩺',t('settings.health'),'maintenance'],
'notifications' => ['/settings/notifications','🔔',t('settings.notifications'),'maintenance'],
'data' => ['/settings/data','🧪',t('settings.data'),'maintenance'],

View file

@ -31,9 +31,9 @@
<div class="col-lg-6">
<section class="card h-100">
<div class="card-body">
<span class="badge bg-blue-lt mb-2">1.0.0-rc.15</span>
<h3>Catalogue vétérinaire modifiable</h3>
<p class="text-muted">Tarifs modifiables directement depuis leur ligne, tout en préservant les dépenses historiques.</p>
<span class="badge bg-blue-lt mb-2">1.0.0-rc.16</span>
<h3>Mises à jour autonomes</h3>
<p class="text-muted">Installation vérifiée depuis Forgejo, sauvegarde obligatoire et retour automatique du code et des données en cas d’échec.</p>
</div>
</section>
</div>

View file

@ -18,6 +18,11 @@ $accountingActive = str_starts_with($currentPath, '/accounting');
$settingsActive = str_starts_with($currentPath, '/settings');
$settingsMenuLinks = settingsNavigationLinks();
$currentUser = Auth::user();
$updateStatus = ['available' => false];
if (Auth::is('admin') && class_exists(UpdateService::class)) {
$updateStatus = UpdateService::refreshIfDue();
}
$updateAvailable = (bool) ($updateStatus['available'] ?? false);
$pageName = trim((string)($title ?? ''));
$documentTitle = $pageName !== '' ? $pageName . ' — Globinours' : 'Globinours';
$pageDescription = trim((string)($pageDescription ?? ''));

View file

@ -110,7 +110,7 @@ $formatSize = static function (int $bytes): string {
<?php foreach ($backups as $backup): ?>
<tr>
<td class="text-nowrap"><?= h($backup['date']) ?></td>
<td><strong><?= h($backup['name']) ?></strong><?php if (str_contains($backup['name'], 'avant-restauration')): ?>
<td><strong><?= h($backup['name']) ?></strong><?php if (str_contains($backup['name'], '-avant-')): ?>
<span class="badge bg-orange-lt"><?= h(t('backups.emergency')) ?></span><?php endif; ?></td>
<td><?= h($formatSize((int)$backup['size'])) ?></td>
<td>

View file

@ -11,6 +11,7 @@ $settingsSection = match(true) {
str_starts_with($settingsPath, '/settings/lab-references') => 'lab-references',
str_starts_with($settingsPath, '/settings/public-site') => 'public-site',
str_starts_with($settingsPath, '/settings/backups') => 'backups',
str_starts_with($settingsPath, '/settings/updates') => 'updates',
str_starts_with($settingsPath, '/settings/health') => 'health',
str_starts_with($settingsPath, '/settings/notifications') => 'notifications',
str_starts_with($settingsPath, '/settings/audit') => 'audit',

View file

@ -0,0 +1,92 @@
<?php require __DIR__ . '/settings_nav.php'; ?>
<div class="mb-3">
<h2 class="mb-1"><?= h(t('updates.heading')) ?></h2>
<div class="text-muted"><?= h(t('updates.intro')) ?></div>
</div>
<?php if ($status === 'checked'): ?><div class="alert alert-success">✅ <?= h(t('updates.checked')) ?></div><?php endif; ?>
<?php if ($status === 'installed'): ?><div class="alert alert-success">✅ <?= h(t('updates.installed', ['version' => (string) ($_GET['version'] ?? '')])) ?></div><?php endif; ?>
<?php if ($error !== ''): ?><div class="alert alert-danger"><strong><?= h(t('updates.failed')) ?></strong> <?= h($error) ?></div><?php endif; ?>
<div class="row g-3 mb-3">
<div class="col-lg-7">
<section class="card h-100">
<div class="card-header"><h3 class="card-title"><?= h(t('updates.version_status')) ?></h3></div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-5"><?= h(t('updates.installed_version')) ?></dt><dd class="col-sm-7"><code><?= h($updateStatus['current']) ?></code></dd>
<dt class="col-sm-5"><?= h(t('updates.available_version')) ?></dt><dd class="col-sm-7"><?= $updateStatus['latest'] !== '' ? '<code>' . h($updateStatus['latest']) . '</code>' : '—' ?></dd>
<dt class="col-sm-5"><?= h(t('updates.last_check')) ?></dt><dd class="col-sm-7"><?= h($updateStatus['checked_at'] ?: '—') ?></dd>
</dl>
<?php if ($updateStatus['available']): ?><div class="alert alert-warning mt-3 mb-0">⬆️ <?= h(t('updates.available', ['version' => $updateStatus['latest']])) ?></div>
<?php elseif ($updateStatus['latest'] !== ''): ?><div class="alert alert-success mt-3 mb-0">✅ <?= h(t('updates.current')) ?></div><?php endif; ?>
<?php if ($updateStatus['error'] !== ''): ?><div class="alert alert-secondary mt-3 mb-0"><?= h(t('updates.check_error', ['error' => $updateStatus['error']])) ?></div><?php endif; ?>
</div>
<div class="card-footer d-flex flex-wrap gap-2 justify-content-between">
<form method="post" action="/settings/updates/check"><button class="btn btn-outline-primary">🔄 <?= h(t('updates.check_now')) ?></button></form>
<?php if ($updateStatus['notes_url'] !== ''): ?><a class="btn btn-link" href="<?= h($updateStatus['notes_url']) ?>" target="_blank" rel="noopener noreferrer"><?= h(t('updates.release_notes')) ?></a><?php endif; ?>
</div>
</section>
</div>
<div class="col-lg-5">
<section class="card h-100">
<div class="card-header"><h3 class="card-title"><?= h(t('updates.preflight')) ?></h3></div>
<div class="list-group list-group-flush">
<?php $ready = true; foreach ($checks as $check): $ready = $ready && $check['ok']; ?><div class="list-group-item d-flex justify-content-between gap-3">
<span><?= $check['ok'] ? '✅' : '❌' ?> <?= h($check['label']) ?></span><small class="text-muted text-end"><?= h($check['detail']) ?></small>
</div><?php endforeach; ?>
</div>
</section>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-lg-6">
<section class="card h-100 <?= $updateStatus['available'] ? 'border-primary' : '' ?>">
<div class="card-header"><h3 class="card-title"><?= h(t('updates.automatic')) ?></h3></div>
<form method="post" action="/settings/updates/install" onsubmit="return confirm(<?= h(json_encode(t('updates.install_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>)">
<div class="card-body">
<p><?= h(t('updates.automatic_help')) ?></p>
<label class="form-label" for="updatePassword"><?= h(t('updates.password')) ?></label>
<input class="form-control" id="updatePassword" type="password" name="password" autocomplete="current-password" required>
</div>
<div class="card-footer text-end"><button class="btn btn-primary" <?= !$ready || !$updateStatus['available'] ? 'disabled' : '' ?>><?= h(t('updates.backup_and_install')) ?></button></div>
</form>
</section>
</div>
<div class="col-lg-6">
<section class="card h-100">
<div class="card-header"><h3 class="card-title"><?= h(t('updates.manual_archive')) ?></h3></div>
<form method="post" action="/settings/updates/upload" enctype="multipart/form-data" onsubmit="return confirm(<?= h(json_encode(t('updates.install_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>)">
<div class="card-body">
<p><?= h(t('updates.manual_help')) ?></p>
<label class="form-label" for="updateRelease"><?= h(t('updates.archive')) ?></label>
<input class="form-control" id="updateRelease" type="file" name="release" accept=".tar.gz,.tgz,application/gzip" required>
<label class="form-label mt-3" for="updateHash"><?= h(t('updates.sha256')) ?></label>
<input class="form-control font-monospace" id="updateHash" name="sha256" minlength="64" maxlength="64" pattern="[a-fA-F0-9]{64}" required>
<label class="form-label mt-3" for="uploadPassword"><?= h(t('updates.password')) ?></label>
<input class="form-control" id="uploadPassword" type="password" name="password" autocomplete="current-password" required>
</div>
<div class="card-footer text-end"><button class="btn btn-outline-primary" <?= !$ready ? 'disabled' : '' ?>><?= h(t('updates.install_archive')) ?></button></div>
</form>
</section>
</div>
</div>
<?php if ($lastUpdate): ?><section class="card border-warning">
<div class="card-header"><h3 class="card-title"><?= h(t('updates.rollback')) ?></h3></div>
<form method="post" action="/settings/updates/rollback" onsubmit="return confirm(<?= h(json_encode(t('updates.rollback_confirm'), JSON_HEX_APOS | JSON_HEX_QUOT)) ?>)">
<div class="card-body">
<p><?= h(t('updates.rollback_help', ['version' => (string) $lastUpdate['from'], 'date' => (string) $lastUpdate['installed_at']])) ?></p>
<div class="alert alert-warning"><?= h(t('updates.rollback_data_warning')) ?></div>
<label class="form-label" for="rollbackPassword"><?= h(t('updates.password')) ?></label>
<input class="form-control" id="rollbackPassword" type="password" name="password" autocomplete="current-password" required>
</div>
<div class="card-footer text-end"><button class="btn btn-warning"><?= h(t('updates.rollback_button', ['version' => (string) $lastUpdate['from']])) ?></button></div>
</form>
</section><?php endif; ?>
<div class="d-flex flex-wrap justify-content-between gap-2 mt-3">
<div class="text-muted small"><?= h(t('updates.privacy')) ?><br><code><?= h(UpdateService::repositoryUrl()) ?></code></div>
<?php if (UpdateService::logPath()): ?><a class="btn btn-sm btn-outline-secondary" href="/settings/updates/log">⬇ <?= h(t('updates.download_log')) ?></a><?php endif; ?>
</div>