Files
GoProxy/webui/server.go
isboyjc f03c3300b4 feat: implement custom proxy subscription management and enhance configuration
- Added support for importing Clash/V2ray subscriptions, including automatic format detection and integration with sing-box for protocol conversion.
- Introduced five proxy usage modes in the configuration, allowing flexible selection between mixed, custom-only, and free-only modes.
- Enhanced `.env.example` and `docker-compose.yml` to include new environment variables for custom proxy settings.
- Updated `CHANGELOG.md` to document new features and improvements related to subscription management.
- Improved WebUI for managing subscriptions and displaying proxy statistics.
- Implemented a background process for refreshing subscriptions and probing disabled proxies for reactivation.
2026-04-04 22:25:54 +08:00

838 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package webui
import (
"crypto/sha256"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"time"
"goproxy/config"
"goproxy/custom"
"goproxy/logger"
"goproxy/pool"
"goproxy/storage"
"goproxy/validator"
)
// 简单内存 session
var (
sessions = make(map[string]time.Time)
sessionsMu sync.Mutex
)
func newSession() string {
token := fmt.Sprintf("%x", sha256.Sum256([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))))
sessionsMu.Lock()
sessions[token] = time.Now().Add(24 * time.Hour)
sessionsMu.Unlock()
return token
}
func validSession(r *http.Request) bool {
cookie, err := r.Cookie("session")
if err != nil {
return false
}
sessionsMu.Lock()
expiry, ok := sessions[cookie.Value]
sessionsMu.Unlock()
return ok && time.Now().Before(expiry)
}
type FetchTrigger func()
type Server struct {
storage *storage.Storage
cfg *config.Config
poolMgr *pool.Manager
customMgr *custom.Manager
fetchTrigger FetchTrigger
configChanged chan<- struct{}
}
func New(s *storage.Storage, cfg *config.Config, pm *pool.Manager, cm *custom.Manager, ft FetchTrigger, cc chan<- struct{}) *Server {
return &Server{
storage: s,
cfg: cfg,
poolMgr: pm,
customMgr: cm,
fetchTrigger: ft,
configChanged: cc,
}
}
func (s *Server) Start() {
mux := http.NewServeMux()
// 添加日志中间件
loggedMux := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("[webui] %s %s | Host: %s | RemoteAddr: %s",
r.Method, r.URL.Path, r.Host, r.RemoteAddr)
mux.ServeHTTP(w, r)
})
mux.HandleFunc("/", s.handleIndex)
mux.HandleFunc("/login", s.handleLogin)
mux.HandleFunc("/logout", s.handleLogout)
// 只读 API访客可访问
mux.HandleFunc("/api/stats", s.readOnlyMiddleware(s.apiStats))
mux.HandleFunc("/api/proxies", s.readOnlyMiddleware(s.apiProxies))
mux.HandleFunc("/api/logs", s.readOnlyMiddleware(s.apiLogs))
mux.HandleFunc("/api/pool/status", s.readOnlyMiddleware(s.apiPoolStatus))
mux.HandleFunc("/api/pool/quality", s.readOnlyMiddleware(s.apiQualityDistribution))
mux.HandleFunc("/api/config", s.readOnlyMiddleware(s.apiConfig))
mux.HandleFunc("/api/auth/check", s.apiAuthCheck) // 检查登录状态
// 管理员 API需要登录
mux.HandleFunc("/api/proxy/delete", s.authMiddleware(s.apiDeleteProxy))
mux.HandleFunc("/api/proxy/refresh", s.authMiddleware(s.apiRefreshProxy))
mux.HandleFunc("/api/fetch", s.authMiddleware(s.apiFetch))
mux.HandleFunc("/api/refresh-latency", s.authMiddleware(s.apiRefreshLatency))
mux.HandleFunc("/api/config/save", s.authMiddleware(s.apiConfigSave))
// 订阅管理 API
mux.HandleFunc("/api/subscriptions", s.readOnlyMiddleware(s.apiSubscriptions))
mux.HandleFunc("/api/custom/status", s.readOnlyMiddleware(s.apiCustomStatus))
mux.HandleFunc("/api/subscription/contribute", s.apiSubscriptionContribute) // 访客可用
mux.HandleFunc("/api/subscription/add", s.authMiddleware(s.apiSubscriptionAdd))
mux.HandleFunc("/api/subscription/delete", s.authMiddleware(s.apiSubscriptionDelete))
mux.HandleFunc("/api/subscription/refresh", s.authMiddleware(s.apiSubscriptionRefresh))
mux.HandleFunc("/api/subscription/refresh-all", s.authMiddleware(s.apiSubscriptionRefreshAll))
mux.HandleFunc("/api/subscription/toggle", s.authMiddleware(s.apiSubscriptionToggle))
log.Printf("WebUI listening on %s", s.cfg.WebUIPort)
go func() {
if err := http.ListenAndServe(s.cfg.WebUIPort, loggedMux); err != nil {
log.Fatalf("webui: %v", err)
}
}()
}
// authMiddleware 管理员权限中间件(必须登录)
func (s *Server) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !validSession(r) {
if len(r.URL.Path) >= 4 && r.URL.Path[:4] == "/api" {
jsonError(w, "unauthorized", http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
return
}
next(w, r)
}
}
// readOnlyMiddleware 只读中间件(访客可访问,但会标记是否为管理员)
func (s *Server) readOnlyMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// 访客和管理员都可以访问,通过 validSession 判断权限
next(w, r)
}
}
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
// 允许访客访问(只读模式),管理员登录后有完整权限
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, dashboardHTML)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, loginHTML)
return
}
password := r.FormValue("password")
hash := fmt.Sprintf("%x", sha256.Sum256([]byte(password)))
if hash != s.cfg.WebUIPasswordHash {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, loginHTMLWithError)
return
}
token := newSession()
http.SetCookie(w, &http.Cookie{
Name: "session",
Value: token,
Path: "/",
Expires: time.Now().Add(24 * time.Hour),
HttpOnly: true,
})
http.Redirect(w, r, "/", http.StatusFound)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie("session"); err == nil {
sessionsMu.Lock()
delete(sessions, cookie.Value)
sessionsMu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusFound)
}
// apiAuthCheck 检查当前用户是否为管理员
func (s *Server) apiAuthCheck(w http.ResponseWriter, r *http.Request) {
isAdmin := validSession(r)
jsonOK(w, map[string]interface{}{
"isAdmin": isAdmin,
"mode": func() string {
if isAdmin {
return "admin"
}
return "guest"
}(),
})
}
func (s *Server) apiStats(w http.ResponseWriter, r *http.Request) {
total, _ := s.storage.Count()
httpCount, _ := s.storage.CountByProtocol("http")
socks5Count, _ := s.storage.CountByProtocol("socks5")
customCount, _ := s.storage.CountBySource("custom")
jsonOK(w, map[string]interface{}{
"total": total,
"http": httpCount,
"socks5": socks5Count,
"custom_count": customCount,
"port": s.cfg.ProxyPort,
})
}
func (s *Server) apiProxies(w http.ResponseWriter, r *http.Request) {
protocol := r.URL.Query().Get("protocol")
var proxies []storage.Proxy
var err error
if protocol != "" {
proxies, err = s.storage.GetByProtocol(protocol)
} else {
proxies, err = s.storage.GetAll()
}
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, proxies)
}
func (s *Server) apiDeleteProxy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Address string `json:"address"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Address == "" {
jsonError(w, "invalid request", http.StatusBadRequest)
return
}
s.storage.Delete(req.Address)
jsonOK(w, map[string]string{"status": "deleted"})
}
func (s *Server) apiRefreshProxy(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Address string `json:"address"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Address == "" {
jsonError(w, "invalid request", http.StatusBadRequest)
return
}
// 从数据库获取代理信息
proxies, err := s.storage.GetAll()
if err != nil {
jsonError(w, "failed to get proxy", http.StatusInternalServerError)
return
}
var targetProxy *storage.Proxy
for i := range proxies {
if proxies[i].Address == req.Address {
targetProxy = &proxies[i]
break
}
}
if targetProxy == nil {
jsonError(w, "proxy not found", http.StatusNotFound)
return
}
// 异步验证并更新
go func() {
cfg := config.Get()
v := validator.New(1, cfg.ValidateTimeout, cfg.ValidateURL)
log.Printf("[webui] refreshing proxy: %s", req.Address)
valid, latency, exitIP, exitLocation := v.ValidateOne(*targetProxy)
if valid {
latencyMs := int(latency.Milliseconds())
s.storage.UpdateExitInfo(req.Address, exitIP, exitLocation, latencyMs)
log.Printf("[webui] proxy refreshed: %s latency=%dms grade=%s", req.Address, latencyMs, storage.CalculateQualityGrade(latencyMs))
} else {
if targetProxy.Source == "custom" {
s.storage.DisableProxy(req.Address)
log.Printf("[webui] custom proxy validation failed, disabled: %s", req.Address)
} else {
s.storage.Delete(req.Address)
log.Printf("[webui] proxy validation failed, removed: %s", req.Address)
}
}
}()
jsonOK(w, map[string]string{"status": "refresh started"})
}
func (s *Server) apiFetch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
go s.fetchTrigger()
jsonOK(w, map[string]string{"status": "fetch started"})
}
func (s *Server) apiRefreshLatency(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
go func() {
log.Println("[webui] refreshing latency for all proxies...")
proxies, err := s.storage.GetAll()
if err != nil {
log.Printf("[webui] get proxies error: %v", err)
return
}
if len(proxies) == 0 {
log.Println("[webui] no proxies to refresh")
return
}
cfg := config.Get()
validate := validator.New(cfg.ValidateConcurrency, cfg.ValidateTimeout, cfg.ValidateURL)
log.Printf("[webui] refreshing latency for %d proxies...", len(proxies))
updated := 0
for r := range validate.ValidateStream(proxies) {
if r.Valid {
latencyMs := int(r.Latency.Milliseconds())
s.storage.UpdateExitInfo(r.Proxy.Address, r.ExitIP, r.ExitLocation, latencyMs)
updated++
} else {
if r.Proxy.Source == "custom" {
s.storage.DisableProxy(r.Proxy.Address)
} else {
s.storage.Delete(r.Proxy.Address)
}
}
}
log.Printf("[webui] latency refresh done: updated=%d", updated)
}()
jsonOK(w, map[string]string{"status": "refresh started"})
}
func (s *Server) apiLogs(w http.ResponseWriter, r *http.Request) {
lines := logger.GetLines(100)
jsonOK(w, map[string]interface{}{"lines": lines})
}
// apiConfig 获取配置
func (s *Server) apiConfig(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
httpSlots, socks5Slots := cfg.CalculateSlots()
jsonOK(w, map[string]interface{}{
// 池子配置
"pool_max_size": cfg.PoolMaxSize,
"pool_http_ratio": cfg.PoolHTTPRatio,
"pool_min_per_protocol": cfg.PoolMinPerProtocol,
"pool_http_slots": httpSlots,
"pool_socks5_slots": socks5Slots,
// 延迟配置
"max_latency_ms": cfg.MaxLatencyMs,
"max_latency_emergency": cfg.MaxLatencyEmergency,
"max_latency_healthy": cfg.MaxLatencyHealthy,
// 验证配置
"validate_concurrency": cfg.ValidateConcurrency,
"validate_timeout": cfg.ValidateTimeout,
// 健康检查配置
"health_check_interval": cfg.HealthCheckInterval,
"health_check_batch_size": cfg.HealthCheckBatchSize,
// 优化配置
"optimize_interval": cfg.OptimizeInterval,
"replace_threshold": cfg.ReplaceThreshold,
// 地理过滤配置
"blocked_countries": cfg.BlockedCountries,
"allowed_countries": cfg.AllowedCountries,
// 自定义订阅代理配置
"custom_proxy_mode": cfg.CustomProxyMode,
"custom_priority": cfg.CustomPriority,
"custom_free_priority": cfg.CustomFreePriority,
"custom_probe_interval": cfg.CustomProbeInterval,
"custom_refresh_interval": cfg.CustomRefreshInterval,
})
}
// apiConfigSave 保存配置
func (s *Server) apiConfigSave(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
PoolMaxSize int `json:"pool_max_size"`
PoolHTTPRatio float64 `json:"pool_http_ratio"`
PoolMinPerProtocol int `json:"pool_min_per_protocol"`
MaxLatencyMs int `json:"max_latency_ms"`
MaxLatencyEmergency int `json:"max_latency_emergency"`
MaxLatencyHealthy int `json:"max_latency_healthy"`
ValidateConcurrency int `json:"validate_concurrency"`
ValidateTimeout int `json:"validate_timeout"`
HealthCheckInterval int `json:"health_check_interval"`
HealthCheckBatchSize int `json:"health_check_batch_size"`
OptimizeInterval int `json:"optimize_interval"`
ReplaceThreshold float64 `json:"replace_threshold"`
BlockedCountries []string `json:"blocked_countries"`
AllowedCountries []string `json:"allowed_countries"`
CustomProxyMode string `json:"custom_proxy_mode"`
CustomPriority *bool `json:"custom_priority"`
CustomFreePriority *bool `json:"custom_free_priority"`
CustomProbeInterval int `json:"custom_probe_interval"`
CustomRefreshInterval int `json:"custom_refresh_interval"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request", http.StatusBadRequest)
return
}
// 验证配置有效性
if req.PoolMaxSize <= 0 || req.PoolHTTPRatio <= 0 || req.PoolHTTPRatio > 1 {
jsonError(w, "invalid pool config", http.StatusBadRequest)
return
}
// 记录旧配置
oldCfg := config.Get()
oldSize := oldCfg.PoolMaxSize
oldRatio := oldCfg.PoolHTTPRatio
// 更新配置
newCfg := *oldCfg
newCfg.PoolMaxSize = req.PoolMaxSize
newCfg.PoolHTTPRatio = req.PoolHTTPRatio
newCfg.PoolMinPerProtocol = req.PoolMinPerProtocol
newCfg.MaxLatencyMs = req.MaxLatencyMs
newCfg.MaxLatencyEmergency = req.MaxLatencyEmergency
newCfg.MaxLatencyHealthy = req.MaxLatencyHealthy
newCfg.ValidateConcurrency = req.ValidateConcurrency
newCfg.ValidateTimeout = req.ValidateTimeout
newCfg.HealthCheckInterval = req.HealthCheckInterval
newCfg.HealthCheckBatchSize = req.HealthCheckBatchSize
newCfg.OptimizeInterval = req.OptimizeInterval
newCfg.ReplaceThreshold = req.ReplaceThreshold
newCfg.BlockedCountries = req.BlockedCountries
newCfg.AllowedCountries = req.AllowedCountries
if req.CustomProxyMode != "" {
newCfg.CustomProxyMode = req.CustomProxyMode
}
if req.CustomPriority != nil {
newCfg.CustomPriority = *req.CustomPriority
if *req.CustomPriority {
newCfg.CustomFreePriority = false // 互斥
}
}
if req.CustomFreePriority != nil {
newCfg.CustomFreePriority = *req.CustomFreePriority
if *req.CustomFreePriority {
newCfg.CustomPriority = false // 互斥
}
}
if req.CustomProbeInterval > 0 {
newCfg.CustomProbeInterval = req.CustomProbeInterval
}
if req.CustomRefreshInterval > 0 {
newCfg.CustomRefreshInterval = req.CustomRefreshInterval
}
if err := config.Save(&newCfg); err != nil {
jsonError(w, "save config error: "+err.Error(), http.StatusInternalServerError)
return
}
// 通知配置变更
select {
case s.configChanged <- struct{}{}:
default:
}
// 如果池子大小或比例变更,调整池子
if oldSize != req.PoolMaxSize || oldRatio != req.PoolHTTPRatio {
go s.poolMgr.AdjustForConfigChange(oldSize, oldRatio)
}
log.Printf("[config] 配置已更新: 池子=%d HTTP=%.0f%% 延迟=%dms",
req.PoolMaxSize, req.PoolHTTPRatio*100, req.MaxLatencyMs)
jsonOK(w, map[string]string{"status": "saved"})
}
// apiPoolStatus 获取池子状态
func (s *Server) apiPoolStatus(w http.ResponseWriter, r *http.Request) {
status, err := s.poolMgr.GetStatus()
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, status)
}
// apiQualityDistribution 获取质量分布
func (s *Server) apiQualityDistribution(w http.ResponseWriter, r *http.Request) {
dist, err := s.storage.GetQualityDistribution()
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, dist)
}
// ========== 订阅管理 API ==========
// apiSubscriptions 获取订阅列表(含每个订阅的可用/不可用代理数)
func (s *Server) apiSubscriptions(w http.ResponseWriter, r *http.Request) {
subs, err := s.storage.GetSubscriptions()
if err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
if subs == nil {
subs = []storage.Subscription{}
}
// 附加每个订阅的代理统计
type subWithStats struct {
storage.Subscription
ActiveCount int `json:"active_count"`
DisabledCount int `json:"disabled_count"`
}
var result []subWithStats
for _, sub := range subs {
active, disabled := s.storage.CountBySubscriptionID(sub.ID)
result = append(result, subWithStats{
Subscription: sub,
ActiveCount: active,
DisabledCount: disabled,
})
}
jsonOK(w, result)
}
// apiCustomStatus 获取订阅代理状态
func (s *Server) apiCustomStatus(w http.ResponseWriter, r *http.Request) {
if s.customMgr == nil {
jsonOK(w, map[string]interface{}{
"singbox_running": false,
"singbox_nodes": 0,
"custom_count": 0,
"disabled_count": 0,
"subscription_count": 0,
})
return
}
jsonOK(w, s.customMgr.GetStatus())
}
// apiSubscriptionContribute 访客贡献订阅(支持 URL 和文件上传,需验证通过才入库)
func (s *Server) apiSubscriptionContribute(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Name string `json:"name"`
URL string `json:"url"`
FileContent string `json:"file_content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request", http.StatusBadRequest)
return
}
if req.URL == "" && req.FileContent == "" {
jsonError(w, "请填写订阅 URL 或上传配置文件", http.StatusBadRequest)
return
}
if req.Name == "" {
req.Name = "贡献订阅"
}
// 如果上传了文件,保存到本地
filePath := ""
if req.FileContent != "" {
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "."
}
subDir := filepath.Join(dataDir, "subscriptions")
os.MkdirAll(subDir, 0755)
filePath = filepath.Join(subDir, fmt.Sprintf("contribute_%d.yaml", time.Now().UnixMilli()))
if err := os.WriteFile(filePath, []byte(req.FileContent), 0644); err != nil {
jsonError(w, "保存文件失败: "+err.Error(), http.StatusInternalServerError)
return
}
filePath, _ = filepath.Abs(filePath)
}
// 先验证能解析出节点
if s.customMgr != nil {
nodeCount, err := s.customMgr.ValidateSubscription(req.URL, filePath)
if err != nil {
if filePath != "" {
os.Remove(filePath)
}
jsonError(w, "订阅验证失败: "+err.Error(), http.StatusBadRequest)
return
}
log.Printf("[webui] 访客贡献订阅验证通过: %s (%d 个节点)", req.Name, nodeCount)
}
// 入库
refreshMin := config.Get().CustomRefreshInterval
var id int64
var err error
if req.URL != "" {
id, err = s.storage.AddContributedSubscription(req.Name, req.URL, refreshMin)
} else {
// 文件上传的贡献,用 AddSubscription + contributed 标记
id, err = s.storage.AddSubscription(req.Name, "", filePath, "auto", refreshMin)
if err == nil {
// 标记为贡献
s.storage.GetDB().Exec(`UPDATE subscriptions SET contributed = 1 WHERE id = ?`, id)
}
}
if err != nil {
if filePath != "" {
os.Remove(filePath)
}
jsonError(w, err.Error(), http.StatusBadRequest)
return
}
// 异步刷新入池
if s.customMgr != nil {
go func() {
if err := s.customMgr.RefreshSubscription(id); err != nil {
log.Printf("[webui] 贡献订阅刷新失败: %v", err)
}
}()
}
log.Printf("[webui] 🎁 访客贡献订阅: %s (url=%v file=%v)", req.Name, req.URL != "", filePath != "")
jsonOK(w, map[string]interface{}{"status": "contributed", "id": id})
}
// apiSubscriptionAdd 添加订阅
func (s *Server) apiSubscriptionAdd(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
Name string `json:"name"`
URL string `json:"url"`
FileContent string `json:"file_content"` // 上传的文件内容Base64 编码)
RefreshMin int `json:"refresh_min"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
jsonError(w, "invalid request", http.StatusBadRequest)
return
}
if req.URL == "" && req.FileContent == "" {
jsonError(w, "请填写订阅 URL 或上传配置文件", http.StatusBadRequest)
return
}
if req.RefreshMin <= 0 {
req.RefreshMin = config.Get().CustomRefreshInterval
}
if req.Name == "" {
req.Name = "订阅"
}
// 如果上传了文件内容,保存到本地
filePath := ""
if req.FileContent != "" {
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {
dataDir = "."
}
subDir := filepath.Join(dataDir, "subscriptions")
os.MkdirAll(subDir, 0755)
filePath = filepath.Join(subDir, fmt.Sprintf("sub_%d.yaml", time.Now().UnixMilli()))
if err := os.WriteFile(filePath, []byte(req.FileContent), 0644); err != nil {
jsonError(w, "保存文件失败: "+err.Error(), http.StatusInternalServerError)
return
}
filePath, _ = filepath.Abs(filePath)
}
// 先验证:拉取并解析,确认能解析出节点后再入库
if s.customMgr != nil {
nodeCount, err := s.customMgr.ValidateSubscription(req.URL, filePath)
if err != nil {
// 清理已保存的文件
if filePath != "" {
os.Remove(filePath)
}
jsonError(w, "订阅验证失败: "+err.Error(), http.StatusBadRequest)
return
}
log.Printf("[webui] 订阅验证通过: %s (%d 个节点)", req.Name, nodeCount)
}
id, err := s.storage.AddSubscription(req.Name, req.URL, filePath, "auto", req.RefreshMin)
if err != nil {
jsonError(w, "add subscription error: "+err.Error(), http.StatusInternalServerError)
return
}
// 验证已通过,异步执行入池
if s.customMgr != nil {
go func() {
if err := s.customMgr.RefreshSubscription(id); err != nil {
log.Printf("[webui] 订阅刷新失败: %v", err)
}
}()
}
log.Printf("[webui] 添加订阅: %s (url=%v file=%v)", req.Name, req.URL != "", filePath != "")
jsonOK(w, map[string]interface{}{"status": "added", "id": id})
}
// apiSubscriptionDelete 删除订阅
func (s *Server) apiSubscriptionDelete(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID int64 `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID <= 0 {
jsonError(w, "invalid request", http.StatusBadRequest)
return
}
// 先删除该订阅关联的代理
if s.customMgr != nil {
deleted, _ := s.storage.DeleteBySubscriptionID(req.ID)
if deleted > 0 {
log.Printf("[webui] 清理订阅 #%d 关联的 %d 个代理", req.ID, deleted)
}
}
if err := s.storage.DeleteSubscription(req.ID); err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
// 重建 sing-box 配置(剩余订阅的节点)
if s.customMgr != nil {
go s.customMgr.RefreshAll()
}
log.Printf("[webui] 删除订阅 #%d", req.ID)
jsonOK(w, map[string]string{"status": "deleted"})
}
// apiSubscriptionRefresh 刷新单个订阅
func (s *Server) apiSubscriptionRefresh(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID int64 `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID <= 0 {
jsonError(w, "invalid request", http.StatusBadRequest)
return
}
if s.customMgr != nil {
go func() {
if err := s.customMgr.RefreshSubscription(req.ID); err != nil {
log.Printf("[webui] 订阅 #%d 刷新失败: %v", req.ID, err)
}
}()
}
jsonOK(w, map[string]string{"status": "refresh started"})
}
// apiSubscriptionRefreshAll 刷新所有订阅
func (s *Server) apiSubscriptionRefreshAll(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if s.customMgr != nil {
go s.customMgr.RefreshAll()
}
jsonOK(w, map[string]string{"status": "refresh all started"})
}
// apiSubscriptionToggle 切换订阅状态
func (s *Server) apiSubscriptionToggle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
jsonError(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
ID int64 `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ID <= 0 {
jsonError(w, "invalid request", http.StatusBadRequest)
return
}
if err := s.storage.ToggleSubscription(req.ID); err != nil {
jsonError(w, err.Error(), http.StatusInternalServerError)
return
}
jsonOK(w, map[string]string{"status": "toggled"})
}
func jsonOK(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func jsonError(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}