Serve article illustrations as deduplicated local static files

This commit is contained in:
Codex 2026-10-30 11:51:22 +08:00
parent f315215f6f
commit b892a4150a
10 changed files with 439 additions and 14 deletions

1
.gitignore vendored
View File

@ -23,3 +23,4 @@ Thumbs.db
!/_env/* !/_env/*
./vite.config.js ./vite.config.js
/node_modules /node_modules
/public/images/illustrations

View File

@ -0,0 +1,52 @@
<?php
namespace App\Console\Commands;
use App\Models\Articles;
use App\Services\IllustrationPool;
use App\Services\LocalIllustrations;
use Illuminate\Console\Command;
use Spatie\ResponseCache\Facades\ResponseCache;
final class SyncLocalIllustrations extends Command
{
protected $signature = 'illustrations:sync-local {--assign-missing : Create missing Resource assignments for existing public articles}';
protected $description = 'Copy approved Resource images into deduplicated local static files and atomically reconcile article mappings';
public function handle(LocalIllustrations $local): int
{
if (! IllustrationPool::enabled()) {
$this->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;
}
}
}

View File

@ -12,7 +12,7 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->app->scoped(\App\Services\LocalIllustrations::class);
} }
/** /**

View File

@ -45,17 +45,7 @@ public static function snapshot(string $pool): array
{ {
return Cache::store('file')->remember('illustrations.v2.'.$pool, 60, function () use ($pool) { return Cache::store('file')->remember('illustrations.v2.'.$pool, 60, function () use ($pool) {
try { try {
$data = self::request('GET', 'pools/'.$pool); $data = self::sourceSnapshot($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'];
}
Cache::store('file')->forever('illustrations.v2.last-good.'.$pool, $data); Cache::store('file')->forever('illustrations.v2.last-good.'.$pool, $data);
return $data; return $data;
} catch (\Throwable $e) { } catch (\Throwable $e) {
@ -71,7 +61,41 @@ public static function forget(string $pool): void
Cache::store('file')->forget('illustrations.v2.'.$pool); 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 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]); $data = self::request('POST', 'pools/'.$pool.'/selections', ['keys' => $keys]);
self::forget($pool); 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 public static function thumbnail(array $article, ?string $original): ?string
{ {
if (!self::enabled()) return $original;
$pool = config('illustrations.categories.'.($article['iCategoryId'] ?? '')); $pool = config('illustrations.categories.'.($article['iCategoryId'] ?? ''));
if (!$pool || empty($article['sSlug'])) return $original; if (!$pool || empty($article['sSlug'])) return $original;
if (LocalIllustrations::enabled()) {
return app(LocalIllustrations::class)->thumbnail($pool, $article['sSlug']);
}
if (!self::enabled()) return $original;
try { try {
$snapshot = self::snapshot($pool); $snapshot = self::snapshot($pool);
$reason = $snapshot['unavailable'][$article['sSlug']] ?? null; $reason = $snapshot['unavailable'][$article['sSlug']] ?? null;

View File

@ -0,0 +1,164 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
/** Public, content-addressed display copies. Network and hash work only runs on writes/sync. */
final class LocalIllustrations
{
private ?array $manifest = null;
public static function enabled(): bool
{
return (bool) config('illustrations.local_enabled');
}
private function read(): array
{
if ($this->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];
});
}
}

View File

@ -4,5 +4,9 @@
'enabled' => (bool) env('RESOURCE_V2_ILLUSTRATIONS_ENABLED', env('LOCAL_ILLUSTRATIONS_ENABLED', env('APP_ENV') === 'local')), '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'), 'base_url' => env('RESOURCE_V2_URL', 'http://resource.loc:8612'),
'credential_file' => env('RESOURCE_V2_CREDENTIAL_FILE'), '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'], 'categories' => [1 => 'freenode', 9 => 'shadowrocketids'],
]; ];

View File

@ -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;
}

View File

@ -0,0 +1,39 @@
# FNA 静态配图副本
用户2026-10-30确认:Resource保留素材/图池/固定分配,FNA保存公开展示副本,由本站Nginx提供图片。仅猫狗资源栏目,教程图片沿原路径不改;不改变Resource的其他调用方。
## 数据与请求路径
- `public/images/illustrations/<sha256>.<ext>`:图片本体,按内容去重,同图多篇只存一份。运行数据不进入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。不要通过直接删除图片测试生产修复。
当前执行状态以本文件后续验收记录为准,不把实现文件存在写成已部署。

View File

@ -34,7 +34,7 @@
<meta property="og:description" content="{{ $sDesc }}"> <meta property="og:description" content="{{ $sDesc }}">
<meta property="og:type" content="{{ $sOgType }}"> <meta property="og:type" content="{{ $sOgType }}">
<meta property="og:url" content="{{ url()->current() }}"> <meta property="og:url" content="{{ url()->current() }}">
<meta property="og:image" content="{{ $sOgImg }}"> <meta property="og:image" content="{{ str_starts_with($sOgImg ?? '', '/') ? url($sOgImg) : $sOgImg }}">
{{-- 声明它是 PNG,并告诉浏览器它的实际尺寸 --}} {{-- 声明它是 PNG,并告诉浏览器它的实际尺寸 --}}
<link rel="icon" type="image/png" href="/sites/{{ config('app.site_code_short') }}/icon.png" sizes="166x166"> <link rel="icon" type="image/png" href="/sites/{{ config('app.site_code_short') }}/icon.png" sizes="166x166">

View File

@ -0,0 +1,128 @@
<?php
namespace Tests\Feature;
use App\Models\Articles;
use App\Services\IllustrationPool;
use App\Services\LocalIllustrations;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses;
use Tests\TestCase;
#[RunTestsInSeparateProcesses]
#[PreserveGlobalState(false)]
class LocalIllustrationsTest extends TestCase
{
private string $dir;
private string $bytes;
private string $sha;
private string $id = '11111111-1111-4111-8111-111111111111';
private string $pub = '22222222-2222-4222-8222-222222222222';
protected function setUp(): void
{
parent::setUp();
$this->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();
}
}