++ add reset program
This commit is contained in:
58
HANDOFF.md
58
HANDOFF.md
@@ -248,7 +248,7 @@ Tre ritocchi minori ma con qualche implicazione:
|
|||||||
`permission:settings.manage` e il link in sidebar è sotto
|
`permission:settings.manage` e il link in sidebar è sotto
|
||||||
`@can('settings.manage')`.
|
`@can('settings.manage')`.
|
||||||
|
|
||||||
### Backup completo `.zip`: dati + file, e cambio password forzato al restore
|
### Backup completo `.zip`, ripristino, azzeramento
|
||||||
|
|
||||||
L'export XML da solo non ricostruisce un'installazione: `pdf_path`,
|
L'export XML da solo non ricostruisce un'installazione: `pdf_path`,
|
||||||
`thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un
|
`thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un
|
||||||
@@ -263,11 +263,65 @@ Scelte non ovvie:
|
|||||||
|
|
||||||
- **Rotte HTTP, non Livewire.** Lo `streamDownload` di Livewire passa il file
|
- **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
|
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
|
sta. `backup/file/{op}` e `backup/restore` sono due rotte normali nel gruppo
|
||||||
`settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge
|
`settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge
|
||||||
dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero
|
dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero
|
||||||
per la memoria del processo.
|
per la memoria del processo.
|
||||||
|
|
||||||
|
- **Tutto in coda, con avanzamento reale.** Backup, ripristino e azzeramento
|
||||||
|
sono job (`Jobs/CreateBackupArchive`, `RestoreBackupArchive`,
|
||||||
|
`ResetApplication`) che scrivono su `Services/BackupOperationState`, e
|
||||||
|
`Livewire/Settings/BackupManager` lo interroga con `wire:poll`. In coda
|
||||||
|
perché un'installazione con qualche centinaio di PDF supera i 300 secondi di
|
||||||
|
`fastcgi_read_timeout` e il browser vedrebbe cadere la connessione a metà
|
||||||
|
compressione, senza modo di distinguerlo da un errore.
|
||||||
|
|
||||||
|
La barra conta unità di lavoro completate, non tempo. Per la compressione
|
||||||
|
questo costa qualcosa: `ZipArchive` non scrive niente finché non si chiama
|
||||||
|
`close()`, quindi contare le `addFile()` porterebbe al 100% in un istante per
|
||||||
|
poi restare fermo per tutta la scrittura. `BackupArchive::create()` chiude e
|
||||||
|
riapre l'archivio a lotti (`FLUSH_MASSIMI = 20`): si paga una ricopiatura
|
||||||
|
delle entry già presenti — byte a byte, senza ricomprimerle — e in cambio
|
||||||
|
ogni avanzamento corrisponde a byte davvero finiti sul disco. Il lotto è
|
||||||
|
`totale/20`, così la ricopiatura resta limitata a ~10 volte la dimensione
|
||||||
|
dell'archivio quanti che siano i file. Estrazione e riscrittura dei file sono
|
||||||
|
cicli nostri, lì l'avanzamento è per-file senza artifici.
|
||||||
|
|
||||||
|
- **L'azzeramento ha due tempi, separati dal download.** Il pulsante nella zona
|
||||||
|
pericolosa (`avviaAzzeramento`) crea *solo* il backup e si ferma in
|
||||||
|
`attesa-download`; la cancellazione parte da un secondo clic esplicito
|
||||||
|
(`confermaAzzeramento`), che rifiuta se lo stato non registra un
|
||||||
|
`servito_at`. Il primo tentativo faceva partire il reset dalla coda dello
|
||||||
|
`streamDownload`, "dopo l'ultimo byte" — **non funziona**: con
|
||||||
|
`fastcgi_buffering on` (il default, e `docker/nginx/default.conf` non lo
|
||||||
|
disattiva) nginx accetta l'intera risposta dal processo PHP, che quindi
|
||||||
|
prosegue anche se il browser ha chiuso la connessione al primo byte. Un
|
||||||
|
download annullato azzerava lo stesso. Il server può verificare di aver
|
||||||
|
*spedito* l'archivio, non che sia arrivato: quella verifica è dell'operatore,
|
||||||
|
e per questo la conferma è umana.
|
||||||
|
|
||||||
|
- **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
|
||||||
|
errore sui file lascerebbe database azzerato e disco pieno di PDF orfani.
|
||||||
|
Collegato: `svuotaDiscoPubblico()` ricontrolla il disco dopo la cancellazione
|
||||||
|
e solleva se qualcosa sopravvive, invece di restituire il conteggio iniziale.
|
||||||
|
Flysystem non solleva se una `unlink` fallisce, e basta un file scritto da un
|
||||||
|
processo con un altro utente (l'entrypoint lavora da root, php-fpm e il
|
||||||
|
worker da www-data) perché l'operazione si dichiari riuscita lasciando il
|
||||||
|
disco popolato.
|
||||||
|
|
||||||
|
- **L'azzeramento tiene in vita l'amministratore che l'ha chiesto.** Un'installazione
|
||||||
|
appena creata ha un amministratore; cancellarli tutti lascerebbe
|
||||||
|
l'applicazione senza nessuno che possa accedere fino al riavvio del container
|
||||||
|
(l'admin iniziale lo ricrea `entrypoint.sh` da `INITIAL_ADMIN_*`, che in
|
||||||
|
esecuzione possono non esserci). Gli altri utenti si eliminano uno alla volta
|
||||||
|
via Eloquent, non con un delete di massa: è il trait `HasRoles` di Spatie a
|
||||||
|
staccare ruoli e permessi nell'evento `deleting`, che un delete di massa non
|
||||||
|
fa scattare. Il registro attività si svuota **per ultimo** — quelle
|
||||||
|
cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che
|
||||||
|
sopravvive, quella dell'azzeramento stesso.
|
||||||
|
|
||||||
- **Ordine del restore: staging → database → file.** I file si estraggono
|
- **Ordine del restore: staging → database → file.** I file si estraggono
|
||||||
prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o
|
prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o
|
||||||
fuori limite fallisce mentre i dati correnti sono ancora intatti; poi
|
fuori limite fallisce mentre i dati correnti sono ancora intatti; poi
|
||||||
|
|||||||
32
README.md
32
README.md
@@ -399,11 +399,16 @@ Un aggiornamento ordinario non richiede alcun intervento manuale.
|
|||||||
|
|
||||||
### Dall'applicazione: pacchetto `.zip` (consigliato)
|
### Dall'applicazione: pacchetto `.zip` (consigliato)
|
||||||
|
|
||||||
*Backup e import* → **Scarica backup completo**. Produce un unico `.zip` con
|
*Backup e import* → **Crea e scarica backup**. Produce un unico `.zip` con
|
||||||
tutti i dati gestionali, gli utenti e tutti i file caricati (PDF dei territori,
|
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
|
miniature, logo). È la via da usare per spostare o rimettere in piedi
|
||||||
un'installazione senza toccare i container.
|
un'installazione senza toccare i container.
|
||||||
|
|
||||||
|
L'archivio si prepara in background e la pagina mostra l'avanzamento reale
|
||||||
|
(file compressi / file totali); il download parte da solo appena è pronto.
|
||||||
|
Stessa cosa per il ripristino: prima la barra di caricamento del pacchetto,
|
||||||
|
poi quella delle fasi di lavoro (estrazione, database, file).
|
||||||
|
|
||||||
Sulla stessa pagina, **Ripristina backup** ricarica il pacchetto: sostituisce i
|
Sulla stessa pagina, **Ripristina backup** ricarica il pacchetto: sostituisce i
|
||||||
dati gestionali correnti *e* i file caricati.
|
dati gestionali correnti *e* i file caricati.
|
||||||
|
|
||||||
@@ -421,6 +426,28 @@ 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/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.
|
`docker/php/php.ini`). Oltre quella soglia usa il backup dei volumi qui sotto.
|
||||||
|
|
||||||
|
### Zona pericolosa: azzera tutto
|
||||||
|
|
||||||
|
In fondo alla stessa pagina, **Scarica backup e azzera tutto** riporta
|
||||||
|
l'applicazione allo stato di installazione appena eseguita. Va scritto
|
||||||
|
`AZZERA TUTTO` per sbloccare il pulsante, e l'operazione procede in due tempi:
|
||||||
|
|
||||||
|
1. Viene creato e scaricato un backup completo di tutto ciò che c'è.
|
||||||
|
2. L'operazione **si ferma e aspetta**: controlli il file scaricato e confermi
|
||||||
|
con un secondo clic. Fino a lì non viene toccato nulla, e puoi annullare.
|
||||||
|
3. Solo dopo la conferma vengono eliminati territori, proclamatori,
|
||||||
|
assegnazioni, campagne, zone, tipologie, anni di servizio, il registro
|
||||||
|
attività e tutti i file caricati; le impostazioni tornano ai default e i
|
||||||
|
ruoli vengono riseminati.
|
||||||
|
|
||||||
|
> **Tutti gli altri utenti vengono eliminati.** Resta solo l'account
|
||||||
|
> dell'amministratore che ha eseguito l'operazione, con la sua password
|
||||||
|
> attuale — come un'installazione nuova, che ha un amministratore solo.
|
||||||
|
> Cancellarli tutti lascerebbe l'applicazione senza nessuno che possa
|
||||||
|
> accedere fino al riavvio del container.
|
||||||
|
|
||||||
|
Il backup scaricato al punto 1 è l'unico modo per tornare indietro.
|
||||||
|
|
||||||
### Dai volumi: cosa salvare
|
### Dai volumi: cosa salvare
|
||||||
|
|
||||||
| Volume | Contenuto | Nel backup |
|
| Volume | Contenuto | Nel backup |
|
||||||
@@ -595,7 +622,10 @@ docker compose --profile dev up -d --build
|
|||||||
### Backup e import
|
### Backup e import
|
||||||
- **Backup completo `.zip`**: dati gestionali, utenti e file caricati (PDF
|
- **Backup completo `.zip`**: dati gestionali, utenti e file caricati (PDF
|
||||||
territori, miniature, logo) in un unico pacchetto scaricabile e ripristinabile
|
territori, miniature, logo) in un unico pacchetto scaricabile e ripristinabile
|
||||||
|
- Barra di avanzamento reale su backup e ripristino (lavoro svolto, non tempo)
|
||||||
- Ripristino da pacchetto, con cambio password obbligatorio per tutti gli utenti
|
- Ripristino da pacchetto, con cambio password obbligatorio per tutti gli utenti
|
||||||
|
- **Zona pericolosa**: azzeramento completo con backup obbligatorio scaricato
|
||||||
|
e confermato prima di procedere
|
||||||
- Import ed export XML dell'intero set di dati gestionali (senza i file)
|
- Import ed export XML dell'intero set di dati gestionali (senza i file)
|
||||||
- Conversione di dump SQL legacy in XML compatibile
|
- Conversione di dump SQL legacy in XML compatibile
|
||||||
- Import massivo dei PDF dei territori da cartella o archivio ZIP
|
- Import massivo dei PDF dei territori da cartella o archivio ZIP
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
namespace App\Http\Controllers\Auth;
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Jobs\RestoreBackupArchive;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,11 +20,17 @@ class PasswordChangeController extends Controller
|
|||||||
{
|
{
|
||||||
public function show()
|
public function show()
|
||||||
{
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
return view('auth.password-change', [
|
return view('auth.password-change', [
|
||||||
'user' => auth()->user(),
|
'user' => $user,
|
||||||
// Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro;
|
// Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro;
|
||||||
// chi e' stato dirottato dal middleware no.
|
// chi e' stato dirottato dal middleware no.
|
||||||
'forced' => (bool) auth()->user()->password_change_required,
|
'forced' => (bool) $user->password_change_required,
|
||||||
|
// Il ripristino gira in coda e non ha una sessione in cui lasciare
|
||||||
|
// un flash; deposita l'esito in cache a nome di chi l'ha avviato e
|
||||||
|
// lo si consuma qui, che e' la prima pagina che quella persona vede.
|
||||||
|
'esitoRipristino' => Cache::pull(RestoreBackupArchive::CHIAVE_ESITO . $user->id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,61 +3,79 @@
|
|||||||
namespace App\Http\Controllers\Settings;
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Livewire\Settings\XmlExchange;
|
use App\Jobs\CreateBackupArchive;
|
||||||
use App\Services\BackupArchive;
|
use App\Jobs\RestoreBackupArchive;
|
||||||
|
use App\Services\BackupOperationState;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Backup e ripristino dell'intera applicazione in un unico pacchetto .zip:
|
* Trasferimento dei file di backup: consegna dell'archivio prodotto e
|
||||||
* dati gestionali, utenti e file caricati.
|
* ricezione di quello da ripristinare.
|
||||||
*
|
*
|
||||||
* Non e' un componente Livewire come il resto della pagina Import: lo scarico
|
* Non e' un componente Livewire come il resto della pagina: lo `streamDownload`
|
||||||
* di un Livewire streamDownload passa il file base64 dentro la risposta JSON,
|
* di Livewire passa il file base64 dentro la risposta JSON, e un archivio con
|
||||||
* e un archivio con tutti i PDF dei territori non ci sta. Sono due rotte HTTP
|
* tutti i PDF dei territori non ci sta. L'avvio delle operazioni e la barra di
|
||||||
* normali, gia' protette da `settings.manage` nel gruppo di rotte.
|
* avanzamento vivono invece in Livewire\Settings\BackupManager, che interroga
|
||||||
|
* BackupOperationState. Entrambe le rotte sono nel gruppo `settings.manage`.
|
||||||
*/
|
*/
|
||||||
class BackupController extends Controller
|
class BackupController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly BackupArchive $archive,
|
private readonly BackupOperationState $stato,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tetto di tempo per comprimere/estrarre un archivio con molti PDF.
|
* Consegna l'archivio prodotto da un'operazione di backup e annota che e'
|
||||||
* Sopra c'e' comunque `fastcgi_read_timeout 300` in docker/nginx/
|
* stato servito.
|
||||||
* default.conf: alzare questo valore da solo non serve a nulla.
|
*
|
||||||
|
* L'annotazione e' meta' del blocco che protegge l'azzeramento: senza un
|
||||||
|
* `servito_at` il pulsante di conferma rifiuta di procedere, quindi non
|
||||||
|
* esiste una strada che cancelli i dati senza che l'archivio sia almeno
|
||||||
|
* uscito dal server. L'altra meta' e' la conferma umana in
|
||||||
|
* BackupManager::confermaAzzeramento().
|
||||||
|
*
|
||||||
|
* Il completamento del trasferimento *non* e' verificabile da qui: con
|
||||||
|
* `fastcgi_buffering on` (il default, e docker/nginx/default.conf non lo
|
||||||
|
* disattiva) nginx accetta l'intera risposta dal processo PHP e la inoltra
|
||||||
|
* per conto suo. PHP finisce di scrivere e prosegue anche se il browser ha
|
||||||
|
* chiuso la connessione al primo byte, quindi far scattare l'azzeramento
|
||||||
|
* "dopo l'ultimo byte" azzererebbe anche su un download annullato.
|
||||||
*/
|
*/
|
||||||
private const SECONDI_MAX = 300;
|
public function file(string $operationId)
|
||||||
|
|
||||||
public function download(XmlExchange $exchange)
|
|
||||||
{
|
{
|
||||||
set_time_limit(self::SECONDI_MAX);
|
$operazione = $this->stato->get($operationId);
|
||||||
|
|
||||||
$path = $this->archive->create($exchange->currentXmlBackup());
|
abort_if($operazione === null, 404, 'Backup non piu\' disponibile: rigeneralo.');
|
||||||
|
|
||||||
$name = 'termanager2-backup-' . now()->format('Ymd-His') . '.zip';
|
$percorso = CreateBackupArchive::percorso($operationId);
|
||||||
|
abort_unless(is_file($percorso), 404, 'Archivio di backup non trovato: rigeneralo.');
|
||||||
|
|
||||||
|
$nome = $operazione['result']['nome_download'] ?? 'termanager2-backup.zip';
|
||||||
|
|
||||||
|
$this->stato->update($operationId, function (array $state) {
|
||||||
|
$state['result']['servito_at'] = now()->toDateTimeString();
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
|
||||||
return response()
|
return response()
|
||||||
->download($path, $name, ['Content-Type' => 'application/zip'])
|
->download($percorso, $nome, [
|
||||||
->deleteFileAfterSend(true);
|
'Content-Type' => 'application/zip',
|
||||||
|
'Content-Length' => (string) filesize($percorso),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ripristina un pacchetto di backup.
|
* Riceve il pacchetto da ripristinare e mette in coda il lavoro.
|
||||||
*
|
*
|
||||||
* Ordine deliberato: i file vengono prima estratti in staging (cosi' un
|
* Risponde JSON perche' il form viene inviato via XMLHttpRequest: e' l'unico
|
||||||
* archivio corrotto fallisce mentre i dati correnti sono ancora intatti),
|
* modo di avere una barra di caricamento reale sull'upload, che per un
|
||||||
* poi si riscrive il database in transazione, poi si sostituiscono i file.
|
* archivio di centinaia di MB e' la parte piu' lunga dell'operazione.
|
||||||
* L'inverso lascerebbe il disco pieno di file di un'installazione e il
|
|
||||||
* database di un'altra.
|
|
||||||
*/
|
*/
|
||||||
public function restore(Request $request, XmlExchange $exchange)
|
public function upload(Request $request)
|
||||||
{
|
{
|
||||||
set_time_limit(self::SECONDI_MAX);
|
|
||||||
|
|
||||||
// 256 MB e' il tetto reale della pila, non una scelta di questa
|
// 256 MB e' il tetto reale della pila, non una scelta di questa
|
||||||
// validazione: piu' in su intervengono prima `client_max_body_size` di
|
// validazione: piu' in su intervengono prima `client_max_body_size` di
|
||||||
// nginx e `upload_max_filesize`/`post_max_size` di PHP, che rifiutano
|
// nginx e `upload_max_filesize`/`post_max_size` di PHP, che rifiutano
|
||||||
@@ -70,68 +88,29 @@ class BackupController extends Controller
|
|||||||
'backupZip.max' => 'Il pacchetto supera i 256 MB accettati dal server.',
|
'backupZip.max' => 'Il pacchetto supera i 256 MB accettati dal server.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$staging = null;
|
$operationId = (string) Str::uuid();
|
||||||
|
|
||||||
try {
|
// Il file dell'upload vive in una cartella temporanea che sparisce a
|
||||||
$extracted = $this->archive->extract($request->file('backupZip')->getRealPath());
|
// fine richiesta: il job lo troverebbe gia' cancellato.
|
||||||
$staging = $extracted['staging'];
|
$percorso = $request->file('backupZip')->storeAs(
|
||||||
|
'backup-restore-in',
|
||||||
$result = $exchange->importXmlFromContent($extracted['xml']);
|
$operationId . '.zip',
|
||||||
|
'local'
|
||||||
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')
|
$this->stato->initialize(
|
||||||
->with('success', $message)
|
$operationId,
|
||||||
->with('importStats', $stats)
|
BackupOperationState::TIPO_RESTORE,
|
||||||
->with('importIssues', $result['issues']);
|
'In coda'
|
||||||
}
|
);
|
||||||
|
$this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.');
|
||||||
|
|
||||||
/**
|
RestoreBackupArchive::dispatch(
|
||||||
* Blocca ogni account finche' non sceglie una password nuova,
|
$operationId,
|
||||||
* l'amministratore che sta ripristinando compreso.
|
storage_path('app/' . $percorso),
|
||||||
*
|
(int) auth()->id()
|
||||||
* 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.
|
return response()->json(['operation_id' => $operationId]);
|
||||||
* 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,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
117
app/Jobs/CreateBackupArchive.php
Normal file
117
app/Jobs/CreateBackupArchive.php
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Livewire\Settings\XmlExchange;
|
||||||
|
use App\Services\BackupArchive;
|
||||||
|
use App\Services\BackupOperationState;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Costruisce il pacchetto di backup in coda.
|
||||||
|
*
|
||||||
|
* In coda e non nella richiesta perche' un'installazione con qualche centinaio
|
||||||
|
* di PDF supera comodamente i 300 secondi di `fastcgi_read_timeout`: il browser
|
||||||
|
* si vedrebbe chiudere la connessione a meta' compressione, senza modo di
|
||||||
|
* distinguerlo da un errore.
|
||||||
|
*/
|
||||||
|
class CreateBackupArchive implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
/** Sotto il --timeout=600 del worker: se sfora, e' il worker a ucciderlo. */
|
||||||
|
public int $timeout = 570;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param bool $perAzzeramento Quando true il job non chiude l'operazione
|
||||||
|
* ma la lascia in attesa del download: e'
|
||||||
|
* l'azzeramento che aspetta di avere
|
||||||
|
* l'archivio al sicuro prima di partire.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $operationId,
|
||||||
|
public bool $perAzzeramento = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
||||||
|
{
|
||||||
|
$stato->markRunning($this->operationId);
|
||||||
|
$stato->fase($this->operationId, 'Lettura dei dati gestionali');
|
||||||
|
|
||||||
|
$destinazione = self::percorso($this->operationId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->rimuoviArchiviVecchi($archivio);
|
||||||
|
|
||||||
|
$xml = $exchange->currentXmlBackup();
|
||||||
|
|
||||||
|
$stato->fase($this->operationId, 'Compressione dei file');
|
||||||
|
|
||||||
|
$archivio->create(
|
||||||
|
$xml,
|
||||||
|
$destinazione,
|
||||||
|
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Creazione del backup non riuscita.', ['exception' => $e]);
|
||||||
|
$stato->markFailed($this->operationId, $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$risultato = [
|
||||||
|
'file' => basename($destinazione),
|
||||||
|
'nome_download' => 'termanager2-backup-' . now()->format('Ymd-His') . '.zip',
|
||||||
|
'dimensione' => filesize($destinazione) ?: 0,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->perAzzeramento) {
|
||||||
|
$stato->log($this->operationId, 'Archivio pronto. In attesa che il download sia completato.');
|
||||||
|
$stato->markAttesaDownload($this->operationId, $risultato);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stato->log($this->operationId, 'Backup pronto per il download.');
|
||||||
|
$stato->markCompleted($this->operationId, $risultato);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function percorso(string $operationId): string
|
||||||
|
{
|
||||||
|
return storage_path('app/backups/' . $operationId . '.zip');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gli archivi restano su disco finche' il browser non li scarica, quindi un
|
||||||
|
* download mai completato lascerebbe un file per sempre. Si ripulisce
|
||||||
|
* all'inizio di ogni nuovo backup invece di aggiungere un task pianificato:
|
||||||
|
* il momento in cui serve spazio e' esattamente questo.
|
||||||
|
*/
|
||||||
|
private function rimuoviArchiviVecchi(BackupArchive $archivio): void
|
||||||
|
{
|
||||||
|
$cartella = storage_path('app/backups');
|
||||||
|
|
||||||
|
if (! is_dir($cartella)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limite = now()->subDay()->getTimestamp();
|
||||||
|
|
||||||
|
foreach (glob($cartella . '/*.zip') ?: [] as $file) {
|
||||||
|
if (filemtime($file) < $limite) {
|
||||||
|
@unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
175
app/Jobs/ResetApplication.php
Normal file
175
app/Jobs/ResetApplication.php
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Models\AnnoTeocratico;
|
||||||
|
use App\Models\Assegnazione;
|
||||||
|
use App\Models\Campagna;
|
||||||
|
use App\Models\Proclamatore;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use App\Models\Territorio;
|
||||||
|
use App\Models\Tipologia;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Zona;
|
||||||
|
use App\Services\BackupArchive;
|
||||||
|
use App\Services\BackupOperationState;
|
||||||
|
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
|
||||||
|
* nessun file caricato, impostazioni ai valori di default, ruoli e permessi
|
||||||
|
* riseminati.
|
||||||
|
*
|
||||||
|
* Non e' reversibile e non fa backup da solo: parte solo dopo che l'archivio
|
||||||
|
* generato da CreateBackupArchive risulta scaricato per intero (vedi
|
||||||
|
* BackupController::file), cosi' non esiste una via che azzeri senza che una
|
||||||
|
* copia sia gia' al sicuro sul computer di chi ha premuto il pulsante.
|
||||||
|
*/
|
||||||
|
class ResetApplication implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 570;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $actorId L'amministratore che ha chiesto l'azzeramento.
|
||||||
|
* E' l'unico account che sopravvive: un'installazione
|
||||||
|
* appena creata ha un amministratore, e cancellarli
|
||||||
|
* tutti lascerebbe l'applicazione senza nessuno che
|
||||||
|
* possa accedere fino al riavvio del container.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $operationId,
|
||||||
|
public int $actorId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(BackupOperationState $stato, BackupArchive $archivio): void
|
||||||
|
{
|
||||||
|
$stato->markRunning($this->operationId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// I file per primi, prima di toccare il database: svuotare il disco
|
||||||
|
// e' l'unico passo che puo' fallire per cause esterne (permessi sul
|
||||||
|
// volume), e fallire qui non ha ancora cancellato niente. Nell'ordine
|
||||||
|
// inverso un errore sui file lascerebbe un'applicazione con il
|
||||||
|
// database azzerato e il disco pieno di PDF che non appartengono
|
||||||
|
// piu' a nessun territorio.
|
||||||
|
$stato->fase($this->operationId, 'Cancellazione dei file caricati', 1);
|
||||||
|
$fileRimossi = $archivio->svuotaDiscoPubblico();
|
||||||
|
$stato->avanza($this->operationId, 1);
|
||||||
|
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
||||||
|
|
||||||
|
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 8);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($stato) {
|
||||||
|
$passi = [
|
||||||
|
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
|
||||||
|
'Campagne' => fn () => Campagna::query()->delete(),
|
||||||
|
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
||||||
|
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
||||||
|
'Tipologie' => fn () => Tipologia::query()->delete(),
|
||||||
|
'Zone' => fn () => Zona::query()->delete(),
|
||||||
|
'Anni teocratici' => fn () => AnnoTeocratico::query()->delete(),
|
||||||
|
'Impostazioni' => fn () => Setting::query()->delete(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$fatti = 0;
|
||||||
|
foreach ($passi as $etichetta => $azione) {
|
||||||
|
$azione();
|
||||||
|
$stato->avanza($this->operationId, ++$fatti);
|
||||||
|
$stato->log($this->operationId, $etichetta . ': azzerate.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Il mass delete non emette eventi, quindi l'istanza memoizzata di
|
||||||
|
// Setting punta ancora alla riga cancellata: senza questo, il
|
||||||
|
// firstOrCreate piu' sotto tornerebbe il fantasma invece di
|
||||||
|
// ricreare la riga.
|
||||||
|
Setting::flushCache();
|
||||||
|
|
||||||
|
$stato->fase($this->operationId, 'Rimozione degli altri utenti');
|
||||||
|
$utentiRimossi = $this->rimuoviAltriUtenti($stato);
|
||||||
|
|
||||||
|
// Il registro si svuota *dopo* le cancellazioni, non prima: gli
|
||||||
|
// altri utenti sono modelli con LogsActivity, quindi eliminarli
|
||||||
|
// scrive righe nuove che una pulizia anticipata non intercetta e
|
||||||
|
// che resterebbero li' come unica traccia — un residuo, non una
|
||||||
|
// scelta. Svuotato per ultimo, l'unica riga che sopravvive e'
|
||||||
|
// quella scritta apposta qui sotto.
|
||||||
|
$stato->fase($this->operationId, 'Pulizia del registro attivita\'', 1);
|
||||||
|
DB::table('activity_log')->delete();
|
||||||
|
activity()
|
||||||
|
->causedBy(User::find($this->actorId))
|
||||||
|
->log('Applicazione azzerata e riportata allo stato iniziale');
|
||||||
|
$stato->avanza($this->operationId, 1);
|
||||||
|
|
||||||
|
$stato->fase($this->operationId, 'Ripristino di ruoli e impostazioni', 3);
|
||||||
|
|
||||||
|
// Ruoli e permessi non vengono cancellati, ma il seeder e' additivo
|
||||||
|
// e idempotente: rieseguirlo garantisce il set di partenza anche se
|
||||||
|
// qualcuno ne aveva rimosso qualcuno a mano.
|
||||||
|
(new RolesAndPermissionsSeeder())->run();
|
||||||
|
$stato->avanza($this->operationId, 1);
|
||||||
|
|
||||||
|
Setting::instance();
|
||||||
|
$stato->avanza($this->operationId, 2);
|
||||||
|
|
||||||
|
$attore = User::find($this->actorId);
|
||||||
|
if ($attore && ! $attore->hasRole('amministratore')) {
|
||||||
|
$attore->assignRole('amministratore');
|
||||||
|
}
|
||||||
|
$stato->avanza($this->operationId, 3);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Azzeramento dell\'applicazione non riuscito.', ['exception' => $e]);
|
||||||
|
$stato->markFailed($this->operationId, $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$messaggio = sprintf(
|
||||||
|
'Applicazione azzerata: dati gestionali rimossi, %d file cancellati, %d utenti eliminati. '
|
||||||
|
. 'Resta il tuo account di amministratore.',
|
||||||
|
$fileRimossi,
|
||||||
|
$utentiRimossi
|
||||||
|
);
|
||||||
|
|
||||||
|
$stato->log($this->operationId, $messaggio);
|
||||||
|
$stato->markCompleted($this->operationId, ['messaggio' => $messaggio]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancella ogni utente tranne chi ha chiesto l'azzeramento.
|
||||||
|
*
|
||||||
|
* Uno alla volta e via Eloquent, non con un delete di massa: il trait
|
||||||
|
* HasRoles di Spatie stacca ruoli e permessi nell'evento `deleting`, che un
|
||||||
|
* delete di massa non fa scattare — resterebbero righe orfane in
|
||||||
|
* model_has_roles pronte a essere riassegnate al primo utente che riceve un
|
||||||
|
* id gia' usato.
|
||||||
|
*/
|
||||||
|
private function rimuoviAltriUtenti(BackupOperationState $stato): int
|
||||||
|
{
|
||||||
|
$altri = User::query()->where('id', '!=', $this->actorId)->get();
|
||||||
|
$stato->avanza($this->operationId, 0, $altri->count());
|
||||||
|
|
||||||
|
$rimossi = 0;
|
||||||
|
foreach ($altri as $utente) {
|
||||||
|
$utente->delete();
|
||||||
|
$stato->avanza($this->operationId, ++$rimossi);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rimossi;
|
||||||
|
}
|
||||||
|
}
|
||||||
156
app/Jobs/RestoreBackupArchive.php
Normal file
156
app/Jobs/RestoreBackupArchive.php
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Livewire\Settings\XmlExchange;
|
||||||
|
use App\Services\BackupArchive;
|
||||||
|
use App\Services\BackupOperationState;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ripristina un pacchetto di backup in coda, riportando l'avanzamento fase per
|
||||||
|
* fase (estrazione, database, file).
|
||||||
|
*/
|
||||||
|
class RestoreBackupArchive implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable;
|
||||||
|
use InteractsWithQueue;
|
||||||
|
use Queueable;
|
||||||
|
use SerializesModels;
|
||||||
|
|
||||||
|
public int $tries = 1;
|
||||||
|
|
||||||
|
public int $timeout = 570;
|
||||||
|
|
||||||
|
/** Chiave con cui la schermata di cambio password ritrova l'esito. */
|
||||||
|
public const CHIAVE_ESITO = 'restore-esito:';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public string $operationId,
|
||||||
|
public string $zipPath,
|
||||||
|
public int $actorId,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
||||||
|
{
|
||||||
|
$stato->markRunning($this->operationId);
|
||||||
|
|
||||||
|
$staging = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stato->fase($this->operationId, 'Estrazione dell\'archivio');
|
||||||
|
|
||||||
|
$estratto = $archivio->extract(
|
||||||
|
$this->zipPath,
|
||||||
|
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||||
|
);
|
||||||
|
$staging = $estratto['staging'];
|
||||||
|
|
||||||
|
$stato->fase($this->operationId, 'Riscrittura del database', 1);
|
||||||
|
|
||||||
|
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni
|
||||||
|
// il cui autore non e' nel file. Qui non c'e' sessione, quindi
|
||||||
|
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul
|
||||||
|
// primo id disponibile invece che su chi ha avviato il ripristino.
|
||||||
|
Auth::onceUsingId($this->actorId);
|
||||||
|
|
||||||
|
$result = $exchange->importXmlFromContent($estratto['xml']);
|
||||||
|
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
$stato->markFailed($this->operationId, $result['error']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stato->avanza($this->operationId, 1, 1);
|
||||||
|
|
||||||
|
$stato->fase($this->operationId, 'Ripristino dei file');
|
||||||
|
|
||||||
|
$fileRipristinati = $archivio->restoreFiles(
|
||||||
|
$staging,
|
||||||
|
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||||
|
);
|
||||||
|
|
||||||
|
$stato->fase($this->operationId, 'Blocco degli account', 1);
|
||||||
|
$utenti = $this->richiediCambioPasswordATutti();
|
||||||
|
$stato->avanza($this->operationId, 1, 1);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
|
||||||
|
$stato->markFailed($this->operationId, $e->getMessage());
|
||||||
|
|
||||||
|
return;
|
||||||
|
} finally {
|
||||||
|
Auth::forgetUser();
|
||||||
|
|
||||||
|
if ($staging !== null) {
|
||||||
|
$archivio->deleteDirectory($staging);
|
||||||
|
}
|
||||||
|
|
||||||
|
@unlink($this->zipPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = $result['stats'];
|
||||||
|
|
||||||
|
$messaggio = sprintf(
|
||||||
|
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. '
|
||||||
|
. 'Per sicurezza %s impostare una nuova password.',
|
||||||
|
$stats['territori_importati'] ?? 0,
|
||||||
|
$stats['proclamatori_importati'] ?? 0,
|
||||||
|
$stats['assegnazioni_importate'] ?? 0,
|
||||||
|
$fileRipristinati,
|
||||||
|
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la
|
||||||
|
// pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
|
||||||
|
// il middleware dirotta l'amministratore sul cambio password. Lo si
|
||||||
|
// lascia in cache a suo nome, e quella schermata lo consuma.
|
||||||
|
Cache::put(self::CHIAVE_ESITO . $this->actorId, $messaggio, 3600);
|
||||||
|
|
||||||
|
$stato->log($this->operationId, $messaggio);
|
||||||
|
$stato->markCompleted($this->operationId, [
|
||||||
|
'stats' => $stats,
|
||||||
|
'issues' => array_slice($result['issues'], 0, 50),
|
||||||
|
'file_ripristinati' => $fileRipristinati,
|
||||||
|
'messaggio' => $messaggio,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* @return int Account bloccati. Non e' il valore di ritorno dell'update:
|
||||||
|
* MariaDB conta le righe *cambiate*, quindi un utente gia'
|
||||||
|
* marcato da un ripristino precedente non comparirebbe e il
|
||||||
|
* riepilogo direbbe meno account di quanti ne sono bloccati.
|
||||||
|
*/
|
||||||
|
private function richiediCambioPasswordATutti(): int
|
||||||
|
{
|
||||||
|
DB::table('users')->update([
|
||||||
|
'password_change_required' => true,
|
||||||
|
'remember_token' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) DB::table('users')->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
249
app/Livewire/Settings/BackupManager.php
Normal file
249
app/Livewire/Settings/BackupManager.php
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire\Settings;
|
||||||
|
|
||||||
|
use App\Jobs\CreateBackupArchive;
|
||||||
|
use App\Jobs\ResetApplication;
|
||||||
|
use App\Services\BackupOperationState;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avvio e monitoraggio delle operazioni lunghe: backup, ripristino,
|
||||||
|
* azzeramento.
|
||||||
|
*
|
||||||
|
* Il lavoro vero sta nei job; qui si avvia e si interroga lo stato. La barra
|
||||||
|
* mostra `processed/total` dell'operazione in corso, cioe' unita' di lavoro
|
||||||
|
* davvero completate — file finiti nell'archivio, file estratti, tabelle
|
||||||
|
* svuotate — non un'animazione a tempo.
|
||||||
|
*/
|
||||||
|
class BackupManager extends Component
|
||||||
|
{
|
||||||
|
/** Operazione seguita in questo momento; null quando non c'e' nulla in corso. */
|
||||||
|
public ?string $operationId = null;
|
||||||
|
|
||||||
|
public array $operazione = [];
|
||||||
|
|
||||||
|
/** Frase da digitare per sbloccare l'azzeramento. */
|
||||||
|
public const CONFERMA_AZZERAMENTO = 'AZZERA TUTTO';
|
||||||
|
|
||||||
|
public string $confermaReset = '';
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->authorize('settings.manage');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function avviaBackup(): void
|
||||||
|
{
|
||||||
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
|
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
|
||||||
|
|
||||||
|
CreateBackupArchive::dispatch($this->operationId, false);
|
||||||
|
|
||||||
|
$this->aggiorna();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Primo tempo dell'azzeramento: si costruisce il backup e lo si scarica.
|
||||||
|
* La cancellazione non parte da qui — serve il secondo tempo,
|
||||||
|
* confermaAzzeramento(), a valle del download.
|
||||||
|
*/
|
||||||
|
public function avviaAzzeramento(): void
|
||||||
|
{
|
||||||
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
|
$this->validate(
|
||||||
|
['confermaReset' => 'required|in:' . self::CONFERMA_AZZERAMENTO],
|
||||||
|
['confermaReset.in' => 'Scrivi esattamente "' . self::CONFERMA_AZZERAMENTO . '" per confermare.',
|
||||||
|
'confermaReset.required' => 'Scrivi "' . self::CONFERMA_AZZERAMENTO . '" per confermare.']
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->confermaReset = '';
|
||||||
|
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
||||||
|
|
||||||
|
CreateBackupArchive::dispatch($this->operationId, true);
|
||||||
|
|
||||||
|
$this->aggiorna();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secondo tempo dell'azzeramento: parte solo su conferma esplicita, dopo
|
||||||
|
* che l'archivio e' stato servito.
|
||||||
|
*
|
||||||
|
* La conferma umana non e' un doppione del `wire:confirm` iniziale: e' il
|
||||||
|
* solo punto in cui si sa che il backup e' davvero sul computer di chi sta
|
||||||
|
* cancellando. Il server puo' verificare di averlo *spedito*
|
||||||
|
* (`servito_at`), non che sia arrivato: nginx bufferizza la risposta, il
|
||||||
|
* processo PHP la considera scritta e un download annullato dal browser
|
||||||
|
* resterebbe indistinguibile da uno riuscito. Chiedere all'operatore e'
|
||||||
|
* l'unico controllo che copre anche quel caso.
|
||||||
|
*/
|
||||||
|
public function confermaAzzeramento(): void
|
||||||
|
{
|
||||||
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
|
$this->aggiorna();
|
||||||
|
|
||||||
|
if (($this->operazione['fase'] ?? null) !== BackupOperationState::FASE_ATTESA_DOWNLOAD) {
|
||||||
|
$this->addError('confermaReset', 'Nessun azzeramento in attesa di conferma.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($this->operazione['result']['servito_at'] ?? null) === null) {
|
||||||
|
$this->addError('confermaReset', 'Scarica prima il backup: senza, l\'azzeramento non parte.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
|
||||||
|
$state['fase'] = 'Azzeramento in avvio';
|
||||||
|
$state['processed'] = 0;
|
||||||
|
$state['total'] = 0;
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
|
||||||
|
ResetApplication::dispatch($this->operationId, (int) auth()->id());
|
||||||
|
|
||||||
|
$this->aggiorna();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Annulla un azzeramento fermo in attesa di conferma. Il backup resta
|
||||||
|
* scaricabile: e' comunque un backup valido.
|
||||||
|
*/
|
||||||
|
public function annullaAzzeramento(): void
|
||||||
|
{
|
||||||
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
|
if (($this->operazione['fase'] ?? null) !== BackupOperationState::FASE_ATTESA_DOWNLOAD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(BackupOperationState::class)->markCompleted($this->operationId, [
|
||||||
|
'messaggio' => 'Azzeramento annullato. Il backup appena creato resta scaricabile.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Da qui in poi e' un backup come un altro: la scheda mostra il
|
||||||
|
// pulsante di download, non piu' la conferma di cancellazione.
|
||||||
|
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
|
||||||
|
$state['tipo'] = BackupOperationState::TIPO_BACKUP;
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->aggiorna();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggancia un'operazione avviata fuori da Livewire (il ripristino, che
|
||||||
|
* parte da un upload XHR e restituisce il suo id in JSON).
|
||||||
|
*/
|
||||||
|
public function seguiOperazione(string $operationId): void
|
||||||
|
{
|
||||||
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
|
$this->operationId = $operationId;
|
||||||
|
$this->aggiorna();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function aggiorna(): void
|
||||||
|
{
|
||||||
|
if (! $this->operationId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stato = app(BackupOperationState::class)->get($this->operationId);
|
||||||
|
|
||||||
|
if (! $stato) {
|
||||||
|
$this->operationId = null;
|
||||||
|
$this->operazione = [];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->operazione = $stato;
|
||||||
|
|
||||||
|
// Il download parte da solo appena l'archivio e' pronto: per il backup
|
||||||
|
// e' una comodita', per l'azzeramento e' il passaggio che sblocca la
|
||||||
|
// cancellazione, quindi non puo' dipendere da un clic.
|
||||||
|
if ($this->scaricaOra()) {
|
||||||
|
$this->dispatch('scarica-backup', url: route('backup.file', $this->operationId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function chiudi(): void
|
||||||
|
{
|
||||||
|
$this->operationId = null;
|
||||||
|
$this->operazione = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True una volta sola per operazione: l'evento di download va emesso quando
|
||||||
|
* l'archivio compare, non a ogni poll successivo.
|
||||||
|
*/
|
||||||
|
private function scaricaOra(): bool
|
||||||
|
{
|
||||||
|
$pronto = ($this->operazione['result']['file'] ?? null) !== null;
|
||||||
|
$giaInviato = $this->operazione['result']['download_inviato'] ?? false;
|
||||||
|
|
||||||
|
if (! $pronto || $giaInviato) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
app(BackupOperationState::class)->update($this->operationId, function (array $state) {
|
||||||
|
$state['result']['download_inviato'] = true;
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->operazione['result']['download_inviato'] = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nuovaOperazione(string $tipo): string
|
||||||
|
{
|
||||||
|
$operationId = (string) \Illuminate\Support\Str::uuid();
|
||||||
|
|
||||||
|
$stato = app(BackupOperationState::class);
|
||||||
|
$stato->initialize($operationId, $tipo, 'In coda');
|
||||||
|
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
|
||||||
|
|
||||||
|
return $operationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Percentuale della fase corrente, 0-100. */
|
||||||
|
public function getPercentualeProperty(): int
|
||||||
|
{
|
||||||
|
$stato = $this->operazione['status'] ?? null;
|
||||||
|
|
||||||
|
if ($stato === 'completed') {
|
||||||
|
return 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = (int) ($this->operazione['total'] ?? 0);
|
||||||
|
|
||||||
|
if ($total <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$processed = (int) ($this->operazione['processed'] ?? 0);
|
||||||
|
|
||||||
|
return (int) min(100, round($processed / $total * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Se true la pagina continua a interrogare lo stato. */
|
||||||
|
public function getInCorsoProperty(): bool
|
||||||
|
{
|
||||||
|
return $this->operationId !== null
|
||||||
|
&& in_array($this->operazione['status'] ?? '', ['queued', 'running'], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.settings.backup-manager');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,53 +45,137 @@ class BackupArchive
|
|||||||
private const MAX_TOTAL_BYTES = 3 * 1024 * 1024 * 1024;
|
private const MAX_TOTAL_BYTES = 3 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Costruisce il pacchetto e ne restituisce il percorso assoluto. Il file e'
|
* Quante volte l'archivio viene chiuso e riaperto mentre lo si costruisce.
|
||||||
* temporaneo: chi chiama lo consegna al browser e lo cancella.
|
*
|
||||||
|
* ZipArchive non scrive niente finche' non si chiama close(): tutte le
|
||||||
|
* addFile() sono solo annotazioni in memoria. Un avanzamento contato sulle
|
||||||
|
* addFile() salirebbe al 100% in un istante per poi restare fermo per tutta
|
||||||
|
* la compressione — cioe' l'esatto contrario di una barra reale. Chiudendo
|
||||||
|
* a lotti si paga una riscrittura dell'archivio per ogni lotto (libzip
|
||||||
|
* ricopia le entry gia' presenti, byte a byte, senza ricomprimerle) e in
|
||||||
|
* cambio ogni avanzamento corrisponde a byte davvero finiti sul disco.
|
||||||
|
*
|
||||||
|
* Il numero e' un tetto, non un passo fisso: il lotto si calcola come
|
||||||
|
* totale/questo valore, cosi' la ricopiatura resta limitata a ~10 volte la
|
||||||
|
* dimensione dell'archivio indipendentemente da quanti file ci sono.
|
||||||
|
*/
|
||||||
|
private const FLUSH_MASSIMI = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
|
||||||
*
|
*
|
||||||
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
|
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
|
||||||
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||||
*/
|
*/
|
||||||
public function create(string $xml): string
|
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string
|
||||||
{
|
{
|
||||||
$disk = Storage::disk('public');
|
$disk = Storage::disk('public');
|
||||||
$files = $disk->allFiles();
|
$files = $disk->allFiles();
|
||||||
|
$totale = count($files);
|
||||||
|
|
||||||
$path = storage_path('app/backup-' . Str::uuid() . '.zip');
|
$cartella = dirname($destinazione);
|
||||||
|
if (! is_dir($cartella) && ! mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
|
||||||
|
throw new RuntimeException('Impossibile preparare la cartella dei backup.');
|
||||||
|
}
|
||||||
|
|
||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
if ($zip->open($destinazione, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
throw new RuntimeException('Impossibile creare il file di backup.');
|
throw new RuntimeException('Impossibile creare il file di backup.');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$zip->addFromString(self::ENTRY_DATA, $xml);
|
||||||
$zip->addFromString(self::ENTRY_DATA, $xml);
|
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
||||||
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
'format_version' => self::FORMAT_VERSION,
|
||||||
'format_version' => self::FORMAT_VERSION,
|
'application' => 'TerManager2',
|
||||||
'application' => 'TerManager2',
|
'generated_at' => now()->toIso8601String(),
|
||||||
'generated_at' => now()->toIso8601String(),
|
'app_url' => (string) config('app.url'),
|
||||||
'app_url' => (string) config('app.url'),
|
'files_count' => $totale,
|
||||||
'files_count' => count($files),
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
|
||||||
|
|
||||||
// addFile() legge dal disco in fase di close(): i PDF non passano
|
if (! $zip->close()) {
|
||||||
// mai interi per la memoria del processo, per quanto sia grande
|
@unlink($destinazione);
|
||||||
// l'archivio.
|
|
||||||
foreach ($files as $relativePath) {
|
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||||
$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath);
|
}
|
||||||
}
|
|
||||||
|
if ($totale === 0) {
|
||||||
|
$onProgress && $onProgress(0, 0);
|
||||||
|
|
||||||
|
return $destinazione;
|
||||||
|
}
|
||||||
|
|
||||||
|
$perLotto = (int) max(1, ceil($totale / self::FLUSH_MASSIMI));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
|
||||||
|
$disk, $files, $totale, $perLotto, $onProgress, $destinazione
|
||||||
|
) {
|
||||||
|
$nelLotto = 0;
|
||||||
|
$fatti = 0;
|
||||||
|
|
||||||
|
foreach ($files as $relativePath) {
|
||||||
|
// addFile() legge dal disco durante close(): i PDF non
|
||||||
|
// passano mai interi per la memoria del processo.
|
||||||
|
$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath);
|
||||||
|
$nelLotto++;
|
||||||
|
$fatti++;
|
||||||
|
|
||||||
|
if ($nelLotto >= $perLotto && $fatti < $totale) {
|
||||||
|
$this->chiudiEriapri($zip, $destinazione);
|
||||||
|
$nelLotto = 0;
|
||||||
|
$onProgress && $onProgress($fatti, $totale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
@unlink($destinazione);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$onProgress && $onProgress($totale, $totale);
|
||||||
|
|
||||||
|
return $destinazione;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apre l'archivio in append, esegue il callback e lo chiude, garantendo la
|
||||||
|
* chiusura anche se il callback solleva.
|
||||||
|
*/
|
||||||
|
private function conArchivioAperto(string $path, callable $callback): void
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($path) !== true) {
|
||||||
|
throw new RuntimeException('Impossibile riaprire il file di backup.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$callback($zip);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$zip->close();
|
$zip->close();
|
||||||
@unlink($path);
|
|
||||||
|
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $zip->close()) {
|
if (! $zip->close()) {
|
||||||
@unlink($path);
|
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manda su disco le entry accumulate e riapre l'archivio per le prossime.
|
||||||
|
* E' qui che avviene il lavoro vero, quindi e' subito dopo questa chiamata
|
||||||
|
* che l'avanzamento ha un significato.
|
||||||
|
*/
|
||||||
|
private function chiudiEriapri(ZipArchive $zip, string $path): void
|
||||||
|
{
|
||||||
|
if (! $zip->close()) {
|
||||||
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return $path;
|
if ($zip->open($path) !== true) {
|
||||||
|
throw new RuntimeException('Impossibile riaprire il file di backup.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -102,9 +186,10 @@ class BackupArchive
|
|||||||
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
||||||
* dati correnti sono ancora intatti.
|
* dati correnti sono ancora intatti.
|
||||||
*
|
*
|
||||||
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||||
* @return array{xml: string, staging: string, files: int}
|
* @return array{xml: string, staging: string, files: int}
|
||||||
*/
|
*/
|
||||||
public function extract(string $zipPath): array
|
public function extract(string $zipPath, ?callable $onProgress = null): array
|
||||||
{
|
{
|
||||||
$zip = new ZipArchive();
|
$zip = new ZipArchive();
|
||||||
if ($zip->open($zipPath) !== true) {
|
if ($zip->open($zipPath) !== true) {
|
||||||
@@ -148,6 +233,11 @@ class BackupArchive
|
|||||||
$totalBytes = 0;
|
$totalBytes = 0;
|
||||||
$extracted = 0;
|
$extracted = 0;
|
||||||
|
|
||||||
|
// Le entry di storage/ sono tutte tranne manifest e data.xml: e' il
|
||||||
|
// totale su cui si misura l'estrazione.
|
||||||
|
$daEstrarre = max(0, $zip->numFiles - 2);
|
||||||
|
$onProgress && $onProgress(0, $daEstrarre);
|
||||||
|
|
||||||
for ($index = 0; $index < $zip->numFiles; $index++) {
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||||
$entryName = $zip->getNameIndex($index);
|
$entryName = $zip->getNameIndex($index);
|
||||||
|
|
||||||
@@ -208,6 +298,7 @@ class BackupArchive
|
|||||||
fclose($target);
|
fclose($target);
|
||||||
|
|
||||||
$extracted++;
|
$extracted++;
|
||||||
|
$onProgress && $onProgress($extracted, $daEstrarre);
|
||||||
}
|
}
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$zip->close();
|
$zip->close();
|
||||||
@@ -228,21 +319,25 @@ class BackupArchive
|
|||||||
* righe del database, che il ripristino ha appena riscritto per intero.
|
* righe del database, che il ripristino ha appena riscritto per intero.
|
||||||
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
||||||
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
|
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
|
||||||
|
*
|
||||||
|
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||||
*/
|
*/
|
||||||
public function restoreFiles(string $staging): int
|
public function restoreFiles(string $staging, ?callable $onProgress = null): int
|
||||||
{
|
{
|
||||||
$disk = Storage::disk('public');
|
$disk = Storage::disk('public');
|
||||||
|
|
||||||
foreach ($disk->directories() as $directory) {
|
$this->svuotaDiscoPubblico();
|
||||||
$disk->deleteDirectory($directory);
|
|
||||||
}
|
|
||||||
$disk->delete($disk->files());
|
|
||||||
|
|
||||||
if (! is_dir($staging)) {
|
if (! is_dir($staging)) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
$restored = 0;
|
$restored = 0;
|
||||||
|
$totale = iterator_count(new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||||
|
));
|
||||||
|
$onProgress && $onProgress(0, $totale);
|
||||||
|
|
||||||
$iterator = new \RecursiveIteratorIterator(
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||||
);
|
);
|
||||||
@@ -267,11 +362,56 @@ class BackupArchive
|
|||||||
}
|
}
|
||||||
|
|
||||||
$restored++;
|
$restored++;
|
||||||
|
$onProgress && $onProgress($restored, $totale);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $restored;
|
return $restored;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Svuota il disco `public` (PDF dei territori, miniature, logo) lasciando
|
||||||
|
* in piedi la cartella, a cui punta il symlink public/storage.
|
||||||
|
*
|
||||||
|
* Il conteggio si ricava da un secondo giro sul disco, non dai file
|
||||||
|
* trovati all'inizio: Flysystem non solleva se una unlink fallisce, quindi
|
||||||
|
* restituire il totale iniziale significherebbe dichiarare cancellati file
|
||||||
|
* ancora li'. Succede davvero — basta un file scritto da un processo che
|
||||||
|
* gira con un altro utente (l'entrypoint lavora da root, php-fpm e il
|
||||||
|
* worker di coda da www-data) perche' il disco resti popolato mentre
|
||||||
|
* l'operazione si dichiara riuscita.
|
||||||
|
*
|
||||||
|
* Se qualcosa sopravvive si solleva: per un ripristino significa che il
|
||||||
|
* disco non e' stato davvero sostituito, per un azzeramento che dei dati
|
||||||
|
* sono rimasti. In entrambi i casi e' un fallimento, non una nota a
|
||||||
|
* margine.
|
||||||
|
*
|
||||||
|
* @return int File effettivamente rimossi.
|
||||||
|
*/
|
||||||
|
public function svuotaDiscoPubblico(): int
|
||||||
|
{
|
||||||
|
$disk = Storage::disk('public');
|
||||||
|
$prima = count($disk->allFiles());
|
||||||
|
|
||||||
|
foreach ($disk->directories() as $directory) {
|
||||||
|
$disk->deleteDirectory($directory);
|
||||||
|
}
|
||||||
|
$disk->delete($disk->files());
|
||||||
|
|
||||||
|
$rimasti = $disk->allFiles();
|
||||||
|
|
||||||
|
if ($rimasti !== []) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Non e\' stato possibile cancellare %d file dal disco (es. "%s"). '
|
||||||
|
. 'Di solito e\' un problema di permessi: controlla che il volume storage '
|
||||||
|
. 'appartenga a www-data.',
|
||||||
|
count($rimasti),
|
||||||
|
$rimasti[0]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $prima;
|
||||||
|
}
|
||||||
|
|
||||||
public function deleteDirectory(string $path): void
|
public function deleteDirectory(string $path): void
|
||||||
{
|
{
|
||||||
if (! is_dir($path)) {
|
if (! is_dir($path)) {
|
||||||
|
|||||||
179
app/Services/BackupOperationState.php
Normal file
179
app/Services/BackupOperationState.php
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stato di avanzamento delle operazioni lunghe di backup, ripristino e
|
||||||
|
* azzeramento.
|
||||||
|
*
|
||||||
|
* Le tre operazioni girano in coda e la pagina le segue interrogando questo
|
||||||
|
* stato: `total` e `processed` contano unita' di lavoro davvero completate
|
||||||
|
* (file scritti nell'archivio, file estratti, tabelle svuotate), non un
|
||||||
|
* temporizzatore. La barra si muove quando qualcosa e' finito sul disco.
|
||||||
|
*
|
||||||
|
* Stessa impostazione di TerritorioPdfImportState, con cui condivide anche il
|
||||||
|
* TTL: lo stato sopravvive un giorno, quindi ricaricare la pagina dopo un
|
||||||
|
* ripristino lungo mostra ancora l'esito.
|
||||||
|
*/
|
||||||
|
class BackupOperationState
|
||||||
|
{
|
||||||
|
public const TIPO_BACKUP = 'backup';
|
||||||
|
public const TIPO_RESTORE = 'restore';
|
||||||
|
public const TIPO_RESET = 'reset';
|
||||||
|
|
||||||
|
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
|
||||||
|
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
|
||||||
|
|
||||||
|
protected int $ttlSeconds = 86400;
|
||||||
|
|
||||||
|
/** Coda dei log: solo le ultime righe, lo stato viaggia a ogni poll. */
|
||||||
|
private const MAX_LOG = 60;
|
||||||
|
|
||||||
|
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
|
||||||
|
{
|
||||||
|
$state = [
|
||||||
|
'id' => $id,
|
||||||
|
'tipo' => $tipo,
|
||||||
|
'status' => 'queued',
|
||||||
|
'fase' => $fase,
|
||||||
|
'total' => $total,
|
||||||
|
'processed' => 0,
|
||||||
|
'logs' => [],
|
||||||
|
'result' => [],
|
||||||
|
'error' => null,
|
||||||
|
'started_at' => null,
|
||||||
|
'finished_at' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->put($id, $state);
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $id): ?array
|
||||||
|
{
|
||||||
|
return Cache::get($this->key($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $id, array $state): void
|
||||||
|
{
|
||||||
|
Cache::put($this->key($id), $state, $this->ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forget(string $id): void
|
||||||
|
{
|
||||||
|
Cache::forget($this->key($id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(string $id, callable $callback): ?array
|
||||||
|
{
|
||||||
|
$state = $this->get($id);
|
||||||
|
|
||||||
|
if (! $state) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$state = $callback($state) ?? $state;
|
||||||
|
$this->put($id, $state);
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function log(string $id, string $message): void
|
||||||
|
{
|
||||||
|
$this->update($id, function (array $state) use ($message) {
|
||||||
|
$state['logs'][] = '[' . now()->format('H:i:s') . '] ' . $message;
|
||||||
|
$state['logs'] = array_slice($state['logs'], -self::MAX_LOG);
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cambia fase e, insieme, il totale di riferimento: ogni fase ha una sua
|
||||||
|
* unita' di misura (file da comprimere, file da estrarre, passi di
|
||||||
|
* cancellazione) e la barra riparte da zero su quel totale.
|
||||||
|
*/
|
||||||
|
public function fase(string $id, string $fase, int $total = 0): void
|
||||||
|
{
|
||||||
|
$this->update($id, function (array $state) use ($fase, $total) {
|
||||||
|
$state['fase'] = $fase;
|
||||||
|
$state['total'] = $total;
|
||||||
|
$state['processed'] = 0;
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->log($id, $fase . ($total > 0 ? ' (' . $total . ')' : ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function avanza(string $id, int $processed, ?int $total = null): void
|
||||||
|
{
|
||||||
|
$this->update($id, function (array $state) use ($processed, $total) {
|
||||||
|
$state['processed'] = $processed;
|
||||||
|
if ($total !== null) {
|
||||||
|
$state['total'] = $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markRunning(string $id): void
|
||||||
|
{
|
||||||
|
$this->update($id, function (array $state) {
|
||||||
|
$state['status'] = 'running';
|
||||||
|
$state['started_at'] ??= now()->toDateTimeString();
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markCompleted(string $id, array $result = []): void
|
||||||
|
{
|
||||||
|
$this->update($id, function (array $state) use ($result) {
|
||||||
|
$state['status'] = 'completed';
|
||||||
|
$state['processed'] = max($state['processed'], $state['total']);
|
||||||
|
$state['result'] = $result + $state['result'];
|
||||||
|
$state['finished_at'] = now()->toDateTimeString();
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function markFailed(string $id, string $error): void
|
||||||
|
{
|
||||||
|
$this->update($id, function (array $state) use ($error) {
|
||||||
|
$state['status'] = 'failed';
|
||||||
|
$state['error'] = $error;
|
||||||
|
$state['finished_at'] = now()->toDateTimeString();
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->log($id, 'ERRORE: ' . $error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'archivio e' pronto ma non si prosegue: l'azzeramento parte solo quando
|
||||||
|
* il download risulta consegnato per intero.
|
||||||
|
*/
|
||||||
|
public function markAttesaDownload(string $id, array $result): void
|
||||||
|
{
|
||||||
|
$this->update($id, function (array $state) use ($result) {
|
||||||
|
$state['status'] = 'running';
|
||||||
|
$state['fase'] = self::FASE_ATTESA_DOWNLOAD;
|
||||||
|
$state['processed'] = $state['total'];
|
||||||
|
$state['result'] = $result + $state['result'];
|
||||||
|
|
||||||
|
return $state;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function key(string $id): string
|
||||||
|
{
|
||||||
|
return 'backup-op:' . $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@
|
|||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
@if($esitoRipristino)
|
||||||
|
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
|
||||||
|
{{ $esitoRipristino }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if($forced)
|
@if($forced)
|
||||||
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
<div class="rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
<strong>Ripristino da backup eseguito.</strong>
|
<strong>Ripristino da backup eseguito.</strong>
|
||||||
|
|||||||
296
resources/views/livewire/settings/backup-manager.blade.php
Normal file
296
resources/views/livewire/settings/backup-manager.blade.php
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
{{--
|
||||||
|
Backup, ripristino e azzeramento, con avanzamento reale.
|
||||||
|
|
||||||
|
La barra legge processed/total dell'operazione in corso, che i job
|
||||||
|
aggiornano quando qualcosa e' davvero finito sul disco. Il poll resta
|
||||||
|
attivo solo mentre l'operazione e' in coda o in esecuzione: appena chiude,
|
||||||
|
si ferma da solo.
|
||||||
|
--}}
|
||||||
|
@php
|
||||||
|
$op = $operazione;
|
||||||
|
$tipo = $op['tipo'] ?? null;
|
||||||
|
$stato = $op['status'] ?? null;
|
||||||
|
$attesaDownload = ($op['fase'] ?? null) === \App\Services\BackupOperationState::FASE_ATTESA_DOWNLOAD;
|
||||||
|
|
||||||
|
$etichettaTipo = match ($tipo) {
|
||||||
|
'backup' => 'Backup in corso',
|
||||||
|
'restore' => 'Ripristino in corso',
|
||||||
|
'reset' => 'Azzeramento in corso',
|
||||||
|
default => 'Operazione in corso',
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="space-y-6"
|
||||||
|
x-data="{
|
||||||
|
zipSelezionato: '',
|
||||||
|
uploadInCorso: false,
|
||||||
|
uploadPercento: 0,
|
||||||
|
erroreUpload: '',
|
||||||
|
|
||||||
|
inviaRipristino(event) {
|
||||||
|
const form = event.target;
|
||||||
|
const formData = new FormData(form);
|
||||||
|
|
||||||
|
this.erroreUpload = '';
|
||||||
|
this.uploadInCorso = true;
|
||||||
|
this.uploadPercento = 0;
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', form.action);
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json');
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
this.uploadPercento = Math.round((e.loaded / e.total) * 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
this.uploadInCorso = false;
|
||||||
|
let payload = {};
|
||||||
|
try { payload = JSON.parse(xhr.responseText || '{}'); } catch (e) { payload = {}; }
|
||||||
|
|
||||||
|
if (xhr.status >= 200 && xhr.status < 300 && payload.operation_id) {
|
||||||
|
this.uploadPercento = 100;
|
||||||
|
this.zipSelezionato = '';
|
||||||
|
form.reset();
|
||||||
|
// Da qui in poi l'avanzamento arriva dal job, non dall'upload.
|
||||||
|
$wire.seguiOperazione(payload.operation_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.erroreUpload = payload.message
|
||||||
|
|| (payload.errors && payload.errors.backupZip ? payload.errors.backupZip[0] : 'Errore durante il caricamento del pacchetto.');
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
this.uploadInCorso = false;
|
||||||
|
this.erroreUpload = 'Errore di rete durante il caricamento del pacchetto.';
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
},
|
||||||
|
}"
|
||||||
|
x-on:scarica-backup.window="window.location.href = $event.detail.url">
|
||||||
|
|
||||||
|
{{-- ─── Pannello di avanzamento ────────────────────────────────── --}}
|
||||||
|
@if($operationId && $op)
|
||||||
|
<div class="rounded-xl border p-5
|
||||||
|
{{ $stato === 'failed' ? 'border-red-300 bg-red-50' : ($stato === 'completed' ? 'border-green-300 bg-green-50' : 'border-indigo-200 bg-indigo-50') }}"
|
||||||
|
@if($this->inCorso) wire:poll.750ms="aggiorna" @endif>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-semibold text-gray-900">
|
||||||
|
{{ $stato === 'completed' ? 'Operazione completata' : ($stato === 'failed' ? 'Operazione fallita' : $etichettaTipo) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 text-xs text-gray-600 truncate">
|
||||||
|
{{ $op['fase'] ?? '' }}
|
||||||
|
@if(($op['total'] ?? 0) > 0 && $stato !== 'completed')
|
||||||
|
— {{ $op['processed'] ?? 0 }} / {{ $op['total'] }}
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-2xl font-bold tabular-nums {{ $stato === 'failed' ? 'text-red-700' : 'text-indigo-700' }}">
|
||||||
|
{{ $this->percentuale }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 h-2.5 overflow-hidden rounded-full bg-white/70">
|
||||||
|
<div class="h-full rounded-full transition-all duration-300
|
||||||
|
{{ $stato === 'failed' ? 'bg-red-600' : ($stato === 'completed' ? 'bg-green-600' : 'bg-indigo-600') }}"
|
||||||
|
style="width: {{ $this->percentuale }}%"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($attesaDownload)
|
||||||
|
<div class="mt-3 rounded-lg border-2 border-amber-400 bg-amber-50 px-4 py-3 space-y-3">
|
||||||
|
<p class="text-sm text-amber-900">
|
||||||
|
<strong>L’archivio è pronto e il download è partito.</strong>
|
||||||
|
Non è stato cancellato ancora nulla: l’azzeramento aspetta la tua conferma.
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-amber-800">
|
||||||
|
Apri il file scaricato e verifica che sia integro. Il server sa di aver <em>spedito</em>
|
||||||
|
l’archivio, non può sapere se è arrivato intero: quella verifica tocca a te,
|
||||||
|
ed è l’ultima possibilità di tornare indietro.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<a href="{{ route('backup.file', $operationId) }}"
|
||||||
|
style="display:inline-flex;align-items:center;gap:6px;background:#fff;color:#92400e;border:1px solid #d97706;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
|
||||||
|
Scarica di nuovo
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
wire:click="confermaAzzeramento"
|
||||||
|
wire:confirm="Hai verificato il backup scaricato? Confermando, i dati vengono cancellati definitivamente."
|
||||||
|
style="display:inline-flex;align-items:center;gap:6px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:600;">
|
||||||
|
Ho salvato il backup, azzera tutto
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button type="button" wire:click="annullaAzzeramento" class="text-xs text-gray-700 underline">
|
||||||
|
Annulla l’azzeramento
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@error('confermaReset') <p class="text-xs font-medium text-red-700">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($stato === 'failed')
|
||||||
|
<p class="mt-3 text-sm text-red-800">{{ $op['error'] ?? 'Errore non specificato.' }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($stato === 'completed' && ($op['result']['messaggio'] ?? null))
|
||||||
|
<p class="mt-3 text-sm text-green-900">{{ $op['result']['messaggio'] }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($stato === 'completed' && $tipo === 'backup' && ($op['result']['file'] ?? null))
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
|
<a href="{{ route('backup.file', $operationId) }}"
|
||||||
|
style="display:inline-flex;align-items:center;gap:6px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:8px;padding:8px 14px;font-size:13px;font-weight:500;text-decoration:none;">
|
||||||
|
Scarica di nuovo
|
||||||
|
</a>
|
||||||
|
<span class="text-xs text-gray-600">
|
||||||
|
{{ $op['result']['nome_download'] ?? '' }} —
|
||||||
|
{{ number_format(($op['result']['dimensione'] ?? 0) / 1048576, 1, ',', '.') }} MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(!empty($op['logs']))
|
||||||
|
<details class="mt-3">
|
||||||
|
<summary class="cursor-pointer text-xs font-medium text-gray-700">Dettaglio</summary>
|
||||||
|
<textarea readonly rows="8" class="mt-2 block w-full rounded-lg border border-gray-300 bg-gray-950 px-3 py-2 font-mono text-xs leading-5 text-gray-100">{{ implode(PHP_EOL, $op['logs']) }}</textarea>
|
||||||
|
</details>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if(in_array($stato, ['completed', 'failed'], true))
|
||||||
|
<button type="button" wire:click="chiudi" class="mt-3 text-xs text-gray-600 underline">Chiudi</button>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
{{-- ─── Backup ─────────────────────────────────────────────────── --}}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Backup completo (.zip)</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Un unico pacchetto con <strong>tutti</strong> i dati dell’applicazione — utenti compresi —
|
||||||
|
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
|
||||||
|
E’ l’archivio da conservare per rimettere in piedi l’installazione da zero.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
|
||||||
|
<strong>Dati sensibili.</strong> 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||||
|
<button type="button" wire:click="avviaBackup" @disabled($this->inCorso)
|
||||||
|
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
|
||||||
|
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
|
||||||
|
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
|
||||||
|
Crea e scarica backup
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-gray-500">L’archivio si genera in background: puoi seguire l’avanzamento qui sopra.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ─── Ripristino ─────────────────────────────────────────────── --}}
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900">Ripristina da backup</h2>
|
||||||
|
<p class="text-xs text-gray-500 mt-1">
|
||||||
|
Carica un pacchetto .zip generato qui sopra. Il ripristino <strong>sostituisce</strong> i dati
|
||||||
|
gestionali correnti e tutti i file caricati.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-red-300 bg-red-50 p-3 text-xs text-red-800">
|
||||||
|
<strong>Al termine tutti dovranno cambiare password</strong>, 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.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action="{{ route('backup.restore') }}" method="POST" enctype="multipart/form-data" class="space-y-4"
|
||||||
|
@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
|
||||||
|
<div>
|
||||||
|
<input name="backupZip" type="file" accept=".zip,application/zip"
|
||||||
|
x-on:change="zipSelezionato = $event.target.files[0] ? $event.target.files[0].name : ''; erroreUpload = ''"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- Caricamento: percentuale reale dell'upload, non del ripristino. --}}
|
||||||
|
<div x-show="uploadInCorso || uploadPercento > 0" x-cloak class="rounded-lg border border-indigo-100 bg-indigo-50 px-3 py-3">
|
||||||
|
<div class="flex items-center justify-between gap-3 text-xs text-indigo-800">
|
||||||
|
<span x-text="uploadInCorso ? 'Caricamento del pacchetto...' : 'Pacchetto caricato'"></span>
|
||||||
|
<span class="tabular-nums" x-text="uploadPercento + '%'"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-2 overflow-hidden rounded-full bg-indigo-100">
|
||||||
|
<div class="h-full rounded-full bg-indigo-600 transition-all duration-200" :style="'width:' + uploadPercento + '%'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
x-bind:disabled="!zipSelezionato || uploadInCorso"
|
||||||
|
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#dc2626;color:#fff;border:1px solid #991b1b;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;"
|
||||||
|
x-bind:style="(!zipSelezionato || uploadInCorso) ? 'opacity:.5;cursor:not-allowed;' : ''">
|
||||||
|
Ripristina backup
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{-- ─── Zona pericolosa ────────────────────────────────────────── --}}
|
||||||
|
<div class="rounded-xl border-2 border-red-300 bg-red-50/40 p-6 space-y-4">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<svg class="h-6 w-6 flex-none text-red-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01M5.07 19h13.86c1.54 0 2.5-1.67 1.73-3L13.73 4a2 2 0 00-3.46 0L3.34 16c-.77 1.33.19 3 1.73 3z"/></svg>
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-red-900">Zona pericolosa</h2>
|
||||||
|
<p class="text-xs text-red-800 mt-1">Operazioni irreversibili. Leggi prima di premere.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-red-300 bg-white p-4 space-y-3">
|
||||||
|
<h3 class="text-sm font-semibold text-gray-900">Azzera tutto e riparti da zero</h3>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-600">
|
||||||
|
Riporta l’applicazione allo stato di installazione appena eseguita. In ordine:
|
||||||
|
</p>
|
||||||
|
<ol class="text-xs text-gray-600 list-decimal list-inside space-y-1">
|
||||||
|
<li><strong>Viene creato e scaricato un backup completo</strong> di tutto ciò che c’è adesso.</li>
|
||||||
|
<li>L’operazione si ferma e aspetta: <strong>controlli il file scaricato e confermi</strong>. Fino a quel clic non viene toccato nulla, e puoi annullare.</li>
|
||||||
|
<li>Vengono eliminati territori, proclamatori, assegnazioni, campagne, zone, tipologie, anni di servizio, il registro attività e <strong>tutti i file caricati</strong>.</li>
|
||||||
|
<li>Le impostazioni tornano ai valori di default e i ruoli vengono riseminati.</li>
|
||||||
|
<li><strong>Tutti gli altri utenti vengono eliminati.</strong> Resta solo il tuo account di amministratore, con la password attuale — come un’installazione nuova, che ha un amministratore solo.</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="rounded-lg border border-red-200 bg-red-50 p-3 text-xs text-red-800">
|
||||||
|
Il backup scaricato è l’<strong>unico</strong> modo per tornare indietro. Verifica di averlo
|
||||||
|
salvato prima di chiudere il browser.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confermaReset" class="block text-xs font-medium text-gray-700">
|
||||||
|
Per confermare, scrivi <code class="rounded bg-gray-100 px-1 py-0.5 font-mono">{{ \App\Livewire\Settings\BackupManager::CONFERMA_AZZERAMENTO }}</code>
|
||||||
|
</label>
|
||||||
|
<input wire:model="confermaReset" type="text" id="confermaReset" autocomplete="off"
|
||||||
|
class="mt-1 block w-full max-w-xs rounded-lg border-gray-300 shadow-sm focus:ring-red-500 focus:border-red-500 text-sm font-mono">
|
||||||
|
@error('confermaReset') <p class="text-red-600 text-xs mt-1">{{ $message }}</p> @enderror
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button"
|
||||||
|
wire:click="avviaAzzeramento"
|
||||||
|
wire:confirm="Ultima conferma. Verrà scaricato un backup e poi l'applicazione sarà azzerata. L'operazione non è reversibile."
|
||||||
|
@disabled($this->inCorso)
|
||||||
|
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#b91c1c;color:#fff;border:1px solid #7f1d1d;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:600;"
|
||||||
|
@class(['opacity-50 cursor-not-allowed' => $this->inCorso])>
|
||||||
|
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.87 12.14A2 2 0 0116.14 21H7.86a2 2 0 01-1.99-1.86L5 7m5 4v6m4-6v6M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3M4 7h16"/></svg>
|
||||||
|
Scarica backup e azzera tutto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -4,62 +4,12 @@
|
|||||||
<p class="text-sm text-gray-500 mt-1">Backup completo dell’applicazione, import PDF territori, conversione legacy SQL, import ed export XML.</p>
|
<p class="text-sm text-gray-500 mt-1">Backup completo dell’applicazione, import PDF territori, conversione legacy SQL, import ed export XML.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{-- Backup completo: e' la sezione che serve nel 99% dei casi, quindi sta
|
{{-- Backup, ripristino e zona pericolosa: componente a sé perché ha uno
|
||||||
in cima. Le sezioni XML piu' sotto restano per gli usi parziali e per
|
stato proprio (l'operazione in corso) e un poll che non deve far
|
||||||
la conversione dei dump legacy. --}}
|
ri-renderizzare tutto il resto della pagina. Sta in cima perché è la
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-5">
|
sezione che serve nel 99% dei casi; le sezioni XML più sotto restano
|
||||||
<div>
|
per gli usi parziali e per la conversione dei dump legacy. --}}
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Backup completo (.zip)</h2>
|
<livewire:settings.backup-manager />
|
||||||
<p class="text-xs text-gray-500 mt-1">
|
|
||||||
Un unico pacchetto con <strong>tutti</strong> i dati dell’applicazione — utenti compresi —
|
|
||||||
e <strong>tutti i file caricati</strong>: PDF dei territori, miniature e logo.
|
|
||||||
E’ l’archivio da conservare per rimettere in piedi l’installazione da zero.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
|
|
||||||
<strong>Dati sensibili.</strong> 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.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
||||||
<a href="{{ route('backup.download') }}"
|
|
||||||
style="display:inline-flex;align-items:center;gap:6px;justify-content:center;min-height:40px;background:#059669;color:#fff;border:1px solid #065f46;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;text-decoration:none;">
|
|
||||||
<svg style="width:16px;height:16px;" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3M3 17v3a1 1 0 001 1h16a1 1 0 001-1v-3"/></svg>
|
|
||||||
Scarica backup completo
|
|
||||||
</a>
|
|
||||||
<p class="text-xs text-gray-500">La preparazione dell’archivio puo’ richiedere qualche minuto se ci sono molti PDF.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="border-t border-gray-200 pt-5">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-900">Ripristina da backup</h3>
|
|
||||||
<p class="mt-1 text-xs text-gray-500">
|
|
||||||
Carica un pacchetto .zip generato qui sopra. Il ripristino <strong>sostituisce</strong> i dati
|
|
||||||
gestionali correnti e tutti i file caricati.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-3 rounded-lg border border-red-300 bg-red-50 p-3 text-xs text-red-800">
|
|
||||||
<strong>Al termine tutti dovranno cambiare password</strong>, 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.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form action="{{ route('backup.restore') }}" method="POST" enctype="multipart/form-data" class="mt-4 space-y-4"
|
|
||||||
onsubmit="return confirm('Confermi il ripristino? I dati gestionali e i file correnti verranno sostituiti, e tutti gli utenti dovranno impostare una nuova password.');">
|
|
||||||
@csrf
|
|
||||||
<div>
|
|
||||||
<input name="backupZip" type="file" accept=".zip,application/zip"
|
|
||||||
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">
|
|
||||||
@error('backupZip') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit"
|
|
||||||
style="display:inline-flex;align-items:center;justify-content:center;min-height:40px;background:#dc2626;color:#fff;border:1px solid #991b1b;border-radius:10px;padding:10px 14px;font-size:14px;font-weight:500;">
|
|
||||||
Ripristina backup
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-4"
|
||||||
x-data="{
|
x-data="{
|
||||||
|
|||||||
@@ -163,9 +163,11 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('xml-exchange/import-xml', [XmlExchangeUploadController::class, 'importXml'])->name('xml.import-xml');
|
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');
|
Route::post('imports/territori/pdf-zip', [TerritoryPdfImportController::class, 'storeZip'])->name('imports.territori.pdf-zip');
|
||||||
|
|
||||||
// Backup completo (.zip): dati, utenti e file caricati.
|
// Backup completo (.zip): dati, utenti e file caricati. L'avvio delle
|
||||||
Route::get('backup/download', [BackupController::class, 'download'])->name('backup.download');
|
// operazioni e la barra di avanzamento stanno in Livewire
|
||||||
Route::post('backup/restore', [BackupController::class, 'restore'])->name('backup.restore');
|
// (Settings\BackupManager); qui passa solo il trasferimento dei file.
|
||||||
|
Route::get('backup/file/{operationId}', [BackupController::class, 'file'])->name('backup.file');
|
||||||
|
Route::post('backup/restore', [BackupController::class, 'upload'])->name('backup.restore');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Privacy / Informativa GDPR — visibile solo all'amministratore.
|
// Privacy / Informativa GDPR — visibile solo all'amministratore.
|
||||||
|
|||||||
Reference in New Issue
Block a user