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:
parent
f41504c852
commit
53e79a72d7
25 changed files with 1146 additions and 61 deletions
13
CHANGELOG.md
13
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
1.0.0-rc.15
|
||||
1.0.0-rc.16
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' => '',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
606
app/Services/UpdateService.php
Normal file
606
app/Services/UpdateService.php
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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']) ?>
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ?? ''));
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
92
app/Views/settings_updates.php
Normal file
92
app/Views/settings_updates.php
Normal 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>
|
||||
49
docs/mises-a-jour.md
Normal file
49
docs/mises-a-jour.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Publier et installer une mise à jour
|
||||
|
||||
Globinours consulte directement les releases de son dépôt Forgejo. L’adresse du dépôt est configurable dans `.env` :
|
||||
|
||||
```dotenv
|
||||
GLOBINOURS_RELEASES_REPOSITORY=https://nnsprod.com/git/oversu/Globinours
|
||||
```
|
||||
|
||||
À partir de cette adresse, Globinours déduit automatiquement l’API Forgejo. Aucun jeton n’est nécessaire tant que le dépôt et ses releases sont publics.
|
||||
|
||||
## Contenu d’une release
|
||||
|
||||
Une release stable doit porter un tag de version, par exemple `1.0.1`, et contenir les deux assets suivants :
|
||||
|
||||
```text
|
||||
globinours-1.0.1.tar.gz
|
||||
SHA256SUMS
|
||||
```
|
||||
|
||||
Le fichier `SHA256SUMS` contient l’empreinte de l’archive :
|
||||
|
||||
```text
|
||||
0f00000000000000000000000000000000000000000000000000000000000000 globinours-1.0.1.tar.gz
|
||||
```
|
||||
|
||||
Un fichier nommé `globinours-1.0.1.tar.gz.sha256` peut remplacer `SHA256SUMS`. Les brouillons et préversions Forgejo sont ignorés par le canal stable.
|
||||
|
||||
L’archive doit contenir un fichier `VERSION`, le dossier `app`, le dossier `migrations` et `public/index.php`, directement ou sous un unique dossier parent. Les dossiers `.env`, `data`, `storage` et `public/media` éventuellement présents sont toujours ignorés.
|
||||
|
||||
## Depuis l’interface
|
||||
|
||||
Un administrateur ouvre **Paramètres → Mises à jour**. Il peut installer la dernière release stable ou téléverser lui-même son archive et son empreinte SHA-256. Son mot de passe est demandé avant toute installation ou restauration.
|
||||
|
||||
## En ligne de commande
|
||||
|
||||
```bash
|
||||
php scripts/update.php --check
|
||||
php scripts/update.php --online
|
||||
php scripts/update.php globinours-1.0.1.tar.gz --sha256=EMPREINTE
|
||||
php scripts/update.php --rollback
|
||||
```
|
||||
|
||||
Avant de remplacer le code, Globinours crée et vérifie une sauvegarde complète. Il conserve ensuite une copie du code précédent dans `storage/releases`. Si une migration ou un contrôle final échoue, le code et les données sont restaurés automatiquement.
|
||||
|
||||
Le retour arrière manuel restaure également les données telles qu’elles étaient avant la mise à jour. Une nouvelle sauvegarde de l’état courant est créée avant cette opération.
|
||||
|
||||
## Détection des versions
|
||||
|
||||
La vérification est effectuée au plus une fois par jour pour un administrateur connecté et pendant la maintenance planifiée. Elle ne transmet aucune donnée du refuge. Seuls le nom et la version de Globinours figurent dans l’identifiant HTTP.
|
||||
|
|
@ -13,6 +13,7 @@ require_once __DIR__ . '/../app/Services/TrustedDeviceService.php';
|
|||
require_once __DIR__ . '/../app/Services/PermissionService.php';
|
||||
require_once __DIR__ . '/../app/Services/AuditService.php';
|
||||
require_once __DIR__ . '/../app/Services/BackupService.php';
|
||||
require_once __DIR__ . '/../app/Services/UpdateService.php';
|
||||
require_once __DIR__ . '/../app/Services/AppSettings.php';
|
||||
require_once __DIR__ . '/../app/Services/PublicContentFormatter.php';
|
||||
require_once __DIR__ . '/../app/Services/InstallationService.php';
|
||||
|
|
|
|||
|
|
@ -127,4 +127,75 @@ return [
|
|||
'care.noon'=>'noon','care.afternoon'=>'afternoon','rooms.admission_rules'=>'Optional admission criteria','rooms.care_periods'=>'Rounds scheduled for this room','rooms.period_required'=>'Select at least one care-round period per room.','rooms.animal_not_allowed'=>'This animal does not match the admission criteria of the selected room.',
|
||||
'pricing.before_sterilization'=>'Before sterilisation','pricing.after_sterilization'=>'After sterilisation','pricing.free_fee'=>'Open amount','pricing.free'=>'Open','pricing.adoption_fees_help'=>'Indicative amounts by species. Enable “Open amount” when no fixed fee is required.','animal.sterilization_auto_free'=>'The adoption fee for this species remains open after sterilisation.',
|
||||
'pricing.delete_confirm'=>'Delete this pricing item? Previously recorded expenses will retain their label and amount.','pricing.edit_tariff'=>'Edit pricing item',
|
||||
'settings.updates'=>'Updates',
|
||||
'updates.title'=>'Settings — Updates',
|
||||
'updates.heading'=>'Globinours updates',
|
||||
'updates.intro'=>'Check, back up, install and roll back from one screen.',
|
||||
'updates.version_status'=>'Version status',
|
||||
'updates.installed_version'=>'Installed version',
|
||||
'updates.available_version'=>'Latest known version',
|
||||
'updates.last_check'=>'Last check',
|
||||
'updates.available'=>'Version :version is available.',
|
||||
'updates.available_short'=>'Update available',
|
||||
'updates.current'=>'Globinours is up to date.',
|
||||
'updates.checked'=>'The update check is complete.',
|
||||
'updates.check_error'=>'The last check failed: :error',
|
||||
'updates.check_now'=>'Check now',
|
||||
'updates.release_notes'=>'Read release notes',
|
||||
'updates.preflight'=>'Hosting compatibility',
|
||||
'updates.check_php'=>'PHP version',
|
||||
'updates.check_zip'=>'ZIP backup support',
|
||||
'updates.check_phar'=>'TAR archive support',
|
||||
'updates.check_root'=>'Application files are writable',
|
||||
'updates.check_public'=>'Public assets are writable',
|
||||
'updates.check_storage'=>'Private storage is writable',
|
||||
'updates.check_disk'=>'Available disk space',
|
||||
'updates.unknown'=>'Unknown',
|
||||
'updates.automatic'=>'Official update',
|
||||
'updates.automatic_help'=>'The latest stable Forgejo release is fetched again, then its archive and SHA-256 fingerprint are checked before any change.',
|
||||
'updates.password'=>'Administrator password',
|
||||
'updates.backup_and_install'=>'Back up and update',
|
||||
'updates.manual_archive'=>'Manual installation',
|
||||
'updates.manual_help'=>'For a server without outbound access, upload the official archive and copy its published SHA-256 fingerprint.',
|
||||
'updates.archive'=>'Globinours archive (.tar.gz)',
|
||||
'updates.sha256'=>'SHA-256 fingerprint published with the release',
|
||||
'updates.install_archive'=>'Verify and install',
|
||||
'updates.install_confirm'=>'Start the update? A complete backup will be created and verified before installation.',
|
||||
'updates.installed'=>'Globinours :version is installed. The rollback backup was retained.',
|
||||
'updates.failed'=>'Update failed:',
|
||||
'updates.rollback'=>'Return to the previous version',
|
||||
'updates.rollback_help'=>'Version :version and the data backed up before the :date update are available.',
|
||||
'updates.rollback_data_warning'=>'Changes entered after this backup will be replaced. The current state will be backed up before rollback.',
|
||||
'updates.rollback_confirm'=>'Return to the previous version? Data will be restored to its state before the update.',
|
||||
'updates.rollback_button'=>'Return to :version',
|
||||
'updates.privacy'=>'The check only sends the Globinours version in the HTTP user agent. No shelter data is transmitted.',
|
||||
'updates.password_invalid'=>'The administrator password is incorrect.',
|
||||
'updates.temp_failed'=>'Unable to prepare the temporary file.',
|
||||
'updates.archive_invalid'=>'The update archive is missing, too large or invalid.',
|
||||
'updates.hash_mismatch'=>'The SHA-256 fingerprint does not match the archive.',
|
||||
'updates.preflight_failed'=>'The “:check” check prevents automatic updating.',
|
||||
'updates.locked'=>'Another update is already running.',
|
||||
'updates.maintenance_failed'=>'Maintenance mode could not be enabled.',
|
||||
'updates.not_newer'=>'Version :version is not newer than the installed version.',
|
||||
'updates.rollback_failed'=>'The update failed (:update) and automatic rollback also failed (:rollback).',
|
||||
'updates.no_rollback'=>'No complete rollback is available.',
|
||||
'updates.release_invalid'=>'The forge response does not contain a valid release list.',
|
||||
'updates.no_release'=>'No complete stable release has been published in this repository yet.',
|
||||
'updates.repository_invalid'=>'The Forgejo repository address is invalid.',
|
||||
'updates.checksum_missing'=>'The release contains neither SHA256SUMS nor a checksum file matching its archive.',
|
||||
'updates.checksum_invalid'=>'The archive fingerprint is missing or invalid in the release.',
|
||||
'updates.php_incompatible'=>'This version requires PHP :version or newer.',
|
||||
'updates.curl_missing'=>'The cURL extension is required for online updates.',
|
||||
'updates.network_failed'=>'The download failed (:error).',
|
||||
'updates.url_invalid'=>'The update address must be a safe HTTPS URL.',
|
||||
'updates.archive_unsafe'=>'The archive contains an unsafe path or symbolic link.',
|
||||
'updates.archive_too_large'=>'The extracted archive exceeds the allowed limits.',
|
||||
'updates.extract_failed'=>'The archive could not be fully extracted.',
|
||||
'updates.release_missing'=>'The release does not contain the required component: :path.',
|
||||
'updates.symlink_refused'=>'The :path symbolic link cannot be included in application code.',
|
||||
'updates.copy_failed'=>'Unable to copy file :path.',
|
||||
'updates.postcheck_failed'=>'Post-installation checks failed.',
|
||||
'updates.directory_failed'=>'Unable to prepare directory :path.',
|
||||
'updates.download_log'=>'Download technical log',
|
||||
'updates.log_missing'=>'No update log is available.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -127,4 +127,75 @@ return [
|
|||
'care.noon'=>'midi','care.afternoon'=>'après-midi','rooms.admission_rules'=>'Critères d’accueil facultatifs','rooms.care_periods'=>'Tournées prévues dans cette salle','rooms.period_required'=>'Sélectionnez au moins un créneau de tournée par salle.','rooms.animal_not_allowed'=>'Cet animal ne correspond pas aux critères d’accueil de la salle choisie.',
|
||||
'pricing.before_sterilization'=>'Avant stérilisation','pricing.after_sterilization'=>'Après stérilisation','pricing.free_fee'=>'Tarif libre','pricing.free'=>'Libre','pricing.adoption_fees_help'=>'Montants indicatifs par espèce. Activez « Tarif libre » lorsqu’aucun montant fixe n’est imposé.','animal.sterilization_auto_free'=>'Le tarif d’adoption de cette espèce reste libre après la stérilisation.',
|
||||
'pricing.delete_confirm'=>'Supprimer ce tarif ? Les dépenses déjà enregistrées conserveront leur libellé et leur montant.','pricing.edit_tariff'=>'Modifier le tarif',
|
||||
'settings.updates'=>'Mises à jour',
|
||||
'updates.title'=>'Paramètres — Mises à jour',
|
||||
'updates.heading'=>'Mises à jour de Globinours',
|
||||
'updates.intro'=>'Vérification, sauvegarde, installation et retour arrière depuis un même écran.',
|
||||
'updates.version_status'=>'État de la version',
|
||||
'updates.installed_version'=>'Version installée',
|
||||
'updates.available_version'=>'Dernière version connue',
|
||||
'updates.last_check'=>'Dernière vérification',
|
||||
'updates.available'=>'La version :version est disponible.',
|
||||
'updates.available_short'=>'Mise à jour disponible',
|
||||
'updates.current'=>'Globinours est à jour.',
|
||||
'updates.checked'=>'La recherche de mise à jour est terminée.',
|
||||
'updates.check_error'=>'La dernière vérification a échoué : :error',
|
||||
'updates.check_now'=>'Rechercher maintenant',
|
||||
'updates.release_notes'=>'Consulter les notes de version',
|
||||
'updates.preflight'=>'Compatibilité de l’hébergement',
|
||||
'updates.check_php'=>'Version de PHP',
|
||||
'updates.check_zip'=>'Création des sauvegardes ZIP',
|
||||
'updates.check_phar'=>'Lecture des archives TAR',
|
||||
'updates.check_root'=>'Écriture des fichiers applicatifs',
|
||||
'updates.check_public'=>'Écriture des ressources publiques',
|
||||
'updates.check_storage'=>'Écriture du stockage privé',
|
||||
'updates.check_disk'=>'Espace disque disponible',
|
||||
'updates.unknown'=>'Inconnu',
|
||||
'updates.automatic'=>'Mise à jour officielle',
|
||||
'updates.automatic_help'=>'La dernière release stable est relue sur Forgejo, puis son archive et son empreinte SHA-256 sont contrôlées avant toute modification.',
|
||||
'updates.password'=>'Mot de passe administrateur',
|
||||
'updates.backup_and_install'=>'Sauvegarder et mettre à jour',
|
||||
'updates.manual_archive'=>'Installation manuelle',
|
||||
'updates.manual_help'=>'Pour un serveur sans accès sortant, téléversez l’archive officielle et recopiez son empreinte SHA-256.',
|
||||
'updates.archive'=>'Archive Globinours (.tar.gz)',
|
||||
'updates.sha256'=>'Empreinte SHA-256 publiée avec la release',
|
||||
'updates.install_archive'=>'Vérifier et installer',
|
||||
'updates.install_confirm'=>'Lancer la mise à jour ? Une sauvegarde complète sera créée et vérifiée avant l’installation.',
|
||||
'updates.installed'=>'Globinours :version est installé. La sauvegarde de retour arrière a été conservée.',
|
||||
'updates.failed'=>'Mise à jour impossible :',
|
||||
'updates.rollback'=>'Retour à la version précédente',
|
||||
'updates.rollback_help'=>'La version :version et les données sauvegardées avant la mise à jour du :date sont disponibles.',
|
||||
'updates.rollback_data_warning'=>'Les modifications saisies après cette sauvegarde seront remplacées. Une sauvegarde de l’état actuel sera créée avant le retour arrière.',
|
||||
'updates.rollback_confirm'=>'Revenir à la version précédente ? Les données seront replacées dans l’état précédant la mise à jour.',
|
||||
'updates.rollback_button'=>'Revenir à :version',
|
||||
'updates.privacy'=>'La vérification transmet uniquement la version de Globinours dans l’identifiant HTTP. Aucune donnée du refuge n’est envoyée.',
|
||||
'updates.password_invalid'=>'Le mot de passe administrateur est incorrect.',
|
||||
'updates.temp_failed'=>'Impossible de préparer le fichier temporaire.',
|
||||
'updates.archive_invalid'=>'L’archive de mise à jour est absente, trop volumineuse ou invalide.',
|
||||
'updates.hash_mismatch'=>'L’empreinte SHA-256 ne correspond pas à l’archive.',
|
||||
'updates.preflight_failed'=>'Le contrôle « :check » empêche la mise à jour automatique.',
|
||||
'updates.locked'=>'Une autre mise à jour est déjà en cours.',
|
||||
'updates.maintenance_failed'=>'Le mode maintenance n’a pas pu être activé.',
|
||||
'updates.not_newer'=>'La version :version n’est pas plus récente que la version installée.',
|
||||
'updates.rollback_failed'=>'La mise à jour a échoué (:update) et le retour arrière automatique a également échoué (:rollback).',
|
||||
'updates.no_rollback'=>'Aucun retour arrière complet n’est disponible.',
|
||||
'updates.release_invalid'=>'La réponse de la forge ne contient pas une liste de releases valide.',
|
||||
'updates.no_release'=>'Aucune release stable complète n’est encore publiée sur ce dépôt.',
|
||||
'updates.repository_invalid'=>'L’adresse du dépôt Forgejo est invalide.',
|
||||
'updates.checksum_missing'=>'La release ne contient ni fichier SHA256SUMS ni empreinte associée à son archive.',
|
||||
'updates.checksum_invalid'=>'L’empreinte de l’archive est absente ou invalide dans la release.',
|
||||
'updates.php_incompatible'=>'Cette version nécessite PHP :version ou plus récent.',
|
||||
'updates.curl_missing'=>'L’extension cURL est nécessaire pour les mises à jour en ligne.',
|
||||
'updates.network_failed'=>'Le téléchargement a échoué (:error).',
|
||||
'updates.url_invalid'=>'L’adresse de mise à jour doit être une URL HTTPS sûre.',
|
||||
'updates.archive_unsafe'=>'L’archive contient un chemin ou un lien symbolique dangereux.',
|
||||
'updates.archive_too_large'=>'Le contenu décompressé de l’archive dépasse les limites autorisées.',
|
||||
'updates.extract_failed'=>'Impossible d’extraire complètement l’archive.',
|
||||
'updates.release_missing'=>'La release ne contient pas le composant requis : :path.',
|
||||
'updates.symlink_refused'=>'Le lien symbolique :path ne peut pas être intégré au code.',
|
||||
'updates.copy_failed'=>'Impossible de copier le fichier :path.',
|
||||
'updates.postcheck_failed'=>'Les contrôles effectués après l’installation ont échoué.',
|
||||
'updates.directory_failed'=>'Impossible de préparer le dossier :path.',
|
||||
'updates.download_log'=>'Télécharger le journal technique',
|
||||
'updates.log_missing'=>'Aucun journal de mise à jour n’est disponible.',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ foreach (
|
|||
'Auth',
|
||||
'AuditService',
|
||||
'BackupService',
|
||||
'UpdateService',
|
||||
'NotificationService',
|
||||
'PrivacyService',
|
||||
]
|
||||
|
|
@ -36,6 +37,9 @@ try {
|
|||
if (in_array($command, ['run', 'notifications'], true)) {
|
||||
$results['notifications'] = NotificationService::send(false);
|
||||
}
|
||||
if (in_array($command, ['run', 'updates'], true)) {
|
||||
$results['updates'] = UpdateService::refreshIfDue();
|
||||
}
|
||||
echo json_encode(
|
||||
['ok' => true, 'at' => date(DATE_ATOM), 'results' => $results],
|
||||
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE,
|
||||
|
|
|
|||
|
|
@ -2,15 +2,4 @@
|
|||
set -euo pipefail
|
||||
|
||||
root_dir="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
snapshot="${1:-}"
|
||||
if [[ -z "$snapshot" || ! -f "$snapshot" || "$(basename "$snapshot")" != code-*.tar.gz ]]; then
|
||||
echo "Usage: $0 storage/releases/code-VERSION-DATE.tar.gz" >&2
|
||||
exit 2
|
||||
fi
|
||||
case "$(realpath "$snapshot")" in "$root_dir"/storage/releases/*) ;; *) echo "Snapshot hors de storage/releases refusé." >&2; exit 2;; esac
|
||||
touch "$root_dir/storage/maintenance.flag"
|
||||
trap 'rm -f "$root_dir/storage/maintenance.flag"' EXIT
|
||||
tar -xzf "$snapshot" -C "$root_dir"
|
||||
php "$root_dir/scripts/migrate.php"
|
||||
rm -f "$root_dir/storage/maintenance.flag"
|
||||
echo "Code restauré. La base n’a pas été rétrogradée automatiquement."
|
||||
exec php "$root_dir/scripts/update.php" --rollback
|
||||
|
|
|
|||
56
scripts/update.php
Normal file
56
scripts/update.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
if (PHP_SAPI !== 'cli') {
|
||||
http_response_code(404);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
foreach (['DB', 'Migrations', 'I18n', 'AppSettings', 'Auth', 'AuditService', 'BackupService', 'UpdateService'] as $class) {
|
||||
require_once $root . '/app/Services/' . $class . '.php';
|
||||
}
|
||||
|
||||
Migrations::run($root . '/migrations');
|
||||
I18n::setLocale(AppSettings::get('app_language'));
|
||||
|
||||
$arguments = array_slice($argv, 1);
|
||||
$command = $arguments[0] ?? '--help';
|
||||
|
||||
try {
|
||||
if ($command === '--check') {
|
||||
echo json_encode(UpdateService::check(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PHP_EOL;
|
||||
exit(0);
|
||||
}
|
||||
if ($command === '--rollback') {
|
||||
echo json_encode(UpdateService::rollbackLast(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PHP_EOL;
|
||||
exit(0);
|
||||
}
|
||||
if ($command === '--online') {
|
||||
echo json_encode(UpdateService::installFromRepository(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PHP_EOL;
|
||||
exit(0);
|
||||
}
|
||||
if (is_file($command)) {
|
||||
$hash = '';
|
||||
foreach ($arguments as $argument) {
|
||||
if (str_starts_with($argument, '--sha256=')) {
|
||||
$hash = substr($argument, 9);
|
||||
}
|
||||
}
|
||||
if ($hash === '') {
|
||||
throw new InvalidArgumentException('L’option --sha256=<empreinte> est obligatoire.');
|
||||
}
|
||||
echo json_encode(UpdateService::install($command, $hash, 'cli'), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), PHP_EOL;
|
||||
exit(0);
|
||||
}
|
||||
fwrite(STDERR, "Utilisation :\n");
|
||||
fwrite(STDERR, " php scripts/update.php --check\n");
|
||||
fwrite(STDERR, " php scripts/update.php --online\n");
|
||||
fwrite(STDERR, " php scripts/update.php archive.tar.gz --sha256=EMPREINTE\n");
|
||||
fwrite(STDERR, " php scripts/update.php --rollback\n");
|
||||
exit(2);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, $e->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
|
|
@ -2,42 +2,4 @@
|
|||
set -euo pipefail
|
||||
|
||||
root_dir="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
release_archive="${1:-}"
|
||||
if [[ -z "$release_archive" || ! -f "$release_archive" ]]; then
|
||||
echo "Usage: $0 /chemin/vers/globinours.tar.gz" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -f "$root_dir/public/index.php" || ! -f "$root_dir/VERSION" ]]; then
|
||||
echo "Installation Globinours invalide: $root_dir" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
stage_dir="$(mktemp -d /tmp/globinours-update-XXXXXX)"
|
||||
trap 'rm -rf "$stage_dir"; rm -f "$root_dir/storage/maintenance.flag"' EXIT
|
||||
if tar -tzf "$release_archive" | grep -Eq '(^/|(^|/)\.\.(/|$))'; then
|
||||
echo "Archive refusée: chemin dangereux." >&2
|
||||
exit 3
|
||||
fi
|
||||
tar -xzf "$release_archive" -C "$stage_dir"
|
||||
source_dir="$stage_dir"
|
||||
if [[ ! -f "$source_dir/public/index.php" ]]; then
|
||||
first_dir="$(find "$stage_dir" -mindepth 1 -maxdepth 1 -type d | head -n 1)"
|
||||
source_dir="$first_dir"
|
||||
fi
|
||||
if [[ ! -f "$source_dir/public/index.php" || ! -f "$source_dir/VERSION" ]]; then
|
||||
echo "Archive Globinours invalide." >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
mkdir -p "$root_dir/storage/releases"
|
||||
current_version="$(tr -d '[:space:]' < "$root_dir/VERSION")"
|
||||
new_version="$(tr -d '[:space:]' < "$source_dir/VERSION")"
|
||||
rollback_archive="$root_dir/storage/releases/code-${current_version}-$(date +%Y%m%d-%H%M%S).tar.gz"
|
||||
php "$root_dir/scripts/backup.php" pre-upgrade
|
||||
tar --exclude='./data' --exclude='./storage' --exclude='./public/media' --exclude='./.env' -czf "$rollback_archive" -C "$root_dir" .
|
||||
touch "$root_dir/storage/maintenance.flag"
|
||||
rsync -a --delete --exclude=data --exclude=storage --exclude=public/media --exclude=.env "$source_dir/" "$root_dir/"
|
||||
php "$root_dir/scripts/migrate.php"
|
||||
rm -f "$root_dir/storage/maintenance.flag"
|
||||
echo "Globinours $current_version -> $new_version"
|
||||
echo "Retour arrière du code: $rollback_archive"
|
||||
exec php "$root_dir/scripts/update.php" "$@"
|
||||
|
|
|
|||
Loading…
Reference in a new issue