Merge pull request #6929 from pixelfed/feature/media-url-migrate

post:status command for post/media diagnostics + admin:MigrateLocalS3MediaURL to fix broken CDN URLs
pull/6931/head
Shlee 4 weeks ago committed by GitHub
commit 94d519d3a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -1,209 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Avatar;
use App\Models\Media;
use Illuminate\Console\Command;
use Illuminate\Contracts\Console\PromptsForMissingInput;
use Illuminate\Support\Facades\Storage;
use function Laravel\Prompts\select;
class MediaCloudUrlRewrite extends Command implements PromptsForMissingInput
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'media:cloud-url-rewrite {oldDomain} {newDomain}';
/**
* Prompt for missing input arguments using the returned questions.
*
* @return array
*/
protected function promptForMissingArgumentsUsing()
{
return [
'oldDomain' => 'The old S3 domain',
'newDomain' => 'The new S3 domain',
];
}
/**
* The console command description.
*
* @var string
*/
protected $description = 'Rewrite S3 media urls from local users';
/**
* Execute the console command.
*/
public function handle()
{
$this->preflightCheck();
$this->bootMessage();
$this->confirmCloudUrl();
$this->handleTask();
}
protected function preflightCheck()
{
if (! (bool) config_cache('pixelfed.cloud_storage')) {
$this->info('Error: Cloud storage is not enabled! Please enable before proceeding.');
$this->error('Aborting...');
exit;
}
}
protected function bootMessage()
{
$this->info(' ____ _ ______ __ ');
$this->info(' / __ \(_) _____ / / __/__ ____/ / ');
$this->info(' / /_/ / / |/_/ _ \/ / /_/ _ \/ __ / ');
$this->info(' / ____/ /> </ __/ / __/ __/ /_/ / ');
$this->info(' /_/ /_/_/|_|\___/_/_/ \___/\__,_/ ');
$this->info(' ');
$this->info(' Media Cloud Url Rewrite Tool');
$this->info(' ===');
$this->info(' Old S3: '.trim($this->argument('oldDomain')));
$this->info(' New S3: '.trim($this->argument('newDomain')));
$this->info(' ');
}
protected function confirmCloudUrl()
{
$disk = Storage::disk(config('filesystems.cloud'))->url('test');
$domain = parse_url($disk, PHP_URL_HOST);
if (trim($this->argument('newDomain')) !== $domain) {
$this->error('Error: The new S3 domain you entered is not currently configured');
exit;
}
if (! $this->confirm('Confirm this is correct')) {
$this->error('Aborting...');
exit;
}
}
protected function handleTask()
{
$task = select(
label: 'What action would you like to perform?',
options: ['Migrate All', 'Migrate Media', 'Migrate Avatars']
);
switch ($task) {
case 'Migrate All':
$this->updateMediaUrls();
$this->updateAvatarUrls();
break;
case 'Migrate Media':
$this->updateMediaUrls();
break;
case 'Migrate Avatars':
$this->updateAvatarUrls();
break;
default:
$this->error('Invalid selection');
return;
}
}
protected function updateMediaUrls()
{
$this->info('Updating media urls...');
$oldDomain = trim($this->argument('oldDomain'));
$newDomain = trim($this->argument('newDomain'));
$disk = Storage::disk(config('filesystems.cloud'));
$count = Media::whereNotNull('cdn_url')->count();
$bar = $this->output->createProgressBar($count);
$counter = 0;
$bar->start();
foreach (Media::whereNotNull('cdn_url')->lazyById(1000, 'id') as $media) {
if (strncmp($media->media_path, 'http', 4) === 0) {
$bar->advance();
continue;
}
$cdnHost = parse_url($media->cdn_url, PHP_URL_HOST);
if ($oldDomain != $cdnHost || $newDomain == $cdnHost) {
$bar->advance();
continue;
}
$media->cdn_url = str_replace($oldDomain, $newDomain, $media->cdn_url);
if ($media->thumbnail_url != null) {
$thumbHost = parse_url($media->thumbnail_url, PHP_URL_HOST);
if ($thumbHost == $oldDomain) {
$thumbUrl = $disk->url($media->thumbnail_path);
$media->thumbnail_url = $thumbUrl;
}
}
if ($media->optimized_url != null) {
$optiHost = parse_url($media->optimized_url, PHP_URL_HOST);
if ($optiHost == $oldDomain) {
$optiUrl = str_replace($oldDomain, $newDomain, $media->optimized_url);
$media->optimized_url = $optiUrl;
}
}
$media->save();
$counter++;
$bar->advance();
}
$bar->finish();
$this->line(' ');
$this->info('Finished! Updated '.$counter.' total records!');
$this->line(' ');
$this->info('Tip: Run `php artisan cache:clear` to purge cached urls');
}
protected function updateAvatarUrls()
{
$this->info('Updating avatar urls...');
$oldDomain = trim($this->argument('oldDomain'));
$newDomain = trim($this->argument('newDomain'));
$disk = Storage::disk(config('filesystems.cloud'));
$count = Avatar::count();
$bar = $this->output->createProgressBar($count);
$counter = 0;
$bar->start();
foreach (Avatar::lazyById(1000, 'id') as $avatar) {
if (! $avatar->cdn_url) {
$bar->advance();
continue;
}
$cdnHost = parse_url($avatar->cdn_url, PHP_URL_HOST);
if (strcasecmp($oldDomain, $cdnHost) !== 0 || strcasecmp($newDomain, $cdnHost) === 0) {
$bar->advance();
continue;
}
$avatar->cdn_url = str_replace($oldDomain, $newDomain, $avatar->cdn_url);
$avatar->save();
$counter++;
$bar->advance();
}
$bar->finish();
$this->line(' ');
$this->info('Finished! Updated '.$counter.' total records!');
$this->line(' ');
$this->info('Tip: Run `php artisan cache:clear` to purge cached urls');
}
}

@ -0,0 +1,382 @@
<?php
namespace App\Console\Commands;
use App\Models\Media;
use App\Models\Status;
use App\Services\MediaService;
use App\Services\StatusService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class MigrateLocalS3MediaURL extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:MigrateLocalS3MediaURL
{id? : A status id (or post URL) to fix; omit with --all}
{--all : Scan every local media row and fix any with a stale host}
{--oldDomain= : Only rewrite URLs whose host matches this old backend (default: rewrite all stale hosts)}
{--newDomain= : Target host to rewrite to (default: the configured cloud disk host from .env)}
{--dry-run : Report what would change without writing}
{--force : Skip the confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Rewrite stale local media cloud URLs (cdn_url, thumbnail_url, optimized_url) from their storage paths to the configured S3/cloud host. Replaces media:cloud-url-rewrite.';
/**
* The target host to rewrite URLs to.
*/
protected ?string $newHost = null;
/**
* Optional old host filter; when set, only URLs on this host are rewritten.
*/
protected ?string $oldHost = null;
public function handle()
{
// This command only makes sense for instances serving media from a
// cloud/object-storage backend. Local-storage instances (PF_ENABLE_CLOUD
// unset/false) serve media from the app domain and have no cloud
// cdn_url to migrate, so refuse rather than risk rewriting local URLs.
if (! (bool) config_cache('pixelfed.cloud_storage')) {
$this->error('Cloud storage is not enabled (PF_ENABLE_CLOUD is false).');
$this->line('This instance serves media from local storage; there are no cloud media URLs to migrate.');
return 1;
}
// Safe default target = the currently configured cloud disk host,
// driven by AWS_URL in .env. Allow explicit override via --newDomain.
$configuredHost = $this->cloudHost();
$override = $this->normalizeHost($this->option('newDomain'));
$this->newHost = $override ?: $configuredHost;
if (! $this->newHost) {
$this->error('Could not resolve a target host.');
$this->line('The cloud disk ('.config('filesystems.cloud').') did not return a usable URL.');
$this->line('Set AWS_URL in your .env, or pass --newDomain explicitly.');
return 1;
}
// Defensive guard: when auto-detecting the target (no --newDomain
// override), never rewrite media URLs to the app's own domain. That
// would indicate local storage or a misconfigured cloud disk URL
// (AWS_URL). An explicit --newDomain is treated as a deliberate choice.
if (! $override) {
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
if ($appHost && strcasecmp($this->newHost, $appHost) === 0) {
$this->error('Refusing to run: auto-detected target host ('.$this->newHost.') is the app domain.');
$this->line('That indicates local storage or a misconfigured cloud disk URL (AWS_URL).');
$this->line('If you really intend this, pass an explicit --newDomain.');
return 1;
}
}
$this->oldHost = $this->normalizeHost($this->option('oldDomain'));
$id = $this->argument('id');
$all = $this->option('all');
if (! $id && ! $all) {
$this->error('Provide a status id/URL, or pass --all.');
return 1;
}
if ($id && $all) {
$this->error('Pass either a status id or --all, not both.');
return 1;
}
// Show the plan and require explicit approval of the target host.
$this->info('Target host (newDomain): '.$this->newHost.($override ? ' (override)' : ' (from configured cloud disk)'));
$this->info('Filter (oldDomain): '.($this->oldHost ?: 'none — rewriting all stale hosts'));
if ($this->newHost !== $configuredHost) {
$this->warn('Note: target host differs from the configured cloud disk host ('.($configuredHost ?: 'unresolved').').');
}
if (! $this->option('dry-run') && ! $this->option('force')) {
if (! $this->confirm('Rewrite media URLs to "'.$this->newHost.'"?', false)) {
$this->comment('Aborted.');
return 0;
}
}
$this->newLine();
if ($id) {
return $this->handleSingle($id);
}
return $this->handleAll();
}
/**
* Extract a bare host from a domain/URL option value.
*/
protected function normalizeHost(?string $value): ?string
{
$value = trim((string) $value);
if ($value === '') {
return null;
}
// Accept full URLs or bare hosts.
if (str_contains($value, '://')) {
$host = parse_url($value, PHP_URL_HOST);
return $host ?: null;
}
// Strip any accidental path/scheme fragments.
$host = parse_url('https://'.$value, PHP_URL_HOST);
return $host ?: null;
}
protected function handleSingle(string $id): int
{
$statusId = $this->resolveStatusId($id);
if (! $statusId) {
$this->error('Could not extract a status id from "'.$id.'".');
return 1;
}
$status = Status::withTrashed()->find($statusId);
if (! $status) {
$this->error('No status found with id '.$statusId.'.');
return 1;
}
$media = Media::whereStatusId($status->id)->get();
if ($media->isEmpty()) {
$this->comment('Status '.$status->id.' has no media.');
return 0;
}
$fixed = 0;
foreach ($media as $m) {
if ($this->migrateOne($m)) {
$fixed++;
}
}
if ($fixed > 0 && ! $this->option('dry-run')) {
$this->bustCaches($status->id);
}
$this->newLine();
$this->info(($this->option('dry-run') ? 'Would fix ' : 'Fixed ').$fixed.' media row(s) for status '.$status->id.'.');
if ($fixed > 0 && ! $this->option('dry-run')) {
$this->comment('Caches busted for this status.');
}
return 0;
}
protected function handleAll(): int
{
$fixed = 0;
$scanned = 0;
$affectedStatusIds = [];
Media::whereNull('remote_url')
->where(function ($q) {
$q->whereNull('remote_media')->orWhere('remote_media', false);
})
->lazyById(1000, 'id')
->each(function ($m) use (&$fixed, &$scanned, &$affectedStatusIds) {
$scanned++;
if ($this->migrateOne($m)) {
$fixed++;
if ($m->status_id) {
$affectedStatusIds[$m->status_id] = true;
}
}
});
if (! $this->option('dry-run')) {
foreach (array_keys($affectedStatusIds) as $sid) {
$this->bustCaches($sid);
}
}
$this->newLine();
$this->info('Scanned '.$scanned.' local media rows; '.($this->option('dry-run') ? 'would fix ' : 'fixed ').$fixed.'.');
if ($fixed > 0 && ! $this->option('dry-run')) {
$this->comment('Caches busted for '.count($affectedStatusIds).' affected status(es).');
$this->comment('Tip: run `php artisan cache:clear` if any stale URLs remain cached elsewhere.');
}
return 0;
}
/**
* Rebuild any stale URL field on a single media row from its storage path.
* Only writes when a field's host differs from the target host.
*
* @return bool whether the row was (or would be) changed
*/
protected function migrateOne(Media $media): bool
{
// Never touch remote media or rows whose media_path is an absolute URL.
if ($media->remote_media || Str::startsWith((string) $media->media_path, 'http')) {
return false;
}
$changes = [];
// cdn_url and optimized_url are both derived from media_path;
// thumbnail_url is derived from thumbnail_path.
$map = [
'cdn_url' => $media->media_path,
'optimized_url' => $media->media_path,
'thumbnail_url' => $media->thumbnail_path,
];
foreach ($map as $field => $path) {
$current = $media->{$field};
if (! $current) {
// Field not set; leave it as-is (nothing to migrate).
continue;
}
if (! $path) {
// No source path to rebuild from; skip.
continue;
}
$host = parse_url($current, PHP_URL_HOST);
if (! $this->shouldRewrite($host)) {
continue;
}
$rebuilt = $this->targetUrl($path);
if (! $rebuilt) {
continue;
}
$changes[$field] = ['from' => $current, 'to' => $rebuilt];
}
if (empty($changes)) {
return false;
}
$this->warn('media '.$media->id.(($media->status_id) ? ' (status '.$media->status_id.')' : '').':');
foreach ($changes as $field => $c) {
$fromHost = parse_url($c['from'], PHP_URL_HOST);
$this->line(' '.$field.': '.$fromHost.' -> '.$this->newHost);
}
if ($this->option('dry-run')) {
return true;
}
foreach ($changes as $field => $c) {
$media->{$field} = $c['to'];
}
$media->save();
return true;
}
protected function bustCaches($statusId): void
{
MediaService::del($statusId);
StatusService::del($statusId, true);
}
/**
* Decide whether a URL on $currentHost should be rewritten.
* Skips when already on the target host, and honours the optional
* --oldDomain filter.
*/
protected function shouldRewrite(?string $currentHost): bool
{
if (! $currentHost) {
return false;
}
// Already on the target host.
if (strcasecmp($currentHost, $this->newHost) === 0) {
return false;
}
// With --oldDomain, only rewrite that specific host.
if ($this->oldHost !== null && strcasecmp($currentHost, $this->oldHost) !== 0) {
return false;
}
return true;
}
/**
* Build the target URL for a storage path against the target host.
* Uses the configured cloud disk to produce the correct path, then
* swaps in --newDomain when it overrides the configured host.
*/
protected function targetUrl(string $path): ?string
{
$url = $this->diskUrl($path);
if (! $url) {
return null;
}
$diskHost = parse_url($url, PHP_URL_HOST);
if ($diskHost && strcasecmp($diskHost, $this->newHost) !== 0) {
// Override host was requested; swap it into the disk-built URL.
$url = preg_replace('#^(https?://)'.preg_quote($diskHost, '#').'#i', '$1'.$this->newHost, $url);
}
return $url;
}
protected function diskUrl(string $path): ?string
{
try {
return (string) Storage::disk(config('filesystems.cloud'))->url($path);
} catch (\Throwable $e) {
return null;
}
}
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;
}
}
protected function resolveStatusId(string $input): ?string
{
$input = trim($input);
if (ctype_digit($input)) {
return $input;
}
if (preg_match('#/p/[^/]+/(\d+)#', $input, $m)) {
return $m[1];
}
if (preg_match('#(\d{6,})#', $input, $m)) {
return $m[1];
}
return null;
}
}

@ -0,0 +1,319 @@
<?php
namespace App\Console\Commands;
use App\Models\Media;
use App\Models\Status;
use App\Services\AccountService;
use App\Services\MediaService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
class PostStatus extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'post:status {id : Status id, or a post URL like https://host/p/username/ID}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Show detailed debug/metadata for a post (Status) and its media, including stored vs expected media URLs';
/**
* Sensitive/long status columns to redact or trim.
*
* @var array<int, string>
*/
protected $longStatusCols = ['caption', 'cw_summary'];
public function handle()
{
$id = $this->resolveId($this->argument('id'));
if (! $id) {
$this->error('Could not extract a status id from "'.$this->argument('id').'".');
return 1;
}
$status = Status::withTrashed()->find($id);
if (! $status) {
$this->error('No status found with id '.$id.'.');
return 1;
}
$this->line(str_repeat('=', 64));
$this->info('STATUS ROW (table: statuses)');
$this->line(str_repeat('=', 64));
$this->dumpStatus($status);
$this->newLine();
$this->line(str_repeat('=', 64));
$this->info('AUTHOR');
$this->line(str_repeat('=', 64));
$this->dumpAuthor($status);
$this->newLine();
$this->line(str_repeat('=', 64));
$this->info('MEDIA');
$this->line(str_repeat('=', 64));
$this->dumpMedia($status);
$this->newLine();
$this->line(str_repeat('=', 64));
$this->info('URL HEALTH CHECK');
$this->line(str_repeat('=', 64));
$this->urlHealth($status);
$this->newLine();
$this->line(str_repeat('=', 64));
$this->info('CACHE');
$this->line(str_repeat('=', 64));
$this->dumpCache($status);
return 0;
}
/**
* Accept a numeric id or a post URL (…/p/username/ID) and return the id.
*/
protected function resolveId(string $input): ?string
{
$input = trim($input);
if (ctype_digit($input)) {
return $input;
}
// Extract the last numeric path segment from a URL.
if (preg_match('#/p/[^/]+/(\d+)#', $input, $m)) {
return $m[1];
}
if (preg_match('#(\d{6,})#', $input, $m)) {
return $m[1];
}
return null;
}
protected function dumpStatus(Status $status): void
{
$rows = [];
foreach ($status->getAttributes() as $key => $value) {
$display = $this->format($value);
if (in_array($key, $this->longStatusCols, true) && is_string($value) && strlen($value) > 60) {
$display = mb_strimwidth($value, 0, 60, '…');
}
$rows[] = [$key, $display];
}
$this->table(['Column', 'Value'], $rows);
$this->comment('Computed:');
$this->line(' url(): '.$this->safe(fn () => $status->url()));
$this->line(' permalink(): '.$this->safe(fn () => $status->permalink()));
$this->line(' is remote: '.($status->uri ? 'yes ('.$status->uri.')' : 'no (local)'));
if ($status->deleted_at) {
$this->error(' ✗ Status is soft-deleted ('.$status->deleted_at.').');
}
}
protected function dumpAuthor(Status $status): void
{
$acct = AccountService::get($status->profile_id, true);
if (! $acct) {
$this->error('No account found for profile_id '.$status->profile_id.'.');
return;
}
$this->table(['Field', 'Value'], [
['profile_id', $status->profile_id],
['username', $acct['username'] ?? 'null'],
['acct', $acct['acct'] ?? 'null'],
['url', $acct['url'] ?? 'null'],
['local', ($acct['local'] ?? null) ? 'true' : 'false'],
]);
}
protected function dumpMedia(Status $status): void
{
$media = Media::withTrashed()->whereStatusId($status->id)->orderBy('order')->get();
if ($media->isEmpty()) {
$this->comment('No media attached to this status.');
return;
}
$cloudHost = $this->cloudHost();
foreach ($media as $i => $m) {
$this->newLine();
$this->comment('Media #'.($i + 1).' (id '.$m->id.', order '.$m->order.')');
$rows = [];
$cols = [
'media_path', 'thumbnail_path', 'cdn_url', 'thumbnail_url',
'optimized_url', 'remote_url', 'remote_media', 'mime', 'size',
'version', 'replicated_at', 'original_sha256', 'processed_at',
'deleted_at',
];
$attrs = $m->getAttributes();
foreach ($cols as $c) {
if (array_key_exists($c, $attrs)) {
$rows[] = [$c, $this->format($attrs[$c])];
}
}
$this->table(['Media Column', 'Value'], $rows);
$this->line(' computed url(): '.$this->safe(fn () => $m->url()));
$this->line(' computed thumbnailUrl(): '.$this->safe(fn () => $m->thumbnailUrl()));
$this->line(' expected (from path): '.$this->expectedUrl($m->media_path));
// Per-field host comparison.
$this->compareHost(' cdn_url', $m->cdn_url, $cloudHost);
$this->compareHost(' thumbnail_url', $m->thumbnail_url, $cloudHost);
$this->compareHost(' optimized_url', $m->optimized_url, $cloudHost);
}
}
protected function urlHealth(Status $status): void
{
$cloudHost = $this->cloudHost();
if (! $cloudHost) {
$this->comment('Cloud storage not configured (or no cloud disk url); skipping host comparison.');
return;
}
$this->line('Configured cloud host (correct base): '.$cloudHost);
$this->newLine();
$media = Media::withTrashed()->whereStatusId($status->id)->get();
$stale = [];
foreach ($media as $m) {
if ($m->remote_media || Str::startsWith((string) $m->media_path, 'http')) {
continue;
}
foreach (['cdn_url', 'thumbnail_url', 'optimized_url'] as $field) {
$val = $m->{$field};
if (! $val) {
continue;
}
$host = parse_url($val, PHP_URL_HOST);
if ($host && $cloudHost && strcasecmp($host, $cloudHost) !== 0) {
$stale[] = 'media '.$m->id.' '.$field.' points at '.$host.' (expected '.$cloudHost.')';
}
}
}
if ($stale) {
$this->error('STALE MEDIA URLS DETECTED:');
foreach ($stale as $s) {
$this->line(' ✗ '.$s);
}
$this->newLine();
$this->comment('Fix with: php artisan admin:MigrateLocalMediaURL '.$status->id);
$this->comment('(or --all to scan every local media row)');
} else {
$this->info('All local media URLs point at the configured cloud host. ✓');
}
}
protected function dumpCache(Status $status): void
{
$cached = MediaService::get($status->id);
if (empty($cached)) {
$this->comment('No cached media_attachments entry (MediaService).');
return;
}
$this->comment('Cached media_attachments (MediaService, 6h TTL) — served to clients:');
foreach ($cached as $i => $item) {
$this->line(' ['.$i.'] url: '.($item['url'] ?? 'null'));
$this->line(' ['.$i.'] preview_url: '.($item['preview_url'] ?? 'null'));
}
$this->newLine();
$this->comment('If these still show a stale host after a DB fix, run: php artisan cache:clear');
}
protected function compareHost(string $label, ?string $url, ?string $cloudHost): void
{
if (! $url) {
return;
}
$host = parse_url($url, PHP_URL_HOST);
if (! $host || ! $cloudHost) {
return;
}
if (strcasecmp($host, $cloudHost) !== 0) {
$this->line('<fg=red>'.$label.' host: '.$host.' ✗ (expected '.$cloudHost.')</>');
} else {
$this->line('<fg=green>'.$label.' host: '.$host.' ✓</>');
}
}
protected function expectedUrl(?string $mediaPath): string
{
if (! $mediaPath || Str::startsWith($mediaPath, 'http')) {
return $mediaPath ?? 'null';
}
try {
return (string) Storage::disk(config('filesystems.cloud'))->url($mediaPath);
} catch (\Throwable $e) {
return '(cloud disk not resolvable in this environment)';
}
}
protected function cloudHost(): ?string
{
try {
if (! (bool) config_cache('pixelfed.cloud_storage')) {
// Still try to read the configured cloud disk host for reference.
}
$url = Storage::disk(config('filesystems.cloud'))->url('probe');
$host = parse_url($url, PHP_URL_HOST);
return $host ?: null;
} catch (\Throwable $e) {
return null;
}
}
protected function safe(callable $fn): string
{
try {
return (string) ($fn() ?? 'null');
} catch (\Throwable $e) {
return 'error: '.$e->getMessage();
}
}
protected function format($value): string
{
if ($value === null) {
return 'null';
}
if (is_bool($value)) {
return $value ? 'true' : 'false';
}
if ($value === '') {
return '(empty string)';
}
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
return (string) $value;
}
}

@ -0,0 +1,189 @@
<?php
use App\Models\Media;
use App\Models\Status;
use App\Models\User;
use App\Services\ConfigCacheService;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use Illuminate\Support\Facades\Config;
uses(LazilyRefreshDatabase::class);
/*
|--------------------------------------------------------------------------
| admin:MigrateLocalS3MediaURL
|--------------------------------------------------------------------------
|
| Rebuilds stale local media URLs (cdn_url, thumbnail_url, optimized_url)
| from their storage paths using the configured cloud disk host.
|
*/
beforeEach(function () {
// Point the cloud disk at a deterministic host with path-style urls so
// Storage::disk('s3')->url($path) == https://cdn.test/<path>.
Config::set('filesystems.cloud', 's3');
Config::set('filesystems.disks.s3', [
'driver' => 's3',
'key' => 'test',
'secret' => 'test',
'region' => 'us-east-1',
'bucket' => 'bucket',
'url' => 'https://cdn.test',
'endpoint' => 'https://cdn.test',
'use_path_style_endpoint' => true,
'visibility' => 'public',
]);
// Enable cloud storage. config_cache() falls through to config() when
// instance.enable_cc is off (as in CI), so set both to be safe.
Config::set('pixelfed.cloud_storage', true);
ConfigCacheService::put('pixelfed.cloud_storage', true);
});
function makeStatusWithStaleMedia(string $staleHost = 'https://s3.old.example'): Media
{
$user = User::factory()->create();
$user->refresh();
$pid = $user->profile->id;
$status = Status::factory()->create(['profile_id' => $pid, 'type' => 'video']);
$path = 'public/m/_v2/'.$pid.'/aa/bb/file.mp4';
$thumbPath = 'public/m/_v2/'.$pid.'/aa/bb/file_thumb.jpeg';
return Media::create([
'status_id' => $status->id,
'profile_id' => $pid,
'user_id' => $user->id,
'media_path' => $path,
'thumbnail_path' => $thumbPath,
'cdn_url' => 'https://cdn.test/'.$path, // already correct
'thumbnail_url' => $staleHost.'/'.$thumbPath, // stale
'optimized_url' => $staleHost.'/'.$path, // stale
'mime' => 'video/mp4',
'remote_media' => false,
'order' => 0,
]);
}
it('rebuilds stale thumbnail_url and optimized_url but leaves correct cdn_url', function () {
$media = makeStatusWithStaleMedia();
$this->artisan('admin:MigrateLocalS3MediaURL', ['id' => (string) $media->status_id, '--force' => true])
->assertExitCode(0);
$media->refresh();
expect($media->cdn_url)->toBe('https://cdn.test/'.$media->media_path);
expect($media->thumbnail_url)->toBe('https://cdn.test/'.$media->thumbnail_path);
expect($media->optimized_url)->toBe('https://cdn.test/'.$media->media_path);
expect(parse_url($media->thumbnail_url, PHP_URL_HOST))->toBe('cdn.test');
expect(parse_url($media->optimized_url, PHP_URL_HOST))->toBe('cdn.test');
});
it('does not change anything in dry-run mode', function () {
$media = makeStatusWithStaleMedia();
$originalThumb = $media->thumbnail_url;
$this->artisan('admin:MigrateLocalS3MediaURL', ['id' => (string) $media->status_id, '--dry-run' => true])
->assertExitCode(0);
expect($media->fresh()->thumbnail_url)->toBe($originalThumb);
});
it('leaves already-correct media untouched', function () {
$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/ok.jpg';
$media = Media::create([
'status_id' => $status->id,
'profile_id' => $pid,
'media_path' => $path,
'cdn_url' => 'https://cdn.test/'.$path,
'thumbnail_url' => 'https://cdn.test/'.$path,
'mime' => 'image/jpeg',
'remote_media' => false,
'order' => 0,
]);
$updatedAt = $media->fresh()->updated_at;
$this->artisan('admin:MigrateLocalS3MediaURL', ['id' => (string) $status->id, '--force' => true])
->assertExitCode(0);
expect($media->fresh()->updated_at->eq($updatedAt))->toBeTrue();
});
it('never rewrites remote media', function () {
$user = User::factory()->create();
$user->refresh();
$pid = $user->profile->id;
$status = Status::factory()->create(['profile_id' => $pid, 'type' => 'photo']);
$media = Media::create([
'status_id' => $status->id,
'profile_id' => $pid,
'media_path' => 'https://remote.example/image.jpg',
'cdn_url' => 'https://s3.old.example/image.jpg',
'remote_media' => true,
'remote_url' => 'https://remote.example/image.jpg',
'mime' => 'image/jpeg',
'order' => 0,
]);
$this->artisan('admin:MigrateLocalS3MediaURL', ['id' => (string) $status->id, '--force' => true])
->assertExitCode(0);
// Unchanged: remote media is skipped.
expect($media->fresh()->cdn_url)->toBe('https://s3.old.example/image.jpg');
});
it('requires an id or --all', function () {
$this->artisan('admin:MigrateLocalS3MediaURL')
->assertExitCode(1);
});
it('refuses to run on a local-storage instance', function () {
// Simulate local storage: cloud disabled (set both, see beforeEach).
Config::set('pixelfed.cloud_storage', false);
ConfigCacheService::put('pixelfed.cloud_storage', false);
$this->artisan('admin:MigrateLocalS3MediaURL', ['--all' => true, '--force' => true])
->expectsOutputToContain('Cloud storage is not enabled')
->assertExitCode(1);
});
it('with --oldDomain only rewrites URLs on that host', function () {
// thumbnail_url on s3.old.example, optimized_url on other.example.
$media = makeStatusWithStaleMedia('https://s3.old.example');
$media->optimized_url = 'https://other.example/'.$media->media_path;
$media->save();
$this->artisan('admin:MigrateLocalS3MediaURL', [
'id' => (string) $media->status_id,
'--oldDomain' => 's3.old.example',
'--force' => true,
])->assertExitCode(0);
$media->refresh();
// Matched the filter -> rewritten.
expect(parse_url($media->thumbnail_url, PHP_URL_HOST))->toBe('cdn.test');
// Did NOT match the filter -> left as-is.
expect(parse_url($media->optimized_url, PHP_URL_HOST))->toBe('other.example');
});
it('with --newDomain override rewrites to the given host', function () {
$media = makeStatusWithStaleMedia('https://s3.old.example');
$this->artisan('admin:MigrateLocalS3MediaURL', [
'id' => (string) $media->status_id,
'--newDomain' => 'media.example',
'--force' => true,
])->assertExitCode(0);
$media->refresh();
expect(parse_url($media->thumbnail_url, PHP_URL_HOST))->toBe('media.example');
expect(parse_url($media->optimized_url, PHP_URL_HOST))->toBe('media.example');
});
Loading…
Cancel
Save