diff --git a/changelog.md b/changelog.md index ea15e261..6faa8b03 100644 --- a/changelog.md +++ b/changelog.md @@ -2,6 +2,7 @@ ## [1.39.3] - 2026-03-07 +- ADDED: **Upload GeoIP/GeoSite files** — you can now upload `.dat` files directly from your computer using the "Upload" button in `Settings > Geo Databases`. - IMPROVED: **Device list sorting** — devices are now sorted alphabetically by name, with selected devices always shown at the top for easier access. - FIXED: **Set Import not working on Android** — pasting a set configuration from the clipboard was not possible on Android devices. diff --git a/docs/docs/intro.md b/docs/docs/intro.md index 2421c1f0..b88b80ec 100644 --- a/docs/docs/intro.md +++ b/docs/docs/intro.md @@ -197,7 +197,6 @@ b4 --geosite /etc/b4/geosite.dat --geosite-categories youtube,netflix - **Loyalsoldier** - универсальный - **RUNET Freedom** - для РФ -- **Nidelon** - альтернативный для РФ ::: ### Установка конкретной версии diff --git a/readme.md b/readme.md index 4885bc74..521b3693 100644 --- a/readme.md +++ b/readme.md @@ -258,9 +258,6 @@ wget https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/ge # RUNET Freedom wget https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/geosite.dat - -# Nidelon -wget https://github.com/Nidelon/ru-block-v2ray-rules/releases/latest/download/geosite.dat ``` Place the file in `/etc/b4/geosite.dat` and configure categories: diff --git a/readme_ru.md b/readme_ru.md index 608059e5..0269580b 100644 --- a/readme_ru.md +++ b/readme_ru.md @@ -259,8 +259,6 @@ wget https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/ge # RUNET Freedom wget https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/geosite.dat -# Nidelon -wget https://github.com/Nidelon/ru-block-v2ray-rules/releases/latest/download/geosite.dat ``` Поместите файл в `/etc/b4/geosite.dat` и настройте категории: diff --git a/src/http/handler/geodat.go b/src/http/handler/geodat.go index 6710f756..48f24f7a 100644 --- a/src/http/handler/geodat.go +++ b/src/http/handler/geodat.go @@ -39,6 +39,7 @@ type GeodatSource struct { func (api *API) RegisterGeodatApi() { api.mux.HandleFunc("/api/geodat/download", api.handleGeodatDownload) + api.mux.HandleFunc("/api/geodat/upload", api.handleGeodatUpload) api.mux.HandleFunc("/api/geodat/sources", api.handleGeodatSources) api.mux.HandleFunc("/api/geodat/info", api.handleFileInfo) } @@ -166,6 +167,124 @@ func (api *API) handleGeodatDownload(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +func (api *API) handleGeodatUpload(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + const maxUploadSize = 500 * 1024 * 1024 // 500MB + + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + http.Error(w, "Failed to parse upload", http.StatusBadRequest) + return + } + + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, "File required", http.StatusBadRequest) + return + } + defer file.Close() + + fileType := r.FormValue("type") + if fileType != "geosite" && fileType != "geoip" { + http.Error(w, "Type must be 'geosite' or 'geoip'", http.StatusBadRequest) + return + } + + destPath := r.FormValue("destination_path") + if destPath == "" { + http.Error(w, "Destination path required", http.StatusBadRequest) + return + } + + ext := strings.ToLower(filepath.Ext(header.Filename)) + if ext != ".dat" && ext != ".db" { + http.Error(w, "Only .dat and .db files are accepted", http.StatusBadRequest) + return + } + + if err := os.MkdirAll(destPath, 0755); err != nil { + msg := fmt.Sprintf("Failed to create directory %s: %v", destPath, err) + log.Errorf("geodat upload: %s", msg) + writeJsonError(w, http.StatusInternalServerError, msg) + return + } + + destFile := filepath.Join(destPath, fileType+".dat") + + tmpFile, err := os.CreateTemp(destPath, ".geodat-upload-*.tmp") + if err != nil { + msg := fmt.Sprintf("Failed to create temp file: %v", err) + log.Errorf("geodat upload: %s", msg) + writeJsonError(w, http.StatusInternalServerError, msg) + return + } + tmpPath := tmpFile.Name() + + size, err := io.Copy(tmpFile, file) + if err != nil { + tmpFile.Close() + os.Remove(tmpPath) + msg := fmt.Sprintf("Failed to write uploaded file: %v", err) + log.Errorf("geodat upload: %s", msg) + writeJsonError(w, http.StatusInternalServerError, msg) + return + } + + if err := tmpFile.Sync(); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + msg := fmt.Sprintf("Failed to flush file to disk: %v", err) + log.Errorf("geodat upload: %s", msg) + writeJsonError(w, http.StatusInternalServerError, msg) + return + } + tmpFile.Close() + + if err := os.Rename(tmpPath, destFile); err != nil { + os.Remove(tmpPath) + msg := fmt.Sprintf("Failed to move uploaded file to %s: %v", destFile, err) + log.Errorf("geodat upload: %s", msg) + writeJsonError(w, http.StatusInternalServerError, msg) + return + } + + if fileType == "geosite" { + api.cfg.System.Geo.GeoSitePath = destFile + api.cfg.System.Geo.GeoSiteURL = "" + } else { + api.cfg.System.Geo.GeoIpPath = destFile + api.cfg.System.Geo.GeoIpURL = "" + } + + if err := api.saveAndPushConfig(api.cfg); err != nil { + msg := fmt.Sprintf("Failed to save configuration: %v", err) + log.Errorf("geodat upload: %s", msg) + writeJsonError(w, http.StatusInternalServerError, msg) + return + } + + api.geodataManager.UpdatePaths(api.cfg.System.Geo.GeoSitePath, api.cfg.System.Geo.GeoIpPath) + api.geodataManager.ClearCache() + + for _, set := range api.cfg.Sets { + log.Infof("Reloading geo targets for set: %s", set.Name) + api.loadTargetsForSetCached(set) + } + + log.Infof("Uploaded %s.dat (%d bytes) from %s", fileType, size, header.Filename) + + setJsonHeader(w) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "message": fmt.Sprintf("Uploaded %s.dat (%d bytes)", fileType, size), + "path": destFile, + "size": size, + }) +} + func checkDiskSpace(dir string, needed int64) error { var stat unix.Statfs_t if err := unix.Statfs(dir, &stat); err != nil { diff --git a/src/http/handler/geodat.json b/src/http/handler/geodat.json index ff4504f4..106642ab 100644 --- a/src/http/handler/geodat.json +++ b/src/http/handler/geodat.json @@ -9,24 +9,9 @@ "geosite_url": "https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/geosite.dat", "geoip_url": "https://raw.githubusercontent.com/runetfreedom/russia-v2ray-rules-dat/release/geoip.dat" }, - { - "name": "Nidelon", - "geosite_url": "https://github.com/Nidelon/ru-block-v2ray-rules/releases/latest/download/geosite.dat", - "geoip_url": "https://github.com/Nidelon/ru-block-v2ray-rules/releases/latest/download/geoip.dat" - }, - { - "name": "DustinWin", - "geosite_url": "https://github.com/DustinWin/ruleset_geodata/releases/download/mihomo-geodata/geosite.dat", - "geoip_url": "https://github.com/DustinWin/ruleset_geodata/releases/download/mihomo-geodata/geoip.dat" - }, - { - "name": "Chocolate4U", - "geosite_url": "https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geosite.dat", - "geoip_url": "https://raw.githubusercontent.com/Chocolate4U/Iran-v2ray-rules/release/geoip.dat" - }, { "name": "b4geoip", "geosite_url": "", "geoip_url": "https://github.com/DanielLavrushin/b4geoip/releases/latest/download/geoip.dat" } -] +] \ No newline at end of file diff --git a/src/http/ui/src/api/settings.ts b/src/http/ui/src/api/settings.ts index cc6b4a84..d58a84e3 100644 --- a/src/http/ui/src/api/settings.ts +++ b/src/http/ui/src/api/settings.ts @@ -1,4 +1,4 @@ -import { apiGet, apiPost, apiFetch } from "./apiClient"; +import { apiGet, apiPost, apiFetch, apiUpload } from "./apiClient"; import { B4Config } from "@models/config"; import { GeoFileInfo, @@ -33,6 +33,16 @@ export const geodatApi = { geoip_url: geoipUrl ?? "", destination_path: destPath, }), + upload: (file: File, type: "geosite" | "geoip", destPath: string) => { + const formData = new FormData(); + formData.append("file", file); + formData.append("type", type); + formData.append("destination_path", destPath); + return apiUpload<{ success: boolean; message: string; path: string; size: number }>( + "/api/geodat/upload", + formData, + ); + }, }; // System API diff --git a/src/http/ui/src/components/settings/Geo.tsx b/src/http/ui/src/components/settings/Geo.tsx index e02689f5..6c8409ae 100644 --- a/src/http/ui/src/components/settings/Geo.tsx +++ b/src/http/ui/src/components/settings/Geo.tsx @@ -10,9 +10,9 @@ import { Chip, Divider, } from "@mui/material"; -import { DomainIcon, DownloadIcon, SuccessIcon } from "@b4.icons"; +import { DomainIcon, DownloadIcon, SuccessIcon, UploadIcon } from "@b4.icons"; import { B4Alert, B4Section, B4TextField } from "@b4.elements"; -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { colors } from "@design"; import { geodatApi, GeodatSource, GeoFileInfo } from "@b4.settings"; @@ -30,8 +30,10 @@ interface GeoFileCardProps { customURL: string; onCustomURLChange: (value: string) => void; downloading: boolean; + uploading: boolean; status: string; onDownload: () => void; + onUpload: (file: File) => void; } const GeoFileCard = ({ @@ -46,9 +48,12 @@ const GeoFileCard = ({ customURL, onCustomURLChange, downloading, + uploading, status, onDownload, + onUpload, }: GeoFileCardProps) => { + const fileInputRef = useRef(null); const formatFileSize = (bytes?: number): string => { if (!bytes) return "Unknown"; const mb = bytes / (1024 * 1024); @@ -117,15 +122,13 @@ const GeoFileCard = ({ {configPath || "Not configured"} - {configUrl && ( - - Source: {configUrl} - - )} + + Source: {configUrl || (fileInfo.exists ? "Local upload" : "Not set")} + {fileInfo.exists && ( @@ -169,8 +172,8 @@ const GeoFileCard = ({ /> )} - {/* Download button */} - + {/* Download & Upload buttons */} + + + { + const file = e.target.files?.[0]; + if (file) onUpload(file); + e.target.value = ""; + }} + /> {status && ( {status} @@ -224,6 +257,7 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => { const [geositeSource, setGeositeSource] = useState(""); const [geositeCustomURL, setGeositeCustomURL] = useState(""); const [geositeDownloading, setGeositeDownloading] = useState(false); + const [geositeUploading, setGeositeUploading] = useState(false); const [geositeStatus, setGeositeStatus] = useState(""); // GeoIP state @@ -231,6 +265,7 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => { const [geoipSource, setGeoipSource] = useState(""); const [geoipCustomURL, setGeoipCustomURL] = useState(""); const [geoipDownloading, setGeoipDownloading] = useState(false); + const [geoipUploading, setGeoipUploading] = useState(false); const [geoipStatus, setGeoipStatus] = useState(""); // Filter sources per file type @@ -293,10 +328,10 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => { setGeositeSource(CUSTOM_SOURCE); setGeositeCustomURL(configUrl); } - } else { + } else if (!config.system.geo.sitedat_path) { setGeositeSource(geositeSources[0].name); } - }, [geositeSources, geositeSource, config.system.geo.sitedat_url]); + }, [geositeSources, geositeSource, config.system.geo.sitedat_url, config.system.geo.sitedat_path]); useEffect(() => { if (geoipSources.length === 0 || geoipSource) return; @@ -309,10 +344,10 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => { setGeoipSource(CUSTOM_SOURCE); setGeoipCustomURL(configUrl); } - } else { + } else if (!config.system.geo.ipdat_path) { setGeoipSource(geoipSources[0].name); } - }, [geoipSources, geoipSource, config.system.geo.ipdat_url]); + }, [geoipSources, geoipSource, config.system.geo.ipdat_url, config.system.geo.ipdat_path]); const extractDir = (path: string): string => { if (!path) return ""; @@ -384,6 +419,40 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => { } }; + const handleGeositeUpload = async (file: File) => { + setGeositeUploading(true); + setGeositeStatus("Uploading geosite.dat..."); + try { + await geodatApi.upload(file, "geosite", destPath); + setGeositeStatus("Uploaded successfully"); + setGeositeSource(""); + loadConfig(); + void checkFileStatus(); + setTimeout(() => setGeositeStatus(""), 5000); + } catch (error) { + setGeositeStatus(`Error: ${String(error)}`); + } finally { + setGeositeUploading(false); + } + }; + + const handleGeoipUpload = async (file: File) => { + setGeoipUploading(true); + setGeoipStatus("Uploading geoip.dat..."); + try { + await geodatApi.upload(file, "geoip", destPath); + setGeoipStatus("Uploaded successfully"); + setGeoipSource(""); + loadConfig(); + void checkFileStatus(); + setTimeout(() => setGeoipStatus(""), 5000); + } catch (error) { + setGeoipStatus(`Error: ${String(error)}`); + } finally { + setGeoipUploading(false); + } + }; + return ( @@ -422,8 +491,10 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => { customURL={geositeCustomURL} onCustomURLChange={setGeositeCustomURL} downloading={geositeDownloading} + uploading={geositeUploading} status={geositeStatus} onDownload={() => void handleGeositeDownload()} + onUpload={(file) => void handleGeositeUpload(file)} /> @@ -439,8 +510,10 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => { customURL={geoipCustomURL} onCustomURLChange={setGeoipCustomURL} downloading={geoipDownloading} + uploading={geoipUploading} status={geoipStatus} onDownload={() => void handleGeoipDownload()} + onUpload={(file) => void handleGeoipUpload(file)} />