mirror of
https://github.com/DanielLavrushin/b4.git
synced 2026-07-10 00:12:25 +00:00
feat(geodat): Add file upload functionality for GeoIP/GeoSite files and update documentation
This commit is contained in:
parent
d07bca0f5a
commit
175efed2a4
8 changed files with 226 additions and 44 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -197,7 +197,6 @@ b4 --geosite /etc/b4/geosite.dat --geosite-categories youtube,netflix
|
|||
|
||||
- **Loyalsoldier** - универсальный
|
||||
- **RUNET Freedom** - для РФ
|
||||
- **Nidelon** - альтернативный для РФ
|
||||
:::
|
||||
|
||||
### Установка конкретной версии
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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` и настройте категории:
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue