*/ 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 */ 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 $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 $tables * @return array */ 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|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|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; } }