fn_b/app/Services/LocalIllustrations.php

196 lines
9.8 KiB
PHP

<?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];
}
/** Delivery intake copies only approved batch assets; it does not reconcile existing articles. */
public function cacheDeliveryAssets(array $images): void
{
$this->locked(function () use ($images) {
$state = $this->read();
foreach ($images as $image) {
$state['assets'][$image['asset_id']] = $this->copy($image, $state['assets']);
}
$this->save($state);
});
}
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 selectionBaseline(): array
{
$this->manifest = null;
return $this->read()['selections'];
}
public function synchronize(array $snapshots, ?array $baseline = null): array
{
$baseline ??= $this->selectionBaseline();
return $this->locked(function () use ($snapshots, $baseline) {
$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;
}
}
// Receivers may publish new mappings while the remote snapshot is in flight.
// Never replace a local change made after this sync's baseline was captured.
$current = $state['selections'][$pool] ?? [];
$before = $baseline[$pool] ?? [];
foreach (array_unique(array_merge(array_keys($before), array_keys($current))) as $slug) {
$exists = array_key_exists($slug, $current);
if ($exists !== array_key_exists($slug, $before) || ($current[$slug] ?? null) !== ($before[$slug] ?? null)) {
if ($exists) $selections[$slug] = $current[$slug];
else unset($selections[$slug]);
unset($pending[$pool.':'.$slug]);
}
}
$state['selections'][$pool] = $selections;
}
$this->save($state);
return ['assets' => count($state['assets']), 'selections' => array_map('count', $state['selections']), 'pending' => $pending];
});
}
}