From b892a4150ac5b3ad9e27b1a1087be51e894498ae Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 30 Oct 2026 11:51:22 +0800 Subject: [PATCH] Serve article illustrations as deduplicated local static files --- .gitignore | 1 + .../Commands/SyncLocalIllustrations.php | 52 ++++++ app/Providers/AppServiceProvider.php | 2 +- app/Services/IllustrationPool.php | 51 ++++-- app/Services/LocalIllustrations.php | 164 ++++++++++++++++++ config/illustrations.php | 4 + docs/deployment/illustrations-static.nginx | 10 ++ docs/maintenance/LOCAL_ILLUSTRATIONS.md | 39 +++++ .../views/components/app-layout.blade.php | 2 +- tests/Feature/LocalIllustrationsTest.php | 128 ++++++++++++++ 10 files changed, 439 insertions(+), 14 deletions(-) create mode 100644 app/Console/Commands/SyncLocalIllustrations.php create mode 100644 app/Services/LocalIllustrations.php create mode 100644 docs/deployment/illustrations-static.nginx create mode 100644 docs/maintenance/LOCAL_ILLUSTRATIONS.md create mode 100644 tests/Feature/LocalIllustrationsTest.php diff --git a/.gitignore b/.gitignore index 2437933e..ed80f4f0 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ Thumbs.db !/_env/* ./vite.config.js /node_modules +/public/images/illustrations diff --git a/app/Console/Commands/SyncLocalIllustrations.php b/app/Console/Commands/SyncLocalIllustrations.php new file mode 100644 index 00000000..96acb5e4 --- /dev/null +++ b/app/Console/Commands/SyncLocalIllustrations.php @@ -0,0 +1,52 @@ +error('Resource binding is not enabled for this environment'); + return self::FAILURE; + } + try { + $snapshots = []; + $expected = []; + foreach (config('illustrations.categories') as $category => $pool) { + $keys = Articles::where('iCategoryId', $category)->where('iStatus', 1)->orderBy('created_at')->orderBy('id')->pluck('sSlug')->all(); + IllustrationPool::forget($pool); + $snapshot = IllustrationPool::sourceSnapshot($pool); + $missing = array_values(array_diff($keys, array_keys($snapshot['selections']), array_keys($snapshot['unavailable'] ?? []))); + if ($missing && ! $this->option('assign-missing')) throw new \RuntimeException('Missing article assignments; rerun with --assign-missing using a write-scoped consumer'); + foreach (array_chunk($missing, 200) as $chunk) IllustrationPool::assignSource($pool, $chunk); + if ($missing) $snapshot = IllustrationPool::sourceSnapshot($pool); + $snapshots[$pool] = $snapshot; + $expected[$pool] = $keys; + } + $result = $local->synchronize($snapshots); + foreach ($expected as $pool => $keys) { + foreach ($keys as $key) { + if (! $local->thumbnail($pool, $key)) $result['pending'][$pool.':'.$key] ??= 'local_copy_unavailable'; + } + } + ResponseCache::clear(); + \Illuminate\Support\Facades\Cache::forget('article_order_likes'); + $this->line(json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)); + return $result['pending'] ? self::FAILURE : self::SUCCESS; + } catch (\Throwable) { + // Never print request/credential exceptions or leave partial manifest writes. + $this->error('Local illustration sync failed; existing manifest retained if publication did not complete. Retry after checking Resource and filesystem access.'); + return self::FAILURE; + } + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index e1237765..6b65689b 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -12,7 +12,7 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->scoped(\App\Services\LocalIllustrations::class); } /** diff --git a/app/Services/IllustrationPool.php b/app/Services/IllustrationPool.php index 57394baf..233c991c 100644 --- a/app/Services/IllustrationPool.php +++ b/app/Services/IllustrationPool.php @@ -45,17 +45,7 @@ public static function snapshot(string $pool): array { return Cache::store('file')->remember('illustrations.v2.'.$pool, 60, function () use ($pool) { try { - $data = self::request('GET', 'pools/'.$pool); - $seen = []; - while (!empty($data['next'])) { - $after = $data['next']; - if (isset($seen[$after])) throw new \RuntimeException('Resource pagination did not advance'); - $seen[$after] = true; - $page = self::request('GET', 'pools/'.$pool.'?after='.rawurlencode($after)); - $data['selections'] = array_replace($data['selections'], $page['selections']); - $data['unavailable'] = array_replace($data['unavailable'] ?? [], $page['unavailable'] ?? []); - $data['next'] = $page['next']; - } + $data = self::sourceSnapshot($pool); Cache::store('file')->forever('illustrations.v2.last-good.'.$pool, $data); return $data; } catch (\Throwable $e) { @@ -71,7 +61,41 @@ public static function forget(string $pool): void Cache::store('file')->forget('illustrations.v2.'.$pool); } + public static function sourceSnapshot(string $pool): array + { + $data = self::request('GET', 'pools/'.$pool); + $seen = []; + while (! empty($data['next'])) { + $after = $data['next']; + if (isset($seen[$after])) throw new \RuntimeException('Resource pagination did not advance'); + $seen[$after] = true; + $page = self::request('GET', 'pools/'.$pool.'?after='.rawurlencode($after)); + $data['selections'] = array_replace($data['selections'], $page['selections']); + $data['unavailable'] = array_replace($data['unavailable'] ?? [], $page['unavailable'] ?? []); + $data['next'] = $page['next']; + } + return $data; + } + public static function assign(string $pool, array $keys): array + { + $selections = self::assignSource($pool, $keys); + if (LocalIllustrations::enabled()) { + foreach ($selections as $key => &$image) { + $image['url'] = app(LocalIllustrations::class)->storeSelection($pool, $key, $image); + } + unset($image); + } + return $selections; + } + + public static function assetMetadata(string $id): array + { + if (! preg_match('/\A[a-f0-9-]{36}\z/D', $id)) throw new \RuntimeException('Invalid illustration asset'); + return self::request('GET', 'assets/'.$id); + } + + public static function assignSource(string $pool, array $keys): array { $data = self::request('POST', 'pools/'.$pool.'/selections', ['keys' => $keys]); self::forget($pool); @@ -85,9 +109,12 @@ public static function forArticle(string $pool, string $slug): string public static function thumbnail(array $article, ?string $original): ?string { - if (!self::enabled()) return $original; $pool = config('illustrations.categories.'.($article['iCategoryId'] ?? '')); if (!$pool || empty($article['sSlug'])) return $original; + if (LocalIllustrations::enabled()) { + return app(LocalIllustrations::class)->thumbnail($pool, $article['sSlug']); + } + if (!self::enabled()) return $original; try { $snapshot = self::snapshot($pool); $reason = $snapshot['unavailable'][$article['sSlug']] ?? null; diff --git a/app/Services/LocalIllustrations.php b/app/Services/LocalIllustrations.php new file mode 100644 index 00000000..91af9652 --- /dev/null +++ b/app/Services/LocalIllustrations.php @@ -0,0 +1,164 @@ +manifest !== null) return $this->manifest; + $path = config('illustrations.local_manifest'); + try { + $value = is_file($path) && ! is_link($path) ? json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR) : []; + return $this->manifest = ($value['version'] ?? null) === 1 ? $value : ['version' => 1, 'assets' => [], 'selections' => []]; + } catch (\Throwable) { + // Public requests never fetch Resource, hash files or repair a missing manifest. + return $this->manifest = ['version' => 1, 'assets' => [], 'selections' => []]; + } + } + + public function thumbnail(string $pool, string $slug): ?string + { + $state = $this->read(); + $id = $state['selections'][$pool][$slug] ?? null; + $url = $id ? ($state['assets'][$id]['url'] ?? null) : null; + return is_string($url) && preg_match('~\A'.preg_quote(config('illustrations.local_url_prefix'), '~').'/[a-f0-9]{64}\.(?:webp|png|jpg|gif)\z~D', $url) ? $url : null; + } + + private function locked(callable $callback): mixed + { + $dir = dirname(config('illustrations.local_manifest')); + if (! is_dir($dir) && ! mkdir($dir, 0700, true) && ! is_dir($dir)) throw new \RuntimeException('Local illustration state directory unavailable'); + if (is_link($dir)) throw new \RuntimeException('Local illustration state must not be a symlink'); + $lock = fopen($dir.'/state.lock', 'c'); + if (! $lock || ! flock($lock, LOCK_EX)) throw new \RuntimeException('Local illustration lock unavailable'); + try { + $this->manifest = null; + return $callback(); + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private function save(array $state): void + { + $state['version'] = 1; + $state['updated_at'] = gmdate('c'); + $path = config('illustrations.local_manifest'); + $tmp = tempnam(dirname($path), '.manifest-'); + try { + chmod($tmp, 0600); + $bytes = json_encode($state, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + if (file_put_contents($tmp, $bytes) !== strlen($bytes) || ! rename($tmp, $path)) throw new \RuntimeException('Local illustration manifest update failed'); + $this->manifest = $state; + } finally { + if (is_file($tmp)) unlink($tmp); + } + } + + private function copy(array $image, array $assets): array + { + $id = $image['asset_id'] ?? ''; + $source = $image['url'] ?? ''; + $base = rtrim(config('illustrations.base_url'), '/'); + if (! preg_match('/\A[a-f0-9-]{36}\z/D', $id) || ! preg_match('~\A'.preg_quote($base, '~').'/media/[a-f0-9-]{36}\z~D', $source)) throw new \RuntimeException('Unregistered illustration source'); + $meta = $assets[$id] ?? IllustrationPool::assetMetadata($id); + $sha = $meta['sha256'] ?? ''; + $mime = $meta['mime'] ?? ''; + $ext = ['image/webp' => 'webp', 'image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif'][$mime] ?? null; + $size = (int) ($meta['bytes'] ?? 0); + if (! $ext || ! preg_match('/\A[a-f0-9]{64}\z/D', $sha) || $size < 1 || $size > 20971520) throw new \RuntimeException('Invalid illustration metadata'); + $dir = config('illustrations.local_directory'); + if (is_link($dir) || (! is_dir($dir) && ! mkdir($dir, 0755, true) && ! is_dir($dir))) throw new \RuntimeException('Local illustration directory unavailable'); + $path = $dir.'/'.$sha.'.'.$ext; + if (is_link($path)) throw new \RuntimeException('Local illustration file must not be a symlink'); + if (! is_file($path) || filesize($path) !== $size || ! hash_equals($sha, hash_file('sha256', $path))) { + $tmp = tempnam(dirname(config('illustrations.local_manifest')), '.image-'); + $out = null; + $stream = null; + try { + $response = Http::withOptions(['proxy' => '', 'allow_redirects' => false, 'stream' => true])->withUserAgent('FNA-V3/LocalIllustrations')->connectTimeout(3)->timeout(30)->get($source); + if (! $response->successful()) throw new \RuntimeException('Illustration download failed'); + $stream = $response->toPsrResponse()->getBody(); + $out = fopen($tmp, 'wb'); + $bytes = 0; + $hash = hash_init('sha256'); + while (! $stream->eof()) { + $chunk = $stream->read(65536); + $bytes += strlen($chunk); + if ($bytes > 20971520 || fwrite($out, $chunk) !== strlen($chunk)) throw new \RuntimeException('Invalid illustration download size'); + hash_update($hash, $chunk); + } + fclose($out);$out = null; + $info = @getimagesize($tmp); + if ($bytes !== $size || ! hash_equals($sha, hash_final($hash)) || ! $info || ($info['mime'] ?? '') !== $mime || $info[0] * $info[1] > 10000000) throw new \RuntimeException('Illustration download integrity failed'); + chmod($tmp, 0644); + if (! rename($tmp, $path)) throw new \RuntimeException('Local illustration publication failed'); + } finally { + if (is_resource($out)) fclose($out); + if ($stream) $stream->close(); + if (is_file($tmp)) unlink($tmp); + } + } + return ['asset_id' => $id, 'sha256' => $sha, 'mime' => $mime, 'bytes' => $size, + 'url' => config('illustrations.local_url_prefix').'/'.$sha.'.'.$ext, 'source_url' => $source]; + } + + public function storeSelection(string $pool, string $slug, array $image): string + { + return $this->locked(function () use ($pool, $slug, $image) { + $state = $this->read(); + $asset = $this->copy($image, $state['assets']); + $state['assets'][$image['asset_id']] = $asset; + $state['selections'][$pool][$slug] = $image['asset_id']; + $this->save($state); + return $asset['url']; + }); + } + + /** One atomic manifest switch after all downloadable files pass verification. */ + public function synchronize(array $snapshots): array + { + return $this->locked(function () use ($snapshots) { + $state = $this->read(); + $pending = []; + foreach ($snapshots as $pool => $snapshot) { + $available = []; + foreach (array_merge($snapshot['items'], array_values($snapshot['selections'])) as $image) { + $id = $image['asset_id']; + if (isset($available[$id])) continue; + $state['assets'][$id] = $this->copy($image, $state['assets']); + $available[$id] = true; + } + $selections = []; + foreach ($snapshot['selections'] as $slug => $image) $selections[$slug] = $image['asset_id']; + foreach ($snapshot['unavailable'] ?? [] as $slug => $reason) { + $id = $state['selections'][$pool][$slug] ?? null; + $old = $id ? ($state['assets'][$id] ?? null) : null; + $local = $old ? config('illustrations.local_directory').'/'.basename($old['url']) : null; + // A healthy independent display copy survives physical loss at the source. + if (in_array($reason, ['file_missing', 'file_corrupt', 'file_symlink']) && $local && is_file($local) && ! is_link($local) && hash_equals($old['sha256'], hash_file('sha256', $local))) { + $selections[$slug] = $id; + } else { + $selections[$slug] = null; + $pending[$pool.':'.$slug] = $reason; + } + } + $state['selections'][$pool] = $selections; + } + $this->save($state); + return ['assets' => count($state['assets']), 'selections' => array_map('count', $state['selections']), 'pending' => $pending]; + }); + } +} diff --git a/config/illustrations.php b/config/illustrations.php index 84bad6f0..6f97a2bf 100644 --- a/config/illustrations.php +++ b/config/illustrations.php @@ -4,5 +4,9 @@ 'enabled' => (bool) env('RESOURCE_V2_ILLUSTRATIONS_ENABLED', env('LOCAL_ILLUSTRATIONS_ENABLED', env('APP_ENV') === 'local')), 'base_url' => env('RESOURCE_V2_URL', 'http://resource.loc:8612'), 'credential_file' => env('RESOURCE_V2_CREDENTIAL_FILE'), + 'local_enabled' => (bool) env('LOCAL_ILLUSTRATIONS_MIRROR_ENABLED', false), + 'local_directory' => public_path('images/illustrations'), + 'local_manifest' => storage_path('app/private/illustrations/manifest.json'), + 'local_url_prefix' => '/images/illustrations', 'categories' => [1 => 'freenode', 9 => 'shadowrocketids'], ]; diff --git a/docs/deployment/illustrations-static.nginx b/docs/deployment/illustrations-static.nginx new file mode 100644 index 00000000..add3da9a --- /dev/null +++ b/docs/deployment/illustrations-static.nginx @@ -0,0 +1,10 @@ +# Inside the site's HTTPS server block; files are verified before publication. +# Hash URLs can be cached; retirement must replace article mappings and clear caches. +location ^~ /images/illustrations/ { + if ($uri !~ "^/images/illustrations/[a-f0-9]{64}\.(webp|png|jpg|gif)$") { return 404; } + try_files $uri =404; + etag on; + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header X-Content-Type-Options nosniff always; + add_header X-Illustration-Delivery static always; +} diff --git a/docs/maintenance/LOCAL_ILLUSTRATIONS.md b/docs/maintenance/LOCAL_ILLUSTRATIONS.md new file mode 100644 index 00000000..1395d8ec --- /dev/null +++ b/docs/maintenance/LOCAL_ILLUSTRATIONS.md @@ -0,0 +1,39 @@ +# FNA 静态配图副本 + +用户2026-10-30确认:Resource保留素材/图池/固定分配,FNA保存公开展示副本,由本站Nginx提供图片。仅猫狗资源栏目,教程图片沿原路径不改;不改变Resource的其他调用方。 + +## 数据与请求路径 + +- `public/images/illustrations/.`:图片本体,按内容去重,同图多篇只存一份。运行数据不进入Git。 +- `storage/app/private/illustrations/manifest.json`:资产元数据和pool→slug→asset映射,0600;只含已公开图片地址,不含消费者密钥。写入加锁并通过rename原子切换,读取不加写锁。 +- `LOCAL_ILLUSTRATIONS_MIRROR_ENABLED=true`:启用本地展示;Resource原开关与精确环境基址仍用于接收/同步。 +- 文章模型读取只读本地清单,请求内复用解析结果;不发Resource请求、不查图片数据库、不算文件hash,也不在用户访问时下载或补图。 +- 浏览器请求固定哈希地址,Nginx直接返回文件,支持ETag/Last-Modified/304,缓存一年且immutable;规则见[静态配置](../deployment/illustrations-static.nginx)。staging还须在该location保留noindex头。不存在或格式不符的路径直接404,不能回落Laravel动态路由。 + +## 创建文章和同步 + +新文章接收仍先向Resource取得持久分配,然后下载副本;核对注册域名/公开media UUID、大小上限20MiB、SHA-256及真实图片格式/像素上限,最后原子发布文件/清单,成功后才返回本地URL交给文章保存。相同内容复用文件,重试沿用原配图;下载失败不写入无效清单或返回远程URL。 + +```sh +php artisan illustrations:sync-local +# 仅在确需为既有公开文章补建Resource分配、且持有write权限时: +php artisan illustrations:sync-local --assign-missing +``` + +命令强制读取Resource实时快照,不采用失败回退缓存;预存池内图片和固定选择,整批校验完成后切换清单,并清站点响应/点赞侧栏缓存。清单切换前失败保留旧清单,未被引用的完整文件可留待后续复用,不自动清理。同步不发布/修改文章正文,不影响点赞计划、URL或计数。不新增服务器cron/后台任务。 + +## 不满意、撤回与删除 + +要替换图片,先在Resource修改受影响文章的selection,再同步FNA并刷新页面缓存;新哈希URL自然换图。仅移出pool不改变旧selection,扩池也不洗牌旧文章。 + +Resource物理文件丢失时,已验证的FNA副本仍可继续展示;本地文件丢失时浏览器会404,运行同步可重新下载,访问请求不负责修复。主动撤回/取消授权等明确状态在同步后从页面映射停止展示。同步不自动删除任何共享副本;退役需先确认无文章/正文引用,再清理指定副本及相关CDN缓存。 + +长期公开缓存意味着不能承诺撤销Resource后立即让所有历史URL失效。FNA副本是独立公开发布物,需单独处理引用、源站文件及缓存;不要把Resource的即时撤回语义套到FNA静态副本。 + +## 部署与恢复 + +先备份代码、环境、Nginx及旧清单;基于当前正式提交构造仅本次文件的Git提交并推送,保留已上线点赞功能和并行生成模板。先test检出→同步→核对覆盖和哈希→启用开关/静态location→验收,再沿同一提交更新正式并执行相同迁移。test和正式各保存一份副本/清单,不共享私有凭据。 + +回退仅关闭LOCAL_ILLUSTRATIONS_MIRROR_ENABLED并清配置/响应缓存,恢复原Resource显示路径;保留副本及Resource引用,不删除资产或回灌整库。新接收文章原始sThumb可能已经是本地URL,回退时继续保留静态文件和location。不要通过直接删除图片测试生产修复。 + +当前执行状态以本文件后续验收记录为准,不把实现文件存在写成已部署。 diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php index 6ee89b13..6abafb0a 100755 --- a/resources/views/components/app-layout.blade.php +++ b/resources/views/components/app-layout.blade.php @@ -34,7 +34,7 @@ - + {{-- 声明它是 PNG,并告诉浏览器它的实际尺寸 --}} diff --git a/tests/Feature/LocalIllustrationsTest.php b/tests/Feature/LocalIllustrationsTest.php new file mode 100644 index 00000000..fef8d132 --- /dev/null +++ b/tests/Feature/LocalIllustrationsTest.php @@ -0,0 +1,128 @@ +app['env'] = 'production'; + $this->dir = sys_get_temp_dir().'/fna-local-images-'.bin2hex(random_bytes(8)); + mkdir($this->dir, 0700, true); + file_put_contents($this->dir.'/credential.json', json_encode(['project' => 'fna-v3', 'base_url' => 'https://resource-v2.gemcook.net', 'token' => 'rsv2_'.str_repeat('a', 64)])); + config(['illustrations.enabled' => true, 'illustrations.local_enabled' => true, + 'illustrations.base_url' => 'https://resource-v2.gemcook.net', 'illustrations.credential_file' => $this->dir.'/credential.json', + 'illustrations.local_directory' => $this->dir.'/public', 'illustrations.local_manifest' => $this->dir.'/private/manifest.json']); + $this->bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jp1sAAAAASUVORK5CYII='); + $this->sha = hash('sha256', $this->bytes); + Http::preventStrayRequests(); + } + + protected function tearDown(): void + { + File::deleteDirectory($this->dir); + parent::tearDown(); + } + + private function image(?string $id = null): array + { + return ['asset_id' => $id ?? $this->id, 'name' => 'cat.png', 'url' => 'https://resource-v2.gemcook.net/media/'.$this->pub]; + } + + private function fakeImages(bool $corrupt = false): void + { + Http::swap(new \Illuminate\Http\Client\Factory()); + Http::preventStrayRequests(); + Http::fake([ + 'https://resource-v2.gemcook.net/api/v2/assets/*' => Http::response(['data' => ['sha256' => $this->sha, 'mime' => 'image/png', 'bytes' => strlen($this->bytes)]]), + 'https://resource-v2.gemcook.net/media/*' => Http::response($corrupt ? 'bad image' : $this->bytes, 200, ['Content-Type' => 'image/png']), + ]); + } + + public function test_content_hash_deduplicates_assets_and_page_reads_make_no_http_requests(): void + { + $this->fakeImages(); + $local = app(LocalIllustrations::class); + $url = $local->storeSelection('freenode', 'fn1', $this->image()); + $local->storeSelection('freenode', 'fn2', $this->image('33333333-3333-4333-8333-333333333333')); + $this->assertSame('/images/illustrations/'.$this->sha.'.png', $url); + $this->assertCount(1, glob($this->dir.'/public/*')); + $this->assertSame($this->bytes, file_get_contents($this->dir.'/public/'.$this->sha.'.png')); + Http::assertSentCount(3); // Two metadata reads, one content download. + Http::fake(); + config(['illustrations.enabled' => false]); // Frontend survives Resource being disabled. + for ($i = 0; $i < 5; $i++) { + $a = new Articles(['iCategoryId' => 1, 'sSlug' => 'fn1', 'sThumb' => 'https://old.invalid/image']); + $this->assertSame($url, $a->sThumb); + } + Http::assertNothingSent(); + } + + public function test_corrupt_download_preserves_old_mapping_and_publishes_no_partial_file(): void + { + $this->fakeImages();$local = app(LocalIllustrations::class); + $local->storeSelection('freenode', 'fn1', $this->image()); + $before = file_get_contents($this->dir.'/private/manifest.json'); + $this->fakeImages(true); + unlink($this->dir.'/public/'.$this->sha.'.png'); + try { $local->storeSelection('freenode', 'fn2', $this->image());$this->fail('Expected invalid download rejection'); } + catch (\RuntimeException $e) { $this->assertStringContainsString('integrity', $e->getMessage()); } + $this->assertSame($before, file_get_contents($this->dir.'/private/manifest.json')); + $this->assertSame([], glob($this->dir.'/public/*')); + $this->assertSame([], glob($this->dir.'/private/.image-*')); + } + + public function test_off_origin_publication_is_rejected_without_any_network_request(): void + { + $image = $this->image();$image['url'] = 'https://example.com/media/'.$this->pub; + try { app(LocalIllustrations::class)->storeSelection('freenode', 'fn1', $image);$this->fail('Expected source rejection'); } + catch (\RuntimeException $e) { $this->assertSame('Unregistered illustration source', $e->getMessage()); } + Http::assertNothingSent(); + } + + public function test_new_article_assignment_downloads_before_returning_local_url_and_retry_reuses_it(): void + { + $this->fakeImages(); + Http::fake(['https://resource-v2.gemcook.net/api/v2/pools/freenode/selections' => Http::response(['data' => ['selections' => ['fn1' => $this->image()]]])]); + $first = IllustrationPool::forArticle('freenode', 'fn1'); + $this->assertSame('/images/illustrations/'.$this->sha.'.png', $first); + $this->assertSame($first, IllustrationPool::forArticle('freenode', 'fn1')); + $this->assertSame($first, app(LocalIllustrations::class)->thumbnail('freenode', 'fn1')); + Http::assertSentCount(4); // Two assignment requests; content downloaded once. + } + + public function test_sync_stops_showing_withdrawn_mapping_without_automatically_deleting_shared_file(): void + { + $this->fakeImages();$local = app(LocalIllustrations::class);$image = $this->image(); + $local->storeSelection('freenode', 'fn1', $image);$local->storeSelection('freenode', 'fn2', $image); + $local->synchronize(['freenode' => ['items' => [$image], 'selections' => ['fn2' => $image], 'unavailable' => ['fn1' => 'publication_revoked']]]); + $this->assertNull($local->thumbnail('freenode', 'fn1')); + $this->assertNotNull($local->thumbnail('freenode', 'fn2')); + $this->assertFileExists($this->dir.'/public/'.$this->sha.'.png'); + } + + public function test_missing_manifest_never_fetches_resource_or_returns_remote_fallback(): void + { + $this->assertNull(IllustrationPool::thumbnail(['iCategoryId' => 1, 'sSlug' => 'fn1'], 'https://old.invalid/image')); + $this->assertSame('/tutorial.png', IllustrationPool::thumbnail(['iCategoryId' => 3, 'sSlug' => 'guide'], '/tutorial.png')); + Http::assertNothingSent(); + } +}