148 lines
6.2 KiB
PHP
148 lines
6.2 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 pendingDeliveries(): array
|
|
{
|
|
return self::request('GET', 'illustration-deliveries');
|
|
}
|
|
|
|
public static function acknowledgeDelivery(string $id, array $receipt): array
|
|
{
|
|
if (! preg_match('/\A[a-f0-9-]{36}\z/D', $id)) throw new \RuntimeException('Invalid delivery');
|
|
return self::request('POST', 'illustration-deliveries/'.$id.'/ack', $receipt);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|