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