Files
sam-api/app/Console/Commands/UploadLocalFilesToR2.php

265 lines
8.7 KiB
PHP
Raw Normal View History

2026-03-11 17:49:16 +09:00
<?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';
}
}