dd/app/Services/Freenode/ReleaseStore.php

172 lines
8.1 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Freenode;
use RuntimeException;
final class ReleaseStore
{
public function __construct(private string $root, private string $publicRoot)
{
if (!is_dir($root)) mkdir($root, 0700, true);
}
public function locked(callable $callback, bool $exclusive = true): mixed
{
$lock = fopen($this->root.'/publish.lock', 'c');
if (!$lock || !flock($lock, $exclusive ? LOCK_EX : LOCK_SH)) throw new RuntimeException('release_lock_failed');
try { return $callback(); } finally { flock($lock, LOCK_UN); fclose($lock); }
}
public function stage(array $result, string $date, float $minimumRatio = 0.5): array
{
return $this->locked(function () use ($result, $date, $minimumRatio) {
$previous = $this->current();
foreach ($result['report']['counts'] as $format => $count) {
$baseline = $previous['counts'][$format] ?? $this->legacyCount($format, $date);
if ($count < 1 || $baseline > 0 && $count < $baseline * $minimumRatio) throw new RuntimeException('count_drop_guard:'.$format);
}
$id = gmdate('YmdHis').'-'.bin2hex(random_bytes(6));
$dir = $this->root.'/versions/'.$id;
mkdir($dir, 0700, true);
$manifest = ['id' => $id, 'date' => $date, 'created_at' => time(), 'validated' => true, 'counts' => $result['report']['counts'], 'files' => []];
foreach ($result['files'] as $key => $content) {
$this->safeKey($key);
$path = $key.'/'.$date.'.txt';
$this->atomic($dir.'/'.$path, $content, 0600);
$manifest['files'][$key] = ['path' => $path, 'sha256' => hash('sha256', $content)];
}
$this->atomic($dir.'/report.json', json_encode($result['report'], JSON_THROW_ON_ERROR), 0600);
$this->atomic($dir.'/manifest.json', json_encode($manifest, JSON_THROW_ON_ERROR), 0600);
$this->atomic($this->root.'/pending.json', json_encode($manifest, JSON_THROW_ON_ERROR), 0600);
return $manifest;
});
}
public function publishPrepared(): array
{
return $this->locked(function () {
$m = $this->readJson($this->root.'/pending.json');
if (!$m || $m['date'] !== date('Y-m-d') || time() - $m['created_at'] > 1800) throw new RuntimeException('no_fresh_prepared_release');
return $this->activateUnlocked($m['id']);
});
}
public function activate(string $id): array
{
return $this->locked(fn () => $this->activateUnlocked($id));
}
private function activateUnlocked(string $id): array
{
if (!preg_match('/^[a-zA-Z0-9-]+$/D', $id)) throw new RuntimeException('invalid_release_id');
$manifest = $this->readJson($this->root.'/versions/'.$id.'/manifest.json');
if (!$manifest) throw new RuntimeException('release_not_found');
$contents = $this->contents($manifest);
$previous = $this->current();
// Capture the actual compatibility files before the first v2 publication.
if (!$previous) $previous = $this->snapshotLegacy($manifest);
$backups = [];
try {
foreach ($contents as $key => $content) {
$target = $this->publicRoot.'/'.$key.'/'.$manifest['date'].'.txt';
$backups[$target] = is_file($target) ? file_get_contents($target) : null;
$this->atomic($target, $content, 0644);
}
// Readers of v2 use this manifest and immutable release files under the shared lock.
$this->atomic($this->root.'/current.json', json_encode($manifest, JSON_THROW_ON_ERROR), 0600);
if ($previous && $previous['id'] !== $id) $this->atomic($this->root.'/previous.json', json_encode($previous, JSON_THROW_ON_ERROR), 0600);
} catch (\Throwable $e) {
foreach ($backups as $target => $content) {
if ($content === null) @unlink($target); else $this->atomic($target, $content, 0644);
}
throw $e;
}
return $manifest;
}
public function current(): ?array { return $this->readJson($this->root.'/current.json'); }
public function previous(): ?array { return $this->readJson($this->root.'/previous.json'); }
public function contents(array $manifest): array
{
$result = [];
foreach ($manifest['files'] as $key => $file) {
$this->safeKey($key);
$expected = $key.'/'.$manifest['date'].'.txt';
if ($file['path'] !== $expected) throw new RuntimeException('invalid_release_path');
$path = $this->root.'/versions/'.$manifest['id'].'/'.$expected;
$content = is_file($path) ? file_get_contents($path) : false;
if ($content === false || !hash_equals($file['sha256'], hash('sha256', $content))) throw new RuntimeException('release_integrity_failed');
$result[$key] = $content;
}
return $result;
}
public function readSite(string $site, array $clients): array
{
return $this->locked(function () use ($site, $clients) {
$current = $this->current();
if (!$current || !$current['validated'] || time() - $current['created_at'] > 86400) throw new RuntimeException('no_recent_validated_release');
$all = $this->contents($current); $result = [];
foreach ($clients as $client) {
if (!isset($all[$site.'/'.$client])) throw new RuntimeException('incomplete_site_release');
$result[$client] = $all[$site.'/'.$client];
}
return $result;
}, false);
}
private function snapshotLegacy(array $new): ?array
{
$id = 'legacy-'.gmdate('YmdHis').'-'.bin2hex(random_bytes(4));
$manifest = ['id' => $id, 'date' => $new['date'], 'created_at' => time(), 'validated' => false, 'counts' => [], 'files' => []];
foreach ($new['files'] as $key => $v) {
$path = $key.'/'.$new['date'].'.txt'; $source = $this->publicRoot.'/'.$path;
if (!is_file($source)) continue;
$content = file_get_contents($source);
$this->atomic($this->root.'/versions/'.$id.'/'.$path, $content, 0600);
$manifest['files'][$key] = ['path' => $path, 'sha256' => hash('sha256', $content)];
}
if (!$manifest['files']) return null;
$this->atomic($this->root.'/versions/'.$id.'/manifest.json', json_encode($manifest, JSON_THROW_ON_ERROR), 0600);
return $manifest;
}
private function legacyCount(string $format, string $date): int
{
$path = $this->publicRoot.'/base/'.$format.'/'.$date.'.txt';
if (!is_file($path)) return 0;
$s = file_get_contents($path);
try {
return match ($format) {
'v2ray' => count(array_filter(explode("\n", Codec::decode64($s)))),
'singbox' => max(0, count(json_decode($s, true, 512, JSON_THROW_ON_ERROR)['outbounds'] ?? []) - 3),
'clash' => count(\Symfony\Component\Yaml\Yaml::parse($s)['proxies'] ?? []),
};
} catch (\Throwable) { throw new RuntimeException('invalid_baseline:'.$format); }
}
private function safeKey(string $key): void
{
if (!preg_match('~^[a-zA-Z0-9_.-]+/(v2ray|clash|singbox)$~D', $key) || str_contains($key, '..')) throw new RuntimeException('invalid_artifact_key');
}
private function readJson(string $path): ?array
{
return is_file($path) ? json_decode(file_get_contents($path), true, 512, JSON_THROW_ON_ERROR) : null;
}
private function atomic(string $path, string $content, int $mode): void
{
if (!is_dir(dirname($path))) mkdir(dirname($path), $mode === 0600 ? 0700 : 0755, true);
$temp = tempnam(dirname($path), '.freenode-');
try {
if (file_put_contents($temp, $content) !== strlen($content)) throw new RuntimeException('artifact_write_failed');
chmod($temp, $mode);
if (!rename($temp, $path)) throw new RuntimeException('artifact_rename_failed');
} finally { if (is_file($temp)) unlink($temp); }
}
}