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 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]; }); } }