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

This commit is contained in:
Rogee
2025-09-28 10:05:07 +08:00
commit 7fcabe0225
481 changed files with 125127 additions and 0 deletions

View 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(),
}
}

File diff suppressed because it is too large Load Diff

View 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)
}
}

View 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)
}

View 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
}

View 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)
}

View 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),
}
}