package admin import ( "context" "errors" "fmt" "strconv" "strings" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/service" "github.com/gin-gonic/gin" ) const ( dataType = "sub2api-data" legacyDataType = "sub2api-bundle" dataVersion = 1 dataPageCap = 1000 ) type DataPayload struct { Type string `json:"type"` Version int `json:"version"` ExportedAt string `json:"exported_at"` Proxies []DataProxy `json:"proxies"` Accounts []DataAccount `json:"accounts"` } type DataProxy struct { ProxyKey string `json:"proxy_key"` Name string `json:"name"` Protocol string `json:"protocol"` Host string `json:"host"` Port int `json:"port"` Username string `json:"username,omitempty"` Password string `json:"password,omitempty"` Status string `json:"status"` } type DataAccount struct { Name string `json:"name"` Notes *string `json:"notes,omitempty"` Platform string `json:"platform"` Type string `json:"type"` Credentials map[string]any `json:"credentials"` Extra map[string]any `json:"extra,omitempty"` ProxyKey *string `json:"proxy_key,omitempty"` Concurrency int `json:"concurrency"` Priority int `json:"priority"` RateMultiplier *float64 `json:"rate_multiplier,omitempty"` ExpiresAt *int64 `json:"expires_at,omitempty"` AutoPauseOnExpired *bool `json:"auto_pause_on_expired,omitempty"` } type DataImportRequest struct { Data DataPayload `json:"data"` SkipDefaultGroupBind *bool `json:"skip_default_group_bind"` } type DataImportResult struct { ProxyCreated int `json:"proxy_created"` ProxyReused int `json:"proxy_reused"` ProxyFailed int `json:"proxy_failed"` AccountCreated int `json:"account_created"` AccountFailed int `json:"account_failed"` Errors []DataImportError `json:"errors,omitempty"` } type DataImportError struct { Kind string `json:"kind"` Name string `json:"name,omitempty"` ProxyKey string `json:"proxy_key,omitempty"` Message string `json:"message"` } func buildProxyKey(protocol, host string, port int, username, password string) string { return fmt.Sprintf("%s|%s|%d|%s|%s", strings.TrimSpace(protocol), strings.TrimSpace(host), port, strings.TrimSpace(username), strings.TrimSpace(password)) } func (h *AccountHandler) ExportData(c *gin.Context) { ctx := c.Request.Context() selectedIDs, err := parseAccountIDs(c) if err != nil { response.BadRequest(c, err.Error()) return } accounts, err := h.resolveExportAccounts(ctx, selectedIDs, c) if err != nil { response.ErrorFrom(c, err) return } includeProxies, err := parseIncludeProxies(c) if err != nil { response.BadRequest(c, err.Error()) return } var proxies []service.Proxy if includeProxies { proxies, err = h.resolveExportProxies(ctx, accounts) if err != nil { response.ErrorFrom(c, err) return } } else { proxies = []service.Proxy{} } proxyKeyByID := make(map[int64]string, len(proxies)) dataProxies := make([]DataProxy, 0, len(proxies)) for i := range proxies { p := proxies[i] key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password) proxyKeyByID[p.ID] = key dataProxies = append(dataProxies, DataProxy{ ProxyKey: key, Name: p.Name, Protocol: p.Protocol, Host: p.Host, Port: p.Port, Username: p.Username, Password: p.Password, Status: p.Status, }) } dataAccounts := make([]DataAccount, 0, len(accounts)) for i := range accounts { acc := accounts[i] var proxyKey *string if acc.ProxyID != nil { if key, ok := proxyKeyByID[*acc.ProxyID]; ok { proxyKey = &key } } var expiresAt *int64 if acc.ExpiresAt != nil { v := acc.ExpiresAt.Unix() expiresAt = &v } dataAccounts = append(dataAccounts, DataAccount{ Name: acc.Name, Notes: acc.Notes, Platform: acc.Platform, Type: acc.Type, Credentials: acc.Credentials, Extra: acc.Extra, ProxyKey: proxyKey, Concurrency: acc.Concurrency, Priority: acc.Priority, RateMultiplier: acc.RateMultiplier, ExpiresAt: expiresAt, AutoPauseOnExpired: &acc.AutoPauseOnExpired, }) } payload := DataPayload{ Type: dataType, Version: dataVersion, ExportedAt: time.Now().UTC().Format(time.RFC3339), Proxies: dataProxies, Accounts: dataAccounts, } response.Success(c, payload) } func (h *AccountHandler) ImportData(c *gin.Context) { var req DataImportRequest if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, "Invalid request: "+err.Error()) return } dataPayload := req.Data if err := validateDataHeader(dataPayload); err != nil { response.BadRequest(c, err.Error()) return } skipDefaultGroupBind := true if req.SkipDefaultGroupBind != nil { skipDefaultGroupBind = *req.SkipDefaultGroupBind } result := DataImportResult{} existingProxies, err := h.listAllProxies(c.Request.Context()) if err != nil { response.ErrorFrom(c, err) return } proxyKeyToID := make(map[string]int64, len(existingProxies)) for i := range existingProxies { p := existingProxies[i] key := buildProxyKey(p.Protocol, p.Host, p.Port, p.Username, p.Password) proxyKeyToID[key] = p.ID } for i := range dataPayload.Proxies { item := dataPayload.Proxies[i] key := item.ProxyKey if key == "" { key = buildProxyKey(item.Protocol, item.Host, item.Port, item.Username, item.Password) } if err := validateDataProxy(item); err != nil { result.ProxyFailed++ result.Errors = append(result.Errors, DataImportError{ Kind: "proxy", Name: item.Name, ProxyKey: key, Message: err.Error(), }) continue } if existingID, ok := proxyKeyToID[key]; ok { proxyKeyToID[key] = existingID result.ProxyReused++ continue } created, err := h.adminService.CreateProxy(c.Request.Context(), &service.CreateProxyInput{ Name: defaultProxyName(item.Name), Protocol: item.Protocol, Host: item.Host, Port: item.Port, Username: item.Username, Password: item.Password, }) if err != nil { result.ProxyFailed++ result.Errors = append(result.Errors, DataImportError{ Kind: "proxy", Name: item.Name, ProxyKey: key, Message: err.Error(), }) continue } proxyKeyToID[key] = created.ID result.ProxyCreated++ if item.Status != "" && item.Status != created.Status { _, _ = h.adminService.UpdateProxy(c.Request.Context(), created.ID, &service.UpdateProxyInput{ Status: item.Status, }) } } for i := range dataPayload.Accounts { item := dataPayload.Accounts[i] if err := validateDataAccount(item); err != nil { result.AccountFailed++ result.Errors = append(result.Errors, DataImportError{ Kind: "account", Name: item.Name, Message: err.Error(), }) continue } var proxyID *int64 if item.ProxyKey != nil && *item.ProxyKey != "" { if id, ok := proxyKeyToID[*item.ProxyKey]; ok { proxyID = &id } else { result.AccountFailed++ result.Errors = append(result.Errors, DataImportError{ Kind: "account", Name: item.Name, ProxyKey: *item.ProxyKey, Message: "proxy_key not found", }) continue } } accountInput := &service.CreateAccountInput{ Name: item.Name, Notes: item.Notes, Platform: item.Platform, Type: item.Type, Credentials: item.Credentials, Extra: item.Extra, ProxyID: proxyID, Concurrency: item.Concurrency, Priority: item.Priority, RateMultiplier: item.RateMultiplier, GroupIDs: nil, ExpiresAt: item.ExpiresAt, AutoPauseOnExpired: item.AutoPauseOnExpired, SkipDefaultGroupBind: skipDefaultGroupBind, } if _, err := h.adminService.CreateAccount(c.Request.Context(), accountInput); err != nil { result.AccountFailed++ result.Errors = append(result.Errors, DataImportError{ Kind: "account", Name: item.Name, Message: err.Error(), }) continue } result.AccountCreated++ } response.Success(c, result) } func (h *AccountHandler) listAllAccounts(ctx context.Context) ([]service.Account, error) { page := 1 pageSize := dataPageCap var out []service.Account for { items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, "", "", "", "") if err != nil { return nil, err } out = append(out, items...) if len(out) >= int(total) || len(items) == 0 { break } page++ } return out, nil } func (h *AccountHandler) listAllProxies(ctx context.Context) ([]service.Proxy, error) { page := 1 pageSize := dataPageCap var out []service.Proxy for { items, total, err := h.adminService.ListProxies(ctx, page, pageSize, "", "", "") if err != nil { return nil, err } out = append(out, items...) if len(out) >= int(total) || len(items) == 0 { break } page++ } return out, nil } func (h *AccountHandler) listAccountsFiltered(ctx context.Context, platform, accountType, status, search string) ([]service.Account, error) { page := 1 pageSize := dataPageCap var out []service.Account for { items, total, err := h.adminService.ListAccounts(ctx, page, pageSize, platform, accountType, status, search) if err != nil { return nil, err } out = append(out, items...) if len(out) >= int(total) || len(items) == 0 { break } page++ } return out, nil } func (h *AccountHandler) resolveExportAccounts(ctx context.Context, ids []int64, c *gin.Context) ([]service.Account, error) { if len(ids) > 0 { accounts, err := h.adminService.GetAccountsByIDs(ctx, ids) if err != nil { return nil, err } out := make([]service.Account, 0, len(accounts)) for _, acc := range accounts { if acc == nil { continue } out = append(out, *acc) } return out, nil } platform := c.Query("platform") accountType := c.Query("type") status := c.Query("status") search := strings.TrimSpace(c.Query("search")) if len(search) > 100 { search = search[:100] } return h.listAccountsFiltered(ctx, platform, accountType, status, search) } func (h *AccountHandler) resolveExportProxies(ctx context.Context, accounts []service.Account) ([]service.Proxy, error) { _ = accounts return h.listAllProxies(ctx) } func parseAccountIDs(c *gin.Context) ([]int64, error) { values := c.QueryArray("ids") if len(values) == 0 { raw := strings.TrimSpace(c.Query("ids")) if raw != "" { values = []string{raw} } } if len(values) == 0 { return nil, nil } ids := make([]int64, 0, len(values)) for _, item := range values { for _, part := range strings.Split(item, ",") { part = strings.TrimSpace(part) if part == "" { continue } id, err := strconv.ParseInt(part, 10, 64) if err != nil || id <= 0 { return nil, fmt.Errorf("invalid account id: %s", part) } ids = append(ids, id) } } return ids, nil } func parseIncludeProxies(c *gin.Context) (bool, error) { raw := strings.TrimSpace(strings.ToLower(c.Query("include_proxies"))) if raw == "" { return true, nil } switch raw { case "1", "true", "yes", "on": return true, nil case "0", "false", "no", "off": return false, nil default: return true, fmt.Errorf("invalid include_proxies value: %s", raw) } } func validateDataHeader(payload DataPayload) error { if payload.Type == "" { return errors.New("data type is required") } if payload.Type != dataType && payload.Type != legacyDataType { return fmt.Errorf("unsupported data type: %s", payload.Type) } if payload.Version != dataVersion { return fmt.Errorf("unsupported data version: %d", payload.Version) } return nil } func validateDataProxy(item DataProxy) error { if strings.TrimSpace(item.Protocol) == "" { return errors.New("proxy protocol is required") } if strings.TrimSpace(item.Host) == "" { return errors.New("proxy host is required") } if item.Port <= 0 || item.Port > 65535 { return errors.New("proxy port is invalid") } switch item.Protocol { case "http", "https", "socks5", "socks5h": default: return fmt.Errorf("proxy protocol is invalid: %s", item.Protocol) } if item.Status != "" { switch item.Status { case service.StatusActive, service.StatusDisabled, "inactive": default: return fmt.Errorf("proxy status is invalid: %s", item.Status) } } return nil } func validateDataAccount(item DataAccount) error { if strings.TrimSpace(item.Name) == "" { return errors.New("account name is required") } if strings.TrimSpace(item.Platform) == "" { return errors.New("account platform is required") } if strings.TrimSpace(item.Type) == "" { return errors.New("account type is required") } if len(item.Credentials) == 0 { return errors.New("account credentials is required") } switch item.Type { case service.AccountTypeOAuth, service.AccountTypeSetupToken, service.AccountTypeAPIKey, service.AccountTypeUpstream: default: return fmt.Errorf("account type is invalid: %s", item.Type) } if item.RateMultiplier != nil && *item.RateMultiplier < 0 { return errors.New("rate_multiplier must be >= 0") } if item.Concurrency < 0 { return errors.New("concurrency must be >= 0") } if item.Priority < 0 { return errors.New("priority must be >= 0") } return nil } func defaultProxyName(name string) string { if strings.TrimSpace(name) == "" { return "imported-proxy" } return name }