diff --git a/.gitignore b/.gitignore index b30abb35c..9ccd6b211 100644 --- a/.gitignore +++ b/.gitignore @@ -601,6 +601,7 @@ pinakes-v*-local.zip.sha256 # Reinstall / upgrade regression runbook (local-only, not committed) reinstall_test.md tests/manual-upgrade-real.spec.js +tests/auto-upgrade-real.spec.js docs/reference/start-server.md docs/reference/security-audit-report.md docs/reference/routes-to-add.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6790ec798..c211c9d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ Full version-by-version history for Pinakes. The README shows only the latest release; everything older lives here. +## [0.7.92] + +### Fixed +- **The automatic update no longer fails with "The server returned an invalid response"** ([#450](https://github.com/fabiodalez-dev/Pinakes/issues/450)). It ran the backup, the download from GitHub, the file copy and the migrations in one long request, holding the 30 MB package in memory; on hosting that cuts long requests off behind a proxy, or will not raise PHP's memory limit, the request ended with an error page instead of an answer, while uploading the same package by hand worked. The automatic update now runs as two requests, like a manual one: the server downloads the release and verifies its sha256, then installs it through the same request a manual update ends with. The package is written to disk as it arrives instead of being held in memory, by the single-request update too. +- **A failed update says why.** A PHP fatal error during an update (memory, a time limit the host enforces) now comes back as a message with PHP's own reason, and an answer that is not JSON at all, such as a proxy's timeout page, is shown with its HTTP status and the start of its text. Before, both read only "invalid response". The update requests also ask for JSON, so an expired session answers in words the page can show. +- **A download that breaks off is reported as one.** On a slow link the package download can stop partway, for instance on a timeout; the partial file then reached the checksum, and the update failed with "the archive does not match the expected checksum", which reads like a tampered release. It now fails as a download, with the transport's reason. Packages downloaded or uploaded for an install that never ran (a closed tab, a session that expired in between) are also removed after an hour, with the other temporary update folders, instead of staying in storage/tmp. +- **The install request installs the package its own page prepared.** A download and an upload, in two tabs of the same session, shared one waiting package, and the install request took whichever came last. The page now sends back the id of the package it downloaded or uploaded; a package replaced in another tab is refused, not installed, and the replaced one is deleted. A downloaded package is checked against its sha256 again right before it is installed. + +This fix runs from the update after 0.7.92: an installation older than 0.7.92 still updates with its own, older updater. If the automatic update fails there, upload the 0.7.92 package under Admin → Updates → Manual update. + ## [0.7.91] ### Added diff --git a/README.md b/README.md index 9dfd167ee..60fbcc73e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,11 @@ Pinakes is a self-hosted, full-featured ILS for schools, municipalities, and pri Highlights of the latest release are below. The full version-by-version history (v0.7.59 → v0.6.x) lives in **[CHANGELOG.md](CHANGELOG.md)**. -### v0.7.91 — latest +### v0.7.92 — latest + +**The automatic update works where it used to answer "invalid response"** ([#450](https://github.com/fabiodalez-dev/Pinakes/issues/450)). It now downloads and verifies the release in one request and installs it in a second, exactly as a manual update does, and writes the package to disk instead of holding it in memory; a failure shows its real cause (a PHP error, or a proxy's HTTP status and page). An installation older than 0.7.92 updates with its own updater: if the automatic update fails there, upload the 0.7.92 package once under Admin → Updates → Manual update. No migration. + +### v0.7.91 **danMARC2, the Danish format, can be imported.** A new DBC preset in Plugins → Z39.50/SRU searches the Danish union catalogue, and any SRU server that answers in danMARC2 works too; before, a danMARC2 record could not be read at all. **Who did what is read from more records:** a translator or editor the record names without a role is found in the title-page statement ("translated by…"), roles are understood in German, French, Spanish, the Scandinavian languages, Dutch and Polish, and a colorist goes to the Colorist picker. Places and editions lose their ISBD brackets ("London [u.a.]" is "London"), and the form gets the ISBN that was searched for. No migration. diff --git a/app/Controllers/UpdateController.php b/app/Controllers/UpdateController.php index 2972886a0..6241dc067 100644 --- a/app/Controllers/UpdateController.php +++ b/app/Controllers/UpdateController.php @@ -151,6 +151,7 @@ public function performUpdate(Request $request, Response $response, mysqli $db): } // Perform the update + $this->answerJsonOnFatal(); $result = $updater->performUpdate($targetVersion); if ($result['success']) { @@ -622,10 +623,10 @@ public function uploadUpdate(Request $request, Response $response, mysqli $db): if ($result['success']) { // Store path in session to avoid leaking filesystem paths to client - $_SESSION['manual_update_path'] = $result['path']; return $this->jsonResponse($response, [ 'success' => true, - 'message' => __('Pacchetto caricato con successo') + 'message' => __('Pacchetto caricato con successo'), + 'package' => $this->holdPendingPackage($updater, (string) $result['path'], null), ]); } @@ -643,6 +644,115 @@ public function uploadUpdate(Request $request, Response $response, mysqli $db): } } + /** + * API: Download the release package for an automatic update. + * + * First of the two requests of an automatic update: the server fetches the + * release from GitHub, verifies its sha256 and keeps it under storage/tmp; + * the page then installs it through install-manual, the same request a + * manual update ends with. Splitting them keeps each request short enough + * for hosting that cuts long requests off behind a proxy (issue #450). + */ + public function downloadUpdatePackage(Request $request, Response $response, mysqli $db): Response + { + if (($_SESSION['user']['tipo_utente'] ?? '') !== 'admin') { + return $this->jsonResponse($response, ['error' => __('Operazione riservata agli amministratori')], 403); + } + + $data = (array) $request->getParsedBody(); + if (!Csrf::validate($data['csrf_token'] ?? '')) { + return $this->jsonResponse($response, ['error' => __('Token CSRF non valido')], 403); + } + + $version = trim((string) ($data['version'] ?? '')); + if ($version === '' || preg_match('/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?$/', $version) !== 1) { + return $this->jsonResponse($response, ['error' => __('Versione non specificata')], 400); + } + + try { + $updater = new Updater($db); + } catch (\Throwable $e) { + return $this->jsonResponse($response, [ + 'success' => false, + 'error' => $this->updaterUnavailable($e, 'downloadUpdatePackage'), + ], 503); + } + + $requirements = $updater->checkRequirements(); + if (!$requirements['met']) { + return $this->jsonResponse($response, [ + 'success' => false, + 'error' => __('Requisiti di sistema non soddisfatti'), + 'requirements' => $requirements['requirements'] + ], 400); + } + + $this->answerJsonOnFatal(); + $result = $updater->downloadPackageForInstall($version); + if (!$result['success'] || $result['path'] === null) { + return $this->jsonResponse($response, ['success' => false, 'error' => $result['error']], 500); + } + + // The path stays on the server, as for an uploaded package + return $this->jsonResponse($response, [ + 'success' => true, + 'message' => __('Pacchetto scaricato e verificato'), + 'package' => $this->holdPendingPackage($updater, $result['path'], $result['sha256']), + ]); + } + + /** + * Keep a downloaded or uploaded package for the install request, under an + * opaque id the page sends back. The install request then installs this + * package and no other: a download in one tab and an upload in another + * share the session, and without the id the second would take the first's + * place unnoticed. A package that is replaced is deleted. + */ + private function holdPendingPackage(Updater $updater, string $path, ?string $sha256): string + { + $previous = (string) ($_SESSION['manual_update_path'] ?? ''); + if ($previous !== '' && $previous !== $path) { + $updater->discardPendingPackage($previous); + } + $id = bin2hex(random_bytes(16)); + $_SESSION['manual_update_path'] = $path; + $_SESSION['manual_update_id'] = $id; + if ($sha256 !== null) { + $_SESSION['manual_update_sha256'] = $sha256; + } else { + unset($_SESSION['manual_update_sha256']); + } + return $id; + } + + /** + * A fatal error during an update (memory, a time limit the host enforces) + * would end the request with an HTML error page or nothing at all, and the + * page could only say "invalid response". Answer it as JSON with PHP's own + * message instead, whenever nothing has been sent yet. + */ + private function answerJsonOnFatal(): void + { + register_shutdown_function(static function (): void { + $error = error_get_last(); + if ($error === null || !in_array($error['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR], true)) { + return; + } + while (ob_get_level() > 0) { + ob_end_clean(); + } + if (headers_sent()) { + return; + } + http_response_code(500); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode([ + 'success' => false, + 'error' => __('Errore fatale PHP durante l\'aggiornamento') . ': ' . $error['message'], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + }); + } + /** * API: Install manually uploaded update package */ @@ -662,9 +772,22 @@ public function installManualUpdate(Request $request, Response $response, mysqli return $this->jsonResponse($response, ['error' => __('Token CSRF non valido')], 403); } + // The page names the package it downloaded or uploaded. When another tab + // has replaced it since, refuse without touching the other one, which + // its own install request can still take. + $pendingId = (string) ($_SESSION['manual_update_id'] ?? ''); + $requestedId = (string) ($data['package'] ?? ''); + if (($pendingId !== '' || $requestedId !== '') && !hash_equals($pendingId, $requestedId)) { + return $this->jsonResponse($response, [ + 'success' => false, + 'error' => __('Il pacchetto in attesa è cambiato in un\'altra scheda: ripeti l\'aggiornamento'), + ], 409); + } + // Retrieve path from session (not from client) to prevent path manipulation $tempPath = $_SESSION['manual_update_path'] ?? ''; - unset($_SESSION['manual_update_path']); + $expectedSha256 = (string) ($_SESSION['manual_update_sha256'] ?? ''); + unset($_SESSION['manual_update_path'], $_SESSION['manual_update_id'], $_SESSION['manual_update_sha256']); if (empty($tempPath)) { return $this->jsonResponse($response, ['error' => __('Path pacchetto non specificato')], 400); @@ -676,13 +799,25 @@ public function installManualUpdate(Request $request, Response $response, mysqli $realTempPath = realpath($tempPath); $realStorageTmp = realpath($storageTmp); - if (!$realTempPath || !$realStorageTmp || !str_starts_with($realTempPath, $realStorageTmp)) { + if (!$realTempPath || !$realStorageTmp || !str_starts_with($realTempPath, $realStorageTmp . DIRECTORY_SEPARATOR)) { return $this->jsonResponse($response, [ 'success' => false, 'error' => __('Path pacchetto non valido') ], 400); } + // A downloaded package is checked again against the digest it was + // verified with, right before it is installed + if ($expectedSha256 !== '') { + $actualSha256 = (string) @hash_file('sha256', $realTempPath . '/update.zip'); + if (!hash_equals($expectedSha256, $actualSha256)) { + return $this->jsonResponse($response, [ + 'success' => false, + 'error' => __('Verifica di integrità fallita: l\'archivio scaricato non corrisponde al checksum atteso.'), + ], 400); + } + } + try { $updater = new Updater($db); } catch (\Throwable $e) { @@ -703,6 +838,7 @@ public function installManualUpdate(Request $request, Response $response, mysqli } // Perform the update from uploaded file (use resolved path to prevent TOCTOU) + $this->answerJsonOnFatal(); $result = $updater->performUpdateFromFile($realTempPath); if ($result['success']) { diff --git a/app/Routes/web.php b/app/Routes/web.php index 753786d46..d9ab53a00 100644 --- a/app/Routes/web.php +++ b/app/Routes/web.php @@ -3593,6 +3593,13 @@ return $controller->uploadUpdate($request, $response, $db); })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Automatic update, first request: download and verify the release package + $app->post('/admin/updates/download', function ($request, $response) use ($app) { + $db = $app->getContainer()->get('db'); + $controller = new \App\Controllers\UpdateController(); + return $controller->downloadUpdatePackage($request, $response, $db); + })->add(new CsrfMiddleware())->add(new AdminAuthMiddleware()); + // Manual update - Install uploaded package $app->post('/admin/updates/install-manual', function ($request, $response) use ($app) { $db = $app->getContainer()->get('db'); diff --git a/app/Support/Updater.php b/app/Support/Updater.php index e4e1b7d23..2ebf9b345 100644 --- a/app/Support/Updater.php +++ b/app/Support/Updater.php @@ -234,29 +234,20 @@ private function cleanupOldTempDirs(string $tmpDir): void return; } - $dirs = @glob($tmpDir . '/pinakes_update_*', GLOB_ONLYDIR); - if ($dirs === false) { - return; - } - $now = time(); $maxAge = 3600; // 1 hour - foreach ($dirs as $dir) { - $mtime = @filemtime($dir); - if ($mtime !== false && ($now - $mtime) > $maxAge) { - $this->debugLog('DEBUG', 'Pulizia vecchia directory temporanea', ['path' => $dir]); - $this->deleteDirectory($dir); + // Update work dirs, pre-update app backups, and downloaded or uploaded + // packages whose install request never came (tab closed, early return) + foreach (['pinakes_update_*', 'pinakes_app_backup_*', 'manual_update_*'] as $pattern) { + $dirs = @glob($tmpDir . '/' . $pattern, GLOB_ONLYDIR); + if ($dirs === false) { + continue; } - } - - // Also clean up old app backup directories - $appBackups = @glob($tmpDir . '/pinakes_app_backup_*', GLOB_ONLYDIR); - if ($appBackups !== false) { - foreach ($appBackups as $dir) { + foreach ($dirs as $dir) { $mtime = @filemtime($dir); if ($mtime !== false && ($now - $mtime) > $maxAge) { - $this->debugLog('DEBUG', 'Pulizia vecchio backup app', ['path' => $dir]); + $this->debugLog('DEBUG', 'Pulizia vecchia directory temporanea', ['path' => $dir]); $this->deleteDirectory($dir); } } @@ -1228,43 +1219,7 @@ public function downloadUpdate(string $version): array 'assets' => array_map(fn($a) => $a['name'], $release['assets'] ?? []) ]); - // SECURITY: only the packaged "pinakes-*.zip" release asset is - // installable — it is the artifact create-release.sh builds and which - // GitHub serves with an API "digest" (sha256, computed server-side). - // The git zipball_url is deliberately NOT used as a fallback: it has - // no digest, so its integrity cannot be verified, and it lacks vendor/. - // We refuse rather than install an unverifiable package. - $downloadUrl = null; - $selectedAssetName = null; - $expectedDigest = null; - - foreach ($release['assets'] ?? [] as $asset) { - $this->debugLog('DEBUG', 'Controllo asset', [ - 'name' => $asset['name'] ?? 'N/A', - 'size' => $asset['size'] ?? 0, - 'download_url' => $asset['browser_download_url'] ?? 'N/A' - ]); - - if (isset($asset['name']) && preg_match('/pinakes.*\.zip$/i', (string) $asset['name'])) { - $downloadUrl = $asset['browser_download_url'] ?? null; - $selectedAssetName = (string) $asset['name']; - $expectedDigest = isset($asset['digest']) && is_string($asset['digest']) ? $asset['digest'] : null; - $this->debugLog('INFO', 'Trovato asset personalizzato', [ - 'name' => $selectedAssetName, - 'url' => $downloadUrl, - 'digest' => $expectedDigest ?? 'N/A' - ]); - break; - } - } - - if (!$downloadUrl || $selectedAssetName === null) { - $this->debugLog('ERROR', 'Nessun asset pacchetto verificabile (pinakes-*.zip) nella release', [ - 'release' => $release['tag_name'] ?? 'N/A', - 'assets' => array_map(static fn($a) => $a['name'] ?? '?', $release['assets'] ?? []) - ]); - throw new Exception(__('La release non contiene un pacchetto installabile verificabile (pinakes-*.zip). Aggiornamento annullato.')); - } + [$downloadUrl, $selectedAssetName, $expectedDigest] = $this->releasePackageAsset($release); $this->debugLog('INFO', 'URL download selezionato', ['url' => $downloadUrl]); @@ -1286,219 +1241,7 @@ public function downloadUpdate(string $version): array $zipPath = $this->tempPath . '/update.zip'; $this->debugLog('DEBUG', 'Path file ZIP', ['path' => $zipPath]); - // Download the file - try cURL first (more reliable), fallback to file_get_contents - $this->debugLog('INFO', 'Inizio download file...', ['url' => $downloadUrl]); - - $startTime = microtime(true); - $fileContent = false; - - // Try cURL first (more reliable on shared hosting) - if (extension_loaded('curl')) { - $this->debugLog('DEBUG', 'Tentativo download con cURL'); - - $ch = curl_init($downloadUrl); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 300, - CURLOPT_CONNECTTIMEOUT => 30, - CURLOPT_USERAGENT => 'Pinakes-Updater/1.0', - CURLOPT_HTTPHEADER => $this->getGitHubHeaders('application/octet-stream', $this->isApiUrl($downloadUrl)), - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_UNRESTRICTED_AUTH => false, // never resend the bearer across a cross-host redirect - CURLOPT_BUFFERSIZE => 1024 * 1024, // 1MB buffer - ]); - - $fileContent = curl_exec($ch); - $curlInfo = curl_getinfo($ch); - $curlError = curl_error($ch); - $curlErrno = curl_errno($ch); - /* curl_close(): no-op since PHP 8.0, deprecated 8.5 */ - - $httpCode = is_array($curlInfo) ? $curlInfo['http_code'] : 0; - $this->debugLog('DEBUG', 'Risultato cURL', [ - 'http_code' => $httpCode, - 'size_download' => is_array($curlInfo) ? $curlInfo['size_download'] : 0, - 'total_time' => is_array($curlInfo) ? $curlInfo['total_time'] : 0, - 'error' => $curlError ?: 'none', - 'errno' => $curlErrno - ]); - - if ($curlErrno !== 0 || $httpCode >= 400) { - // Treat HTTP error responses as failures (don't keep error body as valid content) - $fileContent = false; - - // Retry without token on auth failure before falling back - if (in_array($httpCode, [401, 403], true) && $this->githubToken !== '') { - $this->debugLog('WARNING', 'Download auth fallito, retry senza token', ['http_code' => $httpCode]); - $retryHeaders = $this->getGitHubHeaders('application/octet-stream', false); - $ch2 = curl_init($downloadUrl); - curl_setopt_array($ch2, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 300, - CURLOPT_CONNECTTIMEOUT => 30, - CURLOPT_USERAGENT => 'Pinakes-Updater/1.0', - CURLOPT_HTTPHEADER => $retryHeaders, - CURLOPT_SSL_VERIFYPEER => true, - CURLOPT_UNRESTRICTED_AUTH => false, // never resend the bearer across a cross-host redirect - ]); - $retryContent = curl_exec($ch2); - $retryCode = (int)(curl_getinfo($ch2, CURLINFO_HTTP_CODE)); - /* curl_close(): no-op since PHP 8.0, deprecated 8.5 */ - if ($retryContent !== false && $retryCode >= 200 && $retryCode < 400) { - $fileContent = $retryContent; - } - } - - if ($fileContent === false) { - $this->debugLog('WARNING', 'cURL fallito, tentativo con file_get_contents', [ - 'error' => $curlError, - 'http_code' => $httpCode - ]); - } - } - } - - // Fallback to file_get_contents - if ($fileContent === false) { - $this->debugLog('DEBUG', 'Tentativo download con file_get_contents'); - - $context = stream_context_create([ - 'http' => [ - 'method' => 'GET', - 'header' => $this->getGitHubHeaders('application/octet-stream', $this->isApiUrl($downloadUrl)), - 'timeout' => 300, - 'follow_location' => true, - 'ignore_errors' => true - ] - ]); - - [$fileContent, $responseHeaders] = $this->httpGetWithHeaders($downloadUrl, $context); - - if (!empty($responseHeaders)) { - $this->debugLog('DEBUG', 'Response headers download', [ - 'headers' => $responseHeaders - ]); - } - - // Retry without token on auth failure - $dlStatus = $this->extractFinalHttpStatus($responseHeaders); - if (in_array($dlStatus, [401, 403], true) && $this->githubToken !== '') { - $savedToken = $this->githubToken; - $this->githubToken = ''; - try { - $context = stream_context_create([ - 'http' => [ - 'method' => 'GET', - 'header' => $this->getGitHubHeaders('application/octet-stream', $this->isApiUrl($downloadUrl)), - 'timeout' => 300, - 'follow_location' => true, - 'ignore_errors' => true - ] - ]); - [$retryContent, $retryHeaders] = $this->httpGetWithHeaders($downloadUrl, $context); - $retryStatus = $this->extractFinalHttpStatus($retryHeaders); - $fileContent = ($retryContent !== false && $retryStatus >= 200 && $retryStatus < 400) - ? $retryContent - : false; - } finally { - $this->githubToken = $savedToken; - } - } elseif ($dlStatus >= 400) { - $this->debugLog('ERROR', 'Download HTTP error', ['status' => $dlStatus]); - $fileContent = false; - } - } - - $downloadTime = round(microtime(true) - $startTime, 2); - - if ($fileContent === false) { - $error = error_get_last(); - $this->debugLog('ERROR', 'Download fallito con entrambi i metodi', [ - 'url' => $downloadUrl, - 'error' => $error, - 'download_time' => $downloadTime, - 'curl_available' => extension_loaded('curl') - ]); - throw new Exception(__('Download fallito') . ': ' . ($error['message'] ?? 'Impossibile scaricare il file')); - } - - $fileSize = strlen($fileContent); - $this->debugLog('INFO', 'Download completato', [ - 'size_bytes' => $fileSize, - 'size_mb' => round($fileSize / 1024 / 1024, 2), - 'time_seconds' => $downloadTime - ]); - - if ($fileSize < 1000) { - $this->debugLog('ERROR', 'File scaricato troppo piccolo - probabilmente errore', [ - 'content_preview' => substr($fileContent, 0, 500) - ]); - throw new Exception(__('File di aggiornamento non valido (troppo piccolo)')); - } - - // SECURITY: integrity verification is MANDATORY before the package is - // ever written to disk and extracted. The downloaded bytes must match - // the GitHub asset "digest" ("sha256:", served over TLS by - // api.github.com) — the supply-chain guard that TLS-transport alone does - // not provide against a tampered release artifact. The ".sha256" sidecar - // fallback was removed: payload + sidecar share the same CDN, so a CDN/ - // MITM attacker could forge both. Every GitHub asset carries an API - // digest; if it is missing or malformed, refuse the update (fail-closed). - $expectedHash = null; - if (is_string($expectedDigest) && stripos($expectedDigest, 'sha256:') === 0) { - $candidate = strtolower(substr($expectedDigest, 7)); - // Reject a malformed digest rather than carry it into hash_equals. - if ($this->isValidSha256($candidate)) { - $expectedHash = $candidate; - $this->debugLog('INFO', 'Verifica integrità via digest asset GitHub'); - } - } - - if ($expectedHash === null) { - $this->debugLog('ERROR', 'Nessun digest API valido per il pacchetto, rifiutato', [ - 'asset' => $selectedAssetName - ]); - throw new Exception(__('Verifica di integrità impossibile: la release non pubblica un digest sha256 valido. Installazione di un pacchetto non verificato rifiutata.')); - } - - $actualHash = hash('sha256', $fileContent); - if (!hash_equals($expectedHash, $actualHash)) { - $this->debugLog('ERROR', 'Digest del pacchetto non corrispondente', [ - 'expected' => $expectedHash, - 'actual' => $actualHash - ]); - throw new Exception(__('Verifica di integrità fallita: l\'archivio scaricato non corrisponde al checksum atteso.')); - } - $this->debugLog('INFO', 'Integrità pacchetto verificata (sha256)', ['sha256' => $actualHash]); - - // Save file - $this->debugLog('DEBUG', 'Salvataggio file ZIP', ['path' => $zipPath]); - $bytesWritten = file_put_contents($zipPath, $fileContent); - - if ($bytesWritten === false) { - // Describe first: debugLog() writes to disk and would replace - // the file_put_contents() error that error_get_last() has to - // report. Name the cause because on a full account this is the - // FIRST write to fail, and "Download fallito" alone sends the - // operator looking for a corrupt release instead of free space. - $cause = $this->describeWriteFailure($zipPath); - $this->debugLog('ERROR', 'Impossibile salvare file', [ - 'path' => $zipPath, - 'error' => $cause - ]); - throw new Exception( - __('Impossibile salvare il file di aggiornamento') . ' — ' . $cause - ); - } - - $this->debugLog('INFO', 'File salvato', [ - 'path' => $zipPath, - 'bytes_written' => $bytesWritten - ]); + $this->fetchVerifiedPackage($downloadUrl, $selectedAssetName, $expectedDigest, $zipPath); // Verify it's a valid zip $this->debugLog('DEBUG', 'Verifica integrità ZIP'); @@ -1724,6 +1467,256 @@ public function downloadUpdate(string $version): array } } + /** + * The installable package of a release: the "pinakes-*.zip" asset, its + * download URL and the sha256 digest GitHub computed for it. + * + * SECURITY: only that asset is installable. It is the artifact the release + * workflow builds and that GitHub serves with an API "digest". The git + * zipball is deliberately NOT a fallback: it has no digest, so its + * integrity cannot be verified, and it lacks vendor/. + * + * @param array $release + * @return array{0: string, 1: string, 2: string|null} + */ + private function releasePackageAsset(array $release): array + { + foreach ((array) ($release['assets'] ?? []) as $asset) { + if (!is_array($asset) || !isset($asset['name']) || !preg_match('/pinakes.*\.zip$/i', (string) $asset['name'])) { + continue; + } + $url = (string) ($asset['browser_download_url'] ?? ''); + if ($url === '') { + continue; + } + $digest = isset($asset['digest']) && is_string($asset['digest']) ? $asset['digest'] : null; + $this->debugLog('INFO', 'Trovato asset personalizzato', ['name' => $asset['name'], 'url' => $url, 'digest' => $digest ?? 'N/A']); + return [$url, (string) $asset['name'], $digest]; + } + $this->debugLog('ERROR', 'Nessun asset pacchetto verificabile (pinakes-*.zip) nella release', [ + 'release' => $release['tag_name'] ?? 'N/A', + 'assets' => array_map(static fn($a) => is_array($a) ? ($a['name'] ?? '?') : '?', (array) ($release['assets'] ?? [])), + ]); + throw new Exception(__('La release non contiene un pacchetto installabile verificabile (pinakes-*.zip). Aggiornamento annullato.')); + } + + /** + * Download a release package straight to $zipPath and prove it is the one + * GitHub published: its sha256 must equal the asset's API digest + * ("sha256:", served over TLS by api.github.com). Fail-closed: no + * valid digest, no install. + * + * The bytes go to disk as they arrive, never into a PHP string. Holding the + * 30 MB package in memory, next to the backup the same request had just + * written, is what an account with a fixed memory limit could not afford. + */ + private function fetchVerifiedPackage(string $url, string $assetName, ?string $digest, string $zipPath): string + { + $expectedHash = null; + if (is_string($digest) && stripos($digest, 'sha256:') === 0) { + $candidate = strtolower(substr($digest, 7)); + if ($this->isValidSha256($candidate)) { + $expectedHash = $candidate; + } + } + if ($expectedHash === null) { + $this->debugLog('ERROR', 'Nessun digest API valido per il pacchetto, rifiutato', ['asset' => $assetName]); + throw new Exception(__('Verifica di integrità impossibile: la release non pubblica un digest sha256 valido. Installazione di un pacchetto non verificato rifiutata.')); + } + + $partPath = $zipPath . '.part'; + $startTime = microtime(true); + $this->debugLog('INFO', 'Inizio download file...', ['url' => $url]); + + $status = $this->streamToFile($url, $partPath, $this->isApiUrl($url)); + if (in_array($status, [401, 403], true) && $this->githubToken !== '') { + $this->debugLog('WARNING', 'Download auth fallito, retry senza token', ['http_code' => $status]); + $status = $this->streamToFile($url, $partPath, false); + } + $size = is_file($partPath) ? (int) filesize($partPath) : 0; + $this->debugLog('INFO', 'Download terminato', [ + 'http_code' => $status, + 'size_mb' => round($size / 1048576, 2), + 'time_seconds' => round(microtime(true) - $startTime, 2), + ]); + if ($status < 200 || $status >= 400 || $size < 1000) { + @unlink($partPath); + throw new Exception(__('Download fallito') . ': HTTP ' . $status . ($size < 1000 ? ' — ' . __('File di aggiornamento non valido (troppo piccolo)') : '')); + } + + $actualHash = (string) hash_file('sha256', $partPath); + if (!hash_equals($expectedHash, $actualHash)) { + @unlink($partPath); + $this->debugLog('ERROR', 'Digest del pacchetto non corrispondente', ['expected' => $expectedHash, 'actual' => $actualHash]); + throw new Exception(__('Verifica di integrità fallita: l\'archivio scaricato non corrisponde al checksum atteso.')); + } + if (!@rename($partPath, $zipPath)) { + $cause = $this->describeWriteFailure($zipPath); + @unlink($partPath); + throw new Exception(__('Impossibile salvare il file di aggiornamento') . ' — ' . $cause); + } + $this->debugLog('INFO', 'Integrità pacchetto verificata (sha256)', ['sha256' => $actualHash, 'path' => $zipPath]); + return $actualHash; + } + + /** + * GET $url into $path, following redirects, writing as the bytes arrive. + * Returns the final HTTP status (0 when no connection was made). cURL when + * available, else a PHP stream. + */ + private function streamToFile(string $url, string $path, bool $withAuth): int + { + $headers = $this->getGitHubHeaders('application/octet-stream', $withAuth); + $out = @fopen($path, 'wb'); + if ($out === false) { + throw new Exception(__('Impossibile salvare il file di aggiornamento') . ' — ' . $this->describeWriteFailure($path)); + } + try { + if (extension_loaded('curl')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_FILE => $out, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_MAXREDIRS => 10, + CURLOPT_TIMEOUT => 300, + CURLOPT_CONNECTTIMEOUT => 30, + CURLOPT_USERAGENT => 'Pinakes-Updater/1.0', + CURLOPT_HTTPHEADER => $headers, + CURLOPT_SSL_VERIFYPEER => true, + CURLOPT_UNRESTRICTED_AUTH => false, // never resend the bearer across a cross-host redirect + CURLOPT_PROTOCOLS => CURLPROTO_HTTPS | CURLPROTO_HTTP, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS, + ]); + $ok = curl_exec($ch); + $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + if ($ok !== false) { + return $status; + } + $curlError = curl_error($ch); + $this->debugLog('WARNING', 'cURL fallito', ['error' => $curlError, 'errno' => curl_errno($ch), 'http_code' => $status]); + if ($status > 0) { + // The server answered and the transfer broke off (a timeout on a + // slow link, a dropped connection): say so, rather than let the + // partial file fail the checksum as if it had been tampered with + throw new Exception(__('Download fallito') . ': ' . $curlError); + } + if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOLEAN)) { + return 0; + } + // No connection at all: start the file over and try PHP's own stream + ftruncate($out, 0); + rewind($out); + } + return $this->streamWithPhp($url, $headers, $out); + } finally { + fclose($out); + } + } + + /** + * The PHP-stream half of streamToFile(), for hosts without cURL. A 2xx + * body that ends before its Content-Length (the server closed the + * connection), a failed copy or a timeout is a broken-off download. + * + * @param list $headers + * @param resource $out + */ + private function streamWithPhp(string $url, array $headers, $out): int + { + $context = stream_context_create(['http' => [ + 'method' => 'GET', + 'header' => $headers, + 'timeout' => 300, + 'follow_location' => true, + 'ignore_errors' => true, + ]]); + $in = @fopen($url, 'rb', false, $context); + if ($in === false) { + return 0; + } + $meta = stream_get_meta_data($in); + $copied = stream_copy_to_stream($in, $out); + $timedOut = stream_get_meta_data($in)['timed_out']; + fclose($in); + + $wrapper = (array) ($meta['wrapper_data'] ?? []); + $status = $this->extractFinalHttpStatus($wrapper); + // Content-Length of the last response block: a redirect's own headers come first + $length = null; + foreach ($wrapper as $line) { + if (preg_match('#^HTTP/#i', (string) $line) === 1) { + $length = null; + } elseif (preg_match('/^Content-Length:\s*(\d+)\s*$/i', (string) $line, $m) === 1) { + $length = (int) $m[1]; + } + } + if ($status >= 200 && $status < 300 + && ($copied === false || $timedOut || ($length !== null && $copied < $length))) { + throw new Exception(__('Download fallito') . ': ' . __('trasferimento interrotto')); + } + return $status; + } + + /** + * Download the package of $version into a fresh directory under + * storage/tmp, verified, ready for performUpdateFromFile(). The automatic + * update runs in two requests, download then install, like a manual one: + * one request that took the backup, the download and the install together + * could outlast a proxy or FastCGI timeout on shared hosting (issue #450). + * + * @return array{success: bool, path: string|null, sha256: string|null, error: string|null} + */ + public function downloadPackageForInstall(string $version): array + { + $this->debugLog('INFO', '=== DOWNLOAD PACCHETTO PER INSTALLAZIONE ===', ['target_version' => $version]); + $dir = $this->rootPath . '/storage/tmp/manual_update_' . bin2hex(random_bytes(16)); + try { + $imageBlock = $this->officialImageUpdateBlock(); + if ($imageBlock !== null) { + throw new Exception($imageBlock); + } + $release = $this->getReleaseByVersion($version); + if ($release === null) { + throw new Exception(__('Versione non trovata')); + } + $packageBytes = $this->releaseAssetBytes($version); + $spaceError = $this->checkFreeSpaceForUpdate($packageBytes > 0 ? $packageBytes * 4 : 0, null, true); + if ($spaceError !== null) { + throw new Exception($spaceError); + } + [$url, $assetName, $digest] = $this->releasePackageAsset($release); + if (!@mkdir($dir, 0755, true) && !is_dir($dir)) { + throw new Exception(__('Impossibile creare directory temporanea') . ' — ' . $this->describeWriteFailure($dir)); + } + $sha256 = $this->fetchVerifiedPackage($url, $assetName, $digest, $dir . '/update.zip'); + return ['success' => true, 'path' => $dir, 'sha256' => $sha256, 'error' => null]; + } catch (\Throwable $e) { + $this->debugLog('ERROR', 'Download pacchetto fallito', ['error' => $e->getMessage()]); + if (is_dir($dir)) { + $this->deleteDirectory($dir); + } + return ['success' => false, 'path' => null, 'sha256' => null, 'error' => $e->getMessage()]; + } + } + + /** + * Delete a package waiting for its install request, once another download + * or upload has taken its place. Only a manual_update_* folder directly + * under storage/tmp is removed. + */ + public function discardPendingPackage(string $path): void + { + $expectedRoot = realpath($this->rootPath . '/storage/tmp'); + $realPath = realpath($path); + if ($expectedRoot === false || $realPath === false || !is_dir($realPath) + || dirname($realPath) !== $expectedRoot + || !str_starts_with(basename($realPath), 'manual_update_')) { + return; + } + $this->debugLog('INFO', 'Pacchetto in attesa sostituito, rimosso', ['path' => $realPath]); + $this->deleteDirectory($realPath); + } + /** * Get release by version tag */ diff --git a/app/Views/admin/updates.php b/app/Views/admin/updates.php index 48d50b56e..7fa458b2d 100644 --- a/app/Views/admin/updates.php +++ b/app/Views/admin/updates.php @@ -524,17 +524,17 @@ class="inline-flex items-center justify-center w-full px-6 py-3 bg-gray-800 text
-
+
- +
-
+
- +
@@ -577,14 +577,7 @@ class="inline-flex items-center justify-center w-full px-6 py-3 bg-gray-800 text body: `csrf_token=${encodeURIComponent(csrfToken)}&github_token=${encodeURIComponent(tokenValue)}` }); - const ct = response.headers.get('content-type') || ''; - if (!ct.includes('application/json')) { - const text = await response.text(); - console.error('Server returned non-JSON response:', text.substring(0, 500)); - throw new Error(); - } - - return response.json(); + return readUpdateJson(response); } let tokenRequestInFlight = false; @@ -774,21 +767,29 @@ class="inline-flex items-center justify-center w-full px-6 py-3 bg-gray-800 text // Show progress modal document.getElementById('updateModal').classList.remove('hidden'); - setStepActive('backup'); + // Two requests, as a manual update: the server downloads and verifies the + // package, then installs it (backup, files, migrations). One request doing + // all of it could outlast the timeout of a proxy in front of the site. + let failedStep = 'download'; try { - // Simulate step progress (actual update is single request) - await sleep(500); - setStepComplete('backup'); setStepActive('download'); - - // Perform the actual update - const response = await fetch(window.BASE_PATH + '/admin/updates/perform', { + const downloadData = await readUpdateJson(await fetch(window.BASE_PATH + '/admin/updates/download', { method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, body: `csrf_token=${encodeURIComponent(csrfToken)}&version=${encodeURIComponent(version)}` + })); + if (!downloadData.success) { + throw new Error(downloadData.error || ); + } + setStepComplete('download'); + + failedStep = 'backup'; + setStepActive('backup'); + const response = await fetch(window.BASE_PATH + '/admin/updates/install-manual', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, + body: `csrf_token=${encodeURIComponent(csrfToken)}&package=${encodeURIComponent(downloadData.package || '')}` }); // Check for maintenance mode before parsing response @@ -796,20 +797,10 @@ class="inline-flex items-center justify-center w-full px-6 py-3 bg-gray-800 text throw new Error(); } - // Check response before parsing JSON - const contentType = response.headers.get('content-type') || ''; - if (!contentType.includes('application/json')) { - // Server returned HTML (error page or maintenance page) - const text = await response.text(); - console.error('Server returned non-JSON response:', text.substring(0, 500)); - throw new Error(); - } - - const data = await response.json(); + const data = await readUpdateJson(response); if (data.success) { - // Mark steps complete only on success - setStepComplete('download'); + setStepComplete('backup'); setStepActive('install'); await sleep(300); setStepComplete('install'); @@ -822,8 +813,7 @@ class="inline-flex items-center justify-center w-full px-6 py-3 bg-gray-800 text document.getElementById('updateTitle').textContent = ; document.getElementById('updateMessage').textContent = ; } else { - // Mark failed step with error indicator - setStepFailed('download'); + setStepFailed('backup'); document.getElementById('updateIcon').innerHTML = ''; document.getElementById('updateIcon').className = 'w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4'; document.getElementById('updateTitle').textContent = ; @@ -833,6 +823,7 @@ class="inline-flex items-center justify-center w-full px-6 py-3 bg-gray-800 text document.getElementById('updateActions').classList.remove('hidden'); } catch (error) { + setStepFailed(failedStep); document.getElementById('updateIcon').innerHTML = ''; document.getElementById('updateIcon').className = 'w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4'; document.getElementById('updateTitle').textContent = ; @@ -843,6 +834,22 @@ class="inline-flex items-center justify-center w-full px-6 py-3 bg-gray-800 text } } +/** + * The JSON of an update request, or an error that says what came back instead: + * the HTTP status and the start of the text (a proxy's timeout page, a PHP + * fatal error), so the cause is on screen and not only in the console. + */ +async function readUpdateJson(response) { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('application/json')) { + return response.json(); + } + const text = await response.text(); + console.error('Server returned non-JSON response:', text.substring(0, 500)); + const excerpt = text.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, ' ').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().substring(0, 240); + throw new Error( + ` (HTTP ${response.status}${excerpt ? ': ' + excerpt : ''})`); +} + function setStepActive(step) { const el = document.querySelector(`[data-step="${step}"]`); if (el) { @@ -1496,17 +1503,11 @@ function removeUploadedFile() { const uploadResponse = await fetch(window.BASE_PATH + '/admin/updates/upload', { method: 'POST', + headers: { 'Accept': 'application/json' }, body: formData }); - const uploadContentType = uploadResponse.headers.get('content-type') || ''; - if (!uploadContentType.includes('application/json')) { - const text = await uploadResponse.text(); - console.error('Server returned non-JSON response:', text.substring(0, 500)); - throw new Error(); - } - - const uploadData = await uploadResponse.json(); + const uploadData = await readUpdateJson(uploadResponse); if (!uploadData.success) { throw new Error(uploadData.error || ); @@ -1549,18 +1550,12 @@ function removeUploadedFile() { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', + 'Accept': 'application/json', }, - body: `csrf_token=${encodeURIComponent(csrfToken)}` + body: `csrf_token=${encodeURIComponent(csrfToken)}&package=${encodeURIComponent(uploadData.package || '')}` }); - const installContentType = installResponse.headers.get('content-type') || ''; - if (!installContentType.includes('application/json')) { - const text = await installResponse.text(); - console.error('Server returned non-JSON response:', text.substring(0, 500)); - throw new Error(); - } - - const installData = await installResponse.json(); + const installData = await readUpdateJson(installResponse); if (installData.success) { Swal.fire({ diff --git a/locale/da_DK.json b/locale/da_DK.json index 7dace0a6e..208d458bf 100644 --- a/locale/da_DK.json +++ b/locale/da_DK.json @@ -8041,5 +8041,9 @@ "Questa è l'immagine Docker ufficiale di Pinakes: l'aggiornamento dall'applicazione è disattivato di proposito. Il codice verrebbe riscritto solo nel layer del container e andrebbe perso alla prima ricreazione, mentre le migrazioni del database resterebbero applicate — lasciando codice vecchio su uno schema nuovo. Aggiorna spostando il container sulla nuova immagine: \"docker compose pull && docker compose up -d\".": "Dette er det officielle Pinakes-Docker-image: opdatering inde fra applikationen er deaktiveret med vilje. Den nye kode ville kun blive skrevet til containerlaget og gå tabt, næste gang containeren genskabes, mens databasemigreringerne ville forblive anvendt — hvilket efterlader gammel kode på et nyt skema. Opdater i stedet ved at flytte containeren til det nye image: \"docker compose pull && docker compose up -d\".", "Impossibile verificare lo spazio disponibile in %s. Controlla la configurazione del filesystem e riprova.": "Den ledige plads i %s kunne ikke kontrolleres. Kontrollér filsystemets konfiguration, og prøv igen.", "Il sistema di aggiornamento non è disponibile. Il dettaglio è nel registro dell'applicazione.": "Opdateringssystemet er ikke tilgængeligt. Detaljerne står i applikationsloggen.", - "almeno %s": "mindst %s" + "almeno %s": "mindst %s", + "Pacchetto scaricato e verificato": "Pakke downloadet og kontrolleret", + "trasferimento interrotto": "overførslen blev afbrudt", + "Il pacchetto in attesa è cambiato in un'altra scheda: ripeti l'aggiornamento": "Den ventende pakke blev erstattet i en anden fane: start opdateringen igen", + "Errore fatale PHP durante l'aggiornamento": "Fatal PHP-fejl under opdateringen" } diff --git a/locale/de_DE.json b/locale/de_DE.json index 86968ef21..c4f2438b2 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -8041,5 +8041,9 @@ "Questa è l'immagine Docker ufficiale di Pinakes: l'aggiornamento dall'applicazione è disattivato di proposito. Il codice verrebbe riscritto solo nel layer del container e andrebbe perso alla prima ricreazione, mentre le migrazioni del database resterebbero applicate — lasciando codice vecchio su uno schema nuovo. Aggiorna spostando il container sulla nuova immagine: \"docker compose pull && docker compose up -d\".": "Dies ist das offizielle Pinakes-Docker-Image: Die Aktualisierung aus der Anwendung heraus ist bewusst deaktiviert. Der neue Code würde nur in die Container-Schicht geschrieben und beim nächsten Neuerstellen des Containers verloren gehen, während die Datenbank-Migrationen angewendet blieben — alter Code auf neuem Schema. Aktualisieren Sie stattdessen, indem Sie den Container auf das neue Image umstellen: \"docker compose pull && docker compose up -d\".", "Impossibile verificare lo spazio disponibile in %s. Controlla la configurazione del filesystem e riprova.": "Der verfügbare Speicherplatz in %s kann nicht überprüft werden. Prüfen Sie die Dateisystemkonfiguration und versuchen Sie es erneut.", "Il sistema di aggiornamento non è disponibile. Il dettaglio è nel registro dell'applicazione.": "Das Aktualisierungssystem ist nicht verfügbar. Die Einzelheiten stehen im Anwendungsprotokoll.", - "almeno %s": "mindestens %s" + "almeno %s": "mindestens %s", + "Pacchetto scaricato e verificato": "Paket heruntergeladen und geprüft", + "trasferimento interrotto": "Übertragung abgebrochen", + "Il pacchetto in attesa è cambiato in un'altra scheda: ripeti l'aggiornamento": "Das wartende Paket wurde in einem anderen Tab ersetzt: Starte das Update erneut", + "Errore fatale PHP durante l'aggiornamento": "Schwerwiegender PHP-Fehler während der Aktualisierung" } diff --git a/locale/en_US.json b/locale/en_US.json index 26d76a652..1fb206c15 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -8041,5 +8041,9 @@ "Questa è l'immagine Docker ufficiale di Pinakes: l'aggiornamento dall'applicazione è disattivato di proposito. Il codice verrebbe riscritto solo nel layer del container e andrebbe perso alla prima ricreazione, mentre le migrazioni del database resterebbero applicate — lasciando codice vecchio su uno schema nuovo. Aggiorna spostando il container sulla nuova immagine: \"docker compose pull && docker compose up -d\".": "This is the official Pinakes Docker image: updating from inside the application is disabled on purpose. The new code would be written only into the container layer and lost the next time the container is recreated, while the database migrations would stay applied — leaving old code on a new schema. Update by moving the container to the new image instead: \"docker compose pull && docker compose up -d\".", "Impossibile verificare lo spazio disponibile in %s. Controlla la configurazione del filesystem e riprova.": "Unable to verify available space in %s. Check the filesystem configuration and try again.", "Il sistema di aggiornamento non è disponibile. Il dettaglio è nel registro dell'applicazione.": "The update system is unavailable. The details are in the application log.", - "almeno %s": "at least %s" + "almeno %s": "at least %s", + "Pacchetto scaricato e verificato": "Package downloaded and verified", + "trasferimento interrotto": "transfer interrupted", + "Il pacchetto in attesa è cambiato in un'altra scheda: ripeti l'aggiornamento": "The waiting package was replaced in another tab: start the update again", + "Errore fatale PHP durante l'aggiornamento": "PHP fatal error during the update" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index d2cca7855..81e711af9 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -8041,5 +8041,9 @@ "Questa è l'immagine Docker ufficiale di Pinakes: l'aggiornamento dall'applicazione è disattivato di proposito. Il codice verrebbe riscritto solo nel layer del container e andrebbe perso alla prima ricreazione, mentre le migrazioni del database resterebbero applicate — lasciando codice vecchio su uno schema nuovo. Aggiorna spostando il container sulla nuova immagine: \"docker compose pull && docker compose up -d\".": "Ceci est l'image Docker officielle de Pinakes : la mise à jour depuis l'application est désactivée à dessein. Le nouveau code ne serait écrit que dans la couche du conteneur et serait perdu à la prochaine recréation, tandis que les migrations de la base de données resteraient appliquées — laissant du code ancien sur un schéma nouveau. Mettez plutôt à jour en basculant le conteneur sur la nouvelle image : \"docker compose pull && docker compose up -d\".", "Impossibile verificare lo spazio disponibile in %s. Controlla la configurazione del filesystem e riprova.": "Impossible de vérifier l’espace disponible dans %s. Vérifiez la configuration du système de fichiers et réessayez.", "Il sistema di aggiornamento non è disponibile. Il dettaglio è nel registro dell'applicazione.": "Le système de mise à jour est indisponible. Le détail figure dans le journal de l'application.", - "almeno %s": "au moins %s" + "almeno %s": "au moins %s", + "Pacchetto scaricato e verificato": "Paquet téléchargé et vérifié", + "trasferimento interrotto": "transfert interrompu", + "Il pacchetto in attesa è cambiato in un'altra scheda: ripeti l'aggiornamento": "Le paquet en attente a été remplacé dans un autre onglet : relancez la mise à jour", + "Errore fatale PHP durante l'aggiornamento": "Erreur fatale PHP pendant la mise à jour" } diff --git a/locale/it_IT.json b/locale/it_IT.json index 33e897a85..d1bb357f5 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -8041,5 +8041,9 @@ "Questa è l'immagine Docker ufficiale di Pinakes: l'aggiornamento dall'applicazione è disattivato di proposito. Il codice verrebbe riscritto solo nel layer del container e andrebbe perso alla prima ricreazione, mentre le migrazioni del database resterebbero applicate — lasciando codice vecchio su uno schema nuovo. Aggiorna spostando il container sulla nuova immagine: \"docker compose pull && docker compose up -d\".": "Questa è l'immagine Docker ufficiale di Pinakes: l'aggiornamento dall'applicazione è disattivato di proposito. Il codice verrebbe riscritto solo nel layer del container e andrebbe perso alla prima ricreazione, mentre le migrazioni del database resterebbero applicate — lasciando codice vecchio su uno schema nuovo. Aggiorna spostando il container sulla nuova immagine: \"docker compose pull && docker compose up -d\".", "Impossibile verificare lo spazio disponibile in %s. Controlla la configurazione del filesystem e riprova.": "Impossibile verificare lo spazio disponibile in %s. Controlla la configurazione del filesystem e riprova.", "Il sistema di aggiornamento non è disponibile. Il dettaglio è nel registro dell'applicazione.": "Il sistema di aggiornamento non è disponibile. Il dettaglio è nel registro dell'applicazione.", - "almeno %s": "almeno %s" + "almeno %s": "almeno %s", + "Pacchetto scaricato e verificato": "Pacchetto scaricato e verificato", + "trasferimento interrotto": "trasferimento interrotto", + "Il pacchetto in attesa è cambiato in un'altra scheda: ripeti l'aggiornamento": "Il pacchetto in attesa è cambiato in un'altra scheda: ripeti l'aggiornamento", + "Errore fatale PHP durante l'aggiornamento": "Errore fatale PHP durante l'aggiornamento" } diff --git a/tests/update-pending-package.unit.php b/tests/update-pending-package.unit.php new file mode 100644 index 000000000..b5ec3606e --- /dev/null +++ b/tests/update-pending-package.unit.php @@ -0,0 +1,128 @@ + */ + public static array $installed = []; + /** @var list */ + public static array $discarded = []; + + public function __construct(\mysqli $db) {} + + public function checkRequirements(): array + { + return ['met' => true, 'requirements' => []]; + } + + public function performUpdateFromFile(string $path): array + { + self::$installed[] = $path; + return ['success' => true, 'error' => null, 'backup_path' => null]; + } + + public function discardPendingPackage(string $path): void + { + self::$discarded[] = $path; + } + } +} + +namespace { + require dirname(__DIR__) . '/vendor/autoload.php'; + if (!function_exists('__')) { + function __(string $s): string { return $s; } + } + + $passed = 0; + $check = static function (bool $ok, string $label) use (&$passed): void { + if (!$ok) { + fwrite(STDERR, "FAIL $label\n"); + exit(1); + } + $passed++; + echo "OK $label\n"; + }; + + $controller = new App\Controllers\UpdateController(); + $updater = new App\Support\Updater(new mysqli()); + $db = new mysqli(); + $hold = new ReflectionMethod($controller, 'holdPendingPackage'); + $hold->setAccessible(true); + + $tmp = dirname(__DIR__) . '/storage/tmp'; + if (!is_dir($tmp)) { mkdir($tmp, 0775, true); } + $made = []; + $package = static function (string $content) use ($tmp, &$made): string { + $dir = $tmp . '/manual_update_' . bin2hex(random_bytes(8)); + mkdir($dir); + file_put_contents($dir . '/update.zip', $content); + $made[] = $dir; + return $dir; + }; + $install = static function (string $packageId) use ($controller, $db): array { + $request = (new Slim\Psr7\Factory\ServerRequestFactory())->createServerRequest('POST', '/admin/updates/install-manual') + ->withParsedBody(['csrf_token' => 'test-token', 'package' => $packageId]); + $response = $controller->installManualUpdate($request, new Slim\Psr7\Response(), $db); + return [$response->getStatusCode(), json_decode((string) $response->getBody(), true)]; + }; + $_SESSION = ['user' => ['tipo_utente' => 'admin'], 'csrf_token' => 'test-token']; + + try { + // A tab downloads; another tab uploads before the first installs. + $downloaded = $package('downloaded'); + $downloadId = $hold->invoke($controller, $updater, $downloaded, hash('sha256', 'downloaded')); + $uploaded = $package('uploaded'); + $uploadId = $hold->invoke($controller, $updater, $uploaded, null); + + $check($downloadId !== $uploadId && strlen($downloadId) === 32, 'each package gets its own opaque id'); + $check(App\Support\Updater::$discarded === [$downloaded], 'the package that was replaced is deleted'); + + [$status, $body] = $install($downloadId); + $check($status === 409 && ($body['success'] ?? null) === false, 'the download tab is refused: its package was replaced'); + $check(App\Support\Updater::$installed === [], 'nothing is installed on a refused request'); + $check(($_SESSION['manual_update_path'] ?? '') === $uploaded, 'the other package is left for its own install request'); + + [$status, $body] = $install($uploadId); + $check($status === 200 && ($body['success'] ?? null) === true, 'the upload tab installs'); + $check(App\Support\Updater::$installed === [realpath($uploaded)], 'and installs its own package'); + $check(!isset($_SESSION['manual_update_path'], $_SESSION['manual_update_id']), 'the package is consumed once'); + + [$status] = $install($uploadId); + $check($status === 409, 'the same id does not install twice'); + + // A downloaded package changed on disk after its verification. + $changed = $package('downloaded'); + $changedId = $hold->invoke($controller, $updater, $changed, hash('sha256', 'downloaded')); + file_put_contents($changed . '/update.zip', 'tampered'); + [$status, $body] = $install($changedId); + $check($status === 400 && str_contains((string) ($body['error'] ?? ''), 'checksum'), 'a downloaded package is checked against its digest again before the install'); + $check(count(App\Support\Updater::$installed) === 1, 'and is not installed when it no longer matches'); + + // The same, intact. + $intact = $package('downloaded'); + $intactId = $hold->invoke($controller, $updater, $intact, hash('sha256', 'downloaded')); + [$status] = $install($intactId); + $check($status === 200 && end(App\Support\Updater::$installed) === realpath($intact), 'an intact downloaded package installs'); + } finally { + foreach ($made as $dir) { + @unlink($dir . '/update.zip'); + @rmdir($dir); + } + } + + echo "\nAll $passed checks passed\n"; +} diff --git a/tests/updater-fatal-json.unit.php b/tests/updater-fatal-json.unit.php new file mode 100644 index 000000000..13cd38bf8 --- /dev/null +++ b/tests/updater-fatal-json.unit.php @@ -0,0 +1,49 @@ +newInstanceWithoutConstructor(); +$guard = new ReflectionMethod($controller, "answerJsonOnFatal"); +$guard->setAccessible(true); +$guard->invoke($controller); +ob_start(); +echo "partial page"; +$hog = []; +while (true) { $hog[] = str_repeat("x", 1024 * 1024); } +'); +$out = (string) shell_exec(escapeshellarg(PHP_BINARY) . ' -d memory_limit=32M -d display_errors=0 -d log_errors=0 ' . escapeshellarg($child) . ' 2>/dev/null'); +@unlink($child); + +$json = json_decode(trim($out), true); +check(is_array($json), 'a fatal error answers as JSON, not as an error page: ' . substr($out, 0, 120)); +check(($json['success'] ?? null) === false, 'the answer says the update did not succeed'); +check(str_contains((string) ($json['error'] ?? ''), 'Allowed memory size'), "PHP's own message reaches the page"); +check(!str_contains($out, 'partial page'), 'output buffered before the fatal is dropped, so the JSON stands alone'); + +$controllerSrc = (string) file_get_contents($root . '/app/Controllers/UpdateController.php'); +check(substr_count($controllerSrc, '$this->answerJsonOnFatal();') === 3, 'the guard covers download, install-manual and the single-request perform'); + +echo "SUCCESS $checks checks\n"; diff --git a/tests/updater-hardening.unit.php b/tests/updater-hardening.unit.php index 0cfcc468b..4d387f2a1 100644 --- a/tests/updater-hardening.unit.php +++ b/tests/updater-hardening.unit.php @@ -172,7 +172,10 @@ protected function downloadPatchFile(string $url): ?string 'no unverifiable zipball_url download fallback'); // 8. The package download is token-scoped via isApiUrl($downloadUrl). -$check(strpos($src, "getGitHubHeaders('application/octet-stream', \$this->isApiUrl(\$downloadUrl))") !== false, +// The download streams to disk (0.7.92, #450): the auth flag is decided by +// isApiUrl() on the package URL and is the only thing that sends the token. +$check(strpos($src, "streamToFile(\$url, \$partPath, \$this->isApiUrl(\$url))") !== false + && strpos($src, "getGitHubHeaders('application/octet-stream', \$withAuth)") !== false, 'package download header scoped by isApiUrl()'); // 9. fetchVerifiedReleaseAsset uses hash_equals (timing-safe). diff --git a/tests/updater-truncated-download.unit.php b/tests/updater-truncated-download.unit.php new file mode 100644 index 000000000..d4d700d13 --- /dev/null +++ b/tests/updater-truncated-download.unit.php @@ -0,0 +1,90 @@ +newInstanceWithoutConstructor(); + +/** Point $download at a fresh truncating server and return the error it raised. */ +$attempt = static function (callable $download) use ($serverCode, $check): ?string { + $proc = proc_open([PHP_BINARY, '-r', $serverCode], [1 => ['pipe', 'w']], $pipes); + $check(is_resource($proc), 'local server starts'); + $port = (int) trim((string) fgets($pipes[1])); + $check($port > 0, 'local server listens'); + $path = tempnam(sys_get_temp_dir(), 'pinakes-part-'); + $error = null; + try { + $download("http://127.0.0.1:$port/pinakes.zip", $path); + } catch (\Throwable $e) { + $error = $e->getMessage(); + } finally { + fclose($pipes[1]); + proc_close($proc); + @unlink($path); + } + return $error; +}; + +$streamToFile = new ReflectionMethod($updater, 'streamToFile'); +$streamToFile->setAccessible(true); +$streamWithPhp = new ReflectionMethod($updater, 'streamWithPhp'); +$streamWithPhp->setAccessible(true); + +$paths = [ + 'cURL' => static fn(string $url, string $path) => $streamToFile->invoke($updater, $url, $path, false), + // Hosts without cURL: the PHP stream the updater falls back to + 'PHP stream' => static function (string $url, string $path) use ($streamWithPhp, $updater): void { + $out = fopen($path, 'wb'); + try { + $streamWithPhp->invoke($updater, $url, ['User-Agent: Pinakes-Updater/1.0'], $out); + } finally { + fclose($out); + } + }, +]; +foreach ($paths as $name => $download) { + $error = $attempt($download); + $check($error !== null, "$name: a transfer that breaks off is an error, not a downloaded file"); + $check(str_starts_with((string) $error, 'Download fallito: '), "$name: it is reported as a failed download"); + $check(!str_contains((string) $error, 'checksum'), "$name: it is not reported as a checksum mismatch"); + $check(strlen((string) $error) > strlen('Download fallito: '), "$name: the reason is named: " . $error); +} + +echo "\nAll $checks checks passed\n"; diff --git a/tests/updater-two-step-450.spec.js b/tests/updater-two-step-450.spec.js new file mode 100644 index 000000000..1a8fa1eed --- /dev/null +++ b/tests/updater-two-step-450.spec.js @@ -0,0 +1,83 @@ +// @ts-check +/** + * Issue #450: the automatic update failed on some hosting with "The server + * returned an invalid response" and no way to tell why. From 0.7.92 it runs in + * two requests (download, then the install-manual request a manual update + * ends with), and an answer that is not JSON is shown with its HTTP status and + * the start of its text. + * + * The full download-and-install against GitHub is exercised by + * scripts/reinstall-test.sh --auto-update; here: the new endpoint's answers + * and the error message the page builds, on the real admin page. + */ +const { test, expect } = require('@playwright/test'); + +const BASE = process.env.E2E_BASE_URL || 'http://localhost:8081'; +const ADMIN_EMAIL = process.env.E2E_ADMIN_EMAIL || ''; +const ADMIN_PASS = process.env.E2E_ADMIN_PASS || ''; + +test.skip(!ADMIN_EMAIL || !ADMIN_PASS, 'updater-two-step-450 requires E2E_ADMIN_EMAIL and E2E_ADMIN_PASS'); + +test.describe.serial('Automatic update in two requests (#450)', () => { + /** @type {import('@playwright/test').Page} */ + let page; + + test.beforeAll(async ({ browser }) => { + page = await (await browser.newContext()).newPage(); + await page.goto(`${BASE}/admin`); + if (!page.url().includes('/admin') || /login|accedi|anmelden/.test(page.url())) { + for (const slug of ['accedi', 'login', 'anmelden']) { + const resp = await page.goto(`${BASE}/${slug}`).catch(() => null); + if (resp && resp.status() === 200 && (await page.locator('input[name="email"]').count()) > 0) break; + } + await page.fill('input[name="email"]', ADMIN_EMAIL); + await page.fill('input[name="password"]', ADMIN_PASS); + await Promise.all([page.waitForURL(/admin/, { timeout: 15000 }), page.locator('button[type="submit"]').click()]); + } + await page.goto(`${BASE}/admin/updates`, { waitUntil: 'domcontentloaded' }); + }); + test.afterAll(async () => { await page?.context().close(); }); + + test('1 A proxy timeout page reaches the operator as its HTTP status and text, not only "invalid response"', async () => { + const message = await page.evaluate(async () => { + const html = '

504 Gateway Time-out

nginx

'; + try { + // @ts-ignore readUpdateJson is the page's own helper + await readUpdateJson(new Response(html, { status: 504, headers: { 'Content-Type': 'text/html' } })); + return 'no error'; + } catch (e) { + return String(e.message); + } + }); + expect(message).toContain('HTTP 504'); + expect(message).toContain('504 Gateway Time-out nginx'); + expect(message).not.toContain('

'); + expect(message).not.toContain('color:red'); + }); + + test('2 A JSON answer is read as before', async () => { + const value = await page.evaluate(async () => { + // @ts-ignore + const data = await readUpdateJson(new Response('{"success":true}', { headers: { 'Content-Type': 'application/json' } })); + return data.success; + }); + expect(value).toBe(true); + }); + + test('3 The download endpoint answers JSON to the page (Accept: application/json): no token is refused, a malformed version is rejected', async () => { + const noToken = await page.request.post(`${BASE}/admin/updates/download`, { form: { version: '9.9.9' }, headers: { Accept: 'application/json' } }); + expect(noToken.status()).toBe(403); + expect(noToken.headers()['content-type'] || '').toContain('application/json'); + + // @ts-ignore csrfToken is defined by the updates page + const token = await page.evaluate(() => csrfToken); + const badVersion = await page.request.post(`${BASE}/admin/updates/download`, { form: { csrf_token: token, version: '../../etc' }, headers: { Accept: 'application/json' } }); + expect(badVersion.status()).toBe(400); + expect(await badVersion.json()).toHaveProperty('error'); + }); + + test('4 The progress shows the real order: download first, then backup, files and migrations', async () => { + const steps = await page.locator('#updateProgress .update-step').evaluateAll((els) => els.map((el) => el.getAttribute('data-step'))); + expect(steps).toEqual(['download', 'backup', 'install', 'migrate']); + }); +}); diff --git a/version.json b/version.json index 650cce126..f04e692f5 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { "name": "Pinakes", - "version": "0.7.91", + "version": "0.7.92", "description": "Library Management System - Sistema di Gestione Bibliotecaria" }