option('count'); $source = $this->option('source'); $dryRun = $this->option('dry-run'); $this->info("=== R2 Upload Tool ==="); $this->info("Source: {$source} | Count: {$count}"); return $source === 'db' ? $this->uploadFromDb($count, $dryRun) : $this->uploadFromDisk($count, $dryRun); } /** * Upload files based on DB records (latest by ID desc) */ private function uploadFromDb(int $count, bool $dryRun): int { $files = File::orderByDesc('id')->limit($count)->get(); if ($files->isEmpty()) { $this->warn('No files in DB.'); return 0; } $this->newLine(); $headers = ['ID', 'Display Name', 'R2 Path', 'R2 Exists', 'Local Exists', 'Size']; $rows = []; foreach ($files as $f) { $localPath = storage_path("app/tenants/{$f->file_path}"); $r2Exists = Storage::disk('r2')->exists($f->file_path); $localExists = file_exists($localPath); $rows[] = [ $f->id, mb_strimwidth($f->display_name ?? '', 0, 25, '...'), $f->file_path, $r2Exists ? '✓ YES' : '✗ NO', $localExists ? '✓ YES' : '✗ NO', $f->file_size ? $this->formatSize($f->file_size) : '-', ]; } $this->table($headers, $rows); // Filter: local exists but R2 doesn't $toUpload = $files->filter(function ($f) { $localPath = storage_path("app/tenants/{$f->file_path}"); return file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path); }); $alreadyInR2 = $files->filter(function ($f) { return Storage::disk('r2')->exists($f->file_path); }); if ($alreadyInR2->isNotEmpty()) { $this->info("Already in R2: {$alreadyInR2->count()} files (skipped)"); } if ($toUpload->isEmpty()) { $missingBoth = $files->filter(function ($f) { $localPath = storage_path("app/tenants/{$f->file_path}"); return !file_exists($localPath) && !Storage::disk('r2')->exists($f->file_path); }); if ($missingBoth->isNotEmpty()) { $this->warn("Missing both locally and in R2: {$missingBoth->count()} files"); $this->warn("These files may exist on the dev server only."); } $this->info('Nothing to upload.'); return 0; } if ($dryRun) { $this->warn("[DRY RUN] Would upload {$toUpload->count()} files."); return 0; } // Test R2 connection $this->info('Testing R2 connection...'); try { Storage::disk('r2')->directories('/'); $this->info('✓ R2 connection OK'); } catch (\Exception $e) { $this->error('✗ R2 connection failed: ' . $e->getMessage()); return 1; } // Upload $bar = $this->output->createProgressBar($toUpload->count()); $bar->start(); $success = 0; $failed = 0; foreach ($toUpload as $f) { $localPath = storage_path("app/tenants/{$f->file_path}"); try { $content = FileFacade::get($localPath); $mimeType = $f->mime_type ?: FileFacade::mimeType($localPath); Storage::disk('r2')->put($f->file_path, $content, [ 'ContentType' => $mimeType, ]); $success++; } catch (\Exception $e) { $failed++; $this->newLine(); $this->error(" ✗ ID {$f->id}: {$e->getMessage()}"); } $bar->advance(); } $bar->finish(); $this->newLine(2); $this->info("=== Upload Complete ==="); $this->info("✓ Success: {$success}"); if ($failed > 0) { $this->error("✗ Failed: {$failed}"); return 1; } return 0; } /** * Upload files based on local disk (newest files by mtime) */ private function uploadFromDisk(int $count, bool $dryRun): int { $storagePath = storage_path('app/tenants'); if (!is_dir($storagePath)) { $this->error("Path not found: {$storagePath}"); return 1; } $allFiles = $this->collectFiles($storagePath); if (empty($allFiles)) { $this->warn('No files found.'); return 0; } usort($allFiles, fn($a, $b) => filemtime($b) - filemtime($a)); $filesToUpload = array_slice($allFiles, 0, $count); $this->info("Found " . count($allFiles) . " total files, uploading {$count} most recent:"); $this->newLine(); $headers = ['#', 'File', 'Size', 'Modified', 'R2 Path']; $rows = []; foreach ($filesToUpload as $i => $filePath) { $r2Path = $this->toR2Path($filePath); $rows[] = [$i + 1, basename($filePath), $this->formatSize(filesize($filePath)), date('Y-m-d H:i:s', filemtime($filePath)), $r2Path]; } $this->table($headers, $rows); if ($dryRun) { $this->warn('[DRY RUN] No files uploaded.'); return 0; } $this->info('Testing R2 connection...'); try { Storage::disk('r2')->directories('/'); $this->info('✓ R2 connection OK'); } catch (\Exception $e) { $this->error('✗ R2 connection failed: ' . $e->getMessage()); return 1; } $bar = $this->output->createProgressBar(count($filesToUpload)); $bar->start(); $success = 0; $failed = 0; $fix = $this->option('fix'); foreach ($filesToUpload as $filePath) { $r2Path = $this->toR2Path($filePath); try { if ($fix) { $wrongPath = $this->toRelativePath($filePath); if ($wrongPath !== $r2Path && Storage::disk('r2')->exists($wrongPath)) { Storage::disk('r2')->delete($wrongPath); } } $content = FileFacade::get($filePath); $mimeType = FileFacade::mimeType($filePath); Storage::disk('r2')->put($r2Path, $content, ['ContentType' => $mimeType]); $success++; } catch (\Exception $e) { $failed++; $this->newLine(); $this->error(" ✗ Failed: {$r2Path} - {$e->getMessage()}"); } $bar->advance(); } $bar->finish(); $this->newLine(2); $this->info("=== Upload Complete ==="); $this->info("✓ Success: {$success}"); if ($failed > 0) { $this->error("✗ Failed: {$failed}"); return 1; } return 0; } private function toR2Path(string $filePath): string { $relative = $this->toRelativePath($filePath); return str_starts_with($relative, 'tenants/') ? substr($relative, strlen('tenants/')) : $relative; } private function toRelativePath(string $filePath): string { return str_replace(str_replace('\\', '/', storage_path('app/')), '', str_replace('\\', '/', $filePath)); } private function collectFiles(string $dir): array { $files = []; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS) ); foreach ($iterator as $file) { if ($file->isFile() && $file->getFilename() !== '.gitignore') { $files[] = $file->getPathname(); } } return $files; } private function formatSize(int $bytes): string { if ($bytes >= 1048576) return round($bytes / 1048576, 1) . ' MB'; return round($bytes / 1024, 1) . ' KB'; } }