feat: добавлена возможность поиска и отображения rule-sets из каталога SagerNet
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
This commit is contained in:
@@ -572,6 +572,54 @@ async function handleApi(req, res) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && req.url === "/api/rule-sets/sagernet-catalog") {
|
||||||
|
const cacheFile = path.join(
|
||||||
|
settings.dataDir,
|
||||||
|
"sagernet-catalog-cache.json",
|
||||||
|
);
|
||||||
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 часа
|
||||||
|
|
||||||
|
if (fs.existsSync(cacheFile)) {
|
||||||
|
try {
|
||||||
|
const cached = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
||||||
|
if (Date.now() - new Date(cached.cachedAt).getTime() < CACHE_TTL_MS) {
|
||||||
|
return sendJson(res, 200, { success: true, ...cached });
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers = { "User-Agent": "vpn-proxy-app" };
|
||||||
|
const [gsRes, giRes] = await Promise.all([
|
||||||
|
fetch(
|
||||||
|
"https://api.github.com/repos/SagerNet/sing-geosite/git/trees/rule-set?recursive=1",
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
fetch(
|
||||||
|
"https://api.github.com/repos/SagerNet/sing-geoip/git/trees/rule-set?recursive=1",
|
||||||
|
{ headers },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
const gsData = await gsRes.json();
|
||||||
|
const giData = await giRes.json();
|
||||||
|
|
||||||
|
const geosite = (gsData.tree || [])
|
||||||
|
.filter((f) => f.path.endsWith(".srs"))
|
||||||
|
.map((f) => f.path.replace(".srs", ""))
|
||||||
|
.sort();
|
||||||
|
const geoip = (giData.tree || [])
|
||||||
|
.filter((f) => f.path.endsWith(".srs"))
|
||||||
|
.map((f) => f.path.replace(".srs", ""))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
const result = { geosite, geoip, cachedAt: new Date().toISOString() };
|
||||||
|
writeJson(cacheFile, result);
|
||||||
|
return sendJson(res, 200, { success: true, ...result });
|
||||||
|
} catch (err) {
|
||||||
|
return sendJson(res, 500, { success: false, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && req.url === "/api/route/check") {
|
if (req.method === "POST" && req.url === "/api/route/check") {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const host = String(body.host || "").trim();
|
const host = String(body.host || "").trim();
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ tag, url }),
|
body: JSON.stringify({ tag, url }),
|
||||||
}),
|
}),
|
||||||
|
sagernetCatalog: () => request("/api/rule-sets/sagernet-catalog"),
|
||||||
},
|
},
|
||||||
|
|
||||||
subscription: {
|
subscription: {
|
||||||
|
|||||||
@@ -429,6 +429,143 @@ function CatalogEntry({ entry, added, busy, onAdd, onLookup }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SagerNetSearchCard({ ruleSets, onAdd, busy }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [status, setStatus] = useState('idle'); // idle | loading | done | error
|
||||||
|
const [catalog, setCatalog] = useState(null); // { geosite, geoip, cachedAt }
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [repoFilter, setRepoFilter] = useState('all'); // all | geosite | geoip
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (status !== 'idle') return;
|
||||||
|
setStatus('loading');
|
||||||
|
api.ruleSets.sagernetCatalog()
|
||||||
|
.then((d) => { setCatalog(d); setStatus('done'); })
|
||||||
|
.catch((err) => { setError(err.message); setStatus('error'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
if (!open && status === 'idle') load();
|
||||||
|
setOpen((o) => !o);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!catalog) return [];
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
const toItem = (repo) => (name) => ({ name, repo, url: `https://cdn.jsdelivr.net/gh/SagerNet/sing-${repo}@rule-set/${name}.srs` });
|
||||||
|
const gs = repoFilter !== 'geoip' ? (catalog.geosite || []).map(toItem('geosite')) : [];
|
||||||
|
const gi = repoFilter !== 'geosite' ? (catalog.geoip || []).map(toItem('geoip')) : [];
|
||||||
|
const all = [...gs, ...gi];
|
||||||
|
if (!q) return all;
|
||||||
|
return all.filter((item) => item.name.includes(q));
|
||||||
|
}, [catalog, query, repoFilter]);
|
||||||
|
|
||||||
|
const addedTags = new Set(ruleSets.map((rs) => rs.tag));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header" style={{ cursor: 'pointer' }} onClick={toggle}>
|
||||||
|
<h2>Поиск в каталоге SagerNet</h2>
|
||||||
|
<div className="flex" style={{ gap: 8, alignItems: 'center' }}>
|
||||||
|
{status === 'done' && catalog && (
|
||||||
|
<span className="badge info" style={{ fontSize: 11 }}>
|
||||||
|
{(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} rule-sets
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="muted" style={{ fontSize: 13 }}>{open ? '▲' : '▼'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
{status === 'loading' && (
|
||||||
|
<div style={{ padding: '20px 0', textAlign: 'center', color: 'var(--text-muted)' }}>
|
||||||
|
Загрузка списка из GitHub…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="conflict-banner danger" style={{ marginTop: 8 }}>
|
||||||
|
<span>✗</span><div>{error}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{status === 'done' && (
|
||||||
|
<>
|
||||||
|
<small className="muted" style={{ display: 'block', marginBottom: 12 }}>
|
||||||
|
Полный список rule-sets из репозиториев <strong>SagerNet/sing-geosite</strong> и <strong>SagerNet/sing-geoip</strong>.
|
||||||
|
Ищите по имени: <code>steam</code>, <code>gaming</code>, <code>netflix</code>, <code>apple</code> и т.д.
|
||||||
|
Кеш обновляется раз в 24 ч.
|
||||||
|
</small>
|
||||||
|
<div className="flex" style={{ gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
style={{ flex: 1, minWidth: 180 }}
|
||||||
|
placeholder="steam, gaming, netflix, cloudflare…"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<select className="select" style={{ width: 130 }} value={repoFilter} onChange={(e) => setRepoFilter(e.target.value)}>
|
||||||
|
<option value="all">geosite + geoip</option>
|
||||||
|
<option value="geosite">только geosite</option>
|
||||||
|
<option value="geoip">только geoip</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{query.trim() === '' ? (
|
||||||
|
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>
|
||||||
|
Введите запрос — покажем совпадения ({(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} доступно)
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
|
<div className="muted" style={{ fontSize: 13, padding: '8px 0' }}>Ничего не найдено</div>
|
||||||
|
) : (
|
||||||
|
<table className="table" style={{ fontSize: 13 }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style={{ width: 60 }}>Тип</th>
|
||||||
|
<th>Тег</th>
|
||||||
|
<th style={{ width: 120 }}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{results.slice(0, 100).map((item) => (
|
||||||
|
<tr key={item.name}>
|
||||||
|
<td><span className={`badge ${item.repo === 'geosite' ? 'info' : ''}`} style={{ fontSize: 11 }}>{item.repo}</span></td>
|
||||||
|
<td className="text-mono">{item.name}</td>
|
||||||
|
<td style={{ textAlign: 'right' }}>
|
||||||
|
{addedTags.has(item.name) ? (
|
||||||
|
<span className="badge success" style={{ fontSize: 11 }}>✓ добавлен</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onAdd({ tag: item.name, url: item.url })}
|
||||||
|
>
|
||||||
|
+ Добавить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
{results.length > 100 && (
|
||||||
|
<div className="muted" style={{ fontSize: 12, marginTop: 8 }}>
|
||||||
|
Показано 100 из {results.length} — уточните запрос
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="muted" style={{ fontSize: 11, marginTop: 12 }}>
|
||||||
|
кеш: {catalog.cachedAt ? formatRelative(catalog.cachedAt) : '—'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RuleSetsCard({ pushToast }) {
|
function RuleSetsCard({ pushToast }) {
|
||||||
const [ruleSets, setRuleSets] = useState([]);
|
const [ruleSets, setRuleSets] = useState([]);
|
||||||
const [newTag, setNewTag] = useState('');
|
const [newTag, setNewTag] = useState('');
|
||||||
@@ -586,6 +723,7 @@ function RuleSetsCard({ pushToast }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<SagerNetSearchCard ruleSets={ruleSets} onAdd={addFromCatalog} busy={busy} />
|
||||||
{lookup && (
|
{lookup && (
|
||||||
<RuleSetLookupModal
|
<RuleSetLookupModal
|
||||||
tag={lookup.tag}
|
tag={lookup.tag}
|
||||||
|
|||||||
Reference in New Issue
Block a user