From bb7250e4ac412cf4a8dac8feae13758fff1b8ade Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Fri, 8 May 2026 20:38:27 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=B2=D0=BE=D0=B7=D0=BC=D0=BE=D0=B6=D0=BD?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B8=D1=81=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B8=20=D0=BE=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20rule-sets=20=D0=B8=D0=B7=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=D0=B0=20SagerNet=20Refs:=20None?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/server/index.js | 48 ++++++++++ src/web/api.js | 1 + src/web/components/SettingsPage.jsx | 138 ++++++++++++++++++++++++++++ 3 files changed, 187 insertions(+) diff --git a/src/server/index.js b/src/server/index.js index d50279a..ca04c42 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -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") { const body = await readBody(req); const host = String(body.host || "").trim(); diff --git a/src/web/api.js b/src/web/api.js index 24f5c74..a91d895 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -38,6 +38,7 @@ export const api = { method: "POST", body: JSON.stringify({ tag, url }), }), + sagernetCatalog: () => request("/api/rule-sets/sagernet-catalog"), }, subscription: { diff --git a/src/web/components/SettingsPage.jsx b/src/web/components/SettingsPage.jsx index 4623241..6e86f16 100644 --- a/src/web/components/SettingsPage.jsx +++ b/src/web/components/SettingsPage.jsx @@ -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 ( +
+
+

Поиск в каталоге SagerNet

+
+ {status === 'done' && catalog && ( + + {(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} rule-sets + + )} + {open ? '▲' : '▼'} +
+
+ + {open && ( + <> + {status === 'loading' && ( +
+ Загрузка списка из GitHub… +
+ )} + {status === 'error' && ( +
+
{error}
+
+ )} + {status === 'done' && ( + <> + + Полный список rule-sets из репозиториев SagerNet/sing-geosite и SagerNet/sing-geoip. + Ищите по имени: steam, gaming, netflix, apple и т.д. + Кеш обновляется раз в 24 ч. + +
+ setQuery(e.target.value)} + autoFocus + /> + +
+ + {query.trim() === '' ? ( +
+ Введите запрос — покажем совпадения ({(catalog.geosite?.length || 0) + (catalog.geoip?.length || 0)} доступно) +
+ ) : results.length === 0 ? ( +
Ничего не найдено
+ ) : ( + + + + + + + + + + {results.slice(0, 100).map((item) => ( + + + + + + ))} + +
ТипТег
{item.repo}{item.name} + {addedTags.has(item.name) ? ( + ✓ добавлен + ) : ( + + )} +
+ )} + {results.length > 100 && ( +
+ Показано 100 из {results.length} — уточните запрос +
+ )} +
+ кеш: {catalog.cachedAt ? formatRelative(catalog.cachedAt) : '—'} +
+ + )} + + )} +
+ ); +} + function RuleSetsCard({ pushToast }) { const [ruleSets, setRuleSets] = useState([]); const [newTag, setNewTag] = useState(''); @@ -586,6 +723,7 @@ function RuleSetsCard({ pushToast }) { + {lookup && (