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::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']; } 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 assign(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 { if (!self::enabled()) return $original; $pool = config('illustrations.categories.'.($article['iCategoryId'] ?? '')); if (!$pool || empty($article['sSlug'])) 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; } } }