++ add backup totale applicativo
This commit is contained in:
54
HANDOFF.md
54
HANDOFF.md
@@ -248,6 +248,60 @@ 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
|
||||||
|
|
||||||
|
L'export XML da solo non ricostruisce un'installazione: `pdf_path`,
|
||||||
|
`thumbnail_path` e `logo_path` sono percorsi sul disco `public`, quindi un
|
||||||
|
ripristino su volumi vuoti lascia ogni territorio che punta a un file
|
||||||
|
inesistente — link pubblici a 404 e miniature rotte. Il pacchetto `.zip`
|
||||||
|
(`Services/BackupArchive`, `Http/Controllers/Settings/BackupController`) mette
|
||||||
|
insieme `manifest.json`, `data.xml` (lo stesso XML dell'export) e `storage/…`
|
||||||
|
con **tutti** i file del disco `public`. L'export XML resta, etichettato come
|
||||||
|
parziale.
|
||||||
|
|
||||||
|
Scelte non ovvie:
|
||||||
|
|
||||||
|
- **Rotte HTTP, non Livewire.** Lo `streamDownload` di Livewire passa il file
|
||||||
|
base64 dentro la risposta JSON: un archivio con i PDF dei territori non ci
|
||||||
|
sta. `backup/download` e `backup/restore` sono due rotte normali nel gruppo
|
||||||
|
`settings.manage`. Per lo stesso motivo `ZipArchive::addFile()` (che legge
|
||||||
|
dal disco in `close()`) e gli stream in estrazione: nessun PDF passa intero
|
||||||
|
per la memoria del processo.
|
||||||
|
|
||||||
|
- **Ordine del restore: staging → database → file.** I file si estraggono
|
||||||
|
prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o
|
||||||
|
fuori limite fallisce mentre i dati correnti sono ancora intatti; poi
|
||||||
|
`importXmlFromContent()` in transazione; poi la sostituzione dei file. Il
|
||||||
|
disco `public` viene **svuotato**, non fuso: i percorsi vivono nelle righe
|
||||||
|
appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna
|
||||||
|
schermata mostra e nessuna cancellazione raggiunge.
|
||||||
|
|
||||||
|
- **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su
|
||||||
|
numero di entry, dimensione della singola entry e totale decompresso, pochi
|
||||||
|
MB possono riempire il volume (zip bomb). I nomi delle entry sono
|
||||||
|
normalizzati e ogni `..` o percorso assoluto viene rifiutato (zip slip) —
|
||||||
|
senza, un archivio ostile scriverebbe fuori dalla cartella di destinazione.
|
||||||
|
|
||||||
|
- **Dopo un restore tutti cambiano password.** Il pacchetto contiene gli hash
|
||||||
|
bcrypt di ogni utente: chi lo ha avuto in mano ha potuto tentarne il crack
|
||||||
|
offline, quindi da lì in poi nessuna vecchia password è più un segreto. Il
|
||||||
|
restore accende `password_change_required` su **tutte** le righe di `users`
|
||||||
|
(amministratore che sta ripristinando compreso) e azzera i `remember_token`,
|
||||||
|
che sono credenziali persistenti e stanno pure loro nel backup. Il
|
||||||
|
middleware `RequirePasswordChange` (in coda al gruppo `web`, quindi copre
|
||||||
|
anche `/livewire/update` — Livewire segue le risposte redirezionate) dirotta
|
||||||
|
su `password.change` tutto tranne quella pagina, il suo invio e il logout.
|
||||||
|
|
||||||
|
- **La schermata di cambio password non è Livewire e non chiede la password
|
||||||
|
attuale.** Non è Livewire perché deve restare raggiungibile mentre il
|
||||||
|
middleware respinge anche le POST di Livewire, e distinguere quale
|
||||||
|
componente sta dietro una `/livewire/update` è fragile; due rotte HTTP si
|
||||||
|
escludono per nome. Non chiede la password attuale perché il restore ha
|
||||||
|
appena sovrascritto gli hash con quelli del backup: la "password attuale" di
|
||||||
|
un account è quella dell'epoca del backup, che l'utente — l'amministratore
|
||||||
|
per primo — può benissimo non ricordare. La garanzia è la sessione: a quella
|
||||||
|
pagina ci si arriva già autenticati.
|
||||||
|
|
||||||
### Export/Import XML: backup completo e round-trip
|
### Export/Import XML: backup completo e round-trip
|
||||||
|
|
||||||
`Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali,
|
`Livewire/Settings/XmlExchange` esporta e reimporta **tutti** i dati gestionali,
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -397,7 +397,31 @@ Un aggiornamento ordinario non richiede alcun intervento manuale.
|
|||||||
|
|
||||||
## Backup e ripristino
|
## Backup e ripristino
|
||||||
|
|
||||||
### Cosa salvare
|
### Dall'applicazione: pacchetto `.zip` (consigliato)
|
||||||
|
|
||||||
|
*Backup e import* → **Scarica backup completo**. Produce un unico `.zip` con
|
||||||
|
tutti i dati gestionali, gli utenti e tutti i file caricati (PDF dei territori,
|
||||||
|
miniature, logo). È la via da usare per spostare o rimettere in piedi
|
||||||
|
un'installazione senza toccare i container.
|
||||||
|
|
||||||
|
Sulla stessa pagina, **Ripristina backup** ricarica il pacchetto: sostituisce i
|
||||||
|
dati gestionali correnti *e* i file caricati.
|
||||||
|
|
||||||
|
> **Dopo un ripristino tutti gli utenti — amministratore compreso — devono
|
||||||
|
> impostare una nuova password.** Il pacchetto contiene gli hash delle password
|
||||||
|
> di ogni account: chi lo ha avuto in mano ha potuto tentarne il crack offline,
|
||||||
|
> quindi le vecchie credenziali non sono più considerate sicure. Al primo
|
||||||
|
> accesso l'applicazione resta bloccata sulla schermata di cambio password.
|
||||||
|
|
||||||
|
Il pacchetto **non** contiene `APP_KEY`: i nomi dei proclamatori sono cifrati
|
||||||
|
con quella chiave, quindi il ripristino va fatto su un'installazione che ha la
|
||||||
|
**stessa** `APP_KEY` di quando il backup è stato creato.
|
||||||
|
|
||||||
|
Il tetto per l'upload è **256 MB** (`client_max_body_size` in
|
||||||
|
`docker/nginx/default.conf` e `upload_max_filesize`/`post_max_size` in
|
||||||
|
`docker/php/php.ini`). Oltre quella soglia usa il backup dei volumi qui sotto.
|
||||||
|
|
||||||
|
### Dai volumi: cosa salvare
|
||||||
|
|
||||||
| Volume | Contenuto | Nel backup |
|
| Volume | Contenuto | Nel backup |
|
||||||
|----------------|------------------------------------------------------|:----------:|
|
|----------------|------------------------------------------------------|:----------:|
|
||||||
@@ -568,9 +592,13 @@ docker compose --profile dev up -d --build
|
|||||||
- Filtri per utente e tipo evento, con diff prima/dopo
|
- Filtri per utente e tipo evento, con diff prima/dopo
|
||||||
- Pulizia automatica schedulata secondo la retention configurata
|
- Pulizia automatica schedulata secondo la retention configurata
|
||||||
|
|
||||||
### XML Exchange
|
### Backup e import
|
||||||
|
- **Backup completo `.zip`**: dati gestionali, utenti e file caricati (PDF
|
||||||
|
territori, miniature, logo) in un unico pacchetto scaricabile e ripristinabile
|
||||||
|
- Ripristino da pacchetto, con cambio password obbligatorio per tutti gli utenti
|
||||||
|
- Import ed export XML dell'intero set di dati gestionali (senza i file)
|
||||||
- Conversione di dump SQL legacy in XML compatibile
|
- Conversione di dump SQL legacy in XML compatibile
|
||||||
- Import ed export XML dell'intero set di dati gestionali
|
- Import massivo dei PDF dei territori da cartella o archivio ZIP
|
||||||
|
|
||||||
### Zone e tipologie
|
### Zone e tipologie
|
||||||
- Gestione inline: aggiungi, rinomina, attiva/disattiva, elimina
|
- Gestione inline: aggiungi, rinomina, attiva/disattiva, elimina
|
||||||
|
|||||||
66
app/Http/Controllers/Auth/PasswordChangeController.php
Normal file
66
app/Http/Controllers/Auth/PasswordChangeController.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cambio password obbligatorio, imposto dopo il ripristino di un backup.
|
||||||
|
*
|
||||||
|
* Volutamente NON e' un componente Livewire: la pagina deve restare
|
||||||
|
* raggiungibile mentre RequirePasswordChange respinge tutto il resto, incluse
|
||||||
|
* le richieste /livewire/update. Due rotte HTTP normali sono banali da
|
||||||
|
* escludere; distinguere quale componente sta dietro una POST di Livewire no.
|
||||||
|
*/
|
||||||
|
class PasswordChangeController extends Controller
|
||||||
|
{
|
||||||
|
public function show()
|
||||||
|
{
|
||||||
|
return view('auth.password-change', [
|
||||||
|
'user' => auth()->user(),
|
||||||
|
// Chi arriva qui spontaneamente (dal profilo) puo' tornare indietro;
|
||||||
|
// chi e' stato dirottato dal middleware no.
|
||||||
|
'forced' => (bool) auth()->user()->password_change_required,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request)
|
||||||
|
{
|
||||||
|
// La password attuale NON viene richiesta, ed e' una scelta.
|
||||||
|
//
|
||||||
|
// Il ripristino sovrascrive gli hash con quelli del backup: subito dopo,
|
||||||
|
// la "password attuale" di un account e' quella che aveva all'epoca del
|
||||||
|
// backup, che l'utente puo' benissimo non ricordare — l'amministratore
|
||||||
|
// che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori
|
||||||
|
// proprio le persone che questa schermata deve sbloccare. La garanzia
|
||||||
|
// qui e' la sessione: si arriva a questa pagina gia' autenticati.
|
||||||
|
$validated = $request->validate([
|
||||||
|
'password' => ['required', 'confirmed', Password::min(8)],
|
||||||
|
], [
|
||||||
|
'password.required' => 'Inserisci la nuova password.',
|
||||||
|
'password.confirmed' => 'Le due password non coincidono.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
// Il cast `hashed` sul modello cifra il valore al salvataggio.
|
||||||
|
$user->update([
|
||||||
|
'password' => $validated['password'],
|
||||||
|
'password_change_required' => false,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// La sessione corrente sopravvive al cambio, le altre no: dopo un
|
||||||
|
// ripristino le vecchie credenziali sono da considerarsi compromesse e
|
||||||
|
// una sessione aperta altrove lo e' altrettanto.
|
||||||
|
$request->session()->regenerate();
|
||||||
|
$user->setRememberToken(null);
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
activity()->causedBy($user)->log('password changed');
|
||||||
|
|
||||||
|
return redirect()->route('dashboard')
|
||||||
|
->with('success', 'Password aggiornata. Da ora accedi con quella nuova.');
|
||||||
|
}
|
||||||
|
}
|
||||||
137
app/Http/Controllers/Settings/BackupController.php
Normal file
137
app/Http/Controllers/Settings/BackupController.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Settings;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Livewire\Settings\XmlExchange;
|
||||||
|
use App\Services\BackupArchive;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup e ripristino dell'intera applicazione in un unico pacchetto .zip:
|
||||||
|
* dati gestionali, utenti e file caricati.
|
||||||
|
*
|
||||||
|
* Non e' un componente Livewire come il resto della pagina Import: lo scarico
|
||||||
|
* di un Livewire streamDownload passa il file base64 dentro la risposta JSON,
|
||||||
|
* e un archivio con tutti i PDF dei territori non ci sta. Sono due rotte HTTP
|
||||||
|
* normali, gia' protette da `settings.manage` nel gruppo di rotte.
|
||||||
|
*/
|
||||||
|
class BackupController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly BackupArchive $archive,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tetto di tempo per comprimere/estrarre un archivio con molti PDF.
|
||||||
|
* Sopra c'e' comunque `fastcgi_read_timeout 300` in docker/nginx/
|
||||||
|
* default.conf: alzare questo valore da solo non serve a nulla.
|
||||||
|
*/
|
||||||
|
private const SECONDI_MAX = 300;
|
||||||
|
|
||||||
|
public function download(XmlExchange $exchange)
|
||||||
|
{
|
||||||
|
set_time_limit(self::SECONDI_MAX);
|
||||||
|
|
||||||
|
$path = $this->archive->create($exchange->currentXmlBackup());
|
||||||
|
|
||||||
|
$name = 'termanager2-backup-' . now()->format('Ymd-His') . '.zip';
|
||||||
|
|
||||||
|
return response()
|
||||||
|
->download($path, $name, ['Content-Type' => 'application/zip'])
|
||||||
|
->deleteFileAfterSend(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ripristina un pacchetto di backup.
|
||||||
|
*
|
||||||
|
* Ordine deliberato: i file vengono prima estratti in staging (cosi' un
|
||||||
|
* archivio corrotto fallisce mentre i dati correnti sono ancora intatti),
|
||||||
|
* poi si riscrive il database in transazione, poi si sostituiscono i file.
|
||||||
|
* L'inverso lascerebbe il disco pieno di file di un'installazione e il
|
||||||
|
* database di un'altra.
|
||||||
|
*/
|
||||||
|
public function restore(Request $request, XmlExchange $exchange)
|
||||||
|
{
|
||||||
|
set_time_limit(self::SECONDI_MAX);
|
||||||
|
|
||||||
|
// 256 MB e' il tetto reale della pila, non una scelta di questa
|
||||||
|
// validazione: piu' in su intervengono prima `client_max_body_size` di
|
||||||
|
// nginx e `upload_max_filesize`/`post_max_size` di PHP, che rifiutano
|
||||||
|
// la richiesta con un 413 opaco invece di un messaggio leggibile.
|
||||||
|
$request->validate([
|
||||||
|
'backupZip' => ['required', 'file', 'mimes:zip', 'max:256000'],
|
||||||
|
], [
|
||||||
|
'backupZip.required' => 'Seleziona il pacchetto di backup da ripristinare.',
|
||||||
|
'backupZip.mimes' => 'Il pacchetto di backup deve essere un file .zip.',
|
||||||
|
'backupZip.max' => 'Il pacchetto supera i 256 MB accettati dal server.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$staging = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$extracted = $this->archive->extract($request->file('backupZip')->getRealPath());
|
||||||
|
$staging = $extracted['staging'];
|
||||||
|
|
||||||
|
$result = $exchange->importXmlFromContent($extracted['xml']);
|
||||||
|
|
||||||
|
if (isset($result['error'])) {
|
||||||
|
return back()->withErrors(['backupZip' => $result['error']]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileCount = $this->archive->restoreFiles($staging);
|
||||||
|
|
||||||
|
$utentiBloccati = $this->requirePasswordChangeForEveryone();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
|
||||||
|
|
||||||
|
return back()->withErrors(['backupZip' => $e->getMessage()]);
|
||||||
|
} finally {
|
||||||
|
if ($staging !== null) {
|
||||||
|
$this->archive->deleteDirectory($staging);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = $result['stats'];
|
||||||
|
|
||||||
|
$message = sprintf(
|
||||||
|
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. '
|
||||||
|
. 'Per sicurezza tutti i %d account devono impostare una nuova password.',
|
||||||
|
$stats['territori_importati'] ?? 0,
|
||||||
|
$stats['proclamatori_importati'] ?? 0,
|
||||||
|
$stats['assegnazioni_importate'] ?? 0,
|
||||||
|
$fileCount,
|
||||||
|
$utentiBloccati
|
||||||
|
);
|
||||||
|
|
||||||
|
return redirect()->route('password.change')
|
||||||
|
->with('success', $message)
|
||||||
|
->with('importStats', $stats)
|
||||||
|
->with('importIssues', $result['issues']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blocca ogni account finche' non sceglie una password nuova,
|
||||||
|
* l'amministratore che sta ripristinando compreso.
|
||||||
|
*
|
||||||
|
* Il pacchetto contiene gli hash bcrypt di tutti gli utenti: chi ha avuto
|
||||||
|
* il file fra le mani ha potuto tentarne il crack con calma, quindi dopo un
|
||||||
|
* ripristino nessuna delle vecchie password si puo' considerare segreta.
|
||||||
|
* Anche i remember_token vengono azzerati: sono credenziali persistenti a
|
||||||
|
* tutti gli effetti e stanno pure loro nel backup.
|
||||||
|
*
|
||||||
|
* Scritto con il query builder di proposito — un update di massa su
|
||||||
|
* Eloquent qui non aggiungerebbe nulla e toccherebbe updated_at su tutte le
|
||||||
|
* righe appena importate con i loro timestamp originali.
|
||||||
|
*/
|
||||||
|
private function requirePasswordChangeForEveryone(): int
|
||||||
|
{
|
||||||
|
return DB::table('users')->update([
|
||||||
|
'password_change_required' => true,
|
||||||
|
'remember_token' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Http/Middleware/RequirePasswordChange.php
Normal file
48
app/Http/Middleware/RequirePasswordChange.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chi ha `password_change_required` non puo' usare l'applicazione finche' non
|
||||||
|
* imposta una password nuova.
|
||||||
|
*
|
||||||
|
* Il flag lo accende il ripristino di un backup su TUTTI gli account: il
|
||||||
|
* pacchetto contiene gli hash delle password, quindi da quel momento nessuna
|
||||||
|
* delle vecchie e' piu' un segreto.
|
||||||
|
*/
|
||||||
|
class RequirePasswordChange
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Le sole rotte raggiungibili con il flag acceso: la schermata di cambio
|
||||||
|
* password, il suo invio e l'uscita. Tutto il resto — comprese le POST di
|
||||||
|
* Livewire, che altrimenti lascerebbero operare da una scheda gia' aperta —
|
||||||
|
* viene dirottato.
|
||||||
|
*/
|
||||||
|
private const ROTTE_AMMESSE = [
|
||||||
|
'password.change',
|
||||||
|
'password.change.store',
|
||||||
|
'logout',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if (! $user || ! $user->password_change_required) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($request->routeIs(self::ROTTE_AMMESSE)) {
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vale anche per le POST di /livewire/update: il client segue la
|
||||||
|
// risposta redirezionata e porta la scheda sulla pagina di cambio
|
||||||
|
// password (vedi `response.redirected` in livewire.esm.js).
|
||||||
|
return redirect()->route('password.change');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,14 +135,23 @@ class XmlExchange extends Component
|
|||||||
|
|
||||||
public function exportCurrentAsXml()
|
public function exportCurrentAsXml()
|
||||||
{
|
{
|
||||||
$dataset = $this->currentDataset();
|
$xml = $this->currentXmlBackup();
|
||||||
$xml = $this->datasetToXml($dataset, 'current-app-export');
|
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($xml) {
|
return response()->streamDownload(function () use ($xml) {
|
||||||
echo $xml;
|
echo $xml;
|
||||||
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
|
}, 'termanager-export.xml', ['Content-Type' => 'application/xml; charset=UTF-8']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'export XML completo dei dati correnti. E' lo stesso contenuto che
|
||||||
|
* finisce in data.xml dentro il pacchetto di backup .zip, dove viaggia
|
||||||
|
* insieme ai file caricati.
|
||||||
|
*/
|
||||||
|
public function currentXmlBackup(): string
|
||||||
|
{
|
||||||
|
return $this->datasetToXml($this->currentDataset(), 'current-app-export');
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
if (session()->has('importStats')) {
|
if (session()->has('importStats')) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class User extends Authenticatable
|
|||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
|
'password_change_required',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
@@ -29,6 +30,7 @@ class User extends Authenticatable
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'password_change_required' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
322
app/Services/BackupArchive.php
Normal file
322
app/Services/BackupArchive.php
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
use ZipArchive;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pacchetto di backup completo: i dati gestionali (XML) e i file caricati
|
||||||
|
* (PDF dei territori, miniature, logo) in un unico .zip.
|
||||||
|
*
|
||||||
|
* L'XML da solo non basta a ricostruire l'installazione: `pdf_path` e
|
||||||
|
* `thumbnail_path` sono percorsi sul disco `public`, quindi un ripristino su
|
||||||
|
* volumi vuoti lascerebbe ogni territorio con un riferimento a un file che non
|
||||||
|
* c'e' — link pubblici a 404 e miniature rotte.
|
||||||
|
*
|
||||||
|
* Struttura dell'archivio:
|
||||||
|
*
|
||||||
|
* manifest.json formato, data, conteggi (letto per validare il file)
|
||||||
|
* data.xml lo stesso XML dell'export "Esporta XML"
|
||||||
|
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso
|
||||||
|
* relativo che sta in pdf_path/thumbnail_path/logo_path
|
||||||
|
*/
|
||||||
|
class BackupArchive
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di
|
||||||
|
* quello che sa leggere invece di ripristinarli a meta'.
|
||||||
|
*/
|
||||||
|
public const FORMAT_VERSION = 1;
|
||||||
|
|
||||||
|
private const ENTRY_MANIFEST = 'manifest.json';
|
||||||
|
private const ENTRY_DATA = 'data.xml';
|
||||||
|
private const PREFIX_STORAGE = 'storage/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza
|
||||||
|
* questi controlli un file da pochi MB puo' espandersi fino a riempire il
|
||||||
|
* volume o esaurire la memoria del processo (zip bomb).
|
||||||
|
*/
|
||||||
|
private const MAX_ENTRIES = 20000;
|
||||||
|
private const MAX_ENTRY_BYTES = 100 * 1024 * 1024;
|
||||||
|
private const MAX_TOTAL_BYTES = 3 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Costruisce il pacchetto e ne restituisce il percorso assoluto. Il file e'
|
||||||
|
* temporaneo: chi chiama lo consegna al browser e lo cancella.
|
||||||
|
*
|
||||||
|
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
|
||||||
|
*/
|
||||||
|
public function create(string $xml): string
|
||||||
|
{
|
||||||
|
$disk = Storage::disk('public');
|
||||||
|
$files = $disk->allFiles();
|
||||||
|
|
||||||
|
$path = storage_path('app/backup-' . Str::uuid() . '.zip');
|
||||||
|
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
|
||||||
|
throw new RuntimeException('Impossibile creare il file di backup.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$zip->addFromString(self::ENTRY_DATA, $xml);
|
||||||
|
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
||||||
|
'format_version' => self::FORMAT_VERSION,
|
||||||
|
'application' => 'TerManager2',
|
||||||
|
'generated_at' => now()->toIso8601String(),
|
||||||
|
'app_url' => (string) config('app.url'),
|
||||||
|
'files_count' => count($files),
|
||||||
|
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||||
|
|
||||||
|
// addFile() legge dal disco in fase di close(): i PDF non passano
|
||||||
|
// mai interi per la memoria del processo, per quanto sia grande
|
||||||
|
// l'archivio.
|
||||||
|
foreach ($files as $relativePath) {
|
||||||
|
$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$zip->close();
|
||||||
|
@unlink($path);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $zip->close()) {
|
||||||
|
@unlink($path);
|
||||||
|
|
||||||
|
throw new RuntimeException('Scrittura del file di backup non riuscita.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apre il pacchetto, ne verifica la struttura ed estrae i file in una
|
||||||
|
* cartella di staging, restituendo l'XML e il percorso dello staging.
|
||||||
|
*
|
||||||
|
* I file vengono messi da parte *prima* di toccare il database: se
|
||||||
|
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
||||||
|
* dati correnti sono ancora intatti.
|
||||||
|
*
|
||||||
|
* @return array{xml: string, staging: string, files: int}
|
||||||
|
*/
|
||||||
|
public function extract(string $zipPath): array
|
||||||
|
{
|
||||||
|
$zip = new ZipArchive();
|
||||||
|
if ($zip->open($zipPath) !== true) {
|
||||||
|
throw new RuntimeException('Il file non e\' un archivio ZIP leggibile.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$staging = storage_path('app/backup-restore/' . Str::uuid());
|
||||||
|
|
||||||
|
try {
|
||||||
|
$manifestRaw = $zip->getFromName(self::ENTRY_MANIFEST);
|
||||||
|
if ($manifestRaw === false) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Archivio non riconosciuto: manca ' . self::ENTRY_MANIFEST . '. '
|
||||||
|
. 'Carica un pacchetto generato da "Scarica backup completo".'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = json_decode($manifestRaw, true);
|
||||||
|
$formatVersion = is_array($manifest) ? (int) ($manifest['format_version'] ?? 0) : 0;
|
||||||
|
|
||||||
|
if ($formatVersion < 1) {
|
||||||
|
throw new RuntimeException('Il manifest del backup non e\' valido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($formatVersion > self::FORMAT_VERSION) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'Il backup e\' stato creato con una versione piu\' recente dell\'applicazione '
|
||||||
|
. '(formato ' . $formatVersion . ', qui si legge fino al ' . self::FORMAT_VERSION . ').'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$xml = $zip->getFromName(self::ENTRY_DATA);
|
||||||
|
if ($xml === false || trim($xml) === '') {
|
||||||
|
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($zip->numFiles > self::MAX_ENTRIES) {
|
||||||
|
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalBytes = 0;
|
||||||
|
$extracted = 0;
|
||||||
|
|
||||||
|
for ($index = 0; $index < $zip->numFiles; $index++) {
|
||||||
|
$entryName = $zip->getNameIndex($index);
|
||||||
|
|
||||||
|
if ($entryName === false || str_ends_with($entryName, '/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE));
|
||||||
|
$safePath = $this->safeRelativePath($relativePath);
|
||||||
|
|
||||||
|
if ($safePath === null) {
|
||||||
|
throw new RuntimeException('Percorso di file non ammesso nell\'archivio: ' . $entryName);
|
||||||
|
}
|
||||||
|
|
||||||
|
// La dimensione dichiarata si legge *prima* di estrarre: e'
|
||||||
|
// l'unico momento in cui si puo' rifiutare un'entry che, una
|
||||||
|
// volta decompressa, riempirebbe il disco.
|
||||||
|
$size = (int) ($zip->statIndex($index)['size'] ?? 0);
|
||||||
|
|
||||||
|
if ($size > self::MAX_ENTRY_BYTES) {
|
||||||
|
throw new RuntimeException(sprintf(
|
||||||
|
'Il file "%s" nell\'archivio supera %d MB.',
|
||||||
|
$safePath,
|
||||||
|
self::MAX_ENTRY_BYTES / 1048576
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalBytes += $size;
|
||||||
|
if ($totalBytes > self::MAX_TOTAL_BYTES) {
|
||||||
|
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$destination = $staging . '/' . $safePath;
|
||||||
|
$directory = dirname($destination);
|
||||||
|
|
||||||
|
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
||||||
|
throw new RuntimeException('Impossibile preparare la cartella di ripristino.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stream = $zip->getStream($entryName);
|
||||||
|
if ($stream === false) {
|
||||||
|
throw new RuntimeException('Impossibile leggere "' . $safePath . '" dall\'archivio.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = fopen($destination, 'wb');
|
||||||
|
if ($target === false) {
|
||||||
|
fclose($stream);
|
||||||
|
|
||||||
|
throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.');
|
||||||
|
}
|
||||||
|
|
||||||
|
stream_copy_to_stream($stream, $target);
|
||||||
|
fclose($stream);
|
||||||
|
fclose($target);
|
||||||
|
|
||||||
|
$extracted++;
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$zip->close();
|
||||||
|
$this->deleteDirectory($staging);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$zip->close();
|
||||||
|
|
||||||
|
return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sostituisce il contenuto del disco `public` con quello estratto.
|
||||||
|
*
|
||||||
|
* La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
|
||||||
|
* righe del database, che il ripristino ha appena riscritto per intero.
|
||||||
|
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
||||||
|
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
|
||||||
|
*/
|
||||||
|
public function restoreFiles(string $staging): int
|
||||||
|
{
|
||||||
|
$disk = Storage::disk('public');
|
||||||
|
|
||||||
|
foreach ($disk->directories() as $directory) {
|
||||||
|
$disk->deleteDirectory($directory);
|
||||||
|
}
|
||||||
|
$disk->delete($disk->files());
|
||||||
|
|
||||||
|
if (! is_dir($staging)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$restored = 0;
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $file) {
|
||||||
|
/** @var \SplFileInfo $file */
|
||||||
|
if (! $file->isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1));
|
||||||
|
|
||||||
|
$handle = fopen($file->getPathname(), 'rb');
|
||||||
|
if ($handle === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$disk->writeStream($relativePath, $handle);
|
||||||
|
|
||||||
|
if (is_resource($handle)) {
|
||||||
|
fclose($handle);
|
||||||
|
}
|
||||||
|
|
||||||
|
$restored++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $restored;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteDirectory(string $path): void
|
||||||
|
{
|
||||||
|
if (! is_dir($path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$iterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($iterator as $item) {
|
||||||
|
/** @var \SplFileInfo $item */
|
||||||
|
$item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
|
||||||
|
@rmdir($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizza il percorso di un'entry e rifiuta tutto cio' che potrebbe
|
||||||
|
* scrivere fuori dalla cartella di destinazione (zip slip): percorsi
|
||||||
|
* assoluti, lettere di unita' Windows e qualunque segmento "..".
|
||||||
|
*/
|
||||||
|
private function safeRelativePath(string $relativePath): ?string
|
||||||
|
{
|
||||||
|
$normalized = str_replace('\\', '/', trim($relativePath));
|
||||||
|
|
||||||
|
if ($normalized === '' || str_starts_with($normalized, '/') || preg_match('/^[a-zA-Z]:/', $normalized)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments = [];
|
||||||
|
foreach (explode('/', $normalized) as $segment) {
|
||||||
|
if ($segment === '' || $segment === '.') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($segment === '..') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$segments[] = $segment;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $segments === [] ? null : implode('/', $segments);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
return array_values(array_filter([$host, 'localhost', '127.0.0.1']));
|
return array_values(array_filter([$host, 'localhost', '127.0.0.1']));
|
||||||
}, subdomains: false);
|
}, subdomains: false);
|
||||||
|
|
||||||
|
// Dopo il ripristino di un backup ogni account resta bloccato finche'
|
||||||
|
// non imposta una password nuova. In coda al gruppo web, quindi la
|
||||||
|
// sessione e' gia' avviata e l'utente risolto quando gira.
|
||||||
|
$middleware->web(append: [
|
||||||
|
\App\Http\Middleware\RequirePasswordChange::class,
|
||||||
|
]);
|
||||||
|
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
'role' => \Spatie\Permission\Middleware\RoleMiddleware::class,
|
||||||
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
'permission' => \Spatie\Permission\Middleware\PermissionMiddleware::class,
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marca gli account che devono impostare una nuova password prima di poter
|
||||||
|
* usare l'applicazione.
|
||||||
|
*
|
||||||
|
* Serve al ripristino da backup: l'archivio contiene gli hash delle password di
|
||||||
|
* tutti gli utenti, quindi chi ha avuto in mano il file ha potuto tentarne il
|
||||||
|
* crack offline. Dopo un restore le vecchie credenziali vanno considerate
|
||||||
|
* compromesse e l'accesso resta bloccato finche' non se ne sceglie una nuova.
|
||||||
|
*/
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->boolean('password_change_required')->default(false)->after('password');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('password_change_required');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
77
resources/views/auth/password-change.blade.php
Normal file
77
resources/views/auth/password-change.blade.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<x-layouts.guest title="Cambio password">
|
||||||
|
<div class="bg-white rounded-xl shadow-lg border border-gray-200 p-6 space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">
|
||||||
|
{{ $forced ? 'Imposta una nuova password' : 'Cambia password' }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Account: <span class="font-medium text-gray-700 break-all">{{ $user->email }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if(session('success'))
|
||||||
|
<div class="rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
|
||||||
|
{{ session('success') }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($forced)
|
||||||
|
<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>
|
||||||
|
Il pacchetto di backup contiene le password di tutti gli account, quindi
|
||||||
|
le vecchie credenziali non sono piu’ considerate sicure. Scegli una
|
||||||
|
nuova password per continuare: fino ad allora il resto dell’applicazione
|
||||||
|
resta bloccato.
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($errors->any())
|
||||||
|
<div class="rounded-lg bg-red-50 border border-red-200 px-4 py-2.5 text-sm text-red-800">
|
||||||
|
<ul class="list-disc list-inside space-y-0.5">
|
||||||
|
@foreach($errors->all() as $error)
|
||||||
|
<li>{{ $error }}</li>
|
||||||
|
@endforeach
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<form method="POST" action="{{ route('password.change.store') }}" class="space-y-4">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
{{-- L'email in un campo nascosto: senza, i gestori di password non
|
||||||
|
sanno a quale account associare la credenziale che salvano. --}}
|
||||||
|
<input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label>
|
||||||
|
<input type="password" name="password" id="password" required autofocus autocomplete="new-password"
|
||||||
|
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||||
|
<p class="mt-1 text-xs text-gray-500">Almeno 8 caratteri.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">Conferma nuova password</label>
|
||||||
|
<input type="password" name="password_confirmation" id="password_confirmation" required autocomplete="new-password"
|
||||||
|
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full inline-flex items-center justify-center px-4 py-2.5 text-sm font-medium text-white rounded-lg transition"
|
||||||
|
style="background:#4f46e5" onmouseover="this.style.background='#4338ca'" onmouseout="this.style.background='#4f46e5'">
|
||||||
|
Salva la nuova password
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="flex justify-between text-xs text-gray-500 pt-1 border-t border-gray-100">
|
||||||
|
@if($forced)
|
||||||
|
<span>Non sei tu? Esci e accedi con un altro account.</span>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('dashboard') }}" class="hover:text-indigo-600">Torna alla home</a>
|
||||||
|
@endif
|
||||||
|
<form method="POST" action="{{ route('logout') }}">
|
||||||
|
@csrf
|
||||||
|
<button type="submit" class="hover:text-indigo-600">Esci</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-layouts.guest>
|
||||||
@@ -144,9 +144,9 @@
|
|||||||
Tipologie
|
Tipologie
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ route('xml.exchange') }}"
|
<a href="{{ route('xml.exchange') }}"
|
||||||
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('xml.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
class="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg {{ request()->routeIs('xml.*', 'backup.*') ? 'bg-indigo-50 text-indigo-700' : 'text-gray-700 hover:bg-gray-100' }}">
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16v4m0 0l-3-3m3 3l3-3M4 12a8 8 0 1116 0v1a3 3 0 01-3 3h-1M7 16H6a2 2 0 01-2-2v-2"/></svg>
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 16v4m0 0l-3-3m3 3l3-3M4 12a8 8 0 1116 0v1a3 3 0 01-3 3h-1M7 16H6a2 2 0 01-2-2v-2"/></svg>
|
||||||
Import
|
Backup e import
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@endcan
|
@endcan
|
||||||
|
|||||||
@@ -1,7 +1,64 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Import</h1>
|
<h1 class="text-2xl font-bold text-gray-900">Backup e import</h1>
|
||||||
<p class="text-sm text-gray-500 mt-1">Centro importazioni: PDF territori, conversione legacy SQL, import XML 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>
|
||||||
|
|
||||||
|
{{-- Backup completo: e' la sezione che serve nel 99% dei casi, quindi sta
|
||||||
|
in cima. Le sezioni XML piu' sotto restano per gli usi parziali e per
|
||||||
|
la conversione dei dump legacy. --}}
|
||||||
|
<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">
|
||||||
|
<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>
|
||||||
|
|
||||||
<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"
|
||||||
@@ -341,8 +398,12 @@
|
|||||||
@endif
|
@endif
|
||||||
|
|
||||||
<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">
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Export XML</h2>
|
<h2 class="text-lg font-semibold text-gray-900">Export XML (solo dati)</h2>
|
||||||
<p class="text-xs text-gray-500">Esporta <strong>tutti</strong> i dati correnti dell'app in XML (backup completo, reimportabile).</p>
|
<p class="text-xs text-gray-500">
|
||||||
|
Esporta <strong>tutti</strong> i dati correnti dell'app in XML, reimportabile qui sopra.
|
||||||
|
<strong>Non</strong> include i file caricati: ripristinando solo questo, i territori restano
|
||||||
|
senza PDF né miniature. Per un backup davvero completo usa il pacchetto .zip in cima alla pagina.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
|
<div class="rounded-lg border border-amber-300 bg-amber-50 p-3 text-xs text-amber-800">
|
||||||
<strong>Dati sensibili.</strong> Il file include gli utenti con le password (hash) e i nomi dei proclamatori in chiaro.
|
<strong>Dati sensibili.</strong> Il file include gli utenti con le password (hash) e i nomi dei proclamatori in chiaro.
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use App\Http\Controllers\Settings\BackupController;
|
||||||
use App\Http\Controllers\Settings\TerritoryPdfImportController;
|
use App\Http\Controllers\Settings\TerritoryPdfImportController;
|
||||||
use App\Http\Controllers\Settings\XmlExchangeUploadController;
|
use App\Http\Controllers\Settings\XmlExchangeUploadController;
|
||||||
use App\Http\Controllers\AssignmentPdfController;
|
use App\Http\Controllers\AssignmentPdfController;
|
||||||
use App\Http\Controllers\ShortPdfLinkController;
|
use App\Http\Controllers\ShortPdfLinkController;
|
||||||
use App\Http\Controllers\Auth\LoginController;
|
use App\Http\Controllers\Auth\LoginController;
|
||||||
|
use App\Http\Controllers\Auth\PasswordChangeController;
|
||||||
use App\Livewire\Home;
|
use App\Livewire\Home;
|
||||||
use App\Livewire\Territori\TerritorioIndex;
|
use App\Livewire\Territori\TerritorioIndex;
|
||||||
use App\Livewire\Territori\TerritorioCreate;
|
use App\Livewire\Territori\TerritorioCreate;
|
||||||
@@ -70,6 +72,11 @@ Route::middleware('auth')->group(function () {
|
|||||||
// Profilo personale (ogni utente loggato)
|
// Profilo personale (ogni utente loggato)
|
||||||
Route::get('profilo', Profile::class)->name('profile');
|
Route::get('profilo', Profile::class)->name('profile');
|
||||||
|
|
||||||
|
// Cambio password: raggiungibile anche quando RequirePasswordChange
|
||||||
|
// blocca tutto il resto (vedi l'elenco di rotte ammesse nel middleware).
|
||||||
|
Route::get('cambia-password', [PasswordChangeController::class, 'show'])->name('password.change');
|
||||||
|
Route::post('cambia-password', [PasswordChangeController::class, 'store'])->name('password.change.store');
|
||||||
|
|
||||||
// Territori — visualizzazione (lista + dettaglio) per tutti gli utenti
|
// Territori — visualizzazione (lista + dettaglio) per tutti gli utenti
|
||||||
// operativi, cioè chi può assegnare i territori.
|
// operativi, cioè chi può assegnare i territori.
|
||||||
Route::middleware('permission:territori.assign')->group(function () {
|
Route::middleware('permission:territori.assign')->group(function () {
|
||||||
@@ -155,6 +162,10 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
|
Route::post('xml-exchange/convert-sql', [XmlExchangeUploadController::class, 'convertSqlToXml'])->name('xml.convert-sql');
|
||||||
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.
|
||||||
|
Route::get('backup/download', [BackupController::class, 'download'])->name('backup.download');
|
||||||
|
Route::post('backup/restore', [BackupController::class, 'restore'])->name('backup.restore');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Privacy / Informativa GDPR — visibile solo all'amministratore.
|
// Privacy / Informativa GDPR — visibile solo all'amministratore.
|
||||||
|
|||||||
Reference in New Issue
Block a user