feat: add admin:resyncemoji to re-download remote emoji from origin

Re-fetches remote custom emoji media from image_remote_url (SSRF-hardened via
SecureMediaFetchService) and stores it on the active disk. Useful to repair
emoji whose stored file went missing.

- --missingonly checks each emoji's file on the active disk (cloud when cloud
  storage is enabled) and only re-downloads the ones that are absent
- --dry-run, --limit, --force
- CustomEmojiService::resync() does the per-emoji fetch+store; CustomEmoji
  gains a mediaExists() helper
feat/emoji-cloud-storage-v2
Your Name 4 weeks ago
parent fdd6c4211d
commit 5764c135f5

@ -0,0 +1,114 @@
<?php
namespace App\Console\Commands\Admin;
use App\Models\CustomEmoji;
use App\Services\CustomEmojiService;
use Illuminate\Console\Command;
class ResyncEmoji extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'admin:resyncemoji
{--missingonly : Only resync emoji whose stored media file is missing on the active disk}
{--limit=0 : Max emoji to process this run (0 = no limit)}
{--dry-run : Report what would happen without downloading or writing}
{--force : Skip confirmation prompts}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Re-download remote custom emoji media from their origin (image_remote_url) and store on the active disk.';
public function handle(): int
{
if (! (bool) config_cache('federation.custom_emoji.enabled')) {
$this->error('Custom emoji federation is not enabled (federation.custom_emoji.enabled is false).');
return self::FAILURE;
}
$missingOnly = (bool) $this->option('missingonly');
$dryRun = (bool) $this->option('dry-run');
$limit = max(0, (int) $this->option('limit'));
// Only remote emoji can be re-fetched (they carry an origin URL).
$query = CustomEmoji::whereNotNull('image_remote_url')
->orderBy('id')
->when($limit > 0, fn ($q) => $q->limit($limit));
$candidates = $query->get();
$totalCandidates = $candidates->count();
if ($totalCandidates === 0) {
$this->info('No remote emoji found to resync.');
return self::SUCCESS;
}
// With --missingonly, keep only those whose file is absent on the
// active disk (checks cloud when cloud storage is enabled).
if ($missingOnly) {
$this->info("Checking {$totalCandidates} remote emoji for missing media...");
$bar = $this->output->createProgressBar($totalCandidates);
$bar->start();
$candidates = $candidates->reject(function (CustomEmoji $emoji) use ($bar) {
$exists = CustomEmoji::mediaExists($emoji->media_path);
$bar->advance();
return $exists; // reject those that already exist
})->values();
$bar->finish();
$this->newLine(2);
}
$total = $candidates->count();
if ($total === 0) {
$this->info('Nothing to resync'.($missingOnly ? '; all remote emoji media present.' : '.'));
return self::SUCCESS;
}
if ($dryRun) {
$this->info("[dry-run] Would resync {$total} emoji from origin.");
return self::SUCCESS;
}
if (! $this->option('force') && ! $this->confirm("Resync {$total} emoji from their origin servers?", true)) {
$this->comment('Aborted.');
return self::SUCCESS;
}
$resynced = 0;
$skipped = 0;
$failed = 0;
$bar = $this->output->createProgressBar($total);
$bar->start();
foreach ($candidates as $emoji) {
$result = CustomEmojiService::resync($emoji);
match ($result) {
'resynced' => $resynced++,
'skipped' => $skipped++,
default => $failed++,
};
$bar->advance();
}
$bar->finish();
$this->newLine(2);
$this->info("Done. resynced={$resynced} skipped={$skipped} failed={$failed}.");
return $failed ? self::FAILURE : self::SUCCESS;
}
}

@ -106,6 +106,20 @@ class CustomEmoji extends Model
}
}
/**
* Whether the emoji media for the given media_path exists on the active disk.
*/
public static function mediaExists(?string $mediaPath): bool
{
if (! $mediaPath) {
return false;
}
$target = self::storageTarget();
return $target['disk']->exists($target['prefix'].$mediaPath);
}
public static function scan($text, $activitypub = false)
{
if ((bool) config_cache('federation.custom_emoji.enabled') == false) {

@ -177,6 +177,62 @@ class CustomEmojiService
return true;
}
/**
* Re-download a remote emoji's media from its origin (image_remote_url)
* and store it on the active disk. Used by admin:resyncemoji to repair
* emoji whose stored file is missing.
*
* @return string one of: resynced|skipped|failed
*/
public static function resync(CustomEmoji $emoji): string
{
// Only remote emoji have an origin URL to re-fetch from.
if (empty($emoji->image_remote_url)) {
return 'skipped';
}
$url = Helpers::validateUrl($emoji->image_remote_url);
if ($url === false) {
return 'skipped';
}
if (! self::headCheck($url)) {
return 'failed';
}
// Preserve the existing media_path when present; otherwise derive one
// from the emoji id and the origin URL's extension.
$mediaPath = $emoji->media_path;
if (! $mediaPath) {
$ext = pathinfo(parse_url($url, PHP_URL_PATH) ?? '', PATHINFO_EXTENSION);
$ext = $ext ? '.'.strtolower($ext) : '.png';
$mediaPath = 'emoji/'.$emoji->id.$ext;
}
try {
$maxSize = (int) config('federation.custom_emoji.max_size');
$body = SecureMediaFetchService::get($url, $maxSize > 0 ? $maxSize : null);
if ($body === false) {
return 'failed';
}
CustomEmoji::storeMedia($mediaPath, $body);
if ($emoji->media_path !== $mediaPath) {
$emoji->media_path = $mediaPath;
$emoji->save();
}
} catch (\Throwable $e) {
return 'failed';
}
Cache::forget('pf:custom_emoji');
Cache::forget('pf:custom_emoji:'.str_replace(':', '', (string) $emoji->shortcode));
return 'resynced';
}
public static function all()
{
return Cache::rememberForever('pf:custom_emoji', function () {

Loading…
Cancel
Save