172 lines
8.1 KiB
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); }
|
|
}
|
|
}
|