Files
杨豪 5f8edd593c
Build and Publish Docker Image / build-and-push (pull_request) Successful in 16m36s
fix: normalize literal \n separators in ParseProxyLines
Some frontends stuff multi-line subscription content into a single
string with literal backslash-n sequences. The whole blob parsed as
one URI whose fragment swallowed the remaining links (1 node with a
garbage name instead of N nodes).

Normalize literal \r\n / \n to real newlines at the ParseProxyLines
entry, after format detection and before splitting. Real newline
input and single links are unaffected.

HH-766
2026-08-28 21:23:08 +08:00

165 lines
4.7 KiB
Go

package proxy
import (
"encoding/json"
"regexp"
"strings"
"github.com/peterqiu0516/sub-store/internal/model"
"github.com/peterqiu0516/sub-store/internal/util"
"gopkg.in/yaml.v3"
)
var (
// reURIScheme matches a URI scheme like "vless://", "ss://", etc.
reURIScheme = regexp.MustCompile(`(?im)^[a-z][a-z0-9+.-]*://`)
// reYamlKeys matches YAML top-level keys: proxies, proxy-groups, rules
reYamlKeys = regexp.MustCompile(`(?m)^\s*(proxies|proxy-groups|rules)\s*:`)
// reJSONStart matches the start of a JSON array or object
reJSONStart = regexp.MustCompile(`^\s*[\[{]`)
// reQxConfig matches QX-style config lines: "shadowsocks = ...", "vmess = ...", etc.
reQxConfig = regexp.MustCompile(`(?im)^\s*(shadowsocks|vmess|vless|trojan|http|socks5|anytls)\s*=`)
// reNamedConfig matches Surge/Loon named config lines
reNamedConfig = regexp.MustCompile(`(?im)^\s*[^=\n]{1,80}\s*=\s*(ss|shadowsocks|ssr|vmess|vless|trojan|http|https|socks5|socks5-tls|hysteria2|hysteria|anytls|tuic|tuic-v5)\s*,`)
// reCommentOrSection matches lines to skip: comments (#, ;) and section headers ([...])
reCommentOrSection = regexp.MustCompile(`^\s*(#|;|\[[^\]]+\])`)
)
// ParseProxies is the main entry point for parsing subscription content.
// It trims the input, detects JSON/YAML/URI-line format, and dispatches accordingly.
func ParseProxies(raw string) []model.ProxyNode {
text := strings.TrimSpace(raw)
if text == "" {
return nil
}
if reJSONStart.MatchString(text) {
return ParseJsonProxies(text)
}
if reYamlKeys.MatchString(text) {
return ParseYamlProxies(text)
}
return ParseProxyLines(text)
}
// DecodeMaybeBase64 tries structured detection first; if the input doesn't
// look like a structured subscription, attempts base64 decode.
func DecodeMaybeBase64(raw string) string {
text := strings.TrimSpace(raw)
if LooksLikeStructuredSubscription(text) {
return raw
}
// Try base64 decode (strip all whitespace first)
cleaned := regexp.MustCompile(`\s+`).ReplaceAllString(text, "")
decoded, err := util.DecodeBase64Auto(cleaned)
if err != nil {
return raw
}
if LooksLikeStructuredSubscription(strings.TrimSpace(decoded)) {
return decoded
}
return raw
}
// LooksLikeStructuredSubscription checks if the text looks like structured
// subscription content (URI scheme, YAML keys, JSON, or client config lines).
func LooksLikeStructuredSubscription(text string) bool {
return reURIScheme.MatchString(text) ||
reYamlKeys.MatchString(text) ||
reJSONStart.MatchString(text) ||
reQxConfig.MatchString(text) ||
reNamedConfig.MatchString(text)
}
// ParseJsonProxies parses a JSON array of proxies or {"proxies": [...]}.
func ParseJsonProxies(raw string) []model.ProxyNode {
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil
}
var rawList []any
switch v := payload.(type) {
case []any:
rawList = v
case map[string]any:
if arr, ok := v["proxies"].([]any); ok {
rawList = arr
}
default:
return nil
}
var result []model.ProxyNode
for _, item := range rawList {
if m, ok := item.(map[string]any); ok {
normalized := NormalizeProxy(m)
if IsProxyNode(normalized) {
result = append(result, normalized)
}
}
}
return result
}
// ParseYamlProxies parses a YAML document with a "proxies:" key.
func ParseYamlProxies(raw string) []model.ProxyNode {
var payload map[string]any
if err := yaml.Unmarshal([]byte(raw), &payload); err != nil {
return nil
}
rawList, ok := payload["proxies"].([]any)
if !ok {
return nil
}
var result []model.ProxyNode
for _, item := range rawList {
if m, ok := item.(map[string]any); ok {
normalized := NormalizeProxy(m)
if IsProxyNode(normalized) {
result = append(result, normalized)
}
}
}
return result
}
// ParseProxyLines splits raw text into lines, skips comments/blanks/section headers,
// and parses each line as a URI or client config line.
//
// Literal "\n" / "\r\n" sequences (backslash + n, as produced by some frontends
// stuffing multi-line content into a single string) are normalized to real
// newlines before splitting so each link is parsed as its own line.
func ParseProxyLines(raw string) []model.ProxyNode {
if strings.Contains(raw, `\n`) {
raw = strings.ReplaceAll(raw, `\r\n`, "\n")
raw = strings.ReplaceAll(raw, `\n`, "\n")
}
lines := strings.Split(raw, "\n")
var result []model.ProxyNode
index := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if reCommentOrSection.MatchString(line) {
continue
}
var node model.ProxyNode
if node = ParseProxyUri(line, index); node == nil {
node = ParseClientProxyLine(line, index)
}
if node != nil {
result = append(result, node)
index++
}
}
return result
}