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