++ add backup totale applicativo
This commit is contained in:
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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user