first commit
Some checks failed
CI/CD Pipeline / Test (push) Failing after 22m19s
CI/CD Pipeline / Security Scan (push) Failing after 5m57s
CI/CD Pipeline / Build (amd64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (amd64, linux) (push) Has been skipped
CI/CD Pipeline / Build (amd64, windows) (push) Has been skipped
CI/CD Pipeline / Build (arm64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (arm64, linux) (push) Has been skipped
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Create Release (push) Has been skipped
Some checks failed
CI/CD Pipeline / Test (push) Failing after 22m19s
CI/CD Pipeline / Security Scan (push) Failing after 5m57s
CI/CD Pipeline / Build (amd64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (amd64, linux) (push) Has been skipped
CI/CD Pipeline / Build (amd64, windows) (push) Has been skipped
CI/CD Pipeline / Build (arm64, darwin) (push) Has been skipped
CI/CD Pipeline / Build (arm64, linux) (push) Has been skipped
CI/CD Pipeline / Build Docker Image (push) Has been skipped
CI/CD Pipeline / Create Release (push) Has been skipped
This commit is contained in:
463
internal/conversion/rule_support.go
Normal file
463
internal/conversion/rule_support.go
Normal file
@@ -0,0 +1,463 @@
|
||||
package conversion
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/subconverter-go/internal/logging"
|
||||
"github.com/subconverter-go/internal/parser"
|
||||
)
|
||||
|
||||
type renameRule struct {
|
||||
pattern *regexp.Regexp
|
||||
replacement string
|
||||
script *scriptRunner
|
||||
}
|
||||
|
||||
type emojiRule struct {
|
||||
pattern *regexp.Regexp
|
||||
emoji string
|
||||
script *scriptRunner
|
||||
}
|
||||
|
||||
type scriptRunner struct {
|
||||
fn goja.Callable
|
||||
runtime *goja.Runtime
|
||||
funcName string
|
||||
}
|
||||
|
||||
type scriptNode struct {
|
||||
Group string
|
||||
GroupID int
|
||||
Index int
|
||||
Remark string
|
||||
OriginalRemark string
|
||||
ProxyInfo string
|
||||
}
|
||||
|
||||
const (
|
||||
renameFunctionName = "rename"
|
||||
emojiFunctionName = "getEmoji"
|
||||
filterFunctionName = "filter"
|
||||
maxImportDepth = 8
|
||||
)
|
||||
|
||||
func (ce *ConversionEngine) compileRenameRules(req *ConversionRequest) ([]*renameRule, error) {
|
||||
if len(req.RenameRules) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
flattened, err := ce.expandRuleEntries(req.RenameRules, req.BasePath, 0, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]*renameRule, 0, len(flattened))
|
||||
for _, entry := range flattened {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(entry)
|
||||
if strings.HasPrefix(lower, "!!script:") {
|
||||
src := strings.TrimSpace(entry[len("!!script:"):])
|
||||
runner, err := ce.newScriptRunner(src, req.BasePath, renameFunctionName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid rename script: %w", err)
|
||||
}
|
||||
rules = append(rules, &renameRule{script: runner})
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(entry, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
ce.logger.Warnf("Skipping invalid rename rule without separator: %s", entry)
|
||||
continue
|
||||
}
|
||||
|
||||
pattern := strings.TrimSpace(parts[0])
|
||||
replacement := parts[1]
|
||||
if pattern == "" {
|
||||
ce.logger.Warnf("Skipping rename rule with empty pattern")
|
||||
continue
|
||||
}
|
||||
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid rename regex %q: %w", pattern, err)
|
||||
}
|
||||
rules = append(rules, &renameRule{pattern: compiled, replacement: replacement})
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) compileEmojiRules(req *ConversionRequest) ([]*emojiRule, error) {
|
||||
if len(req.EmojiRules) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
flattened, err := ce.expandRuleEntries(req.EmojiRules, req.BasePath, 0, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rules := make([]*emojiRule, 0, len(flattened))
|
||||
for _, entry := range flattened {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(entry)
|
||||
if strings.HasPrefix(lower, "!!script:") {
|
||||
src := strings.TrimSpace(entry[len("!!script:"):])
|
||||
runner, err := ce.newScriptRunner(src, req.BasePath, emojiFunctionName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid emoji script: %w", err)
|
||||
}
|
||||
rules = append(rules, &emojiRule{script: runner})
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(entry, "@", 2)
|
||||
if len(parts) != 2 {
|
||||
ce.logger.Warnf("Skipping invalid emoji rule without separator: %s", entry)
|
||||
continue
|
||||
}
|
||||
pattern := strings.TrimSpace(parts[0])
|
||||
emoji := strings.TrimSpace(parts[1])
|
||||
if pattern == "" || emoji == "" {
|
||||
ce.logger.Warnf("Skipping emoji rule with empty pattern or emoji")
|
||||
continue
|
||||
}
|
||||
compiled, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid emoji regex %q: %w", pattern, err)
|
||||
}
|
||||
rules = append(rules, &emojiRule{pattern: compiled, emoji: emoji})
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) expandRuleEntries(raw []string, basePath string, depth int, req *ConversionRequest) ([]string, error) {
|
||||
if depth > maxImportDepth {
|
||||
return nil, fmt.Errorf("maximum import depth exceeded")
|
||||
}
|
||||
|
||||
var result []string
|
||||
for _, entry := range raw {
|
||||
entry = strings.TrimSpace(entry)
|
||||
if entry == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(entry)
|
||||
if strings.HasPrefix(lower, "!!import:") {
|
||||
path := strings.TrimSpace(entry[len("!!import:"):])
|
||||
if path == "" {
|
||||
ce.logger.Warn("Encountered empty !!import directive")
|
||||
continue
|
||||
}
|
||||
content, resolved, err := ce.loadImportResource(path, basePath, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(content) == "" {
|
||||
ce.logger.Warnf("Encountered empty import content at %s", path)
|
||||
continue
|
||||
}
|
||||
|
||||
ext := strings.ToLower(filepath.Ext(resolved))
|
||||
if ext == ".js" || ext == ".mjs" || ext == ".ts" {
|
||||
script := strings.TrimSpace(content)
|
||||
if script == "" {
|
||||
ce.logger.Warnf("Encountered empty script import at %s", path)
|
||||
continue
|
||||
}
|
||||
result = append(result, "!!script:"+script)
|
||||
continue
|
||||
}
|
||||
|
||||
lines := strings.Split(content, "\n")
|
||||
clean := make([]string, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed[0] == ';' || trimmed[0] == '#' || strings.HasPrefix(trimmed, "//") {
|
||||
continue
|
||||
}
|
||||
clean = append(clean, trimmed)
|
||||
}
|
||||
nested, err := ce.expandRuleEntries(clean, basePath, depth+1, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, nested...)
|
||||
continue
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) loadImportResource(path string, basePath string, req *ConversionRequest) (string, string, error) {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if trimmed == "" {
|
||||
return "", "", fmt.Errorf("import path is empty")
|
||||
}
|
||||
|
||||
if parsed, err := url.Parse(trimmed); err == nil {
|
||||
switch strings.ToLower(parsed.Scheme) {
|
||||
case "http", "https":
|
||||
content, err := ce.fetchFromURL(trimmed, ce.selectUserAgent(req))
|
||||
if err != nil {
|
||||
return "", parsed.Path, fmt.Errorf("failed to import rules from %s: %w", trimmed, err)
|
||||
}
|
||||
resolved := parsed.Path
|
||||
if resolved == "" {
|
||||
resolved = parsed.Host
|
||||
}
|
||||
return content, resolved, nil
|
||||
case "file":
|
||||
resolved := parsed.Path
|
||||
if resolved == "" {
|
||||
resolved = parsed.Host
|
||||
}
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return "", resolved, fmt.Errorf("failed to import rules from %s: %w", trimmed, err)
|
||||
}
|
||||
return string(data), resolved, nil
|
||||
}
|
||||
}
|
||||
|
||||
resolved := trimmed
|
||||
if basePath != "" && !filepath.IsAbs(resolved) {
|
||||
resolved = filepath.Join(basePath, resolved)
|
||||
}
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return "", resolved, fmt.Errorf("failed to import rules from %s: %w", trimmed, err)
|
||||
}
|
||||
return string(data), resolved, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) newScriptRunner(source, basePath, funcName string) (*scriptRunner, error) {
|
||||
code := strings.TrimSpace(source)
|
||||
if strings.HasPrefix(strings.ToLower(code), "path:") {
|
||||
scriptPath := strings.TrimSpace(code[len("path:"):])
|
||||
if scriptPath == "" {
|
||||
return nil, fmt.Errorf("script path cannot be empty")
|
||||
}
|
||||
resolved := scriptPath
|
||||
if basePath != "" && !filepath.IsAbs(scriptPath) {
|
||||
resolved = filepath.Join(basePath, scriptPath)
|
||||
}
|
||||
data, err := os.ReadFile(resolved)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read script file %s: %w", scriptPath, err)
|
||||
}
|
||||
code = string(data)
|
||||
}
|
||||
if code == "" {
|
||||
return nil, fmt.Errorf("script content cannot be empty")
|
||||
}
|
||||
|
||||
runtime := goja.New()
|
||||
ce.registerHelpers(runtime)
|
||||
|
||||
if _, err := runtime.RunString(code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fnVal := runtime.Get(funcName)
|
||||
callable, ok := goja.AssertFunction(fnVal)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("script does not define %s function", funcName)
|
||||
}
|
||||
|
||||
return &scriptRunner{
|
||||
fn: callable,
|
||||
runtime: runtime,
|
||||
funcName: funcName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ce *ConversionEngine) registerHelpers(runtime *goja.Runtime) {
|
||||
runtime.Set("btoa", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) == 0 {
|
||||
return runtime.ToValue("")
|
||||
}
|
||||
encoded := base64.StdEncoding.EncodeToString([]byte(call.Arguments[0].String()))
|
||||
return runtime.ToValue(encoded)
|
||||
})
|
||||
|
||||
runtime.Set("geoip", func(call goja.FunctionCall) goja.Value {
|
||||
if ce == nil {
|
||||
return runtime.ToValue("{}")
|
||||
}
|
||||
if len(call.Arguments) == 0 {
|
||||
return runtime.ToValue("{}")
|
||||
}
|
||||
address := strings.TrimSpace(call.Arguments[0].String())
|
||||
if address == "" {
|
||||
return runtime.ToValue("{}")
|
||||
}
|
||||
return runtime.ToValue(ce.geoIPJSON(address))
|
||||
})
|
||||
}
|
||||
|
||||
func (sr *scriptRunner) callValue(node scriptNode) (goja.Value, error) {
|
||||
if sr == nil || sr.fn == nil {
|
||||
return goja.Undefined(), fmt.Errorf("script runner not initialised")
|
||||
}
|
||||
res, err := sr.fn(goja.Undefined(), sr.runtime.ToValue(node))
|
||||
if err != nil {
|
||||
return goja.Undefined(), err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (sr *scriptRunner) call(node scriptNode) (string, error) {
|
||||
res, err := sr.callValue(node)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if goja.IsUndefined(res) || goja.IsNull(res) {
|
||||
return "", nil
|
||||
}
|
||||
return res.String(), nil
|
||||
}
|
||||
|
||||
func (sr *scriptRunner) callBool(node scriptNode) (bool, error) {
|
||||
res, err := sr.callValue(node)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if goja.IsUndefined(res) || goja.IsNull(res) {
|
||||
return false, nil
|
||||
}
|
||||
return res.ToBoolean(), nil
|
||||
}
|
||||
|
||||
func applyRenameRules(rules []*renameRule, node *scriptNode, logger *logging.Logger) (string, error) {
|
||||
current := node.Remark
|
||||
original := node.OriginalRemark
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
if rule.script != nil {
|
||||
output, err := rule.script.call(*node)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("rename script execution failed")
|
||||
return "", fmt.Errorf("rename script error: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(output) != "" {
|
||||
current = output
|
||||
node.Remark = current
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rule.pattern != nil && rule.pattern.MatchString(current) {
|
||||
current = rule.pattern.ReplaceAllString(current, rule.replacement)
|
||||
node.Remark = current
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(current) == "" {
|
||||
return original, nil
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
func applyEmojiRules(rules []*emojiRule, node *scriptNode, logger *logging.Logger) (string, error) {
|
||||
for _, rule := range rules {
|
||||
if rule == nil {
|
||||
continue
|
||||
}
|
||||
if rule.script != nil {
|
||||
output, err := rule.script.call(*node)
|
||||
if err != nil {
|
||||
logger.WithError(err).Warn("emoji script execution failed")
|
||||
return "", fmt.Errorf("emoji script error: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(output) != "" {
|
||||
return output, nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
if rule.pattern != nil && rule.pattern.MatchString(node.Remark) {
|
||||
return rule.emoji, nil
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func buildProxyInfo(cfg *parser.ProxyConfig) string {
|
||||
type proxyInfo struct {
|
||||
Name string `json:"Name"`
|
||||
Remark string `json:"Remark"`
|
||||
Hostname string `json:"Hostname"`
|
||||
Server string `json:"Server"`
|
||||
Port int `json:"Port"`
|
||||
Type string `json:"Type"`
|
||||
Protocol string `json:"Protocol"`
|
||||
Settings map[string]interface{} `json:"Settings"`
|
||||
Group string `json:"Group"`
|
||||
Location string `json:"Location"`
|
||||
UDP bool `json:"UDP"`
|
||||
}
|
||||
|
||||
info := proxyInfo{
|
||||
Name: cfg.Name,
|
||||
Remark: cfg.Remarks,
|
||||
Hostname: cfg.Server,
|
||||
Server: cfg.Server,
|
||||
Port: cfg.Port,
|
||||
Type: cfg.Type,
|
||||
Protocol: cfg.Protocol,
|
||||
Settings: cfg.Settings,
|
||||
Group: cfg.Group,
|
||||
Location: cfg.Location,
|
||||
UDP: cfg.UDP,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func copySettings(settings map[string]interface{}) map[string]interface{} {
|
||||
if settings == nil {
|
||||
return nil
|
||||
}
|
||||
dup := make(map[string]interface{}, len(settings))
|
||||
for k, v := range settings {
|
||||
dup[k] = v
|
||||
}
|
||||
return dup
|
||||
}
|
||||
|
||||
func newScriptNode(cfg *parser.ProxyConfig, remark string, group string, index int) scriptNode {
|
||||
return scriptNode{
|
||||
Group: group,
|
||||
GroupID: 0,
|
||||
Index: index,
|
||||
Remark: remark,
|
||||
OriginalRemark: remark,
|
||||
ProxyInfo: buildProxyInfo(cfg),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user