674 lines
18 KiB
Go
674 lines
18 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,
|
|
})
|
|
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,
|
|
})
|
|
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
|
|
}
|
|
// Check scoped grant
|
|
if d.GrantRepo.AuthorizeScoped(token, resourceType, resourceId, target) {
|
|
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": proxy.AddPreviewIds(original),
|
|
"processed": 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,
|
|
})
|
|
if err != nil {
|
|
return failed(c, err.Error(), 400)
|
|
}
|
|
return success(c, fiber.Map{"body": result.Body, "nodes": result.Nodes})
|
|
}
|
|
|
|
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,
|
|
})
|
|
if err != nil {
|
|
return failed(c, err.Error(), 400)
|
|
}
|
|
return success(c, fiber.Map{"body": result.Body, "nodes": result.Nodes})
|
|
}
|
|
|
|
// --- 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
|
|
}
|
|
}
|
|
path := "/download/" + kind + "/" + id
|
|
if target != "" {
|
|
path += "/" + target
|
|
}
|
|
baseURL := getPublicBaseUrl(c)
|
|
u := baseURL + path
|
|
if d.Cfg.Auth.DownloadToken != "" {
|
|
u += "?token=" + d.Cfg.Auth.DownloadToken
|
|
}
|
|
return fiber.Map{
|
|
"url": u,
|
|
"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)
|
|
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) 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) (string, error) {
|
|
client := &http.Client{Timeout: req.Timeout}
|
|
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)
|
|
}
|