mirror of
https://github.com/isboyjc/GoProxy.git
synced 2026-05-06 20:02:54 +08:00
- 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.
838 lines
25 KiB
Go
838 lines
25 KiB
Go
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})
|
||
}
|