++ add backup totale applicativo

This commit is contained in:
2026-07-29 12:34:38 +02:00
parent 2fa76d5073
commit c6abef977d
14 changed files with 864 additions and 11 deletions

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
/**
* Cambio password obbligatorio, imposto dopo il ripristino di un backup.
*
* Volutamente NON e' un componente Livewire: la pagina deve restare
* raggiungibile mentre RequirePasswordChange respinge tutto il resto, incluse
* le richieste /livewire/update. Due rotte HTTP normali sono banali da
* escludere; distinguere quale componente sta dietro una POST di Livewire no.
*/
class PasswordChangeController extends Controller
{
public function show()
{
return view('auth.password-change', [
'user' => 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.');
}
}

View File

@@ -0,0 +1,137 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Livewire\Settings\XmlExchange;
use App\Services\BackupArchive;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* Backup e ripristino dell'intera applicazione in un unico pacchetto .zip:
* dati gestionali, utenti e file caricati.
*
* Non e' un componente Livewire come il resto della pagina Import: lo scarico
* di un Livewire streamDownload passa il file base64 dentro la risposta JSON,
* e un archivio con tutti i PDF dei territori non ci sta. Sono due rotte HTTP
* normali, gia' protette da `settings.manage` nel gruppo di rotte.
*/
class BackupController extends Controller
{
public function __construct(
private readonly BackupArchive $archive,
) {
}
/**
* Tetto di tempo per comprimere/estrarre un archivio con molti PDF.
* Sopra c'e' comunque `fastcgi_read_timeout 300` in docker/nginx/
* default.conf: alzare questo valore da solo non serve a nulla.
*/
private const SECONDI_MAX = 300;
public function download(XmlExchange $exchange)
{
set_time_limit(self::SECONDI_MAX);
$path = $this->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,
]);
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Chi ha `password_change_required` non puo' usare l'applicazione finche' non
* imposta una password nuova.
*
* Il flag lo accende il ripristino di un backup su TUTTI gli account: il
* pacchetto contiene gli hash delle password, quindi da quel momento nessuna
* delle vecchie e' piu' un segreto.
*/
class RequirePasswordChange
{
/**
* Le sole rotte raggiungibili con il flag acceso: la schermata di cambio
* password, il suo invio e l'uscita. Tutto il resto — comprese le POST di
* Livewire, che altrimenti lascerebbero operare da una scheda gia' aperta —
* viene dirottato.
*/
private const ROTTE_AMMESSE = [
'password.change',
'password.change.store',
'logout',
];
public function handle(Request $request, Closure $next): Response
{
$user = $request->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');
}
}

View File

@@ -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')) {

View File

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

View File

@@ -0,0 +1,322 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use ZipArchive;
/**
* Pacchetto di backup completo: i dati gestionali (XML) e i file caricati
* (PDF dei territori, miniature, logo) in un unico .zip.
*
* L'XML da solo non basta a ricostruire l'installazione: `pdf_path` e
* `thumbnail_path` sono percorsi sul disco `public`, quindi un ripristino su
* volumi vuoti lascerebbe ogni territorio con un riferimento a un file che non
* c'e' — link pubblici a 404 e miniature rotte.
*
* Struttura dell'archivio:
*
* manifest.json formato, data, conteggi (letto per validare il file)
* data.xml lo stesso XML dell'export "Esporta XML"
* storage/<percorso> 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);
}
}