feat(geodat): Add file upload functionality for GeoIP/GeoSite files and update documentation

This commit is contained in:
Daniel Lavrushin 2026-03-07 21:53:34 +01:00
parent d07bca0f5a
commit 175efed2a4
No known key found for this signature in database
GPG key ID: 57F1CAB57AD35056
8 changed files with 226 additions and 44 deletions

View file

@ -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.

View file

@ -197,7 +197,6 @@ b4 --geosite /etc/b4/geosite.dat --geosite-categories youtube,netflix
- **Loyalsoldier** - универсальный
- **RUNET Freedom** - для РФ
- **Nidelon** - альтернативный для РФ
:::
### Установка конкретной версии

View file

@ -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:

View file

@ -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` и настройте категории:

View file

@ -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 {

View file

@ -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"
}
]
]

View file

@ -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

View file

@ -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<HTMLInputElement>(null);
const formatFileSize = (bytes?: number): string => {
if (!bytes) return "Unknown";
const mb = bytes / (1024 * 1024);
@ -117,15 +122,13 @@ const GeoFileCard = ({
{configPath || "Not configured"}
</Typography>
{configUrl && (
<Typography
variant="caption"
color="text.secondary"
sx={{ wordBreak: "break-all" }}
>
Source: {configUrl}
</Typography>
)}
<Typography
variant="caption"
color="text.secondary"
sx={{ wordBreak: "break-all" }}
>
Source: {configUrl || (fileInfo.exists ? "Local upload" : "Not set")}
</Typography>
{fileInfo.exists && (
<Box sx={{ display: "flex", justifyContent: "space-between" }}>
@ -169,8 +172,8 @@ const GeoFileCard = ({
/>
)}
{/* Download button */}
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
{/* Download & Upload buttons */}
<Box sx={{ display: "flex", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
<Button
variant="contained"
size="small"
@ -184,19 +187,49 @@ const GeoFileCard = ({
onClick={onDownload}
disabled={
downloading ||
uploading ||
(selectedSource === CUSTOM_SOURCE && !customURL) ||
!selectedSource
}
>
{downloading ? "Downloading..." : "Download"}
{downloading ? "Updating..." : "Update"}
</Button>
<Button
variant="outlined"
size="small"
startIcon={
uploading ? (
<CircularProgress size={16} />
) : (
<UploadIcon />
)
}
onClick={() => fileInputRef.current?.click()}
disabled={downloading || uploading}
>
{uploading ? "Uploading..." : "Upload"}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".dat,.db"
hidden
onChange={(e) => {
const file = e.target.files?.[0];
if (file) onUpload(file);
e.target.value = "";
}}
/>
{status && (
<Typography
variant="caption"
variant="body2"
sx={{
color: status.toLowerCase().includes("error")
? colors.quaternary
: colors.text.secondary,
? "#f44336"
: colors.secondary,
fontWeight: 600,
width: "100%",
mt: 0.5,
}}
>
{status}
@ -224,6 +257,7 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
const [geositeSource, setGeositeSource] = useState<string>("");
const [geositeCustomURL, setGeositeCustomURL] = useState<string>("");
const [geositeDownloading, setGeositeDownloading] = useState(false);
const [geositeUploading, setGeositeUploading] = useState(false);
const [geositeStatus, setGeositeStatus] = useState<string>("");
// GeoIP state
@ -231,6 +265,7 @@ export const GeoSettings = ({ config, loadConfig }: GeoSettingsProps) => {
const [geoipSource, setGeoipSource] = useState<string>("");
const [geoipCustomURL, setGeoipCustomURL] = useState<string>("");
const [geoipDownloading, setGeoipDownloading] = useState(false);
const [geoipUploading, setGeoipUploading] = useState(false);
const [geoipStatus, setGeoipStatus] = useState<string>("");
// 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 (
<Stack spacing={3}>
<B4Alert>
@ -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)}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
@ -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)}
/>
</Grid>
</Grid>