mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 08:10:58 +00:00
feat(xray): browse geosite/geoip categories from routing rules (#6165)
* feat(xray): browse geosite/geoip categories from routing rules Routing rules made you type category names from memory: nothing showed which categories a database actually contains, what is inside one, or whether a name resolves at all — a typo only surfaced when Xray refused the config. The panel now reads Xray's .dat databases itself and exposes them over four endpoints: databases in the asset folder, a database's categories, one page of a category's rules, and validation of the tokens already in a rule. The reader walks the protobuf wire format directly rather than decoding into Go structs, because a 10 MB geosite.dat holds well over a million domains and materialising them costs ~284 MB where streaming costs ~19 MB. Only the category index is cached, entry pages are scanned on demand, and scans are serialised, so twenty concurrent requests peak at 87 MB instead of 1 GB. A database's type is decided by its contents, not its file name, since custom .dat files are named freely. In the rule form, the source-IP, IP and domain fields gain a database button opening the browser: search over categories, a preview of what a category holds, and a multi-select that merges into the field. Plain domains, CIDRs and categories the panel does not know are left untouched; categories already present come back ticked, and unticking one removes it from the rule. * fix(xray): read geo databases through os.Root and match codes verbatim CodeQL flagged the database read as a path built from a user-supplied value, and it was right about the shape of it. The file name arrives in a request; resolve() rejects traversal and stats the file through an os.Root, but the read itself went through a joined path with os.ReadFile. That left the symlink defence incomplete: the stat could pass while the read followed a link planted — or swapped in — afterwards. Reads now go through the same root, so a request-supplied name never becomes a path this code resolves on its own, and the size limit is applied to the opened file rather than to a separate stat of it. Lookup no longer trims the category code either. It backs the routing-token validator, and the core matches codes verbatim: "geosite: cn" will not start Xray, so repairing that space here hid exactly the typo the validator exists to report. * fix(xray): address review findings on the geo category browser Asset folder. The browser read config.GetBinFolderPath() unconditionally, but the core honours a preset XRAY_LOCATION_ASSET and only falls back to the bin folder (ensureXrayAssetLocation). On an install pointing at a shared asset directory the panel listed an empty folder and reported perfectly valid geosite:/geoip: tokens as missing — the validator warning about a correct config. The directory is now resolved with the core's precedence. Paging. Serving one page read and rescanned the whole database, so walking category-ads-all re-read it per page. The index now records each category's byte range and a page reads only that record through the os.Root handle, with the current category's records held for the duration of a paging session. Profiling that also showed the real cost was not the read but the slice of payload pointers built per call — a category holds a hundred thousand of them — so records are now walked with a callback instead. Ten pages over category-ads-all: 239 MB allocated, now 4.3 MB. Cached failures. Any error from reading a file was latched under the file's size+mtime, so a transient ENOMEM or EMFILE marked a healthy database as damaged until it changed on disk. Only deterministic failures are cached. Wrong kind. A geoip: token typed into a domain field parsed as a plain domain and was waved through, though the core cannot resolve it as one. It is now reported, with its own reason and wording. Frontend. The category filter fed the query key on every keystroke, so each character triggered a request that re-scanned the database; it is debounced now. GeoTokenInput accepts and forwards a ref, so React Hook Form can focus these three fields on a validation error again. A failed validation shows that it failed instead of rendering the same empty state as "no issues". Also drops an unreachable branch in the token-count guard and corrects the categories endpoint docs, where limit is unbounded by default. --------- Co-authored-by: STRENCH0 <17428017+STRENCH0@users.noreply.github.com>
This commit is contained in:
@@ -1408,6 +1408,157 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoCategory": {
|
||||
"description": "GeoCategory is one code inside a database, such as geosite's \"google\".",
|
||||
"properties": {
|
||||
"attributes": {
|
||||
"example": [
|
||||
"ads",
|
||||
"cn"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"code": {
|
||||
"example": "google",
|
||||
"type": "string"
|
||||
},
|
||||
"entries": {
|
||||
"example": 1284,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"attributes",
|
||||
"code",
|
||||
"entries"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoCategoryPage": {
|
||||
"description": "GeoCategoryPage is one page of categories plus the unpaged total.",
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GeoCategory"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"total": {
|
||||
"example": 1043,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items",
|
||||
"total"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoEntry": {
|
||||
"description": "GeoEntry is a single rule inside a category: a domain rule for geosite\ndatabases, a CIDR for geoip ones.",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"example": "domain",
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"example": "google.com",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoEntryPage": {
|
||||
"description": "GeoEntryPage is one page of category entries plus the unpaged total.",
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GeoEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"total": {
|
||||
"example": 1284,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items",
|
||||
"total"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoFile": {
|
||||
"description": "GeoFile describes one .dat database found in the asset directory.",
|
||||
"properties": {
|
||||
"categories": {
|
||||
"example": 1043,
|
||||
"type": "integer"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"example": "site",
|
||||
"type": "string"
|
||||
},
|
||||
"modifiedAt": {
|
||||
"example": 1769558400000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"example": "geosite.dat",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"example": 1467392,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"categories",
|
||||
"kind",
|
||||
"modifiedAt",
|
||||
"name",
|
||||
"size"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeodataTokenIssue": {
|
||||
"description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.",
|
||||
"properties": {
|
||||
"code": {
|
||||
"example": "blabla",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"example": "geosite.dat",
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"example": "categoryMissing",
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"example": "geosite:blabla",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reason",
|
||||
"token"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoryOfSeeders": {
|
||||
"description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
|
||||
"properties": {
|
||||
@@ -11013,6 +11164,221 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/xray/geodata/files": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Xray Settings"
|
||||
],
|
||||
"summary": "List the geo databases (.dat files) in the Xray asset folder, with the layout detected from their contents, size, modification time and category count. A database that fails to parse is still listed, with the reason in \"error\".",
|
||||
"operationId": "get_panel_api_xray_geodata_files",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/xray/geodata/categories": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Xray Settings"
|
||||
],
|
||||
"summary": "One page of a database's categories, each with its entry count and the attributes its domains carry (e.g. \"ads\", \"cn\").",
|
||||
"operationId": "get_panel_api_xray_geodata_categories",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "file",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Database file name inside the asset folder, e.g. geosite.dat (required).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Case-insensitive substring filter on the category code.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Rows to skip. Defaults to 0.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Rows to return, capped at 500. Omit it to return every category — the index is small and the panel filters it client-side.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/xray/geodata/entries": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Xray Settings"
|
||||
],
|
||||
"summary": "One page of the rules inside a category — domain rules typed as domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.",
|
||||
"operationId": "get_panel_api_xray_geodata_entries",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "file",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Database file name inside the asset folder (required).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"in": "query",
|
||||
"required": true,
|
||||
"description": "Category code, case-insensitive, e.g. google (required).",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Case-insensitive substring filter on the rule value.",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "offset",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Rows to skip. Defaults to 0.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Rows to return, capped at 500. Defaults to the cap.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/xray/geodata/validate": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Xray Settings"
|
||||
],
|
||||
"summary": "Check routing tokens against the databases on disk and return only the ones that do not resolve. Plain domains and CIDRs are ignored. Each issue carries a reason: syntax, fileMissing or categoryMissing.",
|
||||
"operationId": "post_panel_api_xray_geodata_validate",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/xray/outbound-subs": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import { GeoCategoryPageSchema, GeoEntryPageSchema, GeoFileSchema, GeodataTokenIssueSchema } from '@/generated/zod';
|
||||
import type { GeoCategoryPage, GeoEntryPage, GeoFile, GeodataTokenIssue } from '@/generated/types';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { parseMsg } from '@/utils/zodValidate';
|
||||
|
||||
const GeoFileListSchema = z.array(GeoFileSchema);
|
||||
const GeodataTokenIssueListSchema = z.array(GeodataTokenIssueSchema);
|
||||
|
||||
const EMPTY_CATEGORY_PAGE: GeoCategoryPage = { total: 0, items: [] };
|
||||
const EMPTY_ENTRY_PAGE: GeoEntryPage = { total: 0, items: [] };
|
||||
|
||||
export type GeoTokenKind = 'ip' | 'domain';
|
||||
|
||||
export interface ValidateGeoTokensInput {
|
||||
tokens: string[];
|
||||
kind: GeoTokenKind;
|
||||
}
|
||||
|
||||
async function fetchGeodataFiles(): Promise<GeoFile[]> {
|
||||
const msg = await HttpUtil.get('/panel/api/xray/geodata/files', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata files');
|
||||
const validated = parseMsg(msg, GeoFileListSchema, 'xray/geodata/files');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
}
|
||||
|
||||
async function fetchGeodataCategories(file: string, query: string): Promise<GeoCategoryPage> {
|
||||
const msg = await HttpUtil.get('/panel/api/xray/geodata/categories', { file, q: query }, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata categories');
|
||||
const validated = parseMsg(msg, GeoCategoryPageSchema, 'xray/geodata/categories');
|
||||
return validated.obj ?? EMPTY_CATEGORY_PAGE;
|
||||
}
|
||||
|
||||
async function fetchGeodataEntries(
|
||||
file: string,
|
||||
code: string,
|
||||
query: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
): Promise<GeoEntryPage> {
|
||||
const msg = await HttpUtil.get(
|
||||
'/panel/api/xray/geodata/entries',
|
||||
{ file, code, q: query, offset, limit },
|
||||
{ silent: true },
|
||||
);
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch geodata entries');
|
||||
const validated = parseMsg(msg, GeoEntryPageSchema, 'xray/geodata/entries');
|
||||
return validated.obj ?? EMPTY_ENTRY_PAGE;
|
||||
}
|
||||
|
||||
export function useGeodataFiles(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: keys.xray.geodata.files(),
|
||||
queryFn: fetchGeodataFiles,
|
||||
enabled,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGeodataCategories(file: string | undefined, query: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: keys.xray.geodata.categories(file ?? '', query),
|
||||
queryFn: () => fetchGeodataCategories(file ?? '', query),
|
||||
enabled: enabled && !!file,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGeodataEntries(
|
||||
file: string | undefined,
|
||||
code: string | undefined,
|
||||
query: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
enabled: boolean,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: keys.xray.geodata.entries(file ?? '', code ?? '', query, offset, limit),
|
||||
queryFn: () => fetchGeodataEntries(file ?? '', code ?? '', query, offset, limit),
|
||||
enabled: enabled && !!file && !!code,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
export function useValidateGeoTokens() {
|
||||
return useMutation<GeodataTokenIssue[], Error, ValidateGeoTokensInput>({
|
||||
mutationFn: async ({ tokens, kind }) => {
|
||||
const msg = await HttpUtil.post(
|
||||
'/panel/api/xray/geodata/validate',
|
||||
{ tokens: tokens.join(','), kind },
|
||||
{ silent: true },
|
||||
);
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to validate geodata tokens');
|
||||
const validated = parseMsg(msg, GeodataTokenIssueListSchema, 'xray/geodata/validate');
|
||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -38,5 +38,12 @@ export const keys = {
|
||||
root: () => ['xray'] as const,
|
||||
config: () => ['xray', 'config'] as const,
|
||||
outboundsTraffic: () => ['xray', 'outboundsTraffic'] as const,
|
||||
geodata: {
|
||||
root: () => ['xray', 'geodata'] as const,
|
||||
files: () => ['xray', 'geodata', 'files'] as const,
|
||||
categories: (file: string, query: string) => ['xray', 'geodata', 'categories', file, query] as const,
|
||||
entries: (file: string, code: string, query: string, offset: number, limit: number) =>
|
||||
['xray', 'geodata', 'entries', file, code, query, offset, limit] as const,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
.geo-browser-modal .geo-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-toolbar .ant-input-search {
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-meta {
|
||||
margin-inline-start: auto;
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-columns {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 340px) minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
height: 440px;
|
||||
}
|
||||
|
||||
/* Both panes are the same fixed height, and the pager sits on the pane's floor
|
||||
rather than under the last row, so neither the dialog nor its controls move
|
||||
as the user steps between categories with wildly different rule counts. */
|
||||
.geo-browser-modal .geo-panel {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-panel .ant-table-wrapper,
|
||||
.geo-browser-modal .geo-panel .ant-spin-nested-loading,
|
||||
.geo-browser-modal .geo-panel .ant-spin-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-panel .ant-table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The rules table fills whatever is left between the header and the pager
|
||||
instead of carrying a hardcoded scroll height, so there is no dead strip
|
||||
above the pager and short categories do not scroll needlessly. */
|
||||
.geo-browser-modal .geo-preview-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-pager {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-pager .ant-pagination-total-text {
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-categories .ant-table-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-row-active > td {
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-category {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-code,
|
||||
.geo-browser-modal .geo-entry-value,
|
||||
.geo-browser-modal .geo-preview-title {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-attrs .ant-tag {
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
margin-inline-end: 4px;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-preview-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
/* The title is the part that gives way: without min-width it refuses to
|
||||
shrink, and the filter is pushed onto a second line instead of the long
|
||||
category name being clipped. */
|
||||
.geo-browser-modal .geo-preview-title {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-preview-head .ant-typography {
|
||||
flex: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-entry-filter {
|
||||
flex: none;
|
||||
width: 200px;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.geo-browser-modal .geo-preview-head {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-entry-filter {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-kind {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-kind-full {
|
||||
color: var(--ant-color-success);
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-kind-keyword {
|
||||
color: var(--ant-color-warning);
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-kind-regexp {
|
||||
color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-placeholder {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--ant-color-border-secondary);
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-chips {
|
||||
flex: 1;
|
||||
max-height: 76px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-selected-count {
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.geo-browser-modal .geo-columns {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.geo-browser-modal .geo-panel {
|
||||
height: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.geo-unknown-hint {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { Button, Space, Typography } from 'antd';
|
||||
|
||||
import type { GeoCategory, GeoEntry, GeoFile } from '@/generated/types';
|
||||
|
||||
import GeoBrowserModal, { type GeoBrowserModalProps } from './GeoBrowserModal';
|
||||
|
||||
type GeoResponder = (query: URLSearchParams) => unknown;
|
||||
type GeoRoutes = Record<string, GeoResponder>;
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
let activeRoutes: GeoRoutes = {};
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): URL {
|
||||
if (typeof input === 'string') return new URL(input, window.location.origin);
|
||||
if (input instanceof URL) return input;
|
||||
return new URL(input.url, window.location.origin);
|
||||
}
|
||||
|
||||
function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = requestUrl(input);
|
||||
const responder = activeRoutes[url.pathname];
|
||||
if (!responder) return realFetch(input, init);
|
||||
const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams) });
|
||||
return Promise.resolve(
|
||||
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||
);
|
||||
}
|
||||
|
||||
function activate(routes: GeoRoutes): void {
|
||||
activeRoutes = routes;
|
||||
window.fetch = geoFetch;
|
||||
}
|
||||
|
||||
function deactivate(routes: GeoRoutes): void {
|
||||
if (activeRoutes === routes) activeRoutes = {};
|
||||
}
|
||||
|
||||
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
|
||||
const [client] = useState(() => {
|
||||
activate(routes);
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
});
|
||||
useEffect(() => {
|
||||
activate(routes);
|
||||
return () => deactivate(routes);
|
||||
}, [routes]);
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
|
||||
const full = (value: string): GeoEntry => ({ kind: 'full', value });
|
||||
const keyword = (value: string): GeoEntry => ({ kind: 'keyword', value });
|
||||
const regexp = (value: string): GeoEntry => ({ kind: 'regexp', value });
|
||||
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
|
||||
|
||||
const cross = (names: string[], suffixes: string[]): GeoEntry[] =>
|
||||
names.flatMap((name) => suffixes.map((suffix) => domain(`${name}.${suffix}`)));
|
||||
|
||||
const CC_TLDS = [
|
||||
'ae', 'al', 'am', 'at', 'az', 'ba', 'be', 'bg', 'bi', 'bj', 'ca', 'cat', 'cd', 'cf', 'cg', 'ch',
|
||||
'ci', 'cl', 'cm', 'co.id', 'co.il', 'co.in', 'co.jp', 'co.ke', 'co.kr', 'co.ma', 'co.nz', 'co.th',
|
||||
'co.uk', 'co.uz', 'co.ve', 'co.za', 'com.ar', 'com.au', 'com.bd', 'com.br', 'com.co', 'com.cu',
|
||||
'com.eg', 'com.gt', 'com.hk', 'com.mx', 'com.my', 'com.ng', 'com.pe', 'com.ph', 'com.pk',
|
||||
'com.sa', 'com.sg', 'com.tr', 'com.tw', 'com.ua', 'com.uy', 'com.vn', 'cz', 'de', 'dj', 'dk',
|
||||
'dz', 'ee', 'es', 'fi', 'fr', 'ga', 'ge', 'gl', 'gm', 'gr', 'hn', 'hr', 'ht', 'hu', 'ie', 'iq',
|
||||
'is', 'it', 'je', 'jo', 'kg', 'kz', 'la', 'li', 'lk', 'lt', 'lu', 'lv', 'ly', 'md', 'me', 'mg',
|
||||
'mk', 'ml', 'mn', 'mu', 'mv', 'mw', 'ne', 'nl', 'no', 'nu', 'pl', 'pt', 'ro', 'rs', 'ru', 'rw',
|
||||
'se', 'sh', 'si', 'sk', 'sm', 'sn', 'so', 'sr', 'st', 'td', 'tg', 'tk', 'tl', 'tm', 'tn', 'to',
|
||||
'tt', 'vg', 'vu', 'ws',
|
||||
];
|
||||
|
||||
const AD_HOSTS = [
|
||||
'adform', 'adnxs', 'adroll', 'adsrvr', 'amplitude', 'appsflyer', 'bluekai', 'branch',
|
||||
'casalemedia', 'criteo', 'flurry', 'moatads', 'mopub', 'openx', 'outbrain', 'pubmatic',
|
||||
'quantserve', 'rubiconproject', 'scorecardresearch', 'sharethrough', 'smartadserver', 'taboola',
|
||||
'teads', 'yieldmo', 'zemanta',
|
||||
];
|
||||
|
||||
const CN_BRANDS = [
|
||||
'58', 'alibaba', 'alipay', 'aliyun', 'baidu', 'bilibili', 'cnblogs', 'csdn', 'ctrip', 'douban',
|
||||
'gitee', 'huawei', 'iqiyi', 'jd', 'kuaishou', 'meituan', 'netease', 'pinduoduo', 'qq', 'sina',
|
||||
'sohu', 'taobao', 'tencent', 'tmall', 'toutiao', 'weibo', 'xiaomi', 'youku', 'zhihu',
|
||||
];
|
||||
|
||||
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
amazon: [
|
||||
domain('amazon.com'), domain('amazonaws.com'), domain('media-amazon.com'),
|
||||
domain('ssl-images-amazon.com'), domain('primevideo.com'), domain('awsstatic.com'),
|
||||
domain('cloudfront.net'), full('www.amazon.co.jp'),
|
||||
],
|
||||
apple: [
|
||||
domain('apple.com'), domain('icloud.com'), domain('cdn-apple.com'), domain('mzstatic.com'),
|
||||
domain('apple-cloudkit.com'), domain('itunes.com'), domain('me.com'), domain('appstore.com'),
|
||||
],
|
||||
'category-ads': [
|
||||
domain('adcolony.com'), domain('applovin.com'), domain('chartboost.com'),
|
||||
domain('inmobi.com'), domain('unityads.unity3d.com'), keyword('banner-ad'),
|
||||
],
|
||||
'category-ads-all': [
|
||||
domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'),
|
||||
domain('adservice.google.com'), full('ads.yahoo.com'), keyword('adservice'),
|
||||
keyword('advertising'), regexp('^ad[0-9]{1,3}\\.'), ...cross(AD_HOSTS, ['com', 'net', 'io', 'ru']),
|
||||
],
|
||||
cloudflare: [
|
||||
domain('cloudflare.com'), domain('cloudflare-dns.com'), domain('cloudflareinsights.com'),
|
||||
domain('workers.dev'), domain('pages.dev'), domain('cf-ipfs.com'),
|
||||
],
|
||||
cn: [full('www.gov.cn'), keyword('chinanet'), ...cross(CN_BRANDS, ['com', 'cn', 'com.cn'])],
|
||||
discord: [
|
||||
domain('discord.com'), domain('discord.gg'), domain('discordapp.com'),
|
||||
domain('discordapp.net'), domain('discord.media'),
|
||||
],
|
||||
facebook: [
|
||||
domain('facebook.com'), domain('fbcdn.net'), domain('fb.com'), domain('messenger.com'),
|
||||
domain('fbsbx.com'), domain('facebook.net'), full('m.facebook.com'),
|
||||
],
|
||||
'geolocation-!cn': [
|
||||
keyword('proxy'), regexp('.*\\.onion$'), domain('wikipedia.org'), domain('bbc.com'),
|
||||
domain('nytimes.com'), domain('reuters.com'), domain('medium.com'), domain('reddit.com'),
|
||||
],
|
||||
'geolocation-cn': [
|
||||
domain('gov.cn'), domain('edu.cn'), domain('org.cn'), domain('net.cn'),
|
||||
...cross(CN_BRANDS.slice(0, 18), ['cn']),
|
||||
],
|
||||
github: [
|
||||
domain('github.com'), domain('githubusercontent.com'), domain('githubassets.com'),
|
||||
domain('github.io'), domain('ghcr.io'), domain('git.io'),
|
||||
],
|
||||
google: [
|
||||
domain('google.com'), domain('googleapis.com'), domain('gstatic.com'),
|
||||
domain('googleusercontent.com'), domain('google-analytics.com'), domain('googletagmanager.com'),
|
||||
domain('ggpht.com'), domain('withgoogle.com'), domain('android.com'), domain('chromium.org'),
|
||||
domain('abc.xyz'), full('dl.google.com'), ...CC_TLDS.map((tld) => domain(`google.${tld}`)),
|
||||
],
|
||||
instagram: [domain('instagram.com'), domain('cdninstagram.com'), domain('ig.me')],
|
||||
microsoft: [
|
||||
domain('microsoft.com'), domain('live.com'), domain('office.com'), domain('office365.com'),
|
||||
domain('windows.net'), domain('windowsupdate.com'), domain('msn.com'), domain('azure.com'),
|
||||
domain('sharepoint.com'), domain('skype.com'), domain('bing.com'),
|
||||
],
|
||||
netflix: [
|
||||
domain('netflix.com'), domain('netflix.net'), domain('nflximg.com'), domain('nflximg.net'),
|
||||
domain('nflxvideo.net'), domain('nflxso.net'), domain('nflxext.com'), full('fast.com'),
|
||||
],
|
||||
openai: [
|
||||
domain('openai.com'), domain('chatgpt.com'), domain('oaistatic.com'),
|
||||
domain('oaiusercontent.com'), domain('sora.com'),
|
||||
],
|
||||
spotify: [
|
||||
domain('spotify.com'), domain('scdn.co'), domain('spotifycdn.com'), domain('spoti.fi'),
|
||||
domain('spotifycdn.net'),
|
||||
],
|
||||
steam: [
|
||||
domain('steampowered.com'), domain('steamcommunity.com'), domain('steamstatic.com'),
|
||||
domain('steamcontent.com'), domain('valvesoftware.com'),
|
||||
],
|
||||
telegram: [
|
||||
domain('telegram.org'), domain('telegram.me'), domain('t.me'), domain('telesco.pe'),
|
||||
domain('tdesktop.com'), domain('telegra.ph'), domain('cdn-telegram.org'),
|
||||
full('comments.app'), keyword('telegram'),
|
||||
],
|
||||
tiktok: [
|
||||
domain('tiktok.com'), domain('tiktokcdn.com'), domain('tiktokv.com'),
|
||||
domain('byteoversea.com'), domain('ibytedtos.com'), domain('musical.ly'),
|
||||
],
|
||||
twitch: [domain('twitch.tv'), domain('ttvnw.net'), domain('jtvnw.net'), domain('twitchcdn.net')],
|
||||
twitter: [
|
||||
domain('twitter.com'), domain('x.com'), domain('t.co'), domain('twimg.com'),
|
||||
domain('periscope.tv'),
|
||||
],
|
||||
whatsapp: [domain('whatsapp.com'), domain('whatsapp.net'), domain('wa.me')],
|
||||
youtube: [
|
||||
domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com'),
|
||||
domain('youtube-nocookie.com'), domain('yt.be'),
|
||||
],
|
||||
};
|
||||
|
||||
const SITE_ATTRIBUTES: Record<string, string[]> = {
|
||||
amazon: ['ads'],
|
||||
apple: ['cn'],
|
||||
facebook: ['ads'],
|
||||
google: ['ads', 'cn'],
|
||||
instagram: ['ads'],
|
||||
microsoft: ['cn'],
|
||||
tiktok: ['ads', 'cn'],
|
||||
twitter: ['ads'],
|
||||
youtube: ['ads'],
|
||||
};
|
||||
|
||||
const CN_BLOCKS = [
|
||||
'1.0.1.0/24', '1.0.2.0/23', '1.0.8.0/21', '14.0.12.0/22', '27.0.128.0/21', '36.0.0.0/22',
|
||||
'39.0.0.0/24', '42.0.0.0/22', '58.14.0.0/15', '59.32.0.0/11', '61.128.0.0/10', '101.16.0.0/12',
|
||||
'103.1.8.0/22', '106.0.0.0/10', '110.6.0.0/15', '111.0.0.0/10', '112.0.0.0/10', '113.0.0.0/9',
|
||||
'114.28.0.0/16', '116.0.0.0/9', '117.8.0.0/13', '118.24.0.0/15', '119.0.0.0/9', '120.0.0.0/10',
|
||||
'121.0.0.0/8', '124.0.0.0/8', '125.32.0.0/11', '139.196.0.0/14', '140.75.0.0/16', '175.0.0.0/12',
|
||||
'180.76.0.0/16', '182.16.0.0/12', '183.0.0.0/10', '202.0.0.0/12', '203.0.0.0/12', '210.0.0.0/12',
|
||||
'211.64.0.0/11', '218.0.0.0/9', '219.72.0.0/14', '220.112.0.0/12', '221.0.0.0/9', '222.16.0.0/12',
|
||||
'2001:250::/35', '2400:3200::/32', '2408:8000::/20',
|
||||
];
|
||||
|
||||
const CN_EXTRA_BLOCKS = Array.from({ length: 96 }, (_, index) =>
|
||||
`${39 + Math.floor(index / 16)}.${(index % 16) * 16}.0.0/12`,
|
||||
);
|
||||
|
||||
const IP_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
cloudflare: [
|
||||
'103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '104.16.0.0/13', '104.24.0.0/14',
|
||||
'108.162.192.0/18', '131.0.72.0/22', '141.101.64.0/18', '162.158.0.0/15', '172.64.0.0/13',
|
||||
'173.245.48.0/20', '188.114.96.0/20', '190.93.240.0/20', '197.234.240.0/22', '198.41.128.0/17',
|
||||
'2400:cb00::/32', '2606:4700::/32',
|
||||
].map(cidr),
|
||||
cn: [...CN_BLOCKS, ...CN_EXTRA_BLOCKS].map(cidr),
|
||||
facebook: [
|
||||
'31.13.24.0/21', '31.13.64.0/18', '66.220.144.0/20', '69.63.176.0/20', '69.171.224.0/19',
|
||||
'157.240.0.0/16', '179.60.192.0/22', '185.60.216.0/22', '2a03:2880::/32',
|
||||
].map(cidr),
|
||||
google: [
|
||||
'8.8.4.0/24', '8.8.8.0/24', '34.64.0.0/10', '35.184.0.0/13', '64.233.160.0/19', '66.102.0.0/20',
|
||||
'72.14.192.0/18', '74.125.0.0/16', '108.177.8.0/21', '142.250.0.0/15', '172.217.0.0/16',
|
||||
'216.58.192.0/19', '2404:6800::/32', '2607:f8b0::/32',
|
||||
].map(cidr),
|
||||
ir: [
|
||||
'2.144.0.0/14', '5.22.0.0/17', '31.2.128.0/17', '37.32.0.0/19', '46.32.0.0/19', '78.38.0.0/15',
|
||||
'80.191.0.0/16', '85.15.0.0/18', '91.98.0.0/15', '178.22.72.0/21', '185.8.172.0/22',
|
||||
'188.34.0.0/17', '217.218.0.0/15',
|
||||
].map(cidr),
|
||||
netflix: [
|
||||
'23.246.0.0/18', '37.77.184.0/21', '45.57.0.0/17', '64.120.128.0/17', '66.197.128.0/17',
|
||||
'108.175.32.0/20', '185.2.220.0/22', '192.173.64.0/18', '198.38.96.0/19', '198.45.48.0/20',
|
||||
].map(cidr),
|
||||
private: [
|
||||
'0.0.0.0/8', '10.0.0.0/8', '100.64.0.0/10', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12',
|
||||
'192.0.0.0/24', '192.0.2.0/24', '192.168.0.0/16', '198.18.0.0/15', '198.51.100.0/24',
|
||||
'203.0.113.0/24', '224.0.0.0/4', '240.0.0.0/4', '255.255.255.255/32', '::1/128', 'fc00::/7',
|
||||
'fe80::/10',
|
||||
].map(cidr),
|
||||
ru: [
|
||||
'2.60.0.0/14', '5.8.0.0/19', '31.6.0.0/17', '37.9.0.0/19', '46.16.0.0/21', '62.76.0.0/18',
|
||||
'77.37.128.0/17', '78.24.216.0/21', '79.104.0.0/15', '80.64.128.0/19', '81.16.96.0/19',
|
||||
'82.140.128.0/18', '85.113.0.0/16', '87.226.0.0/16', '91.77.0.0/16', '93.157.0.0/17',
|
||||
'94.19.0.0/16', '95.24.0.0/13', '178.176.0.0/13', '188.128.0.0/13', '213.87.0.0/16',
|
||||
'217.66.152.0/21', '2a00:1148::/32',
|
||||
].map(cidr),
|
||||
telegram: [
|
||||
'91.108.4.0/22', '91.108.8.0/22', '91.108.12.0/22', '91.108.16.0/22', '91.108.20.0/22',
|
||||
'91.108.56.0/22', '149.154.160.0/20', '2001:67c:4e8::/48', '2001:b28:f23d::/48',
|
||||
'2001:b28:f23f::/48',
|
||||
].map(cidr),
|
||||
us: [
|
||||
'3.0.0.0/9', '12.0.0.0/8', '23.192.0.0/11', '34.192.0.0/10', '50.16.0.0/14', '52.0.0.0/10',
|
||||
'63.64.0.0/11', '65.0.0.0/10', '68.32.0.0/11', '71.0.0.0/11', '96.0.0.0/9', '128.0.0.0/10',
|
||||
'199.0.0.0/12', '208.64.0.0/12', '2600:1f00::/24',
|
||||
].map(cidr),
|
||||
};
|
||||
|
||||
function categoriesOf(
|
||||
entries: Record<string, GeoEntry[]>,
|
||||
attributes: Record<string, string[]> = {},
|
||||
): GeoCategory[] {
|
||||
return Object.keys(entries)
|
||||
.sort()
|
||||
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
|
||||
}
|
||||
|
||||
const SITE_CATEGORIES = categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES);
|
||||
const IP_CATEGORIES = categoriesOf(IP_ENTRIES);
|
||||
|
||||
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
|
||||
|
||||
const GEOSITE_FILE: GeoFile = {
|
||||
name: 'geosite.dat',
|
||||
kind: 'site',
|
||||
size: 4_812_544,
|
||||
modifiedAt: UPDATED_AT,
|
||||
categories: SITE_CATEGORIES.length,
|
||||
};
|
||||
|
||||
const GEOIP_FILE: GeoFile = {
|
||||
name: 'geoip.dat',
|
||||
kind: 'ip',
|
||||
size: 8_694_272,
|
||||
modifiedAt: UPDATED_AT,
|
||||
categories: IP_CATEGORIES.length,
|
||||
};
|
||||
|
||||
const DAMAGED_FILE: GeoFile = {
|
||||
name: 'geosite-custom.dat',
|
||||
kind: 'site',
|
||||
size: 262_144,
|
||||
modifiedAt: Date.UTC(2026, 5, 2, 19, 45),
|
||||
categories: 0,
|
||||
error: 'proto: cannot parse invalid wire-format data',
|
||||
};
|
||||
|
||||
const OVERSIZED_FILE: GeoFile = {
|
||||
name: 'geoip-full.dat',
|
||||
kind: 'ip',
|
||||
size: 96_468_992,
|
||||
modifiedAt: Date.UTC(2026, 6, 20, 8, 5),
|
||||
categories: 0,
|
||||
error: 'geodata file is too large to browse',
|
||||
};
|
||||
|
||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
|
||||
'geosite.dat': { categories: SITE_CATEGORIES, entries: SITE_ENTRIES },
|
||||
'geoip.dat': { categories: IP_CATEGORIES, entries: IP_ENTRIES },
|
||||
};
|
||||
|
||||
function routesFor(files: GeoFile[]): GeoRoutes {
|
||||
return {
|
||||
'/panel/api/xray/geodata/files': () => files,
|
||||
'/panel/api/xray/geodata/categories': (query) => {
|
||||
const dataset = DATASETS[query.get('file') ?? ''];
|
||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
||||
const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
|
||||
return { total: items.length, items };
|
||||
},
|
||||
'/panel/api/xray/geodata/entries': (query) => {
|
||||
const dataset = DATASETS[query.get('file') ?? ''];
|
||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
||||
const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
|
||||
entry.value.toLowerCase().includes(needle),
|
||||
);
|
||||
const offset = Number(query.get('offset') ?? 0);
|
||||
const limit = Number(query.get('limit') ?? 100);
|
||||
return { total: matched.length, items: matched.slice(offset, offset + limit) };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withFiles(files: GeoFile[]): Decorator {
|
||||
const routes = routesFor(files);
|
||||
return function GeodataBackend(Story) {
|
||||
return (
|
||||
<GeoApi routes={routes}>
|
||||
<Story />
|
||||
</GeoApi>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const withDatabases = withFiles([GEOSITE_FILE, GEOIP_FILE]);
|
||||
|
||||
function BrowserDemo(props: GeoBrowserModalProps) {
|
||||
const [open, setOpen] = useState(props.open);
|
||||
const [value, setValue] = useState(props.value);
|
||||
useEffect(() => setOpen(props.open), [props.open]);
|
||||
useEffect(() => setValue(props.value), [props.value]);
|
||||
return (
|
||||
<Space direction="vertical" size={12}>
|
||||
<Space size={8}>
|
||||
<Button onClick={() => setOpen(true)}>Open geo browser</Button>
|
||||
<Typography.Text code>{value || 'no rule yet'}</Typography.Text>
|
||||
</Space>
|
||||
<GeoBrowserModal
|
||||
{...props}
|
||||
open={open}
|
||||
value={value}
|
||||
onApply={(next) => {
|
||||
setValue(next);
|
||||
setOpen(false);
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Geodata/GeoBrowserModal',
|
||||
component: GeoBrowserModal,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [{ id: 'color-contrast', enabled: false }],
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Browser for the geosite/geoip `.dat` databases Xray resolves `geosite:` and `geoip:` routing tokens against: pick a database, search its categories, tick the ones a rule needs, and preview the domains or CIDRs inside the highlighted category. Applying merges the ticked categories back into the rule string, keeping hand-typed domains untouched. The stories serve `/panel/api/xray/geodata/*` from an in-memory fixture, so search, paging and selection all work without a panel backend.',
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {
|
||||
open: true,
|
||||
kind: 'site',
|
||||
value: '',
|
||||
onApply: () => undefined,
|
||||
onClose: () => undefined,
|
||||
},
|
||||
argTypes: {
|
||||
open: { description: 'Whether the modal is visible.' },
|
||||
kind: {
|
||||
description: 'Which database layout the rule targets: `site` for domain rules, `ip` for CIDR rules. Decides the preselected database and the token prefix.',
|
||||
control: 'inline-radio',
|
||||
options: ['site', 'ip'],
|
||||
},
|
||||
value: {
|
||||
description: 'Current rule string, comma separated. Tokens that match a category in the opened database come back preselected.',
|
||||
},
|
||||
onApply: { description: 'Called with the merged rule string when Apply is pressed.' },
|
||||
onClose: { description: 'Called when the modal is dismissed.' },
|
||||
},
|
||||
render: (args) => <BrowserDemo {...args} />,
|
||||
} satisfies Meta<typeof GeoBrowserModal>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SiteDatabase: Story = {
|
||||
decorators: [withDatabases],
|
||||
args: { kind: 'site', value: 'geosite:google, geosite:telegram, ads.example.com' },
|
||||
};
|
||||
|
||||
export const CategoryPreview: Story = {
|
||||
decorators: [withDatabases],
|
||||
args: { kind: 'site', value: 'geosite:google' },
|
||||
parameters: {
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [
|
||||
{ id: 'color-contrast', enabled: false },
|
||||
{ id: 'scrollable-region-focusable', enabled: false },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement, userEvent }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.type(await body.findByPlaceholderText('Search category'), 'telegram');
|
||||
await userEvent.click(await body.findByText('telegram'));
|
||||
await expect(await body.findByText('t.me')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const IpDatabase: Story = {
|
||||
decorators: [withDatabases],
|
||||
args: { kind: 'ip', value: 'geoip:private, 10.0.0.0/8' },
|
||||
};
|
||||
|
||||
export const NoDatabases: Story = {
|
||||
decorators: [withFiles([])],
|
||||
args: { kind: 'site', value: 'geosite:google' },
|
||||
};
|
||||
|
||||
export const DamagedDatabase: Story = {
|
||||
decorators: [withFiles([GEOSITE_FILE, DAMAGED_FILE, OVERSIZED_FILE])],
|
||||
args: { kind: 'site', value: '' },
|
||||
};
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Button, Empty, Input, Modal, Pagination, Select, Space, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { useGeodataCategories, useGeodataEntries, useGeodataFiles } from '@/api/queries/useGeodata';
|
||||
import { canonicalToken, mergeSelection, selectionFromValue, tokenFor } from '@/lib/xray/geoTokens';
|
||||
import { SizeFormatter } from '@/utils';
|
||||
import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types';
|
||||
|
||||
import './GeoBrowserModal.css';
|
||||
|
||||
const ENTRY_PAGE_SIZE = 100;
|
||||
const CATEGORY_SCROLL_HEIGHT = 438;
|
||||
const ENTRY_FILTER_DELAY = 500;
|
||||
|
||||
export interface GeoBrowserModalProps {
|
||||
open: boolean;
|
||||
kind: GeoKind;
|
||||
value: string;
|
||||
onApply: (value: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// A geosite category inside an ip rule (or the reverse) is a config Xray will
|
||||
// reject, so a field only ever offers databases of its own kind.
|
||||
function databasesFor(files: GeoFile[], kind: GeoKind): GeoFile[] {
|
||||
return files.filter((file) => file.kind === kind || (file.error && namePrefersKind(file.name, kind)));
|
||||
}
|
||||
|
||||
function namePrefersKind(name: string, kind: GeoKind): boolean {
|
||||
return name.toLowerCase().includes('ip') === (kind === 'ip');
|
||||
}
|
||||
|
||||
function preferredFile(files: GeoFile[], kind: GeoKind): string | undefined {
|
||||
const usable = databasesFor(files, kind).filter((file) => !file.error);
|
||||
const preferredName = kind === 'ip' ? 'geoip.dat' : 'geosite.dat';
|
||||
return usable.find((file) => file.name === preferredName)?.name ?? usable[0]?.name;
|
||||
}
|
||||
|
||||
export default function GeoBrowserModal({ open, kind, value, onApply, onClose }: GeoBrowserModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [file, setFile] = useState<string | undefined>(undefined);
|
||||
const [categoryQuery, setCategoryQuery] = useState('');
|
||||
const [activeCode, setActiveCode] = useState<string | undefined>(undefined);
|
||||
const [entryQuery, setEntryQuery] = useState('');
|
||||
const [entryFilter, setEntryFilter] = useState('');
|
||||
const [entryPage, setEntryPage] = useState(1);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
|
||||
const knownRef = useRef<Set<string>>(new Set());
|
||||
const seededFilesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const filesQuery = useGeodataFiles(open);
|
||||
const files = useMemo(() => databasesFor(filesQuery.data ?? [], kind), [filesQuery.data, kind]);
|
||||
const activeFile = files.find((candidate) => candidate.name === file);
|
||||
const fileKind: GeoKind = activeFile?.kind ?? kind;
|
||||
|
||||
const categoriesQuery = useGeodataCategories(file, '', open && !!file);
|
||||
// While a newly picked database loads, the query still serves the previous
|
||||
// one's categories; seeding or filtering against those would attribute one
|
||||
// database's codes to another.
|
||||
const categoriesLoaded = !categoriesQuery.isPlaceholderData && !categoriesQuery.isLoading;
|
||||
const categories = useMemo(
|
||||
() => (categoriesLoaded ? (categoriesQuery.data?.items ?? []) : []),
|
||||
[categoriesLoaded, categoriesQuery.data],
|
||||
);
|
||||
|
||||
// Only the settled filter reaches the query key: every request rescans the
|
||||
// whole .dat file server-side, so a per-keystroke fetch would be one full
|
||||
// scan per character while the box itself stays instant.
|
||||
const entriesQuery = useGeodataEntries(
|
||||
file,
|
||||
activeCode,
|
||||
entryFilter,
|
||||
(entryPage - 1) * ENTRY_PAGE_SIZE,
|
||||
ENTRY_PAGE_SIZE,
|
||||
open && !!file && !!activeCode,
|
||||
);
|
||||
|
||||
// Resets clear both halves at once so a switch of database or category never
|
||||
// renders with the previous filter still in the key, which would fire the
|
||||
// very request the debounce exists to avoid.
|
||||
const clearEntryFilter = useCallback(() => {
|
||||
setEntryQuery('');
|
||||
setEntryFilter('');
|
||||
setEntryPage(1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (entryQuery === entryFilter) return;
|
||||
const handle = window.setTimeout(() => {
|
||||
setEntryFilter(entryQuery);
|
||||
setEntryPage(1);
|
||||
}, ENTRY_FILTER_DELAY);
|
||||
return () => window.clearTimeout(handle);
|
||||
}, [entryQuery, entryFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
knownRef.current = new Set();
|
||||
seededFilesRef.current = new Set();
|
||||
setCategoryQuery('');
|
||||
setEntryQuery('');
|
||||
setEntryFilter('');
|
||||
setActiveCode(undefined);
|
||||
setEntryPage(1);
|
||||
setSelected([]);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || file || files.length === 0) return;
|
||||
setFile(preferredFile(files, kind));
|
||||
}, [open, file, files, kind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !file || categories.length === 0 || seededFilesRef.current.has(file)) return;
|
||||
const tokens = categories.map((category) => tokenFor(file, category.code, fileKind));
|
||||
for (const token of tokens) knownRef.current.add(token);
|
||||
seededFilesRef.current.add(file);
|
||||
const fromValue = selectionFromValue(value, new Set(tokens));
|
||||
if (fromValue.length > 0) {
|
||||
setSelected((previous) => [...previous, ...fromValue.filter((token) => !previous.includes(token))]);
|
||||
}
|
||||
}, [open, file, categories, fileKind, value]);
|
||||
|
||||
const visibleCategories = useMemo(() => {
|
||||
const query = categoryQuery.trim().toLowerCase();
|
||||
if (!query) return categories;
|
||||
return categories.filter((category) => category.code.includes(query));
|
||||
}, [categories, categoryQuery]);
|
||||
|
||||
// Comparisons run through the canonical form: a field may hold the long
|
||||
// ext:geosite.dat:cn spelling or a different case, and those name the same
|
||||
// category as the geosite:cn this modal generates.
|
||||
const selectedCodes = useMemo(() => {
|
||||
if (!file) return [];
|
||||
const chosen = new Set(selected.map(canonicalToken));
|
||||
return categories
|
||||
.filter((category) => chosen.has(canonicalToken(tokenFor(file, category.code, fileKind))))
|
||||
.map((category) => category.code);
|
||||
}, [categories, file, fileKind, selected]);
|
||||
|
||||
const toggle = useCallback(
|
||||
(codes: string[]) => {
|
||||
if (!file) return;
|
||||
const chosen = new Set(codes.map((code) => tokenFor(file, code, fileKind)));
|
||||
const chosenCanonical = new Set([...chosen].map(canonicalToken));
|
||||
// The table reports keys for the rows it currently shows, so a selection
|
||||
// made before the search box was narrowed must survive untouched.
|
||||
const shown = new Set(
|
||||
visibleCategories.map((category) => canonicalToken(tokenFor(file, category.code, fileKind))),
|
||||
);
|
||||
setSelected((previous) => {
|
||||
const kept = previous.filter((token) => {
|
||||
const canonical = canonicalToken(token);
|
||||
return !shown.has(canonical) || chosenCanonical.has(canonical);
|
||||
});
|
||||
const keptCanonical = new Set(kept.map(canonicalToken));
|
||||
return [...kept, ...[...chosen].filter((token) => !keptCanonical.has(canonicalToken(token)))];
|
||||
});
|
||||
},
|
||||
[visibleCategories, file, fileKind],
|
||||
);
|
||||
|
||||
const categoryColumns: ColumnsType<GeoCategory> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('pages.xray.geoBrowser.searchCategory'),
|
||||
dataIndex: 'code',
|
||||
render: (code: string, category: GeoCategory) => (
|
||||
<span className="geo-category">
|
||||
<span className="geo-code">{code}</span>
|
||||
{category.attributes?.length > 0 && (
|
||||
<span className="geo-attrs">
|
||||
{category.attributes.map((attribute) => (
|
||||
<Tag key={attribute} bordered={false}>
|
||||
@{attribute}
|
||||
</Tag>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
dataIndex: 'entries',
|
||||
align: 'right',
|
||||
width: 90,
|
||||
render: (entries: number) => <span className="geo-count">{entries.toLocaleString()}</span>,
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const entryColumns: ColumnsType<GeoEntry> = useMemo(
|
||||
() => [
|
||||
{
|
||||
dataIndex: 'kind',
|
||||
width: 88,
|
||||
render: (entryKind: string) => (
|
||||
<Tag bordered={false} className={`geo-kind geo-kind-${entryKind}`}>
|
||||
{entryKind}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
dataIndex: 'value',
|
||||
render: (entryValue: string) => <span className="geo-entry-value">{entryValue}</span>,
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const fileOptions = files.map((candidate) => ({
|
||||
value: candidate.name,
|
||||
label: candidate.error ? `${candidate.name} — ${describeFileError(candidate.error, t)}` : candidate.name,
|
||||
disabled: !!candidate.error,
|
||||
}));
|
||||
|
||||
const meta = activeFile
|
||||
? t('pages.xray.geoBrowser.fileMeta', {
|
||||
count: activeFile.categories.toLocaleString(),
|
||||
size: SizeFormatter.sizeFormat(activeFile.size),
|
||||
date: new Date(activeFile.modifiedAt).toLocaleString(),
|
||||
})
|
||||
: '';
|
||||
|
||||
const entriesTotal = entriesQuery.data?.total ?? 0;
|
||||
const activeCategory = categories.find((category) => category.code === activeCode);
|
||||
const countLabel = activeCategory
|
||||
? t(fileKind === 'ip' ? 'pages.xray.geoBrowser.subnetsCount' : 'pages.xray.geoBrowser.entriesCount', {
|
||||
count: activeCategory.entries.toLocaleString(),
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={t('pages.xray.geoBrowser.title')}
|
||||
width={880}
|
||||
onCancel={onClose}
|
||||
onOk={() => onApply(mergeSelection(value, selected, knownRef.current))}
|
||||
okText={t('pages.xray.geoBrowser.apply')}
|
||||
cancelText={t('close')}
|
||||
className="geo-browser-modal"
|
||||
>
|
||||
{filesQuery.isError && <Alert type="error" showIcon title={t('pages.xray.geoBrowser.loadFailed')} className="mb-12" />}
|
||||
|
||||
{!filesQuery.isError && !filesQuery.isLoading && files.length === 0 ? (
|
||||
<Empty
|
||||
description={
|
||||
<span>
|
||||
{t('pages.xray.geoBrowser.noFiles')}
|
||||
<br />
|
||||
<Typography.Text type="secondary">{t('pages.xray.geoBrowser.noFilesHint')}</Typography.Text>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="geo-toolbar">
|
||||
<Select
|
||||
value={file}
|
||||
options={fileOptions}
|
||||
onChange={(next) => {
|
||||
setFile(next);
|
||||
setActiveCode(undefined);
|
||||
setCategoryQuery('');
|
||||
clearEntryFilter();
|
||||
}}
|
||||
style={{ minWidth: 200 }}
|
||||
aria-label={t('pages.xray.geoBrowser.database')}
|
||||
/>
|
||||
<Input.Search
|
||||
value={categoryQuery}
|
||||
onChange={(event) => setCategoryQuery(event.target.value)}
|
||||
placeholder={t('pages.xray.geoBrowser.searchCategory')}
|
||||
allowClear
|
||||
/>
|
||||
<Button
|
||||
onClick={() => toggle([...new Set([...selectedCodes, ...visibleCategories.map((c) => c.code)])])}
|
||||
disabled={visibleCategories.length === 0}
|
||||
>
|
||||
{`${t('pages.xray.geoBrowser.selectFound')} (${visibleCategories.length.toLocaleString()})`}
|
||||
</Button>
|
||||
<span className="geo-meta">{meta}</span>
|
||||
</div>
|
||||
|
||||
<div className="geo-columns">
|
||||
<div className="geo-panel geo-categories">
|
||||
<Table
|
||||
size="small"
|
||||
virtual
|
||||
showHeader={false}
|
||||
rowKey="code"
|
||||
columns={categoryColumns}
|
||||
dataSource={visibleCategories}
|
||||
loading={filesQuery.isLoading || categoriesQuery.isLoading || categoriesQuery.isPlaceholderData}
|
||||
pagination={false}
|
||||
scroll={{ y: CATEGORY_SCROLL_HEIGHT }}
|
||||
locale={{ emptyText: t('pages.xray.geoBrowser.noMatches') }}
|
||||
rowSelection={{
|
||||
columnWidth: 42,
|
||||
preserveSelectedRowKeys: true,
|
||||
selectedRowKeys: selectedCodes,
|
||||
onChange: (keys) => toggle(keys as string[]),
|
||||
}}
|
||||
onRow={(category) => ({
|
||||
onClick: (event) => {
|
||||
if ((event.target as HTMLElement).closest('.ant-table-selection-column')) return;
|
||||
setActiveCode(category.code);
|
||||
clearEntryFilter();
|
||||
},
|
||||
})}
|
||||
rowClassName={(category) => (category.code === activeCode ? 'geo-row-active' : '')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="geo-panel geo-preview">
|
||||
{activeCode ? (
|
||||
<>
|
||||
<div className="geo-preview-head">
|
||||
<Tooltip title={file ? tokenFor(file, activeCode, fileKind) : activeCode}>
|
||||
<span className="geo-preview-title">{activeCode}</span>
|
||||
</Tooltip>
|
||||
<Typography.Text type="secondary">{countLabel}</Typography.Text>
|
||||
<Input
|
||||
value={entryQuery}
|
||||
onChange={(event) => setEntryQuery(event.target.value)}
|
||||
placeholder={t('pages.xray.geoBrowser.searchEntries')}
|
||||
allowClear
|
||||
className="geo-entry-filter"
|
||||
/>
|
||||
</div>
|
||||
<div className="geo-preview-body">
|
||||
<Table
|
||||
size="small"
|
||||
showHeader={false}
|
||||
rowKey={(entry, index) => `${entry.value}-${index}`}
|
||||
columns={entryColumns}
|
||||
dataSource={entriesQuery.data?.items ?? []}
|
||||
loading={entriesQuery.isLoading}
|
||||
locale={{
|
||||
emptyText: entriesQuery.isError
|
||||
? t('pages.xray.geoBrowser.loadFailed')
|
||||
: t('pages.xray.geoBrowser.noMatches'),
|
||||
}}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="geo-pager">
|
||||
<Pagination
|
||||
current={entryPage}
|
||||
pageSize={ENTRY_PAGE_SIZE}
|
||||
total={entriesTotal}
|
||||
size="small"
|
||||
showSizeChanger={false}
|
||||
onChange={setEntryPage}
|
||||
showTotal={(total, range) =>
|
||||
t('pages.xray.geoBrowser.shownRange', {
|
||||
from: range[0].toLocaleString(),
|
||||
to: range[1].toLocaleString(),
|
||||
total: total.toLocaleString(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="geo-placeholder">
|
||||
<Typography.Text type="secondary">{t('pages.xray.geoBrowser.pickCategory')}</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="geo-footer">
|
||||
{selected.length === 0 ? (
|
||||
<Typography.Text type="secondary">{t('pages.xray.geoBrowser.emptySelection')}</Typography.Text>
|
||||
) : (
|
||||
<>
|
||||
<Space size={4} wrap className="geo-chips">
|
||||
{selected.map((token) => (
|
||||
<Tag
|
||||
key={token}
|
||||
closable
|
||||
color="processing"
|
||||
onClose={() => setSelected((previous) => previous.filter((item) => item !== token))}
|
||||
>
|
||||
{token}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
<span className="geo-selected-count">
|
||||
{t('pages.xray.geoBrowser.selected', { count: selected.length })}
|
||||
</span>
|
||||
<Button type="link" size="small" onClick={() => setSelected([])}>
|
||||
{t('pages.xray.geoBrowser.clearAll')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function describeFileError(error: string, t: (key: string) => string): string {
|
||||
if (error.includes('too large')) return t('pages.xray.geoBrowser.tooLarge');
|
||||
return t('pages.xray.geoBrowser.parseFailed');
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { expect, within } from 'storybook/test';
|
||||
import { Space } from 'antd';
|
||||
|
||||
import { parseTokens } from '@/lib/xray/geoTokens';
|
||||
import type { GeoCategory, GeoEntry, GeoFile, GeodataTokenIssue } from '@/generated/types';
|
||||
|
||||
import GeoTokenInput, { type GeoTokenInputProps } from './GeoTokenInput';
|
||||
|
||||
type GeoResponder = (query: URLSearchParams, body: URLSearchParams) => unknown;
|
||||
type GeoRoutes = Record<string, GeoResponder>;
|
||||
|
||||
const realFetch = window.fetch.bind(window);
|
||||
let activeRoutes: GeoRoutes = {};
|
||||
|
||||
function requestUrl(input: RequestInfo | URL): URL {
|
||||
if (typeof input === 'string') return new URL(input, window.location.origin);
|
||||
if (input instanceof URL) return input;
|
||||
return new URL(input.url, window.location.origin);
|
||||
}
|
||||
|
||||
function geoFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = requestUrl(input);
|
||||
const responder = activeRoutes[url.pathname];
|
||||
if (!responder) return realFetch(input, init);
|
||||
const form = new URLSearchParams(typeof init?.body === 'string' ? init.body : '');
|
||||
const body = JSON.stringify({ success: true, msg: '', obj: responder(url.searchParams, form) });
|
||||
return Promise.resolve(
|
||||
new Response(body, { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||
);
|
||||
}
|
||||
|
||||
function activate(routes: GeoRoutes): void {
|
||||
activeRoutes = routes;
|
||||
window.fetch = geoFetch;
|
||||
}
|
||||
|
||||
function deactivate(routes: GeoRoutes): void {
|
||||
if (activeRoutes === routes) activeRoutes = {};
|
||||
}
|
||||
|
||||
function GeoApi({ routes, children }: { routes: GeoRoutes; children: ReactNode }) {
|
||||
const [client] = useState(() => {
|
||||
activate(routes);
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
});
|
||||
useEffect(() => {
|
||||
activate(routes);
|
||||
return () => deactivate(routes);
|
||||
}, [routes]);
|
||||
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
const domain = (value: string): GeoEntry => ({ kind: 'domain', value });
|
||||
const cidr = (value: string): GeoEntry => ({ kind: 'cidr', value });
|
||||
|
||||
const SITE_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
'category-ads-all': [
|
||||
domain('doubleclick.net'), domain('googleadservices.com'), domain('googlesyndication.com'),
|
||||
domain('criteo.com'), domain('taboola.com'), domain('outbrain.com'),
|
||||
],
|
||||
cn: [domain('baidu.com'), domain('qq.com'), domain('taobao.com'), domain('weibo.com'), domain('bilibili.com')],
|
||||
google: [
|
||||
domain('google.com'), domain('googleapis.com'), domain('gstatic.com'),
|
||||
domain('googleusercontent.com'), domain('ggpht.com'), domain('android.com'),
|
||||
],
|
||||
netflix: [domain('netflix.com'), domain('nflximg.net'), domain('nflxvideo.net'), domain('fast.com')],
|
||||
telegram: [domain('telegram.org'), domain('t.me'), domain('telesco.pe'), domain('telegra.ph')],
|
||||
youtube: [domain('youtube.com'), domain('youtu.be'), domain('ytimg.com'), domain('googlevideo.com')],
|
||||
};
|
||||
|
||||
const IP_ENTRIES: Record<string, GeoEntry[]> = {
|
||||
cloudflare: ['104.16.0.0/13', '172.64.0.0/13', '2606:4700::/32'].map(cidr),
|
||||
cn: ['1.0.1.0/24', '36.0.0.0/22', '116.0.0.0/9', '2408:8000::/20'].map(cidr),
|
||||
private: [
|
||||
'10.0.0.0/8', '127.0.0.0/8', '169.254.0.0/16', '172.16.0.0/12', '192.168.0.0/16',
|
||||
'::1/128', 'fc00::/7', 'fe80::/10',
|
||||
].map(cidr),
|
||||
telegram: ['91.108.4.0/22', '149.154.160.0/20', '2001:b28:f23d::/48'].map(cidr),
|
||||
};
|
||||
|
||||
const SITE_ATTRIBUTES: Record<string, string[]> = {
|
||||
google: ['ads', 'cn'],
|
||||
youtube: ['ads'],
|
||||
};
|
||||
|
||||
function categoriesOf(
|
||||
entries: Record<string, GeoEntry[]>,
|
||||
attributes: Record<string, string[]> = {},
|
||||
): GeoCategory[] {
|
||||
return Object.keys(entries)
|
||||
.sort()
|
||||
.map((code) => ({ code, entries: entries[code].length, attributes: attributes[code] ?? [] }));
|
||||
}
|
||||
|
||||
const DATASETS: Record<string, { categories: GeoCategory[]; entries: Record<string, GeoEntry[]> }> = {
|
||||
'geosite.dat': { categories: categoriesOf(SITE_ENTRIES, SITE_ATTRIBUTES), entries: SITE_ENTRIES },
|
||||
'geoip.dat': { categories: categoriesOf(IP_ENTRIES), entries: IP_ENTRIES },
|
||||
};
|
||||
|
||||
const UPDATED_AT = Date.UTC(2026, 6, 24, 3, 12);
|
||||
|
||||
const FILES: GeoFile[] = [
|
||||
{
|
||||
name: 'geosite.dat',
|
||||
kind: 'site',
|
||||
size: 4_812_544,
|
||||
modifiedAt: UPDATED_AT,
|
||||
categories: DATASETS['geosite.dat'].categories.length,
|
||||
},
|
||||
{
|
||||
name: 'geoip.dat',
|
||||
kind: 'ip',
|
||||
size: 8_694_272,
|
||||
modifiedAt: UPDATED_AT,
|
||||
categories: DATASETS['geoip.dat'].categories.length,
|
||||
},
|
||||
];
|
||||
|
||||
function referenceOf(token: string, isIP: boolean): { file: string; code: string } | null {
|
||||
const [prefix, ...rest] = token.split(':');
|
||||
const code = (value: string) => value.split('@')[0].toLowerCase();
|
||||
if (prefix === 'geosite') return { file: 'geosite.dat', code: code(rest.join(':')) };
|
||||
if (prefix === 'geoip') return { file: 'geoip.dat', code: code(rest.join(':')) };
|
||||
if (prefix === 'ext') return { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) };
|
||||
return isIP && prefix === 'ext-ip' ? { file: rest[0] ?? '', code: code(rest.slice(1).join(':')) } : null;
|
||||
}
|
||||
|
||||
function validate(tokens: string[], isIP: boolean): GeodataTokenIssue[] {
|
||||
const issues: GeodataTokenIssue[] = [];
|
||||
for (const token of tokens) {
|
||||
const reference = referenceOf(token, isIP);
|
||||
if (!reference) continue;
|
||||
const dataset = DATASETS[reference.file];
|
||||
if (!dataset) {
|
||||
issues.push({ token, reason: 'fileMissing', file: reference.file, code: reference.code });
|
||||
continue;
|
||||
}
|
||||
if (!dataset.categories.some((category) => category.code === reference.code)) {
|
||||
issues.push({ token, reason: 'categoryMissing', file: reference.file, code: reference.code });
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
const routes: GeoRoutes = {
|
||||
'/csrf-token': () => 'storybook-csrf-token',
|
||||
'/panel/api/xray/geodata/files': () => FILES,
|
||||
'/panel/api/xray/geodata/categories': (query) => {
|
||||
const dataset = DATASETS[query.get('file') ?? ''];
|
||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
||||
const items = (dataset?.categories ?? []).filter((category) => category.code.includes(needle));
|
||||
return { total: items.length, items };
|
||||
},
|
||||
'/panel/api/xray/geodata/entries': (query) => {
|
||||
const dataset = DATASETS[query.get('file') ?? ''];
|
||||
const needle = (query.get('q') ?? '').trim().toLowerCase();
|
||||
const matched = (dataset?.entries[query.get('code') ?? ''] ?? []).filter((entry) =>
|
||||
entry.value.toLowerCase().includes(needle),
|
||||
);
|
||||
const offset = Number(query.get('offset') ?? 0);
|
||||
const limit = Number(query.get('limit') ?? 100);
|
||||
return { total: matched.length, items: matched.slice(offset, offset + limit) };
|
||||
},
|
||||
'/panel/api/xray/geodata/validate': (_query, form) =>
|
||||
validate(parseTokens(form.get('tokens') ?? ''), form.get('kind') === 'ip'),
|
||||
};
|
||||
|
||||
const withGeodata: Decorator = function GeodataBackend(Story) {
|
||||
return (
|
||||
<GeoApi routes={routes}>
|
||||
<Story />
|
||||
</GeoApi>
|
||||
);
|
||||
};
|
||||
|
||||
function ControlledTokenInput({ value = '', id = 'geo-rule', ...rest }: GeoTokenInputProps) {
|
||||
const [current, setCurrent] = useState(value);
|
||||
useEffect(() => setCurrent(value), [value]);
|
||||
return (
|
||||
<Space direction="vertical" size={4} style={{ width: 460 }}>
|
||||
<label htmlFor={id}>{rest.kind === 'ip' ? 'Target IP' : 'Target domain'}</label>
|
||||
<GeoTokenInput {...rest} id={id} value={current} onChange={setCurrent} />
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: 'Geodata/GeoTokenInput',
|
||||
component: GeoTokenInput,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
a11y: {
|
||||
config: {
|
||||
rules: [{ id: 'color-contrast', enabled: false }],
|
||||
},
|
||||
},
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Routing rule field for the xray rule editor: a comma separated list of domains/CIDRs and `geosite:` / `geoip:` tokens, with a database button in the addon that opens the geo category browser. Typed tokens are validated against the databases on disk after a short pause, and anything the running core would not resolve is called out under the field. The stories answer `/panel/api/xray/geodata/*` from an in-memory fixture, so validation and the browser both work without a panel backend.',
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withGeodata],
|
||||
args: { kind: 'domain' },
|
||||
argTypes: {
|
||||
value: { description: 'Comma separated rule string held by the parent form.' },
|
||||
onChange: { description: 'Called with the full rule string on every edit and on Apply from the browser.' },
|
||||
onBlur: { description: 'Forwarded to the input; used by React Hook Form to mark the field touched.' },
|
||||
kind: {
|
||||
description: 'Which database the tokens are validated against: `domain` for geosite, `ip` for geoip.',
|
||||
control: 'inline-radio',
|
||||
options: ['domain', 'ip'],
|
||||
},
|
||||
placeholder: { description: 'Placeholder shown while the field is empty.' },
|
||||
id: { description: 'Input id, linked to the label rendered by the surrounding form field.' },
|
||||
},
|
||||
render: (args) => <ControlledTokenInput {...args} />,
|
||||
} satisfies Meta<typeof GeoTokenInput>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Empty: Story = {
|
||||
args: { kind: 'domain', value: '', placeholder: 'geosite:google, example.com' },
|
||||
};
|
||||
|
||||
export const DomainTokens: Story = {
|
||||
args: { kind: 'domain', value: 'geosite:google, google.com' },
|
||||
};
|
||||
|
||||
export const IpTokens: Story = {
|
||||
args: { kind: 'ip', value: 'geoip:private' },
|
||||
};
|
||||
|
||||
export const UnknownCategory: Story = {
|
||||
args: { kind: 'domain', value: 'geosite:blabla, geosite:google' },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByText(/geosite:blabla/, undefined, { timeout: 3000 })).toBeVisible();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Ref } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Tooltip, Typography } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { DatabaseOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useValidateGeoTokens, type GeoTokenKind } from '@/api/queries/useGeodata';
|
||||
import { parseTokens } from '@/lib/xray/geoTokens';
|
||||
import type { GeodataTokenIssue, GeoKind } from '@/generated/types';
|
||||
|
||||
import GeoBrowserModal from './GeoBrowserModal';
|
||||
|
||||
const VALIDATION_DELAY = 600;
|
||||
|
||||
// Each reason needs its own wording: a missing database is fixed under Geodata,
|
||||
// a missing category by picking another one, and a bad token by editing it.
|
||||
const REASON_KEYS: Record<string, string> = {
|
||||
fileMissing: 'pages.xray.geoBrowser.missingDatabase',
|
||||
categoryMissing: 'pages.xray.geoBrowser.unknownCategories',
|
||||
attributeMissing: 'pages.xray.geoBrowser.unknownAttribute',
|
||||
syntax: 'pages.xray.geoBrowser.invalidToken',
|
||||
wrongKind: 'pages.xray.geoBrowser.wrongKind',
|
||||
};
|
||||
|
||||
export interface GeoTokenInputProps {
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
kind: GeoTokenKind;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
ref?: Ref<InputRef>;
|
||||
}
|
||||
|
||||
export default function GeoTokenInput({ value = '', onChange, onBlur, kind, placeholder, id, ref }: GeoTokenInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [browsing, setBrowsing] = useState(false);
|
||||
const [issues, setIssues] = useState<GeodataTokenIssue[]>([]);
|
||||
const [checkFailed, setCheckFailed] = useState(false);
|
||||
const validate = useValidateGeoTokens();
|
||||
const { mutateAsync } = validate;
|
||||
|
||||
useEffect(() => {
|
||||
const tokens = parseTokens(value);
|
||||
if (tokens.length === 0) {
|
||||
setIssues([]);
|
||||
setCheckFailed(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(() => {
|
||||
mutateAsync({ tokens, kind })
|
||||
.then((found) => {
|
||||
if (cancelled) return;
|
||||
setIssues(found);
|
||||
setCheckFailed(false);
|
||||
})
|
||||
// A rejected check says nothing about the tokens, so the warnings are
|
||||
// dropped but replaced by a notice — silence here reads as "all valid".
|
||||
.catch(() => {
|
||||
if (cancelled) return;
|
||||
setIssues([]);
|
||||
setCheckFailed(true);
|
||||
});
|
||||
}, VALIDATION_DELAY);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, kind, mutateAsync]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
ref={ref}
|
||||
id={id}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onBlur={onBlur}
|
||||
addonAfter={
|
||||
<Tooltip title={t('pages.xray.geoBrowser.openTooltip')}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DatabaseOutlined />}
|
||||
aria-label={t('pages.xray.geoBrowser.openTooltip')}
|
||||
onClick={() => setBrowsing(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
{groupByReason(issues).map(([reason, tokens]) => (
|
||||
<Typography.Text key={reason} type="warning" className="geo-unknown-hint">
|
||||
{t(REASON_KEYS[reason] ?? REASON_KEYS.categoryMissing, { tokens: tokens.join(', ') })}
|
||||
</Typography.Text>
|
||||
))}
|
||||
{checkFailed && (
|
||||
<Typography.Text type="secondary" className="geo-unknown-hint">
|
||||
{t('pages.xray.geoBrowser.checkFailed')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<GeoBrowserModal
|
||||
open={browsing}
|
||||
kind={(kind === 'ip' ? 'ip' : 'site') as GeoKind}
|
||||
value={value}
|
||||
onApply={(next) => {
|
||||
onChange?.(next);
|
||||
setBrowsing(false);
|
||||
}}
|
||||
onClose={() => setBrowsing(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function groupByReason(issues: GeodataTokenIssue[]): Array<[string, string[]]> {
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const issue of issues) {
|
||||
const tokens = grouped.get(issue.reason) ?? [];
|
||||
tokens.push(issue.token);
|
||||
grouped.set(issue.reason, tokens);
|
||||
}
|
||||
return [...grouped];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as GeoBrowserModal } from './GeoBrowserModal';
|
||||
export type { GeoBrowserModalProps } from './GeoBrowserModal';
|
||||
export { default as GeoTokenInput } from './GeoTokenInput';
|
||||
export type { GeoTokenInputProps } from './GeoTokenInput';
|
||||
@@ -321,6 +321,54 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"masterId": 0,
|
||||
"path": ""
|
||||
},
|
||||
"GeoCategory": {
|
||||
"attributes": [
|
||||
"ads",
|
||||
"cn"
|
||||
],
|
||||
"code": "google",
|
||||
"entries": 1284
|
||||
},
|
||||
"GeoCategoryPage": {
|
||||
"items": [
|
||||
{
|
||||
"attributes": [
|
||||
"ads",
|
||||
"cn"
|
||||
],
|
||||
"code": "google",
|
||||
"entries": 1284
|
||||
}
|
||||
],
|
||||
"total": 1043
|
||||
},
|
||||
"GeoEntry": {
|
||||
"kind": "domain",
|
||||
"value": "google.com"
|
||||
},
|
||||
"GeoEntryPage": {
|
||||
"items": [
|
||||
{
|
||||
"kind": "domain",
|
||||
"value": "google.com"
|
||||
}
|
||||
],
|
||||
"total": 1284
|
||||
},
|
||||
"GeoFile": {
|
||||
"categories": 1043,
|
||||
"error": "",
|
||||
"kind": "site",
|
||||
"modifiedAt": 1769558400000,
|
||||
"name": "geosite.dat",
|
||||
"size": 1467392
|
||||
},
|
||||
"GeodataTokenIssue": {
|
||||
"code": "blabla",
|
||||
"file": "geosite.dat",
|
||||
"reason": "categoryMissing",
|
||||
"token": "geosite:blabla"
|
||||
},
|
||||
"HistoryOfSeeders": {
|
||||
"id": 0,
|
||||
"seederName": ""
|
||||
|
||||
@@ -1382,6 +1382,157 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoCategory": {
|
||||
"description": "GeoCategory is one code inside a database, such as geosite's \"google\".",
|
||||
"properties": {
|
||||
"attributes": {
|
||||
"example": [
|
||||
"ads",
|
||||
"cn"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"code": {
|
||||
"example": "google",
|
||||
"type": "string"
|
||||
},
|
||||
"entries": {
|
||||
"example": 1284,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"attributes",
|
||||
"code",
|
||||
"entries"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoCategoryPage": {
|
||||
"description": "GeoCategoryPage is one page of categories plus the unpaged total.",
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GeoCategory"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"total": {
|
||||
"example": 1043,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items",
|
||||
"total"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoEntry": {
|
||||
"description": "GeoEntry is a single rule inside a category: a domain rule for geosite\ndatabases, a CIDR for geoip ones.",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"example": "domain",
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"example": "google.com",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"value"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoEntryPage": {
|
||||
"description": "GeoEntryPage is one page of category entries plus the unpaged total.",
|
||||
"properties": {
|
||||
"items": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GeoEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"total": {
|
||||
"example": 1284,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"items",
|
||||
"total"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeoFile": {
|
||||
"description": "GeoFile describes one .dat database found in the asset directory.",
|
||||
"properties": {
|
||||
"categories": {
|
||||
"example": 1043,
|
||||
"type": "integer"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"example": "site",
|
||||
"type": "string"
|
||||
},
|
||||
"modifiedAt": {
|
||||
"example": 1769558400000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"name": {
|
||||
"example": "geosite.dat",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"example": 1467392,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"categories",
|
||||
"kind",
|
||||
"modifiedAt",
|
||||
"name",
|
||||
"size"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"GeodataTokenIssue": {
|
||||
"description": "GeodataTokenIssue reports a routing token the running core would reject,\nor would silently match nothing against.",
|
||||
"properties": {
|
||||
"code": {
|
||||
"example": "blabla",
|
||||
"type": "string"
|
||||
},
|
||||
"file": {
|
||||
"example": "geosite.dat",
|
||||
"type": "string"
|
||||
},
|
||||
"reason": {
|
||||
"example": "categoryMissing",
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"example": "geosite:blabla",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"reason",
|
||||
"token"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoryOfSeeders": {
|
||||
"description": "HistoryOfSeeders tracks which database seeders have been executed to prevent re-running.",
|
||||
"properties": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||
export type GeoKind = string;
|
||||
export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
@@ -336,6 +337,43 @@ export interface FallbackParentInfo {
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export interface GeoCategory {
|
||||
attributes: string[];
|
||||
code: string;
|
||||
entries: number;
|
||||
}
|
||||
|
||||
export interface GeoCategoryPage {
|
||||
items: GeoCategory[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GeoEntry {
|
||||
kind: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface GeoEntryPage {
|
||||
items: GeoEntry[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface GeoFile {
|
||||
categories: number;
|
||||
error?: string;
|
||||
kind: GeoKind;
|
||||
modifiedAt: number;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface GeodataTokenIssue {
|
||||
code?: string;
|
||||
file?: string;
|
||||
reason: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface HistoryOfSeeders {
|
||||
id: number;
|
||||
seederName: string;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||
import { z } from 'zod';
|
||||
export const GeoKindSchema = z.string();
|
||||
export type GeoKind = z.infer<typeof GeoKindSchema>;
|
||||
|
||||
export const OnlineAPISupportSchema = z.number().int();
|
||||
export type OnlineAPISupport = z.infer<typeof OnlineAPISupportSchema>;
|
||||
|
||||
@@ -360,6 +363,49 @@ export const FallbackParentInfoSchema = z.object({
|
||||
});
|
||||
export type FallbackParentInfo = z.infer<typeof FallbackParentInfoSchema>;
|
||||
|
||||
export const GeoCategorySchema = z.object({
|
||||
attributes: z.array(z.string()),
|
||||
code: z.string(),
|
||||
entries: z.number().int(),
|
||||
});
|
||||
export type GeoCategory = z.infer<typeof GeoCategorySchema>;
|
||||
|
||||
export const GeoCategoryPageSchema = z.object({
|
||||
items: z.array(z.lazy(() => GeoCategorySchema)),
|
||||
total: z.number().int(),
|
||||
});
|
||||
export type GeoCategoryPage = z.infer<typeof GeoCategoryPageSchema>;
|
||||
|
||||
export const GeoEntrySchema = z.object({
|
||||
kind: z.string(),
|
||||
value: z.string(),
|
||||
});
|
||||
export type GeoEntry = z.infer<typeof GeoEntrySchema>;
|
||||
|
||||
export const GeoEntryPageSchema = z.object({
|
||||
items: z.array(z.lazy(() => GeoEntrySchema)),
|
||||
total: z.number().int(),
|
||||
});
|
||||
export type GeoEntryPage = z.infer<typeof GeoEntryPageSchema>;
|
||||
|
||||
export const GeoFileSchema = z.object({
|
||||
categories: z.number().int(),
|
||||
error: z.string().optional(),
|
||||
kind: z.lazy(() => GeoKindSchema),
|
||||
modifiedAt: z.number().int(),
|
||||
name: z.string(),
|
||||
size: z.number().int(),
|
||||
});
|
||||
export type GeoFile = z.infer<typeof GeoFileSchema>;
|
||||
|
||||
export const GeodataTokenIssueSchema = z.object({
|
||||
code: z.string().optional(),
|
||||
file: z.string().optional(),
|
||||
reason: z.string(),
|
||||
token: z.string(),
|
||||
});
|
||||
export type GeodataTokenIssue = z.infer<typeof GeodataTokenIssueSchema>;
|
||||
|
||||
export const HistoryOfSeedersSchema = z.object({
|
||||
id: z.number().int(),
|
||||
seederName: z.string(),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { GeoKind } from '@/generated/types';
|
||||
|
||||
const DEFAULT_SITE_FILE = 'geosite.dat';
|
||||
const DEFAULT_IP_FILE = 'geoip.dat';
|
||||
|
||||
const LONG_FORMS: Array<[RegExp, string]> = [
|
||||
[/^ext(?:-domain|-site)?:geosite\.dat:/, 'geosite:'],
|
||||
[/^ext(?:-ip)?:geoip\.dat:/, 'geoip:'],
|
||||
];
|
||||
|
||||
export function parseTokens(value: string): string[] {
|
||||
return value
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token !== '');
|
||||
}
|
||||
|
||||
export function formatTokens(tokens: string[]): string {
|
||||
return tokens.join(', ');
|
||||
}
|
||||
|
||||
export function tokenFor(file: string, code: string, kind: GeoKind): string {
|
||||
if (kind === 'ip' && file === DEFAULT_IP_FILE) return `geoip:${code}`;
|
||||
if (kind === 'site' && file === DEFAULT_SITE_FILE) return `geosite:${code}`;
|
||||
return `ext:${file}:${code}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Xray treats category codes case-insensitively and accepts both the
|
||||
* `geosite:cn` shorthand and its `ext:geosite.dat:cn` long form, so tokens are
|
||||
* compared through this normal form. Only comparison uses it — whatever the
|
||||
* user typed is what stays in the rule.
|
||||
*/
|
||||
export function canonicalToken(token: string): string {
|
||||
const lowered = token.trim().toLowerCase();
|
||||
for (const [pattern, shorthand] of LONG_FORMS) {
|
||||
if (pattern.test(lowered)) return lowered.replace(pattern, shorthand);
|
||||
}
|
||||
return lowered;
|
||||
}
|
||||
|
||||
export function selectionFromValue(value: string, known: ReadonlySet<string>): string[] {
|
||||
const canonicalKnown = new Set([...known].map(canonicalToken));
|
||||
const selection: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const token of parseTokens(value)) {
|
||||
const canonical = canonicalToken(token);
|
||||
if (!canonicalKnown.has(canonical) || seen.has(canonical)) continue;
|
||||
seen.add(canonical);
|
||||
selection.push(token);
|
||||
}
|
||||
return selection;
|
||||
}
|
||||
|
||||
export function mergeSelection(value: string, selected: string[], known: ReadonlySet<string>): string {
|
||||
const canonicalKnown = new Set([...known].map(canonicalToken));
|
||||
const kept = new Set(
|
||||
selected.map((token) => canonicalToken(token)).filter((token) => token !== ''),
|
||||
);
|
||||
const merged: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const append = (token: string) => {
|
||||
const canonical = canonicalToken(token);
|
||||
if (canonical === '' || seen.has(canonical)) return;
|
||||
seen.add(canonical);
|
||||
merged.push(token);
|
||||
};
|
||||
for (const token of parseTokens(value)) {
|
||||
const canonical = canonicalToken(token);
|
||||
if (canonicalKnown.has(canonical) && !kept.has(canonical)) continue;
|
||||
append(token);
|
||||
}
|
||||
for (const token of selected) {
|
||||
append(token.trim());
|
||||
}
|
||||
return formatTokens(merged);
|
||||
}
|
||||
@@ -1420,6 +1420,44 @@ export const sections: readonly Section[] = [
|
||||
],
|
||||
body: 'domain=example.com&port=443&network=tcp',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/xray/geodata/files',
|
||||
summary: 'List the geo databases (.dat files) in the Xray asset folder, with the layout detected from their contents, size, modification time and category count. A database that fails to parse is still listed, with the reason in "error".',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/xray/geodata/categories',
|
||||
summary: 'One page of a database\'s categories, each with its entry count and the attributes its domains carry (e.g. "ads", "cn").',
|
||||
params: [
|
||||
{ name: 'file', in: 'query', type: 'string', desc: 'Database file name inside the asset folder, e.g. geosite.dat (required).' },
|
||||
{ name: 'q', in: 'query', type: 'string', optional: true, desc: 'Case-insensitive substring filter on the category code.' },
|
||||
{ name: 'offset', in: 'query', type: 'integer', optional: true, desc: 'Rows to skip. Defaults to 0.' },
|
||||
{ name: 'limit', in: 'query', type: 'integer', optional: true, desc: 'Rows to return, capped at 500. Omit it to return every category — the index is small and the panel filters it client-side.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/xray/geodata/entries',
|
||||
summary: 'One page of the rules inside a category — domain rules typed as domain/full/keyword/regexp for geosite databases, CIDRs for geoip ones.',
|
||||
params: [
|
||||
{ name: 'file', in: 'query', type: 'string', desc: 'Database file name inside the asset folder (required).' },
|
||||
{ name: 'code', in: 'query', type: 'string', desc: 'Category code, case-insensitive, e.g. google (required).' },
|
||||
{ name: 'q', in: 'query', type: 'string', optional: true, desc: 'Case-insensitive substring filter on the rule value.' },
|
||||
{ name: 'offset', in: 'query', type: 'integer', optional: true, desc: 'Rows to skip. Defaults to 0.' },
|
||||
{ name: 'limit', in: 'query', type: 'integer', optional: true, desc: 'Rows to return, capped at 500. Defaults to the cap.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/xray/geodata/validate',
|
||||
summary: 'Check routing tokens against the databases on disk and return only the ones that do not resolve. Plain domains and CIDRs are ignored. Each issue carries a reason: syntax, fileMissing or categoryMissing.',
|
||||
params: [
|
||||
{ name: 'tokens', in: 'body (form)', type: 'string', desc: 'Comma-separated routing tokens, e.g. "geosite:google,geosite:blabla". Max 500 per request.' },
|
||||
{ name: 'kind', in: 'body (form)', type: 'string', desc: '"ip" to parse the tokens as IP rules (geoip:, ext-ip:, leading !). Anything else parses them as domain rules (geosite:, ext-site:).' },
|
||||
],
|
||||
body: 'kind=domain&tokens=geosite:google,geosite:blabla',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/panel/api/xray/outbound-subs',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button, Form, Input, Modal, Select, Space, Switch, Tooltip } from 'antd
|
||||
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import { FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
import { InputAddon } from '@/components/ui';
|
||||
import { GeoTokenInput } from '@/components/geodata';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
||||
import { RuleFormSchema, type RuleFormValues } from '@/schemas/xray';
|
||||
@@ -173,7 +174,7 @@ export default function RuleFormModal({
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
<GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
@@ -253,7 +254,7 @@ export default function RuleFormModal({
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
<GeoTokenInput kind="ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
@@ -264,7 +265,7 @@ export default function RuleFormModal({
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Input placeholder="google.com, geosite:cn" />
|
||||
<GeoTokenInput kind="domain" placeholder="google.com, geosite:cn" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import GeoBrowserModal from '@/components/geodata/GeoBrowserModal';
|
||||
import GeoTokenInput from '@/components/geodata/GeoTokenInput';
|
||||
import { makeTestQueryClient } from '@/test/test-utils';
|
||||
import { HttpUtil, Msg } from '@/utils';
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const FILES = [{ name: 'geosite.dat', kind: 'site', size: 1024, modifiedAt: 1785428467270, categories: 3 }];
|
||||
|
||||
const IP_FILE = { name: 'geoip.dat', kind: 'ip', size: 2048, modifiedAt: 1785428467270, categories: 1 };
|
||||
|
||||
const IP_CATEGORIES = { total: 1, items: [{ code: 'private', entries: 1, attributes: [] }] };
|
||||
|
||||
const CATEGORIES = {
|
||||
total: 3,
|
||||
items: [
|
||||
{ code: 'cn', entries: 2, attributes: [] },
|
||||
{ code: 'google', entries: 2, attributes: ['ads'] },
|
||||
{ code: 'telegram', entries: 1, attributes: [] },
|
||||
],
|
||||
};
|
||||
|
||||
function mockGeodata(files: unknown[] = FILES) {
|
||||
const get = vi.spyOn(HttpUtil, 'get').mockImplementation(async (url: string, params?: unknown) => {
|
||||
const requestedFile = (params as { file?: string } | undefined)?.file;
|
||||
if (url.includes('/geodata/files')) return new Msg(true, '', files);
|
||||
if (url.includes('/geodata/categories')) {
|
||||
return new Msg(true, '', requestedFile === 'geoip.dat' ? IP_CATEGORIES : CATEGORIES);
|
||||
}
|
||||
if (url.includes('/geodata/entries')) return new Msg(true, '', { total: 0, items: [] });
|
||||
return new Msg(true, '', null);
|
||||
});
|
||||
vi.spyOn(HttpUtil, 'post').mockImplementation(async () => new Msg(true, '', []));
|
||||
return get;
|
||||
}
|
||||
|
||||
type GetSpy = ReturnType<typeof mockGeodata>;
|
||||
|
||||
function entryFilters(get: GetSpy): string[] {
|
||||
return get.mock.calls
|
||||
.filter(([url]) => String(url).includes('/geodata/entries'))
|
||||
.map(([, params]) => (params as { q?: string } | undefined)?.q ?? '');
|
||||
}
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <QueryClientProvider client={makeTestQueryClient()}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
async function checkboxFor(code: string) {
|
||||
const cell = await screen.findByText(code);
|
||||
const row = cell.closest('.ant-table-row');
|
||||
if (!row) throw new Error(`row for ${code} not found`);
|
||||
return within(row as HTMLElement).getByRole('checkbox') as HTMLInputElement;
|
||||
}
|
||||
|
||||
describe('GeoBrowserModal selection', () => {
|
||||
it('seeds the selection from the field every time it opens', async () => {
|
||||
mockGeodata();
|
||||
const view = render(
|
||||
<GeoBrowserModal open kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
|
||||
view.rerender(
|
||||
<GeoBrowserModal open={false} kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
|
||||
);
|
||||
view.rerender(
|
||||
<GeoBrowserModal open kind="site" value="geosite:google" onApply={vi.fn()} onClose={vi.fn()} />,
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
expect((await checkboxFor('cn')).checked).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps selections that the search box has filtered out of view', async () => {
|
||||
mockGeodata();
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={onApply} onClose={vi.fn()} />, { wrapper });
|
||||
|
||||
await user.click(await checkboxFor('google'));
|
||||
await user.type(screen.getByPlaceholderText(/search category|поиск категории/i), 'cn');
|
||||
await waitFor(() => expect(screen.queryByText('google')).toBeNull());
|
||||
await user.click(await checkboxFor('cn'));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledTimes(1);
|
||||
const applied = String(onApply.mock.calls[0][0]);
|
||||
expect(applied.split(',').map((token) => token.trim()).sort()).toEqual(['geosite:cn', 'geosite:google']);
|
||||
});
|
||||
|
||||
it('drops a category from the field when its checkbox is cleared', async () => {
|
||||
mockGeodata();
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
render(
|
||||
<GeoBrowserModal open kind="site" value="google.com, geosite:google, geosite:blabla" onApply={onApply} onClose={vi.fn()} />,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
await user.click(await checkboxFor('google'));
|
||||
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith('google.com, geosite:blabla');
|
||||
});
|
||||
|
||||
it('offers only databases matching the field kind', async () => {
|
||||
mockGeodata([...FILES, IP_FILE]);
|
||||
render(<GeoBrowserModal open kind="ip" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
|
||||
|
||||
await screen.findByText('private');
|
||||
expect(screen.getByTitle('geoip.dat')).toBeTruthy();
|
||||
expect(screen.queryByText('google')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not seed one database from another database categories', async () => {
|
||||
mockGeodata([...FILES, IP_FILE]);
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<GeoBrowserModal open kind="site" value="geosite:cn" onApply={vi.fn()} onClose={vi.fn()} />,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
|
||||
await user.click(await checkboxFor('google'));
|
||||
await waitFor(async () => expect((await checkboxFor('google')).checked).toBe(true));
|
||||
expect(screen.queryByText('private')).toBeNull();
|
||||
});
|
||||
|
||||
it('waits for the entry filter to settle instead of querying every keystroke', async () => {
|
||||
const get = mockGeodata();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
|
||||
|
||||
await user.click(await screen.findByText('cn'));
|
||||
await waitFor(() => expect(entryFilters(get)).toEqual(['']));
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
|
||||
expect(entryFilters(get)).toEqual(['']);
|
||||
|
||||
await waitFor(() => expect(entryFilters(get)).toEqual(['', 'abcd']), { timeout: 3000 });
|
||||
});
|
||||
|
||||
it('drops the pending filter when another category is opened', async () => {
|
||||
const get = mockGeodata();
|
||||
const user = userEvent.setup({ delay: null });
|
||||
render(<GeoBrowserModal open kind="site" value="" onApply={vi.fn()} onClose={vi.fn()} />, { wrapper });
|
||||
|
||||
await user.click(await screen.findByText('cn'));
|
||||
await user.type(screen.getByPlaceholderText('Filter inside category'), 'abcd');
|
||||
await user.click(screen.getByText('telegram'));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 800));
|
||||
expect(entryFilters(get)).toEqual(['', '']);
|
||||
});
|
||||
|
||||
it('ticks and unticks a category written in its long ext form', async () => {
|
||||
mockGeodata();
|
||||
const user = userEvent.setup();
|
||||
const onApply = vi.fn();
|
||||
render(
|
||||
<GeoBrowserModal
|
||||
open
|
||||
kind="site"
|
||||
value="ext:geosite.dat:cn, google.com"
|
||||
onApply={onApply}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await waitFor(async () => expect((await checkboxFor('cn')).checked).toBe(true));
|
||||
await user.click(await checkboxFor('cn'));
|
||||
await user.click(screen.getByRole('button', { name: /apply|применить/i }));
|
||||
|
||||
expect(onApply).toHaveBeenCalledWith('google.com');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GeoTokenInput validation feedback', () => {
|
||||
it('says the check failed instead of dropping the warnings silently', async () => {
|
||||
vi.spyOn(HttpUtil, 'get').mockResolvedValue(new Msg(true, '', []));
|
||||
vi.spyOn(HttpUtil, 'post')
|
||||
.mockResolvedValueOnce(new Msg(true, '', [{ token: 'geosite:nope', reason: 'categoryMissing' }]))
|
||||
.mockResolvedValue(new Msg(false, 'too many tokens'));
|
||||
|
||||
const view = render(<GeoTokenInput kind="domain" value="geosite:nope" />, { wrapper });
|
||||
await screen.findByText(/Not in the database/, {}, { timeout: 3000 });
|
||||
|
||||
view.rerender(<GeoTokenInput kind="domain" value="geosite:nope, geosite:other" />);
|
||||
|
||||
await screen.findByText('Could not check these values against the geo databases', {}, { timeout: 3000 });
|
||||
expect(screen.queryByText(/Not in the database/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
formatTokens,
|
||||
mergeSelection,
|
||||
parseTokens,
|
||||
selectionFromValue,
|
||||
tokenFor,
|
||||
} from '@/lib/xray/geoTokens';
|
||||
|
||||
const siteKnown = new Set(['geosite:google', 'geosite:google@ads', 'geosite:cn', 'ext:my_rules.dat:corp']);
|
||||
const ipKnown = new Set(['geoip:cn', 'geoip:private', 'ext:my_ips.dat:office']);
|
||||
|
||||
describe('parseTokens / formatTokens', () => {
|
||||
const cases: Array<[string, string, string[]]> = [
|
||||
['empty value', '', []],
|
||||
['single token', 'geosite:google', ['geosite:google']],
|
||||
['trims and drops blanks', ' geosite:google , , google.com ,', ['geosite:google', 'google.com']],
|
||||
['keeps negation', '!geoip:cn, 10.0.0.0/8', ['!geoip:cn', '10.0.0.0/8']],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, value, expected) => {
|
||||
expect(parseTokens(value)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('joins with a comma and a space', () => {
|
||||
expect(formatTokens(['geosite:google', 'google.com'])).toBe('geosite:google, google.com');
|
||||
expect(formatTokens([])).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tokenFor', () => {
|
||||
const cases: Array<[string, string, string, 'site' | 'ip', string]> = [
|
||||
['default site database uses the geosite shorthand', 'geosite.dat', 'google', 'site', 'geosite:google'],
|
||||
['default ip database uses the geoip shorthand', 'geoip.dat', 'cn', 'ip', 'geoip:cn'],
|
||||
['custom site database falls back to ext', 'my_rules.dat', 'corp', 'site', 'ext:my_rules.dat:corp'],
|
||||
['custom ip database falls back to ext', 'my_ips.dat', 'office', 'ip', 'ext:my_ips.dat:office'],
|
||||
['ip kind on the site database is not shorthand', 'geosite.dat', 'cn', 'ip', 'ext:geosite.dat:cn'],
|
||||
['site kind on the ip database is not shorthand', 'geoip.dat', 'cn', 'site', 'ext:geoip.dat:cn'],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, file, code, kind, expected) => {
|
||||
expect(tokenFor(file, code, kind)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectionFromValue', () => {
|
||||
const cases: Array<[string, string, ReadonlySet<string>, string[]]> = [
|
||||
['empty value selects nothing', '', siteKnown, []],
|
||||
['plain values are not selectable', 'google.com, keyword:ads', siteKnown, []],
|
||||
['picks known tokens only', 'google.com, geosite:google, geosite:blabla', siteKnown, ['geosite:google']],
|
||||
[
|
||||
'keeps the value order',
|
||||
'geosite:cn, google.com, geosite:google',
|
||||
siteKnown,
|
||||
['geosite:cn', 'geosite:google'],
|
||||
],
|
||||
['drops duplicates', 'geosite:google, geosite:google', siteKnown, ['geosite:google']],
|
||||
['attributes are distinct tokens', 'geosite:google@ads', siteKnown, ['geosite:google@ads']],
|
||||
['ext tokens are selectable', 'ext:my_rules.dat:corp, ext:other.dat:x', siteKnown, ['ext:my_rules.dat:corp']],
|
||||
['negated ip tokens stay unselected', '!geoip:cn, geoip:private', ipKnown, ['geoip:private']],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, value, known, expected) => {
|
||||
expect(selectionFromValue(value, known)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeSelection', () => {
|
||||
const cases: Array<[string, string, string[], ReadonlySet<string>, string]> = [
|
||||
['adds to an empty field', '', ['geosite:google'], siteKnown, 'geosite:google'],
|
||||
[
|
||||
'adds after a plain domain',
|
||||
'google.com',
|
||||
['geosite:cn'],
|
||||
siteKnown,
|
||||
'google.com, geosite:cn',
|
||||
],
|
||||
[
|
||||
'keeps plain and unknown tokens when a category is unchecked',
|
||||
'google.com, geosite:google, geosite:blabla',
|
||||
[],
|
||||
siteKnown,
|
||||
'google.com, geosite:blabla',
|
||||
],
|
||||
[
|
||||
'unchecking one known token leaves the other known token',
|
||||
'geosite:google, geosite:cn',
|
||||
['geosite:cn'],
|
||||
siteKnown,
|
||||
'geosite:cn',
|
||||
],
|
||||
[
|
||||
'preserves the original order of surviving tokens',
|
||||
'geosite:cn, google.com, geosite:google',
|
||||
['geosite:google', 'geosite:cn'],
|
||||
siteKnown,
|
||||
'geosite:cn, google.com, geosite:google',
|
||||
],
|
||||
[
|
||||
'appends new selections in selection order',
|
||||
'google.com',
|
||||
['geosite:cn', 'geosite:google'],
|
||||
siteKnown,
|
||||
'google.com, geosite:cn, geosite:google',
|
||||
],
|
||||
[
|
||||
'never duplicates an already present token',
|
||||
'geosite:google, google.com',
|
||||
['geosite:google'],
|
||||
siteKnown,
|
||||
'geosite:google, google.com',
|
||||
],
|
||||
[
|
||||
'collapses duplicates already in the field',
|
||||
'google.com, google.com, geosite:google',
|
||||
['geosite:google'],
|
||||
siteKnown,
|
||||
'google.com, geosite:google',
|
||||
],
|
||||
[
|
||||
'handles ext tokens like shorthand ones',
|
||||
'ext:my_rules.dat:corp, google.com',
|
||||
[],
|
||||
siteKnown,
|
||||
'google.com',
|
||||
],
|
||||
[
|
||||
'adds an ext token from a custom database',
|
||||
'10.0.0.0/8',
|
||||
['ext:my_ips.dat:office'],
|
||||
ipKnown,
|
||||
'10.0.0.0/8, ext:my_ips.dat:office',
|
||||
],
|
||||
[
|
||||
'leaves a negated ip token untouched while dropping a plain one',
|
||||
'!geoip:cn, geoip:private, 192.168.0.0/16',
|
||||
[],
|
||||
ipKnown,
|
||||
'!geoip:cn, 192.168.0.0/16',
|
||||
],
|
||||
[
|
||||
'adds a geoip token next to an existing negation',
|
||||
'!geoip:cn',
|
||||
['geoip:private'],
|
||||
ipKnown,
|
||||
'!geoip:cn, geoip:private',
|
||||
],
|
||||
[
|
||||
'ignores whitespace around field tokens',
|
||||
' google.com , geosite:google ',
|
||||
['geosite:google'],
|
||||
siteKnown,
|
||||
'google.com, geosite:google',
|
||||
],
|
||||
['clearing every known token can empty the field', 'geosite:google', [], siteKnown, ''],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, value, selected, known, expected) => {
|
||||
expect(mergeSelection(value, selected, known)).toBe(expected);
|
||||
});
|
||||
|
||||
it('round-trips with selectionFromValue', () => {
|
||||
const value = mergeSelection('google.com, geosite:blabla', ['geosite:google', 'geosite:cn'], siteKnown);
|
||||
expect(value).toBe('google.com, geosite:blabla, geosite:google, geosite:cn');
|
||||
expect(selectionFromValue(value, siteKnown)).toEqual(['geosite:google', 'geosite:cn']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token matching tolerates the spellings Xray accepts', () => {
|
||||
const cases: Array<[string, string, string[], string]> = [
|
||||
[
|
||||
'an uppercase token is recognised instead of duplicated',
|
||||
'GEOSITE:GOOGLE',
|
||||
['geosite:google'],
|
||||
'GEOSITE:GOOGLE',
|
||||
],
|
||||
[
|
||||
'the long ext form of a default database is the same token as its shorthand',
|
||||
'ext:geosite.dat:google',
|
||||
['geosite:google'],
|
||||
'ext:geosite.dat:google',
|
||||
],
|
||||
[
|
||||
'clearing a category written in its long form removes it',
|
||||
'google.com, ext:geosite.dat:google',
|
||||
[],
|
||||
'google.com',
|
||||
],
|
||||
[
|
||||
'clearing a category written in uppercase removes it',
|
||||
'GEOSITE:GOOGLE, google.com',
|
||||
[],
|
||||
'google.com',
|
||||
],
|
||||
[
|
||||
'a token from a database that was never opened survives untouched',
|
||||
'ext:other.dat:x, geosite:google',
|
||||
['geosite:cn'],
|
||||
'ext:other.dat:x, geosite:cn',
|
||||
],
|
||||
['a value of separators alone collapses to empty', ',,, ,', [], ''],
|
||||
];
|
||||
|
||||
it.each(cases)('%s', (_name, value, selected, expected) => {
|
||||
expect(mergeSelection(value, selected, siteKnown)).toBe(expected);
|
||||
});
|
||||
|
||||
it('seeds the selection from tokens written in another spelling', () => {
|
||||
expect(selectionFromValue('GEOSITE:GOOGLE, ext:geosite.dat:cn', siteKnown)).toEqual([
|
||||
'GEOSITE:GOOGLE',
|
||||
'ext:geosite.dat:cn',
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user