Some checks failed
CI/CD Pipeline / Test (push) Failing after 22m19s
CI/CD Pipeline / Security Scan (push) Failing after 5m57s
CI/CD Pipeline / Build (amd64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (amd64, linux) (push) Has been skipped
CI/CD Pipeline / Build (amd64, windows) (push) Has been skipped
CI/CD Pipeline / Build (arm64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (arm64, linux) (push) Has been skipped
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Create Release (push) Has been skipped
826 lines
23 KiB
Go
826 lines
23 KiB
Go
package conversion
|
||
|
||
import (
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// ConversionRequest 表示转换请求的参数和配置
|
||
// 该结构体包含从HTTP请求解析出的所有转换参数
|
||
type ConversionRequest struct {
|
||
// 基本参数
|
||
Target string `json:"target"` // 目标格式:clash, surge, quanx, loon, surfboard, v2ray
|
||
URL string `json:"url"` // 源订阅URL
|
||
RawURL string `json:"-"` // 原始请求中的URL参数(可能包含多源)
|
||
Sources []string `json:"-"` // 解析后的订阅源列表
|
||
|
||
// 请求上下文
|
||
UserAgent string `json:"-"` // 客户端传入的 User-Agent,用于远程拉取订阅时复用
|
||
|
||
// 可选参数
|
||
ConfigURL string `json:"config_url,omitempty"` // 自定义配置文件URL
|
||
Emoji bool `json:"emoji,omitempty"` // 是否启用emoji
|
||
UDP bool `json:"udp,omitempty"` // 是否启用UDP支持
|
||
IPv6 bool `json:"ipv6,omitempty"` // 是否启用IPv6支持
|
||
Group string `json:"group,omitempty"` // 节点分组名称
|
||
Insert bool `json:"insert,omitempty"` // 是否插入URL测试
|
||
Strict bool `json:"strict,omitempty"` // 是否严格模式
|
||
Compatible bool `json:"compatible,omitempty"` // 是否兼容模式
|
||
AppendType bool `json:"append_type,omitempty"`
|
||
TFO bool `json:"tfo,omitempty"`
|
||
Script bool `json:"script,omitempty"`
|
||
SkipCert bool `json:"skip_cert_verify,omitempty"`
|
||
FilterDeprecated bool `json:"filter_deprecated,omitempty"`
|
||
ExpandRules bool `json:"expand_rules,omitempty"`
|
||
AppendInfo bool `json:"append_info,omitempty"`
|
||
Prepend bool `json:"prepend,omitempty"`
|
||
Classic bool `json:"classic,omitempty"`
|
||
TLS13 bool `json:"tls13,omitempty"`
|
||
AddEmoji bool `json:"add_emoji,omitempty"`
|
||
RemoveEmoji bool `json:"remove_emoji,omitempty"`
|
||
Upload bool `json:"upload,omitempty"`
|
||
|
||
// 过滤参数
|
||
Include []string `json:"include,omitempty"` // 包含的关键词
|
||
Exclude []string `json:"exclude,omitempty"` // 排除的关键词
|
||
Regex string `json:"regex,omitempty"` // 正则表达式过滤
|
||
Location string `json:"location,omitempty"` // 地区过滤
|
||
RenameRules []string `json:"rename_rules,omitempty"` // 重命名规则
|
||
|
||
// 高级参数
|
||
Test bool `json:"test,omitempty"` // 是否启用测试
|
||
List bool `json:"list,omitempty"` // 是否列表模式
|
||
Filename string `json:"filename,omitempty"` // 输出文件名
|
||
UploadPath string `json:"upload_path,omitempty"`
|
||
FilterScript string `json:"filter_script,omitempty"`
|
||
DeviceID string `json:"device_id,omitempty"`
|
||
Interval string `json:"interval,omitempty"`
|
||
Groups []string `json:"groups,omitempty"`
|
||
Rulesets []string `json:"rulesets,omitempty"`
|
||
BasePath string `json:"-"`
|
||
Providers []string `json:"providers,omitempty"`
|
||
EmojiRules []string `json:"emoji_rules,omitempty"`
|
||
}
|
||
|
||
// NewConversionRequest 创建新的转换请求
|
||
// 返回包含默认值的ConversionRequest结构体
|
||
func NewConversionRequest() *ConversionRequest {
|
||
return &ConversionRequest{
|
||
Target: "",
|
||
URL: "",
|
||
RawURL: "",
|
||
Sources: []string{},
|
||
UserAgent: "",
|
||
ConfigURL: "",
|
||
Emoji: false,
|
||
UDP: false,
|
||
IPv6: false,
|
||
Group: "",
|
||
Insert: false,
|
||
Strict: false,
|
||
Compatible: true, // 默认启用兼容模式
|
||
Include: []string{},
|
||
Exclude: []string{},
|
||
Regex: "",
|
||
Location: "",
|
||
RenameRules: []string{},
|
||
Test: false,
|
||
List: false,
|
||
Filename: "",
|
||
UploadPath: "",
|
||
FilterScript: "",
|
||
DeviceID: "",
|
||
Interval: "",
|
||
Groups: []string{},
|
||
Rulesets: []string{},
|
||
BasePath: "",
|
||
Providers: []string{},
|
||
EmojiRules: []string{},
|
||
}
|
||
}
|
||
|
||
// SetURL 设置订阅URL(支持多源)
|
||
func (r *ConversionRequest) SetURL(raw string) error {
|
||
trimmed := strings.TrimSpace(raw)
|
||
r.RawURL = trimmed
|
||
if trimmed == "" {
|
||
r.URL = ""
|
||
r.Sources = nil
|
||
return nil
|
||
}
|
||
sources, err := parseSourcesFromRaw(trimmed)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
r.Sources = sources
|
||
if len(sources) > 0 {
|
||
r.URL = sources[0]
|
||
} else {
|
||
r.URL = ""
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// GetSources 返回订阅源列表(拷贝)
|
||
func (r *ConversionRequest) GetSources() []string {
|
||
if len(r.Sources) > 0 {
|
||
out := make([]string, len(r.Sources))
|
||
copy(out, r.Sources)
|
||
return out
|
||
}
|
||
if strings.TrimSpace(r.URL) != "" {
|
||
return []string{strings.TrimSpace(r.URL)}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SourceSummary 返回用于记录/展示的源URL字符串
|
||
func (r *ConversionRequest) SourceSummary() string {
|
||
if strings.TrimSpace(r.RawURL) != "" {
|
||
return r.RawURL
|
||
}
|
||
sources := r.GetSources()
|
||
if len(sources) == 0 {
|
||
return ""
|
||
}
|
||
return strings.Join(sources, "|")
|
||
}
|
||
|
||
// Validate 验证转换请求的有效性
|
||
// 返回error如果请求无效,nil表示请求有效
|
||
func (r *ConversionRequest) Validate() error {
|
||
// 验证必需字段
|
||
if r.Target == "" {
|
||
return fmt.Errorf("target format cannot be empty")
|
||
}
|
||
|
||
sources := r.GetSources()
|
||
if len(sources) == 0 && strings.TrimSpace(r.URL) != "" {
|
||
if err := r.SetURL(r.URL); err != nil {
|
||
return err
|
||
}
|
||
sources = r.GetSources()
|
||
}
|
||
if len(sources) == 0 {
|
||
return fmt.Errorf("source URL cannot be empty")
|
||
}
|
||
|
||
// 验证目标格式
|
||
target := strings.ToLower(r.Target)
|
||
if target == "auto" {
|
||
return nil
|
||
}
|
||
|
||
validTargets := map[string]bool{
|
||
"clash": true,
|
||
"clashr": true,
|
||
"surge": true,
|
||
"quanx": true,
|
||
"loon": true,
|
||
"surfboard": true,
|
||
"v2ray": true,
|
||
}
|
||
if !validTargets[target] {
|
||
return fmt.Errorf("unsupported target format: %s, must be one of: clash, clashr, surge, quanx, loon, surfboard, v2ray", r.Target)
|
||
}
|
||
|
||
// 验证URL格式
|
||
for _, src := range sources {
|
||
if err := r.validateURL(src, "source URL"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// 验证配置URL(如果提供)
|
||
if r.ConfigURL != "" {
|
||
if err := r.validateURL(r.ConfigURL, "config URL"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// 验证正则表达式
|
||
if r.Regex != "" {
|
||
if _, err := regexp.Compile(r.Regex); err != nil {
|
||
return fmt.Errorf("invalid regex pattern: %v", err)
|
||
}
|
||
}
|
||
|
||
// 验证文件名
|
||
if r.Filename != "" {
|
||
if len(r.Filename) > 255 {
|
||
return fmt.Errorf("filename too long (max 255 characters)")
|
||
}
|
||
if strings.ContainsAny(r.Filename, "\\/:*?\"<>|") {
|
||
return fmt.Errorf("filename contains invalid characters")
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// validateURL 验证URL格式
|
||
func (r *ConversionRequest) validateURL(rawURL, fieldName string) error {
|
||
parsedURL, err := url.Parse(rawURL)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid %s: %v", fieldName, err)
|
||
}
|
||
|
||
if parsedURL.Scheme == "" {
|
||
return fmt.Errorf("%s must have a scheme (http:// or https://)", fieldName)
|
||
}
|
||
|
||
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
|
||
return fmt.Errorf("%s scheme must be http or https, got: %s", fieldName, parsedURL.Scheme)
|
||
}
|
||
|
||
if parsedURL.Host == "" {
|
||
return fmt.Errorf("%s must have a host", fieldName)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// FromQuery 从HTTP查询参数解析转换请求
|
||
// 根据查询参数自动填充ConversionRequest字段
|
||
func (r *ConversionRequest) FromQuery(query string) error {
|
||
values, err := url.ParseQuery(query)
|
||
if err != nil {
|
||
return fmt.Errorf("failed to parse query: %v", err)
|
||
}
|
||
|
||
splitAndTrim := func(input, sep string) []string {
|
||
parts := strings.Split(input, sep)
|
||
out := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||
out = append(out, trimmed)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
decodeBase64List := func(key, sep string) ([]string, error) {
|
||
raw := values.Get(key)
|
||
if raw == "" {
|
||
return nil, nil
|
||
}
|
||
decoded, err := base64.StdEncoding.DecodeString(raw)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("invalid %s parameter: %v", key, err)
|
||
}
|
||
decodedStr := strings.TrimSpace(string(decoded))
|
||
if decodedStr == "" {
|
||
return []string{}, nil
|
||
}
|
||
return splitAndTrim(decodedStr, sep), nil
|
||
}
|
||
|
||
parseBool := func(key string, setter func(bool)) error {
|
||
raw := values.Get(key)
|
||
if raw == "" {
|
||
return nil
|
||
}
|
||
parsed, err := strconv.ParseBool(raw)
|
||
if err != nil {
|
||
return fmt.Errorf("invalid %s parameter: %v", key, err)
|
||
}
|
||
setter(parsed)
|
||
return nil
|
||
}
|
||
|
||
// 基本参数
|
||
if target := values.Get("target"); target != "" {
|
||
r.Target = target
|
||
}
|
||
if urlStr := values.Get("url"); urlStr != "" {
|
||
if err := r.SetURL(urlStr); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
|
||
// 可选参数
|
||
if configURL := values.Get("config"); configURL != "" {
|
||
r.ConfigURL = configURL
|
||
}
|
||
if err := parseBool("emoji", func(v bool) { r.Emoji = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("udp", func(v bool) { r.UDP = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("ipv6", func(v bool) { r.IPv6 = v }); err != nil {
|
||
return err
|
||
}
|
||
if group := values.Get("group"); group != "" {
|
||
r.Group = group
|
||
}
|
||
if err := parseBool("insert", func(v bool) { r.Insert = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("strict", func(v bool) { r.Strict = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("compatible", func(v bool) { r.Compatible = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("append_type", func(v bool) { r.AppendType = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("tfo", func(v bool) { r.TFO = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("script", func(v bool) { r.Script = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("scv", func(v bool) { r.SkipCert = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("fdn", func(v bool) { r.FilterDeprecated = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("expand", func(v bool) { r.ExpandRules = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("append_info", func(v bool) { r.AppendInfo = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("prepend", func(v bool) { r.Prepend = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("classic", func(v bool) { r.Classic = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("tls13", func(v bool) { r.TLS13 = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("add_emoji", func(v bool) { r.AddEmoji = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("remove_emoji", func(v bool) { r.RemoveEmoji = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("upload", func(v bool) { r.Upload = v }); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 过滤参数
|
||
if include := values.Get("include"); include != "" {
|
||
r.Include = splitAndTrim(include, ",")
|
||
}
|
||
if exclude := values.Get("exclude"); exclude != "" {
|
||
r.Exclude = splitAndTrim(exclude, ",")
|
||
}
|
||
if regex := values.Get("regex"); regex != "" {
|
||
r.Regex = regex
|
||
}
|
||
if location := values.Get("location"); location != "" {
|
||
r.Location = location
|
||
}
|
||
|
||
// 高级参数
|
||
if err := parseBool("test", func(v bool) { r.Test = v }); err != nil {
|
||
return err
|
||
}
|
||
if err := parseBool("list", func(v bool) { r.List = v }); err != nil {
|
||
return err
|
||
}
|
||
if filename := values.Get("filename"); filename != "" {
|
||
r.Filename = filename
|
||
}
|
||
if uploadPath := values.Get("upload_path"); uploadPath != "" {
|
||
r.UploadPath = uploadPath
|
||
}
|
||
if rename := values.Get("rename"); rename != "" {
|
||
r.RenameRules = splitAndTrim(rename, "`")
|
||
}
|
||
if filterScript := values.Get("filter_script"); filterScript != "" {
|
||
r.FilterScript = filterScript
|
||
}
|
||
if deviceID := values.Get("dev_id"); deviceID != "" {
|
||
r.DeviceID = deviceID
|
||
}
|
||
if interval := values.Get("interval"); interval != "" {
|
||
r.Interval = interval
|
||
}
|
||
if groups, err := decodeBase64List("groups", "@"); err != nil {
|
||
return err
|
||
} else if groups != nil {
|
||
r.Groups = groups
|
||
}
|
||
if rulesets, err := decodeBase64List("ruleset", "@"); err != nil {
|
||
return err
|
||
} else if rulesets != nil {
|
||
r.Rulesets = rulesets
|
||
}
|
||
if providers, err := decodeBase64List("providers", "@"); err != nil {
|
||
return err
|
||
} else if providers != nil {
|
||
r.Providers = providers
|
||
}
|
||
if emojiRules := values.Get("emoji_rule"); emojiRules != "" {
|
||
r.EmojiRules = splitAndTrim(emojiRules, "`")
|
||
}
|
||
if len(r.EmojiRules) == 0 {
|
||
if emojiRules := values.Get("emoji_rules"); emojiRules != "" {
|
||
r.EmojiRules = splitAndTrim(emojiRules, "`")
|
||
}
|
||
}
|
||
|
||
return r.Validate()
|
||
}
|
||
|
||
// FromHTTPRequest 从HTTP请求解析转换请求
|
||
// 自动从URL查询参数和请求头中提取转换参数
|
||
func (r *ConversionRequest) FromHTTPRequest(req *http.Request) error {
|
||
// 从查询参数解析
|
||
if err := r.FromQuery(req.URL.RawQuery); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 从请求头中获取额外的信息
|
||
if userAgent := req.Header.Get("User-Agent"); userAgent != "" {
|
||
// 可以根据User-Agent进行特殊处理
|
||
}
|
||
|
||
if accept := req.Header.Get("Accept"); accept != "" {
|
||
// 可以根据Accept头调整输出格式
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// ToQuery 将转换请求转换为查询字符串
|
||
// 返回URL编码的查询字符串
|
||
func (r *ConversionRequest) ToQuery() string {
|
||
values := url.Values{}
|
||
|
||
// 基本参数
|
||
values.Set("target", r.Target)
|
||
values.Set("url", r.SourceSummary())
|
||
|
||
// 可选参数
|
||
if r.ConfigURL != "" {
|
||
values.Set("config", r.ConfigURL)
|
||
}
|
||
values.Set("emoji", strconv.FormatBool(r.Emoji))
|
||
values.Set("udp", strconv.FormatBool(r.UDP))
|
||
values.Set("ipv6", strconv.FormatBool(r.IPv6))
|
||
|
||
if r.Group != "" {
|
||
values.Set("group", r.Group)
|
||
}
|
||
values.Set("insert", strconv.FormatBool(r.Insert))
|
||
values.Set("strict", strconv.FormatBool(r.Strict))
|
||
values.Set("compatible", strconv.FormatBool(r.Compatible))
|
||
|
||
// 过滤参数
|
||
if len(r.Include) > 0 {
|
||
values.Set("include", strings.Join(r.Include, ","))
|
||
}
|
||
if len(r.Exclude) > 0 {
|
||
values.Set("exclude", strings.Join(r.Exclude, ","))
|
||
}
|
||
if r.Regex != "" {
|
||
values.Set("regex", r.Regex)
|
||
}
|
||
if r.Location != "" {
|
||
values.Set("location", r.Location)
|
||
}
|
||
|
||
// 高级参数
|
||
values.Set("test", strconv.FormatBool(r.Test))
|
||
values.Set("list", strconv.FormatBool(r.List))
|
||
if r.Filename != "" {
|
||
values.Set("filename", r.Filename)
|
||
}
|
||
|
||
return values.Encode()
|
||
}
|
||
|
||
// Clone 创建转换请求的副本
|
||
// 返回新的ConversionRequest实例
|
||
func (r *ConversionRequest) Clone() *ConversionRequest {
|
||
return &ConversionRequest{
|
||
Target: r.Target,
|
||
URL: r.URL,
|
||
RawURL: r.RawURL,
|
||
Sources: append([]string{}, r.Sources...),
|
||
ConfigURL: r.ConfigURL,
|
||
Emoji: r.Emoji,
|
||
UDP: r.UDP,
|
||
Group: r.Group,
|
||
Insert: r.Insert,
|
||
Strict: r.Strict,
|
||
Compatible: r.Compatible,
|
||
Include: append([]string{}, r.Include...),
|
||
Exclude: append([]string{}, r.Exclude...),
|
||
Regex: r.Regex,
|
||
Location: r.Location,
|
||
Test: r.Test,
|
||
List: r.List,
|
||
Filename: r.Filename,
|
||
Providers: append([]string{}, r.Providers...),
|
||
EmojiRules: append([]string{}, r.EmojiRules...),
|
||
}
|
||
}
|
||
|
||
// CacheSignature returns a canonical representation of the request for caching.
|
||
func (r *ConversionRequest) CacheSignature() string {
|
||
sources := r.GetSources()
|
||
signature := struct {
|
||
Target string `json:"target"`
|
||
Sources []string `json:"sources"`
|
||
ConfigURL string `json:"config_url"`
|
||
Emoji bool `json:"emoji"`
|
||
UDP bool `json:"udp"`
|
||
IPv6 bool `json:"ipv6"`
|
||
Group string `json:"group"`
|
||
Insert bool `json:"insert"`
|
||
Strict bool `json:"strict"`
|
||
Compatible bool `json:"compatible"`
|
||
AppendType bool `json:"append_type"`
|
||
TFO bool `json:"tfo"`
|
||
Script bool `json:"script"`
|
||
SkipCert bool `json:"skip_cert"`
|
||
FilterDeprecated bool `json:"filter_deprecated"`
|
||
ExpandRules bool `json:"expand_rules"`
|
||
AppendInfo bool `json:"append_info"`
|
||
Prepend bool `json:"prepend"`
|
||
Classic bool `json:"classic"`
|
||
TLS13 bool `json:"tls13"`
|
||
AddEmoji bool `json:"add_emoji"`
|
||
RemoveEmoji bool `json:"remove_emoji"`
|
||
Upload bool `json:"upload"`
|
||
UploadPath string `json:"upload_path"`
|
||
Filename string `json:"filename"`
|
||
Test bool `json:"test"`
|
||
List bool `json:"list"`
|
||
DeviceID string `json:"device_id"`
|
||
Interval string `json:"interval"`
|
||
Include []string `json:"include"`
|
||
Exclude []string `json:"exclude"`
|
||
Regex string `json:"regex"`
|
||
Location string `json:"location"`
|
||
RenameRules []string `json:"rename_rules"`
|
||
Groups []string `json:"groups"`
|
||
Rulesets []string `json:"rulesets"`
|
||
Providers []string `json:"providers"`
|
||
EmojiRules []string `json:"emoji_rules"`
|
||
FilterScript string `json:"filter_script"`
|
||
BasePath string `json:"base_path"`
|
||
}{
|
||
Target: strings.ToLower(r.Target),
|
||
Sources: append([]string{}, sources...),
|
||
ConfigURL: r.ConfigURL,
|
||
Emoji: r.Emoji,
|
||
UDP: r.UDP,
|
||
IPv6: r.IPv6,
|
||
Group: r.Group,
|
||
Insert: r.Insert,
|
||
Strict: r.Strict,
|
||
Compatible: r.Compatible,
|
||
AppendType: r.AppendType,
|
||
TFO: r.TFO,
|
||
Script: r.Script,
|
||
SkipCert: r.SkipCert,
|
||
FilterDeprecated: r.FilterDeprecated,
|
||
ExpandRules: r.ExpandRules,
|
||
AppendInfo: r.AppendInfo,
|
||
Prepend: r.Prepend,
|
||
Classic: r.Classic,
|
||
TLS13: r.TLS13,
|
||
AddEmoji: r.AddEmoji,
|
||
RemoveEmoji: r.RemoveEmoji,
|
||
Upload: r.Upload,
|
||
UploadPath: r.UploadPath,
|
||
Filename: r.Filename,
|
||
Test: r.Test,
|
||
List: r.List,
|
||
DeviceID: r.DeviceID,
|
||
Interval: r.Interval,
|
||
Include: append([]string{}, r.Include...),
|
||
Exclude: append([]string{}, r.Exclude...),
|
||
Regex: r.Regex,
|
||
Location: r.Location,
|
||
RenameRules: append([]string{}, r.RenameRules...),
|
||
Groups: append([]string{}, r.Groups...),
|
||
Rulesets: append([]string{}, r.Rulesets...),
|
||
Providers: append([]string{}, r.Providers...),
|
||
EmojiRules: append([]string{}, r.EmojiRules...),
|
||
FilterScript: r.FilterScript,
|
||
BasePath: r.BasePath,
|
||
}
|
||
|
||
data, err := json.Marshal(signature)
|
||
if err != nil {
|
||
return fmt.Sprintf("%s|%s|%t|%t|%t", signature.Target, r.SourceSummary(), r.AddEmoji, r.RemoveEmoji, r.Upload)
|
||
}
|
||
return string(data)
|
||
}
|
||
|
||
func parseSourcesFromRaw(raw string) ([]string, error) {
|
||
trimmed := strings.TrimSpace(raw)
|
||
if trimmed == "" {
|
||
return nil, nil
|
||
}
|
||
sources := splitSources(trimmed)
|
||
if len(sources) <= 1 {
|
||
if decoded, ok := decodeURLListFromBase64(trimmed); ok {
|
||
sources = splitSources(decoded)
|
||
}
|
||
}
|
||
if len(sources) == 0 && trimmed != "" {
|
||
sources = []string{trimmed}
|
||
}
|
||
return sources, nil
|
||
}
|
||
|
||
func splitSources(input string) []string {
|
||
if input == "" {
|
||
return nil
|
||
}
|
||
normalized := strings.ReplaceAll(input, "\r\n", "\n")
|
||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||
parts := strings.FieldsFunc(normalized, func(r rune) bool {
|
||
switch r {
|
||
case '|', '\n', ',', ';':
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
})
|
||
result := make([]string, 0, len(parts))
|
||
for _, part := range parts {
|
||
if trimmed := strings.TrimSpace(part); trimmed != "" {
|
||
result = append(result, trimmed)
|
||
}
|
||
}
|
||
if len(result) == 0 {
|
||
if trimmed := strings.TrimSpace(input); trimmed != "" {
|
||
result = append(result, trimmed)
|
||
}
|
||
}
|
||
return result
|
||
}
|
||
|
||
func decodeURLListFromBase64(raw string) (string, bool) {
|
||
data, ok := decodeBase64String(raw)
|
||
if !ok || len(data) == 0 {
|
||
return "", false
|
||
}
|
||
decoded := strings.TrimSpace(string(data))
|
||
lower := strings.ToLower(decoded)
|
||
if !strings.Contains(lower, "http://") && !strings.Contains(lower, "https://") {
|
||
return "", false
|
||
}
|
||
return decoded, true
|
||
}
|
||
|
||
func decodeBase64String(raw string) ([]byte, bool) {
|
||
s := strings.TrimSpace(raw)
|
||
if s == "" {
|
||
return nil, false
|
||
}
|
||
s = strings.ReplaceAll(s, "\n", "")
|
||
s = strings.ReplaceAll(s, "\r", "")
|
||
if rem := len(s) % 4; rem != 0 {
|
||
s += strings.Repeat("=", 4-rem)
|
||
}
|
||
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.URLEncoding} {
|
||
decoded, err := enc.DecodeString(s)
|
||
if err == nil && len(decoded) > 0 {
|
||
return decoded, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// MatchesFilter 检查代理是否匹配过滤条件
|
||
// 返回true如果代理匹配所有过滤条件
|
||
func (r *ConversionRequest) MatchesFilter(proxyName, proxyLocation string) bool {
|
||
// 检查包含关键词
|
||
for _, include := range r.Include {
|
||
if !strings.Contains(strings.ToLower(proxyName), strings.ToLower(include)) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 检查排除关键词
|
||
for _, exclude := range r.Exclude {
|
||
if strings.Contains(strings.ToLower(proxyName), strings.ToLower(exclude)) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 检查正则表达式
|
||
if r.Regex != "" {
|
||
if matched, err := regexp.MatchString(r.Regex, proxyName); err != nil || !matched {
|
||
return false
|
||
}
|
||
}
|
||
|
||
// 检查地区
|
||
if r.Location != "" && proxyLocation != "" {
|
||
if !strings.EqualFold(proxyLocation, r.Location) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
// GetOutputFilename 获取输出文件名
|
||
// 根据配置生成合适的输出文件名
|
||
func (r *ConversionRequest) GetOutputFilename() string {
|
||
if r.Filename != "" {
|
||
// 确保有正确的扩展名
|
||
if !strings.HasSuffix(r.Filename, "."+r.GetFileExtension()) {
|
||
return r.Filename + "." + r.GetFileExtension()
|
||
}
|
||
return r.Filename
|
||
}
|
||
|
||
// 生成默认文件名
|
||
defaultName := "converted"
|
||
if r.Group != "" {
|
||
defaultName = r.Group
|
||
}
|
||
|
||
return fmt.Sprintf("%s.%s", defaultName, r.GetFileExtension())
|
||
}
|
||
|
||
// GetFileExtension 获取目标格式的文件扩展名
|
||
// 返回对应格式的文件扩展名
|
||
func (r *ConversionRequest) GetFileExtension() string {
|
||
switch strings.ToLower(r.Target) {
|
||
case "clash":
|
||
return "yaml"
|
||
case "surge":
|
||
return "conf"
|
||
case "quanx":
|
||
return "conf"
|
||
case "loon":
|
||
return "conf"
|
||
case "surfboard":
|
||
return "conf"
|
||
case "v2ray":
|
||
return "json"
|
||
default:
|
||
return "txt"
|
||
}
|
||
}
|
||
|
||
// GetContentType 获取响应的Content-Type
|
||
// 返回适合目标格式的MIME类型
|
||
func (r *ConversionRequest) GetContentType() string {
|
||
if r.List {
|
||
return "text/plain; charset=utf-8"
|
||
}
|
||
switch strings.ToLower(r.Target) {
|
||
case "clash":
|
||
return "text/yaml; charset=utf-8"
|
||
case "surge", "quanx", "loon", "surfboard":
|
||
return "text/plain; charset=utf-8"
|
||
case "v2ray":
|
||
return "application/json; charset=utf-8"
|
||
default:
|
||
return "text/plain; charset=utf-8"
|
||
}
|
||
}
|
||
|
||
// IsCompatible 检查是否与C++版本兼容
|
||
// 返回true如果使用兼容模式
|
||
func (r *ConversionRequest) IsCompatible() bool {
|
||
return r.Compatible
|
||
}
|
||
|
||
// ShouldIncludeTests 检查是否应该包含测试信息
|
||
// 返回true如果启用了测试模式
|
||
func (r *ConversionRequest) ShouldIncludeTests() bool {
|
||
return r.Test
|
||
}
|
||
|
||
// ShouldShowList 检查是否应该显示列表
|
||
// 返回true如果是列表模式
|
||
func (r *ConversionRequest) ShouldShowList() bool {
|
||
return r.List
|
||
}
|
||
|
||
// ShouldInsertURLTest 检查是否应该插入URL测试
|
||
// 返回true如果启用了插入URL测试
|
||
func (r *ConversionRequest) ShouldInsertURLTest() bool {
|
||
return r.Insert
|
||
}
|
||
|
||
// IsStrictMode 检查是否是严格模式
|
||
// 返回true如果启用了严格模式
|
||
func (r *ConversionRequest) IsStrictMode() bool {
|
||
return r.Strict
|
||
}
|