first commit
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
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
This commit is contained in:
106
internal/conversion/converter.go
Normal file
106
internal/conversion/converter.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package conversion
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/subconverter-go/internal/config"
|
||||
"github.com/subconverter-go/internal/logging"
|
||||
)
|
||||
|
||||
// Converter 代理订阅转换器
|
||||
// 负责在不同代理格式之间进行转换
|
||||
type Converter struct {
|
||||
logger *logging.Logger
|
||||
configMgr *config.ConfigManager
|
||||
}
|
||||
|
||||
// NewConverter 创建新的转换器
|
||||
// 返回初始化好的Converter实例
|
||||
func NewConverter(logger *logging.Logger, configMgr *config.ConfigManager) *Converter {
|
||||
return &Converter{
|
||||
logger: logger,
|
||||
configMgr: configMgr,
|
||||
}
|
||||
}
|
||||
|
||||
// Convert 执行转换操作
|
||||
// 将输入的订阅转换为指定格式的配置
|
||||
func (c *Converter) Convert(req *ConversionRequest) (*ConversionResponse, error) {
|
||||
c.logger.Infof("Converting subscription from %s to %s format", req.SourceSummary(), req.Target)
|
||||
|
||||
// 这里实现实际的转换逻辑
|
||||
// 目前只是占位符,后续会实现完整的转换功能
|
||||
|
||||
// 验证目标格式是否支持
|
||||
if !c.isTargetSupported(req.Target) {
|
||||
return nil, fmt.Errorf("unsupported target format: %s", req.Target)
|
||||
}
|
||||
|
||||
// 创建响应
|
||||
resp := NewConversionResponse()
|
||||
resp.Success = true
|
||||
resp.Content = "# Placeholder converted configuration\n# Target: " + req.Target + "\n# Source: " + req.SourceSummary() + "\n"
|
||||
resp.ContentType = "text/plain"
|
||||
resp.TargetFormat = req.Target
|
||||
resp.SourceURL = req.SourceSummary()
|
||||
resp.ConfigURL = req.ConfigURL
|
||||
resp.NodeCount = 0
|
||||
resp.ProcessingTime = 0
|
||||
|
||||
c.logger.Infof("Conversion completed successfully for target: %s", req.Target)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// isTargetSupported 检查目标格式是否支持
|
||||
func (c *Converter) isTargetSupported(target string) bool {
|
||||
config := c.configMgr.GetConfig()
|
||||
supportedTargets := config.Conversion.SupportedTargets
|
||||
|
||||
for _, supported := range supportedTargets {
|
||||
if supported == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetSupportedTargets 获取支持的目标格式列表
|
||||
func (c *Converter) GetSupportedTargets() []string {
|
||||
config := c.configMgr.GetConfig()
|
||||
return config.Conversion.SupportedTargets
|
||||
}
|
||||
|
||||
// ValidateRequest 验证转换请求
|
||||
func (c *Converter) ValidateRequest(req *ConversionRequest) error {
|
||||
// 验证目标格式
|
||||
if !c.isTargetSupported(req.Target) {
|
||||
return fmt.Errorf("unsupported target format: %s", req.Target)
|
||||
}
|
||||
|
||||
// 验证订阅URL
|
||||
if len(req.GetSources()) == 0 {
|
||||
return fmt.Errorf("subscription URL is required")
|
||||
}
|
||||
|
||||
// 验证配置URL(如果提供)
|
||||
if req.ConfigURL != "" {
|
||||
// 这里应该验证URL格式
|
||||
// 目前简单检查不为空
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatistics 获取转换统计信息
|
||||
func (c *Converter) GetStatistics() map[string]interface{} {
|
||||
// 返回转换统计信息
|
||||
return map[string]interface{}{
|
||||
"total_conversions": 0,
|
||||
"successful_conversions": 0,
|
||||
"failed_conversions": 0,
|
||||
"cache_hits": 0,
|
||||
"cache_misses": 0,
|
||||
"supported_targets": c.GetSupportedTargets(),
|
||||
}
|
||||
}
|
||||
1741
internal/conversion/engine.go
Normal file
1741
internal/conversion/engine.go
Normal file
File diff suppressed because it is too large
Load Diff
169
internal/conversion/engine_internal_test.go
Normal file
169
internal/conversion/engine_internal_test.go
Normal file
@@ -0,0 +1,169 @@
|
||||
package conversion
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/subconverter-go/internal/config"
|
||||
"github.com/subconverter-go/internal/generator"
|
||||
"github.com/subconverter-go/internal/logging"
|
||||
"github.com/subconverter-go/internal/parser"
|
||||
)
|
||||
|
||||
func newTestConversionEngine(t *testing.T) *ConversionEngine {
|
||||
t.Helper()
|
||||
|
||||
logger, err := logging.NewLogger(&logging.LoggingConfig{
|
||||
Level: "error",
|
||||
Format: "text",
|
||||
Output: "stdout",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create logger: %v", err)
|
||||
}
|
||||
|
||||
configMgr, err := config.NewConfigManager("")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create config manager: %v", err)
|
||||
}
|
||||
|
||||
parserMgr := parser.NewParserManager(logger, configMgr)
|
||||
generatorMgr := generator.NewGeneratorManager(logger)
|
||||
|
||||
return NewConversionEngine(logger, parserMgr, generatorMgr)
|
||||
}
|
||||
|
||||
func TestTrimLineCleansCommentsAndBom(t *testing.T) {
|
||||
engine := newTestConversionEngine(t)
|
||||
|
||||
cases := map[string]string{
|
||||
"\ufeff ss://example": "ss://example",
|
||||
"# leading comment": "",
|
||||
" // spaced comment": "",
|
||||
"\t;remark": "",
|
||||
"ss://example": "ss://example",
|
||||
}
|
||||
|
||||
for input, expected := range cases {
|
||||
if got := engine.trimLine(input); got != expected {
|
||||
t.Fatalf("trimLine(%q) = %q, expected %q", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConfigurationsHandlesLegacyProtocols(t *testing.T) {
|
||||
engine := newTestConversionEngine(t)
|
||||
|
||||
vmessPayload := "eyJ2IjoiMiIsInBzIjoidGVzdCIsImFkZCI6IjE5Mi4xNjguMS4xIiwicG9ydCI6IjQ0MyIsImlkIjoiMTIzNDU2NzgtMTIzNC0xMjM0LTEyMzQtMTIzNDU2Nzg5MDEyIiwiYWlkIjoiMCIsIm5ldCI6IndzIiwidHlwZSI6Im5vbmUiLCJob3N0IjoidGVzdC5jb20iLCJwYXRoIjoiL3Rlc3QiLCJ0bHMiOiJ0bHMifQ=="
|
||||
|
||||
input := "\ufeff# first line is a comment\n" +
|
||||
"socks://user:pass@example.com:1080#socks-proxy\n" +
|
||||
"vmess1://" + vmessPayload + "\n"
|
||||
|
||||
configs, result, err := engine.parseConfigurations(input, true)
|
||||
if err != nil {
|
||||
t.Fatalf("parseConfigurations returned error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.InvalidInputs) != 0 {
|
||||
t.Fatalf("expected no invalid inputs, got %v", result.InvalidInputs)
|
||||
}
|
||||
|
||||
if len(configs) != 2 {
|
||||
t.Fatalf("expected 2 valid configs, got %d", len(configs))
|
||||
}
|
||||
|
||||
protocols := []string{configs[0].Protocol, configs[1].Protocol}
|
||||
expected := map[string]bool{"socks5": false, "vmess": false}
|
||||
for _, protocol := range protocols {
|
||||
if _, ok := expected[protocol]; !ok {
|
||||
t.Fatalf("unexpected protocol parsed: %s", protocol)
|
||||
}
|
||||
expected[protocol] = true
|
||||
}
|
||||
|
||||
for proto, seen := range expected {
|
||||
if !seen {
|
||||
t.Fatalf("protocol %s not detected in parsed configs", proto)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateStatsIncludedInDebugInfo(t *testing.T) {
|
||||
engine := newTestConversionEngine(t)
|
||||
|
||||
req := &ConversionRequest{Target: "clash", URL: "https://example.com"}
|
||||
configs := []*parser.ProxyConfig{{Protocol: "socks5"}}
|
||||
parseResult := &ParseResult{
|
||||
ValidConfigs: configs,
|
||||
ProtocolStats: map[string]int{"socks5": 1},
|
||||
}
|
||||
generation := &GenerateResult{
|
||||
GeneratedOutput: "payload",
|
||||
FormatFeatures: map[string]bool{"success": true},
|
||||
GenerateStats: &GenerateStats{
|
||||
GeneratedProxies: 1,
|
||||
GeneratedGroups: 2,
|
||||
GeneratedRules: 3,
|
||||
FormatFeatures: map[string]bool{"success": true},
|
||||
},
|
||||
}
|
||||
|
||||
resp := engine.createSuccessResponse(req, "payload", configs, parseResult, generation)
|
||||
statsVal, ok := resp.DebugInfo["generate_stats"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected generate_stats in debug info, got %v", resp.DebugInfo)
|
||||
}
|
||||
|
||||
if statsVal["generated_proxies"].(int) != 1 {
|
||||
t.Fatalf("expected generated_proxies=1, got %v", statsVal["generated_proxies"])
|
||||
}
|
||||
if statsVal["generated_groups"].(int) != 2 {
|
||||
t.Fatalf("expected generated_groups=2, got %v", statsVal["generated_groups"])
|
||||
}
|
||||
if statsVal["generated_rules"].(int) != 3 {
|
||||
t.Fatalf("expected generated_rules=3, got %v", statsVal["generated_rules"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveLeadingEmojiHandlesVariants(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"🛰️ ss-192.168.1.1:8388": "ss-192.168.1.1:8388",
|
||||
"🇭🇰 ss-node": "ss-node",
|
||||
"🇭🇰ss-node": "ss-node",
|
||||
" ss-node": "ss-node",
|
||||
"": "",
|
||||
}
|
||||
|
||||
for input, expected := range cases {
|
||||
if got := removeLeadingEmoji(input); got != expected {
|
||||
t.Fatalf("removeLeadingEmoji(%q) = %q, want %q", input, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyTransformationsRemoveEmojiAfterRename(t *testing.T) {
|
||||
engine := newTestConversionEngine(t)
|
||||
req := NewConversionRequest()
|
||||
req.RenameRules = []string{".*@🇭🇰 $0"}
|
||||
req.RemoveEmoji = true
|
||||
|
||||
config := &parser.ProxyConfig{
|
||||
Name: "ss-192.168.1.1:8388",
|
||||
Remarks: "",
|
||||
Protocol: "ss",
|
||||
Type: "ss",
|
||||
Settings: map[string]interface{}{"method": "aes-256-cfb"},
|
||||
}
|
||||
|
||||
updated, err := engine.applyTransformations([]*parser.ProxyConfig{config}, req)
|
||||
if err != nil {
|
||||
t.Fatalf("applyTransformations returned error: %v", err)
|
||||
}
|
||||
if len(updated) != 1 {
|
||||
t.Fatalf("expected one config, got %d", len(updated))
|
||||
}
|
||||
|
||||
if updated[0].Name != "ss-192.168.1.1:8388" {
|
||||
t.Fatalf("expected name without emoji, got %q", updated[0].Name)
|
||||
}
|
||||
}
|
||||
244
internal/conversion/geoip.go
Normal file
244
internal/conversion/geoip.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package conversion
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/subconverter-go/internal/logging"
|
||||
)
|
||||
|
||||
// GeoIPInfo represents a subset of geoip info exposed to scripts.
|
||||
type GeoIPInfo struct {
|
||||
IP string `json:"ip"`
|
||||
CountryCode string `json:"country_code"`
|
||||
CountryName string `json:"country_name"`
|
||||
Region string `json:"region"`
|
||||
City string `json:"city"`
|
||||
ISP string `json:"isp"`
|
||||
ASN string `json:"asn"`
|
||||
Latitude float64 `json:"latitude,omitempty"`
|
||||
Longitude float64 `json:"longitude,omitempty"`
|
||||
}
|
||||
|
||||
// GeoIPResolver resolves geo-location information for a given address.
|
||||
type GeoIPResolver interface {
|
||||
Lookup(ctx context.Context, address string) (*GeoIPInfo, error)
|
||||
}
|
||||
|
||||
const (
|
||||
defaultGeoIPBaseURL = "https://api.ip.sb/geoip"
|
||||
geoIPRequestTimeout = 3 * time.Second
|
||||
geoIPUserAgent = "subconverter-go/geoip"
|
||||
classificationPrivate = "PRIVATE"
|
||||
classificationUnspecified = "UNSPECIFIED"
|
||||
)
|
||||
|
||||
type httpGeoIPResolver struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
logger *logging.Logger
|
||||
}
|
||||
|
||||
func newDefaultGeoIPResolver(logger *logging.Logger) GeoIPResolver {
|
||||
return &httpGeoIPResolver{
|
||||
baseURL: defaultGeoIPBaseURL,
|
||||
client: &http.Client{
|
||||
Timeout: geoIPRequestTimeout,
|
||||
},
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL allows overriding the base geoip endpoint (used for tests).
|
||||
func (r *httpGeoIPResolver) SetBaseURL(base string) {
|
||||
if base != "" {
|
||||
r.baseURL = base
|
||||
}
|
||||
}
|
||||
|
||||
func (r *httpGeoIPResolver) Lookup(ctx context.Context, address string) (*GeoIPInfo, error) {
|
||||
target, err := normalizeGeoIPTarget(address)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if info := classifySpecialIP(target); info != nil {
|
||||
return info, nil
|
||||
}
|
||||
|
||||
if r == nil {
|
||||
return nil, errors.New("geoip resolver is not configured")
|
||||
}
|
||||
|
||||
endpoint, err := buildGeoIPURL(r.baseURL, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", geoIPUserAgent)
|
||||
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return &GeoIPInfo{IP: target}, fmt.Errorf("geoip lookup failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload GeoIPInfo
|
||||
decoder := json.NewDecoder(resp.Body)
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if payload.IP == "" {
|
||||
payload.IP = target
|
||||
}
|
||||
return &payload, nil
|
||||
}
|
||||
|
||||
func normalizeGeoIPTarget(input string) (string, error) {
|
||||
trimmed := strings.TrimSpace(input)
|
||||
if trimmed == "" {
|
||||
return "", errors.New("empty address")
|
||||
}
|
||||
|
||||
// If input is a URL, extract host part.
|
||||
if strings.Contains(trimmed, "://") {
|
||||
if parsed, err := url.Parse(trimmed); err == nil && parsed.Host != "" {
|
||||
trimmed = parsed.Host
|
||||
}
|
||||
}
|
||||
|
||||
// Remove port if present.
|
||||
if host, _, err := net.SplitHostPort(trimmed); err == nil && host != "" {
|
||||
trimmed = host
|
||||
}
|
||||
|
||||
trimmed = strings.Trim(trimmed, "[]")
|
||||
return trimmed, nil
|
||||
}
|
||||
|
||||
func buildGeoIPURL(baseURL, target string) (string, error) {
|
||||
if baseURL == "" {
|
||||
return "", errors.New("geoip base url is empty")
|
||||
}
|
||||
|
||||
parsed, err := url.Parse(baseURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Ensure trailing slash and append escaped target.
|
||||
parsed.Path = path.Join(parsed.Path, url.PathEscape(target))
|
||||
return parsed.String(), nil
|
||||
}
|
||||
|
||||
func classifySpecialIP(target string) *GeoIPInfo {
|
||||
ip := net.ParseIP(target)
|
||||
if ip == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case ip.IsLoopback(), ip.IsUnspecified():
|
||||
return &GeoIPInfo{
|
||||
IP: ip.String(),
|
||||
CountryCode: classificationUnspecified,
|
||||
CountryName: "Unspecified",
|
||||
ISP: "Localhost",
|
||||
}
|
||||
case ip.IsPrivate(), ip.IsLinkLocalMulticast(), ip.IsLinkLocalUnicast():
|
||||
return &GeoIPInfo{
|
||||
IP: ip.String(),
|
||||
CountryCode: classificationPrivate,
|
||||
CountryName: "Private Network",
|
||||
ISP: "Local Network",
|
||||
}
|
||||
case ip.IsMulticast():
|
||||
return &GeoIPInfo{
|
||||
IP: ip.String(),
|
||||
CountryCode: classificationUnspecified,
|
||||
CountryName: "Multicast",
|
||||
ISP: "Multicast",
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) SetGeoIPResolver(resolver GeoIPResolver) {
|
||||
if resolver == nil {
|
||||
ce.geoResolver = newDefaultGeoIPResolver(ce.logger)
|
||||
} else {
|
||||
ce.geoResolver = resolver
|
||||
}
|
||||
ce.geoipCache = sync.Map{}
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) lookupGeoIP(address string) (*GeoIPInfo, error) {
|
||||
target, err := normalizeGeoIPTarget(address)
|
||||
if err != nil || target == "" {
|
||||
return &GeoIPInfo{IP: target}, err
|
||||
}
|
||||
|
||||
if value, ok := ce.geoipCache.Load(target); ok {
|
||||
if info, ok := value.(*GeoIPInfo); ok {
|
||||
return info, nil
|
||||
}
|
||||
}
|
||||
|
||||
resolver := ce.geoResolver
|
||||
if resolver == nil {
|
||||
resolver = newDefaultGeoIPResolver(ce.logger)
|
||||
ce.geoResolver = resolver
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), geoIPRequestTimeout)
|
||||
defer cancel()
|
||||
|
||||
info, err := resolver.Lookup(ctx, target)
|
||||
if err != nil {
|
||||
ce.logger.WithError(err).Debug("geoip lookup failed")
|
||||
if classified := classifySpecialIP(target); classified != nil {
|
||||
info = classified
|
||||
} else if info == nil {
|
||||
info = &GeoIPInfo{IP: target}
|
||||
}
|
||||
}
|
||||
|
||||
if info != nil {
|
||||
ce.geoipCache.Store(target, info)
|
||||
return info, nil
|
||||
}
|
||||
|
||||
return &GeoIPInfo{IP: target}, err
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) geoIPJSON(address string) string {
|
||||
info, err := ce.lookupGeoIP(address)
|
||||
if err != nil {
|
||||
ce.logger.WithError(err).Debug("geoip lookup returned error")
|
||||
}
|
||||
data, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
ce.logger.WithError(err).Warn("failed to marshal geoip info")
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
825
internal/conversion/request.go
Normal file
825
internal/conversion/request.go
Normal file
@@ -0,0 +1,825 @@
|
||||
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
|
||||
}
|
||||
399
internal/conversion/response.go
Normal file
399
internal/conversion/response.go
Normal file
@@ -0,0 +1,399 @@
|
||||
package conversion
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ConversionResponse 表示转换响应的结果
|
||||
// 该结构体包含转换操作的结果和元数据
|
||||
type ConversionResponse struct {
|
||||
// 基本状态
|
||||
Success bool `json:"success"` // 转换是否成功
|
||||
|
||||
// 内容字段
|
||||
Content string `json:"content,omitempty"` // 转换后的配置内容
|
||||
ContentType string `json:"content_type,omitempty"` // 内容类型
|
||||
|
||||
// 错误信息
|
||||
Error string `json:"error,omitempty"` // 错误消息
|
||||
ErrorCode string `json:"error_code,omitempty"` // 错误代码
|
||||
ErrorDetail string `json:"error_detail,omitempty"` // 详细错误信息
|
||||
|
||||
// 元数据
|
||||
Timestamp time.Time `json:"timestamp"` // 处理时间戳
|
||||
ProcessingTime int64 `json:"processing_time_ms"` // 处理时间(毫秒)
|
||||
TargetFormat string `json:"target_format,omitempty"` // 目标格式
|
||||
SourceURL string `json:"source_url,omitempty"` // 源URL
|
||||
ConfigURL string `json:"config_url,omitempty"` // 配置URL
|
||||
|
||||
// 统计信息
|
||||
NodeCount int `json:"node_count,omitempty"` // 节点数量
|
||||
FilteredCount int `json:"filtered_count,omitempty"` // 过滤后的节点数量
|
||||
ProxyTypes []string `json:"proxy_types,omitempty"` // 代理类型列表
|
||||
|
||||
// 响应头信息
|
||||
Headers map[string]string `json:"headers,omitempty"` // 自定义响应头
|
||||
ContentLength int64 `json:"content_length,omitempty"` // 内容长度
|
||||
ETag string `json:"etag,omitempty"` // ETag标识
|
||||
CacheControl string `json:"cache_control,omitempty"` // 缓存控制
|
||||
|
||||
// 调试信息
|
||||
DebugInfo map[string]interface{} `json:"debug_info,omitempty"` // 调试信息
|
||||
RequestID string `json:"request_id,omitempty"` // 请求ID
|
||||
ServerInfo string `json:"server_info,omitempty"` // 服务器信息
|
||||
}
|
||||
|
||||
// NewConversionResponse 创建新的成功响应
|
||||
// 返回包含基本成功信息的ConversionResponse
|
||||
func NewConversionResponse() *ConversionResponse {
|
||||
return &ConversionResponse{
|
||||
Success: true,
|
||||
Timestamp: time.Now(),
|
||||
ProcessingTime: 0,
|
||||
Headers: make(map[string]string),
|
||||
DebugInfo: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// NewErrorResponse 创建新的错误响应
|
||||
// 根据错误信息创建错误响应
|
||||
func NewErrorResponse(message string) *ConversionResponse {
|
||||
return &ConversionResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
ErrorCode: "CONVERSION_ERROR",
|
||||
Timestamp: time.Now(),
|
||||
Headers: make(map[string]string),
|
||||
DebugInfo: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// NewErrorResponseWithCode 创建带有错误代码的错误响应
|
||||
// 返回包含错误代码和详细信息的ConversionResponse
|
||||
func NewErrorResponseWithCode(message, code, detail string) *ConversionResponse {
|
||||
return &ConversionResponse{
|
||||
Success: false,
|
||||
Error: message,
|
||||
ErrorCode: code,
|
||||
ErrorDetail: detail,
|
||||
Timestamp: time.Now(),
|
||||
Headers: make(map[string]string),
|
||||
DebugInfo: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// SetContent 设置响应内容和相关元数据
|
||||
func (r *ConversionResponse) SetContent(content, contentType, targetFormat string) {
|
||||
r.Content = content
|
||||
r.ContentType = contentType
|
||||
r.TargetFormat = targetFormat
|
||||
r.ContentLength = int64(len(content))
|
||||
}
|
||||
|
||||
// SetSource 设置源信息
|
||||
func (r *ConversionResponse) SetSource(sourceURL, configURL string) {
|
||||
r.SourceURL = sourceURL
|
||||
r.ConfigURL = configURL
|
||||
}
|
||||
|
||||
// SetStats 设置统计信息
|
||||
func (r *ConversionResponse) SetStats(nodeCount, filteredCount int, proxyTypes []string) {
|
||||
r.NodeCount = nodeCount
|
||||
r.FilteredCount = filteredCount
|
||||
r.ProxyTypes = proxyTypes
|
||||
}
|
||||
|
||||
// SetProcessingTime 设置处理时间
|
||||
func (r *ConversionResponse) SetProcessingTime(startTime time.Time) {
|
||||
r.ProcessingTime = time.Since(startTime).Milliseconds()
|
||||
}
|
||||
|
||||
// AddHeader 添加响应头
|
||||
func (r *ConversionResponse) AddHeader(key, value string) {
|
||||
if r.Headers == nil {
|
||||
r.Headers = make(map[string]string)
|
||||
}
|
||||
r.Headers[key] = value
|
||||
}
|
||||
|
||||
// AddDebugInfo 添加调试信息
|
||||
func (r *ConversionResponse) AddDebugInfo(key string, value interface{}) {
|
||||
if r.DebugInfo == nil {
|
||||
r.DebugInfo = make(map[string]interface{})
|
||||
}
|
||||
r.DebugInfo[key] = value
|
||||
}
|
||||
|
||||
// SetRequestID 设置请求ID
|
||||
func (r *ConversionResponse) SetRequestID(requestID string) {
|
||||
r.RequestID = requestID
|
||||
}
|
||||
|
||||
// GenerateETag 生成ETag
|
||||
// 基于内容和时间戳生成ETag
|
||||
func (r *ConversionResponse) GenerateETag() string {
|
||||
if r.Content == "" {
|
||||
return ""
|
||||
}
|
||||
// 简单的ETag生成算法,实际可以使用更复杂的方法
|
||||
return fmt.Sprintf("\"%d-%d\"", len(r.Content), r.Timestamp.Unix())
|
||||
}
|
||||
|
||||
// Validate 验证响应的有效性
|
||||
// 返回error如果响应无效,nil表示响应有效
|
||||
func (r *ConversionResponse) Validate() error {
|
||||
// 基本验证
|
||||
if r.Timestamp.IsZero() {
|
||||
return fmt.Errorf("timestamp cannot be zero")
|
||||
}
|
||||
|
||||
if r.Success {
|
||||
// 成功响应的验证
|
||||
if r.Content == "" {
|
||||
return fmt.Errorf("content cannot be empty for successful response")
|
||||
}
|
||||
if r.ContentType == "" {
|
||||
return fmt.Errorf("content type cannot be empty for successful response")
|
||||
}
|
||||
if r.TargetFormat == "" {
|
||||
return fmt.Errorf("target format cannot be empty for successful response")
|
||||
}
|
||||
} else {
|
||||
// 错误响应的验证
|
||||
if r.Error == "" {
|
||||
return fmt.Errorf("error message cannot be empty for failed response")
|
||||
}
|
||||
if r.ErrorCode == "" {
|
||||
return fmt.Errorf("error code cannot be empty for failed response")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToJSON 将响应转换为JSON格式
|
||||
// 返回JSON格式的响应字符串
|
||||
func (r *ConversionResponse) ToJSON() (string, error) {
|
||||
if err := r.Validate(); err != nil {
|
||||
return "", fmt.Errorf("response validation failed: %v", err)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal response: %v", err)
|
||||
}
|
||||
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// ToJSONBytes 将响应转换为JSON字节数组
|
||||
// 返回JSON格式的响应字节数组
|
||||
func (r *ConversionResponse) ToJSONBytes() ([]byte, error) {
|
||||
if err := r.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("response validation failed: %v", err)
|
||||
}
|
||||
|
||||
return json.Marshal(r)
|
||||
}
|
||||
|
||||
// FromJSON 从JSON字符串解析响应
|
||||
// 解析JSON字符串到ConversionResponse
|
||||
func (r *ConversionResponse) FromJSON(jsonStr string) error {
|
||||
return json.Unmarshal([]byte(jsonStr), r)
|
||||
}
|
||||
|
||||
// FromJSONBytes 从JSON字节数组解析响应
|
||||
// 解析JSON字节数组到ConversionResponse
|
||||
func (r *ConversionResponse) FromJSONBytes(data []byte) error {
|
||||
return json.Unmarshal(data, r)
|
||||
}
|
||||
|
||||
// IsSuccess 检查是否成功
|
||||
// 返回true如果转换成功
|
||||
func (r *ConversionResponse) IsSuccess() bool {
|
||||
return r.Success
|
||||
}
|
||||
|
||||
// GetError 获取错误信息
|
||||
// 返回错误消息和错误代码
|
||||
func (r *ConversionResponse) GetError() (string, string) {
|
||||
return r.Error, r.ErrorCode
|
||||
}
|
||||
|
||||
// GetContentLength 获取内容长度
|
||||
// 返回内容的字节长度
|
||||
func (r *ConversionResponse) GetContentLength() int64 {
|
||||
return r.ContentLength
|
||||
}
|
||||
|
||||
// GetProcessingTime 获取处理时间
|
||||
// 返回处理时间的毫秒数
|
||||
func (r *ConversionResponse) GetProcessingTime() int64 {
|
||||
return r.ProcessingTime
|
||||
}
|
||||
|
||||
// GetNodeCount 获取节点数量
|
||||
// 返回处理的节点数量
|
||||
func (r *ConversionResponse) GetNodeCount() int {
|
||||
return r.NodeCount
|
||||
}
|
||||
|
||||
// GetFilteredCount 获取过滤后的节点数量
|
||||
// 返回过滤后的节点数量
|
||||
func (r *ConversionResponse) GetFilteredCount() int {
|
||||
return r.FilteredCount
|
||||
}
|
||||
|
||||
// GetProxyTypes 获取代理类型列表
|
||||
// 返回包含的代理类型
|
||||
func (r *ConversionResponse) GetProxyTypes() []string {
|
||||
return r.ProxyTypes
|
||||
}
|
||||
|
||||
// GetHeaders 获取响应头
|
||||
// 返回自定义响应头映射
|
||||
func (r *ConversionResponse) GetHeaders() map[string]string {
|
||||
return r.Headers
|
||||
}
|
||||
|
||||
// GetDebugInfo 获取调试信息
|
||||
// 返回调试信息映射
|
||||
func (r *ConversionResponse) GetDebugInfo() map[string]interface{} {
|
||||
return r.DebugInfo
|
||||
}
|
||||
|
||||
// GetTimestamp 获取时间戳
|
||||
// 返回处理时间戳
|
||||
func (r *ConversionResponse) GetTimestamp() time.Time {
|
||||
return r.Timestamp
|
||||
}
|
||||
|
||||
// GetTargetFormat 获取目标格式
|
||||
// 返回转换的目标格式
|
||||
func (r *ConversionResponse) GetTargetFormat() string {
|
||||
return r.TargetFormat
|
||||
}
|
||||
|
||||
// GetSourceURL 获取源URL
|
||||
// 返回处理的源URL
|
||||
func (r *ConversionResponse) GetSourceURL() string {
|
||||
return r.SourceURL
|
||||
}
|
||||
|
||||
// GetConfigURL 获取配置URL
|
||||
// 返回使用的配置URL
|
||||
func (r *ConversionResponse) GetConfigURL() string {
|
||||
return r.ConfigURL
|
||||
}
|
||||
|
||||
// Clone 创建响应的副本
|
||||
// 返回新的ConversionResponse实例
|
||||
func (r *ConversionResponse) Clone() *ConversionResponse {
|
||||
clone := &ConversionResponse{
|
||||
Success: r.Success,
|
||||
Content: r.Content,
|
||||
ContentType: r.ContentType,
|
||||
Error: r.Error,
|
||||
ErrorCode: r.ErrorCode,
|
||||
ErrorDetail: r.ErrorDetail,
|
||||
Timestamp: r.Timestamp,
|
||||
ProcessingTime: r.ProcessingTime,
|
||||
TargetFormat: r.TargetFormat,
|
||||
SourceURL: r.SourceURL,
|
||||
ConfigURL: r.ConfigURL,
|
||||
NodeCount: r.NodeCount,
|
||||
FilteredCount: r.FilteredCount,
|
||||
ContentLength: r.ContentLength,
|
||||
ETag: r.ETag,
|
||||
CacheControl: r.CacheControl,
|
||||
RequestID: r.RequestID,
|
||||
ServerInfo: r.ServerInfo,
|
||||
}
|
||||
|
||||
// 深拷贝切片
|
||||
if r.ProxyTypes != nil {
|
||||
clone.ProxyTypes = make([]string, len(r.ProxyTypes))
|
||||
copy(clone.ProxyTypes, r.ProxyTypes)
|
||||
}
|
||||
|
||||
// 深拷贝映射
|
||||
if r.Headers != nil {
|
||||
clone.Headers = make(map[string]string)
|
||||
for k, v := range r.Headers {
|
||||
clone.Headers[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
if r.DebugInfo != nil {
|
||||
clone.DebugInfo = make(map[string]interface{})
|
||||
for k, v := range r.DebugInfo {
|
||||
clone.DebugInfo[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// WithRequestID 创建带有请求ID的响应副本
|
||||
// 返回新的ConversionResponse实例,包含指定的请求ID
|
||||
func (r *ConversionResponse) WithRequestID(requestID string) *ConversionResponse {
|
||||
clone := r.Clone()
|
||||
clone.RequestID = requestID
|
||||
return clone
|
||||
}
|
||||
|
||||
// WithHeaders 创建带有自定义响应头的响应副本
|
||||
// 返回新的ConversionResponse实例,包含指定的响应头
|
||||
func (r *ConversionResponse) WithHeaders(headers map[string]string) *ConversionResponse {
|
||||
clone := r.Clone()
|
||||
if clone.Headers == nil {
|
||||
clone.Headers = make(map[string]string)
|
||||
}
|
||||
for k, v := range headers {
|
||||
clone.Headers[k] = v
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
// WithCacheControl 创建带有缓存控制的响应副本
|
||||
// 返回新的ConversionResponse实例,包含指定的缓存控制
|
||||
func (r *ConversionResponse) WithCacheControl(cacheControl string) *ConversionResponse {
|
||||
clone := r.Clone()
|
||||
clone.CacheControl = cacheControl
|
||||
return clone
|
||||
}
|
||||
|
||||
// ToErrorResponse 转换为错误响应
|
||||
// 将当前响应转换为错误响应
|
||||
func (r *ConversionResponse) ToErrorResponse(message, code string) *ConversionResponse {
|
||||
r.Success = false
|
||||
r.Error = message
|
||||
r.ErrorCode = code
|
||||
r.Content = ""
|
||||
r.ContentType = ""
|
||||
r.ContentLength = 0
|
||||
return r
|
||||
}
|
||||
|
||||
// ToSuccessResponse 转换为成功响应
|
||||
// 将当前响应转换为成功响应
|
||||
func (r *ConversionResponse) ToSuccessResponse(content, contentType, targetFormat string) *ConversionResponse {
|
||||
r.Success = true
|
||||
r.Error = ""
|
||||
r.ErrorCode = ""
|
||||
r.ErrorDetail = ""
|
||||
r.SetContent(content, contentType, targetFormat)
|
||||
return r
|
||||
}
|
||||
|
||||
// String 返回响应的字符串表示
|
||||
// 实现Stringer接口
|
||||
func (r *ConversionResponse) String() string {
|
||||
if r.Success {
|
||||
return fmt.Sprintf("Success: %s format, %d nodes, %dms",
|
||||
r.TargetFormat, r.NodeCount, r.ProcessingTime)
|
||||
}
|
||||
return fmt.Sprintf("Error: %s (%s)", r.Error, r.ErrorCode)
|
||||
}
|
||||
463
internal/conversion/rule_support.go
Normal file
463
internal/conversion/rule_support.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package conversion
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/subconverter-go/internal/logging"
|
||||
"github.com/subconverter-go/internal/parser"
|
||||
)
|
||||
|
||||
type renameRule struct {
|
||||
pattern *regexp.Regexp
|
||||
replacement string
|
||||
script *scriptRunner
|
||||
}
|
||||
|
||||
type emojiRule struct {
|
||||
pattern *regexp.Regexp
|
||||
emoji string
|
||||
script *scriptRunner
|
||||
}
|
||||
|
||||
type scriptRunner struct {
|
||||
fn goja.Callable
|
||||
runtime *goja.Runtime
|
||||
funcName string
|
||||
}
|
||||
|
||||
type scriptNode struct {
|
||||
Group string
|
||||
GroupID int
|
||||
Index int
|
||||
Remark string
|
||||
OriginalRemark string
|
||||
ProxyInfo string
|
||||
}
|
||||
|
||||
const (
|
||||
renameFunctionName = "rename"
|
||||
emojiFunctionName = "getEmoji"
|
||||
filterFunctionName = "filter"
|
||||
maxImportDepth = 8
|
||||
)
|
||||
|
||||
func (ce *ConversionEngine) compileRenameRules(req *ConversionRequest) ([]*renameRule, error) {
|
||||
if len(req.RenameRules) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
flattened, err := ce.expandRuleEntries(req.RenameRules, req.BasePath, 0, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]*renameRule, 0, len(flattened))
|
||||
for _, entry := range flattened {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(entry)
|
||||
if strings.HasPrefix(lower, "!!script:") {
|
||||
src := strings.TrimSpace(entry[len("!!script:"):])
|
||||
runner, err := ce.newScriptRunner(src, req.BasePath, renameFunctionName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid rename script: %w", err)
|
||||
}
|
||||
rules = append(rules, &renameRule{script: runner})
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(entry, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
ce.logger.Warnf("Skipping invalid rename rule without separator: %s", entry)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern := strings.TrimSpace(parts[0])
|
||||
replacement := parts[1]
|
||||
if pattern == "" {
|
||||
ce.logger.Warnf("Skipping rename rule with empty pattern")
|
||||
continue
|
||||
}
|
||||
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid rename regex %q: %w", pattern, err)
|
||||
}
|
||||
rules = append(rules, &renameRule{pattern: compiled, replacement: replacement})
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) compileEmojiRules(req *ConversionRequest) ([]*emojiRule, error) {
|
||||
if len(req.EmojiRules) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
flattened, err := ce.expandRuleEntries(req.EmojiRules, req.BasePath, 0, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]*emojiRule, 0, len(flattened))
|
||||
for _, entry := range flattened {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(entry)
|
||||
if strings.HasPrefix(lower, "!!script:") {
|
||||
src := strings.TrimSpace(entry[len("!!script:"):])
|
||||
runner, err := ce.newScriptRunner(src, req.BasePath, emojiFunctionName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid emoji script: %w", err)
|
||||
}
|
||||
rules = append(rules, &emojiRule{script: runner})
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(entry, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
ce.logger.Warnf("Skipping invalid emoji rule without separator: %s", entry)
|
||||
continue
|
||||
}
|
||||
pattern := strings.TrimSpace(parts[0])
|
||||
emoji := strings.TrimSpace(parts[1])
|
||||
if pattern == "" || emoji == "" {
|
||||
ce.logger.Warnf("Skipping emoji rule with empty pattern or emoji")
|
||||
continue
|
||||
}
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid emoji regex %q: %w", pattern, err)
|
||||
}
|
||||
rules = append(rules, &emojiRule{pattern: compiled, emoji: emoji})
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) expandRuleEntries(raw []string, basePath string, depth int, req *ConversionRequest) ([]string, error) {
|
||||
if depth > maxImportDepth {
|
||||
return nil, fmt.Errorf("maximum import depth exceeded")
|
||||
}
|
||||
|
||||
var result []string
|
||||
for _, entry := range raw {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(entry)
|
||||
if strings.HasPrefix(lower, "!!import:") {
|
||||
path := strings.TrimSpace(entry[len("!!import:"):])
|
||||
if path == "" {
|
||||
ce.logger.Warn("Encountered empty !!import directive")
|
||||
continue
|
||||
}
|
||||
content, resolved, err := ce.loadImportResource(path, basePath, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
ce.logger.Warnf("Encountered empty import content at %s", path)
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(resolved))
|
||||
if ext == ".js" || ext == ".mjs" || ext == ".ts" {
|
||||
script := strings.TrimSpace(content)
|
||||
if script == "" {
|
||||
ce.logger.Warnf("Encountered empty script import at %s", path)
|
||||
continue
|
||||
}
|
||||
result = append(result, "!!script:"+script)
|
||||
continue
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
clean := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed[0] == ';' || trimmed[0] == '#' || strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, trimmed)
|
||||
}
|
||||
nested, err := ce.expandRuleEntries(clean, basePath, depth+1, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, nested...)
|
||||
continue
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) loadImportResource(path string, basePath string, req *ConversionRequest) (string, string, error) {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return "", "", fmt.Errorf("import path is empty")
|
||||
}
|
||||
|
||||
if parsed, err := url.Parse(trimmed); err == nil {
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http", "https":
|
||||
content, err := ce.fetchFromURL(trimmed, ce.selectUserAgent(req))
|
||||
if err != nil {
|
||||
return "", parsed.Path, fmt.Errorf("failed to import rules from %s: %w", trimmed, err)
|
||||
}
|
||||
resolved := parsed.Path
|
||||
if resolved == "" {
|
||||
resolved = parsed.Host
|
||||
}
|
||||
return content, resolved, nil
|
||||
case "file":
|
||||
resolved := parsed.Path
|
||||
if resolved == "" {
|
||||
resolved = parsed.Host
|
||||
}
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return "", resolved, fmt.Errorf("failed to import rules from %s: %w", trimmed, err)
|
||||
}
|
||||
return string(data), resolved, nil
|
||||
}
|
||||
}
|
||||
|
||||
resolved := trimmed
|
||||
if basePath != "" && !filepath.IsAbs(resolved) {
|
||||
resolved = filepath.Join(basePath, resolved)
|
||||
}
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return "", resolved, fmt.Errorf("failed to import rules from %s: %w", trimmed, err)
|
||||
}
|
||||
return string(data), resolved, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) newScriptRunner(source, basePath, funcName string) (*scriptRunner, error) {
|
||||
code := strings.TrimSpace(source)
|
||||
if strings.HasPrefix(strings.ToLower(code), "path:") {
|
||||
scriptPath := strings.TrimSpace(code[len("path:"):])
|
||||
if scriptPath == "" {
|
||||
return nil, fmt.Errorf("script path cannot be empty")
|
||||
}
|
||||
resolved := scriptPath
|
||||
if basePath != "" && !filepath.IsAbs(scriptPath) {
|
||||
resolved = filepath.Join(basePath, scriptPath)
|
||||
}
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read script file %s: %w", scriptPath, err)
|
||||
}
|
||||
code = string(data)
|
||||
}
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("script content cannot be empty")
|
||||
}
|
||||
|
||||
runtime := goja.New()
|
||||
ce.registerHelpers(runtime)
|
||||
|
||||
if _, err := runtime.RunString(code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fnVal := runtime.Get(funcName)
|
||||
callable, ok := goja.AssertFunction(fnVal)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("script does not define %s function", funcName)
|
||||
}
|
||||
|
||||
return &scriptRunner{
|
||||
fn: callable,
|
||||
runtime: runtime,
|
||||
funcName: funcName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) registerHelpers(runtime *goja.Runtime) {
|
||||
runtime.Set("btoa", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
return runtime.ToValue("")
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(call.Arguments[0].String()))
|
||||
return runtime.ToValue(encoded)
|
||||
})
|
||||
|
||||
runtime.Set("geoip", func(call goja.FunctionCall) goja.Value {
|
||||
if ce == nil {
|
||||
return runtime.ToValue("{}")
|
||||
}
|
||||
if len(call.Arguments) == 0 {
|
||||
return runtime.ToValue("{}")
|
||||
}
|
||||
address := strings.TrimSpace(call.Arguments[0].String())
|
||||
if address == "" {
|
||||
return runtime.ToValue("{}")
|
||||
}
|
||||
return runtime.ToValue(ce.geoIPJSON(address))
|
||||
})
|
||||
}
|
||||
|
||||
func (sr *scriptRunner) callValue(node scriptNode) (goja.Value, error) {
|
||||
if sr == nil || sr.fn == nil {
|
||||
return goja.Undefined(), fmt.Errorf("script runner not initialised")
|
||||
}
|
||||
res, err := sr.fn(goja.Undefined(), sr.runtime.ToValue(node))
|
||||
if err != nil {
|
||||
return goja.Undefined(), err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (sr *scriptRunner) call(node scriptNode) (string, error) {
|
||||
res, err := sr.callValue(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if goja.IsUndefined(res) || goja.IsNull(res) {
|
||||
return "", nil
|
||||
}
|
||||
return res.String(), nil
|
||||
}
|
||||
|
||||
func (sr *scriptRunner) callBool(node scriptNode) (bool, error) {
|
||||
res, err := sr.callValue(node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if goja.IsUndefined(res) || goja.IsNull(res) {
|
||||
return false, nil
|
||||
}
|
||||
return res.ToBoolean(), nil
|
||||
}
|
||||
|
||||
func applyRenameRules(rules []*renameRule, node *scriptNode, logger *logging.Logger) (string, error) {
|
||||
current := node.Remark
|
||||
original := node.OriginalRemark
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
if rule.script != nil {
|
||||
output, err := rule.script.call(*node)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("rename script execution failed")
|
||||
return "", fmt.Errorf("rename script error: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(output) != "" {
|
||||
current = output
|
||||
node.Remark = current
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rule.pattern != nil && rule.pattern.MatchString(current) {
|
||||
current = rule.pattern.ReplaceAllString(current, rule.replacement)
|
||||
node.Remark = current
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(current) == "" {
|
||||
return original, nil
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func applyEmojiRules(rules []*emojiRule, node *scriptNode, logger *logging.Logger) (string, error) {
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
if rule.script != nil {
|
||||
output, err := rule.script.call(*node)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("emoji script execution failed")
|
||||
return "", fmt.Errorf("emoji script error: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(output) != "" {
|
||||
return output, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rule.pattern != nil && rule.pattern.MatchString(node.Remark) {
|
||||
return rule.emoji, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func buildProxyInfo(cfg *parser.ProxyConfig) string {
|
||||
type proxyInfo struct {
|
||||
Name string `json:"Name"`
|
||||
Remark string `json:"Remark"`
|
||||
Hostname string `json:"Hostname"`
|
||||
Server string `json:"Server"`
|
||||
Port int `json:"Port"`
|
||||
Type string `json:"Type"`
|
||||
Protocol string `json:"Protocol"`
|
||||
Settings map[string]interface{} `json:"Settings"`
|
||||
Group string `json:"Group"`
|
||||
Location string `json:"Location"`
|
||||
UDP bool `json:"UDP"`
|
||||
}
|
||||
|
||||
info := proxyInfo{
|
||||
Name: cfg.Name,
|
||||
Remark: cfg.Remarks,
|
||||
Hostname: cfg.Server,
|
||||
Server: cfg.Server,
|
||||
Port: cfg.Port,
|
||||
Type: cfg.Type,
|
||||
Protocol: cfg.Protocol,
|
||||
Settings: cfg.Settings,
|
||||
Group: cfg.Group,
|
||||
Location: cfg.Location,
|
||||
UDP: cfg.UDP,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func copySettings(settings map[string]interface{}) map[string]interface{} {
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
dup := make(map[string]interface{}, len(settings))
|
||||
for k, v := range settings {
|
||||
dup[k] = v
|
||||
}
|
||||
return dup
|
||||
}
|
||||
|
||||
func newScriptNode(cfg *parser.ProxyConfig, remark string, group string, index int) scriptNode {
|
||||
return scriptNode{
|
||||
Group: group,
|
||||
GroupID: 0,
|
||||
Index: index,
|
||||
Remark: remark,
|
||||
OriginalRemark: remark,
|
||||
ProxyInfo: buildProxyInfo(cfg),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user