++ fix restore backup

This commit is contained in:
2026-07-29 17:56:52 +02:00
parent 7ac454cd25
commit 8dee7b1f44
9 changed files with 237 additions and 24 deletions

View File

@@ -81,6 +81,22 @@ CACHE_STORE=redis
SESSION_DRIVER=redis SESSION_DRIVER=redis
QUEUE_CONNECTION=redis QUEUE_CONNECTION=redis
# -----------------------------------------------------------------------------
# Dimensione massima dei file caricati (MB)
# -----------------------------------------------------------------------------
# Governa insieme nginx (client_max_body_size), PHP (upload_max_filesize e
# post_max_size) e la validazione dell'applicazione: li genera l'entrypoint da
# questo unico valore, cosi' non possono andare fuori sincrono.
#
# Il file piu' grande che passa di qui e' il pacchetto di ripristino, che
# contiene tutti i PDF dei territori: con qualche centinaio di mappe si arriva
# facilmente a oltre un giga. Se un caricamento muore senza spiegazioni e la
# barra si pianta, e' quasi sempre questo il limite che si e' incontrato.
#
# Alzarlo non costa memoria: nginx accumula il corpo su disco e PHP lavora su un
# file temporaneo. Serve pero' spazio libero nel volume.
MAX_UPLOAD_MB=2048
# DEVE essere false se accedi in HTTP (tipicamente per IP:porta). # DEVE essere false se accedi in HTTP (tipicamente per IP:porta).
# #
# Con true, il cookie di sessione viene marcato "secure" e il browser non lo # Con true, il cookie di sessione viene marcato "secure" e il browser non lo

View File

@@ -353,6 +353,31 @@ Scelte non ovvie:
già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del
proprio ripristino. proprio ripristino.
- **Il tetto degli upload è una variabile sola, `MAX_UPLOAD_MB`.** Prima viveva
in tre posti scollegati — `client_max_body_size` in
`docker/nginx/default.conf`, `upload_max_filesize`/`post_max_size` in
`docker/php/php.ini`, e la regola `max:` nella validazione — e bastava che
uno restasse indietro perché il più piccolo vincesse nel modo peggiore.
Adesso `entrypoint.sh` genera i primi due (`conf.d/upload-limit.conf` e
`conf.d/zz-upload-limit.ini`) e `config/app.php` alimenta la terza.
**Non rimettere `client_max_body_size` nel blocco `server` di
`default.conf`**: una direttiva lì dentro vince su quella generata a livello
http e reintroduce in silenzio proprio il disallineamento che questa
disposizione esiste per evitare. La validazione tiene un 2% di margine sotto
il valore di nginx, così a fermare un pacchetto fuori misura è Laravel, che
sa spiegarsi, e non nginx.
Il sintomo, quando il limite viene superato, non assomiglia affatto a un
errore di dimensione: nginx risponde **413 dopo aver accettato zero byte** e
chiude. Il browser ha appena iniziato a spedire, la barra si pianta a una
percentuale bassa e nessun evento utile arriva all'`XMLHttpRequest` — pagina
muta, con l'aria di essersi bloccata. Da qui i tre presidi nella vista:
controllo della dimensione *prima* di caricare, `xhr.timeout` con i relativi
gestori di `timeout`/`abort`/`error` (senza, un caricamento fermo non produce
più nulla per sempre), e il codice HTTP dentro il messaggio, perché 413 e 419
non hanno un corpo JSON da cui ricavare una spiegazione.
- **Nell'azzeramento i file si cancellano prima del database.** Svuotare il - **Nell'azzeramento i file si cancellano prima del database.** Svuotare il
disco è l'unico passo che può fallire per cause esterne (permessi sul disco è l'unico passo che può fallire per cause esterne (permessi sul
volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un volume), e fallire lì non ha ancora toccato niente. Nell'ordine inverso un

View File

@@ -330,6 +330,7 @@ ridichiara le variabili applicative: le prende dal `.env` così com'è.
| `APP_TIMEZONE` | `Europe/Rome` | Fuso orario | | `APP_TIMEZONE` | `Europe/Rome` | Fuso orario |
| `LOG_LEVEL` | `warning` | `debug` per diagnosticare | | `LOG_LEVEL` | `warning` | `debug` per diagnosticare |
| `SESSION_SECURE_COOKIE` | `false` | `true` solo servendo in HTTPS | | `SESSION_SECURE_COOKIE` | `false` | `true` solo servendo in HTTPS |
| `MAX_UPLOAD_MB` | `2048` | Tetto dei file caricati: governa insieme nginx, PHP e la validazione |
| `APP_BIND` | `0.0.0.0` | `127.0.0.1` dietro un reverse proxy | | `APP_BIND` | `0.0.0.0` | `127.0.0.1` dietro un reverse proxy |
| `APP_PORT` | `8080` | Porta dell'applicazione sull'host | | `APP_PORT` | `8080` | Porta dell'applicazione sull'host |
| `DB_BIND` | `127.0.0.1` | Interfaccia della porta MariaDB | | `DB_BIND` | `127.0.0.1` | Interfaccia della porta MariaDB |
@@ -422,9 +423,23 @@ Il pacchetto **non** contiene `APP_KEY`: i nomi dei proclamatori sono cifrati
con quella chiave, quindi il ripristino va fatto su un'installazione che ha la con quella chiave, quindi il ripristino va fatto su un'installazione che ha la
**stessa** `APP_KEY` di quando il backup è stato creato. **stessa** `APP_KEY` di quando il backup è stato creato.
Il tetto per l'upload è **256 MB** (`client_max_body_size` in Il tetto per l'upload lo decide **`MAX_UPLOAD_MB`** nel `.env` (default: 2048,
`docker/nginx/default.conf` e `upload_max_filesize`/`post_max_size` in cioè 2 GB). Da quel valore l'entrypoint genera `client_max_body_size` per nginx
`docker/php/php.ini`). Oltre quella soglia usa il backup dei volumi qui sotto. e `upload_max_filesize`/`post_max_size` per PHP, e la validazione lo rilegge:
un solo numero per tutta la catena.
> Se un caricamento si pianta a una percentuale bassa **senza messaggi**, è
> quasi sempre questo il limite. Superato il tetto nginx risponde `413` dopo
> aver accettato zero byte: il browser si vede chiudere la connessione a metà
> invio e la barra resta ferma. Dalla versione attuale la pagina controlla la
> dimensione *prima* di iniziare e lo dice subito, ma se aggiorni un'installazione
> vecchia il sintomo è quello.
Per alzarlo servono un `MAX_UPLOAD_MB` più grande **e** un redeploy (i limiti si
scrivono all'avvio del container). Alzarlo non costa memoria — nginx accumula il
corpo su disco e PHP lavora su un file temporaneo — ma serve spazio libero nel
volume. Oltre qualche giga conviene comunque il backup dei volumi qui sotto: un
upload lunghissimo da browser è fragile.
### Zona pericolosa: azzera tutto ### Zona pericolosa: azzera tutto

View File

@@ -21,6 +21,32 @@ use Illuminate\Support\Str;
*/ */
class BackupController extends Controller class BackupController extends Controller
{ {
/**
* Tetto per il pacchetto in ingresso, in kilobyte.
*
* Deriva da `app.max_upload_mb`, da cui l'entrypoint ricava anche i limiti
* di nginx e di PHP. Il valore qui sta *sotto* quelli di un margine: se a
* fermare la richiesta e' nginx, il browser riceve un 413 senza corpo — o
* niente affatto — invece di un messaggio; se la ferma la validazione, chi
* carica legge cosa e' successo. Il margine copre anche l'intestazione
* multipart, che gonfia il corpo oltre la dimensione del file.
*
* La vista lo legge da qui per rifiutare il file prima ancora di iniziare a
* caricarlo: una sola fonte per un solo numero.
*/
public static function maxUploadKb(): int
{
$mb = max(1, (int) config('app.max_upload_mb', 2048));
// 2% di margine, minimo 1 MB, per l'incapsulamento multipart.
return (int) (($mb - max(1, (int) ($mb * 0.02))) * 1000);
}
public static function maxUploadMb(): int
{
return (int) (self::maxUploadKb() / 1000);
}
public function __construct( public function __construct(
private readonly BackupOperationState $stato, private readonly BackupOperationState $stato,
) { ) {
@@ -76,16 +102,12 @@ class BackupController extends Controller
*/ */
public function upload(Request $request) public function upload(Request $request)
{ {
// 256 MB e' il tetto reale della pila, non una scelta di questa
// validazione: piu' in su intervengono prima `client_max_body_size` di
// nginx e `upload_max_filesize`/`post_max_size` di PHP, che rifiutano
// la richiesta con un 413 opaco invece di un messaggio leggibile.
$request->validate([ $request->validate([
'backupZip' => ['required', 'file', 'mimes:zip', 'max:256000'], 'backupZip' => ['required', 'file', 'mimes:zip', 'max:' . self::maxUploadKb()],
], [ ], [
'backupZip.required' => 'Seleziona il pacchetto di backup da ripristinare.', 'backupZip.required' => 'Seleziona il pacchetto di backup da ripristinare.',
'backupZip.mimes' => 'Il pacchetto di backup deve essere un file .zip.', 'backupZip.mimes' => 'Il pacchetto di backup deve essere un file .zip.',
'backupZip.max' => 'Il pacchetto supera i 256 MB accettati dal server.', 'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.',
]); ]);
// Stesso blocco del componente Livewire, ripetuto qui perche' questa e' // Stesso blocco del componente Livewire, ripetuto qui perche' questa e'

View File

@@ -21,4 +21,21 @@ return [
'driver' => 'file', 'driver' => 'file',
], ],
/*
* Dimensione massima, in MB, di un file caricato dall'interfaccia —
* in pratica il pacchetto di ripristino, che e' di gran lunga il piu'
* grande.
*
* E' l'unica fonte del numero: l'entrypoint ne ricava
* `client_max_body_size` per nginx e `upload_max_filesize`/`post_max_size`
* per PHP, e la validazione di BackupController lo rilegge da qui. Prima
* viveva in tre file separati, e bastava che uno restasse indietro perche'
* il caricamento morisse con un 413 muto invece di un messaggio.
*
* Alzarlo non costa memoria: nginx accumula il corpo su disco e PHP lavora
* su un file temporaneo. Il limite pratico e' la pazienza di chi carica e
* la tenuta della connessione.
*/
'max_upload_mb' => (int) env('MAX_UPLOAD_MB', 2048),
]; ];

View File

@@ -6,9 +6,15 @@ server {
charset utf-8; charset utf-8;
# Territory PDF/ZIP imports are the largest uploads; keep this in step with # NOTE: client_max_body_size is deliberately NOT set here.
# upload_max_filesize / post_max_size in docker/php/php.ini. #
client_max_body_size 256M; # entrypoint.sh writes it to /etc/nginx/conf.d/upload-limit.conf from
# MAX_UPLOAD_MB, together with PHP's upload_max_filesize/post_max_size, so
# that one variable governs the whole chain. A value in this server block
# would override the generated one and silently reinstate the mismatch this
# arrangement exists to prevent — the symptom being a 413 with no body,
# returned after zero bytes, which leaves the browser's upload bar frozen
# and the page with nothing to report.
# Behind Dokploy's Traefik, the real client IP arrives in X-Forwarded-For. # Behind Dokploy's Traefik, the real client IP arrives in X-Forwarded-For.
real_ip_header X-Forwarded-For; real_ip_header X-Forwarded-For;

View File

@@ -118,6 +118,28 @@ map \$host \$app_forwarded_proto {
EOF EOF
ok "Forwarded scheme set to ${FORWARDED_PROTO}." ok "Forwarded scheme set to ${FORWARDED_PROTO}."
# -----------------------------------------------------------------------------
# 2c. Upload ceiling, from a single variable.
# nginx, PHP and the Laravel validation rule must agree: when they don't,
# the smallest one wins and does so badly — nginx answers 413 with no body
# after accepting zero bytes, so the browser's upload bar simply freezes and
# the page has nothing to report. MAX_UPLOAD_MB drives all three
# (config/app.php reads the same variable for the validation rule).
# -----------------------------------------------------------------------------
MAX_UPLOAD_MB="${MAX_UPLOAD_MB:-2048}"
cat > /etc/nginx/conf.d/upload-limit.conf <<EOF
client_max_body_size ${MAX_UPLOAD_MB}m;
EOF
cat > /usr/local/etc/php/conf.d/zz-upload-limit.ini <<EOF
; Generated by entrypoint.sh from MAX_UPLOAD_MB — do not edit by hand.
; The zz- prefix makes it load after custom.ini, so these win.
upload_max_filesize = ${MAX_UPLOAD_MB}M
post_max_size = ${MAX_UPLOAD_MB}M
EOF
ok "Upload ceiling set to ${MAX_UPLOAD_MB} MB."
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# 3. Storage symlink (public/storage -> storage/app/public) # 3. Storage symlink (public/storage -> storage/app/public)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------

View File

@@ -1,6 +1,9 @@
[PHP] [PHP]
upload_max_filesize = 256M ; upload_max_filesize e post_max_size NON stanno qui: li genera entrypoint.sh
post_max_size = 256M ; in conf.d/zz-upload-limit.ini a partire da MAX_UPLOAD_MB, insieme al
; client_max_body_size di nginx, cosi' una sola variabile governa tutta la
; catena. Rimetterli qui non farebbe danno (zz- viene caricato dopo e vince),
; ma sarebbe una seconda fonte per lo stesso numero.
memory_limit = 256M memory_limit = 256M
max_execution_time = 120 max_execution_time = 120
max_input_vars = 3000 max_input_vars = 3000

View File

@@ -41,11 +41,42 @@
// Svuotato subito: se la password è sbagliata si ridigita, e nel // Svuotato subito: se la password è sbagliata si ridigita, e nel
// frattempo non resta nel campo di una scheda lasciata aperta. // frattempo non resta nel campo di una scheda lasciata aperta.
this.passwordAzzeramento = ''; this.passwordAzzeramento = '';
$wire.confermaAzzeramento(password); // this.$wire e non $wire: dentro un metodo di x-data la forma
// esplicita e' l'unica su cui si possa contare.
this.$wire.confermaAzzeramento(password);
},
/**
* Il file e' troppo grande? Si scopre qui, prima di caricare.
*
* Superato il tetto, e' nginx a chiudere la connessione a meta'
* trasferimento: la barra si pianta a una percentuale qualsiasi e non
* arriva nessun evento utile, quindi la pagina resta muta con l'aria di
* essersi bloccata. Controllare la dimensione prima di partire rende
* quel caso un messaggio immediato invece di un'attesa senza fine.
*/
zipTroppoGrande(file) {
const maxByte = {{ \App\Http\Controllers\Settings\BackupController::maxUploadKb() }} * 1000;
if (!file || file.size <= maxByte) {
return false;
}
const mb = (n) => (n / 1000000).toFixed(0);
this.erroreUpload = `Il pacchetto pesa ${mb(file.size)} MB e supera il limite di ${mb(maxByte)} MB accettato dal server. `
+ `Per archivi piu' grandi usa il ripristino dai volumi descritto nel README, oppure chiedi di alzare il limite.`;
return true;
}, },
inviaRipristino(event) { inviaRipristino(event) {
const form = event.target; const form = event.target;
const file = form.querySelector('input[name=backupZip]').files[0];
if (this.zipTroppoGrande(file)) {
return;
}
const formData = new FormData(form); const formData = new FormData(form);
this.erroreUpload = ''; this.erroreUpload = '';
@@ -57,6 +88,11 @@
xhr.setRequestHeader('Accept', 'application/json'); xhr.setRequestHeader('Accept', 'application/json');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
// Un caricamento che non si muove per cinque minuti e' fermo, non
// lento: senza questo non scatterebbe piu' nessun evento e la
// pagina resterebbe in attesa per sempre.
xhr.timeout = 300000;
xhr.upload.addEventListener('progress', (e) => { xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) { if (e.lengthComputable) {
this.uploadPercento = Math.round((e.loaded / e.total) * 100); this.uploadPercento = Math.round((e.loaded / e.total) * 100);
@@ -73,25 +109,64 @@
this.zipSelezionato = ''; this.zipSelezionato = '';
form.reset(); form.reset();
// Da qui in poi l'avanzamento arriva dal job, non dall'upload. // Da qui in poi l'avanzamento arriva dal job, non dall'upload.
$wire.seguiOperazione(payload.operation_id); this.$wire.seguiOperazione(payload.operation_id);
return; return;
} }
// 409: c'è già un'operazione in corso. Il pacchetto è stato // Qualsiasi esito diverso: la barra torna a zero, perche'
// caricato per niente, quindi la barra torna a zero: lasciarla // lasciarla dov'era farebbe credere che il ripristino sia
// al 100% farebbe credere che il ripristino sia partito. // partito. Il codice HTTP entra nel messaggio: 413 e 419 non
// producono un corpo JSON, quindi senza di esso resterebbe solo
// un generico "errore" che non dice cosa fare.
this.uploadPercento = 0; this.uploadPercento = 0;
this.erroreUpload = payload.message this.erroreUpload = this.messaggioErrore(xhr.status, payload);
|| (payload.errors && payload.errors.backupZip ? payload.errors.backupZip[0] : 'Errore durante il caricamento del pacchetto.');
}); });
xhr.addEventListener('error', () => { xhr.addEventListener('error', () => {
this.uploadInCorso = false; this.uploadInCorso = false;
this.erroreUpload = 'Errore di rete durante il caricamento del pacchetto.'; this.uploadPercento = 0;
this.erroreUpload = 'Errore di rete durante il caricamento: connessione interrotta dal server. '
+ 'Succede tipicamente quando il pacchetto supera il limite accettato.';
});
xhr.addEventListener('timeout', () => {
this.uploadInCorso = false;
this.uploadPercento = 0;
this.erroreUpload = 'Il caricamento non ha risposto entro 5 minuti ed e\\' stato interrotto.';
});
xhr.addEventListener('abort', () => {
this.uploadInCorso = false;
this.uploadPercento = 0;
this.erroreUpload = 'Caricamento annullato.';
}); });
xhr.send(formData); xhr.send(formData);
}, },
messaggioErrore(status, payload) {
if (payload.message) {
return payload.message;
}
if (payload.errors && payload.errors.backupZip) {
return payload.errors.backupZip[0];
}
if (status === 413) {
return 'Il pacchetto e\\' troppo grande per il server (413). Alza client_max_body_size in nginx e post_max_size in PHP, oppure usa il ripristino dai volumi.';
}
if (status === 419) {
return 'Sessione scaduta durante il caricamento. Ricarica la pagina e riprova.';
}
if (status === 0) {
return 'Il server ha chiuso la connessione durante il caricamento, senza rispondere. Di solito significa che il pacchetto supera il limite accettato.';
}
return `Errore durante il caricamento del pacchetto (HTTP ${status}).`;
},
}" }"
x-on:scarica-backup.window="window.location.href = $event.detail.url"> x-on:scarica-backup.window="window.location.href = $event.detail.url">
@@ -262,10 +337,22 @@
@submit.prevent="if (confirm('Confermi il ripristino? I dati gestionali e i file correnti verranno sostituiti, e tutti gli utenti dovranno impostare una nuova password.')) inviaRipristino($event)"> @submit.prevent="if (confirm('Confermi il ripristino? I dati gestionali e i file correnti verranno sostituiti, e tutti gli utenti dovranno impostare una nuova password.')) inviaRipristino($event)">
@csrf @csrf
<div> <div>
{{-- La dimensione si controlla gia' alla selezione: sapere
subito che il file e' fuori limite evita di aspettare un
caricamento destinato a essere tagliato. --}}
<input name="backupZip" type="file" accept=".zip,application/zip" <input name="backupZip" type="file" accept=".zip,application/zip"
x-on:change="zipSelezionato = $event.target.files[0] ? $event.target.files[0].name : ''; erroreUpload = ''" x-on:change="
erroreUpload = '';
uploadPercento = 0;
const f = $event.target.files[0];
zipSelezionato = f ? f.name + ' (' + (f.size / 1000000).toFixed(1) + ' MB)' : '';
if (f) zipTroppoGrande(f);
"
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-red-50 file:text-red-700 hover:file:bg-red-100"> class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-red-50 file:text-red-700 hover:file:bg-red-100">
<p x-show="erroreUpload" x-cloak class="text-red-500 text-xs mt-1" x-text="erroreUpload"></p> <p class="mt-1 text-xs text-gray-500">
Dimensione massima accettata: {{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB.
</p>
<p x-show="erroreUpload" x-cloak class="text-red-600 text-xs mt-1 font-medium" x-text="erroreUpload"></p>
</div> </div>
{{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}} {{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}}