'List files that would be migrated without uploading.', '--force' => 'Re-upload even if the object already exists on S3.', '--delete-local' => 'Delete local file after successful S3 upload + verify.', '--verify' => 'Compare MD5 of local file vs S3 read-back after upload (default: on).', '--no-verify' => 'Skip read-back verification after upload.', '--module' => 'Migrate one or more modules (comma-separated): policy,endorsement,agent,enquiry,quotation,claims', ]; /** @var list */ private array $allowedModules = ['policy', 'endorsement', 'agent', 'enquiry', 'quotation', 'claims']; /** @var array */ private array $stats = [ 'scanned' => 0, 'uploaded' => 0, 'skipped' => 0, 'failed' => 0, 'deleted' => 0, ]; public function run(array $params) { $config = config('Storage'); if ($config->s3Bucket === '' || $config->s3AccessKey === '' || $config->s3SecretKey === '') { CLI::error('S3 is not configured. Set AWS_BUCKET, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY in .env'); return EXIT_ERROR; } $dryRun = (bool) CLI::getOption('dry-run'); $force = (bool) CLI::getOption('force'); $deleteLocal = (bool) CLI::getOption('delete-local'); $verify = ! CLI::getOption('no-verify'); $moduleFilter = CLI::getOption('module'); // Support: --module "agent, enquiry, quotation, claims" // and bare tokens after a partial --module agent, enquiry quotation claims if (is_string($moduleFilter) && $moduleFilter !== '') { $extraTokens = []; foreach ($params as $param) { if (! is_string($param) || $param === '' || str_starts_with($param, '-')) { continue; } $extraTokens[] = $param; } if ($extraTokens !== []) { $moduleFilter = rtrim($moduleFilter, ',') . ',' . implode(',', $extraTokens); } } $modules = $this->parseModuleFilter($moduleFilter); if ($modules === null) { return EXIT_ERROR; } try { $s3Driver = new S3StorageDriver($config); } catch (FileStorageException $e) { CLI::error($e->getMessage()); return EXIT_ERROR; } $storage = new FileStorageService($config); CLI::write('Local to S3 migration', 'green'); CLI::write('Local root : ' . $config->localRoot, 'cyan'); CLI::write('S3 bucket : ' . $config->s3Bucket, 'cyan'); CLI::write('S3 region : ' . $config->s3Region, 'cyan'); CLI::write('S3 prefix : ' . ($config->s3Prefix !== '' ? $config->s3Prefix : '(none)'), 'cyan'); CLI::write('Mode : ' . ($dryRun ? 'DRY RUN' : 'LIVE'), $dryRun ? 'yellow' : 'green'); if ($modules !== []) { CLI::write('Modules : ' . implode(', ', $modules), 'cyan'); } CLI::newLine(); $targets = $this->migrationTargets(); if ($modules !== []) { $targets = array_values(array_filter( $targets, static fn (array $target) => in_array($target['module'], $modules, true) )); } if ($targets === []) { CLI::error('No migration targets matched the given --module filter.'); return EXIT_ERROR; } foreach ($targets as $target) { $this->migrateTarget($target, $s3Driver, $storage, $dryRun, $force, $deleteLocal, $verify); } CLI::newLine(); CLI::write('Migration summary', 'yellow'); CLI::write(' Scanned : ' . $this->stats['scanned'], 'white'); CLI::write(' Uploaded: ' . $this->stats['uploaded'], 'green'); CLI::write(' Skipped : ' . $this->stats['skipped'], 'yellow'); CLI::write(' Failed : ' . $this->stats['failed'], $this->stats['failed'] > 0 ? 'red' : 'white'); CLI::write(' Deleted : ' . $this->stats['deleted'], 'white'); if ($dryRun) { CLI::newLine(); CLI::write('Dry run complete. Re-run without --dry-run to upload.', 'yellow'); } return $this->stats['failed'] === 0 ? EXIT_SUCCESS : EXIT_ERROR; } /** * Parse --module as a single value or comma-separated list (case-insensitive). * * @return list|null empty list = all modules; null = invalid input */ private function parseModuleFilter(mixed $moduleFilter): ?array { if (! is_string($moduleFilter) || trim($moduleFilter) === '') { return []; } $requested = array_values(array_filter(array_map( static fn (string $value): string => strtolower(trim($value)), explode(',', $moduleFilter) ), static fn (string $value): bool => $value !== '')); if ($requested === []) { return []; } $invalid = array_values(array_diff($requested, $this->allowedModules)); if ($invalid !== []) { CLI::error( 'Invalid --module value(s): ' . implode(', ', $invalid) . '. Allowed: ' . implode(', ', $this->allowedModules) . '. Example: --module agent,enquiry,quotation,claims' ); return null; } return array_values(array_unique($requested)); } /** * @return list */ private function migrationTargets(): array { return [ [ 'label' => 'policy_pdf', 'module' => 'policy', 'subFolder' => 'policy_pdf', 'localRelative' => 'uploads/policy/policy_pdf', 'topLevelOnly' => true, ], [ 'label' => 'policy_payment_receipt', 'module' => 'policy', 'subFolder' => 'policy_payment_receipt', 'localRelative' => 'uploads/policy/policy_payment_receipt', 'topLevelOnly' => true, ], [ 'label' => 'policy_md', 'module' => 'policy', 'subFolder' => 'policy_md', 'localRelative' => 'uploads/policy/policy_md', 'topLevelOnly' => true, ], [ 'label' => 'endorsement_original', 'module' => 'endorsement', 'subFolder' => '', 'localRelative' => 'uploads/endorsement', 'topLevelOnly' => true, ], [ 'label' => 'endorsement_completion', 'module' => 'endorsement', 'subFolder' => 'endorsement_pdf', 'localRelative' => 'uploads/endorsement/endorsement_pdf', 'topLevelOnly' => true, ], [ 'label' => 'agent_certificate', 'module' => 'agent', 'subFolder' => 'certificate_file', 'localRelative' => 'uploads/agent/certificate_file', 'topLevelOnly' => true, ], [ 'label' => 'agent_incentive', 'module' => 'agent', 'subFolder' => 'incentive_file', 'localRelative' => 'uploads/agent/incentive_file', 'topLevelOnly' => true, ], [ 'label' => 'enquiry_id_proof', 'module' => 'enquiry', 'subFolder' => 'id_proof', 'localRelative' => 'uploads/enquiry/id_proof', 'topLevelOnly' => true, ], [ 'label' => 'enquiry_rc', 'module' => 'enquiry', 'subFolder' => 'rc', 'localRelative' => 'uploads/enquiry/rc', 'topLevelOnly' => true, ], [ 'label' => 'enquiry_previous_policy', 'module' => 'enquiry', 'subFolder' => 'previous_policy', 'localRelative' => 'uploads/enquiry/previous_policy', 'topLevelOnly' => true, ], [ 'label' => 'enquiry_root', 'module' => 'enquiry', 'subFolder' => '', 'localRelative' => 'uploads/enquiry', 'topLevelOnly' => true, ], [ 'label' => 'quotation', 'module' => 'quotation', 'subFolder' => '', 'localRelative' => 'uploads/quotation', 'topLevelOnly' => true, ], [ 'label' => 'claims', 'module' => 'claims', 'subFolder' => '', 'localRelative' => 'uploads/claims', 'topLevelOnly' => true, ], ]; } /** * @param array{label: string, module: string, subFolder: string, localRelative: string, topLevelOnly: bool} $target */ private function migrateTarget( array $target, S3StorageDriver $s3Driver, FileStorageService $storage, bool $dryRun, bool $force, bool $deleteLocal, bool $verify ): void { $localDir = rtrim(config('Storage')->localRoot, '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $target['localRelative']); CLI::write('[' . $target['label'] . '] ' . $target['localRelative'], 'yellow'); if (! is_dir($localDir)) { CLI::write(' Directory not found, skipping.', 'dark_gray'); CLI::newLine(); return; } $files = $this->listLocalFiles($localDir, $target['topLevelOnly']); if ($files === []) { CLI::write(' No files found.', 'dark_gray'); CLI::newLine(); return; } foreach ($files as $localPath) { $this->stats['scanned']++; $fileName = basename($localPath); $key = $storage->resolveKey($target['module'], $target['subFolder'], $fileName); $size = filesize($localPath) ?: 0; try { if (! $force && $s3Driver->exists($key)) { $this->stats['skipped']++; CLI::write(" skip (exists on S3): {$key}", 'dark_gray'); continue; } if ($dryRun) { $this->stats['uploaded']++; CLI::write(" would upload: {$key} ({$size} bytes)", 'cyan'); continue; } $s3Driver->upload($localPath, $key); if ($verify) { $this->verifyUpload($localPath, $s3Driver, $key); } $this->stats['uploaded']++; CLI::write(" uploaded: {$key} ({$size} bytes)", 'green'); if ($deleteLocal) { if (@unlink($localPath)) { $this->stats['deleted']++; CLI::write(' deleted local: ' . $localPath, 'dark_gray'); } else { throw new FileStorageException('Uploaded to S3 but failed to delete local file: ' . $localPath); } } } catch (\Throwable $e) { $this->stats['failed']++; CLI::error(" failed: {$key} -> " . $e->getMessage()); } } CLI::newLine(); } /** * @return list absolute local file paths */ private function listLocalFiles(string $directory, bool $topLevelOnly): array { $files = []; if ($topLevelOnly) { foreach (scandir($directory) ?: [] as $item) { if ($item === '.' || $item === '..') { continue; } $path = $directory . DIRECTORY_SEPARATOR . $item; if (! is_file($path)) { continue; } if ($this->shouldSkipFile($item)) { continue; } $files[] = $path; } sort($files); return $files; } $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $fileInfo) { if (! $fileInfo->isFile()) { continue; } $name = $fileInfo->getFilename(); if ($this->shouldSkipFile($name)) { continue; } $files[] = $fileInfo->getPathname(); } sort($files); return $files; } private function shouldSkipFile(string $fileName): bool { return in_array(strtolower($fileName), ['index.html', '.htaccess', '.gitkeep'], true); } private function verifyUpload(string $localPath, S3StorageDriver $s3Driver, string $key): void { $localHash = md5_file($localPath); if ($localHash === false) { throw new FileStorageException('Unable to hash local file: ' . $localPath); } $remoteContents = $s3Driver->read($key); $remoteHash = md5($remoteContents); if ($localHash !== $remoteHash) { throw new FileStorageException('Verification failed: S3 content hash mismatch for ' . $key); } } }