Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
ecd57f0
feat(updater): refuse an update that will not fit, and say why a copy…
fabiodalez-dev Sep 9, 2026
c07747b
fix(updater): prove the whole space requirement, and name the cause o…
fabiodalez-dev Sep 10, 2026
d362ef0
fix(updater): describe a write failure before anything else can overw…
fabiodalez-dev Sep 10, 2026
50d0d72
fix(updater): gate before the first write, size the extraction, recon…
fabiodalez-dev Sep 10, 2026
e52cf32
perf(updater): stop paying update-sized costs on every render of the …
fabiodalez-dev Sep 10, 2026
504cc44
feat(updater): disable the in-app update on the official Docker image
fabiodalez-dev Sep 10, 2026
efc78c1
fix(updater): verify cumulative capacity on actual write destinations
fabiodalez-dev Sep 10, 2026
54a85c9
Merge remote-tracking branch 'origin/main' into fix/update-space-pref…
fabiodalez-dev Oct 3, 2026
c848896
Keep updater internals out of the response, and log every failure to …
fabiodalez-dev Oct 3, 2026
05311ef
Merge branch 'ci/waive-dev-only-braces-advisory' into fix/update-spac…
fabiodalez-dev Oct 3, 2026
1a5a152
Report every unwritable update destination and keep the CLI upgrade u…
fabiodalez-dev Oct 3, 2026
25fc7e3
Merge remote-tracking branch 'origin/ci/waive-dev-only-braces-advisor…
fabiodalez-dev Oct 3, 2026
3e66c0e
Keep a dense write probe when the operator waives an unmeasurable spa…
fabiodalez-dev Oct 4, 2026
19ae889
Merge remote-tracking branch 'origin/main' into fix/update-space-pref…
fabiodalez-dev Oct 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 96 additions & 8 deletions app/Controllers/UpdateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Support\BackupManager;
use App\Support\Csrf;
use App\Support\SecureLogger;
use App\Support\UpdaterPreflightException;
use mysqli;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
Expand All @@ -23,7 +24,38 @@ public function index(Request $request, Response $response, mysqli $db): Respons
// Admin-only access check removed - relying on Middleware


$updater = new Updater($db);
// The constructor refuses to build when a genuinely fatal precondition
// fails (storage/tmp or storage/backups unwritable, no ZipArchive, no
// HTTP transport). Letting that escape turns this page — the one place
// that would name the problem — into a blank 500, which is the opposite
// of what an operator in trouble needs. Degrade to a page that says what
// to fix instead.
try {
$updater = new Updater($db);
} catch (\Throwable $e) {
ob_start();
?>
<div class="max-w-3xl mx-auto mt-10 bg-white dark:bg-gray-800 border border-red-300 dark:border-red-700 rounded-lg shadow p-6">
<h1 class="text-xl font-semibold text-red-700 dark:text-red-400 mb-3">
<?php echo htmlspecialchars(__('Aggiornamenti non disponibili'), ENT_QUOTES, 'UTF-8'); ?>
</h1>
<p class="text-gray-700 dark:text-gray-300 mb-4">
<?php echo htmlspecialchars(__('Il sistema di aggiornamento non può essere avviato perché una condizione preliminare non è soddisfatta. Correggi quanto indicato qui sotto e ricarica la pagina.'), ENT_QUOTES, 'UTF-8'); ?>
</p>
<pre class="bg-gray-100 dark:bg-gray-900 text-sm text-gray-800 dark:text-gray-200 rounded p-4 whitespace-pre-wrap"><?php
echo htmlspecialchars($this->updaterUnavailable($e, 'index'), ENT_QUOTES, 'UTF-8');
?></pre>
</div>
<?php
$content = ob_get_clean();

ob_start();
require __DIR__ . '/../Views/layout.php';
$html = ob_get_clean();

$response->getBody()->write($html);
return $response->withStatus(503);
}

// Check for updates
$updateInfo = $updater->checkForUpdates();
Expand Down Expand Up @@ -61,7 +93,14 @@ public function checkUpdates(Request $request, Response $response, mysqli $db):
// Admin-only access check removed


$updater = new Updater($db);
try {
$updater = new Updater($db);
} catch (\Throwable $e) {
return $this->jsonResponse($response, [
'success' => false,
'error' => $this->updaterUnavailable($e, 'checkUpdates'),
], 503);
}
$updateInfo = $updater->checkForUpdates();

return $this->jsonResponse($response, $updateInfo);
Expand Down Expand Up @@ -92,7 +131,14 @@ public function performUpdate(Request $request, Response $response, mysqli $db):
return $this->jsonResponse($response, ['error' => __('Versione non specificata')], 400);
}

$updater = new Updater($db);
try {
$updater = new Updater($db);
} catch (\Throwable $e) {
return $this->jsonResponse($response, [
'success' => false,
'error' => $this->updaterUnavailable($e, 'performUpdate'),
], 503);
}

// Check requirements first
$requirements = $updater->checkRequirements();
Expand Down Expand Up @@ -165,7 +211,14 @@ public function getHistory(Request $request, Response $response, mysqli $db): Re
// Admin-only access check removed


$updater = new Updater($db);
try {
$updater = new Updater($db);
} catch (\Throwable $e) {
return $this->jsonResponse($response, [
'success' => false,
'error' => $this->updaterUnavailable($e, 'getHistory'),
], 503);
}
$history = $updater->getUpdateHistory();

return $this->jsonResponse($response, ['history' => $history]);
Expand All @@ -182,7 +235,14 @@ public function checkAvailable(Request $request, Response $response, mysqli $db)
return $this->jsonResponse($response, ['available' => false]);
}

$updater = new Updater($db);
try {
$updater = new Updater($db);
} catch (\Throwable $e) {
return $this->jsonResponse($response, [
'success' => false,
'error' => $this->updaterUnavailable($e, 'checkAvailable'),
], 503);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
$updateInfo = $updater->checkForUpdates();

return $this->jsonResponse($response, [
Expand Down Expand Up @@ -426,7 +486,7 @@ public function clearMaintenance(Request $request, Response $response): Response
if (file_exists($maintenanceFile)) {
// nosemgrep: php.lang.security.unlink-use.unlink-use -- constant internal path (storage/.maintenance), not user input
if (@unlink($maintenanceFile)) {
error_log("[Updater] Maintenance mode cleared manually by admin user " . ($_SESSION['user']['id'] ?? 'unknown'));
SecureLogger::info('[Updater] Maintenance mode cleared manually by admin user ' . ($_SESSION['user']['id'] ?? 'unknown'));
return $this->jsonResponse($response, [
'success' => true,
'message' => __('Modalità manutenzione disattivata')
Expand Down Expand Up @@ -575,7 +635,7 @@ public function uploadUpdate(Request $request, Response $response, mysqli $db):
], 500);

} catch (\Throwable $e) {
error_log('[UpdateController] Upload failed: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
SecureLogger::error('[UpdateController] Upload failed (' . get_class($e) . '): ' . $e->getMessage());
return $this->jsonResponse($response, [
'success' => false,
'error' => __('Errore durante il caricamento del pacchetto')
Expand Down Expand Up @@ -623,7 +683,14 @@ public function installManualUpdate(Request $request, Response $response, mysqli
], 400);
}

$updater = new Updater($db);
try {
$updater = new Updater($db);
} catch (\Throwable $e) {
return $this->jsonResponse($response, [
'success' => false,
'error' => $this->updaterUnavailable($e, 'installManualUpdate'),
], 503);
}

// Check requirements first
$requirements = $updater->checkRequirements();
Expand Down Expand Up @@ -723,6 +790,27 @@ private function restoreFailureStatus(array $result): int
};
}

/**
* What to say when the updater cannot be built, after logging why.
*
* The full exception always goes to the application log. Only an
* administrator, and only for a missing host precondition
* (UpdaterPreflightException, whose message is written for the operator),
* sees the cause; anyone else, or any unexpected exception, gets a generic
* message, since a raw exception can carry server paths and internals and
* some of these endpoints also answer staff.
*/
private function updaterUnavailable(\Throwable $e, string $action): string
{
SecureLogger::error('[UpdateController] ' . $action . ': updater unavailable (' . get_class($e) . '): ' . $e->getMessage());

if ($e instanceof UpdaterPreflightException && ($_SESSION['user']['tipo_utente'] ?? '') === 'admin') {
return $e->getMessage();
}

return __("Il sistema di aggiornamento non è disponibile. Il dettaglio è nel registro dell'applicazione.");
}

/**
* Helper: Send JSON response
*/
Expand Down
151 changes: 111 additions & 40 deletions app/Support/BackupManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,16 +101,81 @@ public function __construct(mysqli $db, string $rootPath)
$this->backupPath = $this->rootPath . '/storage/backups';
}

/**
* Why a write to this path failed, in words an operator can act on.
*
* A deliberate local mirror of Updater::describeWriteFailure(): this class
* is used by the admin Backup UI as well as by the updater and must not
* depend on it. The message strings are the SAME msgids, so the five locale
* files already carry them.
*
* Cheapest and most specific first, and error_get_last() is captured before
* any I/O of our own — it is process-global and the diagnosis would
* otherwise overwrite the very error it explains. The probe writes a real
* 1 MB file: a sparse hole consumes no quota, and the quota is what an
* exhausted cPanel account runs out of while the filesystem still reports
* gigabytes free.
*/
private function describeWriteFailure(string $targetPath): string
{
$last = error_get_last();
$dir = dirname($targetPath);

if (is_file($targetPath) && !is_writable($targetPath)) {
return __('il file di destinazione esiste e non è scrivibile');
}
if (!is_dir($dir)) {
return __('la directory di destinazione non esiste');
}
if (!is_writable($dir)) {
return __('la directory di destinazione non è scrivibile');
}
if ($this->outOfSpace($dir)) {
return __('spazio su disco o quota dell\'account esauriti');
}

$message = $last === null ? '' : trim($last['message']);
return $message !== '' ? $message : __('causa sconosciuta');
}

/** True when a real, bounded 1 MB write into $dir cannot be completed. */
private function outOfSpace(string $dir): bool
{
$bytes = 1024 * 1024;
$free = @disk_free_space($dir);
if (is_float($free) && $free < $bytes) {
return true;
}
$probe = $dir . '/.backup_probe_' . bin2hex(random_bytes(4));
$handle = @fopen($probe, 'wb');
if ($handle === false) {
// Could not create the probe at all: that is a permission problem,
// not a space one, and the caller's stat checks already covered it.
return false;
}
$ok = true;
try {
$written = @fwrite($handle, str_repeat('0', $bytes));
if ($written === false || $written < $bytes) {
$ok = false;
}
if ($ok && !@fflush($handle)) {
$ok = false;
}
} catch (\Throwable) {
$ok = false;
} finally {
@fclose($handle);
// nosemgrep: php.lang.security.unlink-use.unlink-use -- own probe file, name generated here
@unlink($probe);
}
return !$ok;
}

// ---------------------------------------------------------------------
// Create
// ---------------------------------------------------------------------

/**
* Create a backup ZIP.
*
* @param string $scope 'full' (DB + files) or 'db' (database only)
* @return array{success: bool, name: string|null, path: string|null, size: int, error: string|null}
*/
/**
* Build a backup filename carrying its origin.
*
Expand Down Expand Up @@ -150,7 +215,10 @@ public function createBackup(string $scope = 'full', string $origin = self::ORIG
throw new \RuntimeException(__('Estensione ZipArchive non disponibile'));
}
if (!is_dir($this->backupPath) && !@mkdir($this->backupPath, 0755, true) && !is_dir($this->backupPath)) {
throw new \RuntimeException(__('Impossibile creare directory di backup'));
throw new \RuntimeException(
__('Impossibile creare directory di backup')
. ' — ' . $this->describeWriteFailure($this->backupPath)
);
}

// Serialize the whole write-then-rotate sequence. Two concurrent
Expand All @@ -173,7 +241,13 @@ public function createBackup(string $scope = 'full', string $origin = self::ORIG
// 2. Open the ZIP.
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException(__('Impossibile creare il file di backup'));
// This is the FIRST writer of the whole update flow: on an
// account whose quota is exhausted it is the most likely place
// to fail, and "Backup fallito" alone says nothing about why.
throw new \RuntimeException(
__('Impossibile creare il file di backup')
. ' — ' . $this->describeWriteFailure($zipPath)
);
}

$zip->addFile($sqlTmp, 'database.sql');
Expand Down Expand Up @@ -203,7 +277,12 @@ public function createBackup(string $scope = 'full', string $origin = self::ORIG
$zip->addFromString('manifest.json', (string) json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

if (!$zip->close()) {
throw new \RuntimeException(__('Errore nella scrittura del backup'));
// ZipArchive flushes on close: a quota that ran out while the
// entries were being written surfaces HERE, not at open().
throw new \RuntimeException(
__('Errore nella scrittura del backup')
. ' — ' . $this->describeWriteFailure($zipPath)
);
}

// nosemgrep: php.lang.security.unlink-use.unlink-use -- tempnam()-generated temp dump path, not user input
Expand Down Expand Up @@ -376,14 +455,6 @@ private function pruneOldBackups(string $justWritten): void
}
}

/**
* @return array<int, array{name: string, path: string, size: int, date: string, contents: string, created_at: int}>
*/
/**
* Origin encoded in a filename, for archives whose manifest predates it.
* Unknown or absent suffix means the automatic shape, which is what every
* archive written before this existed actually was.
*/
/** True only for a directory whose sole entry is a database.sql file. */
private static function isLegacyBackupDirectory(string $dir): bool
{
Expand Down Expand Up @@ -576,6 +647,20 @@ public function restoreFromUploadedZip(string $tmpPath, int $size): array
return $this->restoreZip($dest);
}

/**
* True from the instant the import may have executed its first DROP TABLE
* until it has finished.
*
* MySQL cannot roll back DDL, so between those two points the database is
* neither the one the site was serving nor the one the archive describes.
* Two things must follow from that and neither did: the outcome has to say
* so, and the site must stay closed. An interrupted restore reported as an
* ordinary failure invites the operator to retry into the wreckage, and the
* maintenance flag was being lifted on the way out — including by the
* fatal-error shutdown handler, which is exactly the case this describes.
*/
private bool $databaseReplacementStarted = false;

/**
* Entry point for both restore paths (stored backup + uploaded ZIP).
*
Expand All @@ -592,20 +677,6 @@ public function restoreFromUploadedZip(string $tmpPath, int $size): array
*
* @return array{success: bool, safety_backup: string|null, error: string|null, partial?: bool, restored_phase?: string}
*/
/**
* True from the instant the import may have executed its first DROP TABLE
* until it has finished.
*
* MySQL cannot roll back DDL, so between those two points the database is
* neither the one the site was serving nor the one the archive describes.
* Two things must follow from that and neither did: the outcome has to say
* so, and the site must stay closed. An interrupted restore reported as an
* ordinary failure invites the operator to retry into the wreckage, and the
* maintenance flag was being lifted on the way out — including by the
* fatal-error shutdown handler, which is exactly the case this describes.
*/
private bool $databaseReplacementStarted = false;

private function restoreZip(string $zipPath): array
{
$lockFile = $this->rootPath . '/storage/cache/update.lock';
Expand Down Expand Up @@ -1005,15 +1076,6 @@ private function removeDir(string $dir): void
// Internals
// ---------------------------------------------------------------------

/**
* Dump every table to $filepath (DROP/CREATE/INSERT). Returns the table count.
*
* Binary columns are NOT supported by this dump path: every value is emitted
* as a single-quoted, real_escape_string()-escaped text literal (no _binary
* or hex literal), so any BLOB/BINARY bytes would be corrupted on a
* backup/restore round-trip. The schema is text/numeric/datetime only —
* adding a binary column requires changing this serialization first.
*/
/**
* The columns of $table a restore may write, in declaration order.
*
Expand Down Expand Up @@ -1112,6 +1174,15 @@ private function insertableColumns(string $table): array
return $columns;
}

/**
* Dump every table to $filepath (DROP/CREATE/INSERT). Returns the table count.
*
* Binary columns are NOT supported by this dump path: every value is emitted
* as a single-quoted, real_escape_string()-escaped text literal (no _binary
* or hex literal), so any BLOB/BINARY bytes would be corrupted on a
* backup/restore round-trip. The schema is text/numeric/datetime only —
* adding a binary column requires changing this serialization first.
*/
private function dumpDatabaseTo(string $filepath): int
{
$handle = fopen($filepath, 'w');
Expand Down
Loading
Loading