netgescon-day0/app/Console/Commands/NetgesconPreupdateBackupCommand.php

457 lines
17 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\Amministratore;
use Illuminate\Console\Command;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use ZipArchive;
class NetgesconPreupdateBackupCommand extends Command
{
protected $signature = 'netgescon:preupdate-backup
{--admin-id= : ID amministratore per backup su Google Drive}
{--differential : Copia solo file cambiati rispetto all ultimo snapshot}
{--drive : Carica backup anche su Google Drive}
{--tag=auto : Etichetta operazione (es. auto/manual)}
{--tables=users,amministratori,stabili,fornitori,tickets,ticket_messages,ticket_attachments,rubrica_universale,fornitore_dipendenti : Tabelle da esportare}';
protected $description = 'Crea backup pre-update con snapshot differenziale, indice record e upload opzionale su Google Drive.';
/** @var array<string,mixed> */
private array $latestManifest = [];
public function handle(): int
{
$stamp = now()->format('Ymd-His');
$tag = trim((string) $this->option('tag')) ?: 'auto';
$snapshotId = $stamp . '-' . Str::slug($tag, '-');
$root = storage_path('app/update-backups');
$snapshotDir = $root . '/snapshots/' . $snapshotId;
$filesDir = $snapshotDir . '/files';
$dbDir = $snapshotDir . '/db';
$metaDir = $snapshotDir . '/meta';
File::ensureDirectoryExists($filesDir);
File::ensureDirectoryExists($dbDir);
File::ensureDirectoryExists($metaDir);
$this->line('Backup snapshot: ' . $snapshotId);
$latestPath = $root . '/latest-manifest.json';
if (is_file($latestPath)) {
$decoded = json_decode((string) file_get_contents($latestPath), true);
if (is_array($decoded)) {
$this->latestManifest = $decoded;
}
}
$manifest = $this->buildFileManifest();
$differential = (bool) $this->option('differential');
$copiedCount = $this->copySnapshotFiles($manifest, $filesDir, $differential);
$tables = array_values(array_filter(array_map('trim', explode(',', (string) $this->option('tables'))), fn(string $v): bool => $v !== ''));
$dbSummary = $this->exportTables($tables, $dbDir, $metaDir);
$meta = [
'snapshot_id' => $snapshotId,
'created_at' => now()->toIso8601String(),
'tag' => $tag,
'differential' => $differential,
'copied_files' => $copiedCount,
'tables' => $dbSummary,
'node_code' => (string) config('distribution.node_code', ''),
'app_version' => (string) config('netgescon.version', '0.0.0'),
];
file_put_contents($metaDir . '/snapshot-meta.json', json_encode($meta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
file_put_contents($metaDir . '/files-manifest.json', json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
if (is_file(base_path('.env'))) {
File::copy(base_path('.env'), $metaDir . '/.env.backup');
}
if (is_file(base_path('VERSION'))) {
File::copy(base_path('VERSION'), $metaDir . '/VERSION.backup');
}
$zipPath = $root . '/archives/' . $snapshotId . '.zip';
File::ensureDirectoryExists(dirname($zipPath));
$this->zipDirectory($snapshotDir, $zipPath);
$latestPayload = [
'snapshot_id' => $snapshotId,
'snapshot_dir' => $snapshotDir,
'zip_path' => $zipPath,
'created_at' => now()->toIso8601String(),
'files_manifest' => $manifest,
];
file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$this->info('Backup locale creato: ' . $zipPath);
$driveInfo = null;
if ((bool) $this->option('drive')) {
$adminId = (int) $this->option('admin-id');
if ($adminId <= 0) {
$this->warn('Drive upload saltato: --admin-id mancante.');
} else {
$driveInfo = $this->uploadBackupToDrive($adminId, $zipPath, $snapshotId);
if (is_array($driveInfo)) {
$this->info('Backup caricato su Drive: ' . (string) ($driveInfo['webViewLink'] ?? $driveInfo['id'] ?? 'ok'));
} else {
$this->warn('Upload Drive non riuscito.');
}
}
}
$result = [
'snapshot_id' => $snapshotId,
'zip_path' => $zipPath,
'drive' => $driveInfo,
'copied_files' => $copiedCount,
'tables' => $dbSummary,
];
$this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
return self::SUCCESS;
}
/** @return array<string,string> */
private function buildFileManifest(): array
{
$roots = ['app', 'bootstrap', 'config', 'database', 'resources', 'routes', 'public'];
$manifest = [];
foreach ($roots as $root) {
$abs = base_path($root);
if (! is_dir($abs)) {
continue;
}
$files = File::allFiles($abs);
foreach ($files as $file) {
$path = $file->getPathname();
$rel = ltrim(str_replace(base_path(), '', $path), '/');
$hash = hash_file('sha256', $path) ?: '';
if ($hash === '') {
continue;
}
$manifest[$rel] = $hash;
}
}
ksort($manifest);
return $manifest;
}
/**
* @param array<string,string> $manifest
*/
private function copySnapshotFiles(array $manifest, string $targetRoot, bool $differential): int
{
$old = [];
if ($differential && is_array($this->latestManifest['files_manifest'] ?? null)) {
$old = (array) $this->latestManifest['files_manifest'];
}
$count = 0;
foreach ($manifest as $rel => $hash) {
$unchanged = $differential && isset($old[$rel]) && (string) $old[$rel] === (string) $hash;
if ($unchanged) {
continue;
}
$src = base_path($rel);
if (! is_file($src)) {
continue;
}
$dst = $targetRoot . '/' . $rel;
File::ensureDirectoryExists(dirname($dst));
File::copy($src, $dst);
$count++;
}
return $count;
}
/**
* @param array<int,string> $tables
* @return array<string,array{rows:int,file:string,index:string}>
*/
private function exportTables(array $tables, string $dbDir, string $metaDir): array
{
$summary = [];
foreach ($tables as $table) {
if (! DB::getSchemaBuilder()->hasTable($table)) {
continue;
}
$columns = DB::getSchemaBuilder()->getColumnListing($table);
$pk = in_array('id', $columns, true) ? 'id' : ($columns[0] ?? 'id');
$file = $dbDir . '/' . $table . '.ndjson';
$indexFile = $metaDir . '/index-' . $table . '.json';
if (is_file($file)) {
@unlink($file);
}
$rowsCount = 0;
$index = [];
DB::table($table)
->orderBy($pk)
->chunk(500, function ($rows) use (&$rowsCount, &$index, $file, $pk): void {
foreach ($rows as $rowObj) {
$row = (array) $rowObj;
$payload = json_encode($row, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (! is_string($payload)) {
continue;
}
$rowHash = sha1($payload);
$pkValue = (string) ($row[$pk] ?? '');
$record = [
'pk' => $pkValue,
'hash' => $rowHash,
'data' => $row,
];
file_put_contents($file, json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL, FILE_APPEND);
if ($pkValue !== '') {
$index[$pkValue] = $rowHash;
}
$rowsCount++;
}
});
file_put_contents($indexFile, json_encode($index, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
$summary[$table] = [
'rows' => $rowsCount,
'file' => $file,
'index' => $indexFile,
];
}
return $summary;
}
private function zipDirectory(string $sourceDir, string $zipPath): void
{
$zip = new ZipArchive();
if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('Impossibile creare archivio zip backup.');
}
$files = File::allFiles($sourceDir);
foreach ($files as $file) {
$absolute = $file->getPathname();
$relative = ltrim(str_replace($sourceDir, '', $absolute), '/');
$zip->addFile($absolute, $relative);
}
$zip->close();
}
/** @return array<string,mixed>|null */
private function uploadBackupToDrive(int $adminId, string $zipPath, string $snapshotId): ?array
{
$admin = Amministratore::query()->find($adminId);
if (! $admin instanceof Amministratore) {
$this->warn('Amministratore non trovato per upload Drive.');
return null;
}
$google = Arr::get($admin->impostazioni ?? [], 'google', []);
$oauth = Arr::get($google, 'oauth', []);
$token = $this->resolveGoogleAccessToken($admin, $google, $oauth);
if ($token === null) {
$this->warn('Token Google non disponibile per upload backup.');
return null;
}
$rootFolderId = trim((string) Arr::get($google, 'drive_backup_root_id', ''));
if ($rootFolderId === '') {
$rootFolderId = 'root';
}
$node = (string) config('distribution.node_code', 'node');
$year = now()->format('Y');
$month = now()->format('m');
$backupsFolder = $this->ensureDriveFolder($token, 'NetGescon-Backups', $rootFolderId);
if ($backupsFolder === null) {
return null;
}
$nodeFolder = $this->ensureDriveFolder($token, $node !== '' ? $node : 'node', $backupsFolder);
$yearFolder = $nodeFolder ? $this->ensureDriveFolder($token, $year, $nodeFolder) : null;
$monthFolder = $yearFolder ? $this->ensureDriveFolder($token, $month, $yearFolder) : null;
if ($monthFolder === null) {
return null;
}
$this->ensureDriveTemplateStructure($token, $rootFolderId);
$fileName = basename($zipPath);
return $this->uploadDriveFile($token, $monthFolder, $fileName, $zipPath, 'application/zip');
}
private function ensureDriveTemplateStructure(string $token, string $rootFolderId): void
{
$templateRoot = $this->ensureDriveFolder($token, 'NetGescon-Template-Cartelle-Condominio', $rootFolderId);
if ($templateRoot === null) {
return;
}
$folders = config('netgescon.google.drive_template_folders', []);
if (! is_array($folders)) {
return;
}
foreach ($folders as $folder) {
$name = trim((string) $folder);
if ($name === '') {
continue;
}
$this->ensureDriveFolder($token, $name, $templateRoot);
}
}
private function ensureDriveFolder(string $token, string $name, string $parentId): ?string
{
$query = sprintf("mimeType='application/vnd.google-apps.folder' and trashed=false and name='%s' and '%s' in parents", str_replace("'", "\\'", $name), $parentId);
$response = Http::withToken($token)
->acceptJson()
->get('https://www.googleapis.com/drive/v3/files', [
'q' => $query,
'fields' => 'files(id,name)',
'pageSize' => 1,
'supportsAllDrives' => 'true',
'includeItemsFromAllDrives' => 'true',
]);
if ($response->successful()) {
$files = $response->json('files');
if (is_array($files) && isset($files[0]['id']) && is_string($files[0]['id'])) {
return $files[0]['id'];
}
}
$create = Http::withToken($token)
->acceptJson()
->post('https://www.googleapis.com/drive/v3/files?supportsAllDrives=true', [
'name' => $name,
'mimeType' => 'application/vnd.google-apps.folder',
'parents' => [$parentId],
]);
if (! $create->successful()) {
$this->warn('Impossibile creare cartella Drive: ' . $name);
return null;
}
$id = $create->json('id');
return is_string($id) ? $id : null;
}
/** @return array<string,mixed>|null */
private function uploadDriveFile(string $token, string $parentId, string $name, string $path, string $mime): ?array
{
$meta = [
'name' => $name,
'parents' => [$parentId],
];
$boundary = 'netgescon_' . Str::random(20);
$content = "--{$boundary}\r\n";
$content .= "Content-Type: application/json; charset=UTF-8\r\n\r\n";
$content .= json_encode($meta, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\r\n";
$content .= "--{$boundary}\r\n";
$content .= "Content-Type: {$mime}\r\n\r\n";
$content .= (string) file_get_contents($path) . "\r\n";
$content .= "--{$boundary}--";
$response = Http::withToken($token)
->withHeaders([
'Content-Type' => 'multipart/related; boundary=' . $boundary,
])
->post('https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink', $content);
if (! $response->successful()) {
$this->warn('Upload Drive fallito: HTTP ' . $response->status());
return null;
}
$json = $response->json();
return is_array($json) ? $json : null;
}
private function resolveGoogleAccessToken(Amministratore $admin, array $google, array $oauth): ?string
{
$accessToken = trim((string) Arr::get($oauth, 'access_token', ''));
if ($accessToken !== '') {
return $accessToken;
}
$refreshToken = trim((string) Arr::get($oauth, 'refresh_token', ''));
if ($refreshToken === '') {
return null;
}
$settingsClientId = trim((string) Arr::get($google, 'client_id', ''));
$settingsClientSecret = trim((string) Arr::get($google, 'client_secret', ''));
$configClientId = trim((string) config('services.google.client_id'));
$configClientSecret = trim((string) config('services.google.client_secret'));
$pairs = [];
if ($settingsClientId !== '' && $settingsClientSecret !== '') {
$pairs[] = [$settingsClientId, $settingsClientSecret];
}
if ($configClientId !== '' && $configClientSecret !== '') {
$pairs[] = [$configClientId, $configClientSecret];
}
foreach ($pairs as [$clientId, $clientSecret]) {
$res = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'client_id' => $clientId,
'client_secret' => $clientSecret,
'refresh_token' => $refreshToken,
'grant_type' => 'refresh_token',
]);
if (! $res->successful()) {
continue;
}
$token = trim((string) $res->json('access_token', ''));
if ($token === '') {
continue;
}
$settings = $admin->impostazioni ?? [];
$settingsGoogle = Arr::get($settings, 'google', []);
$settingsOauth = Arr::get($settingsGoogle, 'oauth', []);
$settingsOauth['access_token'] = $token;
$settingsOauth['expires_in'] = (int) $res->json('expires_in', 3600);
$settingsOauth['refreshed_at'] = now()->toDateTimeString();
$settingsGoogle['oauth'] = $settingsOauth;
$settings['google'] = $settingsGoogle;
$admin->impostazioni = $settings;
$admin->save();
return $token;
}
return null;
}
}