package handler import ( "context" "encoding/json" "fmt" "io" "net/http" "net/url" "regexp" "strconv" "strings" "time" "github.com/gofiber/fiber/v3" "github.com/peterqiu0516/sub-store/internal/middleware" "github.com/peterqiu0516/sub-store/internal/model" "github.com/peterqiu0516/sub-store/internal/proxy" "github.com/peterqiu0516/sub-store/internal/render" "github.com/peterqiu0516/sub-store/internal/rules" "github.com/peterqiu0516/sub-store/internal/service" "github.com/peterqiu0516/sub-store/internal/template" "github.com/peterqiu0516/sub-store/internal/util" ) // --- Download handlers --- func (d *Deps) HandleDownloadCollection(c fiber.Ctx) error { name := c.Params("name") target := d.getDownloadTarget(c) if target == "" { return failed(c, "Unsupported target", 400) } if err := d.rejectInvalidDownloadToken(c, "collection", name, target); err != nil { return err } collection, err := d.CollectionRepo.Get(name) if err != nil || collection == nil || !collection.Enabled { return failed(c, "Collection not found", 404) } sources, _ := d.SourceRepo.List() tmplConfig := d.getTemplateConfig(collection.TemplateId) settings, _ := d.SettingsRepo.Get() result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{ Collection: collection, Sources: sources, Target: target, TemplateConfig: tmplConfig, Settings: settings, RequestUserAgent: c.Get("User-Agent"), ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1", CacheRepo: d.CacheRepo, ProxyURL: d.Cfg.Fetcher.ProxyURL, }) if err != nil { return failed(c, err.Error(), 500) } return d.sendDownloadResponse(c, result, target) } func (d *Deps) HandleDownloadSource(c fiber.Ctx) error { name := c.Params("name") target := d.getDownloadTarget(c) if target == "" { return failed(c, "Unsupported target", 400) } if err := d.rejectInvalidDownloadToken(c, "source", name, target); err != nil { return err } source, err := d.SourceRepo.Get(name) if err != nil || source == nil || !source.Enabled { return failed(c, "Subscription not found", 404) } settings, _ := d.SettingsRepo.Get() result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{ Source: source, Sources: []model.SourceRecord{*source}, Target: target, Settings: settings, RequestUserAgent: c.Get("User-Agent"), ForceRefresh: c.Query("refresh") == "1" || c.Query("noCache") == "1", CacheRepo: d.CacheRepo, ProxyURL: d.Cfg.Fetcher.ProxyURL, }) if err != nil { return failed(c, err.Error(), 500) } return d.sendDownloadResponse(c, result, target) } func (d *Deps) sendDownloadResponse(c fiber.Ctx, result *service.BuildResult, target string) error { c.Set("Content-Type", model.GetTargetContentType(target)) // review-resolution #43: profile-update-interval default "6" interval := result.Metadata.ProfileUpdateInterval if interval == "" { interval = "6" } c.Set("Profile-Update-Interval", interval) // review-resolution #44: cache-control: no-store c.Set("Cache-Control", "no-store") middleware.SetSafeResponseHeader(c, "Subscription-Userinfo", result.Metadata.SubscriptionUserinfo) middleware.SetSafeResponseHeader(c, "Profile-Web-Page-Url", result.Metadata.ProfileWebPageUrl) middleware.SetSafeResponseHeader(c, "Content-Disposition", result.Metadata.ContentDisposition) middleware.SetSafeResponseHeader(c, "X-Sub-Store-Cache", result.Metadata.CacheStatus) return c.SendString(result.Body) } func (d *Deps) getDownloadTarget(c fiber.Ctx) string { explicit := c.Params("target") if explicit != "" { return model.NormalizeTargetAlias(explicit) } if t := c.Query("target"); t != "" { return model.NormalizeTargetAlias(t) } return model.NormalizeTarget("", c.Get("User-Agent")) } func (d *Deps) rejectInvalidDownloadToken(c fiber.Ctx, resourceType, resourceId, target string) error { token := c.Params("token") if token == "" { token = c.Query("token") } // Check global download token if util.IsTokenValid(token, d.Cfg.Auth.DownloadToken) { return nil } return failed(c, "Download token is invalid", 403) } func (d *Deps) getTemplateConfig(templateId string) map[string]any { if templateId == "" { templateId = template.DefaultTemplateID } // Check built-in for _, bt := range template.BuiltinTemplates { if bt.ID == templateId { return bt.Config } } // Check DB rec, err := d.TemplateRepo.Get(templateId) if err == nil && rec != nil { return rec.Config } return map[string]any{} } // --- Preview handlers --- func (d *Deps) HandlePreviewSource(c fiber.Ctx) error { var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } settings, _ := d.SettingsRepo.Get() if getStringValue(input["type"]) == "local" || getStringValue(input["content"]) != "" { // Local preview content := getStringValue(input["content"]) original := proxy.ParseProxies(proxy.DecodeMaybeBase64(content)) if len(original) == 0 { return failed(c, "No valid proxy nodes found", 400) } rec := mapToSourceRecord(input) processed := proxy.EnsureUniqueProxyNames( applyFiltersSafe(original, rec.Filters, settings, "json", rec.ID), ) return success(c, fiber.Map{ "original": d.addCachedEgressInfo(proxy.AddPreviewIds(original)), "processed": d.addCachedEgressInfo(proxy.AddPreviewIds(processed)), }) } // Remote preview rec := mapToSourceRecord(input) result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{ Source: &rec, Sources: []model.SourceRecord{rec}, Target: "json", Settings: settings, RequestUserAgent: c.Get("User-Agent"), CacheRepo: d.CacheRepo, ProxyURL: d.Cfg.Fetcher.ProxyURL, }) if err != nil { return failed(c, err.Error(), 400) } return success(c, fiber.Map{ "body": result.Body, "nodes": result.Nodes, "originalCount": result.OriginalNodes, "processed": d.addCachedEgressInfo(previewNodesFromBody(result.Body)), }) } func (d *Deps) HandlePreviewCollection(c fiber.Ctx) error { var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } settings, _ := d.SettingsRepo.Get() rec := mapToCollectionRecord(input) sources, _ := d.SourceRepo.List() result, err := service.BuildSubscriptionResult(context.Background(), service.BuildOptions{ Collection: &rec, Sources: sources, Target: "json", TemplateConfig: d.getTemplateConfig(rec.TemplateId), Settings: settings, RequestUserAgent: c.Get("User-Agent"), CacheRepo: d.CacheRepo, ProxyURL: d.Cfg.Fetcher.ProxyURL, }) if err != nil { return failed(c, err.Error(), 400) } return success(c, fiber.Map{ "body": result.Body, "nodes": result.Nodes, "originalCount": result.OriginalNodes, "processed": d.addCachedEgressInfo(previewNodesFromBody(result.Body)), }) } func previewNodesFromBody(body string) []model.ProxyNode { var payload struct { Proxies []model.ProxyNode `json:"proxies"` } if err := json.Unmarshal([]byte(body), &payload); err != nil { return nil } return proxy.AddPreviewIds(payload.Proxies) } // --- Link handlers --- func (d *Deps) HandleLinkSource(c fiber.Ctx) error { name := c.Params("name") sub, err := d.SourceRepo.Get(name) if err != nil || sub == nil { return failed(c, "Source not found", 404) } return success(c, d.buildDownloadLink(c, "source", sub.ID)) } func (d *Deps) HandleLinkCollection(c fiber.Ctx) error { name := c.Params("name") col, err := d.CollectionRepo.Get(name) if err != nil || col == nil { return failed(c, "Collection not found", 404) } return success(c, d.buildDownloadLink(c, "collection", col.ID)) } func (d *Deps) buildDownloadLink(c fiber.Ctx, kind, id string) fiber.Map { target := "" rawTarget := c.Query("target") if rawTarget != "" { target = model.NormalizeTargetAlias(rawTarget) if target == "" { return nil } } baseURL := getPublicBaseUrl(c) path := "/" + kind + "s/" + id query := "" if d.Cfg.Auth.DownloadToken != "" { path += "/" + url.PathEscape(d.Cfg.Auth.DownloadToken) } if target != "" { query = "?target=" + url.QueryEscape(target) } return fiber.Map{ "url": baseURL + path + query, "target": orDefault(target, "auto"), "tokenIncluded": d.Cfg.Auth.DownloadToken != "", } } // --- Flow info handler --- func (d *Deps) HandleFlowInfo(c fiber.Ctx) error { name := c.Params("name") sub, err := d.SourceRepo.Get(name) if err != nil || sub == nil { return c.Status(400).JSON(fiber.Map{ "status": "failed", "error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "Source not found"}, }) } settings, _ := d.SettingsRepo.Get() flowReq := parseFlowRequest(sub, settings) if flowReq == nil { return c.Status(400).JSON(fiber.Map{ "status": "failed", "error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "No flow info"}, }) } headers, err := fetchFlowHeaders(flowReq, d.Cfg.Fetcher.ProxyURL) if err != nil { return c.Status(500).JSON(fiber.Map{ "status": "failed", "error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": err.Error()}, }) } meta := "" if sub.Meta != nil { if s, ok := sub.Meta["subUserinfo"].(string); ok { meta = s } } combined := strings.TrimSpace(meta + "; " + headers) flow := parseFlowHeaders(combined) if flow == nil { return c.Status(400).JSON(fiber.Map{ "status": "failed", "error": fiber.Map{"code": "NO_FLOW_INFO", "type": "NO_FLOW_INFO", "message": "No flow info"}, }) } return success(c, flow) } // --- Tool API handlers --- func (d *Deps) HandleProxyParse(c fiber.Ctx) error { var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { input = map[string]any{} } target := model.NormalizeTargetAlias(input["client"]) if target == "" { target = model.NormalizeTargetAlias(input["platform"]) } if target == "" { target = model.NormalizeTargetAlias(input["target"]) } if target == "" { return failed(c, "Unsupported target", 400) } content := getStringValue(input["data"]) if content == "" { content = getStringValue(input["content"]) } if content == "" { return failed(c, "Proxy content is required", 400) } settings, _ := d.SettingsRepo.Get() parsed := proxy.ParseProxies(proxy.DecodeMaybeBase64(content)) if len(parsed) == 0 { return failed(c, "No valid proxy nodes found", 400) } filters := parseFilters(input["filters"]) processed := proxy.EnsureUniqueProxyNames(applyFiltersSafe(parsed, filters, settings, target, "one-shot")) var supported []model.ProxyNode for _, p := range processed { ptype := getStringValue(p["type"]) if model.IsTargetCompatible(ptype, target) { supported = append(supported, p) } } output, err := render.RenderTarget(supported, target, nil) if err != nil { return failed(c, err.Error(), 400) } skipped := len(processed) - len(supported) warnings := []string{} if skipped > 0 { warnings = append(warnings, fmt.Sprintf("%d node(s) cannot be represented by %s", skipped, target)) } return success(c, fiber.Map{ "par_res": output, "content": output, "parsed": len(parsed), "emitted": len(supported), "skipped": skipped, "warnings": warnings, }) } func (d *Deps) HandleRuleParse(c fiber.Ctx) error { var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { input = map[string]any{} } targetStr := getStringValue(input["client"]) if targetStr == "" { targetStr = getStringValue(input["platform"]) } if targetStr == "" { targetStr = getStringValue(input["target"]) } target := normalizeRuleTarget(targetStr) if target == "" { return failed(c, "Unsupported rule target", 400) } content := getStringValue(input["data"]) if content == "" { content = getStringValue(input["content"]) } if content == "" { return failed(c, "Rule content is required", 400) } result := rules.ConvertRules(content, rules.RuleTarget(target)) return success(c, fiber.Map{ "par_res": result.Content, "content": result.Content, "parsed": result.Parsed, "emitted": result.Emitted, "skipped": result.Skipped, "warnings": result.Warnings, }) } func (d *Deps) HandleProxyURI(c fiber.Ctx) error { var input model.ProxyNode if err := json.Unmarshal(c.Body(), &input); err != nil { return failed(c, "Invalid JSON", 400) } if getStringValue(input["name"]) == "" { input["name"] = getStringValue(input["remarks"]) } if getStringValue(input["server"]) == "" { input["server"] = getStringValue(input["address"]) } if getStringValue(input["network"]) == "" { input["network"] = getStringValue(input["transport"]) } uri := render.ToProxyUri(input) if uri == "" { return failed(c, "Unsupported proxy node", 400) } return success(c, fiber.Map{"uri": uri}) } func (d *Deps) HandleNodeInfo(c fiber.Ctx) error { var input map[string]any if err := json.Unmarshal(c.Body(), &input); err != nil { input = map[string]any{} } server := strings.Trim(getStringValue(input["server"]), "[]") if server == "" { return failed(c, "Node server is required", 400) } settings, _ := d.SettingsRepo.Get() configured := getStringFromMap(settings, "nodeInfoApiUrl") if configured == "" { configured = "https://ipwho.is/{ip}" } if !strings.HasPrefix(configured, "https://") || !strings.Contains(configured, "{ip}") { return failed(c, "Node info API must be an HTTPS URL containing {ip}", 400) } infoURL := strings.Replace(configured, "{ip}", url.QueryEscape(server), 1) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Get(infoURL) if err != nil { return failed(c, err.Error(), 502) } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, util.MaxFlowRespBytes)) var data map[string]any json.Unmarshal(body, &data) if success, ok := data["success"].(bool); ok && !success { msg := getStringValue(data["message"]) if msg == "" { msg = "Node info lookup failed" } return failed(c, msg, 502) } return success(c, fiber.Map{ "ip": data["ip"], "country": data["country"], "region": data["region"], "city": data["city"], "connection": data["connection"], }) } // --- Flow helpers --- type flowRequest struct { URL string UserAgent string Headers map[string]string Timeout time.Duration } func parseFlowRequest(sub *model.SourceRecord, settings map[string]any) *flowRequest { rawURL := sub.URL args := parseURLArguments(rawURL) flowURL := getStringValue(args["flowUrl"]) if flowURL == "" { flowURL = strings.SplitN(rawURL, "#", 2)[0] } if noFlow, ok := args["noFlow"].(bool); ok && noFlow { return nil } if !strings.HasPrefix(flowURL, "http://") && !strings.HasPrefix(flowURL, "https://") { return nil } ua := getStringValue(args["flowUserAgent"]) if ua == "" { ua = getStringFromMap(settings, "defaultFlowUserAgent") } if ua == "" { ua = getStringFromMap(settings, "defaultUserAgent") } if ua == "" { ua = "clash.meta/v1.19.24" } timeout := 30 * time.Second if t, ok := settings["defaultTimeout"]; ok { if n := toIntSafe(t); n > 0 { timeout = time.Duration(n) * time.Millisecond } } return &flowRequest{ URL: flowURL, UserAgent: ua, Headers: parseJSONHeaders(args["flowHeaders"]), Timeout: timeout, } } func parseURLArguments(rawURL string) map[string]any { hashIdx := strings.Index(rawURL, "#") if hashIdx < 0 { return map[string]any{} } hash := rawURL[hashIdx+1:] if hash == "" { return map[string]any{} } // Try JSON first var m map[string]any if err := json.Unmarshal([]byte(hash), &m); err == nil { return m } // Try query-string format result := map[string]any{} for _, pair := range strings.Split(hash, "&") { if pair == "" { continue } parts := strings.SplitN(pair, "=", 2) key := parts[0] if len(parts) == 1 || parts[1] == "" { result[key] = true } else { val, err := url.QueryUnescape(parts[1]) if err != nil { val = parts[1] } result[key] = val } } return result } func parseJSONHeaders(v any) map[string]string { result := map[string]string{} if s, ok := v.(string); ok && s != "" { var m map[string]any json.Unmarshal([]byte(s), &m) for k, v := range m { result[k] = fmt.Sprintf("%v", v) } } if m, ok := v.(map[string]any); ok { for k, v := range m { result[k] = fmt.Sprintf("%v", v) } } return result } func fetchFlowHeaders(req *flowRequest, proxyURL string) (string, error) { client := buildFlowHTTPClient(req.Timeout, proxyURL) httpReq, err := http.NewRequest("GET", req.URL, nil) if err != nil { return "", err } httpReq.Header.Set("User-Agent", req.UserAgent) for k, v := range req.Headers { httpReq.Header.Set(k, v) } resp, err := client.Do(httpReq) if err != nil { return "", err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, util.MaxFlowRespBytes)) bodyStr := string(body) parts := []string{} if h := resp.Header.Get("subscription-userinfo"); h != "" { parts = append(parts, h) } if hasUploadDownload(bodyStr) { parts = append(parts, bodyStr) } if appUrl := resp.Header.Get("profile-web-page-url"); appUrl != "" { parts = append(parts, "app_url="+url.QueryEscape(appUrl)) } if planName := resp.Header.Get("profile-title"); planName != "" { parts = append(parts, "plan_name="+url.QueryEscape(planName)) } return strings.Join(parts, "; "), nil } func hasUploadDownload(body string) bool { return regexp.MustCompile(`(?:^|[;\n\r ])upload=`).MatchString(body) } func parseFlowHeaders(flowHeaders string) map[string]any { upload := numberField(flowHeaders, "upload") download := numberField(flowHeaders, "download") total := numberField(flowHeaders, "total") if download == nil || total == nil { return nil } result := map[string]any{ "expires": numberField(flowHeaders, "expire"), "total": *total, "usage": map[string]any{"upload": *upload, "download": *download}, "remainingDays": numberField(flowHeaders, "reset_day"), "appUrl": textField(flowHeaders, "app_url"), "planName": textField(flowHeaders, "plan_name"), } return result } func numberField(input, key string) *float64 { re := regexp.MustCompile(key + `=([-+]?)([0-9]*\.?[0-9]+(?:[eE][-+]?[0-9]+)?)`) matches := re.FindStringSubmatch(input) if len(matches) < 3 { return nil } n, err := strconv.ParseFloat(matches[1]+matches[2], 64) if err != nil { return nil } return &n } func textField(input, key string) string { re := regexp.MustCompile(key + `=(.*?)\s*?(;|$)`) matches := re.FindStringSubmatch(input) if len(matches) < 2 { return "" } val, err := url.QueryUnescape(matches[1]) if err != nil { return matches[1] } return val } func toIntSafe(v any) int { switch n := v.(type) { case int: return n case float64: return int(n) case string: i, _ := strconv.Atoi(n) return i } return 0 } // getStringFromMap returns a string value from a map[string]any. func getStringFromMap(m map[string]any, key string) string { if v, ok := m[key]; ok { if s, ok := v.(string); ok { return s } } return "" } func normalizeRuleTarget(input string) string { switch strings.ToLower(input) { case "mihomo", "clash", "clashmeta", "clash-meta": return "mihomo" case "surge": return "surge" case "loon": return "loon" case "qx", "quanx", "quantumultx", "quantumult-x": return "qx" } return "" } // applyFiltersSafe wraps filter.ApplyFilters with the correct context. func applyFiltersSafe(nodes []model.ProxyNode, filters []model.FilterRule, settings map[string]any, target, sourceId string) []model.ProxyNode { if filters == nil { filters = []model.FilterRule{} } ctx := struct { TargetPlatform string SourceId string CollectionId string }{ TargetPlatform: target, SourceId: sourceId, } _ = ctx // Use the filter package's ApplyFilters with FilterContext return applyFiltersWithContext(nodes, filters, settings, target, sourceId) } // buildFlowHTTPClient builds an HTTP client for flow-info requests with // optional proxy support. func buildFlowHTTPClient(timeout time.Duration, proxyURL string) *http.Client { if proxyURL == "" { return &http.Client{Timeout: timeout} } pu, err := url.Parse(proxyURL) if err != nil { return &http.Client{Timeout: timeout} } return &http.Client{ Timeout: timeout, Transport: &http.Transport{ Proxy: http.ProxyURL(pu), }, } }