瀏覽代碼

refactor: replace custom geo manager with Xray-core native geodata auto-update

Remove the panel-side custom geo download feature (service, controller,
/panel/api/custom-geo/* endpoints, CustomGeoResource model, UI tab) in
favor of Xray-core's native geodata section
(https://xtls.github.io/config/geodata.html).

- pass the top-level "geodata" key through xray.Config so it survives
  the template round-trip into the generated config
- add a Geodata Auto-Update section to the Xray Updates modal that
  edits geodata (cron schedule, download outbound, asset list) in the
  config template and restarts Xray on save
- previously downloaded geo files in the bin folder keep working in
  ext: routing rules; the orphaned custom_geo_resources table is left
  in place so existing source URLs stay recoverable
MHSanaei 17 小時之前
父節點
當前提交
3092326d9e
共有 43 個文件被更改,包括 416 次插入2875 次删除
  1. 0 305
      frontend/public/openapi.json
  2. 0 11
      frontend/src/generated/examples.ts
  3. 0 43
      frontend/src/generated/schemas.ts
  4. 0 12
      frontend/src/generated/types.ts
  5. 0 13
      frontend/src/generated/zod.ts
  6. 0 55
      frontend/src/pages/api-docs/endpoints.ts
  7. 1 1
      frontend/src/pages/clients/FilterDrawer.tsx
  8. 1 1
      frontend/src/pages/groups/GroupAddClientsModal.tsx
  9. 1 1
      frontend/src/pages/groups/GroupRemoveClientsModal.tsx
  10. 1 1
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  11. 1 1
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  12. 1 1
      frontend/src/pages/inbounds/clients/DetachClientsModal.tsx
  13. 0 114
      frontend/src/pages/index/CustomGeoFormModal.tsx
  14. 0 65
      frontend/src/pages/index/CustomGeoSection.css
  15. 0 283
      frontend/src/pages/index/CustomGeoSection.tsx
  16. 219 0
      frontend/src/pages/index/GeodataSection.tsx
  17. 9 3
      frontend/src/pages/index/VersionModal.tsx
  18. 0 21
      frontend/src/schemas/xray.ts
  19. 0 1
      internal/database/db.go
  20. 0 1
      internal/database/migrate_data.go
  21. 0 12
      internal/database/model/model.go
  22. 3 6
      internal/web/controller/api.go
  23. 0 2
      internal/web/controller/api_docs_test.go
  24. 0 180
      internal/web/controller/custom_geo.go
  25. 0 784
      internal/web/service/integration/custom_geo.go
  26. 0 348
      internal/web/service/integration/custom_geo_test.go
  27. 2 54
      internal/web/service/integration/panel_proxy_test.go
  28. 13 42
      internal/web/translation/ar-EG.json
  29. 13 42
      internal/web/translation/en-US.json
  30. 13 42
      internal/web/translation/es-ES.json
  31. 13 42
      internal/web/translation/fa-IR.json
  32. 13 42
      internal/web/translation/id-ID.json
  33. 13 42
      internal/web/translation/ja-JP.json
  34. 13 42
      internal/web/translation/pt-BR.json
  35. 13 42
      internal/web/translation/ru-RU.json
  36. 13 42
      internal/web/translation/tr-TR.json
  37. 13 42
      internal/web/translation/uk-UA.json
  38. 13 42
      internal/web/translation/vi-VN.json
  39. 13 42
      internal/web/translation/zh-CN.json
  40. 13 42
      internal/web/translation/zh-TW.json
  41. 4 9
      internal/web/web.go
  42. 4 0
      internal/xray/config.go
  43. 0 1
      tools/openapigen/main.go

+ 0 - 305
frontend/public/openapi.json

@@ -1219,49 +1219,6 @@
         ],
         "type": "object"
       },
-      "CustomGeoResource": {
-        "properties": {
-          "alias": {
-            "type": "string"
-          },
-          "createdAt": {
-            "type": "integer"
-          },
-          "id": {
-            "type": "integer"
-          },
-          "lastModified": {
-            "type": "string"
-          },
-          "lastUpdatedAt": {
-            "type": "integer"
-          },
-          "localPath": {
-            "type": "string"
-          },
-          "type": {
-            "type": "string"
-          },
-          "updatedAt": {
-            "type": "integer"
-          },
-          "url": {
-            "type": "string"
-          }
-        },
-        "required": [
-          "alias",
-          "createdAt",
-          "id",
-          "lastModified",
-          "lastUpdatedAt",
-          "localPath",
-          "type",
-          "updatedAt",
-          "url"
-        ],
-        "type": "object"
-      },
       "FallbackParentInfo": {
         "description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.",
         "properties": {
@@ -1880,10 +1837,6 @@
       "name": "Nodes",
       "description": "Manage remote 3x-ui panels acting as nodes for a central panel. All endpoints under /panel/api/nodes."
     },
-    {
-      "name": "Custom Geo",
-      "description": "Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo."
-    },
     {
       "name": "Backup",
       "description": "Operations that interact with the configured Telegram bot."
@@ -6593,264 +6546,6 @@
         }
       }
     },
-    "/panel/api/custom-geo/list": {
-      "get": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.",
-        "operationId": "get_panel_api_custom_geo_list",
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/aliases": {
-      "get": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.",
-        "operationId": "get_panel_api_custom_geo_aliases",
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/add": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.",
-        "operationId": "post_panel_api_custom_geo_add",
-        "requestBody": {
-          "required": true,
-          "content": {
-            "application/json": {
-              "schema": {
-                "type": "object"
-              },
-              "example": {
-                "type": "geoip",
-                "alias": "myips",
-                "url": "https://example.com/geo/my.dat"
-              }
-            }
-          }
-        },
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/update/{id}": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Replace a custom geo source. Same body shape as /add.",
-        "operationId": "post_panel_api_custom_geo_update_id",
-        "parameters": [
-          {
-            "name": "id",
-            "in": "path",
-            "required": true,
-            "description": "Custom geo source ID.",
-            "schema": {
-              "type": "integer"
-            }
-          }
-        ],
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/delete/{id}": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Remove a custom geo source and its cached file.",
-        "operationId": "post_panel_api_custom_geo_delete_id",
-        "parameters": [
-          {
-            "name": "id",
-            "in": "path",
-            "required": true,
-            "description": "Custom geo source ID.",
-            "schema": {
-              "type": "integer"
-            }
-          }
-        ],
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/download/{id}": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Re-download one custom geo source on demand.",
-        "operationId": "post_panel_api_custom_geo_download_id",
-        "parameters": [
-          {
-            "name": "id",
-            "in": "path",
-            "required": true,
-            "description": "Custom geo source ID.",
-            "schema": {
-              "type": "integer"
-            }
-          }
-        ],
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
-    "/panel/api/custom-geo/update-all": {
-      "post": {
-        "tags": [
-          "Custom Geo"
-        ],
-        "summary": "Re-download every configured custom geo source. Errors are reported per-source in the response.",
-        "operationId": "post_panel_api_custom_geo_update_all",
-        "responses": {
-          "200": {
-            "description": "Successful response",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "object",
-                  "properties": {
-                    "success": {
-                      "type": "boolean"
-                    },
-                    "msg": {
-                      "type": "string"
-                    },
-                    "obj": {}
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    },
     "/panel/api/backuptotgbot": {
       "post": {
         "tags": [

+ 0 - 11
frontend/src/generated/examples.ts

@@ -250,17 +250,6 @@ export const EXAMPLES: Record<string, unknown> = {
     "up": 1048576,
     "uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
   },
-  "CustomGeoResource": {
-    "alias": "",
-    "createdAt": 0,
-    "id": 0,
-    "lastModified": "",
-    "lastUpdatedAt": 0,
-    "localPath": "",
-    "type": "",
-    "updatedAt": 0,
-    "url": ""
-  },
   "FallbackParentInfo": {
     "masterId": 0,
     "path": ""

+ 0 - 43
frontend/src/generated/schemas.ts

@@ -1193,49 +1193,6 @@ export const SCHEMAS: Record<string, unknown> = {
     ],
     "type": "object"
   },
-  "CustomGeoResource": {
-    "properties": {
-      "alias": {
-        "type": "string"
-      },
-      "createdAt": {
-        "type": "integer"
-      },
-      "id": {
-        "type": "integer"
-      },
-      "lastModified": {
-        "type": "string"
-      },
-      "lastUpdatedAt": {
-        "type": "integer"
-      },
-      "localPath": {
-        "type": "string"
-      },
-      "type": {
-        "type": "string"
-      },
-      "updatedAt": {
-        "type": "integer"
-      },
-      "url": {
-        "type": "string"
-      }
-    },
-    "required": [
-      "alias",
-      "createdAt",
-      "id",
-      "lastModified",
-      "lastUpdatedAt",
-      "localPath",
-      "type",
-      "updatedAt",
-      "url"
-    ],
-    "type": "object"
-  },
   "FallbackParentInfo": {
     "description": "FallbackParentInfo carries everything the frontend needs to rewrite a\nchild inbound's client link: where to connect (the master's address\nand port) and which path matched on the master's fallbacks array.\nThe frontend already has the master inbound in its dbInbounds list,\nso we only ship identifiers + the match path here.",
     "properties": {

+ 0 - 12
frontend/src/generated/types.ts

@@ -263,18 +263,6 @@ export interface ClientTraffic {
   uuid: string;
 }
 
-export interface CustomGeoResource {
-  alias: string;
-  createdAt: number;
-  id: number;
-  lastModified: string;
-  lastUpdatedAt: number;
-  localPath: string;
-  type: string;
-  updatedAt: number;
-  url: string;
-}
-
 export interface FallbackParentInfo {
   masterId: number;
   path?: string;

+ 0 - 13
frontend/src/generated/zod.ts

@@ -280,19 +280,6 @@ export const ClientTrafficSchema = z.object({
 });
 export type ClientTraffic = z.infer<typeof ClientTrafficSchema>;
 
-export const CustomGeoResourceSchema = z.object({
-  alias: z.string(),
-  createdAt: z.number().int(),
-  id: z.number().int(),
-  lastModified: z.string(),
-  lastUpdatedAt: z.number().int(),
-  localPath: z.string(),
-  type: z.string(),
-  updatedAt: z.number().int(),
-  url: z.string(),
-});
-export type CustomGeoResource = z.infer<typeof CustomGeoResourceSchema>;
-
 export const FallbackParentInfoSchema = z.object({
   masterId: z.number().int(),
   path: z.string().optional(),

+ 0 - 55
frontend/src/pages/api-docs/endpoints.ts

@@ -860,61 +860,6 @@ export const sections: readonly Section[] = [
     ],
   },
 
-  {
-    id: 'custom-geo',
-    title: 'Custom Geo',
-    description:
-      'Manage user-supplied GeoIP / GeoSite source files. All endpoints under /panel/api/custom-geo.',
-    endpoints: [
-      {
-        method: 'GET',
-        path: '/panel/api/custom-geo/list',
-        summary: 'List configured custom geo sources with their type, alias, URL, status, and last-download timestamp.',
-      },
-      {
-        method: 'GET',
-        path: '/panel/api/custom-geo/aliases',
-        summary: 'List geo aliases currently usable in routing rules — both built-in defaults and the user-configured ones.',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/add',
-        summary: 'Register a custom geo source. Alias is auto-normalised; URL must point to a .dat / .json blob.',
-        body:
-          '{\n  "type": "geoip",\n  "alias": "myips",\n  "url": "https://example.com/geo/my.dat"\n}',
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/update/:id',
-        summary: 'Replace a custom geo source. Same body shape as /add.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/delete/:id',
-        summary: 'Remove a custom geo source and its cached file.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/download/:id',
-        summary: 'Re-download one custom geo source on demand.',
-        params: [
-          { name: 'id', in: 'path', type: 'number', desc: 'Custom geo source ID.' },
-        ],
-      },
-      {
-        method: 'POST',
-        path: '/panel/api/custom-geo/update-all',
-        summary: 'Re-download every configured custom geo source. Errors are reported per-source in the response.',
-      },
-    ],
-  },
-
   {
     id: 'backup',
     title: 'Backup',

+ 1 - 1
frontend/src/pages/clients/FilterDrawer.tsx

@@ -94,7 +94,7 @@ export default function FilterDrawer({
             value={filters.buckets}
             onChange={(v) => patch('buckets', v as string[])}
           >
-            <Space direction="vertical">
+            <Space orientation="vertical">
               {BUCKET_KEYS.map((k) => (
                 <Checkbox key={k} value={k}>
                   {bucketLabel(k, t)}

+ 1 - 1
frontend/src/pages/groups/GroupAddClientsModal.tsx

@@ -122,7 +122,7 @@ export default function GroupAddClientsModal({
         {t('pages.groups.addToGroupDesc')}
       </Typography.Paragraph>
 
-      <Space direction="vertical" size="small" style={{ width: '100%' }}>
+      <Space orientation="vertical" size="small" style={{ width: '100%' }}>
         <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
           <Input.Search
             allowClear

+ 1 - 1
frontend/src/pages/groups/GroupRemoveClientsModal.tsx

@@ -110,7 +110,7 @@ export default function GroupRemoveClientsModal({
         {t('pages.groups.removeFromGroupDesc')}
       </Typography.Paragraph>
 
-      <Space direction="vertical" size="small" style={{ width: '100%' }}>
+      <Space orientation="vertical" size="small" style={{ width: '100%' }}>
         <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
           <Input.Search
             allowClear

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -158,7 +158,7 @@ export default function AttachClientsModal({
         {t('pages.inbounds.attachClientsDesc', { count: clientRows.length })}
       </Typography.Paragraph>
 
-      <Space direction="vertical" size="small" style={{ width: '100%', marginBottom: 12 }}>
+      <Space orientation="vertical" size="small" style={{ width: '100%', marginBottom: 12 }}>
         <Typography.Text strong>{t('pages.inbounds.attachClientsSelectLabel')}</Typography.Text>
         <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
           <Input.Search

+ 1 - 1
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -182,7 +182,7 @@ export default function AttachExistingClientsModal({
         <Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
       ) : (
         <Spin spinning={loading}>
-          <Space direction="vertical" size="small" style={{ width: '100%' }}>
+          <Space orientation="vertical" size="small" style={{ width: '100%' }}>
             <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
               <Space wrap>
                 <Input.Search

+ 1 - 1
frontend/src/pages/inbounds/clients/DetachClientsModal.tsx

@@ -147,7 +147,7 @@ export default function DetachClientsModal({
         {t('pages.inbounds.detachClientsDesc', { count: clientRows.length })}
       </Typography.Paragraph>
 
-      <Space direction="vertical" size="small" style={{ width: '100%' }}>
+      <Space orientation="vertical" size="small" style={{ width: '100%' }}>
         <Typography.Text strong>{t('pages.inbounds.detachClientsSelectLabel')}</Typography.Text>
         <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
           <Input.Search

+ 0 - 114
frontend/src/pages/index/CustomGeoFormModal.tsx

@@ -1,114 +0,0 @@
-import { useEffect, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Form, Input, message, Modal, Select } from 'antd';
-
-import { HttpUtil } from '@/utils';
-import { CustomGeoFormSchema } from '@/schemas/xray';
-
-export interface CustomGeoRecord {
-  id: number;
-  type: 'geosite' | 'geoip';
-  alias: string;
-  url: string;
-}
-
-interface CustomGeoFormModalProps {
-  open: boolean;
-  record: CustomGeoRecord | null;
-  onClose: () => void;
-  onSaved: () => void;
-}
-
-export default function CustomGeoFormModal({
-  open,
-  record,
-  onClose,
-  onSaved,
-}: CustomGeoFormModalProps) {
-  const { t } = useTranslation();
-  const [messageApi, messageContextHolder] = message.useMessage();
-  const [type, setType] = useState<'geosite' | 'geoip'>('geosite');
-  const [alias, setAlias] = useState('');
-  const [url, setUrl] = useState('');
-  const [saving, setSaving] = useState(false);
-
-  const editing = record != null;
-
-  useEffect(() => {
-    if (!open) return;
-    if (record) {
-      setType(record.type);
-      setAlias(record.alias);
-      setUrl(record.url);
-    } else {
-      setType('geosite');
-      setAlias('');
-      setUrl('');
-    }
-  }, [open, record]);
-
-  async function submit() {
-    const validated = CustomGeoFormSchema.safeParse({ type, alias, url });
-    if (!validated.success) {
-      messageApi.error(t(validated.error.issues[0]?.message ?? 'somethingWentWrong'));
-      return;
-    }
-    setSaving(true);
-    try {
-      const apiUrl = editing
-        ? `/panel/api/custom-geo/update/${record!.id}`
-        : '/panel/api/custom-geo/add';
-      const msg = await HttpUtil.post(apiUrl, validated.data);
-      if (msg?.success) {
-        onSaved();
-        onClose();
-      }
-    } finally {
-      setSaving(false);
-    }
-  }
-
-  return (
-    <>
-      {messageContextHolder}
-      <Modal
-        open={open}
-        title={editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')}
-      confirmLoading={saving}
-      okText={t('pages.index.customGeoModalSave')}
-      cancelText={t('close')}
-      onOk={submit}
-      onCancel={onClose}
-    >
-      <Form layout="vertical">
-        <Form.Item label={t('pages.index.customGeoType')}>
-          <Select
-            value={type}
-            disabled={editing}
-            onChange={(v) => setType(v)}
-            options={[
-              { value: 'geosite', label: 'geosite' },
-              { value: 'geoip', label: 'geoip' },
-            ]}
-          />
-        </Form.Item>
-        <Form.Item label={t('pages.index.customGeoAlias')}>
-          <Input
-            value={alias}
-            disabled={editing}
-            placeholder={t('pages.index.customGeoAliasPlaceholder')}
-            onChange={(e) => setAlias(e.target.value)}
-          />
-        </Form.Item>
-        <Form.Item label={t('pages.index.customGeoUrl')}>
-          <Input
-            value={url}
-            placeholder="https://"
-            onChange={(e) => setUrl(e.target.value)}
-          />
-        </Form.Item>
-      </Form>
-      </Modal>
-    </>
-  );
-}

+ 0 - 65
frontend/src/pages/index/CustomGeoSection.css

@@ -1,65 +0,0 @@
-.toolbar {
-  display: flex;
-  align-items: center;
-  flex-wrap: wrap;
-  gap: 8px;
-  margin-bottom: 10px;
-}
-
-.custom-geo-count {
-  margin-left: 4px;
-  padding: 2px 8px;
-  border-radius: 10px;
-  background: var(--ant-color-fill-tertiary);
-  font-size: 12px;
-  opacity: 0.75;
-}
-
-.custom-geo-alias-cell {
-  display: flex;
-  align-items: center;
-  gap: 6px;
-}
-
-.custom-geo-alias {
-  font-weight: 500;
-  word-break: break-all;
-}
-
-.custom-geo-type-tag {
-  margin: 0;
-}
-
-.custom-geo-url {
-  word-break: break-all;
-}
-
-.custom-geo-ext-code {
-  cursor: pointer;
-  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
-  font-size: 12px;
-  padding: 2px 6px;
-  border-radius: 4px;
-  background: var(--ant-color-fill-tertiary);
-  user-select: all;
-}
-
-.custom-geo-copyable:hover {
-  background: var(--ant-color-fill-secondary);
-}
-
-.custom-geo-muted {
-  opacity: 0.5;
-}
-
-.custom-geo-empty {
-  text-align: center;
-  padding: 18px 0;
-  opacity: 0.6;
-}
-
-.custom-geo-empty-icon {
-  font-size: 32px;
-  margin-bottom: 6px;
-  display: block;
-}

+ 0 - 283
frontend/src/pages/index/CustomGeoSection.tsx

@@ -1,283 +0,0 @@
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import { useTranslation } from 'react-i18next';
-import { Alert, Button, message, Modal, Space, Table, Tag, Tooltip } from 'antd';
-import type { ColumnsType } from 'antd/es/table';
-import {
-  PlusOutlined,
-  ReloadOutlined,
-  EditOutlined,
-  DeleteOutlined,
-  InboxOutlined,
-} from '@ant-design/icons';
-
-import { HttpUtil, ClipboardManager } from '@/utils';
-import CustomGeoFormModal from './CustomGeoFormModal';
-import type { CustomGeoRecord } from './CustomGeoFormModal';
-import './CustomGeoSection.css';
-
-interface CustomGeoSectionProps {
-  active: boolean;
-}
-
-interface CustomGeoListRecord extends CustomGeoRecord {
-  lastUpdatedAt?: number;
-}
-
-function formatTime(ts?: number): string {
-  if (!ts) return '';
-  const d = new Date(ts * 1000);
-  if (isNaN(d.getTime())) return String(ts);
-  const pad = (n: number) => String(n).padStart(2, '0');
-  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
-}
-
-function relativeTime(ts?: number): string {
-  if (!ts) return '';
-  const diff = Math.floor(Date.now() / 1000) - ts;
-  if (diff < 60) return 'just now';
-  if (diff < 3600) return `${Math.floor(diff / 60)} min ago`;
-  if (diff < 86400) return `${Math.floor(diff / 3600)} h ago`;
-  if (diff < 2592000) return `${Math.floor(diff / 86400)} d ago`;
-  return formatTime(ts);
-}
-
-function extDisplay(record: CustomGeoListRecord): string {
-  const fn = record.type === 'geoip'
-    ? `geoip_${record.alias}.dat`
-    : `geosite_${record.alias}.dat`;
-  return `ext:${fn}:tag`;
-}
-
-export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
-  const { t } = useTranslation();
-  const [modal, modalContextHolder] = Modal.useModal();
-  const [messageApi, messageContextHolder] = message.useMessage();
-  const [list, setList] = useState<CustomGeoListRecord[]>([]);
-  const [loading, setLoading] = useState(false);
-  const [updatingAll, setUpdatingAll] = useState(false);
-  const [actionId, setActionId] = useState<number | null>(null);
-  const [formOpen, setFormOpen] = useState(false);
-  const [editingRecord, setEditingRecord] = useState<CustomGeoListRecord | null>(null);
-
-  const loadList = useCallback(async () => {
-    setLoading(true);
-    try {
-      const msg = await HttpUtil.get('/panel/api/custom-geo/list');
-      if (msg?.success && Array.isArray(msg.obj)) setList(msg.obj);
-    } finally {
-      setLoading(false);
-    }
-  }, []);
-
-  useEffect(() => {
-    if (active) loadList();
-  }, [active, loadList]);
-
-  function openAdd() {
-    setEditingRecord(null);
-    setFormOpen(true);
-  }
-
-  function openEdit(record: CustomGeoListRecord) {
-    setEditingRecord(record);
-    setFormOpen(true);
-  }
-
-  async function copyExt(record: CustomGeoListRecord) {
-    const text = extDisplay(record);
-    const ok = await ClipboardManager.copyText(text);
-    if (ok) messageApi.success(`${t('copied')}: ${text}`);
-  }
-
-  function confirmDelete(record: CustomGeoListRecord) {
-    modal.confirm({
-      title: t('pages.index.customGeoDelete'),
-      content: t('pages.index.customGeoDeleteConfirm'),
-      okText: t('delete'),
-      okType: 'danger',
-      cancelText: t('cancel'),
-      onOk: async () => {
-        const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
-        if (msg?.success) await loadList();
-      },
-    });
-  }
-
-  async function downloadOne(id: number) {
-    setActionId(id);
-    try {
-      const msg = await HttpUtil.post(`/panel/api/custom-geo/download/${id}`);
-      if (msg?.success) await loadList();
-    } finally {
-      setActionId(null);
-    }
-  }
-
-  async function updateAll() {
-    setUpdatingAll(true);
-    try {
-      const msg = await HttpUtil.post<{ succeeded?: unknown[]; failed?: unknown[] }>('/panel/api/custom-geo/update-all');
-      const ok = msg?.obj?.succeeded?.length || 0;
-      const failed = msg?.obj?.failed?.length || 0;
-      if (msg?.success || ok > 0) {
-        await loadList();
-        if (failed > 0) messageApi.warning(`Updated ${ok}, failed ${failed}`);
-      }
-    } finally {
-      setUpdatingAll(false);
-    }
-  }
-
-  const columns = useMemo<ColumnsType<CustomGeoListRecord>>(
-    () => [
-      {
-        title: t('pages.index.customGeoAlias'),
-        key: 'alias',
-        width: 200,
-        render: (_v, record) => (
-          <div className="custom-geo-alias-cell">
-            <Tag color={record.type === 'geoip' ? 'cyan' : 'purple'} className="custom-geo-type-tag">
-              {record.type}
-            </Tag>
-            <span className="custom-geo-alias">{record.alias}</span>
-          </div>
-        ),
-      },
-      {
-        title: t('pages.index.customGeoUrl'),
-        key: 'url',
-        ellipsis: true,
-        render: (_v, record) => (
-          <Tooltip placement="topLeft" title={record.url}>
-            <a
-              href={record.url}
-              target="_blank"
-              rel="noopener noreferrer"
-              className="custom-geo-url"
-            >
-              {record.url}
-            </a>
-          </Tooltip>
-        ),
-      },
-      {
-        title: t('pages.index.customGeoExtColumn'),
-        key: 'extDat',
-        width: 220,
-        render: (_v, record) => (
-          <Tooltip title={t('copy')}>
-            <code
-              className="custom-geo-ext-code custom-geo-copyable"
-              onClick={() => copyExt(record)}
-            >
-              {extDisplay(record)}
-            </code>
-          </Tooltip>
-        ),
-      },
-      {
-        title: t('pages.index.customGeoLastUpdated'),
-        key: 'lastUpdatedAt',
-        width: 140,
-        render: (_v, record) =>
-          record.lastUpdatedAt ? (
-            <Tooltip title={formatTime(record.lastUpdatedAt)}>
-              <span>{relativeTime(record.lastUpdatedAt)}</span>
-            </Tooltip>
-          ) : (
-            <span className="custom-geo-muted">—</span>
-          ),
-      },
-      {
-        title: t('pages.index.customGeoActions'),
-        key: 'action',
-        width: 120,
-        render: (_v, record) => (
-          <Space size="small">
-            <Tooltip title={t('pages.index.customGeoEdit')}>
-              <Button
-                type="link"
-                size="small"
-                icon={<EditOutlined />}
-                onClick={() => openEdit(record)}
-              />
-            </Tooltip>
-            <Tooltip title={t('pages.index.customGeoDownload')}>
-              <Button
-                type="link"
-                size="small"
-                loading={actionId === record.id}
-                icon={<ReloadOutlined />}
-                onClick={() => downloadOne(record.id)}
-              />
-            </Tooltip>
-            <Tooltip title={t('pages.index.customGeoDelete')}>
-              <Button
-                type="link"
-                size="small"
-                danger
-                icon={<DeleteOutlined />}
-                onClick={() => confirmDelete(record)}
-              />
-            </Tooltip>
-          </Space>
-        ),
-      },
-    ],
-    // eslint-disable-next-line react-hooks/exhaustive-deps
-    [t, actionId],
-  );
-
-  return (
-    <div className="custom-geo-section">
-      {messageContextHolder}
-      {modalContextHolder}
-      <Alert
-        type="info"
-        showIcon
-        className="mb-10"
-        title={t('pages.index.customGeoRoutingHint')}
-      />
-
-      <div className="toolbar">
-        <Button type="primary" loading={loading} onClick={openAdd} icon={<PlusOutlined />}>
-          {t('pages.index.customGeoAdd')}
-        </Button>
-        <Button
-          loading={updatingAll}
-          disabled={list.length === 0}
-          onClick={updateAll}
-          icon={<ReloadOutlined />}
-        >
-          {t('pages.index.geofilesUpdateAll')}
-        </Button>
-        {list.length > 0 && <span className="custom-geo-count">{list.length}</span>}
-      </div>
-
-      <Table
-        columns={columns}
-        dataSource={list}
-        pagination={false}
-        rowKey={(r) => r.id}
-        loading={loading}
-        size="small"
-        scroll={{ x: 760 }}
-        locale={{
-          emptyText: (
-            <div className="custom-geo-empty">
-              <InboxOutlined className="custom-geo-empty-icon" />
-              <div>{t('pages.index.customGeoEmpty')}</div>
-            </div>
-          ),
-        }}
-      />
-
-      <CustomGeoFormModal
-        open={formOpen}
-        record={editingRecord}
-        onClose={() => setFormOpen(false)}
-        onSaved={loadList}
-      />
-    </div>
-  );
-}

+ 219 - 0
frontend/src/pages/index/GeodataSection.tsx

@@ -0,0 +1,219 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Button, Form, Input, Modal, Select, Space, Spin, Typography, message } from 'antd';
+import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
+
+import { HttpUtil } from '@/utils';
+
+interface GeodataAssetRow {
+  url: string;
+  file: string;
+}
+
+interface GeodataSectionProps {
+  active: boolean;
+  onBusy: (e: { busy: boolean; tip?: string }) => void;
+  onClose: () => void;
+}
+
+const DEFAULT_CRON = '0 4 * * *';
+// Xray resolves `file` inside its asset directory; plain file names only.
+const FILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
+
+function fileNameFromUrl(url: string): string {
+  try {
+    const seg = new URL(url).pathname.split('/').filter(Boolean).pop() || '';
+    return FILE_NAME_PATTERN.test(seg) ? seg : '';
+  } catch {
+    return '';
+  }
+}
+
+export default function GeodataSection({ active, onBusy, onClose }: GeodataSectionProps) {
+  const { t } = useTranslation();
+  const [modal, modalContextHolder] = Modal.useModal();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [loading, setLoading] = useState(false);
+  const [cron, setCron] = useState(DEFAULT_CRON);
+  const [outbound, setOutbound] = useState<string | undefined>(undefined);
+  const [rows, setRows] = useState<GeodataAssetRow[]>([]);
+  const [outboundTags, setOutboundTags] = useState<string[]>([]);
+  const templateRef = useRef<Record<string, unknown> | null>(null);
+  const outboundTestUrlRef = useRef('');
+
+  const load = useCallback(async () => {
+    setLoading(true);
+    try {
+      const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
+      if (!msg?.success || typeof msg.obj !== 'string') return;
+      const payload = JSON.parse(msg.obj) as Record<string, unknown>;
+      const template = (payload.xraySetting || {}) as Record<string, unknown>;
+      templateRef.current = template;
+      outboundTestUrlRef.current =
+        typeof payload.outboundTestUrl === 'string' ? payload.outboundTestUrl : '';
+
+      const geodata = (template.geodata || {}) as Record<string, unknown>;
+      const assets = Array.isArray(geodata.assets) ? geodata.assets : [];
+      setRows(
+        assets
+          .filter((a): a is Record<string, unknown> => !!a && typeof a === 'object')
+          .map((a) => ({ url: String(a.url ?? ''), file: String(a.file ?? '') })),
+      );
+      setCron(typeof geodata.cron === 'string' && geodata.cron ? geodata.cron : DEFAULT_CRON);
+      setOutbound(
+        typeof geodata.outbound === 'string' && geodata.outbound ? geodata.outbound : undefined,
+      );
+
+      // Download outbound candidates: template outbounds + subscription outbounds.
+      const tags = new Set<string>();
+      const outbounds = Array.isArray(template.outbounds) ? template.outbounds : [];
+      for (const o of outbounds) {
+        const tag = o && typeof o === 'object' ? (o as Record<string, unknown>).tag : undefined;
+        if (typeof tag === 'string' && tag) tags.add(tag);
+      }
+      const subTags = Array.isArray(payload.subscriptionOutboundTags)
+        ? payload.subscriptionOutboundTags
+        : [];
+      for (const tag of subTags) {
+        if (typeof tag === 'string' && tag) tags.add(tag);
+      }
+      setOutboundTags([...tags]);
+    } finally {
+      setLoading(false);
+    }
+  }, []);
+
+  useEffect(() => {
+    if (active) load();
+  }, [active, load]);
+
+  function setRow(index: number, patch: Partial<GeodataAssetRow>) {
+    setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
+  }
+
+  function onUrlBlur(index: number) {
+    setRows((prev) =>
+      prev.map((r, i) => (i === index && !r.file ? { ...r, file: fileNameFromUrl(r.url) } : r)),
+    );
+  }
+
+  function save() {
+    const template = templateRef.current;
+    if (!template) return;
+    const assets = rows
+      .map((r) => ({ url: r.url.trim(), file: r.file.trim() }))
+      .filter((r) => r.url || r.file);
+    for (const a of assets) {
+      // Xray's geodata downloader accepts HTTPS URLs only.
+      if (!/^https:\/\/\S+$/i.test(a.url)) {
+        messageApi.error(t('pages.index.geodataInvalidUrl'));
+        return;
+      }
+      if (!FILE_NAME_PATTERN.test(a.file)) {
+        messageApi.error(t('pages.index.geodataInvalidFile'));
+        return;
+      }
+    }
+    const cronValue = cron.trim();
+    if (assets.length > 0 && cronValue && cronValue.split(/\s+/).length !== 5) {
+      messageApi.error(t('pages.index.geodataInvalidCron'));
+      return;
+    }
+
+    modal.confirm({
+      title: t('pages.index.geodataConfirmTitle'),
+      content: t('pages.index.geodataConfirmContent'),
+      okText: t('confirm'),
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const next: Record<string, unknown> = { ...template };
+        if (assets.length === 0) {
+          delete next.geodata;
+        } else {
+          const geodata: Record<string, unknown> = { assets };
+          if (cronValue) geodata.cron = cronValue;
+          if (outbound) geodata.outbound = outbound;
+          next.geodata = geodata;
+        }
+        onClose();
+        onBusy({ busy: true, tip: t('pages.index.dontRefresh') });
+        try {
+          const msg = await HttpUtil.post('/panel/api/xray/update', {
+            xraySetting: JSON.stringify(next, null, 2),
+            outboundTestUrl: outboundTestUrlRef.current,
+          });
+          if (msg?.success) {
+            await HttpUtil.post('/panel/api/server/restartXrayService');
+          }
+        } finally {
+          onBusy({ busy: false });
+        }
+      },
+    });
+  }
+
+  return (
+    <div>
+      {modalContextHolder}
+      {messageContextHolder}
+      <Spin spinning={loading}>
+        <Alert type="info" className="mb-12" title={t('pages.index.geodataHint')} showIcon />
+        <Form layout="vertical">
+          <Form.Item label={t('pages.index.geodataCron')} style={{ marginBottom: 8 }}>
+            <Input
+              value={cron}
+              placeholder={DEFAULT_CRON}
+              onChange={(e) => setCron(e.target.value)}
+            />
+          </Form.Item>
+          <Form.Item label={t('pages.index.geodataOutbound')} style={{ marginBottom: 8 }}>
+            <Select
+              style={{ width: '100%' }}
+              allowClear
+              value={outbound}
+              onChange={(v) => setOutbound(v)}
+              options={outboundTags.map((tag) => ({ label: tag, value: tag }))}
+            />
+          </Form.Item>
+        </Form>
+        <Space orientation="vertical" style={{ width: '100%' }} size={8}>
+          {rows.length === 0 && (
+            <Typography.Text type="secondary">{t('pages.index.geodataEmpty')}</Typography.Text>
+          )}
+          {rows.map((row, i) => (
+            <Space.Compact key={i} style={{ width: '100%' }}>
+              <Input
+                style={{ width: '60%' }}
+                placeholder="https://example.com/geosite_custom.dat"
+                value={row.url}
+                onChange={(e) => setRow(i, { url: e.target.value })}
+                onBlur={() => onUrlBlur(i)}
+              />
+              <Input
+                style={{ width: '40%' }}
+                placeholder={t('pages.index.geodataFile')}
+                value={row.file}
+                onChange={(e) => setRow(i, { file: e.target.value })}
+              />
+              <Button
+                icon={<DeleteOutlined />}
+                onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
+              />
+            </Space.Compact>
+          ))}
+          <div className="actions-row">
+            <Button
+              icon={<PlusOutlined />}
+              onClick={() => setRows((p) => [...p, { url: '', file: '' }])}
+            >
+              {t('pages.index.geodataAddFile')}
+            </Button>
+            <Button type="primary" onClick={save} disabled={loading || !templateRef.current}>
+              {t('pages.index.geodataSaveRestart')}
+            </Button>
+          </div>
+        </Space>
+      </Spin>
+    </div>
+  );
+}

+ 9 - 3
frontend/src/pages/index/VersionModal.tsx

@@ -5,7 +5,7 @@ import { ReloadOutlined } from '@ant-design/icons';
 
 import { HttpUtil } from '@/utils';
 import type { Status } from '@/models/status';
-import CustomGeoSection from './CustomGeoSection';
+import GeodataSection from './GeodataSection';
 import './VersionModal.css';
 
 interface BusyEvent {
@@ -161,8 +161,14 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
             },
             {
               key: '3',
-              label: t('pages.index.customGeoTitle'),
-              children: <CustomGeoSection active={activeKeyStr === '3'} />,
+              label: t('pages.index.geodataTitle'),
+              children: (
+                <GeodataSection
+                  active={activeKeyStr === '3'}
+                  onBusy={onBusy}
+                  onClose={onClose}
+                />
+              ),
             },
           ]}
         />

+ 0 - 21
frontend/src/schemas/xray.ts

@@ -72,26 +72,6 @@ export const OutboundTestResultSchema = z.object({
     .optional(),
 }).loose();
 
-export const CustomGeoFormSchema = z.object({
-  type: z.enum(['geosite', 'geoip']),
-  alias: z.string().regex(/^[a-z0-9_-]+$/, 'pages.index.customGeoValidationAlias'),
-  url: z
-    .string()
-    .trim()
-    .refine(
-      (u) => {
-        if (!/^https?:\/\//i.test(u)) return false;
-        try {
-          const parsed = new URL(u);
-          return parsed.protocol === 'http:' || parsed.protocol === 'https:';
-        } catch {
-          return false;
-        }
-      },
-      { message: 'pages.index.customGeoValidationUrl' },
-    ),
-});
-
 export const RuleFormSchema = z.object({
   domain: z.string(),
   ip: z.string(),
@@ -123,7 +103,6 @@ export const OutboundTagSchema = z
 
 export type BalancerFormValues = z.infer<typeof BalancerFormSchema>;
 export type RuleFormValues = z.infer<typeof RuleFormSchema>;
-export type CustomGeoFormValues = z.infer<typeof CustomGeoFormSchema>;
 export type XraySettingsValue = z.infer<typeof XraySettingsValueSchema>;
 export type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
 export type OutboundTrafficRow = z.infer<typeof OutboundTrafficRowSchema>;

+ 0 - 1
internal/database/db.go

@@ -65,7 +65,6 @@ func initModels() error {
 		&model.InboundClientIps{},
 		&xray.ClientTraffic{},
 		&model.HistoryOfSeeders{},
-		&model.CustomGeoResource{},
 		&model.Node{},
 		&model.ApiToken{},
 		&model.ClientRecord{},

+ 0 - 1
internal/database/migrate_data.go

@@ -39,7 +39,6 @@ func migrationModels() []any {
 		&model.User{},
 		&model.Setting{},
 		&model.HistoryOfSeeders{},
-		&model.CustomGeoResource{},
 		&model.Node{},
 		&model.ApiToken{},
 		&model.Inbound{},

+ 0 - 12
internal/database/model/model.go

@@ -525,18 +525,6 @@ type NodeSummary struct {
 	XrayError string `json:"xrayError,omitempty"`
 }
 
-type CustomGeoResource struct {
-	Id            int    `json:"id" gorm:"primaryKey;autoIncrement"`
-	Type          string `json:"type" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias;column:geo_type"`
-	Alias         string `json:"alias" gorm:"not null;uniqueIndex:idx_custom_geo_type_alias"`
-	Url           string `json:"url" gorm:"not null"`
-	LocalPath     string `json:"localPath" gorm:"column:local_path"`
-	LastUpdatedAt int64  `json:"lastUpdatedAt" gorm:"default:0;column:last_updated_at"`
-	LastModified  string `json:"lastModified" gorm:"column:last_modified"`
-	CreatedAt     int64  `json:"createdAt" gorm:"autoCreateTime:milli;column:created_at"`
-	UpdatedAt     int64  `json:"updatedAt" gorm:"autoUpdateTime:milli;column:updated_at"`
-}
-
 type ClientReverse struct {
 	Tag string `json:"tag"`
 }

+ 3 - 6
internal/web/controller/api.go

@@ -6,7 +6,6 @@ import (
 
 	"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/session"
@@ -29,9 +28,9 @@ type APIController struct {
 }
 
 // NewAPIController creates a new APIController instance and initializes its routes.
-func NewAPIController(g *gin.RouterGroup, customGeo *integration.CustomGeoService) *APIController {
+func NewAPIController(g *gin.RouterGroup) *APIController {
 	a := &APIController{}
-	a.initRouter(g, customGeo)
+	a.initRouter(g)
 	return a
 }
 
@@ -60,7 +59,7 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
 }
 
 // initRouter sets up the API routes for inbounds, server, and other endpoints.
-func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *integration.CustomGeoService) {
+func (a *APIController) initRouter(g *gin.RouterGroup) {
 	// Main API group
 	api := g.Group("/panel/api")
 	api.Use(a.checkAPIAuth)
@@ -82,8 +81,6 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *integration.Cu
 	nodes := api.Group("/nodes")
 	a.nodeController = NewNodeController(nodes)
 
-	NewCustomGeoController(api.Group("/custom-geo"), customGeo)
-
 	// Settings + Xray config management live under the API surface too, so the
 	// same API token drives them. Paths are /panel/api/setting/* and
 	// /panel/api/xray/*.

+ 0 - 2
internal/web/controller/api_docs_test.go

@@ -99,8 +99,6 @@ func TestAPIRoutesDocumented(t *testing.T) {
 			basePath = "/panel/api/setting"
 		case "xray_setting.go":
 			basePath = "/panel/api/xray"
-		case "custom_geo.go":
-			basePath = "/panel/api/custom-geo"
 		case "websocket.go":
 			basePath = ""
 		}

+ 0 - 180
internal/web/controller/custom_geo.go

@@ -1,180 +0,0 @@
-package controller
-
-import (
-	"errors"
-	"net/http"
-	"strconv"
-
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
-
-	"github.com/gin-gonic/gin"
-)
-
-type CustomGeoController struct {
-	BaseController
-	customGeoService *integration.CustomGeoService
-}
-
-func NewCustomGeoController(g *gin.RouterGroup, customGeo *integration.CustomGeoService) *CustomGeoController {
-	a := &CustomGeoController{customGeoService: customGeo}
-	a.initRouter(g)
-	return a
-}
-
-func (a *CustomGeoController) initRouter(g *gin.RouterGroup) {
-	g.GET("/list", a.list)
-	g.GET("/aliases", a.aliases)
-	g.POST("/add", a.add)
-	g.POST("/update/:id", a.update)
-	g.POST("/delete/:id", a.delete)
-	g.POST("/download/:id", a.download)
-	g.POST("/update-all", a.updateAll)
-}
-
-func mapCustomGeoErr(c *gin.Context, err error) error {
-	if err == nil {
-		return nil
-	}
-	switch {
-	case errors.Is(err, integration.ErrCustomGeoInvalidType):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidType"))
-	case errors.Is(err, integration.ErrCustomGeoAliasRequired):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasRequired"))
-	case errors.Is(err, integration.ErrCustomGeoAliasPattern):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasPattern"))
-	case errors.Is(err, integration.ErrCustomGeoAliasReserved):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrAliasReserved"))
-	case errors.Is(err, integration.ErrCustomGeoURLRequired):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlRequired"))
-	case errors.Is(err, integration.ErrCustomGeoInvalidURL):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrInvalidUrl"))
-	case errors.Is(err, integration.ErrCustomGeoURLScheme):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlScheme"))
-	case errors.Is(err, integration.ErrCustomGeoURLHost):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
-	case errors.Is(err, integration.ErrCustomGeoDuplicateAlias):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrDuplicateAlias"))
-	case errors.Is(err, integration.ErrCustomGeoNotFound):
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrNotFound"))
-	case errors.Is(err, integration.ErrCustomGeoDownload):
-		logger.Warning("custom geo download:", err)
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
-	case errors.Is(err, integration.ErrCustomGeoSSRFBlocked):
-		logger.Warning("custom geo SSRF blocked:", err)
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrUrlHost"))
-	case errors.Is(err, integration.ErrCustomGeoPathTraversal):
-		logger.Warning("custom geo path traversal blocked:", err)
-		return errors.New(I18nWeb(c, "pages.index.customGeoErrDownload"))
-	default:
-		return err
-	}
-}
-
-func (a *CustomGeoController) list(c *gin.Context) {
-	list, err := a.customGeoService.GetAll()
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastList"), mapCustomGeoErr(c, err))
-		return
-	}
-	jsonObj(c, list, nil)
-}
-
-func (a *CustomGeoController) aliases(c *gin.Context) {
-	out, err := a.customGeoService.GetAliasesForUI()
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.index.customGeoAliasesError"), mapCustomGeoErr(c, err))
-		return
-	}
-	jsonObj(c, out, nil)
-}
-
-type customGeoForm struct {
-	Type  string `json:"type" form:"type"`
-	Alias string `json:"alias" form:"alias"`
-	Url   string `json:"url" form:"url"`
-}
-
-func (a *CustomGeoController) add(c *gin.Context) {
-	var form customGeoForm
-	if err := c.ShouldBind(&form); err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), err)
-		return
-	}
-	r := &model.CustomGeoResource{
-		Type:  form.Type,
-		Alias: form.Alias,
-		Url:   form.Url,
-	}
-	err := a.customGeoService.Create(r)
-	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastAdd"), mapCustomGeoErr(c, err))
-}
-
-func parseCustomGeoID(c *gin.Context, idStr string) (int, bool) {
-	id, err := strconv.Atoi(idStr)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), err)
-		return 0, false
-	}
-	if id <= 0 {
-		jsonMsg(c, I18nWeb(c, "pages.index.customGeoInvalidId"), errors.New(""))
-		return 0, false
-	}
-	return id, true
-}
-
-func (a *CustomGeoController) update(c *gin.Context) {
-	id, ok := parseCustomGeoID(c, c.Param("id"))
-	if !ok {
-		return
-	}
-	var form customGeoForm
-	if bindErr := c.ShouldBind(&form); bindErr != nil {
-		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), bindErr)
-		return
-	}
-	r := &model.CustomGeoResource{
-		Type:  form.Type,
-		Alias: form.Alias,
-		Url:   form.Url,
-	}
-	err := a.customGeoService.Update(id, r)
-	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdate"), mapCustomGeoErr(c, err))
-}
-
-func (a *CustomGeoController) delete(c *gin.Context) {
-	id, ok := parseCustomGeoID(c, c.Param("id"))
-	if !ok {
-		return
-	}
-	name, err := a.customGeoService.Delete(id)
-	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDelete", "fileName=="+name), mapCustomGeoErr(c, err))
-}
-
-func (a *CustomGeoController) download(c *gin.Context) {
-	id, ok := parseCustomGeoID(c, c.Param("id"))
-	if !ok {
-		return
-	}
-	name, err := a.customGeoService.TriggerUpdate(id)
-	jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastDownload", "fileName=="+name), mapCustomGeoErr(c, err))
-}
-
-func (a *CustomGeoController) updateAll(c *gin.Context) {
-	res, err := a.customGeoService.TriggerUpdateAll()
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), mapCustomGeoErr(c, err))
-		return
-	}
-	if len(res.Failed) > 0 {
-		c.JSON(http.StatusOK, entity.Msg{
-			Success: false,
-			Msg:     I18nWeb(c, "pages.index.customGeoErrUpdateAllIncomplete"),
-			Obj:     res,
-		})
-		return
-	}
-	jsonMsgObj(c, I18nWeb(c, "pages.index.customGeoToastUpdateAll"), res, nil)
-}

+ 0 - 784
internal/web/service/integration/custom_geo.go

@@ -1,784 +0,0 @@
-package integration
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"net"
-	"net/http"
-	"net/url"
-	"os"
-	"path/filepath"
-	"regexp"
-	"strings"
-	"time"
-
-	"github.com/mhsanaei/3x-ui/v3/internal/config"
-	"github.com/mhsanaei/3x-ui/v3/internal/database"
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
-	"github.com/mhsanaei/3x-ui/v3/internal/logger"
-	"github.com/mhsanaei/3x-ui/v3/internal/util/netproxy"
-	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
-)
-
-const (
-	customGeoTypeGeosite  = "geosite"
-	customGeoTypeGeoip    = "geoip"
-	minDatBytes           = 64
-	customGeoProbeTimeout = 12 * time.Second
-)
-
-var (
-	customGeoAliasPattern = regexp.MustCompile(`^[a-z0-9_-]+$`)
-	reservedCustomAliases = map[string]struct{}{
-		"geoip": {}, "geosite": {},
-		"geoip_ir": {}, "geosite_ir": {},
-		"geoip_ru": {}, "geosite_ru": {},
-	}
-	ErrCustomGeoInvalidType    = errors.New("custom_geo_invalid_type")
-	ErrCustomGeoAliasRequired  = errors.New("custom_geo_alias_required")
-	ErrCustomGeoAliasPattern   = errors.New("custom_geo_alias_pattern")
-	ErrCustomGeoAliasReserved  = errors.New("custom_geo_alias_reserved")
-	ErrCustomGeoURLRequired    = errors.New("custom_geo_url_required")
-	ErrCustomGeoInvalidURL     = errors.New("custom_geo_invalid_url")
-	ErrCustomGeoURLScheme      = errors.New("custom_geo_url_scheme")
-	ErrCustomGeoURLHost        = errors.New("custom_geo_url_host")
-	ErrCustomGeoDuplicateAlias = errors.New("custom_geo_duplicate_alias")
-	ErrCustomGeoNotFound       = errors.New("custom_geo_not_found")
-	ErrCustomGeoDownload       = errors.New("custom_geo_download")
-	ErrCustomGeoSSRFBlocked    = errors.New("custom_geo_ssrf_blocked")
-	ErrCustomGeoPathTraversal  = errors.New("custom_geo_path_traversal")
-)
-
-type CustomGeoUpdateAllItem struct {
-	Id       int    `json:"id"`
-	Alias    string `json:"alias"`
-	FileName string `json:"fileName"`
-}
-
-type CustomGeoUpdateAllFailure struct {
-	Id       int    `json:"id"`
-	Alias    string `json:"alias"`
-	FileName string `json:"fileName"`
-	Err      string `json:"error"`
-}
-
-type CustomGeoUpdateAllResult struct {
-	Succeeded []CustomGeoUpdateAllItem    `json:"succeeded"`
-	Failed    []CustomGeoUpdateAllFailure `json:"failed"`
-}
-
-type CustomGeoService struct {
-	serverService    *service.ServerService
-	updateAllGetAll  func() ([]model.CustomGeoResource, error)
-	updateAllApply   func(id int, onStartup bool) (string, error)
-	updateAllRestart func() error
-	getPanelProxy    func() (string, error)
-}
-
-func NewCustomGeoService() *CustomGeoService {
-	s := &CustomGeoService{
-		serverService: &service.ServerService{},
-	}
-	s.updateAllGetAll = s.GetAll
-	s.updateAllApply = s.applyDownloadAndPersist
-	s.updateAllRestart = func() error { return s.serverService.RestartXrayService() }
-	s.getPanelProxy = (&service.SettingService{}).GetPanelProxy
-	return s
-}
-
-func NormalizeAliasKey(alias string) string {
-	return strings.ToLower(strings.ReplaceAll(alias, "-", "_"))
-}
-
-func (s *CustomGeoService) fileNameFor(typ, alias string) string {
-	if typ == customGeoTypeGeoip {
-		return fmt.Sprintf("geoip_%s.dat", alias)
-	}
-	return fmt.Sprintf("geosite_%s.dat", alias)
-}
-
-func (s *CustomGeoService) validateType(typ string) error {
-	if typ != customGeoTypeGeosite && typ != customGeoTypeGeoip {
-		return ErrCustomGeoInvalidType
-	}
-	return nil
-}
-
-func (s *CustomGeoService) validateAlias(alias string) error {
-	if alias == "" {
-		return ErrCustomGeoAliasRequired
-	}
-	if !customGeoAliasPattern.MatchString(alias) {
-		return ErrCustomGeoAliasPattern
-	}
-	if _, ok := reservedCustomAliases[NormalizeAliasKey(alias)]; ok {
-		return ErrCustomGeoAliasReserved
-	}
-	return nil
-}
-
-func (s *CustomGeoService) sanitizeURL(raw string) (string, error) {
-	if raw == "" {
-		return "", ErrCustomGeoURLRequired
-	}
-	u, err := url.Parse(raw)
-	if err != nil {
-		return "", ErrCustomGeoInvalidURL
-	}
-	if u.Scheme != "http" && u.Scheme != "https" {
-		return "", ErrCustomGeoURLScheme
-	}
-	if u.Host == "" {
-		return "", ErrCustomGeoURLHost
-	}
-	if err := checkSSRF(context.Background(), u.Hostname()); err != nil {
-		return "", err
-	}
-	// Reconstruct URL from parsed components to break taint propagation.
-	clean := &url.URL{
-		Scheme:   u.Scheme,
-		Host:     u.Host,
-		Path:     u.Path,
-		RawPath:  u.RawPath,
-		RawQuery: u.RawQuery,
-		Fragment: u.Fragment,
-	}
-	return clean.String(), nil
-}
-
-func localDatFileNeedsRepair(path string) bool {
-	safePath, err := sanitizeDestPath(path)
-	if err != nil {
-		return true
-	}
-	fi, err := os.Stat(safePath)
-	if err != nil {
-		return true
-	}
-	if fi.IsDir() {
-		return true
-	}
-	return fi.Size() < int64(minDatBytes)
-}
-
-func CustomGeoLocalFileNeedsRepair(path string) bool {
-	return localDatFileNeedsRepair(path)
-}
-
-func isBlockedIP(ip net.IP) bool {
-	return netsafe.IsBlockedIP(ip)
-}
-
-// checkSSRFDefault validates that the given host does not resolve to a private/internal IP.
-// It is context-aware so that dial context cancellation/deadlines are respected during DNS resolution.
-func checkSSRFDefault(ctx context.Context, hostname string) error {
-	ips, err := net.DefaultResolver.LookupIPAddr(ctx, hostname)
-	if err != nil {
-		return fmt.Errorf("%w: cannot resolve host %s", ErrCustomGeoSSRFBlocked, hostname)
-	}
-	for _, ipAddr := range ips {
-		if isBlockedIP(ipAddr.IP) {
-			return fmt.Errorf("%w: %s resolves to blocked address %s", ErrCustomGeoSSRFBlocked, hostname, ipAddr.IP)
-		}
-	}
-	return nil
-}
-
-// checkSSRF is the active SSRF guard. Override in tests to allow localhost test servers.
-var checkSSRF = checkSSRFDefault
-
-func ssrfSafeTransport() http.RoundTripper {
-	base, ok := http.DefaultTransport.(*http.Transport)
-	if !ok {
-		base = &http.Transport{}
-	}
-	cloned := base.Clone()
-	cloned.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
-		host, _, err := net.SplitHostPort(addr)
-		if err != nil {
-			return nil, fmt.Errorf("%w: %v", ErrCustomGeoSSRFBlocked, err)
-		}
-		if err := checkSSRF(ctx, host); err != nil {
-			return nil, err
-		}
-		var dialer net.Dialer
-		return dialer.DialContext(ctx, network, addr)
-	}
-	return cloned
-}
-
-func (s *CustomGeoService) httpClient(timeout time.Duration) *http.Client {
-	proxyURL := ""
-	if s.getPanelProxy != nil {
-		if p, err := s.getPanelProxy(); err != nil {
-			logger.Warning("custom geo: read panel proxy:", err)
-		} else {
-			proxyURL = strings.TrimSpace(p)
-		}
-	}
-	if proxyURL != "" {
-		client, err := netproxy.NewHTTPClient(proxyURL, timeout)
-		if err != nil {
-			logger.Warningf("custom geo: invalid panel proxy %q, using direct connection: %v", proxyURL, err)
-		} else {
-			return client
-		}
-	}
-	return &http.Client{Timeout: timeout, Transport: ssrfSafeTransport()}
-}
-
-func (s *CustomGeoService) probeCustomGeoURLWithGET(rawURL string) error {
-	sanitizedURL, err := s.sanitizeURL(rawURL)
-	if err != nil {
-		return err
-	}
-	client := s.httpClient(customGeoProbeTimeout)
-	req, err := http.NewRequest(http.MethodGet, sanitizedURL, nil)
-	if err != nil {
-		return err
-	}
-	req.Header.Set("Range", "bytes=0-0")
-	resp, err := client.Do(req)
-	if err != nil {
-		return err
-	}
-	defer resp.Body.Close()
-	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256))
-	switch resp.StatusCode {
-	case http.StatusOK, http.StatusPartialContent:
-		return nil
-	default:
-		return fmt.Errorf("get range status %d", resp.StatusCode)
-	}
-}
-
-func (s *CustomGeoService) probeCustomGeoURL(rawURL string) error {
-	sanitizedURL, err := s.sanitizeURL(rawURL)
-	if err != nil {
-		return err
-	}
-	client := s.httpClient(customGeoProbeTimeout)
-	req, err := http.NewRequest(http.MethodHead, sanitizedURL, nil)
-	if err != nil {
-		return err
-	}
-	resp, err := client.Do(req)
-	if err != nil {
-		return err
-	}
-	_ = resp.Body.Close()
-	sc := resp.StatusCode
-	if sc >= 200 && sc < 300 {
-		return nil
-	}
-	if sc == http.StatusMethodNotAllowed || sc == http.StatusNotImplemented {
-		return s.probeCustomGeoURLWithGET(rawURL)
-	}
-	return fmt.Errorf("head status %d", sc)
-}
-
-func (s *CustomGeoService) EnsureOnStartup() {
-	list, err := s.GetAll()
-	if err != nil {
-		logger.Warning("custom geo startup: load list:", err)
-		return
-	}
-	n := len(list)
-	if n == 0 {
-		logger.Info("custom geo startup: no custom geofiles configured")
-		return
-	}
-	logger.Infof("custom geo startup: checking %d custom geofile(s)", n)
-	for i := range list {
-		r := &list[i]
-		sanitizedURL, err := s.sanitizeURL(r.Url)
-		if err != nil {
-			logger.Warningf("custom geo startup id=%d: invalid url: %v", r.Id, err)
-			continue
-		}
-		r.Url = sanitizedURL
-		s.syncLocalPath(r)
-		localPath := r.LocalPath
-		if !localDatFileNeedsRepair(localPath) {
-			logger.Infof("custom geo startup id=%d alias=%s path=%s: present", r.Id, r.Alias, localPath)
-			continue
-		}
-		logger.Infof("custom geo startup id=%d alias=%s path=%s: missing or needs repair, probing source", r.Id, r.Alias, localPath)
-		if err := s.probeCustomGeoURL(r.Url); err != nil {
-			logger.Warningf("custom geo startup id=%d alias=%s url=%s: probe: %v (attempting download anyway)", r.Id, r.Alias, r.Url, err)
-		}
-		_, _ = s.applyDownloadAndPersist(r.Id, true)
-	}
-}
-
-func (s *CustomGeoService) downloadToPath(resourceURL, destPath string, lastModifiedHeader string) (skipped bool, newLastModified string, err error) {
-	safeDestPath, err := sanitizeDestPath(destPath)
-	if err != nil {
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-
-	skipped, lm, err := s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, false)
-	if err != nil {
-		return false, "", err
-	}
-	if skipped {
-		if _, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
-			return true, lm, nil
-		}
-		return s.downloadToPathOnce(resourceURL, safeDestPath, lastModifiedHeader, true)
-	}
-	return false, lm, nil
-}
-
-// sanitizeDestPath ensures destPath is inside the bin folder, preventing path traversal.
-// It resolves symlinks to prevent symlink-based escapes.
-// Returns the cleaned absolute path that is safe to use in file operations.
-func sanitizeDestPath(destPath string) (string, error) {
-	baseDirAbs, err := filepath.Abs(config.GetBinFolderPath())
-	if err != nil {
-		return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
-	}
-	// Resolve symlinks in base directory to get the real path.
-	if resolved, evalErr := filepath.EvalSymlinks(baseDirAbs); evalErr == nil {
-		baseDirAbs = resolved
-	}
-	destPathAbs, err := filepath.Abs(destPath)
-	if err != nil {
-		return "", fmt.Errorf("%w: %v", ErrCustomGeoPathTraversal, err)
-	}
-	// Resolve symlinks for the parent directory of the destination path.
-	destDir := filepath.Dir(destPathAbs)
-	if resolved, evalErr := filepath.EvalSymlinks(destDir); evalErr == nil {
-		destPathAbs = filepath.Join(resolved, filepath.Base(destPathAbs))
-	}
-	// Verify the resolved path is within the safe base directory using prefix check.
-	safeDirPrefix := baseDirAbs + string(filepath.Separator)
-	if !strings.HasPrefix(destPathAbs, safeDirPrefix) {
-		return "", ErrCustomGeoPathTraversal
-	}
-	return destPathAbs, nil
-}
-
-func (s *CustomGeoService) downloadToPathOnce(resourceURL, destPath string, lastModifiedHeader string, forceFull bool) (skipped bool, newLastModified string, err error) {
-	safeDestPath, err := sanitizeDestPath(destPath)
-	if err != nil {
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-	sanitizedURL, err := s.sanitizeURL(resourceURL)
-	if err != nil {
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-
-	var req *http.Request
-	req, err = http.NewRequest(http.MethodGet, sanitizedURL, nil)
-	if err != nil {
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-
-	if !forceFull {
-		if fi, statErr := os.Stat(safeDestPath); statErr == nil && !localDatFileNeedsRepair(safeDestPath) {
-			if !fi.ModTime().IsZero() {
-				req.Header.Set("If-Modified-Since", fi.ModTime().UTC().Format(http.TimeFormat))
-			} else if lastModifiedHeader != "" {
-				if t, perr := time.Parse(http.TimeFormat, lastModifiedHeader); perr == nil {
-					req.Header.Set("If-Modified-Since", t.UTC().Format(http.TimeFormat))
-				}
-			}
-		}
-	}
-
-	client := s.httpClient(10 * time.Minute)
-	// lgtm[go/request-forgery]
-	resp, err := client.Do(req)
-	if err != nil {
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-	defer resp.Body.Close()
-
-	var serverModTime time.Time
-	if lm := resp.Header.Get("Last-Modified"); lm != "" {
-		if parsed, perr := time.Parse(http.TimeFormat, lm); perr == nil {
-			serverModTime = parsed
-			newLastModified = lm
-		}
-	}
-
-	updateModTime := func() {
-		if !serverModTime.IsZero() {
-			_ = os.Chtimes(safeDestPath, serverModTime, serverModTime)
-		}
-	}
-
-	if resp.StatusCode == http.StatusNotModified {
-		if forceFull {
-			return false, "", fmt.Errorf("%w: unexpected 304 on unconditional get", ErrCustomGeoDownload)
-		}
-		updateModTime()
-		return true, newLastModified, nil
-	}
-	if resp.StatusCode != http.StatusOK {
-		return false, "", fmt.Errorf("%w: unexpected status %d", ErrCustomGeoDownload, resp.StatusCode)
-	}
-
-	binDir := filepath.Dir(safeDestPath)
-	if err = os.MkdirAll(binDir, 0o755); err != nil {
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-
-	safeTmpPath, err := sanitizeDestPath(safeDestPath + ".tmp")
-	if err != nil {
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-	out, err := os.Create(safeTmpPath)
-	if err != nil {
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-	n, err := io.Copy(out, resp.Body)
-	closeErr := out.Close()
-	if err != nil {
-		_ = os.Remove(safeTmpPath)
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-	if closeErr != nil {
-		_ = os.Remove(safeTmpPath)
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, closeErr)
-	}
-	if n < minDatBytes {
-		_ = os.Remove(safeTmpPath)
-		return false, "", fmt.Errorf("%w: file too small", ErrCustomGeoDownload)
-	}
-
-	if err = os.Rename(safeTmpPath, safeDestPath); err != nil {
-		_ = os.Remove(safeTmpPath)
-		return false, "", fmt.Errorf("%w: %v", ErrCustomGeoDownload, err)
-	}
-
-	updateModTime()
-	if newLastModified == "" && resp.Header.Get("Last-Modified") != "" {
-		newLastModified = resp.Header.Get("Last-Modified")
-	}
-	return false, newLastModified, nil
-}
-
-func (s *CustomGeoService) resolveDestPath(r *model.CustomGeoResource) string {
-	if r.LocalPath != "" {
-		return r.LocalPath
-	}
-	return filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
-}
-
-func (s *CustomGeoService) syncLocalPath(r *model.CustomGeoResource) {
-	p := filepath.Join(config.GetBinFolderPath(), s.fileNameFor(r.Type, r.Alias))
-	r.LocalPath = p
-}
-
-func (s *CustomGeoService) syncAndSanitizeLocalPath(r *model.CustomGeoResource) error {
-	s.syncLocalPath(r)
-	safePath, err := sanitizeDestPath(r.LocalPath)
-	if err != nil {
-		return err
-	}
-	r.LocalPath = safePath
-	return nil
-}
-
-func removeSafePathIfExists(path string) error {
-	safePath, err := sanitizeDestPath(path)
-	if err != nil {
-		return err
-	}
-	if _, err := os.Stat(safePath); err == nil {
-		if err := os.Remove(safePath); err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-func (s *CustomGeoService) Create(r *model.CustomGeoResource) error {
-	if err := s.validateType(r.Type); err != nil {
-		return err
-	}
-	if err := s.validateAlias(r.Alias); err != nil {
-		return err
-	}
-	sanitizedURL, err := s.sanitizeURL(r.Url)
-	if err != nil {
-		return err
-	}
-	r.Url = sanitizedURL
-	var existing int64
-	database.GetDB().Model(&model.CustomGeoResource{}).
-		Where("geo_type = ? AND alias = ?", r.Type, r.Alias).Count(&existing)
-	if existing > 0 {
-		return ErrCustomGeoDuplicateAlias
-	}
-	if err := s.syncAndSanitizeLocalPath(r); err != nil {
-		return err
-	}
-	skipped, lm, err := s.downloadToPath(r.Url, r.LocalPath, r.LastModified)
-	if err != nil {
-		return err
-	}
-	now := time.Now().Unix()
-	r.LastUpdatedAt = now
-	r.LastModified = lm
-	if err = database.GetDB().Create(r).Error; err != nil {
-		_ = removeSafePathIfExists(r.LocalPath)
-		return err
-	}
-	logger.Infof("custom geo created id=%d type=%s alias=%s skipped=%v", r.Id, r.Type, r.Alias, skipped)
-	if err = s.serverService.RestartXrayService(); err != nil {
-		logger.Warning("custom geo create: restart xray:", err)
-	}
-	return nil
-}
-
-func (s *CustomGeoService) Update(id int, r *model.CustomGeoResource) error {
-	var cur model.CustomGeoResource
-	if err := database.GetDB().First(&cur, id).Error; err != nil {
-		if database.IsNotFound(err) {
-			return ErrCustomGeoNotFound
-		}
-		return err
-	}
-	if err := s.validateType(r.Type); err != nil {
-		return err
-	}
-	if err := s.validateAlias(r.Alias); err != nil {
-		return err
-	}
-	sanitizedURL, err := s.sanitizeURL(r.Url)
-	if err != nil {
-		return err
-	}
-	r.Url = sanitizedURL
-	if cur.Type != r.Type || cur.Alias != r.Alias {
-		var cnt int64
-		database.GetDB().Model(&model.CustomGeoResource{}).
-			Where("geo_type = ? AND alias = ? AND id <> ?", r.Type, r.Alias, id).
-			Count(&cnt)
-		if cnt > 0 {
-			return ErrCustomGeoDuplicateAlias
-		}
-	}
-	oldPath := s.resolveDestPath(&cur)
-	r.Id = id
-	if err := s.syncAndSanitizeLocalPath(r); err != nil {
-		return err
-	}
-	if oldPath != r.LocalPath && oldPath != "" {
-		if err := removeSafePathIfExists(oldPath); err != nil && !errors.Is(err, ErrCustomGeoPathTraversal) {
-			logger.Warningf("custom geo remove old path %s: %v", oldPath, err)
-		}
-	}
-	_, lm, err := s.downloadToPath(r.Url, r.LocalPath, cur.LastModified)
-	if err != nil {
-		return err
-	}
-	r.LastUpdatedAt = time.Now().Unix()
-	r.LastModified = lm
-	err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(map[string]any{
-		"geo_type":        r.Type,
-		"alias":           r.Alias,
-		"url":             r.Url,
-		"local_path":      r.LocalPath,
-		"last_updated_at": r.LastUpdatedAt,
-		"last_modified":   r.LastModified,
-	}).Error
-	if err != nil {
-		return err
-	}
-	logger.Infof("custom geo updated id=%d", id)
-	if err = s.serverService.RestartXrayService(); err != nil {
-		logger.Warning("custom geo update: restart xray:", err)
-	}
-	return nil
-}
-
-func (s *CustomGeoService) Delete(id int) (displayName string, err error) {
-	var r model.CustomGeoResource
-	if err := database.GetDB().First(&r, id).Error; err != nil {
-		if database.IsNotFound(err) {
-			return "", ErrCustomGeoNotFound
-		}
-		return "", err
-	}
-	displayName = s.fileNameFor(r.Type, r.Alias)
-	p := s.resolveDestPath(&r)
-	if _, err := sanitizeDestPath(p); err != nil {
-		return displayName, err
-	}
-	if err := database.GetDB().Delete(&model.CustomGeoResource{}, id).Error; err != nil {
-		return displayName, err
-	}
-	if p != "" {
-		if err := removeSafePathIfExists(p); err != nil {
-			logger.Warningf("custom geo delete file %s: %v", p, err)
-		}
-	}
-	logger.Infof("custom geo deleted id=%d", id)
-	if err := s.serverService.RestartXrayService(); err != nil {
-		logger.Warning("custom geo delete: restart xray:", err)
-	}
-	return displayName, nil
-}
-
-func (s *CustomGeoService) GetAll() ([]model.CustomGeoResource, error) {
-	var list []model.CustomGeoResource
-	err := database.GetDB().Order("id asc").Find(&list).Error
-	return list, err
-}
-
-func (s *CustomGeoService) applyDownloadAndPersist(id int, onStartup bool) (displayName string, err error) {
-	var r model.CustomGeoResource
-	if err := database.GetDB().First(&r, id).Error; err != nil {
-		if database.IsNotFound(err) {
-			return "", ErrCustomGeoNotFound
-		}
-		return "", err
-	}
-	displayName = s.fileNameFor(r.Type, r.Alias)
-	if err := s.syncAndSanitizeLocalPath(&r); err != nil {
-		return displayName, err
-	}
-	sanitizedURL, sanitizeErr := s.sanitizeURL(r.Url)
-	if sanitizeErr != nil {
-		return displayName, sanitizeErr
-	}
-	skipped, lm, err := s.downloadToPath(sanitizedURL, r.LocalPath, r.LastModified)
-	if err != nil {
-		if onStartup {
-			logger.Warningf("custom geo startup download id=%d: %v", id, err)
-		} else {
-			logger.Warningf("custom geo manual update id=%d: %v", id, err)
-		}
-		return displayName, err
-	}
-	now := time.Now().Unix()
-	updates := map[string]any{
-		"last_modified":   lm,
-		"local_path":      r.LocalPath,
-		"last_updated_at": now,
-	}
-	if err = database.GetDB().Model(&model.CustomGeoResource{}).Where("id = ?", id).Updates(updates).Error; err != nil {
-		if onStartup {
-			logger.Warningf("custom geo startup id=%d: persist metadata: %v", id, err)
-		} else {
-			logger.Warningf("custom geo manual update id=%d: persist metadata: %v", id, err)
-		}
-		return displayName, err
-	}
-	if skipped {
-		if onStartup {
-			logger.Infof("custom geo startup download skipped (not modified) id=%d", id)
-		} else {
-			logger.Infof("custom geo manual update skipped (not modified) id=%d", id)
-		}
-	} else {
-		if onStartup {
-			logger.Infof("custom geo startup download ok id=%d", id)
-		} else {
-			logger.Infof("custom geo manual update ok id=%d", id)
-		}
-	}
-	return displayName, nil
-}
-
-func (s *CustomGeoService) TriggerUpdate(id int) (string, error) {
-	displayName, err := s.applyDownloadAndPersist(id, false)
-	if err != nil {
-		return displayName, err
-	}
-	if err = s.serverService.RestartXrayService(); err != nil {
-		logger.Warning("custom geo manual update: restart xray:", err)
-	}
-	return displayName, nil
-}
-
-func (s *CustomGeoService) TriggerUpdateAll() (*CustomGeoUpdateAllResult, error) {
-	var list []model.CustomGeoResource
-	var err error
-	if s.updateAllGetAll != nil {
-		list, err = s.updateAllGetAll()
-	} else {
-		list, err = s.GetAll()
-	}
-	if err != nil {
-		return nil, err
-	}
-	res := &CustomGeoUpdateAllResult{}
-	if len(list) == 0 {
-		return res, nil
-	}
-	for _, r := range list {
-		var name string
-		var applyErr error
-		if s.updateAllApply != nil {
-			name, applyErr = s.updateAllApply(r.Id, false)
-		} else {
-			name, applyErr = s.applyDownloadAndPersist(r.Id, false)
-		}
-		if applyErr != nil {
-			res.Failed = append(res.Failed, CustomGeoUpdateAllFailure{
-				Id: r.Id, Alias: r.Alias, FileName: name, Err: applyErr.Error(),
-			})
-			continue
-		}
-		res.Succeeded = append(res.Succeeded, CustomGeoUpdateAllItem{
-			Id: r.Id, Alias: r.Alias, FileName: name,
-		})
-	}
-	if len(res.Succeeded) > 0 {
-		var restartErr error
-		if s.updateAllRestart != nil {
-			restartErr = s.updateAllRestart()
-		} else {
-			restartErr = s.serverService.RestartXrayService()
-		}
-		if restartErr != nil {
-			logger.Warning("custom geo update all: restart xray:", restartErr)
-		}
-	}
-	return res, nil
-}
-
-type CustomGeoAliasItem struct {
-	Alias      string `json:"alias"`
-	Type       string `json:"type"`
-	FileName   string `json:"fileName"`
-	ExtExample string `json:"extExample"`
-}
-
-type CustomGeoAliasesResponse struct {
-	Geosite []CustomGeoAliasItem `json:"geosite"`
-	Geoip   []CustomGeoAliasItem `json:"geoip"`
-}
-
-func (s *CustomGeoService) GetAliasesForUI() (CustomGeoAliasesResponse, error) {
-	list, err := s.GetAll()
-	if err != nil {
-		logger.Warning("custom geo GetAliasesForUI:", err)
-		return CustomGeoAliasesResponse{}, err
-	}
-	var out CustomGeoAliasesResponse
-	for _, r := range list {
-		fn := s.fileNameFor(r.Type, r.Alias)
-		ex := fmt.Sprintf("ext:%s:tag", fn)
-		item := CustomGeoAliasItem{
-			Alias:      r.Alias,
-			Type:       r.Type,
-			FileName:   fn,
-			ExtExample: ex,
-		}
-		if r.Type == customGeoTypeGeoip {
-			out.Geoip = append(out.Geoip, item)
-		} else {
-			out.Geosite = append(out.Geosite, item)
-		}
-	}
-	return out, nil
-}

+ 0 - 348
internal/web/service/integration/custom_geo_test.go

@@ -1,348 +0,0 @@
-package integration
-
-import (
-	"context"
-	"errors"
-	"fmt"
-	"net/http"
-	"net/http/httptest"
-	"os"
-	"path/filepath"
-	"testing"
-
-	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
-)
-
-// disableSSRFCheck disables the SSRF guard for the duration of a test,
-// allowing httptest servers on localhost. It restores the original on cleanup.
-func disableSSRFCheck(t *testing.T) {
-	t.Helper()
-	orig := checkSSRF
-	checkSSRF = func(_ context.Context, _ string) error { return nil }
-	t.Cleanup(func() { checkSSRF = orig })
-}
-
-func TestNormalizeAliasKey(t *testing.T) {
-	if got := NormalizeAliasKey("GeoIP-IR"); got != "geoip_ir" {
-		t.Fatalf("got %q", got)
-	}
-	if got := NormalizeAliasKey("a-b_c"); got != "a_b_c" {
-		t.Fatalf("got %q", got)
-	}
-}
-
-func TestNewCustomGeoService(t *testing.T) {
-	s := NewCustomGeoService()
-	if err := s.validateAlias("ok_alias-1"); err != nil {
-		t.Fatal(err)
-	}
-}
-
-func TestTriggerUpdateAllAllSuccess(t *testing.T) {
-	s := CustomGeoService{}
-	s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
-		return []model.CustomGeoResource{
-			{Id: 1, Alias: "a"},
-			{Id: 2, Alias: "b"},
-		}, nil
-	}
-	s.updateAllApply = func(id int, onStartup bool) (string, error) {
-		return fmt.Sprintf("geo_%d.dat", id), nil
-	}
-	restartCalls := 0
-	s.updateAllRestart = func() error {
-		restartCalls++
-		return nil
-	}
-
-	res, err := s.TriggerUpdateAll()
-	if err != nil {
-		t.Fatal(err)
-	}
-	if len(res.Succeeded) != 2 || len(res.Failed) != 0 {
-		t.Fatalf("unexpected result: %+v", res)
-	}
-	if restartCalls != 1 {
-		t.Fatalf("expected 1 restart, got %d", restartCalls)
-	}
-}
-
-func TestTriggerUpdateAllPartialSuccess(t *testing.T) {
-	s := CustomGeoService{}
-	s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
-		return []model.CustomGeoResource{
-			{Id: 1, Alias: "ok"},
-			{Id: 2, Alias: "bad"},
-		}, nil
-	}
-	s.updateAllApply = func(id int, onStartup bool) (string, error) {
-		if id == 2 {
-			return "geo_2.dat", ErrCustomGeoDownload
-		}
-		return "geo_1.dat", nil
-	}
-	restartCalls := 0
-	s.updateAllRestart = func() error {
-		restartCalls++
-		return nil
-	}
-
-	res, err := s.TriggerUpdateAll()
-	if err != nil {
-		t.Fatal(err)
-	}
-	if len(res.Succeeded) != 1 || len(res.Failed) != 1 {
-		t.Fatalf("unexpected result: %+v", res)
-	}
-	if restartCalls != 1 {
-		t.Fatalf("expected 1 restart, got %d", restartCalls)
-	}
-}
-
-func TestTriggerUpdateAllAllFailure(t *testing.T) {
-	s := CustomGeoService{}
-	s.updateAllGetAll = func() ([]model.CustomGeoResource, error) {
-		return []model.CustomGeoResource{
-			{Id: 1, Alias: "a"},
-			{Id: 2, Alias: "b"},
-		}, nil
-	}
-	s.updateAllApply = func(id int, onStartup bool) (string, error) {
-		return fmt.Sprintf("geo_%d.dat", id), ErrCustomGeoDownload
-	}
-	restartCalls := 0
-	s.updateAllRestart = func() error {
-		restartCalls++
-		return nil
-	}
-
-	res, err := s.TriggerUpdateAll()
-	if err != nil {
-		t.Fatal(err)
-	}
-	if len(res.Succeeded) != 0 || len(res.Failed) != 2 {
-		t.Fatalf("unexpected result: %+v", res)
-	}
-	if restartCalls != 0 {
-		t.Fatalf("expected 0 restart, got %d", restartCalls)
-	}
-}
-
-func TestCustomGeoValidateAlias(t *testing.T) {
-	s := CustomGeoService{}
-	if err := s.validateAlias(""); !errors.Is(err, ErrCustomGeoAliasRequired) {
-		t.Fatal("empty alias")
-	}
-	if err := s.validateAlias("Bad"); !errors.Is(err, ErrCustomGeoAliasPattern) {
-		t.Fatal("uppercase")
-	}
-	if err := s.validateAlias("a b"); !errors.Is(err, ErrCustomGeoAliasPattern) {
-		t.Fatal("space")
-	}
-	if err := s.validateAlias("ok_alias-1"); err != nil {
-		t.Fatal(err)
-	}
-	if err := s.validateAlias("geoip"); !errors.Is(err, ErrCustomGeoAliasReserved) {
-		t.Fatal("reserved")
-	}
-}
-
-func TestCustomGeoValidateURL(t *testing.T) {
-	s := CustomGeoService{}
-	if _, err := s.sanitizeURL(""); !errors.Is(err, ErrCustomGeoURLRequired) {
-		t.Fatal("empty")
-	}
-	if _, err := s.sanitizeURL("ftp://x"); !errors.Is(err, ErrCustomGeoURLScheme) {
-		t.Fatal("ftp")
-	}
-	if sanitized, err := s.sanitizeURL("https://example.com/a.dat"); err != nil {
-		t.Fatal(err)
-	} else if sanitized != "https://example.com/a.dat" {
-		t.Fatalf("unexpected sanitized URL: %s", sanitized)
-	}
-}
-
-func TestCustomGeoValidateType(t *testing.T) {
-	s := CustomGeoService{}
-	if err := s.validateType("geosite"); err != nil {
-		t.Fatal(err)
-	}
-	if err := s.validateType("x"); !errors.Is(err, ErrCustomGeoInvalidType) {
-		t.Fatal("bad type")
-	}
-}
-
-func TestCustomGeoDownloadToPath(t *testing.T) {
-	disableSSRFCheck(t)
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		w.Header().Set("X-Test", "1")
-		if r.Header.Get("If-Modified-Since") != "" {
-			w.WriteHeader(http.StatusNotModified)
-			return
-		}
-		w.WriteHeader(http.StatusOK)
-		_, _ = w.Write(make([]byte, minDatBytes+1))
-	}))
-	defer ts.Close()
-	dir := t.TempDir()
-	t.Setenv("XUI_BIN_FOLDER", dir)
-	dest := filepath.Join(dir, "geoip_t.dat")
-	s := CustomGeoService{}
-	skipped, _, err := s.downloadToPath(ts.URL, dest, "")
-	if err != nil {
-		t.Fatal(err)
-	}
-	if skipped {
-		t.Fatal("expected download")
-	}
-	st, err := os.Stat(dest)
-	if err != nil || st.Size() < minDatBytes {
-		t.Fatalf("file %v", err)
-	}
-	skipped2, _, err2 := s.downloadToPath(ts.URL, dest, "")
-	if err2 != nil || !skipped2 {
-		t.Fatalf("304 expected skipped=%v err=%v", skipped2, err2)
-	}
-}
-
-func TestCustomGeoDownloadToPath_missingLocalSendsNoIMSFromDB(t *testing.T) {
-	disableSSRFCheck(t)
-	lm := "Wed, 21 Oct 2015 07:28:00 GMT"
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if r.Header.Get("If-Modified-Since") != "" {
-			w.WriteHeader(http.StatusNotModified)
-			return
-		}
-		w.Header().Set("Last-Modified", lm)
-		w.WriteHeader(http.StatusOK)
-		_, _ = w.Write(make([]byte, minDatBytes+1))
-	}))
-	defer ts.Close()
-	dir := t.TempDir()
-	t.Setenv("XUI_BIN_FOLDER", dir)
-	dest := filepath.Join(dir, "geoip_rebuild.dat")
-	s := CustomGeoService{}
-	skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if skipped {
-		t.Fatal("must not treat as not-modified when local file is missing")
-	}
-	if _, err := os.Stat(dest); err != nil {
-		t.Fatal("file should exist after container-style rebuild")
-	}
-}
-
-func TestCustomGeoDownloadToPath_repairSkipsConditional(t *testing.T) {
-	disableSSRFCheck(t)
-	lm := "Wed, 21 Oct 2015 07:28:00 GMT"
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if r.Header.Get("If-Modified-Since") != "" {
-			w.WriteHeader(http.StatusNotModified)
-			return
-		}
-		w.Header().Set("Last-Modified", lm)
-		w.WriteHeader(http.StatusOK)
-		_, _ = w.Write(make([]byte, minDatBytes+1))
-	}))
-	defer ts.Close()
-	dir := t.TempDir()
-	t.Setenv("XUI_BIN_FOLDER", dir)
-	dest := filepath.Join(dir, "geoip_bad.dat")
-	if err := os.WriteFile(dest, make([]byte, minDatBytes-1), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	s := CustomGeoService{}
-	skipped, _, err := s.downloadToPath(ts.URL, dest, lm)
-	if err != nil {
-		t.Fatal(err)
-	}
-	if skipped {
-		t.Fatal("corrupt local file must be re-downloaded, not 304")
-	}
-	st, err := os.Stat(dest)
-	if err != nil || st.Size() < minDatBytes {
-		t.Fatalf("file repaired: %v", err)
-	}
-}
-
-func TestCustomGeoFileNameFor(t *testing.T) {
-	s := CustomGeoService{}
-	if s.fileNameFor("geoip", "a") != "geoip_a.dat" {
-		t.Fatal("geoip name")
-	}
-	if s.fileNameFor("geosite", "b") != "geosite_b.dat" {
-		t.Fatal("geosite name")
-	}
-}
-
-func TestLocalDatFileNeedsRepair(t *testing.T) {
-	dir := t.TempDir()
-	t.Setenv("XUI_BIN_FOLDER", dir)
-	if !localDatFileNeedsRepair(filepath.Join(dir, "missing.dat")) {
-		t.Fatal("missing")
-	}
-	smallPath := filepath.Join(dir, "small.dat")
-	if err := os.WriteFile(smallPath, make([]byte, minDatBytes-1), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	if !localDatFileNeedsRepair(smallPath) {
-		t.Fatal("small")
-	}
-	okPath := filepath.Join(dir, "ok.dat")
-	if err := os.WriteFile(okPath, make([]byte, minDatBytes), 0o644); err != nil {
-		t.Fatal(err)
-	}
-	if localDatFileNeedsRepair(okPath) {
-		t.Fatal("ok size")
-	}
-	dirPath := filepath.Join(dir, "isdir.dat")
-	if err := os.Mkdir(dirPath, 0o755); err != nil {
-		t.Fatal(err)
-	}
-	if !localDatFileNeedsRepair(dirPath) {
-		t.Fatal("dir should need repair")
-	}
-	if !CustomGeoLocalFileNeedsRepair(dirPath) {
-		t.Fatal("exported wrapper dir")
-	}
-	if CustomGeoLocalFileNeedsRepair(okPath) {
-		t.Fatal("exported wrapper ok file")
-	}
-}
-
-func TestProbeCustomGeoURL_HEADOK(t *testing.T) {
-	disableSSRFCheck(t)
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if r.Method == http.MethodHead {
-			w.WriteHeader(http.StatusOK)
-			return
-		}
-		w.WriteHeader(http.StatusOK)
-	}))
-	defer ts.Close()
-	if err := (&CustomGeoService{}).probeCustomGeoURL(ts.URL); err != nil {
-		t.Fatal(err)
-	}
-}
-
-func TestProbeCustomGeoURL_HEAD405GETRange(t *testing.T) {
-	disableSSRFCheck(t)
-	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if r.Method == http.MethodHead {
-			w.WriteHeader(http.StatusMethodNotAllowed)
-			return
-		}
-		if r.Method == http.MethodGet && r.Header.Get("Range") != "" {
-			w.WriteHeader(http.StatusPartialContent)
-			_, _ = w.Write([]byte{0})
-			return
-		}
-		w.WriteHeader(http.StatusBadRequest)
-	}))
-	defer ts.Close()
-	if err := (&CustomGeoService{}).probeCustomGeoURL(ts.URL); err != nil {
-		t.Fatal(err)
-	}
-}

+ 2 - 54
internal/web/service/integration/panel_proxy_test.go

@@ -3,8 +3,6 @@ package integration
 import (
 	"net/http"
 	"net/http/httptest"
-	"os"
-	"path/filepath"
 	"sync/atomic"
 	"testing"
 	"time"
@@ -17,7 +15,7 @@ func recordingProxy(t *testing.T, hits *int64) *httptest.Server {
 	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		atomic.AddInt64(hits, 1)
 		w.WriteHeader(http.StatusOK)
-		_, _ = w.Write(make([]byte, minDatBytes+1))
+		_, _ = w.Write([]byte("ok"))
 	}))
 }
 
@@ -26,7 +24,7 @@ func originServer(t *testing.T, hits *int64) *httptest.Server {
 	return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		atomic.AddInt64(hits, 1)
 		w.WriteHeader(http.StatusOK)
-		_, _ = w.Write(make([]byte, minDatBytes+1))
+		_, _ = w.Write([]byte("ok"))
 	}))
 }
 
@@ -51,53 +49,3 @@ func TestPanelProxy_NetproxyHelperRoutesThroughProxy(t *testing.T) {
 		t.Fatalf("expected panel proxy to be hit once, got %d (origin hits=%d)", proxyHits, originHits)
 	}
 }
-
-func TestPanelProxy_CustomGeoDownloadUsesProxy(t *testing.T) {
-	disableSSRFCheck(t)
-
-	var proxyHits, originHits int64
-	proxy := recordingProxy(t, &proxyHits)
-	defer proxy.Close()
-	origin := originServer(t, &originHits)
-	defer origin.Close()
-
-	dir := t.TempDir()
-	t.Setenv("XUI_BIN_FOLDER", dir)
-	dest := filepath.Join(dir, "geosite_repro.dat")
-
-	s := CustomGeoService{getPanelProxy: func() (string, error) { return proxy.URL, nil }}
-	if _, _, err := s.downloadToPath(origin.URL, dest, ""); err != nil {
-		t.Fatalf("download failed: %v", err)
-	}
-	if _, err := os.Stat(dest); err != nil {
-		t.Fatalf("expected file to be written: %v", err)
-	}
-
-	if got := atomic.LoadInt64(&proxyHits); got != 1 {
-		t.Fatalf("custom geo download did not route through the Panel Network Proxy "+
-			"(proxy hits=%d, origin hits=%d)", got, atomic.LoadInt64(&originHits))
-	}
-}
-
-func TestPanelProxy_CustomGeoDownloadDirectWhenUnset(t *testing.T) {
-	disableSSRFCheck(t)
-
-	var proxyHits, originHits int64
-	proxy := recordingProxy(t, &proxyHits)
-	defer proxy.Close()
-	origin := originServer(t, &originHits)
-	defer origin.Close()
-
-	dir := t.TempDir()
-	t.Setenv("XUI_BIN_FOLDER", dir)
-	dest := filepath.Join(dir, "geosite_direct.dat")
-
-	s := CustomGeoService{}
-	if _, _, err := s.downloadToPath(origin.URL, dest, ""); err != nil {
-		t.Fatalf("download failed: %v", err)
-	}
-	if atomic.LoadInt64(&proxyHits) != 0 || atomic.LoadInt64(&originHits) != 1 {
-		t.Fatalf("expected direct connection (proxy=0, origin=1), got proxy=%d origin=%d",
-			atomic.LoadInt64(&proxyHits), atomic.LoadInt64(&originHits))
-	}
-}

+ 13 - 42
internal/web/translation/ar-EG.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "سيؤدي هذا إلى تحديث كافة الملفات.",
       "geofilesUpdateAll": "تحديث الكل",
       "geofileUpdatePopover": "تم تحديث ملف الجغرافيا بنجاح",
-      "customGeoTitle": "GeoSite / GeoIP مخصص",
-      "customGeoAdd": "إضافة",
-      "customGeoType": "النوع",
-      "customGeoAlias": "اسم مستعار",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "مفعّل",
-      "customGeoLastUpdated": "آخر تحديث",
-      "customGeoExtColumn": "التوجيه (ext:…)",
-      "customGeoToastUpdateAll": "تم تحديث جميع المصادر المخصصة",
-      "customGeoActions": "إجراءات",
-      "customGeoEdit": "تحرير",
-      "customGeoDelete": "حذف",
-      "customGeoDownload": "تحديث الآن",
-      "customGeoModalAdd": "إضافة geo مخصص",
-      "customGeoModalEdit": "تعديل geo مخصص",
-      "customGeoModalSave": "حفظ",
-      "customGeoDeleteConfirm": "حذف مصدر geo المخصص هذا؟",
-      "customGeoRoutingHint": "في قواعد التوجيه استخدم العمود كـ ext:file.dat:tag (استبدل tag).",
-      "customGeoInvalidId": "معرّف المورد غير صالح",
-      "customGeoAliasesError": "تعذّر تحميل أسماء geo المخصصة",
-      "customGeoValidationAlias": "الاسم المستعار: أحرف صغيرة وأرقام و - و _ فقط",
-      "customGeoValidationUrl": "يجب أن يبدأ الرابط بـ http:// أو https://",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (مخصص)",
-      "customGeoToastList": "قائمة geo المخصص",
-      "customGeoToastAdd": "إضافة geo مخصص",
-      "customGeoToastUpdate": "تحديث geo مخصص",
-      "customGeoToastDelete": "تم حذف geofile «{{ .fileName }}» المخصص",
-      "customGeoToastDownload": "تم تحديث geofile «{{ .fileName }}»",
-      "customGeoErrInvalidType": "يجب أن يكون النوع geosite أو geoip",
-      "customGeoErrAliasRequired": "الاسم المستعار مطلوب",
-      "customGeoErrAliasPattern": "الاسم المستعار يحتوي على أحرف غير مسموحة",
-      "customGeoErrAliasReserved": "هذا الاسم محجوز",
-      "customGeoErrUrlRequired": "الرابط مطلوب",
-      "customGeoErrInvalidUrl": "الرابط غير صالح",
-      "customGeoErrUrlScheme": "يجب أن يستخدم الرابط http أو https",
-      "customGeoErrUrlHost": "مضيف الرابط غير صالح",
-      "customGeoErrDuplicateAlias": "هذا الاسم مستخدم مسبقاً لهذا النوع",
-      "customGeoErrNotFound": "مصدر geo المخصص غير موجود",
-      "customGeoErrDownload": "فشل التنزيل",
-      "customGeoErrUpdateAllIncomplete": "تعذر تحديث مصدر واحد أو أكثر من مصادر geo المخصصة",
-      "customGeoEmpty": "لا توجد مصادر geo مخصصة بعد — انقر على «إضافة» لإنشاء واحد",
+      "geodataTitle": "التحديث التلقائي لـ Geodata",
+      "geodataHint": "يقوم Xray بتنزيل هذه الملفات حسب الجدول الزمني وإعادة تحميلها دون إعادة تشغيل. يجب أن تكون الروابط HTTPS. يجب أن يكون كل ملف موجوداً مسبقاً في مجلد bin حتى يتمكن Xray من تحديثه.",
+      "geodataCron": "الجدولة (cron)",
+      "geodataOutbound": "التنزيل عبر outbound (اختياري)",
+      "geodataFile": "اسم الملف",
+      "geodataAddFile": "إضافة ملف",
+      "geodataSaveRestart": "حفظ وإعادة تشغيل Xray",
+      "geodataConfirmTitle": "حفظ إعدادات geodata؟",
+      "geodataConfirmContent": "سيتم تحديث قالب إعدادات Xray وإعادة تشغيل Xray.",
+      "geodataInvalidUrl": "كل ملف يحتاج إلى رابط HTTPS.",
+      "geodataInvalidFile": "يجب أن يكون اسم الملف اسماً بسيطاً مثل geosite_custom.dat (بدون مسارات).",
+      "geodataInvalidCron": "يجب أن يتكون cron من 5 حقول، مثل 0 4 * * *",
+      "geodataEmpty": "لا توجد ملفات مهيأة. في قواعد التوجيه يُشار إلى الملفات بالشكل ext:geosite_custom.dat:category.",
       "dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة",
       "logs": "السجلات",
       "config": "الإعدادات",

+ 13 - 42
internal/web/translation/en-US.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "This will update all geofiles.",
       "geofilesUpdateAll": "Update all",
       "geofileUpdatePopover": "Geofile updated successfully",
-      "customGeoTitle": "Custom GeoSite / GeoIP",
-      "customGeoAdd": "Add",
-      "customGeoType": "Type",
-      "customGeoAlias": "Alias",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "Enabled",
-      "customGeoLastUpdated": "Last updated",
-      "customGeoExtColumn": "Routing (ext:…)",
-      "customGeoToastUpdateAll": "All custom geo sources updated",
-      "customGeoActions": "Actions",
-      "customGeoEdit": "Edit",
-      "customGeoDelete": "Delete",
-      "customGeoDownload": "Update now",
-      "customGeoModalAdd": "Add custom geo",
-      "customGeoModalEdit": "Edit custom geo",
-      "customGeoModalSave": "Save",
-      "customGeoDeleteConfirm": "Delete this custom geo source?",
-      "customGeoRoutingHint": "In routing rules use the value column as ext:file.dat:tag (replace tag).",
-      "customGeoInvalidId": "Invalid resource id",
-      "customGeoAliasesError": "Failed to load custom geo aliases",
-      "customGeoValidationAlias": "Alias may only contain lowercase letters, digits, - and _",
-      "customGeoValidationUrl": "URL must start with http:// or https://",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (custom)",
-      "customGeoToastList": "Custom geo list",
-      "customGeoToastAdd": "Add custom geo",
-      "customGeoToastUpdate": "Update custom geo",
-      "customGeoToastDelete": "Custom geo file “{{ .fileName }}” deleted",
-      "customGeoToastDownload": "Geofile “{{ .fileName }}” updated",
-      "customGeoErrInvalidType": "Type must be geosite or geoip",
-      "customGeoErrAliasRequired": "Alias is required",
-      "customGeoErrAliasPattern": "Alias must match allowed characters",
-      "customGeoErrAliasReserved": "This alias is reserved",
-      "customGeoErrUrlRequired": "URL is required",
-      "customGeoErrInvalidUrl": "URL is invalid",
-      "customGeoErrUrlScheme": "URL must use http or https",
-      "customGeoErrUrlHost": "URL host is invalid",
-      "customGeoErrDuplicateAlias": "This alias is already used for this type",
-      "customGeoErrNotFound": "Custom geo source not found",
-      "customGeoErrDownload": "Download failed",
-      "customGeoErrUpdateAllIncomplete": "One or more custom geo sources failed to update",
-      "customGeoEmpty": "No custom geo sources yet — click Add to create one",
+      "geodataTitle": "Geodata Auto-Update",
+      "geodataHint": "Xray downloads these files on schedule and hot-reloads them without a restart. URLs must be HTTPS. Each file must already exist in the bin folder once before Xray can update it.",
+      "geodataCron": "Schedule (cron)",
+      "geodataOutbound": "Download through outbound (optional)",
+      "geodataFile": "File name",
+      "geodataAddFile": "Add file",
+      "geodataSaveRestart": "Save & Restart Xray",
+      "geodataConfirmTitle": "Save geodata settings?",
+      "geodataConfirmContent": "This updates the Xray config template and restarts Xray.",
+      "geodataInvalidUrl": "Each file needs an HTTPS URL.",
+      "geodataInvalidFile": "File names must be plain names like geosite_custom.dat (no paths).",
+      "geodataInvalidCron": "Cron must have 5 fields, e.g. 0 4 * * *",
+      "geodataEmpty": "No files configured. Reference files in routing rules as ext:geosite_custom.dat:category.",
       "dontRefresh": "Installation is in progress, please do not refresh this page",
       "logs": "Logs",
       "config": "Config",

+ 13 - 42
internal/web/translation/es-ES.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "Esto actualizará todos los archivos.",
       "geofilesUpdateAll": "Actualizar todo",
       "geofileUpdatePopover": "Geofichero actualizado correctamente",
-      "customGeoTitle": "GeoSite / GeoIP personalizados",
-      "customGeoAdd": "Añadir",
-      "customGeoType": "Tipo",
-      "customGeoAlias": "Alias",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "Activado",
-      "customGeoLastUpdated": "Última actualización",
-      "customGeoExtColumn": "Enrutamiento (ext:…)",
-      "customGeoToastUpdateAll": "Todas las fuentes personalizadas se actualizaron",
-      "customGeoActions": "Acciones",
-      "customGeoEdit": "Editar",
-      "customGeoDelete": "Eliminar",
-      "customGeoDownload": "Actualizar ahora",
-      "customGeoModalAdd": "Añadir geo personalizado",
-      "customGeoModalEdit": "Editar geo personalizado",
-      "customGeoModalSave": "Guardar",
-      "customGeoDeleteConfirm": "¿Eliminar esta fuente geo personalizada?",
-      "customGeoRoutingHint": "En reglas de enrutamiento use la columna de valor como ext:archivo.dat:etiqueta (sustituya la etiqueta).",
-      "customGeoInvalidId": "Id de recurso no válido",
-      "customGeoAliasesError": "No se pudieron cargar los alias geo personalizados",
-      "customGeoValidationAlias": "El alias solo puede contener letras minúsculas, dígitos, - y _",
-      "customGeoValidationUrl": "La URL debe comenzar con http:// o https://",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (personalizado)",
-      "customGeoToastList": "Lista de geo personalizado",
-      "customGeoToastAdd": "Añadir geo personalizado",
-      "customGeoToastUpdate": "Actualizar geo personalizado",
-      "customGeoToastDelete": "Geofile personalizado «{{ .fileName }}» eliminado",
-      "customGeoToastDownload": "Geofile «{{ .fileName }}» actualizado",
-      "customGeoErrInvalidType": "El tipo debe ser geosite o geoip",
-      "customGeoErrAliasRequired": "El alias es obligatorio",
-      "customGeoErrAliasPattern": "El alias contiene caracteres no permitidos",
-      "customGeoErrAliasReserved": "Este alias está reservado",
-      "customGeoErrUrlRequired": "La URL es obligatoria",
-      "customGeoErrInvalidUrl": "La URL no es válida",
-      "customGeoErrUrlScheme": "La URL debe usar http o https",
-      "customGeoErrUrlHost": "El host de la URL no es válido",
-      "customGeoErrDuplicateAlias": "Este alias ya se usa para este tipo",
-      "customGeoErrNotFound": "Fuente geo personalizada no encontrada",
-      "customGeoErrDownload": "Error de descarga",
-      "customGeoErrUpdateAllIncomplete": "No se pudieron actualizar una o más fuentes geo personalizadas",
-      "customGeoEmpty": "Aún no hay fuentes geo personalizadas — haz clic en Añadir para crear una",
+      "geodataTitle": "Actualización automática de Geodata",
+      "geodataHint": "Xray descarga estos archivos según la programación y los recarga en caliente sin reiniciar. Las URL deben ser HTTPS. Cada archivo debe existir previamente en la carpeta bin para que Xray pueda actualizarlo.",
+      "geodataCron": "Programación (cron)",
+      "geodataOutbound": "Descargar a través de outbound (opcional)",
+      "geodataFile": "Nombre de archivo",
+      "geodataAddFile": "Añadir archivo",
+      "geodataSaveRestart": "Guardar y reiniciar Xray",
+      "geodataConfirmTitle": "¿Guardar la configuración de geodata?",
+      "geodataConfirmContent": "Se actualizará la plantilla de configuración de Xray y se reiniciará Xray.",
+      "geodataInvalidUrl": "Cada archivo necesita una URL HTTPS.",
+      "geodataInvalidFile": "El nombre de archivo debe ser simple, p. ej. geosite_custom.dat (sin rutas).",
+      "geodataInvalidCron": "Cron debe tener 5 campos, p. ej. 0 4 * * *",
+      "geodataEmpty": "No hay archivos configurados. En las reglas de enrutamiento se referencian como ext:geosite_custom.dat:category.",
       "dontRefresh": "La instalación está en progreso, por favor no actualices esta página.",
       "logs": "Registros",
       "config": "Configuración",

+ 13 - 42
internal/web/translation/fa-IR.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "با این کار همه فایل‌ها به‌روزرسانی می‌شوند.",
       "geofilesUpdateAll": "همه را به‌روزرسانی کنید",
       "geofileUpdatePopover": "فایل جغرافیایی با موفقیت به‌روز شد",
-      "customGeoTitle": "GeoSite / GeoIP سفارشی",
-      "customGeoAdd": "افزودن",
-      "customGeoType": "نوع",
-      "customGeoAlias": "نام مستعار",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "فعال",
-      "customGeoLastUpdated": "آخرین به‌روزرسانی",
-      "customGeoExtColumn": "مسیریابی (ext:…)",
-      "customGeoToastUpdateAll": "همه منابع سفارشی به‌روزرسانی شدند",
-      "customGeoActions": "اقدامات",
-      "customGeoEdit": "ویرایش",
-      "customGeoDelete": "حذف",
-      "customGeoDownload": "به‌روزرسانی اکنون",
-      "customGeoModalAdd": "افزودن geo سفارشی",
-      "customGeoModalEdit": "ویرایش geo سفارشی",
-      "customGeoModalSave": "ذخیره",
-      "customGeoDeleteConfirm": "این منبع geo سفارشی حذف شود؟",
-      "customGeoRoutingHint": "در قوانین مسیریابی مقدار را به صورت ext:file.dat:tag استفاده کنید (tag را جایگزین کنید).",
-      "customGeoInvalidId": "شناسه منبع نامعتبر است",
-      "customGeoAliasesError": "بارگذاری نام مستعارهای geo سفارشی ناموفق بود",
-      "customGeoValidationAlias": "نام مستعار فقط حروف کوچک، اعداد، - و _",
-      "customGeoValidationUrl": "URL باید با http:// یا https:// شروع شود",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (سفارشی)",
-      "customGeoToastList": "فهرست geo سفارشی",
-      "customGeoToastAdd": "افزودن geo سفارشی",
-      "customGeoToastUpdate": "به‌روزرسانی geo سفارشی",
-      "customGeoToastDelete": "geofile سفارشی «{{ .fileName }}» حذف شد",
-      "customGeoToastDownload": "geofile «{{ .fileName }}» به‌روزرسانی شد",
-      "customGeoErrInvalidType": "نوع باید geosite یا geoip باشد",
-      "customGeoErrAliasRequired": "نام مستعار لازم است",
-      "customGeoErrAliasPattern": "نام مستعار دارای نویسه نامجاز است",
-      "customGeoErrAliasReserved": "این نام مستعار رزرو است",
-      "customGeoErrUrlRequired": "URL لازم است",
-      "customGeoErrInvalidUrl": "URL نامعتبر است",
-      "customGeoErrUrlScheme": "URL باید http یا https باشد",
-      "customGeoErrUrlHost": "میزبان URL نامعتبر است",
-      "customGeoErrDuplicateAlias": "این نام مستعار برای این نوع قبلاً استفاده شده است",
-      "customGeoErrNotFound": "منبع geo سفارشی یافت نشد",
-      "customGeoErrDownload": "بارگیری ناموفق بود",
-      "customGeoErrUpdateAllIncomplete": "به‌روزرسانی یک یا چند منبع geo سفارشی ناموفق بود",
-      "customGeoEmpty": "هنوز منبع geo سفارشی‌ای ثبت نشده — برای ایجاد روی «افزودن» کلیک کنید",
+      "geodataTitle": "به‌روزرسانی خودکار Geodata",
+      "geodataHint": "Xray این فایل‌ها را طبق زمان‌بندی دانلود کرده و بدون ری‌استارت بارگذاری مجدد می‌کند. آدرس‌ها باید HTTPS باشند. هر فایل باید از قبل در پوشه bin موجود باشد تا Xray بتواند آن را به‌روزرسانی کند.",
+      "geodataCron": "زمان‌بندی (cron)",
+      "geodataOutbound": "دانلود از طریق خروجی (اختیاری)",
+      "geodataFile": "نام فایل",
+      "geodataAddFile": "افزودن فایل",
+      "geodataSaveRestart": "ذخیره و ری‌استارت Xray",
+      "geodataConfirmTitle": "تنظیمات geodata ذخیره شود؟",
+      "geodataConfirmContent": "قالب پیکربندی Xray به‌روزرسانی و Xray ری‌استارت می‌شود.",
+      "geodataInvalidUrl": "هر فایل به یک آدرس HTTPS نیاز دارد.",
+      "geodataInvalidFile": "نام فایل باید ساده باشد، مانند geosite_custom.dat (بدون مسیر).",
+      "geodataInvalidCron": "Cron باید ۵ بخش داشته باشد، مثل 0 4 * * *",
+      "geodataEmpty": "فایلی پیکربندی نشده است. در قوانین مسیریابی فایل‌ها به صورت ext:geosite_custom.dat:category استفاده می‌شوند.",
       "dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید",
       "logs": "لاگ‌ها",
       "config": "پیکربندی",

+ 13 - 42
internal/web/translation/id-ID.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "Ini akan memperbarui semua berkas.",
       "geofilesUpdateAll": "Perbarui semua",
       "geofileUpdatePopover": "Geofile berhasil diperbarui",
-      "customGeoTitle": "GeoSite / GeoIP kustom",
-      "customGeoAdd": "Tambah",
-      "customGeoType": "Jenis",
-      "customGeoAlias": "Alias",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "Aktif",
-      "customGeoLastUpdated": "Terakhir diperbarui",
-      "customGeoExtColumn": "Routing (ext:…)",
-      "customGeoToastUpdateAll": "Semua sumber kustom telah diperbarui",
-      "customGeoActions": "Aksi",
-      "customGeoEdit": "Edit",
-      "customGeoDelete": "Hapus",
-      "customGeoDownload": "Perbarui sekarang",
-      "customGeoModalAdd": "Tambah geo kustom",
-      "customGeoModalEdit": "Edit geo kustom",
-      "customGeoModalSave": "Simpan",
-      "customGeoDeleteConfirm": "Hapus sumber geo kustom ini?",
-      "customGeoRoutingHint": "Pada aturan routing gunakan kolom nilai sebagai ext:file.dat:tag (ganti tag).",
-      "customGeoInvalidId": "ID sumber tidak valid",
-      "customGeoAliasesError": "Gagal memuat alias geo kustom",
-      "customGeoValidationAlias": "Alias hanya huruf kecil, angka, - dan _",
-      "customGeoValidationUrl": "URL harus diawali http:// atau https://",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (kustom)",
-      "customGeoToastList": "Daftar geo kustom",
-      "customGeoToastAdd": "Tambah geo kustom",
-      "customGeoToastUpdate": "Perbarui geo kustom",
-      "customGeoToastDelete": "Geofile kustom “{{ .fileName }}” dihapus",
-      "customGeoToastDownload": "Geofile “{{ .fileName }}” diperbarui",
-      "customGeoErrInvalidType": "Jenis harus geosite atau geoip",
-      "customGeoErrAliasRequired": "Alias wajib diisi",
-      "customGeoErrAliasPattern": "Alias berisi karakter yang tidak diizinkan",
-      "customGeoErrAliasReserved": "Alias ini dicadangkan",
-      "customGeoErrUrlRequired": "URL wajib diisi",
-      "customGeoErrInvalidUrl": "URL tidak valid",
-      "customGeoErrUrlScheme": "URL harus memakai http atau https",
-      "customGeoErrUrlHost": "Host URL tidak valid",
-      "customGeoErrDuplicateAlias": "Alias ini sudah dipakai untuk jenis ini",
-      "customGeoErrNotFound": "Sumber geo kustom tidak ditemukan",
-      "customGeoErrDownload": "Unduh gagal",
-      "customGeoErrUpdateAllIncomplete": "Satu atau lebih sumber geo kustom gagal diperbarui",
-      "customGeoEmpty": "Belum ada sumber geo kustom — klik Tambah untuk membuatnya",
+      "geodataTitle": "Pembaruan Otomatis Geodata",
+      "geodataHint": "Xray mengunduh berkas ini sesuai jadwal dan memuat ulang tanpa restart. URL harus HTTPS. Setiap berkas harus sudah ada di folder bin agar Xray dapat memperbaruinya.",
+      "geodataCron": "Jadwal (cron)",
+      "geodataOutbound": "Unduh melalui outbound (opsional)",
+      "geodataFile": "Nama berkas",
+      "geodataAddFile": "Tambah berkas",
+      "geodataSaveRestart": "Simpan & Mulai Ulang Xray",
+      "geodataConfirmTitle": "Simpan pengaturan geodata?",
+      "geodataConfirmContent": "Templat konfigurasi Xray akan diperbarui dan Xray akan dimulai ulang.",
+      "geodataInvalidUrl": "Setiap berkas memerlukan URL HTTPS.",
+      "geodataInvalidFile": "Nama berkas harus berupa nama sederhana, mis. geosite_custom.dat (tanpa path).",
+      "geodataInvalidCron": "Cron harus terdiri dari 5 bagian, mis. 0 4 * * *",
+      "geodataEmpty": "Belum ada berkas yang dikonfigurasi. Pada aturan routing, rujuk berkas sebagai ext:geosite_custom.dat:category.",
       "dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini",
       "logs": "Log",
       "config": "Konfigurasi",

+ 13 - 42
internal/web/translation/ja-JP.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "これにより、すべてのファイルが更新されます。",
       "geofilesUpdateAll": "すべて更新",
       "geofileUpdatePopover": "ジオファイルの更新が成功しました",
-      "customGeoTitle": "カスタム GeoSite / GeoIP",
-      "customGeoAdd": "追加",
-      "customGeoType": "種類",
-      "customGeoAlias": "エイリアス",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "有効",
-      "customGeoLastUpdated": "最終更新",
-      "customGeoExtColumn": "ルーティング (ext:…)",
-      "customGeoToastUpdateAll": "すべてのカスタムソースを更新しました",
-      "customGeoActions": "操作",
-      "customGeoEdit": "編集",
-      "customGeoDelete": "削除",
-      "customGeoDownload": "今すぐ更新",
-      "customGeoModalAdd": "カスタム geo を追加",
-      "customGeoModalEdit": "カスタム geo を編集",
-      "customGeoModalSave": "保存",
-      "customGeoDeleteConfirm": "このカスタム geo ソースを削除しますか?",
-      "customGeoRoutingHint": "ルーティングでは値を ext:ファイル.dat:タグ(タグを置換)として使います。",
-      "customGeoInvalidId": "無効なリソース ID",
-      "customGeoAliasesError": "カスタム geo エイリアスの読み込みに失敗しました",
-      "customGeoValidationAlias": "エイリアスは小文字・数字・- と _ のみ使用できます",
-      "customGeoValidationUrl": "URL は http:// または https:// で始めてください",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": "(カスタム)",
-      "customGeoToastList": "カスタム geo 一覧",
-      "customGeoToastAdd": "カスタム geo を追加",
-      "customGeoToastUpdate": "カスタム geo を更新",
-      "customGeoToastDelete": "カスタム geofile「{{ .fileName }}」を削除しました",
-      "customGeoToastDownload": "geofile「{{ .fileName }}」を更新しました",
-      "customGeoErrInvalidType": "種類は geosite または geoip である必要があります",
-      "customGeoErrAliasRequired": "エイリアスが必要です",
-      "customGeoErrAliasPattern": "エイリアスに使用できない文字が含まれています",
-      "customGeoErrAliasReserved": "このエイリアスは予約されています",
-      "customGeoErrUrlRequired": "URL が必要です",
-      "customGeoErrInvalidUrl": "URL が無効です",
-      "customGeoErrUrlScheme": "URL は http または https を使用してください",
-      "customGeoErrUrlHost": "URL のホストが無効です",
-      "customGeoErrDuplicateAlias": "この種類ですでにこのエイリアスが使われています",
-      "customGeoErrNotFound": "カスタム geo ソースが見つかりません",
-      "customGeoErrDownload": "ダウンロードに失敗しました",
-      "customGeoErrUpdateAllIncomplete": "カスタム geo ソースの 1 件以上を更新できませんでした",
-      "customGeoEmpty": "カスタム geo ソースはまだありません — 「追加」をクリックして作成してください",
+      "geodataTitle": "Geodata 自動更新",
+      "geodataHint": "Xray はスケジュールに従ってこれらのファイルをダウンロードし、再起動なしでホットリロードします。URL は HTTPS が必須です。各ファイルは事前に bin フォルダーに存在している必要があります。",
+      "geodataCron": "スケジュール (cron)",
+      "geodataOutbound": "アウトバウンド経由でダウンロード(任意)",
+      "geodataFile": "ファイル名",
+      "geodataAddFile": "ファイルを追加",
+      "geodataSaveRestart": "保存して Xray を再起動",
+      "geodataConfirmTitle": "geodata 設定を保存しますか?",
+      "geodataConfirmContent": "Xray 設定テンプレートを更新し、Xray を再起動します。",
+      "geodataInvalidUrl": "各ファイルには HTTPS URL が必要です。",
+      "geodataInvalidFile": "ファイル名はパスを含まない単純な名前にしてください(例: geosite_custom.dat)。",
+      "geodataInvalidCron": "Cron は 5 フィールド必要です(例: 0 4 * * *)",
+      "geodataEmpty": "ファイルが設定されていません。ルーティングルールでは ext:geosite_custom.dat:category の形式で参照します。",
       "dontRefresh": "インストール中、このページをリロードしないでください",
       "logs": "ログ",
       "config": "設定",

+ 13 - 42
internal/web/translation/pt-BR.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "Isso atualizará todos os arquivos.",
       "geofilesUpdateAll": "Atualizar tudo",
       "geofileUpdatePopover": "Geofile atualizado com sucesso",
-      "customGeoTitle": "GeoSite / GeoIP personalizados",
-      "customGeoAdd": "Adicionar",
-      "customGeoType": "Tipo",
-      "customGeoAlias": "Alias",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "Ativado",
-      "customGeoLastUpdated": "Última atualização",
-      "customGeoExtColumn": "Roteamento (ext:…)",
-      "customGeoToastUpdateAll": "Todas as fontes personalizadas foram atualizadas",
-      "customGeoActions": "Ações",
-      "customGeoEdit": "Editar",
-      "customGeoDelete": "Excluir",
-      "customGeoDownload": "Atualizar agora",
-      "customGeoModalAdd": "Adicionar geo personalizado",
-      "customGeoModalEdit": "Editar geo personalizado",
-      "customGeoModalSave": "Salvar",
-      "customGeoDeleteConfirm": "Excluir esta fonte geo personalizada?",
-      "customGeoRoutingHint": "Nas regras de roteamento use a coluna de valor como ext:arquivo.dat:tag (substitua a tag).",
-      "customGeoInvalidId": "ID de recurso inválido",
-      "customGeoAliasesError": "Falha ao carregar aliases geo personalizados",
-      "customGeoValidationAlias": "O alias só pode conter letras minúsculas, dígitos, - e _",
-      "customGeoValidationUrl": "A URL deve começar com http:// ou https://",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (personalizado)",
-      "customGeoToastList": "Lista de geo personalizado",
-      "customGeoToastAdd": "Adicionar geo personalizado",
-      "customGeoToastUpdate": "Atualizar geo personalizado",
-      "customGeoToastDelete": "Geofile personalizado “{{ .fileName }}” excluído",
-      "customGeoToastDownload": "Geofile “{{ .fileName }}” atualizado",
-      "customGeoErrInvalidType": "O tipo deve ser geosite ou geoip",
-      "customGeoErrAliasRequired": "Alias é obrigatório",
-      "customGeoErrAliasPattern": "O alias contém caracteres não permitidos",
-      "customGeoErrAliasReserved": "Este alias é reservado",
-      "customGeoErrUrlRequired": "URL é obrigatória",
-      "customGeoErrInvalidUrl": "URL inválida",
-      "customGeoErrUrlScheme": "A URL deve usar http ou https",
-      "customGeoErrUrlHost": "Host da URL inválido",
-      "customGeoErrDuplicateAlias": "Este alias já está em uso para este tipo",
-      "customGeoErrNotFound": "Fonte geo personalizada não encontrada",
-      "customGeoErrDownload": "Falha no download",
-      "customGeoErrUpdateAllIncomplete": "Falha ao atualizar uma ou mais fontes geo personalizadas",
-      "customGeoEmpty": "Ainda não há fontes geo personalizadas — clique em Adicionar para criar uma",
+      "geodataTitle": "Atualização automática de Geodata",
+      "geodataHint": "O Xray baixa esses arquivos conforme o agendamento e os recarrega sem reiniciar. As URLs devem ser HTTPS. Cada arquivo já deve existir na pasta bin para que o Xray possa atualizá-lo.",
+      "geodataCron": "Agendamento (cron)",
+      "geodataOutbound": "Baixar através de outbound (opcional)",
+      "geodataFile": "Nome do arquivo",
+      "geodataAddFile": "Adicionar arquivo",
+      "geodataSaveRestart": "Salvar e reiniciar o Xray",
+      "geodataConfirmTitle": "Salvar configurações de geodata?",
+      "geodataConfirmContent": "O modelo de configuração do Xray será atualizado e o Xray será reiniciado.",
+      "geodataInvalidUrl": "Cada arquivo precisa de uma URL HTTPS.",
+      "geodataInvalidFile": "O nome do arquivo deve ser simples, ex.: geosite_custom.dat (sem caminhos).",
+      "geodataInvalidCron": "O cron deve ter 5 campos, ex.: 0 4 * * *",
+      "geodataEmpty": "Nenhum arquivo configurado. Nas regras de roteamento, referencie como ext:geosite_custom.dat:category.",
       "dontRefresh": "Instalação em andamento, por favor não atualize a página",
       "logs": "Logs",
       "config": "Configuração",

+ 13 - 42
internal/web/translation/ru-RU.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "Это обновит все геофайлы.",
       "geofilesUpdateAll": "Обновить все",
       "geofileUpdatePopover": "Геофайлы успешно обновлены",
-      "customGeoTitle": "Пользовательские GeoSite / GeoIP",
-      "customGeoAdd": "Добавить",
-      "customGeoType": "Тип",
-      "customGeoAlias": "Псевдоним",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "Включено",
-      "customGeoLastUpdated": "Обновлено",
-      "customGeoExtColumn": "Маршрутизация (ext:…)",
-      "customGeoToastUpdateAll": "Все пользовательские источники обновлены",
-      "customGeoActions": "Действия",
-      "customGeoEdit": "Изменить",
-      "customGeoDelete": "Удалить",
-      "customGeoDownload": "Обновить сейчас",
-      "customGeoModalAdd": "Добавить источник",
-      "customGeoModalEdit": "Изменить источник",
-      "customGeoModalSave": "Сохранить",
-      "customGeoDeleteConfirm": "Удалить этот пользовательский источник?",
-      "customGeoRoutingHint": "В правилах маршрутизации используйте значение как ext:файл.dat:тег (замените тег).",
-      "customGeoInvalidId": "Некорректный идентификатор",
-      "customGeoAliasesError": "Не удалось загрузить список пользовательских geo",
-      "customGeoValidationAlias": "Псевдоним: только a-z, цифры, - и _",
-      "customGeoValidationUrl": "URL должен начинаться с http:// или https://",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (свой)",
-      "customGeoToastList": "Список пользовательских geo",
-      "customGeoToastAdd": "Добавить пользовательский geo",
-      "customGeoToastUpdate": "Изменить пользовательский geo",
-      "customGeoToastDelete": "Пользовательский geo-файл «{{ .fileName }}» удалён",
-      "customGeoToastDownload": "Geofile «{{ .fileName }}» обновлен",
-      "customGeoErrInvalidType": "Тип должен быть geosite или geoip",
-      "customGeoErrAliasRequired": "Укажите псевдоним",
-      "customGeoErrAliasPattern": "Псевдоним содержит недопустимые символы",
-      "customGeoErrAliasReserved": "Этот псевдоним зарезервирован",
-      "customGeoErrUrlRequired": "Укажите URL",
-      "customGeoErrInvalidUrl": "Некорректный URL",
-      "customGeoErrUrlScheme": "URL должен использовать http или https",
-      "customGeoErrUrlHost": "Некорректный хост URL",
-      "customGeoErrDuplicateAlias": "Такой псевдоним уже используется для этого типа",
-      "customGeoErrNotFound": "Источник не найден",
-      "customGeoErrDownload": "Ошибка загрузки",
-      "customGeoErrUpdateAllIncomplete": "Не удалось обновить один или несколько пользовательских источников",
-      "customGeoEmpty": "Пользовательских источников geo пока нет — нажмите «Добавить», чтобы создать",
+      "geodataTitle": "Автообновление Geodata",
+      "geodataHint": "Xray скачивает эти файлы по расписанию и перезагружает их без перезапуска. URL должны быть HTTPS. Файл должен уже существовать в папке bin, прежде чем Xray сможет его обновлять.",
+      "geodataCron": "Расписание (cron)",
+      "geodataOutbound": "Скачивать через outbound (необязательно)",
+      "geodataFile": "Имя файла",
+      "geodataAddFile": "Добавить файл",
+      "geodataSaveRestart": "Сохранить и перезапустить Xray",
+      "geodataConfirmTitle": "Сохранить настройки geodata?",
+      "geodataConfirmContent": "Шаблон конфигурации Xray будет обновлён, а Xray перезапущен.",
+      "geodataInvalidUrl": "Для каждого файла нужен HTTPS URL.",
+      "geodataInvalidFile": "Имя файла должно быть простым, например geosite_custom.dat (без путей).",
+      "geodataInvalidCron": "Cron должен содержать 5 полей, напр. 0 4 * * *",
+      "geodataEmpty": "Файлы не настроены. В правилах маршрутизации файлы указываются как ext:geosite_custom.dat:category.",
       "dontRefresh": "Установка в процессе. Не обновляйте страницу",
       "logs": "Логи",
       "config": "Конфигурация",

+ 13 - 42
internal/web/translation/tr-TR.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "Bu, tüm dosyaları güncelleyecektir.",
       "geofilesUpdateAll": "Tümünü Güncelle",
       "geofileUpdatePopover": "Geofile başarıyla güncellendi",
-      "customGeoTitle": "Özel GeoSite / GeoIP",
-      "customGeoAdd": "Ekle",
-      "customGeoType": "Tür",
-      "customGeoAlias": "Takma Ad",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "Etkin",
-      "customGeoLastUpdated": "Son güncelleme",
-      "customGeoExtColumn": "Yönlendirme (ext:…)",
-      "customGeoToastUpdateAll": "Tüm özel kaynaklar güncellendi",
-      "customGeoActions": "İşlemler",
-      "customGeoEdit": "Düzenle",
-      "customGeoDelete": "Sil",
-      "customGeoDownload": "Şimdi Güncelle",
-      "customGeoModalAdd": "Özel geo ekle",
-      "customGeoModalEdit": "Özel geo düzenle",
-      "customGeoModalSave": "Kaydet",
-      "customGeoDeleteConfirm": "Bu özel geo kaynağı silinsin mi?",
-      "customGeoRoutingHint": "Yönlendirme kurallarında value sütununu ext:dosya.dat:etiket olarak kullanın (etiketi uygun şekilde değiştirin).",
-      "customGeoInvalidId": "Geçersiz kaynak kimliği",
-      "customGeoAliasesError": "Özel geo takma adları yüklenemedi",
-      "customGeoValidationAlias": "Takma ad yalnızca küçük harf, rakam, - ve _ içerebilir",
-      "customGeoValidationUrl": "URL http:// veya https:// ile başlamalıdır",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (özel)",
-      "customGeoToastList": "Özel geo listesi",
-      "customGeoToastAdd": "Özel geo ekle",
-      "customGeoToastUpdate": "Özel geo güncelle",
-      "customGeoToastDelete": "Özel geofile \"{{ .fileName }}\" silindi",
-      "customGeoToastDownload": "\"{{ .fileName }}\" geofile güncellendi",
-      "customGeoErrInvalidType": "Tür geosite veya geoip olmalıdır",
-      "customGeoErrAliasRequired": "Takma ad gerekli",
-      "customGeoErrAliasPattern": "Takma ad izin verilmeyen karakterler içeriyor",
-      "customGeoErrAliasReserved": "Bu takma ad ayrılmış",
-      "customGeoErrUrlRequired": "URL gerekli",
-      "customGeoErrInvalidUrl": "URL geçersiz",
-      "customGeoErrUrlScheme": "URL http veya https kullanmalıdır",
-      "customGeoErrUrlHost": "URL ana bilgisayarı geçersiz",
-      "customGeoErrDuplicateAlias": "Bu takma ad bu tür için zaten kullanılıyor",
-      "customGeoErrNotFound": "Özel geo kaynağı bulunamadı",
-      "customGeoErrDownload": "İndirme başarısız",
-      "customGeoErrUpdateAllIncomplete": "Bir veya daha fazla özel geo kaynağı güncellenemedi",
-      "customGeoEmpty": "Henüz özel geo kaynağı yok — oluşturmak için Ekle'ye tıklayın",
+      "geodataTitle": "Geodata Otomatik Güncelleme",
+      "geodataHint": "Xray bu dosyaları zamanlamaya göre indirir ve yeniden başlatmadan canlı yükler. URL'ler HTTPS olmalıdır. Xray'in güncelleyebilmesi için her dosyanın bin klasöründe önceden mevcut olması gerekir.",
+      "geodataCron": "Zamanlama (cron)",
+      "geodataOutbound": "Outbound üzerinden indir (isteğe bağlı)",
+      "geodataFile": "Dosya adı",
+      "geodataAddFile": "Dosya ekle",
+      "geodataSaveRestart": "Kaydet ve Xray'i Yeniden Başlat",
+      "geodataConfirmTitle": "Geodata ayarları kaydedilsin mi?",
+      "geodataConfirmContent": "Xray yapılandırma şablonu güncellenecek ve Xray yeniden başlatılacak.",
+      "geodataInvalidUrl": "Her dosya için bir HTTPS URL gerekir.",
+      "geodataInvalidFile": "Dosya adı yalnızca dosya adı olmalıdır, örn. geosite_custom.dat (yol içermemeli).",
+      "geodataInvalidCron": "Cron 5 alandan oluşmalıdır, örn. 0 4 * * *",
+      "geodataEmpty": "Yapılandırılmış dosya yok. Yönlendirme kurallarında dosyalar ext:geosite_custom.dat:category olarak kullanılır.",
       "dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin",
       "logs": "Günlükler",
       "config": "Yapılandırma",

+ 13 - 42
internal/web/translation/uk-UA.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "Це оновить усі геофайли.",
       "geofilesUpdateAll": "Оновити все",
       "geofileUpdatePopover": "Геофайл успішно оновлено",
-      "customGeoTitle": "Користувацькі GeoSite / GeoIP",
-      "customGeoAdd": "Додати",
-      "customGeoType": "Тип",
-      "customGeoAlias": "Псевдонім",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "Увімкнено",
-      "customGeoLastUpdated": "Оновлено",
-      "customGeoExtColumn": "Маршрутизація (ext:…)",
-      "customGeoToastUpdateAll": "Усі користувацькі джерела оновлено",
-      "customGeoActions": "Дії",
-      "customGeoEdit": "Змінити",
-      "customGeoDelete": "Видалити",
-      "customGeoDownload": "Оновити зараз",
-      "customGeoModalAdd": "Додати користувацький geo",
-      "customGeoModalEdit": "Змінити користувацький geo",
-      "customGeoModalSave": "Зберегти",
-      "customGeoDeleteConfirm": "Видалити це джерело geo?",
-      "customGeoRoutingHint": "У правилах маршрутизації використовуйте значення як ext:файл.dat:тег (замініть тег).",
-      "customGeoInvalidId": "Некоректний ідентифікатор ресурсу",
-      "customGeoAliasesError": "Не вдалося завантажити псевдоніми geo",
-      "customGeoValidationAlias": "Псевдонім: лише a-z, цифри, - і _",
-      "customGeoValidationUrl": "URL має починатися з http:// або https://",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (власний)",
-      "customGeoToastList": "Список користувацьких geo",
-      "customGeoToastAdd": "Додати користувацький geo",
-      "customGeoToastUpdate": "Оновити користувацький geo",
-      "customGeoToastDelete": "Користувацький geofile «{{ .fileName }}» видалено",
-      "customGeoToastDownload": "Geofile «{{ .fileName }}» оновлено",
-      "customGeoErrInvalidType": "Тип має бути geosite або geoip",
-      "customGeoErrAliasRequired": "Потрібен псевдонім",
-      "customGeoErrAliasPattern": "Псевдонім містить недопустимі символи",
-      "customGeoErrAliasReserved": "Цей псевдонім зарезервовано",
-      "customGeoErrUrlRequired": "Потрібен URL",
-      "customGeoErrInvalidUrl": "Некоректний URL",
-      "customGeoErrUrlScheme": "URL має використовувати http або https",
-      "customGeoErrUrlHost": "Некоректний хост URL",
-      "customGeoErrDuplicateAlias": "Цей псевдонім уже використовується для цього типу",
-      "customGeoErrNotFound": "Джерело geo не знайдено",
-      "customGeoErrDownload": "Помилка завантаження",
-      "customGeoErrUpdateAllIncomplete": "Не вдалося оновити один або кілька користувацьких джерел",
-      "customGeoEmpty": "Користувацьких джерел geo поки немає — натисніть «Додати», щоб створити",
+      "geodataTitle": "Автооновлення Geodata",
+      "geodataHint": "Xray завантажує ці файли за розкладом і перезавантажує їх без перезапуску. URL мають бути HTTPS. Файл має вже існувати в теці bin, щоб Xray міг його оновлювати.",
+      "geodataCron": "Розклад (cron)",
+      "geodataOutbound": "Завантажувати через outbound (необов’язково)",
+      "geodataFile": "Ім’я файлу",
+      "geodataAddFile": "Додати файл",
+      "geodataSaveRestart": "Зберегти та перезапустити Xray",
+      "geodataConfirmTitle": "Зберегти налаштування geodata?",
+      "geodataConfirmContent": "Шаблон конфігурації Xray буде оновлено, а Xray перезапущено.",
+      "geodataInvalidUrl": "Для кожного файлу потрібен HTTPS URL.",
+      "geodataInvalidFile": "Ім’я файлу має бути простим, напр. geosite_custom.dat (без шляхів).",
+      "geodataInvalidCron": "Cron має містити 5 полів, напр. 0 4 * * *",
+      "geodataEmpty": "Файли не налаштовано. У правилах маршрутизації файли вказуються як ext:geosite_custom.dat:category.",
       "dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку",
       "logs": "Логи",
       "config": "Конфігурація",

+ 13 - 42
internal/web/translation/vi-VN.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "Thao tác này sẽ cập nhật tất cả các tập tin.",
       "geofilesUpdateAll": "Cập nhật tất cả",
       "geofileUpdatePopover": "Geofile đã được cập nhật thành công",
-      "customGeoTitle": "GeoSite / GeoIP tùy chỉnh",
-      "customGeoAdd": "Thêm",
-      "customGeoType": "Loại",
-      "customGeoAlias": "Bí danh",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "Bật",
-      "customGeoLastUpdated": "Cập nhật lần cuối",
-      "customGeoExtColumn": "Định tuyến (ext:…)",
-      "customGeoToastUpdateAll": "Đã cập nhật tất cả nguồn tùy chỉnh",
-      "customGeoActions": "Thao tác",
-      "customGeoEdit": "Sửa",
-      "customGeoDelete": "Xóa",
-      "customGeoDownload": "Cập nhật ngay",
-      "customGeoModalAdd": "Thêm geo tùy chỉnh",
-      "customGeoModalEdit": "Sửa geo tùy chỉnh",
-      "customGeoModalSave": "Lưu",
-      "customGeoDeleteConfirm": "Xóa nguồn geo tùy chỉnh này?",
-      "customGeoRoutingHint": "Trong quy tắc định tuyến dùng cột giá trị dạng ext:file.dat:tag (thay tag).",
-      "customGeoInvalidId": "ID tài nguyên không hợp lệ",
-      "customGeoAliasesError": "Không tải được bí danh geo tùy chỉnh",
-      "customGeoValidationAlias": "Bí danh chỉ gồm chữ thường, số, - và _",
-      "customGeoValidationUrl": "URL phải bắt đầu bằng http:// hoặc https://",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": " (tùy chỉnh)",
-      "customGeoToastList": "Danh sách geo tùy chỉnh",
-      "customGeoToastAdd": "Thêm geo tùy chỉnh",
-      "customGeoToastUpdate": "Cập nhật geo tùy chỉnh",
-      "customGeoToastDelete": "Đã xóa geofile tùy chỉnh “{{ .fileName }}”",
-      "customGeoToastDownload": "Đã cập nhật geofile “{{ .fileName }}”",
-      "customGeoErrInvalidType": "Loại phải là geosite hoặc geoip",
-      "customGeoErrAliasRequired": "Cần bí danh",
-      "customGeoErrAliasPattern": "Bí danh có ký tự không hợp lệ",
-      "customGeoErrAliasReserved": "Bí danh này được dành riêng",
-      "customGeoErrUrlRequired": "Cần URL",
-      "customGeoErrInvalidUrl": "URL không hợp lệ",
-      "customGeoErrUrlScheme": "URL phải dùng http hoặc https",
-      "customGeoErrUrlHost": "Máy chủ URL không hợp lệ",
-      "customGeoErrDuplicateAlias": "Bí danh này đã dùng cho loại này",
-      "customGeoErrNotFound": "Không tìm thấy nguồn geo tùy chỉnh",
-      "customGeoErrDownload": "Tải xuống thất bại",
-      "customGeoErrUpdateAllIncomplete": "Một hoặc nhiều nguồn geo tùy chỉnh không cập nhật được",
-      "customGeoEmpty": "Chưa có nguồn geo tùy chỉnh nào — nhấp Thêm để tạo",
+      "geodataTitle": "Tự động cập nhật Geodata",
+      "geodataHint": "Xray tải các tệp này theo lịch và nạp lại nóng mà không cần khởi động lại. URL phải là HTTPS. Mỗi tệp phải tồn tại sẵn trong thư mục bin thì Xray mới có thể cập nhật.",
+      "geodataCron": "Lịch cập nhật (cron)",
+      "geodataOutbound": "Tải qua outbound (tùy chọn)",
+      "geodataFile": "Tên tệp",
+      "geodataAddFile": "Thêm tệp",
+      "geodataSaveRestart": "Lưu và khởi động lại Xray",
+      "geodataConfirmTitle": "Lưu cài đặt geodata?",
+      "geodataConfirmContent": "Mẫu cấu hình Xray sẽ được cập nhật và Xray sẽ khởi động lại.",
+      "geodataInvalidUrl": "Mỗi tệp cần một URL HTTPS.",
+      "geodataInvalidFile": "Tên tệp phải là tên đơn giản, ví dụ geosite_custom.dat (không chứa đường dẫn).",
+      "geodataInvalidCron": "Cron phải có 5 trường, ví dụ 0 4 * * *",
+      "geodataEmpty": "Chưa cấu hình tệp nào. Trong quy tắc định tuyến, tham chiếu tệp dạng ext:geosite_custom.dat:category.",
       "dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.",
       "logs": "Nhật ký",
       "config": "Cấu hình",

+ 13 - 42
internal/web/translation/zh-CN.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "这将更新所有文件。",
       "geofilesUpdateAll": "全部更新",
       "geofileUpdatePopover": "地理文件更新成功",
-      "customGeoTitle": "自定义 GeoSite / GeoIP",
-      "customGeoAdd": "添加",
-      "customGeoType": "类型",
-      "customGeoAlias": "别名",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "启用",
-      "customGeoLastUpdated": "上次更新",
-      "customGeoExtColumn": "路由 (ext:…)",
-      "customGeoToastUpdateAll": "所有自定义来源已更新",
-      "customGeoActions": "操作",
-      "customGeoEdit": "编辑",
-      "customGeoDelete": "删除",
-      "customGeoDownload": "立即更新",
-      "customGeoModalAdd": "添加自定义 geo",
-      "customGeoModalEdit": "编辑自定义 geo",
-      "customGeoModalSave": "保存",
-      "customGeoDeleteConfirm": "删除此自定义 geo 源?",
-      "customGeoRoutingHint": "在路由规则中将值列写为 ext:文件.dat:标签(替换标签)。",
-      "customGeoInvalidId": "无效的资源 ID",
-      "customGeoAliasesError": "加载自定义 geo 别名失败",
-      "customGeoValidationAlias": "别名只能包含小写字母、数字、- 和 _",
-      "customGeoValidationUrl": "URL 必须以 http:// 或 https:// 开头",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": "(自定义)",
-      "customGeoToastList": "自定义 geo 列表",
-      "customGeoToastAdd": "添加自定义 geo",
-      "customGeoToastUpdate": "更新自定义 geo",
-      "customGeoToastDelete": "自定义 geofile「{{ .fileName }}」已删除",
-      "customGeoToastDownload": "geofile「{{ .fileName }}」已更新",
-      "customGeoErrInvalidType": "类型必须是 geosite 或 geoip",
-      "customGeoErrAliasRequired": "请填写别名",
-      "customGeoErrAliasPattern": "别名包含不允许的字符",
-      "customGeoErrAliasReserved": "该别名已保留",
-      "customGeoErrUrlRequired": "请填写 URL",
-      "customGeoErrInvalidUrl": "URL 无效",
-      "customGeoErrUrlScheme": "URL 必须使用 http 或 https",
-      "customGeoErrUrlHost": "URL 主机无效",
-      "customGeoErrDuplicateAlias": "此类型下已使用该别名",
-      "customGeoErrNotFound": "未找到自定义 geo 源",
-      "customGeoErrDownload": "下载失败",
-      "customGeoErrUpdateAllIncomplete": "有一个或多个自定义 geo 源更新失败",
-      "customGeoEmpty": "暂无自定义 geo 源 — 点击「添加」以创建",
+      "geodataTitle": "Geodata 自动更新",
+      "geodataHint": "Xray 会按计划下载这些文件并热重载,无需重启。URL 必须为 HTTPS。文件必须已存在于 bin 目录中,Xray 才能对其更新。",
+      "geodataCron": "更新计划 (cron)",
+      "geodataOutbound": "通过出站下载(可选)",
+      "geodataFile": "文件名",
+      "geodataAddFile": "添加文件",
+      "geodataSaveRestart": "保存并重启 Xray",
+      "geodataConfirmTitle": "保存 geodata 设置?",
+      "geodataConfirmContent": "将更新 Xray 配置模板并重启 Xray。",
+      "geodataInvalidUrl": "每个文件都需要 HTTPS 地址。",
+      "geodataInvalidFile": "文件名必须是纯文件名,例如 geosite_custom.dat(不能包含路径)。",
+      "geodataInvalidCron": "Cron 必须为 5 段,例如 0 4 * * *",
+      "geodataEmpty": "尚未配置文件。路由规则中可通过 ext:geosite_custom.dat:category 引用文件。",
       "dontRefresh": "安装中,请勿刷新此页面",
       "logs": "日志",
       "config": "配置",

+ 13 - 42
internal/web/translation/zh-TW.json

@@ -219,48 +219,19 @@
       "geofilesUpdateDialogDesc": "這將更新所有文件。",
       "geofilesUpdateAll": "全部更新",
       "geofileUpdatePopover": "地理檔案更新成功",
-      "customGeoTitle": "自訂 GeoSite / GeoIP",
-      "customGeoAdd": "新增",
-      "customGeoType": "類型",
-      "customGeoAlias": "別名",
-      "customGeoUrl": "URL",
-      "customGeoEnabled": "啟用",
-      "customGeoLastUpdated": "上次更新",
-      "customGeoExtColumn": "路由 (ext:…)",
-      "customGeoToastUpdateAll": "所有自訂來源已更新",
-      "customGeoActions": "操作",
-      "customGeoEdit": "編輯",
-      "customGeoDelete": "刪除",
-      "customGeoDownload": "立即更新",
-      "customGeoModalAdd": "新增自訂 geo",
-      "customGeoModalEdit": "編輯自訂 geo",
-      "customGeoModalSave": "儲存",
-      "customGeoDeleteConfirm": "刪除此自訂 geo 來源?",
-      "customGeoRoutingHint": "在路由規則中將值欄寫為 ext:檔案.dat:標籤(替換標籤)。",
-      "customGeoInvalidId": "無效的資源 ID",
-      "customGeoAliasesError": "載入自訂 geo 別名失敗",
-      "customGeoValidationAlias": "別名只能包含小寫字母、數字、- 和 _",
-      "customGeoValidationUrl": "URL 必須以 http:// 或 https:// 開頭",
-      "customGeoAliasPlaceholder": "a-z 0-9 _ -",
-      "customGeoAliasLabelSuffix": "(自訂)",
-      "customGeoToastList": "自訂 geo 清單",
-      "customGeoToastAdd": "新增自訂 geo",
-      "customGeoToastUpdate": "更新自訂 geo",
-      "customGeoToastDelete": "自訂 geofile「{{ .fileName }}」已刪除",
-      "customGeoToastDownload": "geofile「{{ .fileName }}」已更新",
-      "customGeoErrInvalidType": "類型必須是 geosite 或 geoip",
-      "customGeoErrAliasRequired": "請填寫別名",
-      "customGeoErrAliasPattern": "別名包含不允許的字元",
-      "customGeoErrAliasReserved": "此別名已保留",
-      "customGeoErrUrlRequired": "請填寫 URL",
-      "customGeoErrInvalidUrl": "URL 無效",
-      "customGeoErrUrlScheme": "URL 必須使用 http 或 https",
-      "customGeoErrUrlHost": "URL 主機無效",
-      "customGeoErrDuplicateAlias": "此類型已使用該別名",
-      "customGeoErrNotFound": "找不到自訂 geo 來源",
-      "customGeoErrDownload": "下載失敗",
-      "customGeoErrUpdateAllIncomplete": "有一個或多個自訂 geo 來源更新失敗",
-      "customGeoEmpty": "尚無自訂 geo 來源 — 點擊「新增」以建立",
+      "geodataTitle": "Geodata 自動更新",
+      "geodataHint": "Xray 會按排程下載這些檔案並熱重載,無需重啟。URL 必須為 HTTPS。檔案必須已存在於 bin 目錄中,Xray 才能對其更新。",
+      "geodataCron": "更新排程 (cron)",
+      "geodataOutbound": "透過出站下載(可選)",
+      "geodataFile": "檔案名稱",
+      "geodataAddFile": "新增檔案",
+      "geodataSaveRestart": "儲存並重啟 Xray",
+      "geodataConfirmTitle": "儲存 geodata 設定?",
+      "geodataConfirmContent": "將更新 Xray 設定範本並重啟 Xray。",
+      "geodataInvalidUrl": "每個檔案都需要 HTTPS 位址。",
+      "geodataInvalidFile": "檔案名稱必須是純檔名,例如 geosite_custom.dat(不能包含路徑)。",
+      "geodataInvalidCron": "Cron 必須為 5 段,例如 0 4 * * *",
+      "geodataEmpty": "尚未設定檔案。路由規則中可透過 ext:geosite_custom.dat:category 引用檔案。",
       "dontRefresh": "安裝中,請勿重新整理此頁面",
       "logs": "記錄",
       "config": "配置",

+ 4 - 9
internal/web/web.go

@@ -26,7 +26,6 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/web/network"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service"
-	"github.com/mhsanaei/3x-ui/v3/internal/web/service/integration"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/panel"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/service/tgbot"
 	"github.com/mhsanaei/3x-ui/v3/internal/web/websocket"
@@ -107,10 +106,9 @@ type Server struct {
 	api   *controller.APIController
 	ws    *controller.WebSocketController
 
-	xrayService      service.XrayService
-	settingService   service.SettingService
-	tgbotService     tgbot.Tgbot
-	customGeoService *integration.CustomGeoService
+	xrayService    service.XrayService
+	settingService service.SettingService
+	tgbotService   tgbot.Tgbot
 
 	wsHub *websocket.Hub
 
@@ -229,7 +227,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	s.index = controller.NewIndexController(g)
 	s.panel = controller.NewXUIController(g)
 	g.GET("/panel/api/openapi.json", controller.ServeOpenAPISpec)
-	s.api = controller.NewAPIController(g, s.customGeoService)
+	s.api = controller.NewAPIController(g)
 
 	// Initialize WebSocket hub
 	s.wsHub = websocket.NewHub()
@@ -257,7 +255,6 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 // startTask schedules background jobs (Xray checks, traffic jobs, cron
 // jobs) which the panel relies on for periodic maintenance and monitoring.
 func (s *Server) startTask(restartXray bool) {
-	s.customGeoService.EnsureOnStartup()
 	if restartXray {
 		err := s.xrayService.RestartXray(true)
 		if err != nil {
@@ -389,8 +386,6 @@ func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
 		SetNeedRestart: func() { s.xrayService.SetToNeedRestart() },
 	}))
 
-	s.customGeoService = integration.NewCustomGeoService()
-
 	engine, err := s.initRouter()
 	if err != nil {
 		return err

+ 4 - 0
internal/xray/config.go

@@ -23,6 +23,7 @@ type Config struct {
 	Observatory      json_util.RawMessage `json:"observatory,omitempty"`
 	BurstObservatory json_util.RawMessage `json:"burstObservatory,omitempty"`
 	Metrics          json_util.RawMessage `json:"metrics"`
+	Geodata          json_util.RawMessage `json:"geodata,omitempty"`
 }
 
 // Equals compares two Config instances for deep equality.
@@ -68,5 +69,8 @@ func (c *Config) Equals(other *Config) bool {
 	if !bytes.Equal(c.Metrics, other.Metrics) {
 		return false
 	}
+	if !bytes.Equal(c.Geodata, other.Geodata) {
+		return false
+	}
 	return true
 }

+ 0 - 1
tools/openapigen/main.go

@@ -33,7 +33,6 @@ func run(root, outDir string) error {
 				"HistoryOfSeeders",
 				"Setting",
 				"Node",
-				"CustomGeoResource",
 				"ClientReverse",
 				"Client",
 				"ClientRecord",