Serve article illustrations as deduplicated local static files
This commit is contained in:
parent
f315215f6f
commit
b892a4150a
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,3 +23,4 @@ Thumbs.db
|
||||
!/_env/*
|
||||
./vite.config.js
|
||||
/node_modules
|
||||
/public/images/illustrations
|
||||
|
||||
52
app/Console/Commands/SyncLocalIllustrations.php
Normal file
52
app/Console/Commands/SyncLocalIllustrations.php
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -12,7 +12,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
$this->app->scoped(\App\Services\LocalIllustrations::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
164
app/Services/LocalIllustrations.php
Normal file
164
app/Services/LocalIllustrations.php
Normal 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];
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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'],
|
||||
];
|
||||
|
||||
10
docs/deployment/illustrations-static.nginx
Normal file
10
docs/deployment/illustrations-static.nginx
Normal 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;
|
||||
}
|
||||
39
docs/maintenance/LOCAL_ILLUSTRATIONS.md
Normal file
39
docs/maintenance/LOCAL_ILLUSTRATIONS.md
Normal 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。不要通过直接删除图片测试生产修复。
|
||||
|
||||
当前执行状态以本文件后续验收记录为准,不把实现文件存在写成已部署。
|
||||
@ -34,7 +34,7 @@
|
||||
<meta property="og:description" content="{{ $sDesc }}">
|
||||
<meta property="og:type" content="{{ $sOgType }}">
|
||||
<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,并告诉浏览器它的实际尺寸 --}}
|
||||
<link rel="icon" type="image/png" href="/sites/{{ config('app.site_code_short') }}/icon.png" sizes="166x166">
|
||||
|
||||
128
tests/Feature/LocalIllustrationsTest.php
Normal file
128
tests/Feature/LocalIllustrationsTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user