diff --git a/.env.example b/.env.example index f87731c..1f92141 100644 --- a/.env.example +++ b/.env.example @@ -81,6 +81,22 @@ CACHE_STORE=redis SESSION_DRIVER=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). # # Con true, il cookie di sessione viene marcato "secure" e il browser non lo diff --git a/HANDOFF.md b/HANDOFF.md index e232d06..e3a0f7c 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -353,6 +353,31 @@ Scelte non ovvie: già vuota. Con il `pull` l'amministratore non vedeva **mai** il riepilogo del 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 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 diff --git a/README.md b/README.md index c2f0bbc..acd9fc1 100644 --- a/README.md +++ b/README.md @@ -330,6 +330,7 @@ ridichiara le variabili applicative: le prende dal `.env` così com'è. | `APP_TIMEZONE` | `Europe/Rome` | Fuso orario | | `LOG_LEVEL` | `warning` | `debug` per diagnosticare | | `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_PORT` | `8080` | Porta dell'applicazione sull'host | | `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 **stessa** `APP_KEY` di quando il backup è stato creato. -Il tetto per l'upload è **256 MB** (`client_max_body_size` in -`docker/nginx/default.conf` e `upload_max_filesize`/`post_max_size` in -`docker/php/php.ini`). Oltre quella soglia usa il backup dei volumi qui sotto. +Il tetto per l'upload lo decide **`MAX_UPLOAD_MB`** nel `.env` (default: 2048, +cioè 2 GB). Da quel valore l'entrypoint genera `client_max_body_size` per nginx +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 diff --git a/app/Http/Controllers/Settings/BackupController.php b/app/Http/Controllers/Settings/BackupController.php index d13721f..c1495b6 100644 --- a/app/Http/Controllers/Settings/BackupController.php +++ b/app/Http/Controllers/Settings/BackupController.php @@ -21,6 +21,32 @@ use Illuminate\Support\Str; */ 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( private readonly BackupOperationState $stato, ) { @@ -76,16 +102,12 @@ class BackupController extends Controller */ 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([ - '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.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' diff --git a/config/app.php b/config/app.php index a988513..f69e8e7 100644 --- a/config/app.php +++ b/config/app.php @@ -21,4 +21,21 @@ return [ '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), + ]; diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf index 46a6a35..19b7b4b 100644 --- a/docker/nginx/default.conf +++ b/docker/nginx/default.conf @@ -6,9 +6,15 @@ server { charset utf-8; - # Territory PDF/ZIP imports are the largest uploads; keep this in step with - # upload_max_filesize / post_max_size in docker/php/php.ini. - client_max_body_size 256M; + # NOTE: client_max_body_size is deliberately NOT set here. + # + # 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. real_ip_header X-Forwarded-For; diff --git a/docker/php/entrypoint.sh b/docker/php/entrypoint.sh index cfd2e53..819f3d1 100755 --- a/docker/php/entrypoint.sh +++ b/docker/php/entrypoint.sh @@ -118,6 +118,28 @@ map \$host \$app_forwarded_proto { EOF 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 < /usr/local/etc/php/conf.d/zz-upload-limit.ini < storage/app/public) # ----------------------------------------------------------------------------- diff --git a/docker/php/php.ini b/docker/php/php.ini index b7f7253..2c80de8 100644 --- a/docker/php/php.ini +++ b/docker/php/php.ini @@ -1,6 +1,9 @@ [PHP] -upload_max_filesize = 256M -post_max_size = 256M +; upload_max_filesize e post_max_size NON stanno qui: li genera entrypoint.sh +; 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 max_execution_time = 120 max_input_vars = 3000 diff --git a/resources/views/livewire/settings/backup-manager.blade.php b/resources/views/livewire/settings/backup-manager.blade.php index 7580bc4..a14cac6 100644 --- a/resources/views/livewire/settings/backup-manager.blade.php +++ b/resources/views/livewire/settings/backup-manager.blade.php @@ -41,11 +41,42 @@ // Svuotato subito: se la password è sbagliata si ridigita, e nel // frattempo non resta nel campo di una scheda lasciata aperta. 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) { const form = event.target; + const file = form.querySelector('input[name=backupZip]').files[0]; + + if (this.zipTroppoGrande(file)) { + return; + } + const formData = new FormData(form); this.erroreUpload = ''; @@ -57,6 +88,11 @@ xhr.setRequestHeader('Accept', 'application/json'); 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) => { if (e.lengthComputable) { this.uploadPercento = Math.round((e.loaded / e.total) * 100); @@ -73,25 +109,64 @@ this.zipSelezionato = ''; form.reset(); // Da qui in poi l'avanzamento arriva dal job, non dall'upload. - $wire.seguiOperazione(payload.operation_id); + this.$wire.seguiOperazione(payload.operation_id); return; } - // 409: c'è già un'operazione in corso. Il pacchetto è stato - // caricato per niente, quindi la barra torna a zero: lasciarla - // al 100% farebbe credere che il ripristino sia partito. + // Qualsiasi esito diverso: la barra torna a zero, perche' + // lasciarla dov'era farebbe credere che il ripristino sia + // 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.erroreUpload = payload.message - || (payload.errors && payload.errors.backupZip ? payload.errors.backupZip[0] : 'Errore durante il caricamento del pacchetto.'); + this.erroreUpload = this.messaggioErrore(xhr.status, payload); }); xhr.addEventListener('error', () => { 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); }, + + 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"> @@ -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)"> @csrf
+ {{-- La dimensione si controlla gia' alla selezione: sapere + subito che il file e' fuori limite evita di aspettare un + caricamento destinato a essere tagliato. --}} -

+

+ Dimensione massima accettata: {{ \App\Http\Controllers\Settings\BackupController::maxUploadMb() }} MB. +

+

{{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}}