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
464 lines
12 KiB
Go
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),
|
|
}
|
|
}
|