From c6abef977dc83332f6b3a91d905b49bc369b6abc Mon Sep 17 00:00:00 2001 From: Francesco Picone Date: Wed, 29 Jul 2026 12:34:38 +0200 Subject: [PATCH] ++ add backup totale applicativo --- HANDOFF.md | 54 +++ README.md | 34 +- .../Auth/PasswordChangeController.php | 66 ++++ .../Controllers/Settings/BackupController.php | 137 ++++++++ app/Http/Middleware/RequirePasswordChange.php | 48 +++ app/Livewire/Settings/XmlExchange.php | 13 +- app/Models/User.php | 2 + app/Services/BackupArchive.php | 322 ++++++++++++++++++ bootstrap/app.php | 7 + ...assword_change_required_to_users_table.php | 31 ++ .../views/auth/password-change.blade.php | 77 +++++ .../views/components/layouts/app.blade.php | 4 +- .../livewire/settings/xml-exchange.blade.php | 69 +++- routes/web.php | 11 + 14 files changed, 864 insertions(+), 11 deletions(-) create mode 100644 app/Http/Controllers/Auth/PasswordChangeController.php create mode 100644 app/Http/Controllers/Settings/BackupController.php create mode 100644 app/Http/Middleware/RequirePasswordChange.php create mode 100644 app/Services/BackupArchive.php create mode 100644 database/migrations/2026_07_29_000001_add_password_change_required_to_users_table.php create mode 100644 resources/views/auth/password-change.blade.php diff --git a/HANDOFF.md b/HANDOFF.md index 93f4087..cb87498 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -248,6 +248,60 @@ Tre ritocchi minori ma con qualche implicazione: `permission:settings.manage` e il link in sidebar è sotto `@can('settings.manage')`. +### Backup completo `.zip`: dati + file, e cambio password forzato al restore + +L'export XML da solo non ricostruisce un'installazione: `pdf_path`, +`thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un +ripristino su volumi vuoti lascia ogni territorio che punta a un file +inesistente — link pubblici a 404 e miniature rotte. Il pacchetto `.zip` +(`Services/BackupArchive`, `Http/Controllers/Settings/BackupController`) mette +insieme `manifest.json`, `data.xml` (lo stesso XML dell'export) e `storage/…` +con **tutti** i file del disco `public`. L'export XML resta, etichettato come +parziale. + +Scelte non ovvie: + +- **Rotte HTTP, non Livewire.** Lo `streamDownload` di Livewire passa il file + base64 dentro la risposta JSON: un archivio con i PDF dei territori non ci + sta. `backup/download` e `backup/restore` sono due rotte normali nel gruppo + `settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge + dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero + per la memoria del processo. + +- **Ordine del restore: staging → database → file.** I file si estraggono + prima in `storage/app/backup-restore/`, così un archivio corrotto o + fuori limite fallisce mentre i dati correnti sono ancora intatti; poi + `importXmlFromContent()` in transazione; poi la sostituzione dei file. Il + disco `public` viene **svuotato**, non fuso: i percorsi vivono nelle righe + appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna + schermata mostra e nessuna cancellazione raggiunge. + +- **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su + numero di entry, dimensione della singola entry e totale decompresso, pochi + MB possono riempire il volume (zip bomb). I nomi delle entry sono + normalizzati e ogni `..` o percorso assoluto viene rifiutato (zip slip) — + senza, un archivio ostile scriverebbe fuori dalla cartella di destinazione. + +- **Dopo un restore tutti cambiano password.** Il pacchetto contiene gli hash + bcrypt di ogni utente: chi lo ha avuto in mano ha potuto tentarne il crack + offline, quindi da lì in poi nessuna vecchia password è più un segreto. Il + restore accende `password_change_required` su **tutte** le righe di `users` + (amministratore che sta ripristinando compreso) e azzera i `remember_token`, + che sono credenziali persistenti e stanno pure loro nel backup. Il + middleware `RequirePasswordChange` (in coda al gruppo `web`, quindi copre + anche `/livewire/update` — Livewire segue le risposte redirezionate) dirotta + su `password.change` tutto tranne quella pagina, il suo invio e il logout. + +- **La schermata di cambio password non è Livewire e non chiede la password + attuale.** Non è Livewire perché deve restare raggiungibile mentre il + middleware respinge anche le POST di Livewire, e distinguere quale + componente sta dietro una `/livewire/update` è fragile; due rotte HTTP si + escludono per nome. Non chiede la password attuale perché il restore ha + appena sovrascritto gli hash con quelli del backup: la "password attuale" di + un account è quella dell'epoca del backup, che l'utente — l'amministratore + per primo — può benissimo non ricordare. La garanzia è la sessione: a quella + pagina ci si arriva già autenticati. + ### Export/Import XML: backup completo e round-trip `Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali, diff --git a/README.md b/README.md index c8ab211..1abc1e8 100644 --- a/README.md +++ b/README.md @@ -397,7 +397,31 @@ Un aggiornamento ordinario non richiede alcun intervento manuale. ## Backup e ripristino -### Cosa salvare +### Dall'applicazione: pacchetto `.zip` (consigliato) + +*Backup e import* → **Scarica backup completo**. Produce un unico `.zip` con +tutti i dati gestionali, gli utenti e tutti i file caricati (PDF dei territori, +miniature, logo). È la via da usare per spostare o rimettere in piedi +un'installazione senza toccare i container. + +Sulla stessa pagina, **Ripristina backup** ricarica il pacchetto: sostituisce i +dati gestionali correnti *e* i file caricati. + +> **Dopo un ripristino tutti gli utenti — amministratore compreso — devono +> impostare una nuova password.** Il pacchetto contiene gli hash delle password +> di ogni account: chi lo ha avuto in mano ha potuto tentarne il crack offline, +> quindi le vecchie credenziali non sono più considerate sicure. Al primo +> accesso l'applicazione resta bloccata sulla schermata di cambio password. + +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. + +### Dai volumi: cosa salvare | Volume | Contenuto | Nel backup | |----------------|------------------------------------------------------|:----------:| @@ -568,9 +592,13 @@ docker compose --profile dev up -d --build - Filtri per utente e tipo evento, con diff prima/dopo - Pulizia automatica schedulata secondo la retention configurata -### XML Exchange +### Backup e import +- **Backup completo `.zip`**: dati gestionali, utenti e file caricati (PDF + territori, miniature, logo) in un unico pacchetto scaricabile e ripristinabile +- Ripristino da pacchetto, con cambio password obbligatorio per tutti gli utenti +- Import ed export XML dell'intero set di dati gestionali (senza i file) - Conversione di dump SQL legacy in XML compatibile -- Import ed export XML dell'intero set di dati gestionali +- Import massivo dei PDF dei territori da cartella o archivio ZIP ### Zone e tipologie - Gestione inline: aggiungi, rinomina, attiva/disattiva, elimina diff --git a/app/Http/Controllers/Auth/PasswordChangeController.php b/app/Http/Controllers/Auth/PasswordChangeController.php new file mode 100644 index 0000000..b18618a --- /dev/null +++ b/app/Http/Controllers/Auth/PasswordChangeController.php @@ -0,0 +1,66 @@ + auth()->user(), + // Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro; + // chi e' stato dirottato dal middleware no. + 'forced' => (bool) auth()->user()->password_change_required, + ]); + } + + public function store(Request $request) + { + // La password attuale NON viene richiesta, ed e' una scelta. + // + // Il ripristino sovrascrive gli hash con quelli del backup: subito dopo, + // la "password attuale" di un account e' quella che aveva all'epoca del + // backup, che l'utente puo' benissimo non ricordare — l'amministratore + // che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori + // proprio le persone che questa schermata deve sbloccare. La garanzia + // qui e' la sessione: si arriva a questa pagina gia' autenticati. + $validated = $request->validate([ + 'password' => ['required', 'confirmed', Password::min(8)], + ], [ + 'password.required' => 'Inserisci la nuova password.', + 'password.confirmed' => 'Le due password non coincidono.', + ]); + + $user = $request->user(); + + // Il cast `hashed` sul modello cifra il valore al salvataggio. + $user->update([ + 'password' => $validated['password'], + 'password_change_required' => false, + ]); + + // La sessione corrente sopravvive al cambio, le altre no: dopo un + // ripristino le vecchie credenziali sono da considerarsi compromesse e + // una sessione aperta altrove lo e' altrettanto. + $request->session()->regenerate(); + $user->setRememberToken(null); + $user->save(); + + activity()->causedBy($user)->log('password changed'); + + return redirect()->route('dashboard') + ->with('success', 'Password aggiornata. Da ora accedi con quella nuova.'); + } +} diff --git a/app/Http/Controllers/Settings/BackupController.php b/app/Http/Controllers/Settings/BackupController.php new file mode 100644 index 0000000..a574ebc --- /dev/null +++ b/app/Http/Controllers/Settings/BackupController.php @@ -0,0 +1,137 @@ +archive->create($exchange->currentXmlBackup()); + + $name = 'termanager2-backup-' . now()->format('Ymd-His') . '.zip'; + + return response() + ->download($path, $name, ['Content-Type' => 'application/zip']) + ->deleteFileAfterSend(true); + } + + /** + * Ripristina un pacchetto di backup. + * + * Ordine deliberato: i file vengono prima estratti in staging (cosi' un + * archivio corrotto fallisce mentre i dati correnti sono ancora intatti), + * poi si riscrive il database in transazione, poi si sostituiscono i file. + * L'inverso lascerebbe il disco pieno di file di un'installazione e il + * database di un'altra. + */ + public function restore(Request $request, XmlExchange $exchange) + { + set_time_limit(self::SECONDI_MAX); + + // 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' => '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.', + ]); + + $staging = null; + + try { + $extracted = $this->archive->extract($request->file('backupZip')->getRealPath()); + $staging = $extracted['staging']; + + $result = $exchange->importXmlFromContent($extracted['xml']); + + if (isset($result['error'])) { + return back()->withErrors(['backupZip' => $result['error']]); + } + + $fileCount = $this->archive->restoreFiles($staging); + + $utentiBloccati = $this->requirePasswordChangeForEveryone(); + } catch (\Throwable $e) { + Log::error('Ripristino del backup non riuscito.', ['exception' => $e]); + + return back()->withErrors(['backupZip' => $e->getMessage()]); + } finally { + if ($staging !== null) { + $this->archive->deleteDirectory($staging); + } + } + + $stats = $result['stats']; + + $message = sprintf( + 'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. ' + . 'Per sicurezza tutti i %d account devono impostare una nuova password.', + $stats['territori_importati'] ?? 0, + $stats['proclamatori_importati'] ?? 0, + $stats['assegnazioni_importate'] ?? 0, + $fileCount, + $utentiBloccati + ); + + return redirect()->route('password.change') + ->with('success', $message) + ->with('importStats', $stats) + ->with('importIssues', $result['issues']); + } + + /** + * Blocca ogni account finche' non sceglie una password nuova, + * l'amministratore che sta ripristinando compreso. + * + * Il pacchetto contiene gli hash bcrypt di tutti gli utenti: chi ha avuto + * il file fra le mani ha potuto tentarne il crack con calma, quindi dopo un + * ripristino nessuna delle vecchie password si puo' considerare segreta. + * Anche i remember_token vengono azzerati: sono credenziali persistenti a + * tutti gli effetti e stanno pure loro nel backup. + * + * Scritto con il query builder di proposito — un update di massa su + * Eloquent qui non aggiungerebbe nulla e toccherebbe updated_at su tutte le + * righe appena importate con i loro timestamp originali. + */ + private function requirePasswordChangeForEveryone(): int + { + return DB::table('users')->update([ + 'password_change_required' => true, + 'remember_token' => null, + ]); + } +} diff --git a/app/Http/Middleware/RequirePasswordChange.php b/app/Http/Middleware/RequirePasswordChange.php new file mode 100644 index 0000000..5a6ebac --- /dev/null +++ b/app/Http/Middleware/RequirePasswordChange.php @@ -0,0 +1,48 @@ +user(); + + if (! $user || ! $user->password_change_required) { + return $next($request); + } + + if ($request->routeIs(self::ROTTE_AMMESSE)) { + return $next($request); + } + + // Vale anche per le POST di /livewire/update: il client segue la + // risposta redirezionata e porta la scheda sulla pagina di cambio + // password (vedi `response.redirected` in livewire.esm.js). + return redirect()->route('password.change'); + } +} diff --git a/app/Livewire/Settings/XmlExchange.php b/app/Livewire/Settings/XmlExchange.php index 08281ad..cff4d6f 100644 --- a/app/Livewire/Settings/XmlExchange.php +++ b/app/Livewire/Settings/XmlExchange.php @@ -135,14 +135,23 @@ class XmlExchange extends Component public function exportCurrentAsXml() { - $dataset = $this->currentDataset(); - $xml = $this->datasetToXml($dataset, 'current-app-export'); + $xml = $this->currentXmlBackup(); return response()->streamDownload(function () use ($xml) { echo $xml; }, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']); } + /** + * L'export XML completo dei dati correnti. E' lo stesso contenuto che + * finisce in data.xml dentro il pacchetto di backup .zip, dove viaggia + * insieme ai file caricati. + */ + public function currentXmlBackup(): string + { + return $this->datasetToXml($this->currentDataset(), 'current-app-export'); + } + public function render() { if (session()->has('importStats')) { diff --git a/app/Models/User.php b/app/Models/User.php index 92607c1..856a712 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,6 +17,7 @@ class User extends Authenticatable 'name', 'email', 'password', + 'password_change_required', ]; protected $hidden = [ @@ -29,6 +30,7 @@ class User extends Authenticatable return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'password_change_required' => 'boolean', ]; } diff --git a/app/Services/BackupArchive.php b/app/Services/BackupArchive.php new file mode 100644 index 0000000..d5db98d --- /dev/null +++ b/app/Services/BackupArchive.php @@ -0,0 +1,322 @@ + ogni file del disco `public`, con lo stesso percorso + * relativo che sta in pdf_path/thumbnail_path/logo_path + */ +class BackupArchive +{ + /** + * Versione del formato. L'import rifiuta i pacchetti piu' recenti di + * quello che sa leggere invece di ripristinarli a meta'. + */ + public const FORMAT_VERSION = 1; + + private const ENTRY_MANIFEST = 'manifest.json'; + private const ENTRY_DATA = 'data.xml'; + private const PREFIX_STORAGE = 'storage/'; + + /** + * Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza + * questi controlli un file da pochi MB puo' espandersi fino a riempire il + * volume o esaurire la memoria del processo (zip bomb). + */ + private const MAX_ENTRIES = 20000; + private const MAX_ENTRY_BYTES = 100 * 1024 * 1024; + private const MAX_TOTAL_BYTES = 3 * 1024 * 1024 * 1024; + + /** + * Costruisce il pacchetto e ne restituisce il percorso assoluto. Il file e' + * temporaneo: chi chiama lo consegna al browser e lo cancella. + * + * @param string $xml Contenuto di data.xml (l'export XML gia' pronto). + */ + public function create(string $xml): string + { + $disk = Storage::disk('public'); + $files = $disk->allFiles(); + + $path = storage_path('app/backup-' . Str::uuid() . '.zip'); + + $zip = new ZipArchive(); + if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { + throw new RuntimeException('Impossibile creare il file di backup.'); + } + + try { + $zip->addFromString(self::ENTRY_DATA, $xml); + $zip->addFromString(self::ENTRY_MANIFEST, json_encode([ + 'format_version' => self::FORMAT_VERSION, + 'application' => 'TerManager2', + 'generated_at' => now()->toIso8601String(), + 'app_url' => (string) config('app.url'), + 'files_count' => count($files), + ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + // addFile() legge dal disco in fase di close(): i PDF non passano + // mai interi per la memoria del processo, per quanto sia grande + // l'archivio. + foreach ($files as $relativePath) { + $zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath); + } + } catch (\Throwable $e) { + $zip->close(); + @unlink($path); + + throw $e; + } + + if (! $zip->close()) { + @unlink($path); + + throw new RuntimeException('Scrittura del file di backup non riuscita.'); + } + + return $path; + } + + /** + * Apre il pacchetto, ne verifica la struttura ed estrae i file in una + * cartella di staging, restituendo l'XML e il percorso dello staging. + * + * I file vengono messi da parte *prima* di toccare il database: se + * l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i + * dati correnti sono ancora intatti. + * + * @return array{xml: string, staging: string, files: int} + */ + public function extract(string $zipPath): array + { + $zip = new ZipArchive(); + if ($zip->open($zipPath) !== true) { + throw new RuntimeException('Il file non e\' un archivio ZIP leggibile.'); + } + + $staging = storage_path('app/backup-restore/' . Str::uuid()); + + try { + $manifestRaw = $zip->getFromName(self::ENTRY_MANIFEST); + if ($manifestRaw === false) { + throw new RuntimeException( + 'Archivio non riconosciuto: manca ' . self::ENTRY_MANIFEST . '. ' + . 'Carica un pacchetto generato da "Scarica backup completo".' + ); + } + + $manifest = json_decode($manifestRaw, true); + $formatVersion = is_array($manifest) ? (int) ($manifest['format_version'] ?? 0) : 0; + + if ($formatVersion < 1) { + throw new RuntimeException('Il manifest del backup non e\' valido.'); + } + + if ($formatVersion > self::FORMAT_VERSION) { + throw new RuntimeException( + 'Il backup e\' stato creato con una versione piu\' recente dell\'applicazione ' + . '(formato ' . $formatVersion . ', qui si legge fino al ' . self::FORMAT_VERSION . ').' + ); + } + + $xml = $zip->getFromName(self::ENTRY_DATA); + if ($xml === false || trim($xml) === '') { + throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.'); + } + + if ($zip->numFiles > self::MAX_ENTRIES) { + throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.'); + } + + $totalBytes = 0; + $extracted = 0; + + for ($index = 0; $index < $zip->numFiles; $index++) { + $entryName = $zip->getNameIndex($index); + + if ($entryName === false || str_ends_with($entryName, '/')) { + continue; + } + + if (! str_starts_with($entryName, self::PREFIX_STORAGE)) { + continue; + } + + $relativePath = substr($entryName, strlen(self::PREFIX_STORAGE)); + $safePath = $this->safeRelativePath($relativePath); + + if ($safePath === null) { + throw new RuntimeException('Percorso di file non ammesso nell\'archivio: ' . $entryName); + } + + // La dimensione dichiarata si legge *prima* di estrarre: e' + // l'unico momento in cui si puo' rifiutare un'entry che, una + // volta decompressa, riempirebbe il disco. + $size = (int) ($zip->statIndex($index)['size'] ?? 0); + + if ($size > self::MAX_ENTRY_BYTES) { + throw new RuntimeException(sprintf( + 'Il file "%s" nell\'archivio supera %d MB.', + $safePath, + self::MAX_ENTRY_BYTES / 1048576 + )); + } + + $totalBytes += $size; + if ($totalBytes > self::MAX_TOTAL_BYTES) { + throw new RuntimeException('L\'archivio decompresso supera il limite consentito.'); + } + + $destination = $staging . '/' . $safePath; + $directory = dirname($destination); + + if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) { + throw new RuntimeException('Impossibile preparare la cartella di ripristino.'); + } + + $stream = $zip->getStream($entryName); + if ($stream === false) { + throw new RuntimeException('Impossibile leggere "' . $safePath . '" dall\'archivio.'); + } + + $target = fopen($destination, 'wb'); + if ($target === false) { + fclose($stream); + + throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.'); + } + + stream_copy_to_stream($stream, $target); + fclose($stream); + fclose($target); + + $extracted++; + } + } catch (\Throwable $e) { + $zip->close(); + $this->deleteDirectory($staging); + + throw $e; + } + + $zip->close(); + + return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted]; + } + + /** + * Sostituisce il contenuto del disco `public` con quello estratto. + * + * La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle + * righe del database, che il ripristino ha appena riscritto per intero. + * Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati + * da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge. + */ + public function restoreFiles(string $staging): int + { + $disk = Storage::disk('public'); + + foreach ($disk->directories() as $directory) { + $disk->deleteDirectory($directory); + } + $disk->delete($disk->files()); + + if (! is_dir($staging)) { + return 0; + } + + $restored = 0; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS) + ); + + foreach ($iterator as $file) { + /** @var \SplFileInfo $file */ + if (! $file->isFile()) { + continue; + } + + $relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1)); + + $handle = fopen($file->getPathname(), 'rb'); + if ($handle === false) { + continue; + } + + $disk->writeStream($relativePath, $handle); + + if (is_resource($handle)) { + fclose($handle); + } + + $restored++; + } + + return $restored; + } + + public function deleteDirectory(string $path): void + { + if (! is_dir($path)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $item) { + /** @var \SplFileInfo $item */ + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + + @rmdir($path); + } + + /** + * Normalizza il percorso di un'entry e rifiuta tutto cio' che potrebbe + * scrivere fuori dalla cartella di destinazione (zip slip): percorsi + * assoluti, lettere di unita' Windows e qualunque segmento "..". + */ + private function safeRelativePath(string $relativePath): ?string + { + $normalized = str_replace('\\', '/', trim($relativePath)); + + if ($normalized === '' || str_starts_with($normalized, '/') || preg_match('/^[a-zA-Z]:/', $normalized)) { + return null; + } + + $segments = []; + foreach (explode('/', $normalized) as $segment) { + if ($segment === '' || $segment === '.') { + continue; + } + + if ($segment === '..') { + return null; + } + + $segments[] = $segment; + } + + return $segments === [] ? null : implode('/', $segments); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 79082d4..edd11e2 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -36,6 +36,13 @@ return Application::configure(basePath: dirname(__DIR__)) return array_values(array_filter([$host, 'localhost', '127.0.0.1'])); }, subdomains: false); + // Dopo il ripristino di un backup ogni account resta bloccato finche' + // non imposta una password nuova. In coda al gruppo web, quindi la + // sessione e' gia' avviata e l'utente risolto quando gira. + $middleware->web(append: [ + \App\Http\Middleware\RequirePasswordChange::class, + ]); + $middleware->alias([ 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, 'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class, diff --git a/database/migrations/2026_07_29_000001_add_password_change_required_to_users_table.php b/database/migrations/2026_07_29_000001_add_password_change_required_to_users_table.php new file mode 100644 index 0000000..448ee84 --- /dev/null +++ b/database/migrations/2026_07_29_000001_add_password_change_required_to_users_table.php @@ -0,0 +1,31 @@ +boolean('password_change_required')->default(false)->after('password'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('password_change_required'); + }); + } +}; diff --git a/resources/views/auth/password-change.blade.php b/resources/views/auth/password-change.blade.php new file mode 100644 index 0000000..d34c857 --- /dev/null +++ b/resources/views/auth/password-change.blade.php @@ -0,0 +1,77 @@ + +
+
+

+ {{ $forced ? 'Imposta una nuova password' : 'Cambia password' }} +

+

+ Account: {{ $user->email }} +

+
+ + @if(session('success')) +
+ {{ session('success') }} +
+ @endif + + @if($forced) +
+ Ripristino da backup eseguito. + Il pacchetto di backup contiene le password di tutti gli account, quindi + le vecchie credenziali non sono piu’ considerate sicure. Scegli una + nuova password per continuare: fino ad allora il resto dell’applicazione + resta bloccato. +
+ @endif + + @if($errors->any()) +
+
    + @foreach($errors->all() as $error) +
  • {{ $error }}
  • + @endforeach +
+
+ @endif + +
+ @csrf + + {{-- L'email in un campo nascosto: senza, i gestori di password non + sanno a quale account associare la credenziale che salvano. --}} + + +
+ + +

Almeno 8 caratteri.

+
+ +
+ + +
+ + +
+ +
+ @if($forced) + Non sei tu? Esci e accedi con un altro account. + @else + Torna alla home + @endif +
+ @csrf + +
+
+
+
diff --git a/resources/views/components/layouts/app.blade.php b/resources/views/components/layouts/app.blade.php index 57ca7ac..b3434a6 100644 --- a/resources/views/components/layouts/app.blade.php +++ b/resources/views/components/layouts/app.blade.php @@ -144,9 +144,9 @@ Tipologie + class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('xml.*', 'backup.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}"> - Import + Backup e import @endcan diff --git a/resources/views/livewire/settings/xml-exchange.blade.php b/resources/views/livewire/settings/xml-exchange.blade.php index 5015136..64cbb06 100644 --- a/resources/views/livewire/settings/xml-exchange.blade.php +++ b/resources/views/livewire/settings/xml-exchange.blade.php @@ -1,7 +1,64 @@
-

Import

-

Centro importazioni: PDF territori, conversione legacy SQL, import XML ed export XML.

+

Backup e import

+

Backup completo dell’applicazione, import PDF territori, conversione legacy SQL, import ed export XML.

+
+ + {{-- Backup completo: e' la sezione che serve nel 99% dei casi, quindi sta + in cima. Le sezioni XML piu' sotto restano per gli usi parziali e per + la conversione dei dump legacy. --}} +
+
+

Backup completo (.zip)

+

+ Un unico pacchetto con tutti i dati dell’applicazione — utenti compresi — + e tutti i file caricati: PDF dei territori, miniature e logo. + E’ l’archivio da conservare per rimettere in piedi l’installazione da zero. +

+
+ +
+ Dati sensibili. Il pacchetto include gli utenti con le password (hash), i nomi dei + proclamatori in chiaro e le mappe dei territori. Conservalo in un luogo sicuro e non condividerlo. +
+ +
+ + + Scarica backup completo + +

La preparazione dell’archivio puo’ richiedere qualche minuto se ci sono molti PDF.

+
+ +
+

Ripristina da backup

+

+ Carica un pacchetto .zip generato qui sopra. Il ripristino sostituisce i dati + gestionali correnti e tutti i file caricati. +

+ +
+ Al termine tutti dovranno cambiare password, amministratore compreso: il pacchetto + contiene le password di ogni account, quindi dopo un ripristino le vecchie non sono piu’ da + considerarsi segrete. Verrai portato subito sulla schermata di cambio password. +
+ +
+ @csrf +
+ + @error('backupZip')

{{ $message }}

@enderror +
+ + +
+
-

Export XML

-

Esporta tutti i dati correnti dell'app in XML (backup completo, reimportabile).

+

Export XML (solo dati)

+

+ Esporta tutti i dati correnti dell'app in XML, reimportabile qui sopra. + Non include i file caricati: ripristinando solo questo, i territori restano + senza PDF né miniature. Per un backup davvero completo usa il pacchetto .zip in cima alla pagina. +

Dati sensibili. Il file include gli utenti con le password (hash) e i nomi dei proclamatori in chiaro. diff --git a/routes/web.php b/routes/web.php index 45a7215..0efd2fb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,11 +1,13 @@ group(function () { // Profilo personale (ogni utente loggato) Route::get('profilo', Profile::class)->name('profile'); + // Cambio password: raggiungibile anche quando RequirePasswordChange + // blocca tutto il resto (vedi l'elenco di rotte ammesse nel middleware). + Route::get('cambia-password', [PasswordChangeController::class, 'show'])->name('password.change'); + Route::post('cambia-password', [PasswordChangeController::class, 'store'])->name('password.change.store'); + // Territori — visualizzazione (lista + dettaglio) per tutti gli utenti // operativi, cioè chi può assegnare i territori. Route::middleware('permission:territori.assign')->group(function () { @@ -155,6 +162,10 @@ Route::middleware('auth')->group(function () { Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql'); Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml'); Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip'); + + // Backup completo (.zip): dati, utenti e file caricati. + Route::get('backup/download', [BackupController::class, 'download'])->name('backup.download'); + Route::post('backup/restore', [BackupController::class, 'restore'])->name('backup.restore'); }); // Privacy / Informativa GDPR — visibile solo all'amministratore.