fn_b/app/Services/IllustrationPool.php

137 lines
5.8 KiB
PHP

<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
final class IllustrationPool
{
public static function enabled(): bool
{
$base = match (app()->environment()) {
'local' => 'http://resource.loc:8612',
'production' => 'https://resource-v2.gemcook.net',
'staging' => 'https://test.clashfreenode.com/resource-v2',
default => null,
};
return $base !== null && config('illustrations.enabled')
&& config('illustrations.base_url') === $base;
}
private static function request(string $method, string $path, array $body = []): array
{
if (!self::enabled()) throw new \RuntimeException('Illustration service is disabled or not bound to this environment');
$file = config('illustrations.credential_file');
if (!$file || !is_file($file)) throw new \RuntimeException('Illustration credential unavailable');
$credential = json_decode(file_get_contents($file), true, 512, JSON_THROW_ON_ERROR);
if (($credential['project'] ?? '') !== 'fna-v3' || ($credential['base_url'] ?? '') !== config('illustrations.base_url')
|| !preg_match('/\Arsv2_[a-f0-9]{64}\z/D', $credential['token'] ?? '')) {
throw new \RuntimeException('Invalid illustration credential configuration');
}
try {
$response = Http::withOptions(['proxy' => '', 'allow_redirects' => false])
->withToken($credential['token'])->acceptJson()->withUserAgent('FNA-V3/ResourceV2')
->connectTimeout(2)->timeout(8)->send($method, config('illustrations.base_url').'/api/v2/'.$path, ['json' => $body]);
if (!$response->successful() || !is_array($response->json('data'))) throw new \RuntimeException();
return $response->json('data');
} catch (\Throwable $e) {
// Never include request headers, credential bytes or response bodies in exceptions.
throw new \RuntimeException('Resource V2 illustration request failed');
}
}
public static function snapshot(string $pool): array
{
return Cache::store('file')->remember('illustrations.v2.'.$pool, 60, function () use ($pool) {
try {
$data = self::sourceSnapshot($pool);
Cache::store('file')->forever('illustrations.v2.last-good.'.$pool, $data);
return $data;
} catch (\Throwable $e) {
$saved = Cache::store('file')->get('illustrations.v2.last-good.'.$pool);
if (is_array($saved)) return $saved;
throw $e;
}
});
}
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'] = 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);
return $data['selections'];
}
public static function forArticle(string $pool, string $slug): string
{
return self::assign($pool, [$slug])[$slug]['url'];
}
public static function thumbnail(array $article, ?string $original): ?string
{
$pool = config('illustrations.categories.'.($article['iCategoryId'] ?? ''));
if (!$pool || empty($article['sSlug'])) return $original;
if (LocalIllustrations::enabled()) {
$url = app(LocalIllustrations::class)->thumbnail($pool, $article['sSlug']);
return $url ? url($url) : null;
}
if (!self::enabled()) return $original;
try {
$snapshot = self::snapshot($pool);
$reason = $snapshot['unavailable'][$article['sSlug']] ?? null;
if ($reason) {
// Repair accidental physical loss, never override deliberate publication/access withdrawal.
if (in_array($reason, ['file_missing', 'file_corrupt', 'file_symlink'], true)) {
try { return self::forArticle($pool, $article['sSlug']); }
catch (\Throwable $e) { return null; }
}
return null;
}
return $snapshot['selections'][$article['sSlug']]['url'] ?? $original;
} catch (\Throwable $e) {
// A failed resource service must not invent or reassign an article's image.
return $original;
}
}
}