Files
sub-store/internal/handler/download_tools.go
T
rogee 55ab656606 feat: add force refresh button to source/collection cards
- Backend: preview handlers accept forceRefresh in request body
- Frontend: Sources & Collections cards get a refresh button that
  bypasses cache and re-fetches from remote
2026-07-28 21:42:12 +08:00

735 lines
20 KiB
Go

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"),
ForceRefresh: getBoolValue(input["forceRefresh"]),
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"),
ForceRefresh: getBoolValue(input["forceRefresh"]),
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),
},
}
}