dd/app/Services/Freenode/Builder.php

107 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Freenode;
use InvalidArgumentException;
use RuntimeException;
use Symfony\Component\Yaml\Yaml;
use Throwable;
final class Builder
{
/** No database, file publication or network side effects. */
public function build(array $sources, array $sites, NativeValidator $validator): array
{
$uri = []; $clash = []; $report = ['sources' => [], 'excluded' => [], 'nodes' => []];
foreach ($sources as $source) {
$name = $source['name'];
$stats = ['uri_read' => 0, 'clash_read' => 0, 'invalid' => 0];
foreach (preg_split('/\r\n|\n|\r/', $source['v2ray'] ?? '') as $line) {
if (trim($line) === '') continue;
$stats['uri_read']++;
try {
$node = Codec::parse($line);
$id = $node['id'];
if (!isset($uri[$id])) $uri[$id] = $node;
$uri[$id]['sources'][$name] = true;
} catch (Throwable $e) {
$stats['invalid']++;
$reason = $e instanceof InvalidArgumentException ? $e->getMessage() : 'invalid_uri';
$report['excluded'][$name][$reason] = ($report['excluded'][$name][$reason] ?? 0) + 1;
}
}
if (!empty($source['clash'])) {
try {
$data = Yaml::parse(preg_replace('/^\xEF\xBB\xBF/', '', $source['clash']));
if (!is_array($data['proxies'] ?? null)) throw new RuntimeException('no_proxies');
} catch (Throwable $e) {
throw new RuntimeException('invalid_source_yaml:'.$name);
}
foreach ($data['proxies'] as $raw) {
$stats['clash_read']++;
try {
if (!is_array($raw) || !is_string($raw['type'] ?? null)) throw new RuntimeException();
Codec::endpoint((string) ($raw['server'] ?? ''), (int) ($raw['port'] ?? 0));
$originalName = (string) ($raw['name'] ?? $raw['type']);
unset($raw['name']);
$sorted = $raw; ksort($sorted);
$id = hash('sha256', json_encode($sorted, JSON_THROW_ON_ERROR));
if (!isset($clash[$id])) $clash[$id] = ['id' => $id, 'name' => $originalName, 'data' => $raw, 'sources' => []];
$clash[$id]['sources'][$name] = true;
} catch (Throwable $e) {
$stats['invalid']++;
$report['excluded'][$name]['invalid_clash'] = ($report['excluded'][$name]['invalid_clash'] ?? 0) + 1;
}
}
}
if ($stats['uri_read'] + $stats['clash_read'] === 0) throw new RuntimeException('empty_source:'.$name);
$report['sources'][$name] = $stats;
}
if (!$uri || !$clash) throw new RuntimeException('empty_input_format');
ksort($uri); ksort($clash);
$lines = []; $singbox = []; $proxies = [];
foreach ($uri as $id => $node) {
$label = $this->label($node['name'], $id);
$line = Codec::uri($node, $label);
$roundtrip = Codec::parse($line);
if ($roundtrip['id'] !== $id) throw new RuntimeException('uri_roundtrip_failed');
$lines[$id] = $line;
$report['nodes']['uri'][$id] = array_keys($node['sources']);
try {
$singbox[$id] = Codec::singbox($node, $label);
} catch (Throwable $e) {
$reason = $e instanceof InvalidArgumentException ? $e->getMessage() : 'conversion_error';
$report['excluded']['singbox'][$reason] = ($report['excluded']['singbox'][$reason] ?? 0) + 1;
}
}
foreach ($clash as $id => $node) {
$proxies[$id] = ['name' => $this->label($node['name'], $id)] + $node['data'];
$report['nodes']['clash'][$id] = array_keys($node['sources']);
}
$singbox = $validator->filter('singbox', $singbox);
$proxies = $validator->filter('clash', $proxies);
if (!$singbox || !$proxies) throw new RuntimeException('empty_validated_format');
$outputs = ['v2ray' => base64_encode(implode("\n", $lines)), 'singbox' => $validator->render('singbox', $singbox), 'clash' => $validator->render('clash', $proxies)];
if (!$validator->check('singbox', $outputs['singbox']) || !$validator->check('clash', $outputs['clash'])) throw new RuntimeException('final_validation_failed');
$files = [];
foreach (array_merge([['code' => 'base', 'clients' => array_keys($outputs)]], $sites) as $site) {
if (!preg_match('/^[a-zA-Z0-9_.-]+$/D', $site['code'])) throw new RuntimeException('invalid_site_code');
foreach ($site['clients'] as $client) {
if (!isset($outputs[$client])) throw new RuntimeException('unsupported_output_format');
$files[$site['code'].'/'.$client] = $outputs[$client];
}
}
$report['counts'] = ['v2ray' => count($lines), 'clash' => count($proxies), 'singbox' => count($singbox)];
$report['native_rejected'] = $validator->rejected;
$report['native_checks'] = $validator->checks;
return ['files' => $files, 'report' => $report];
}
private function label(string $name, string $id): string
{
$name = preg_replace('/[\x00-\x1F\x7F]/u', '', $name) ?? 'Node';
return mb_substr($name, 0, 80).' ['.substr($id, 0, 12).']';
}
}