154 lines
5.1 KiB
PHP
154 lines
5.1 KiB
PHP
<?php
|
|
|
|
namespace Toolbox;
|
|
|
|
/**
|
|
* Autoactualización del instalador global: detecta la última release vía la
|
|
* API de Forgejo/Gitea eligiendo el mayor tag semver de la lista (igual que
|
|
* el core con sus tags), o un directorio plano; descarga el phar, verifica
|
|
* su sha256 contra el publicado y reemplaza el binario de forma atómica.
|
|
*/
|
|
final class SelfUpdater
|
|
{
|
|
public function __construct(private readonly Console $console)
|
|
{
|
|
}
|
|
|
|
public function update(string $from, string $target, string $tempDir, bool $force = false): void
|
|
{
|
|
[$source, $sumUrl, $tag] = $this->resolve($from);
|
|
|
|
if ($tag !== null) {
|
|
$remote = ltrim($tag, 'vV');
|
|
if (!$force && Semver::compare(Config::VERSION, $remote) >= 0) {
|
|
$this->console->out(' ya está actualizado (v' . Config::VERSION . ')');
|
|
return;
|
|
}
|
|
}
|
|
|
|
$this->console->out(' descargando ' . $source);
|
|
$data = @file_get_contents($source);
|
|
if ($data === false) {
|
|
throw new ToolboxException("No se pudo descargar el instalador: {$source}");
|
|
}
|
|
|
|
$expected = trim((string) @file_get_contents($sumUrl));
|
|
if ($expected === '') {
|
|
throw new ToolboxException("No se pudo leer el sha256 publicado: {$sumUrl}");
|
|
}
|
|
|
|
$actual = hash('sha256', $data);
|
|
if (!hash_equals($expected, $actual)) {
|
|
throw new ToolboxException('sha256 inválido; actualización abortada sin tocar el binario');
|
|
}
|
|
|
|
if (!is_dir($tempDir)) {
|
|
mkdir($tempDir, 0777, true);
|
|
}
|
|
|
|
$temp = rtrim($tempDir, '/') . '/duckbrain.phar.' . bin2hex(random_bytes(4));
|
|
file_put_contents($temp, $data);
|
|
chmod($temp, 0755);
|
|
|
|
if (!rename($temp, $target)) {
|
|
@unlink($temp);
|
|
throw new ToolboxException("No se pudo reemplazar el instalador: {$target}");
|
|
}
|
|
|
|
$this->console->ok('instalador actualizado' . ($tag !== null ? ' a v' . ltrim($tag, 'vV') : '') . ' (' . $target . ')');
|
|
}
|
|
|
|
public function available(string $from): ?string
|
|
{
|
|
return $this->resolve($from)[2];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: string, 1: string, 2: ?string} [URL del phar, URL del sha256, tag o null]
|
|
*/
|
|
private function resolve(string $from): array
|
|
{
|
|
if (!preg_match('#/releases(\?|$|/)|\.json$#', $from)) {
|
|
$source = rtrim($from, '/') . '/duckbrain.phar';
|
|
return [$source, $source . '.sha256', null];
|
|
}
|
|
|
|
$json = @file_get_contents($from);
|
|
if ($json === false) {
|
|
throw new ToolboxException("No se pudo leer la release: {$from}");
|
|
}
|
|
|
|
$data = json_decode($json, true);
|
|
if (!is_array($data)) {
|
|
throw new ToolboxException("Respuesta de release inválida: {$from}");
|
|
}
|
|
|
|
$candidates = [];
|
|
foreach (array_is_list($data) ? $data : [$data] as $release) {
|
|
$tag = is_array($release) ? (string) ($release['tag_name'] ?? '') : '';
|
|
if ($tag === '' || (is_array($release) && ($release['draft'] ?? false)) || !is_array($release)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
$version = Semver::normalize($tag);
|
|
Semver::parse($version);
|
|
} catch (ToolboxException) {
|
|
continue;
|
|
}
|
|
|
|
$assets = $this->assets((array) $release, $from);
|
|
if ($assets === null) {
|
|
continue;
|
|
}
|
|
|
|
$candidates[] = ['version' => $version, 'tag' => $tag, 'phar' => $assets[0], 'sum' => $assets[1]];
|
|
}
|
|
|
|
if ($candidates === []) {
|
|
throw new ToolboxException("Ninguna release publicada en '{$from}' incluye duckbrain.phar con su sha256");
|
|
}
|
|
|
|
usort($candidates, static fn (array $a, array $b): int => Semver::compare($b['version'], $a['version']));
|
|
|
|
$best = $candidates[0];
|
|
|
|
return [$best['phar'], $best['sum'], $best['tag']];
|
|
}
|
|
|
|
/**
|
|
* @return array{0: string, 1: string}|null
|
|
*/
|
|
private function assets(array $release, string $from): ?array
|
|
{
|
|
$phar = $sum = null;
|
|
foreach ($release['assets'] ?? [] as $asset) {
|
|
$name = (string) ($asset['name'] ?? '');
|
|
if ($name === 'duckbrain.phar' || $name === 'duckbrain.phar.sha256') {
|
|
$url = $this->absolute((string) ($asset['browser_download_url'] ?? $asset['download_url'] ?? ''), $from);
|
|
if ($url === '') {
|
|
return null;
|
|
}
|
|
if ($name === 'duckbrain.phar') {
|
|
$phar = $url;
|
|
} else {
|
|
$sum = $url;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $phar !== null && $sum !== null ? [$phar, $sum] : null;
|
|
}
|
|
|
|
private function absolute(string $url, string $base): string
|
|
{
|
|
if ($url === '' || preg_match('#^(https?|file)://#', $url)) {
|
|
return $url;
|
|
}
|
|
|
|
$parts = parse_url($base);
|
|
|
|
return ($parts['scheme'] ?? 'https') . '://' . ($parts['host'] ?? '') . $url;
|
|
}
|
|
}
|