diff --git a/app/Console/Commands/CloudMediaMigrate.php b/app/Console/Commands/CloudMediaMigrate.php deleted file mode 100644 index fbb068731..000000000 --- a/app/Console/Commands/CloudMediaMigrate.php +++ /dev/null @@ -1,102 +0,0 @@ -error('Cloud storage not enabled. Exiting...'); - - return; - } - - if (! $this->confirm('Are you sure you want to proceed?')) { - return; - } - - $limit = $this->option('limit'); - $hugeMode = $this->option('huge'); - - if ($limit > 500 && ! $hugeMode) { - $this->error('Max limit exceeded, use a limit lower than 500 or run again with the --huge flag'); - - return; - } - - $bar = $this->output->createProgressBar($limit); - $bar->start(); - - Media::whereNot('version', '4') - ->where('created_at', '<', now()->subDays(2)) - ->whereRemoteMedia(false) - ->whereNotNull(['status_id', 'profile_id']) - ->whereNull(['cdn_url', 'replicated_at']) - ->orderByDesc('size') - ->take($limit) - ->get() - ->each(function ($media) use ($bar) { - if (Storage::disk('local')->exists($media->media_path)) { - $this->totalSize = $this->totalSize + $media->size; - try { - MediaStorageService::store($media); - } catch (FileNotFoundException $e) { - $this->error('Error migrating media '.$media->id.' to cloud storage: '.$e->getMessage()); - - return; - } catch (NotFoundHttpException $e) { - $this->error('Error migrating media '.$media->id.' to cloud storage: '.$e->getMessage()); - - return; - } catch (\Exception $e) { - $this->error('Error migrating media '.$media->id.' to cloud storage: '.$e->getMessage()); - - return; - } - } - $bar->advance(); - }); - - $bar->finish(); - $this->line(' '); - $this->info('Finished!'); - if ($this->totalSize) { - $this->info('Uploaded '.PrettyNumber::size($this->totalSize).' of media to cloud storage!'); - $this->line(' '); - $this->info('These files are still stored locally, and will be automatically removed.'); - } - - return Command::SUCCESS; - } -} diff --git a/app/Console/Commands/Concerns/ManagesMediaStorageEnv.php b/app/Console/Commands/Concerns/ManagesMediaStorageEnv.php new file mode 100644 index 000000000..716a2a02d --- /dev/null +++ b/app/Console/Commands/Concerns/ManagesMediaStorageEnv.php @@ -0,0 +1,122 @@ +environmentFilePath(); + if (! is_file($envPath)) { + return null; + } + $payload = file_get_contents($envPath); + if ($payload === false) { + return null; + } + if (! preg_match("/^{$key}=([^\r\n]*)/m", $payload, $m)) { + return null; + } + + return trim($m[1], " \t\"'"); + } + + /** + * Set an .env key + the live runtime config + config-cache entry so the + * change takes effect immediately on a running server. + * + * @param string $configKey dotted config key kept in sync (e.g. 'pixelfed.cloud_storage') + * @param mixed $configValue the typed runtime value (e.g. true/false) + */ + protected function setStorageEnv(string $envKey, string $envValue, string $configKey, $configValue): void + { + // 1. Persist to .env atomically (survives restarts). + $this->updateEnvFile($envKey, $envValue); + + // 2. Update the live runtime config for the current process. + config([$configKey => $configValue]); + + // 3. Update the DB-backed config cache so other workers/requests + // reading via config_cache() see the new value (hot server). + try { + ConfigCacheService::put($configKey, $configValue); + } catch (\Throwable $e) { + $this->warn('Could not update config cache for '.$configKey.': '.$e->getMessage()); + } + } + + /** + * The configured cloud disk host (used to sanity check cloud config). + */ + protected function cloudHost(): ?string + { + try { + $url = Storage::disk(config('filesystems.cloud'))->url('probe'); + $host = parse_url($url, PHP_URL_HOST); + + return $host ?: null; + } catch (\Throwable $e) { + return null; + } + } + + // ---- Atomic .env writer (adapted from Installer) --------------------- + + protected function updateEnvFile($key, $value): void + { + $envPath = app()->environmentFilePath(); + $payload = file_get_contents($envPath); + + $value = str_replace(['\\', '"', "\n", "\r"], ['\\\\', '\\"', '\\n', '\\r'], $value); + + if (($existing = $this->existingEnv($key, $payload)) !== false) { + $payload = str_replace("{$key}={$existing}", "{$key}=\"{$value}\"", $payload); + } else { + $payload = $payload."\n{$key}=\"{$value}\"\n"; + } + + $this->storeEnv($payload); + } + + protected function existingEnv($needle, $haystack) + { + preg_match("/^{$needle}=[^\r\n]*/m", $haystack, $matches); + if ($matches && count($matches)) { + return substr($matches[0], strlen($needle) + 1); + } + + return false; + } + + protected function storeEnv($payload): void + { + $envPath = app()->environmentFilePath(); + $tempPath = $envPath.'.tmp'; + + $file = fopen($tempPath, 'w'); + if ($file === false) { + throw new \RuntimeException("Cannot write to {$tempPath}"); + } + fwrite($file, $payload); + fclose($file); + + if (! rename($tempPath, $envPath)) { + @unlink($tempPath); + throw new \RuntimeException('Cannot update .env file'); + } + } +} diff --git a/app/Console/Commands/MediaMoveStorageCloudToLocal.php b/app/Console/Commands/MediaMoveStorageCloudToLocal.php new file mode 100644 index 000000000..bfbdfc2bb --- /dev/null +++ b/app/Console/Commands/MediaMoveStorageCloudToLocal.php @@ -0,0 +1,227 @@ +readEnvValue('PF_ENABLE_CLOUD'); + $cloudEnabled = filter_var($envCloud, FILTER_VALIDATE_BOOLEAN); + + if ($cloudEnabled) { + $this->warn('PF_ENABLE_CLOUD is currently true.'); + $this->line('New uploads would keep landing on CLOUD storage during this migration.'); + if ($this->option('dry-run')) { + $this->line('[dry-run] Would set PF_ENABLE_CLOUD=false (.env + runtime + config cache).'); + } elseif ($this->option('force') || $this->confirm('Set PF_ENABLE_CLOUD=false now so new uploads stay local?', true)) { + $this->setStorageEnv('PF_ENABLE_CLOUD', 'false', 'pixelfed.cloud_storage', false); + $this->info('PF_ENABLE_CLOUD set to false (.env + live runtime + config cache).'); + } else { + $this->error('Aborting: refusing to migrate to local while new uploads go to cloud.'); + + return 1; + } + } else { + $this->info('PF_ENABLE_CLOUD is already false; new uploads stay local. ✓'); + } + + $this->newLine(); + if (! $this->option('dry-run') && ! $this->option('force')) { + if (! $this->confirm('Begin migrating cloud media to local?', true)) { + $this->comment('Aborted.'); + + return 0; + } + } + + $limit = (int) $this->option('limit'); + $moved = 0; + $skipped = 0; + $failed = 0; + + // Candidates: non-remote media that has a cloud copy (cdn_url set). + $query = Media::whereRemoteMedia(false) + ->whereNotNull(['media_path', 'cdn_url']) + ->orderByDesc('id') + ->limit($limit); + + $bar = $this->output->createProgressBar($query->count()); + $bar->start(); + + foreach ($query->get() as $media) { + $result = $this->migrateOne($media, $localDisk, $cloudDisk); + match ($result) { + 'moved' => $moved++, + 'skipped' => $skipped++, + default => $failed++, + }; + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + $this->info(($this->option('dry-run') ? '[dry-run] ' : '').'Done. moved='.$moved.' skipped='.$skipped.' failed='.$failed.'.'); + if ($this->movedBytes) { + $this->info('Transferred '.PrettyNumber::size($this->movedBytes).' back to local storage.'); + } + + return 0; + } + + /** + * @return string one of moved|skipped|failed + */ + protected function migrateOne(Media $media, $localDisk, $cloudDisk): string + { + if (Str::startsWith((string) $media->media_path, 'http')) { + return 'skipped'; + } + + // Must exist on cloud to pull down. + if (! $cloudDisk->exists($media->media_path)) { + // Already local-only? just clear the cloud url fields. + if ($localDisk->exists($media->media_path)) { + if (! $this->option('dry-run')) { + $this->clearCloudFields($media); + $media->save(); + } + + return 'skipped'; + } + + return 'failed'; + } + + if ($this->option('dry-run')) { + return 'moved'; + } + + try { + $this->copyToLocal($media->media_path, $localDisk, $cloudDisk); + if ($media->thumbnail_path && $cloudDisk->exists($media->thumbnail_path)) { + $this->copyToLocal($media->thumbnail_path, $localDisk, $cloudDisk); + } + + if (! $this->verify($media->media_path, $localDisk, $cloudDisk, $media->original_sha256)) { + $this->warn(PHP_EOL.'Verify failed for media '.$media->id.' ('.$media->media_path.'); left cloud copy intact.'); + + return 'failed'; + } + + // Point URLs back at local storage. + $this->clearCloudFields($media); + + // Integrated GC: delete the verified cloud copy unless --keep-cloud. + if (! $this->option('keep-cloud')) { + $cloudDisk->delete($media->media_path); + if ($media->thumbnail_path && $cloudDisk->exists($media->thumbnail_path)) { + $cloudDisk->delete($media->thumbnail_path); + } + } + + $media->save(); + $this->movedBytes += (int) $media->size; + + if ($media->status_id) { + MediaService::del($media->status_id); + StatusService::del($media->status_id, false); + } + + return 'moved'; + } catch (\Throwable $e) { + $this->warn(PHP_EOL.'Error migrating media '.$media->id.': '.$e->getMessage()); + + return 'failed'; + } + } + + /** + * Reset a media row to local-served state. + */ + protected function clearCloudFields(Media $media): void + { + $media->cdn_url = null; + $media->optimized_url = null; + $media->thumbnail_url = null; + $media->replicated_at = null; + // version 4 meant "local deleted, cloud only"; reset so the file is + // treated as locally present again. + if ($media->version === '4' || $media->version === 4) { + $media->version = 3; + } + } + + protected function copyToLocal(string $path, $localDisk, $cloudDisk): void + { + $stream = $cloudDisk->readStream($path); + if ($stream === false || $stream === null) { + throw new \RuntimeException('Could not open cloud stream for '.$path); + } + $localDisk->writeStream($path, $stream); + if (is_resource($stream)) { + fclose($stream); + } + } + + /** + * Verify the local copy matches the cloud source by size, and by sha256 + * against the stored original checksum when available. Fails closed. + */ + protected function verify(string $path, $localDisk, $cloudDisk, ?string $expectedSha = null): bool + { + if (! $localDisk->exists($path)) { + return false; + } + + $localSize = $localDisk->size($path); + $cloudSize = $cloudDisk->size($path); + if ($localSize === false || $cloudSize === false || $localSize !== $cloudSize) { + return false; + } + + if ($expectedSha) { + $localSha = @hash_file('sha256', $localDisk->path($path)); + if ($localSha && ! hash_equals($expectedSha, $localSha)) { + return false; + } + } + + return true; + } +} diff --git a/app/Console/Commands/MediaMoveStorageLocalToCloud.php b/app/Console/Commands/MediaMoveStorageLocalToCloud.php new file mode 100644 index 000000000..185f73739 --- /dev/null +++ b/app/Console/Commands/MediaMoveStorageLocalToCloud.php @@ -0,0 +1,239 @@ +error('Cloud disk ('.config('filesystems.cloud').') could not be resolved: '.$e->getMessage()); + + return 1; + } + + if (! $this->cloudHost()) { + $this->error('Cloud disk ('.config('filesystems.cloud').') is not configured (no resolvable URL).'); + $this->line('Set AWS_URL / AWS_* in your .env before migrating to cloud.'); + + return 1; + } + + // --- Ensure new uploads route to cloud during the migration -------- + $envCloud = $this->readEnvValue('PF_ENABLE_CLOUD'); + $cloudEnabled = filter_var($envCloud, FILTER_VALIDATE_BOOLEAN); + + if (! $cloudEnabled) { + $this->warn('PF_ENABLE_CLOUD is currently "'.($envCloud ?? 'unset').'".'); + $this->line('New uploads would keep landing on LOCAL storage during this migration.'); + if ($this->option('dry-run')) { + $this->line('[dry-run] Would set PF_ENABLE_CLOUD=true (.env + runtime + config cache).'); + } elseif ($this->option('force') || $this->confirm('Set PF_ENABLE_CLOUD=true now so new uploads go to cloud?', true)) { + $this->setStorageEnv('PF_ENABLE_CLOUD', 'true', 'pixelfed.cloud_storage', true); + $this->info('PF_ENABLE_CLOUD set to true (.env + live runtime + config cache).'); + } else { + $this->error('Aborting: refusing to migrate to cloud while new uploads stay local.'); + + return 1; + } + } else { + $this->info('PF_ENABLE_CLOUD is already true; new uploads route to cloud. ✓'); + } + + $this->newLine(); + if (! $this->option('dry-run') && ! $this->option('force')) { + if (! $this->confirm('Begin migrating local media to cloud?', true)) { + $this->comment('Aborted.'); + + return 0; + } + } + + $limit = (int) $this->option('limit'); + $moved = 0; + $skipped = 0; + $failed = 0; + + // Candidates: local, non-remote media not yet replicated to cloud. + $query = Media::whereRemoteMedia(false) + ->whereNotNull('media_path') + ->where(function ($q) { + $q->whereNull('cdn_url')->orWhereNull('replicated_at')->orWhereNot('version', '4'); + }) + ->orderByDesc('id') + ->limit($limit); + + $bar = $this->output->createProgressBar($query->count()); + $bar->start(); + + foreach ($query->get() as $media) { + $result = $this->migrateOne($media, $localDisk, $cloudDisk); + match ($result) { + 'moved' => $moved++, + 'skipped' => $skipped++, + default => $failed++, + }; + $bar->advance(); + } + + $bar->finish(); + $this->newLine(2); + $this->info(($this->option('dry-run') ? '[dry-run] ' : '').'Done. moved='.$moved.' skipped='.$skipped.' failed='.$failed.'.'); + if ($this->movedBytes) { + $this->info('Transferred '.PrettyNumber::size($this->movedBytes).' to cloud storage.'); + } + + return 0; + } + + /** + * @return string one of moved|skipped|failed + */ + protected function migrateOne(Media $media, $localDisk, $cloudDisk): string + { + if (Str::startsWith((string) $media->media_path, 'http')) { + return 'skipped'; + } + + // Nothing to do if the local file is gone. + if (! $localDisk->exists($media->media_path)) { + // Already on cloud only? mark version and move on. + if ($cloudDisk->exists($media->media_path)) { + if (! $this->option('dry-run') && $media->version !== '4') { + $media->version = 4; + $media->save(); + } + + return 'skipped'; + } + + return 'skipped'; + } + + if ($this->option('dry-run')) { + return 'moved'; + } + + try { + // Copy the primary file (and thumbnail) to cloud. + $this->copyToCloud($media->media_path, $localDisk, $cloudDisk); + if ($media->thumbnail_path && $localDisk->exists($media->thumbnail_path)) { + $this->copyToCloud($media->thumbnail_path, $localDisk, $cloudDisk); + } + + // Verify the primary file before touching anything else. + if (! $this->verify($media->media_path, $localDisk, $cloudDisk, $media->original_sha256)) { + $this->warn(PHP_EOL.'Verify failed for media '.$media->id.' ('.$media->media_path.'); left local copy intact.'); + + return 'failed'; + } + + // Update URL fields to the cloud disk. + $media->cdn_url = $cloudDisk->url($media->media_path); + $media->optimized_url = $media->cdn_url; + if ($media->thumbnail_path && $cloudDisk->exists($media->thumbnail_path)) { + $media->thumbnail_url = $cloudDisk->url($media->thumbnail_path); + } + $media->replicated_at = now(); + + // Integrated GC: delete the verified local copy unless --keep-local. + if (! $this->option('keep-local')) { + $localDisk->delete($media->media_path); + if ($media->thumbnail_path && $localDisk->exists($media->thumbnail_path)) { + $localDisk->delete($media->thumbnail_path); + } + $media->version = 4; + } + + $media->save(); + $this->movedBytes += (int) $media->size; + + if ($media->status_id) { + MediaService::del($media->status_id); + StatusService::del($media->status_id, false); + } + + return 'moved'; + } catch (\Throwable $e) { + $this->warn(PHP_EOL.'Error migrating media '.$media->id.': '.$e->getMessage()); + + return 'failed'; + } + } + + protected function copyToCloud(string $path, $localDisk, $cloudDisk): void + { + $p = explode('/', $path); + $name = array_pop($p); + $storagePath = implode('/', $p); + + // Reuse the resilient uploader (handles alt disks + retries). + ResilientMediaStorageService::store($storagePath, $localDisk->path($path), $name); + } + + /** + * Verify the cloud copy matches the local source by size, and by sha256 + * when a checksum is available/cheap. Fails closed. + */ + protected function verify(string $path, $localDisk, $cloudDisk, ?string $expectedSha = null): bool + { + if (! $cloudDisk->exists($path)) { + return false; + } + + $localSize = $localDisk->size($path); + $cloudSize = $cloudDisk->size($path); + if ($localSize === false || $cloudSize === false || $localSize !== $cloudSize) { + return false; + } + + // If we already have the original checksum, verify the local file still + // matches it (so we never delete a locally-corrupted-but-uploaded file + // without noticing). Cloud content hashing would require a full + // download, which we avoid for large media; size parity + known sha + // is a strong signal. + if ($expectedSha) { + $localSha = @hash_file('sha256', $localDisk->path($path)); + if ($localSha && ! hash_equals($expectedSha, $localSha)) { + return false; + } + } + + return true; + } +} diff --git a/app/Console/Commands/MediaS3GarbageCollector.php b/app/Console/Commands/MediaS3GarbageCollector.php deleted file mode 100644 index d569659fa..000000000 --- a/app/Console/Commands/MediaS3GarbageCollector.php +++ /dev/null @@ -1,204 +0,0 @@ -error('Cloud storage not enabled. Exiting...'); - - return; - } - - $deleteEnabled = config('media.delete_local_after_cloud'); - if (! $deleteEnabled) { - $this->error('Delete local storage after cloud upload is not enabled'); - - return; - } - - $limit = $this->option('limit'); - $hugeMode = $this->option('huge'); - $log = $this->option('log-errors'); - - if ($limit > 2000 && ! $hugeMode) { - $this->error('Limit exceeded, please use a limit under 2000 or run again with the --huge flag'); - - return; - } - - $minId = Media::orderByDesc('id')->where('created_at', '<', now()->subHours(12))->first(); - - if (! $minId) { - return; - } else { - $minId = $minId->id; - } - - return $hugeMode ? - $this->hugeMode($minId, $limit, $log) : - $this->regularMode($minId, $limit, $log); - } - - protected function regularMode($minId, $limit, $log) - { - $gc = Media::whereRemoteMedia(false) - ->whereNotNull(['status_id', 'cdn_url', 'replicated_at']) - ->whereNot('version', '4') - ->where('id', '<', $minId) - ->inRandomOrder() - ->take($limit) - ->get(); - - $totalSize = 0; - $bar = $this->output->createProgressBar($gc->count()); - $bar->start(); - $cloudDisk = Storage::disk(config('filesystems.cloud')); - $localDisk = Storage::disk('local'); - - foreach ($gc as $media) { - try { - if ( - $cloudDisk->exists($media->media_path) - ) { - if ($localDisk->exists($media->media_path)) { - $localDisk->delete($media->media_path); - $media->version = 4; - $media->save(); - $totalSize = $totalSize + $media->size; - MediaService::del($media->status_id); - StatusService::del($media->status_id, false); - if ($localDisk->exists($media->thumbnail_path)) { - $localDisk->delete($media->thumbnail_path); - } - } else { - $media->version = 4; - $media->save(); - } - } else { - if ($log) { - Log::channel('media')->info('[GC] Local media not properly persisted to cloud storage', ['media_id' => $media->id]); - } - } - $bar->advance(); - } catch (FileNotFoundException $e) { - $bar->advance(); - - continue; - } catch (NotFoundHttpException $e) { - $bar->advance(); - - continue; - } catch (\Exception $e) { - $bar->advance(); - - continue; - } - } - $bar->finish(); - $this->line(' '); - $this->info('Finished!'); - if ($totalSize) { - $this->info('Cleared '.$totalSize.' bytes of media from local disk!'); - } - - return 0; - } - - protected function hugeMode($minId, $limit, $log) - { - $cloudDisk = Storage::disk(config('filesystems.cloud')); - $localDisk = Storage::disk('local'); - - $bar = $this->output->createProgressBar($limit); - $bar->start(); - - Media::whereRemoteMedia(false) - ->whereNotNull(['status_id', 'cdn_url', 'replicated_at']) - ->whereNot('version', '4') - ->where('id', '<', $minId) - ->chunk(50, function ($medias) use ($cloudDisk, $localDisk, $bar, $log) { - foreach ($medias as $media) { - try { - if ($cloudDisk->exists($media->media_path)) { - if ($localDisk->exists($media->media_path)) { - $localDisk->delete($media->media_path); - $media->version = 4; - $media->save(); - MediaService::del($media->status_id); - StatusService::del($media->status_id, false); - if ($localDisk->exists($media->thumbnail_path)) { - $localDisk->delete($media->thumbnail_path); - } - } else { - $media->version = 4; - $media->save(); - } - } else { - if ($log) { - Log::channel('media')->info('[GC] Local media not properly persisted to cloud storage', ['media_id' => $media->id]); - } - } - $bar->advance(); - } catch (FileNotFoundException $e) { - $bar->advance(); - - continue; - } catch (NotFoundHttpException $e) { - $bar->advance(); - - continue; - } catch (\Exception $e) { - $bar->advance(); - - continue; - } - } - }); - - $bar->finish(); - $this->line(' '); - $this->info('Finished!'); - } -} diff --git a/bootstrap/app.php b/bootstrap/app.php index b9dd57a76..9d4f06c0b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -143,7 +143,8 @@ return Application::configure(basePath: dirname(__DIR__)) $schedule->command('passport:purge')->everyFourHours(20)->onOneServer(); if ((bool) config_cache('pixelfed.cloud_storage') && (bool) config_cache('media.delete_local_after_cloud')) { - $schedule->command('media:s3gc')->hourlyAt(15); + // Upload any local stragglers to cloud and GC verified local copies. + $schedule->command('admin:MediaMoveStorageLocalToCloud --force --limit=500')->hourlyAt(15); } if (config('import.instagram.enabled')) { diff --git a/tests/Feature/Account/MediaMoveStorageTest.php b/tests/Feature/Account/MediaMoveStorageTest.php new file mode 100644 index 000000000..5017292ec --- /dev/null +++ b/tests/Feature/Account/MediaMoveStorageTest.php @@ -0,0 +1,150 @@ + 'https://cdn.test']); + + // Use a throwaway env file so the command's env edits don't touch the + // real one. App::useEnvironmentPath expects a directory; the app resolves + // the environment-specific filename (e.g. .env.testing) itself. + $this->originalEnvPath = app()->environmentPath(); + $dir = sys_get_temp_dir().'/pf-env-test-'.uniqid(); + mkdir($dir); + app()->useEnvironmentPath($dir); + file_put_contents(app()->environmentFilePath(), "APP_KEY=base64:test\nPF_ENABLE_CLOUD=false\n"); +}); + +afterEach(function () { + // Restore the real environment path so we don't leak into other test files. + if (isset($this->originalEnvPath)) { + app()->useEnvironmentPath($this->originalEnvPath); + } +}); + +function makeCloudMedia(): Media +{ + $user = User::factory()->create(); + $user->refresh(); + $pid = $user->profile->id; + $status = Status::factory()->create(['profile_id' => $pid, 'type' => 'photo']); + + $path = 'public/m/_v2/'.$pid.'/aa/bb/file.jpg'; + $thumb = 'public/m/_v2/'.$pid.'/aa/bb/file_thumb.jpeg'; + + // Put the files on the cloud disk only. + Storage::disk('s3')->put($path, 'PRIMARY-BYTES-1234567890'); + Storage::disk('s3')->put($thumb, 'THUMB-BYTES'); + + return Media::create([ + 'status_id' => $status->id, + 'profile_id' => $pid, + 'user_id' => $user->id, + 'media_path' => $path, + 'thumbnail_path' => $thumb, + 'cdn_url' => Storage::disk('s3')->url($path), + 'thumbnail_url' => Storage::disk('s3')->url($thumb), + 'optimized_url' => Storage::disk('s3')->url($path), + 'mime' => 'image/jpeg', + 'size' => strlen('PRIMARY-BYTES-1234567890'), + 'remote_media' => false, + 'version' => 4, + 'replicated_at' => now(), + 'order' => 0, + ]); +} + +describe('admin:MediaMoveStorageCloudToLocal', function () { + it('downloads cloud media to local, clears cloud urls and deletes the cloud copy', function () { + $media = makeCloudMedia(); + + $this->artisan('admin:MediaMoveStorageCloudToLocal', ['--force' => true]) + ->assertExitCode(0); + + // File is now on local disk. + expect(Storage::disk('local')->exists($media->media_path))->toBeTrue(); + // Cloud copy removed (GC), thumbnail too. + expect(Storage::disk('s3')->exists($media->media_path))->toBeFalse(); + + $media->refresh(); + expect($media->cdn_url)->toBeNull(); + expect($media->optimized_url)->toBeNull(); + expect($media->thumbnail_url)->toBeNull(); + expect($media->replicated_at)->toBeNull(); + expect((string) $media->version)->toBe('3'); + }); + + it('keeps the cloud copy with --keep-cloud', function () { + $media = makeCloudMedia(); + + $this->artisan('admin:MediaMoveStorageCloudToLocal', ['--force' => true, '--keep-cloud' => true]) + ->assertExitCode(0); + + expect(Storage::disk('local')->exists($media->media_path))->toBeTrue(); + expect(Storage::disk('s3')->exists($media->media_path))->toBeTrue(); + }); + + it('does not modify anything in dry-run', function () { + $media = makeCloudMedia(); + + $this->artisan('admin:MediaMoveStorageCloudToLocal', ['--force' => true, '--dry-run' => true]) + ->assertExitCode(0); + + expect(Storage::disk('local')->exists($media->media_path))->toBeFalse(); + expect($media->fresh()->cdn_url)->not->toBeNull(); + }); + + it('sets PF_ENABLE_CLOUD=false in .env and runtime when cloud is enabled', function () { + // Start with cloud enabled. + file_put_contents(app()->environmentFilePath(), "APP_KEY=base64:test\nPF_ENABLE_CLOUD=true\n"); + Config::set('pixelfed.cloud_storage', true); + makeCloudMedia(); + + $this->artisan('admin:MediaMoveStorageCloudToLocal', ['--force' => true]) + ->assertExitCode(0); + + expect(file_get_contents(app()->environmentFilePath()))->toContain('PF_ENABLE_CLOUD="false"'); + expect(config('pixelfed.cloud_storage'))->toBeFalse(); + }); +}); + +describe('admin:MediaMoveStorageLocalToCloud', function () { + it('requires a configured cloud disk', function () { + // Fake s3 disk has no url() host resolvable? Storage::fake provides a + // url, so instead point cloud at a disk that throws. + Config::set('filesystems.cloud', 'does-not-exist'); + + $this->artisan('admin:MediaMoveStorageLocalToCloud', ['--force' => true]) + ->assertExitCode(1); + }); + + it('flips PF_ENABLE_CLOUD to true before migrating (dry-run reports it)', function () { + // cloud currently false in the temp .env from beforeEach. + $this->artisan('admin:MediaMoveStorageLocalToCloud', ['--dry-run' => true]) + ->expectsOutputToContain('PF_ENABLE_CLOUD') + ->assertExitCode(0); + + // dry-run must not write the .env. + expect(file_get_contents(app()->environmentFilePath()))->toContain('PF_ENABLE_CLOUD=false'); + }); +});