492 lines
18 KiB
PHP
492 lines
18 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}
|
|
{--require-drive : Fallisce se il backup Google Drive non viene completato}
|
|
{--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(),
|
|
'copied_files' => $copiedCount,
|
|
'tables' => $dbSummary,
|
|
'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;
|
|
$requireDrive = (bool) $this->option('require-drive');
|
|
if ((bool) $this->option('drive')) {
|
|
$adminId = (int) $this->option('admin-id');
|
|
if ($adminId <= 0) {
|
|
$this->warn('Drive upload saltato: --admin-id mancante.');
|
|
if ($requireDrive) {
|
|
return self::FAILURE;
|
|
}
|
|
} 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.');
|
|
if ($requireDrive) {
|
|
return self::FAILURE;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$latestPayload['drive'] = $driveInfo;
|
|
file_put_contents($latestPath, json_encode($latestPayload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
|
|
|
$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);
|
|
}
|
|
|
|
$yearRootName = trim((string) config('netgescon.google.drive_template_year_root', ''));
|
|
$yearModelName = trim((string) config('netgescon.google.drive_template_year_model', ''));
|
|
|
|
if ($yearRootName === '' || $yearModelName === '') {
|
|
return;
|
|
}
|
|
|
|
$yearRootId = $this->ensureDriveFolder($token, $yearRootName, $templateRoot);
|
|
if ($yearRootId === null) {
|
|
return;
|
|
}
|
|
|
|
$yearModelId = $this->ensureDriveFolder($token, $yearModelName, $yearRootId);
|
|
if ($yearModelId === null) {
|
|
return;
|
|
}
|
|
|
|
foreach ($folders as $folder) {
|
|
$name = trim((string) $folder);
|
|
if ($name === '') {
|
|
continue;
|
|
}
|
|
$this->ensureDriveFolder($token, $name, $yearModelId);
|
|
}
|
|
}
|
|
|
|
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)
|
|
->withBody($content, 'multipart/related; boundary=' . $boundary)
|
|
->send('POST', 'https://www.googleapis.com/upload/drive/v3/files?uploadType=multipart&supportsAllDrives=true&fields=id,name,webViewLink');
|
|
|
|
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', ''));
|
|
$refreshToken = trim((string) Arr::get($oauth, 'refresh_token', ''));
|
|
if ($refreshToken === '' && $accessToken !== '') {
|
|
return $accessToken;
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|