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 ( +
steam, gaming, netflix, apple и т.д.
+ Кеш обновляется раз в 24 ч.
+
+ | Тип | +Тег | ++ |
|---|---|---|
| {item.repo} | +{item.name} | ++ {addedTags.has(item.name) ? ( + ✓ добавлен + ) : ( + + )} + | +