265 lines
8.7 KiB
PHP
265 lines
8.7 KiB
PHP
|
|
<?php
|
||
|
|
|
||
|
|
namespace App\Console\Commands;
|
||
|
|
|
||
|
|
use App\Models\Commons\File;
|
||
|
|
use Illuminate\Console\Command;
|
||
|
|
use Illuminate\Support\Facades\Storage;
|
||
|
|
use Illuminate\Support\Facades\File as FileFacade;
|
||
|
|
|
||
|
|
class UploadLocalFilesToR2 extends Command
|
||
|
|
{
|
||
|
|
protected $signature = 'r2:upload-local
|
||
|
|
{--count=3 : Number of files to upload}
|
||
|
|
{--source=db : Source: "db" (latest DB records) or "disk" (latest local files)}
|
||
|
|
{--dry-run : Show files without uploading}
|
||
|
|
{--fix : Delete wrong-path files from R2 before re-uploading}';
|
||
|
|
|
||
|
|
protected $description = 'Upload local files to Cloudflare R2 (by DB records or local disk)';
|
||
|
|
|
||
|
|
public function handle(): int
|
||
|
|
{
|
||
|
|
$count = (int) $this->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';
|
||
|
|
}
|
||
|
|
}
|