Les installations hors YunoHost peuvent désormais vérifier et installer une release Forgejo avec sauvegarde et retour arrière complet.
606 lines
25 KiB
PHP
606 lines
25 KiB
PHP
<?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';
|
|
}
|
|
}
|