Files
subconverter-go/internal/conversion/rule_support.go
Rogee 7fcabe0225
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
first commit
2025-09-28 10:05:07 +08:00

464 lines
12 KiB
Go

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