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, ]); } }