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
1742 lines
46 KiB
Go
1742 lines
46 KiB
Go
package conversion
|
||
|
||
import (
|
||
"bytes"
|
||
"compress/gzip"
|
||
"compress/zlib"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
yaml "gopkg.in/yaml.v3"
|
||
|
||
"github.com/subconverter-go/internal/generator"
|
||
"github.com/subconverter-go/internal/logging"
|
||
"github.com/subconverter-go/internal/parser"
|
||
)
|
||
|
||
// EngineConversionRequest 引擎内部使用的转换请求
|
||
// 基于基础ConversionRequest,添加引擎需要的额外字段
|
||
type EngineConversionRequest struct {
|
||
*ConversionRequest // 嵌入基础请求结构
|
||
|
||
// 引擎特定字段
|
||
InputText string `json:"inputText"` // 输入文本内容
|
||
Options *generator.GenerationOptions `json:"options"` // 生成选项
|
||
SkipInvalid bool `json:"skipInvalid"` // 是否跳过无效配置
|
||
}
|
||
|
||
// EngineConversionResponse 引擎内部使用的转换响应
|
||
// 基于基础ConversionResponse,添加引擎需要的额外字段
|
||
type EngineConversionResponse struct {
|
||
*ConversionResponse // 嵌入基础响应结构
|
||
|
||
// 引擎特定字段
|
||
Output string `json:"output"` // 转换后的配置内容
|
||
ProxyCount int `json:"proxyCount"` // 成功转换的代理数量
|
||
Errors []string `json:"errors"` // 详细错误列表
|
||
DebugInfo *DebugInfo `json:"debugInfo"` // 调试信息
|
||
}
|
||
|
||
// DebugInfo 调试信息结构体
|
||
// 包含转换过程的调试数据
|
||
type DebugInfo struct {
|
||
ParseTime int64 `json:"parseTime"` // 解析耗时(ms)
|
||
GenerateTime int64 `json:"generateTime"` // 生成耗时(ms)
|
||
TotalTime int64 `json:"totalTime"` // 总耗时(ms)
|
||
InputSize int `json:"inputSize"` // 输入大小(bytes)
|
||
OutputSize int `json:"outputSize"` // 输出大小(bytes)
|
||
|
||
// 解析统计
|
||
ParseStats *ParseStats `json:"parseStats"` // 解析统计信息
|
||
|
||
// 生成统计
|
||
GenerateStats *GenerateStats `json:"generateStats"` // 生成统计信息
|
||
}
|
||
|
||
// ParseStats 解析统计信息
|
||
type ParseStats struct {
|
||
TotalInput int `json:"totalInput"` // 输入行数
|
||
ValidInput int `json:"validInput"` // 有效输入数
|
||
InvalidInput int `json:"invalidInput"` // 无效输入数
|
||
Protocols map[string]int `json:"protocols"` // 协议分布
|
||
}
|
||
|
||
// GenerateStats 生成统计信息
|
||
type GenerateStats struct {
|
||
GeneratedProxies int `json:"generatedProxies"` // 生成的代理数
|
||
GeneratedGroups int `json:"generatedGroups"` // 生成的代理组数
|
||
GeneratedRules int `json:"generatedRules"` // 生成的规则数
|
||
FormatFeatures map[string]bool `json:"formatFeatures"` // 格式特性
|
||
}
|
||
|
||
// ConversionEngine 转换引擎
|
||
// 核心转换逻辑的协调器和执行器
|
||
type ConversionEngine struct {
|
||
logger *logging.Logger
|
||
parserMgr *parser.ParserManager
|
||
generatorMgr *generator.GeneratorManager
|
||
geoResolver GeoIPResolver
|
||
geoipCache sync.Map
|
||
httpClient *http.Client
|
||
fetchCache sync.Map
|
||
}
|
||
|
||
type fetchCacheEntry struct {
|
||
data string
|
||
expires time.Time
|
||
}
|
||
|
||
const (
|
||
defaultFetchTimeout = 15 * time.Second
|
||
fetchCacheTTL = 5 * time.Minute
|
||
maxFetchBodySize int64 = 5 * 1024 * 1024 // 5MB safeguard
|
||
)
|
||
|
||
// NewConversionEngine 创建新的转换引擎
|
||
// 返回初始化好的ConversionEngine实例
|
||
func NewConversionEngine(
|
||
logger *logging.Logger,
|
||
parserMgr *parser.ParserManager,
|
||
generatorMgr *generator.GeneratorManager,
|
||
) *ConversionEngine {
|
||
return &ConversionEngine{
|
||
logger: logger,
|
||
parserMgr: parserMgr,
|
||
generatorMgr: generatorMgr,
|
||
geoResolver: newDefaultGeoIPResolver(logger),
|
||
httpClient: &http.Client{
|
||
Timeout: defaultFetchTimeout,
|
||
},
|
||
}
|
||
}
|
||
|
||
// SetHTTPClient allows tests or callers to provide a custom HTTP client.
|
||
func (ce *ConversionEngine) SetHTTPClient(client *http.Client) {
|
||
if client == nil {
|
||
ce.httpClient = &http.Client{Timeout: defaultFetchTimeout}
|
||
return
|
||
}
|
||
ce.httpClient = client
|
||
}
|
||
|
||
// Convert 执行转换操作
|
||
// 支持从URL或文本内容转换到目标格式
|
||
func (ce *ConversionEngine) Convert(request *ConversionRequest) (*ConversionResponse, error) {
|
||
ce.logger.WithField("requestId", "engine-"+request.Target).
|
||
WithField("targetFormat", request.Target).
|
||
WithField("sourceUrl", request.SourceSummary()).
|
||
Info("Starting conversion process")
|
||
|
||
// 验证请求参数
|
||
if err := ce.validateRequest(request); err != nil {
|
||
return ce.createErrorResponse(request, fmt.Sprintf("Invalid request: %v", err)), nil
|
||
}
|
||
|
||
// 记录开始时间
|
||
startTime := time.Now()
|
||
|
||
// 解析输入配置
|
||
configs, parseResult, err := ce.parseInput(request)
|
||
if err != nil {
|
||
return ce.createErrorResponse(request, fmt.Sprintf("Failed to parse input: %v", err)), nil
|
||
}
|
||
|
||
// 如果没有有效的配置,返回错误
|
||
if len(configs) == 0 {
|
||
return ce.createErrorResponse(request, "No valid proxy configurations found"), nil
|
||
}
|
||
|
||
filteredConfigs := ce.applyFilters(configs, request)
|
||
if len(filteredConfigs) == 0 {
|
||
return ce.createErrorResponse(request, "No proxy configurations matched requested filters"), nil
|
||
}
|
||
|
||
transformedConfigs, err := ce.applyTransformations(filteredConfigs, request)
|
||
if err != nil {
|
||
return ce.createErrorResponse(request, fmt.Sprintf("Invalid request: %v", err)), nil
|
||
}
|
||
|
||
finalConfigs, err := ce.applyFilterScript(transformedConfigs, request)
|
||
if err != nil {
|
||
return ce.createErrorResponse(request, fmt.Sprintf("Invalid request: %v", err)), nil
|
||
}
|
||
if len(finalConfigs) == 0 {
|
||
return ce.createErrorResponse(request, "No proxy configurations remained after filtering"), nil
|
||
}
|
||
|
||
// 生成目标格式配置
|
||
output, generateResult, err := ce.generateOutput(request, finalConfigs)
|
||
if err == nil {
|
||
if uploadErr := ce.handleUpload(request, output); uploadErr != nil {
|
||
return ce.createErrorResponse(request, fmt.Sprintf("Failed to upload content: %v", uploadErr)), nil
|
||
}
|
||
output = ce.applyManagedConfig(request, output)
|
||
}
|
||
if err != nil {
|
||
return ce.createErrorResponse(request, fmt.Sprintf("Failed to generate output: %v", err)), nil
|
||
}
|
||
|
||
// 记录结束时间
|
||
endTime := time.Now()
|
||
|
||
// 创建成功响应
|
||
response := ce.createSuccessResponse(request, output, finalConfigs, parseResult, generateResult)
|
||
|
||
// 设置调试信息
|
||
response.DebugInfo = ce.createDebugInfo(request, startTime, endTime, parseResult, generateResult)
|
||
|
||
ce.logger.WithField("requestId", "engine-"+request.Target).
|
||
WithField("proxyCount", len(configs)).
|
||
WithField("duration", endTime.Sub(startTime).Milliseconds()).
|
||
Info("Conversion completed successfully")
|
||
|
||
return response, nil
|
||
}
|
||
|
||
// validateRequest 验证转换请求
|
||
func (ce *ConversionEngine) validateRequest(request *ConversionRequest) error {
|
||
if request == nil {
|
||
return fmt.Errorf("request cannot be nil")
|
||
}
|
||
|
||
// 验证输入源
|
||
sources := request.GetSources()
|
||
if len(sources) == 0 {
|
||
return fmt.Errorf("source URL cannot be empty")
|
||
}
|
||
|
||
// 验证目标格式 - 使用现有的Target字段
|
||
if request.Target == "" {
|
||
return fmt.Errorf("target format cannot be empty")
|
||
}
|
||
|
||
// 检查目标格式是否支持
|
||
supportedFormats := ce.generatorMgr.GetSupportedFormats()
|
||
supported := false
|
||
for _, format := range supportedFormats {
|
||
if format == request.Target {
|
||
supported = true
|
||
break
|
||
}
|
||
}
|
||
if !supported {
|
||
return fmt.Errorf("unsupported target format: %s", request.Target)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// parseInput 解析输入配置
|
||
func (ce *ConversionEngine) parseInput(request *ConversionRequest) ([]*parser.ProxyConfig, *ParseResult, error) {
|
||
var inputText string
|
||
|
||
// 获取输入文本
|
||
sources := request.GetSources()
|
||
if len(sources) > 0 {
|
||
var builder strings.Builder
|
||
for idx, src := range sources {
|
||
text, fetchErr := ce.fetchFromURL(src, ce.selectUserAgent(request))
|
||
if fetchErr != nil {
|
||
return nil, nil, fmt.Errorf("failed to fetch from URL %s: %v", src, fetchErr)
|
||
}
|
||
normalized := ce.normalizeSubscriptionContent(text)
|
||
if normalized == "" {
|
||
continue
|
||
}
|
||
builder.WriteString(normalized)
|
||
if !strings.HasSuffix(normalized, "\n") {
|
||
builder.WriteByte('\n')
|
||
}
|
||
if idx < len(sources)-1 {
|
||
builder.WriteByte('\n')
|
||
}
|
||
}
|
||
inputText = builder.String()
|
||
} else {
|
||
// 如果没有URL,使用默认的测试配置(简化实现)
|
||
inputText = "ss://aes-256-cfb:password@192.168.1.1:8388"
|
||
}
|
||
|
||
// 解析配置 - 跳过无效配置
|
||
return ce.parseConfigurations(inputText, true)
|
||
}
|
||
|
||
// fetchFromURL loads subscription or helper content either from remote HTTP(S)
|
||
// endpoints or local files. Results are cached briefly to avoid redundant
|
||
// network calls when the same resource is requested multiple times within a
|
||
// short window (mirroring the behaviour of the original service).
|
||
func (ce *ConversionEngine) fetchFromURL(source string, userAgent string) (string, error) {
|
||
if strings.TrimSpace(source) == "" {
|
||
return "", fmt.Errorf("empty source URL")
|
||
}
|
||
|
||
cacheKey := ce.buildFetchCacheKey(source, userAgent)
|
||
if cached, ok := ce.loadFetchCache(cacheKey); ok {
|
||
return cached, nil
|
||
}
|
||
|
||
parsed, err := url.Parse(source)
|
||
if err != nil {
|
||
return "", fmt.Errorf("invalid URL: %w", err)
|
||
}
|
||
|
||
var (
|
||
data []byte
|
||
readErr error
|
||
)
|
||
|
||
switch strings.ToLower(parsed.Scheme) {
|
||
case "http", "https":
|
||
data, readErr = ce.fetchHTTPResource(source, userAgent)
|
||
case "file":
|
||
resolved := parsed.Path
|
||
if resolved == "" {
|
||
resolved = parsed.Host
|
||
}
|
||
data, readErr = os.ReadFile(resolved)
|
||
default:
|
||
if parsed.Scheme != "" {
|
||
readErr = fmt.Errorf("unsupported URL scheme: %s", parsed.Scheme)
|
||
} else {
|
||
resolved := source
|
||
if !filepath.IsAbs(resolved) && parsed.Path != "" {
|
||
resolved = parsed.Path
|
||
}
|
||
data, readErr = os.ReadFile(resolved)
|
||
}
|
||
}
|
||
|
||
if readErr != nil {
|
||
return "", readErr
|
||
}
|
||
|
||
content := string(data)
|
||
ce.storeFetchCache(cacheKey, content)
|
||
|
||
return content, nil
|
||
}
|
||
|
||
func (ce *ConversionEngine) buildFetchCacheKey(source, userAgent string) string {
|
||
key := strings.TrimSpace(source)
|
||
ua := strings.TrimSpace(userAgent)
|
||
if ua == "" {
|
||
return key
|
||
}
|
||
return key + "::ua=" + ua
|
||
}
|
||
|
||
func (ce *ConversionEngine) selectUserAgent(request *ConversionRequest) string {
|
||
if request != nil {
|
||
if ua := strings.TrimSpace(request.UserAgent); ua != "" {
|
||
return ua
|
||
}
|
||
switch strings.ToLower(strings.TrimSpace(request.Target)) {
|
||
case "clash", "clashr":
|
||
return "ClashForWindows/0.20.0"
|
||
case "surge":
|
||
return "Surge/1109"
|
||
case "quanx", "quantumult-x":
|
||
return "Quantumult%20X/1.2.0"
|
||
case "loon":
|
||
return "Loon/2.1.2"
|
||
case "surfboard":
|
||
return "Surfboard/1.6.0"
|
||
case "v2ray":
|
||
return "v2rayN/6.30"
|
||
}
|
||
}
|
||
return "subconverter-go/1.0"
|
||
}
|
||
|
||
func (ce *ConversionEngine) normalizeSubscriptionContent(raw string) string {
|
||
return ce.normalizeSubscriptionContentWithDepth(raw, 0)
|
||
}
|
||
|
||
func (ce *ConversionEngine) normalizeSubscriptionContentWithDepth(raw string, depth int) string {
|
||
trimmed := strings.TrimSpace(raw)
|
||
if trimmed == "" {
|
||
return raw
|
||
}
|
||
if depth > 4 {
|
||
return trimmed
|
||
}
|
||
if ce.looksLikeClashYAML(trimmed) {
|
||
if converted, ok := ce.convertClashYAML(trimmed); ok {
|
||
return converted
|
||
}
|
||
}
|
||
if ce.containsProxyIndicators(trimmed) {
|
||
return trimmed
|
||
}
|
||
decoded := ce.decodeBase64Payload(trimmed)
|
||
if decoded != "" && decoded != raw {
|
||
return ce.normalizeSubscriptionContentWithDepth(decoded, depth+1)
|
||
}
|
||
return trimmed
|
||
}
|
||
|
||
func (ce *ConversionEngine) containsProxyIndicators(content string) bool {
|
||
lower := strings.ToLower(content)
|
||
if strings.Contains(lower, "vmess://") || strings.Contains(lower, "ss://") || strings.Contains(lower, "trojan://") || strings.Contains(lower, "vless://") || strings.Contains(lower, "hysteria://") || strings.Contains(lower, "ssr://") {
|
||
return true
|
||
}
|
||
if strings.Contains(lower, "proxies:") && strings.Contains(lower, "proxy-groups:") {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (ce *ConversionEngine) decodeBase64Payload(raw string) string {
|
||
clean := make([]rune, 0, len(raw))
|
||
for _, r := range raw {
|
||
if r == '\n' || r == '\r' || r == '\t' || r == ' ' {
|
||
continue
|
||
}
|
||
clean = append(clean, r)
|
||
}
|
||
if len(clean) == 0 {
|
||
return ""
|
||
}
|
||
data, ok := ce.tryDecodeBase64(string(clean))
|
||
if !ok || len(data) == 0 {
|
||
return ""
|
||
}
|
||
plain, err := ce.maybeDecompress(data)
|
||
if err == nil {
|
||
data = plain
|
||
}
|
||
if !isLikelyText(data) {
|
||
return ""
|
||
}
|
||
return string(data)
|
||
}
|
||
|
||
func (ce *ConversionEngine) tryDecodeBase64(clean string) ([]byte, bool) {
|
||
paddingNeeded := len(clean) % 4
|
||
if paddingNeeded != 0 {
|
||
clean += strings.Repeat("=", 4-paddingNeeded)
|
||
}
|
||
for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.URLEncoding} {
|
||
decoded, err := enc.DecodeString(clean)
|
||
if err == nil && len(decoded) > 0 {
|
||
return decoded, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
func (ce *ConversionEngine) maybeDecompress(data []byte) ([]byte, error) {
|
||
if len(data) < 2 {
|
||
return data, nil
|
||
}
|
||
if bytes.HasPrefix(data, []byte{0x1f, 0x8b}) {
|
||
gr, err := gzip.NewReader(bytes.NewReader(data))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer gr.Close()
|
||
return io.ReadAll(gr)
|
||
}
|
||
if data[0] == 0x78 && (data[1] == 0x01 || data[1] == 0x9c || data[1] == 0xda) {
|
||
zr, err := zlib.NewReader(bytes.NewReader(data))
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer zr.Close()
|
||
return io.ReadAll(zr)
|
||
}
|
||
return data, nil
|
||
}
|
||
|
||
func isLikelyText(data []byte) bool {
|
||
if len(data) == 0 {
|
||
return false
|
||
}
|
||
var printable int
|
||
for _, b := range data {
|
||
switch {
|
||
case b == 0x0a || b == 0x0d || b == 0x09:
|
||
printable++
|
||
case b >= 0x20 && b <= 0x7e:
|
||
printable++
|
||
case b >= 0x80:
|
||
printable++
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
return float64(printable)/float64(len(data)) > 0.7
|
||
}
|
||
|
||
func (ce *ConversionEngine) looksLikeClashYAML(content string) bool {
|
||
lower := strings.ToLower(content)
|
||
return strings.Contains(lower, "proxies:") && (strings.Contains(lower, "proxy-groups:") || strings.Contains(lower, "proxy-groups:") || strings.Contains(lower, "rules:"))
|
||
}
|
||
|
||
func (ce *ConversionEngine) convertClashYAML(content string) (string, bool) {
|
||
var doc struct {
|
||
Proxies []map[string]interface{} `yaml:"proxies"`
|
||
}
|
||
if err := yaml.Unmarshal([]byte(content), &doc); err != nil {
|
||
return "", false
|
||
}
|
||
if len(doc.Proxies) == 0 {
|
||
return "", false
|
||
}
|
||
builder := strings.Builder{}
|
||
for _, proxy := range doc.Proxies {
|
||
share, ok := ce.shareURIFromClashProxy(proxy)
|
||
if !ok || strings.TrimSpace(share) == "" {
|
||
continue
|
||
}
|
||
builder.WriteString(share)
|
||
builder.WriteByte('\n')
|
||
}
|
||
result := strings.TrimSpace(builder.String())
|
||
if result == "" {
|
||
return "", false
|
||
}
|
||
return result, true
|
||
}
|
||
|
||
func (ce *ConversionEngine) shareURIFromClashProxy(proxy map[string]interface{}) (string, bool) {
|
||
typeVal := strings.ToLower(getString(proxy, "type"))
|
||
name := getString(proxy, "name")
|
||
if name == "" {
|
||
name = getString(proxy, "ps")
|
||
}
|
||
server := getString(proxy, "server")
|
||
port, ok := getInt(proxy, "port")
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
switch typeVal {
|
||
case "ss", "shadowsocks":
|
||
return buildShadowsocksURI(proxy, name, server, port)
|
||
case "vmess":
|
||
return buildVmessURI(proxy, name, server, port)
|
||
case "trojan":
|
||
return buildTrojanURI(proxy, name, server, port)
|
||
case "socks5", "socks":
|
||
return buildSocksURI(proxy, name, server, port)
|
||
case "http", "https":
|
||
return buildHTTPURI(proxy, name, server, port)
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func buildShadowsocksURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
|
||
cipher := getString(proxy, "cipher")
|
||
if cipher == "" {
|
||
cipher = getString(proxy, "method")
|
||
}
|
||
password := getString(proxy, "password")
|
||
if cipher == "" || password == "" || server == "" {
|
||
return "", false
|
||
}
|
||
userInfo := fmt.Sprintf("%s:%s", cipher, password)
|
||
encoded := base64.StdEncoding.EncodeToString([]byte(userInfo))
|
||
encoded = strings.TrimRight(encoded, "=")
|
||
uri := fmt.Sprintf("ss://%s@%s:%d", encoded, server, port)
|
||
plugin := getString(proxy, "plugin")
|
||
if plugin != "" {
|
||
params := url.Values{}
|
||
pluginOpts := fetchPluginOptions(proxy)
|
||
params.Set("plugin", pluginOpts)
|
||
uri += "?" + params.Encode()
|
||
}
|
||
if name != "" {
|
||
uri += "#" + url.QueryEscape(name)
|
||
}
|
||
return uri, true
|
||
}
|
||
|
||
func fetchPluginOptions(proxy map[string]interface{}) string {
|
||
plugin := getString(proxy, "plugin")
|
||
if plugin == "" {
|
||
return ""
|
||
}
|
||
pluginOpts := proxy["plugin-opts"]
|
||
switch v := pluginOpts.(type) {
|
||
case string:
|
||
if strings.TrimSpace(v) == "" {
|
||
return plugin
|
||
}
|
||
return fmt.Sprintf("%s;%s", plugin, v)
|
||
case map[string]interface{}:
|
||
parts := make([]string, 0, len(v)+1)
|
||
parts = append(parts, plugin)
|
||
for key, value := range v {
|
||
parts = append(parts, fmt.Sprintf("%s=%v", key, value))
|
||
}
|
||
return strings.Join(parts, ";")
|
||
default:
|
||
return plugin
|
||
}
|
||
}
|
||
|
||
func buildVmessURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
|
||
uuid := getString(proxy, "uuid")
|
||
if uuid == "" {
|
||
uuid = getString(proxy, "id")
|
||
}
|
||
if uuid == "" || server == "" {
|
||
return "", false
|
||
}
|
||
alterID, _ := getInt(proxy, "alterId")
|
||
if alterID == 0 {
|
||
alterID, _ = getInt(proxy, "alterid")
|
||
}
|
||
scy := getString(proxy, "cipher")
|
||
if scy == "" {
|
||
scy = getString(proxy, "security")
|
||
}
|
||
if scy == "" {
|
||
scy = "auto"
|
||
}
|
||
network := strings.ToLower(getString(proxy, "network"))
|
||
if network == "" {
|
||
network = "tcp"
|
||
}
|
||
path := getString(proxy, "path")
|
||
if path == "" {
|
||
if wsOpts, ok := proxy["ws-opts"].(map[string]interface{}); ok {
|
||
path = getString(wsOpts, "path")
|
||
}
|
||
}
|
||
host := getString(proxy, "host")
|
||
if host == "" {
|
||
if headers, ok := proxy["ws-opts"].(map[string]interface{}); ok {
|
||
if hMap, ok := headers["headers"].(map[string]interface{}); ok {
|
||
host = getString(hMap, "Host")
|
||
}
|
||
}
|
||
}
|
||
tlsField := proxy["tls"]
|
||
tls := ""
|
||
switch v := tlsField.(type) {
|
||
case bool:
|
||
if v {
|
||
tls = "tls"
|
||
}
|
||
case string:
|
||
if strings.EqualFold(v, "tls") || strings.EqualFold(v, "true") {
|
||
tls = "tls"
|
||
}
|
||
}
|
||
sni := getString(proxy, "sni")
|
||
if sni == "" {
|
||
sni = getString(proxy, "servername")
|
||
}
|
||
var alpn []string
|
||
if rawAlpn, ok := proxy["alpn"].([]interface{}); ok {
|
||
for _, item := range rawAlpn {
|
||
if s, ok := item.(string); ok && s != "" {
|
||
alpn = append(alpn, s)
|
||
}
|
||
}
|
||
}
|
||
settings := map[string]interface{}{
|
||
"v": "2",
|
||
"ps": name,
|
||
"add": server,
|
||
"port": fmt.Sprintf("%d", port),
|
||
"id": uuid,
|
||
"aid": fmt.Sprintf("%d", alterID),
|
||
"scy": scy,
|
||
"net": network,
|
||
"type": "none",
|
||
"host": host,
|
||
"path": path,
|
||
"tls": tls,
|
||
}
|
||
if sni != "" {
|
||
settings["sni"] = sni
|
||
}
|
||
if len(alpn) > 0 {
|
||
settings["alpn"] = strings.Join(alpn, ",")
|
||
}
|
||
if fp := getString(proxy, "client-fingerprint"); fp != "" {
|
||
settings["fp"] = fp
|
||
}
|
||
jsonBytes, err := json.Marshal(settings)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
encoded := base64.StdEncoding.EncodeToString(jsonBytes)
|
||
return fmt.Sprintf("vmess://%s", encoded), true
|
||
}
|
||
|
||
func buildTrojanURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
|
||
password := getString(proxy, "password")
|
||
if password == "" || server == "" {
|
||
return "", false
|
||
}
|
||
params := url.Values{}
|
||
sni := getString(proxy, "sni")
|
||
if sni == "" {
|
||
sni = getString(proxy, "servername")
|
||
}
|
||
if sni != "" {
|
||
params.Set("sni", sni)
|
||
}
|
||
alpn := proxy["alpn"]
|
||
switch v := alpn.(type) {
|
||
case string:
|
||
if v != "" {
|
||
params.Set("alpn", v)
|
||
}
|
||
case []interface{}:
|
||
slices := make([]string, 0, len(v))
|
||
for _, item := range v {
|
||
if s, ok := item.(string); ok && s != "" {
|
||
slices = append(slices, s)
|
||
}
|
||
}
|
||
if len(slices) > 0 {
|
||
params.Set("alpn", strings.Join(slices, ","))
|
||
}
|
||
}
|
||
if network := getString(proxy, "network"); network != "" {
|
||
params.Set("type", network)
|
||
}
|
||
uri := fmt.Sprintf("trojan://%s@%s:%d", password, server, port)
|
||
if len(params) > 0 {
|
||
uri += "?" + params.Encode()
|
||
}
|
||
if name != "" {
|
||
uri += "#" + url.QueryEscape(name)
|
||
}
|
||
return uri, true
|
||
}
|
||
|
||
func buildSocksURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
|
||
if server == "" {
|
||
return "", false
|
||
}
|
||
user := getString(proxy, "username")
|
||
if user == "" {
|
||
user = getString(proxy, "user")
|
||
}
|
||
pass := getString(proxy, "password")
|
||
var auth string
|
||
if user != "" || pass != "" {
|
||
auth = url.UserPassword(user, pass).String() + "@"
|
||
}
|
||
uri := fmt.Sprintf("socks5://%s%s:%d", auth, server, port)
|
||
if name != "" {
|
||
uri += "#" + url.QueryEscape(name)
|
||
}
|
||
return uri, true
|
||
}
|
||
|
||
func buildHTTPURI(proxy map[string]interface{}, name, server string, port int) (string, bool) {
|
||
if server == "" {
|
||
return "", false
|
||
}
|
||
user := getString(proxy, "username")
|
||
if user == "" {
|
||
user = getString(proxy, "user")
|
||
}
|
||
pass := getString(proxy, "password")
|
||
var auth string
|
||
if user != "" || pass != "" {
|
||
auth = url.UserPassword(user, pass).String() + "@"
|
||
}
|
||
scheme := "http"
|
||
if tlsEnabled(proxy) {
|
||
scheme = "https"
|
||
}
|
||
uri := fmt.Sprintf("%s://%s%s:%d", scheme, auth, server, port)
|
||
if name != "" {
|
||
uri += "#" + url.QueryEscape(name)
|
||
}
|
||
return uri, true
|
||
}
|
||
|
||
func tlsEnabled(proxy map[string]interface{}) bool {
|
||
if proxy == nil {
|
||
return false
|
||
}
|
||
if v, ok := proxy["tls"].(bool); ok {
|
||
return v
|
||
}
|
||
if s := strings.ToLower(getString(proxy, "tls")); s == "tls" || s == "true" {
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func getString(m map[string]interface{}, key string) string {
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
if val, ok := m[key]; ok {
|
||
switch v := val.(type) {
|
||
case string:
|
||
return v
|
||
case fmt.Stringer:
|
||
return v.String()
|
||
case []byte:
|
||
return string(v)
|
||
case int, int64, float64:
|
||
return fmt.Sprintf("%v", v)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func getInt(m map[string]interface{}, key string) (int, bool) {
|
||
if m == nil {
|
||
return 0, false
|
||
}
|
||
if val, ok := m[key]; ok {
|
||
switch v := val.(type) {
|
||
case int:
|
||
return v, true
|
||
case int64:
|
||
return int(v), true
|
||
case float64:
|
||
return int(v), true
|
||
case string:
|
||
if v == "" {
|
||
return 0, false
|
||
}
|
||
i, err := strconv.Atoi(v)
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return i, true
|
||
}
|
||
}
|
||
return 0, false
|
||
}
|
||
|
||
func (ce *ConversionEngine) fetchHTTPResource(source string, userAgent string) ([]byte, error) {
|
||
client := ce.httpClient
|
||
if client == nil {
|
||
client = &http.Client{Timeout: defaultFetchTimeout}
|
||
}
|
||
|
||
req, err := http.NewRequest(http.MethodGet, source, nil)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
ua := strings.TrimSpace(userAgent)
|
||
if ua == "" {
|
||
ua = "subconverter-go/1.0"
|
||
}
|
||
req.Header.Set("User-Agent", ua)
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest {
|
||
return nil, fmt.Errorf("fetch failed with status %d", resp.StatusCode)
|
||
}
|
||
|
||
reader := io.LimitReader(resp.Body, maxFetchBodySize+1)
|
||
data, err := io.ReadAll(reader)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if int64(len(data)) > maxFetchBodySize {
|
||
return nil, errors.New("remote content exceeds allowed size")
|
||
}
|
||
|
||
return data, nil
|
||
}
|
||
|
||
func (ce *ConversionEngine) loadFetchCache(key string) (string, bool) {
|
||
if value, ok := ce.fetchCache.Load(key); ok {
|
||
if entry, ok := value.(fetchCacheEntry); ok {
|
||
if time.Now().Before(entry.expires) {
|
||
return entry.data, true
|
||
}
|
||
ce.fetchCache.Delete(key)
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func (ce *ConversionEngine) storeFetchCache(key, data string) {
|
||
ce.fetchCache.Store(key, fetchCacheEntry{
|
||
data: data,
|
||
expires: time.Now().Add(fetchCacheTTL),
|
||
})
|
||
}
|
||
|
||
// parseConfigurations 解析配置文本
|
||
func (ce *ConversionEngine) parseConfigurations(inputText string, skipInvalid bool) ([]*parser.ProxyConfig, *ParseResult, error) {
|
||
result := &ParseResult{
|
||
ValidConfigs: make([]*parser.ProxyConfig, 0),
|
||
InvalidInputs: make([]string, 0),
|
||
ErrorMessages: make([]string, 0),
|
||
ProtocolStats: make(map[string]int),
|
||
}
|
||
|
||
// 分割输入文本
|
||
lines := ce.splitInputLines(inputText)
|
||
|
||
for i, line := range lines {
|
||
line = ce.trimLine(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
|
||
// 尝试解析每一行
|
||
config, err := ce.parseSingleLine(line, i+1)
|
||
if err != nil {
|
||
result.InvalidInputs = append(result.InvalidInputs, line)
|
||
result.ErrorMessages = append(result.ErrorMessages, fmt.Sprintf("Line %d: %v", i+1, err))
|
||
|
||
if !skipInvalid {
|
||
return nil, result, fmt.Errorf("failed to parse line %d: %v", i+1, err)
|
||
}
|
||
continue
|
||
}
|
||
|
||
if config != nil {
|
||
result.ValidConfigs = append(result.ValidConfigs, config)
|
||
result.ProtocolStats[config.Protocol]++
|
||
}
|
||
}
|
||
|
||
ce.logger.WithField("validConfigs", len(result.ValidConfigs)).
|
||
WithField("invalidInputs", len(result.InvalidInputs)).
|
||
Info("Configuration parsing completed")
|
||
|
||
return result.ValidConfigs, result, nil
|
||
}
|
||
|
||
// splitInputLines 分割输入文本为行
|
||
func (ce *ConversionEngine) splitInputLines(input string) []string {
|
||
// 按行分割输入文本
|
||
lines := strings.Split(strings.TrimSpace(input), "\n")
|
||
|
||
// 过滤空行
|
||
result := make([]string, 0)
|
||
for _, line := range lines {
|
||
if trimmed := strings.TrimSpace(line); trimmed != "" {
|
||
result = append(result, trimmed)
|
||
}
|
||
}
|
||
|
||
return result
|
||
}
|
||
|
||
// trimLine 清理行内容
|
||
func (ce *ConversionEngine) trimLine(line string) string {
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed == "" {
|
||
return ""
|
||
}
|
||
|
||
// 移除可能存在的 BOM 或零宽度字符
|
||
trimmed = strings.TrimLeft(trimmed, "\ufeff\u200b\u200c\u200d\u2060")
|
||
trimmed = strings.TrimSpace(trimmed)
|
||
if trimmed == "" {
|
||
return ""
|
||
}
|
||
|
||
// 跳过整行注释
|
||
switch {
|
||
case strings.HasPrefix(trimmed, "#"),
|
||
strings.HasPrefix(trimmed, ";"),
|
||
strings.HasPrefix(trimmed, "//"):
|
||
return ""
|
||
}
|
||
|
||
return trimmed
|
||
}
|
||
|
||
// parseSingleLine 解析单行配置
|
||
func (ce *ConversionEngine) parseSingleLine(line string, lineNumber int) (*parser.ProxyConfig, error) {
|
||
// 尝试自动检测协议并解析
|
||
protocol := ce.detectProtocol(line)
|
||
if protocol == "" {
|
||
return nil, fmt.Errorf("unable to detect protocol for line: %s", line)
|
||
}
|
||
|
||
// 获取对应的解析器
|
||
parser, err := ce.parserMgr.GetParser(protocol)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("no parser available for protocol: %s", protocol)
|
||
}
|
||
|
||
// 解析配置
|
||
config, err := parser.Parse(line)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("failed to parse %s configuration: %v", protocol, err)
|
||
}
|
||
|
||
// 验证配置
|
||
if err := parser.Validate(config); err != nil {
|
||
return nil, fmt.Errorf("invalid %s configuration: %v", protocol, err)
|
||
}
|
||
|
||
return config, nil
|
||
}
|
||
|
||
// detectProtocol 检测配置协议
|
||
func (ce *ConversionEngine) detectProtocol(line string) string {
|
||
candidate := strings.TrimSpace(line)
|
||
if candidate == "" {
|
||
return ""
|
||
}
|
||
|
||
lower := strings.ToLower(candidate)
|
||
|
||
switch {
|
||
case strings.HasPrefix(lower, "ssr://"):
|
||
return "ssr"
|
||
case strings.HasPrefix(lower, "vmess://"), strings.HasPrefix(lower, "vmess1://"):
|
||
return "vmess"
|
||
case strings.HasPrefix(lower, "ss://"):
|
||
return "ss"
|
||
case strings.HasPrefix(lower, "socks5://"), strings.HasPrefix(lower, "socks://"), strings.HasPrefix(lower, "https://t.me/socks"), strings.HasPrefix(lower, "tg://socks"):
|
||
return "socks5"
|
||
case strings.HasPrefix(lower, "trojan://"):
|
||
return "trojan"
|
||
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"), strings.HasPrefix(lower, "tg://http"), strings.HasPrefix(lower, "https://t.me/http"):
|
||
return "http"
|
||
}
|
||
|
||
// Fallback: 尝试在字符串中查找协议标识
|
||
if strings.Contains(lower, "ssr://") {
|
||
return "ssr"
|
||
}
|
||
if strings.Contains(lower, "vmess://") || strings.Contains(lower, "vmess1://") {
|
||
return "vmess"
|
||
}
|
||
if strings.Contains(lower, "ss://") {
|
||
return "ss"
|
||
}
|
||
if strings.Contains(lower, "trojan://") {
|
||
return "trojan"
|
||
}
|
||
if strings.Contains(lower, "socks5://") || strings.Contains(lower, "socks://") {
|
||
return "socks5"
|
||
}
|
||
if strings.Contains(lower, "http://") || strings.Contains(lower, "https://") {
|
||
return "http"
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
// generateOutput 生成输出配置
|
||
func (ce *ConversionEngine) generateOutput(request *ConversionRequest, configs []*parser.ProxyConfig) (string, *GenerateResult, error) {
|
||
result := &GenerateResult{
|
||
GeneratedOutput: "",
|
||
FormatFeatures: make(map[string]bool),
|
||
}
|
||
|
||
if request.List {
|
||
lines := make([]string, 0, len(configs))
|
||
for _, cfg := range configs {
|
||
name := strings.TrimSpace(cfg.Name)
|
||
if name == "" {
|
||
if cfg.Server != "" && cfg.Port != 0 {
|
||
name = fmt.Sprintf("%s:%d", cfg.Server, cfg.Port)
|
||
} else {
|
||
name = cfg.Type
|
||
}
|
||
}
|
||
lines = append(lines, name)
|
||
}
|
||
output := strings.Join(lines, "\n")
|
||
result.GeneratedOutput = output
|
||
result.FormatFeatures["list_mode"] = true
|
||
return output, result, nil
|
||
}
|
||
|
||
// 获取对应的生成器
|
||
gen, err := ce.generatorMgr.GetGenerator(request.Target)
|
||
if err != nil {
|
||
return "", result, fmt.Errorf("no generator available for format: %s", request.Target)
|
||
}
|
||
|
||
// 创建默认的生成选项
|
||
options := &generator.GenerationOptions{
|
||
Name: "SubConverter-Go",
|
||
Group: request.Group,
|
||
IPv6: request.IPv6,
|
||
Mode: "rule",
|
||
BasePath: request.BasePath,
|
||
}
|
||
|
||
options.AppendType = request.AppendType
|
||
options.TFO = request.TFO
|
||
options.Script = request.Script
|
||
options.SkipCert = request.SkipCert
|
||
options.FilterDeprecated = request.FilterDeprecated
|
||
options.ExpandRules = request.ExpandRules
|
||
options.AppendInfo = request.AppendInfo
|
||
options.Prepend = request.Prepend
|
||
options.Classic = request.Classic
|
||
options.TLS13 = request.TLS13
|
||
options.AddEmoji = request.AddEmoji
|
||
options.RemoveEmoji = request.RemoveEmoji
|
||
options.RenameRules = append([]string{}, request.RenameRules...)
|
||
options.CustomGroups = append([]string{}, request.Groups...)
|
||
options.CustomRulesets = append([]string{}, request.Rulesets...)
|
||
options.CustomProviders = append([]string{}, request.Providers...)
|
||
|
||
if len(request.Providers) > 0 {
|
||
providers := make([]*generator.ProviderDefinition, 0, len(request.Providers))
|
||
for _, entry := range request.Providers {
|
||
parsed, err := generator.ParseProviderDefinition(entry)
|
||
if err != nil {
|
||
return "", result, fmt.Errorf("invalid provider definition: %w", err)
|
||
}
|
||
providers = append(providers, parsed)
|
||
}
|
||
options.Providers = providers
|
||
}
|
||
|
||
if len(request.Groups) > 0 {
|
||
groups := make([]*generator.GroupDefinition, 0, len(request.Groups))
|
||
for _, entry := range request.Groups {
|
||
parsed, err := generator.ParseGroupDefinition(entry)
|
||
if err != nil {
|
||
return "", result, fmt.Errorf("invalid group definition: %w", err)
|
||
}
|
||
groups = append(groups, parsed)
|
||
}
|
||
options.GroupDefinitions = groups
|
||
}
|
||
options.FilterScript = request.FilterScript
|
||
options.Upload = request.Upload
|
||
options.UploadPath = request.UploadPath
|
||
options.DeviceID = request.DeviceID
|
||
options.Interval = request.Interval
|
||
|
||
// 验证生成选项
|
||
if err := gen.ValidateOptions(options); err != nil {
|
||
return "", result, fmt.Errorf("invalid generation options: %v", err)
|
||
}
|
||
|
||
// 生成配置
|
||
output, err := gen.Generate(configs, options)
|
||
if err != nil {
|
||
return "", result, fmt.Errorf("failed to generate %s configuration: %v", request.Target, err)
|
||
}
|
||
|
||
result.GeneratedOutput = output
|
||
result.FormatFeatures["success"] = true
|
||
result.GenerateStats = &GenerateStats{
|
||
GeneratedProxies: len(configs),
|
||
GeneratedGroups: len(options.GroupDefinitions),
|
||
GeneratedRules: len(options.CustomRulesets),
|
||
FormatFeatures: copyFormatFeatures(result.FormatFeatures),
|
||
}
|
||
|
||
return output, result, nil
|
||
}
|
||
|
||
func (ce *ConversionEngine) applyFilters(configs []*parser.ProxyConfig, request *ConversionRequest) []*parser.ProxyConfig {
|
||
if len(request.Include) == 0 && len(request.Exclude) == 0 {
|
||
return configs
|
||
}
|
||
|
||
includeTokens := make([]string, 0, len(request.Include))
|
||
for _, token := range request.Include {
|
||
token = strings.ToLower(strings.TrimSpace(token))
|
||
if token != "" {
|
||
includeTokens = append(includeTokens, token)
|
||
}
|
||
}
|
||
|
||
excludeTokens := make([]string, 0, len(request.Exclude))
|
||
for _, token := range request.Exclude {
|
||
token = strings.ToLower(strings.TrimSpace(token))
|
||
if token != "" {
|
||
excludeTokens = append(excludeTokens, token)
|
||
}
|
||
}
|
||
|
||
base := make([]*parser.ProxyConfig, 0, len(configs))
|
||
for _, cfg := range configs {
|
||
label := strings.ToLower(strings.TrimSpace(firstNonEmpty(cfg.Remarks, cfg.Name)))
|
||
if label == "" {
|
||
if cfg.Server != "" && cfg.Port != 0 {
|
||
label = fmt.Sprintf("%s:%d", strings.ToLower(cfg.Server), cfg.Port)
|
||
} else {
|
||
label = strings.ToLower(cfg.Type)
|
||
}
|
||
}
|
||
|
||
if matchesAny(label, excludeTokens) {
|
||
continue
|
||
}
|
||
|
||
base = append(base, cfg)
|
||
}
|
||
|
||
filtered := base
|
||
if len(includeTokens) > 0 {
|
||
included := make([]*parser.ProxyConfig, 0, len(base))
|
||
for _, cfg := range base {
|
||
label := strings.ToLower(strings.TrimSpace(firstNonEmpty(cfg.Remarks, cfg.Name)))
|
||
if label == "" {
|
||
if cfg.Server != "" && cfg.Port != 0 {
|
||
label = fmt.Sprintf("%s:%d", strings.ToLower(cfg.Server), cfg.Port)
|
||
} else {
|
||
label = strings.ToLower(cfg.Type)
|
||
}
|
||
}
|
||
if matchesAny(label, includeTokens) {
|
||
included = append(included, cfg)
|
||
}
|
||
}
|
||
if len(included) > 0 {
|
||
filtered = included
|
||
}
|
||
}
|
||
|
||
ce.logger.WithField("original", len(configs)).
|
||
WithField("after_exclude", len(base)).
|
||
WithField("filtered", len(filtered)).
|
||
Debug("Applied include/exclude filters to proxy list")
|
||
|
||
return filtered
|
||
}
|
||
|
||
func (ce *ConversionEngine) applyTransformations(configs []*parser.ProxyConfig, request *ConversionRequest) ([]*parser.ProxyConfig, error) {
|
||
result := make([]*parser.ProxyConfig, 0, len(configs))
|
||
|
||
renameRules, err := ce.compileRenameRules(request)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
emojiRules, err := ce.compileEmojiRules(request)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
needsRename := len(renameRules) > 0
|
||
needsRemove := request.RemoveEmoji
|
||
needsEmoji := request.AddEmoji || len(emojiRules) > 0
|
||
|
||
if !needsRename && !needsRemove && !needsEmoji {
|
||
return configs, nil
|
||
}
|
||
|
||
for idx, cfg := range configs {
|
||
clone := *cfg
|
||
clone.Settings = copySettings(cfg.Settings)
|
||
|
||
remark := firstNonEmpty(cfg.Remarks, cfg.Name)
|
||
if remark == "" {
|
||
remark = cfg.Name
|
||
}
|
||
name := cfg.Name
|
||
|
||
node := newScriptNode(cfg, remark, request.Group, idx)
|
||
|
||
if needsRename {
|
||
updated, renameErr := applyRenameRules(renameRules, &node, ce.logger)
|
||
if renameErr != nil {
|
||
return nil, renameErr
|
||
}
|
||
if trimmed := strings.TrimSpace(updated); trimmed != "" {
|
||
remark = trimmed
|
||
name = trimmed
|
||
node.Remark = trimmed
|
||
}
|
||
}
|
||
|
||
if needsRemove {
|
||
remark = removeLeadingEmoji(remark)
|
||
name = removeLeadingEmoji(name)
|
||
node.Remark = remark
|
||
}
|
||
|
||
emoji := ""
|
||
if len(emojiRules) > 0 {
|
||
e, emojiErr := applyEmojiRules(emojiRules, &node, ce.logger)
|
||
if emojiErr != nil {
|
||
return nil, emojiErr
|
||
}
|
||
emoji = strings.TrimSpace(e)
|
||
}
|
||
if emoji == "" && request.AddEmoji {
|
||
emoji = ce.pickEmoji(clone, request)
|
||
}
|
||
if emoji != "" {
|
||
remark = prependEmoji(emoji, remark)
|
||
name = prependEmoji(emoji, name)
|
||
}
|
||
|
||
clone.Name = strings.TrimSpace(name)
|
||
clone.Remarks = strings.TrimSpace(remark)
|
||
result = append(result, &clone)
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func (ce *ConversionEngine) applyFilterScript(configs []*parser.ProxyConfig, request *ConversionRequest) ([]*parser.ProxyConfig, error) {
|
||
script, err := ce.prepareFilterScript(request.FilterScript, request.BasePath, request)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if script == "" {
|
||
return configs, nil
|
||
}
|
||
|
||
runner, err := ce.newScriptRunner(script, request.BasePath, filterFunctionName)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
filtered := make([]*parser.ProxyConfig, 0, len(configs))
|
||
for idx, cfg := range configs {
|
||
remark := firstNonEmpty(cfg.Remarks, cfg.Name)
|
||
if remark == "" {
|
||
remark = cfg.Name
|
||
}
|
||
node := newScriptNode(cfg, remark, request.Group, idx)
|
||
shouldRemove, callErr := runner.callBool(node)
|
||
if callErr != nil {
|
||
ce.logger.WithError(callErr).Warn("filter script execution failed")
|
||
return nil, fmt.Errorf("filter script error: %w", callErr)
|
||
}
|
||
if !shouldRemove {
|
||
filtered = append(filtered, cfg)
|
||
}
|
||
}
|
||
|
||
return filtered, nil
|
||
}
|
||
|
||
func (ce *ConversionEngine) prepareFilterScript(raw string, basePath string, request *ConversionRequest) (string, error) {
|
||
script := strings.TrimSpace(raw)
|
||
if script == "" {
|
||
return "", nil
|
||
}
|
||
lower := strings.ToLower(script)
|
||
|
||
switch {
|
||
case strings.HasPrefix(lower, "!!script:"):
|
||
return strings.TrimSpace(script[len("!!script:"):]), nil
|
||
case strings.HasPrefix(lower, "!!import:"):
|
||
content, _, err := ce.loadImportResource(script[len("!!import:"):], basePath, request)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return strings.TrimSpace(content), nil
|
||
case strings.HasPrefix(lower, "path:"):
|
||
return script, nil
|
||
case strings.HasPrefix(lower, "http://") || strings.HasPrefix(lower, "https://"):
|
||
content, err := ce.fetchFromURL(script, ce.selectUserAgent(request))
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return strings.TrimSpace(content), nil
|
||
}
|
||
|
||
if strings.Contains(script, "function ") {
|
||
return script, nil
|
||
}
|
||
|
||
resolved := script
|
||
if basePath != "" && !filepath.IsAbs(resolved) {
|
||
resolved = filepath.Join(basePath, resolved)
|
||
}
|
||
if _, err := os.Stat(resolved); err == nil {
|
||
return "path:" + script, nil
|
||
}
|
||
|
||
ce.logger.Warnf("Filter script %s not found; skipping", script)
|
||
return "", nil
|
||
}
|
||
|
||
func (ce *ConversionEngine) handleUpload(request *ConversionRequest, content string) error {
|
||
if !request.Upload {
|
||
return nil
|
||
}
|
||
path := strings.TrimSpace(request.UploadPath)
|
||
if path == "" {
|
||
ce.logger.Warn("Upload requested without upload_path; skipping")
|
||
return nil
|
||
}
|
||
|
||
lower := strings.ToLower(path)
|
||
switch {
|
||
case strings.HasPrefix(lower, "file://"):
|
||
parsed, err := url.Parse(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
resolved := parsed.Path
|
||
if resolved == "" {
|
||
resolved = parsed.Host
|
||
}
|
||
return ce.uploadToFile(resolved, content)
|
||
case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):
|
||
return ce.uploadOverHTTP(path, content)
|
||
default:
|
||
resolved := path
|
||
if request.BasePath != "" && !filepath.IsAbs(resolved) {
|
||
resolved = filepath.Join(request.BasePath, resolved)
|
||
}
|
||
return ce.uploadToFile(resolved, content)
|
||
}
|
||
}
|
||
|
||
func (ce *ConversionEngine) uploadToFile(destination string, content string) error {
|
||
if destination == "" {
|
||
return fmt.Errorf("upload destination is empty")
|
||
}
|
||
dir := filepath.Dir(destination)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
return err
|
||
}
|
||
return os.WriteFile(destination, []byte(content), 0o644)
|
||
}
|
||
|
||
func (ce *ConversionEngine) uploadOverHTTP(target string, content string) error {
|
||
client := ce.httpClient
|
||
if client == nil {
|
||
client = &http.Client{Timeout: defaultFetchTimeout}
|
||
}
|
||
|
||
methods := []string{http.MethodPut, http.MethodPost}
|
||
for idx, method := range methods {
|
||
req, err := http.NewRequest(method, target, strings.NewReader(content))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
|
||
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
if idx == len(methods)-1 {
|
||
return err
|
||
}
|
||
continue
|
||
}
|
||
_, _ = io.Copy(io.Discard, resp.Body)
|
||
resp.Body.Close()
|
||
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
|
||
return nil
|
||
}
|
||
if idx == len(methods)-1 {
|
||
return fmt.Errorf("upload failed with status %d", resp.StatusCode)
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func (ce *ConversionEngine) pickEmoji(cfg parser.ProxyConfig, request *ConversionRequest) string {
|
||
switch strings.ToLower(cfg.Protocol) {
|
||
case "ss", "ssr":
|
||
return "🛰️"
|
||
case "vmess":
|
||
return "🛸"
|
||
case "trojan":
|
||
return "🐴"
|
||
}
|
||
|
||
host := strings.ToLower(cfg.Server)
|
||
switch {
|
||
case strings.Contains(host, "hk"):
|
||
return "🇭🇰"
|
||
case strings.Contains(host, "jp"):
|
||
return "🇯🇵"
|
||
case strings.Contains(host, "us") || strings.HasSuffix(host, ".us"):
|
||
return "🇺🇸"
|
||
case strings.Contains(host, "sg"):
|
||
return "🇸🇬"
|
||
case strings.Contains(host, "kr"):
|
||
return "🇰🇷"
|
||
case strings.Contains(host, "tw"):
|
||
return "🇹🇼"
|
||
}
|
||
|
||
return ""
|
||
}
|
||
|
||
func removeLeadingEmoji(input string) string {
|
||
trimmed := strings.TrimSpace(input)
|
||
if trimmed == "" {
|
||
return trimmed
|
||
}
|
||
runes := []rune(trimmed)
|
||
if len(runes) == 0 {
|
||
return trimmed
|
||
}
|
||
skip := leadingEmojiLength(runes)
|
||
if skip == 0 {
|
||
return trimmed
|
||
}
|
||
return strings.TrimSpace(string(runes[skip:]))
|
||
}
|
||
|
||
func prependEmoji(emoji string, value string) string {
|
||
trimmed := strings.TrimSpace(value)
|
||
if trimmed == "" {
|
||
return emoji
|
||
}
|
||
if strings.HasPrefix(trimmed, emoji) {
|
||
return trimmed
|
||
}
|
||
return strings.TrimSpace(emoji + " " + trimmed)
|
||
}
|
||
|
||
func isEmojiRune(r rune) bool {
|
||
return r >= 0x1F300 && r <= 0x1FAFF
|
||
}
|
||
|
||
func isRegionalIndicator(r rune) bool {
|
||
return r >= 0x1F1E6 && r <= 0x1F1FF
|
||
}
|
||
|
||
func isVariationSelector(r rune) bool {
|
||
return r >= 0xFE00 && r <= 0xFE0F
|
||
}
|
||
|
||
func leadingEmojiLength(runes []rune) int {
|
||
if len(runes) == 0 {
|
||
return 0
|
||
}
|
||
|
||
first := runes[0]
|
||
if isEmojiRune(first) {
|
||
skip := 1
|
||
if len(runes) > skip && isVariationSelector(runes[skip]) {
|
||
skip++
|
||
}
|
||
return skip
|
||
}
|
||
|
||
if isRegionalIndicator(first) {
|
||
skip := 1
|
||
if len(runes) > skip && isRegionalIndicator(runes[skip]) {
|
||
skip++
|
||
}
|
||
return skip
|
||
}
|
||
|
||
return 0
|
||
}
|
||
|
||
func matchesAny(label string, tokens []string) bool {
|
||
for _, token := range tokens {
|
||
if token != "" && strings.Contains(label, token) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func copyFormatFeatures(features map[string]bool) map[string]bool {
|
||
if len(features) == 0 {
|
||
return map[string]bool{}
|
||
}
|
||
dup := make(map[string]bool, len(features))
|
||
for k, v := range features {
|
||
dup[k] = v
|
||
}
|
||
return dup
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, v := range values {
|
||
if strings.TrimSpace(v) != "" {
|
||
return v
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// createSuccessResponse 创建成功响应
|
||
func (ce *ConversionEngine) createSuccessResponse(
|
||
request *ConversionRequest,
|
||
output string,
|
||
configs []*parser.ProxyConfig,
|
||
parseResult *ParseResult,
|
||
generateResult *GenerateResult,
|
||
) *ConversionResponse {
|
||
response := NewConversionResponse()
|
||
response.Success = true
|
||
response.Content = output
|
||
response.ContentType = request.GetContentType()
|
||
response.TargetFormat = request.Target
|
||
response.SourceURL = request.SourceSummary()
|
||
response.NodeCount = len(configs)
|
||
response.ProcessingTime = 0 // 临时设置为0,实际应该在调用处计算
|
||
response.Timestamp = time.Now()
|
||
|
||
if headerVal := ce.profileUpdateHeader(request.Interval); headerVal != "" {
|
||
response.AddHeader("profile-update-interval", headerVal)
|
||
}
|
||
|
||
// 添加调试信息
|
||
debugInfo := map[string]interface{}{
|
||
"parse_time": 0,
|
||
"generate_time": 0,
|
||
"total_time": 0,
|
||
"input_size": 0,
|
||
"output_size": len(output),
|
||
"proxy_count": len(configs),
|
||
"error_count": len(parseResult.ErrorMessages),
|
||
"protocols": parseResult.ProtocolStats,
|
||
"format_features": generateResult.FormatFeatures,
|
||
}
|
||
if generateResult.GenerateStats != nil {
|
||
debugInfo["generate_stats"] = map[string]interface{}{
|
||
"generated_proxies": generateResult.GenerateStats.GeneratedProxies,
|
||
"generated_groups": generateResult.GenerateStats.GeneratedGroups,
|
||
"generated_rules": generateResult.GenerateStats.GeneratedRules,
|
||
"features": generateResult.GenerateStats.FormatFeatures,
|
||
}
|
||
}
|
||
response.DebugInfo = debugInfo
|
||
|
||
return response
|
||
}
|
||
|
||
// createErrorResponse 创建错误响应
|
||
func (ce *ConversionEngine) createErrorResponse(request *ConversionRequest, errorMessage string) *ConversionResponse {
|
||
response := NewConversionResponse()
|
||
response.Success = false
|
||
response.Error = errorMessage
|
||
response.ErrorCode = "VALIDATION_ERROR"
|
||
response.ErrorDetail = errorMessage
|
||
response.TargetFormat = request.Target
|
||
response.SourceURL = request.SourceSummary()
|
||
response.ProcessingTime = 0
|
||
response.Timestamp = time.Now()
|
||
|
||
return response
|
||
}
|
||
|
||
// createDebugInfo 创建调试信息
|
||
func (ce *ConversionEngine) createDebugInfo(
|
||
request *ConversionRequest,
|
||
startTime, endTime time.Time,
|
||
parseResult *ParseResult,
|
||
generateResult *GenerateResult,
|
||
) map[string]interface{} {
|
||
totalTime := endTime.Sub(startTime).Milliseconds()
|
||
|
||
info := map[string]interface{}{
|
||
"parse_time": 0,
|
||
"generate_time": 0,
|
||
"total_time": totalTime,
|
||
"input_size": 0,
|
||
"output_size": len(generateResult.GeneratedOutput),
|
||
"proxy_count": len(parseResult.ValidConfigs),
|
||
"error_count": len(parseResult.ErrorMessages),
|
||
"protocols": parseResult.ProtocolStats,
|
||
"format_features": generateResult.FormatFeatures,
|
||
}
|
||
if generateResult.GenerateStats != nil {
|
||
info["generate_stats"] = map[string]interface{}{
|
||
"generated_proxies": generateResult.GenerateStats.GeneratedProxies,
|
||
"generated_groups": generateResult.GenerateStats.GeneratedGroups,
|
||
"generated_rules": generateResult.GenerateStats.GeneratedRules,
|
||
"features": generateResult.GenerateStats.FormatFeatures,
|
||
}
|
||
}
|
||
return info
|
||
}
|
||
|
||
// ParseResult 解析结果
|
||
type ParseResult struct {
|
||
ValidConfigs []*parser.ProxyConfig `json:"validConfigs"`
|
||
InvalidInputs []string `json:"invalidInputs"`
|
||
ErrorMessages []string `json:"errorMessages"`
|
||
ProtocolStats map[string]int `json:"protocolStats"`
|
||
}
|
||
|
||
// GenerateResult 生成结果
|
||
type GenerateResult struct {
|
||
GeneratedOutput string `json:"generatedOutput"`
|
||
FormatFeatures map[string]bool `json:"formatFeatures"`
|
||
GenerateStats *GenerateStats `json:"generateStats"`
|
||
}
|
||
|
||
func (ce *ConversionEngine) applyManagedConfig(request *ConversionRequest, content string) string {
|
||
if strings.ToLower(request.Target) != "surge" {
|
||
return content
|
||
}
|
||
if !request.Upload {
|
||
return content
|
||
}
|
||
|
||
managedURL := ce.buildManagedConfigURL(request)
|
||
if managedURL == "" {
|
||
return content
|
||
}
|
||
|
||
header := fmt.Sprintf("#!MANAGED-CONFIG %s", managedURL)
|
||
if interval := ce.normalizeInterval(request.Interval); interval != "" {
|
||
header += " interval=" + interval
|
||
}
|
||
header += fmt.Sprintf(" strict=%t", request.Strict)
|
||
|
||
return header + "\n\n" + content
|
||
}
|
||
|
||
func (ce *ConversionEngine) buildManagedConfigURL(request *ConversionRequest) string {
|
||
params := url.Values{}
|
||
params.Set("target", request.Target)
|
||
if summary := request.SourceSummary(); summary != "" {
|
||
params.Set("url", summary)
|
||
}
|
||
if request.Strict {
|
||
params.Set("strict", "true")
|
||
}
|
||
if request.Interval != "" {
|
||
params.Set("interval", request.Interval)
|
||
}
|
||
|
||
return "/sub?" + params.Encode()
|
||
}
|
||
|
||
func (ce *ConversionEngine) normalizeInterval(raw string) string {
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
value, err := strconv.Atoi(raw)
|
||
if err != nil || value <= 0 {
|
||
return ""
|
||
}
|
||
return strconv.Itoa(value)
|
||
}
|
||
|
||
func (ce *ConversionEngine) profileUpdateHeader(raw string) string {
|
||
if raw == "" {
|
||
return ""
|
||
}
|
||
value, err := strconv.Atoi(raw)
|
||
if err != nil || value <= 0 {
|
||
return ""
|
||
}
|
||
hours := value / 3600
|
||
if hours <= 0 {
|
||
hours = 1
|
||
}
|
||
return strconv.Itoa(hours)
|
||
}
|