184 lines
9.4 KiB
PHP
184 lines
9.4 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];
|
|
}
|
|
|
|
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];
|
|
});
|
|
}
|
|
}
|