Files
sub-store/internal/render/textutil.go
T

347 lines
8.2 KiB
Go

package render
import (
"fmt"
"strings"
"github.com/peterqiu0516/sub-store/internal/model"
)
// RenderTextProxyList renders proxies into newline-separated text lines using
// the given producer. If the producer returns "" for a proxy it is skipped.
// Returns an error when no supported nodes were produced.
func RenderTextProxyList(proxies []model.ProxyNode, target string, producer func(model.ProxyNode) string) (string, error) {
var lines []string
for _, p := range proxies {
line := producer(p)
if line != "" {
lines = append(lines, line)
}
}
if len(lines) == 0 {
return "", fmt.Errorf("No supported nodes for %s output", target)
}
return strings.Join(lines, "\n"), nil
}
// JoinTextProxy joins a proxy base line with key=value entries.
// Entries with nil or empty-string values are dropped.
func JoinTextProxy(base string, entries [][2]any) string {
var parts []string
for _, entry := range entries {
key, _ := entry[0].(string)
value := entry[1]
if value == nil {
continue
}
if s, ok := value.(string); ok && s == "" {
continue
}
parts = append(parts, key+"="+FormatTextOptionValue(value))
}
if len(parts) == 0 {
return base
}
return base + "," + strings.Join(parts, ",")
}
// FormatTextOptionValue formats a value for a text proxy option line.
func FormatTextOptionValue(value any) string {
switch v := value.(type) {
case bool:
if v {
return "true"
}
return "false"
case []any:
strs := make([]string, 0, len(v))
for _, item := range v {
strs = append(strs, fmt.Sprint(item))
}
return QuoteTextValue(strings.Join(strs, ","))
case []string:
return QuoteTextValue(strings.Join(v, ","))
case nil:
return ""
default:
text := fmt.Sprint(value)
if strings.ContainsAny(text, ", \t\"") {
return QuoteTextValue(text)
}
return text
}
}
// QuoteTextValue wraps a string in double quotes, escaping inner quotes.
func QuoteTextValue(value any) string {
s := ""
if value != nil {
s = fmt.Sprint(value)
}
s = strings.ReplaceAll(s, `"`, `\"`)
return `"` + s + `"`
}
// SanitizeTextProxyName replaces characters that are illegal in text proxy
// names (=, comma, CR, LF) with spaces. Returns "proxy" when empty.
func SanitizeTextProxyName(name string) string {
r := strings.NewReplacer("=", " ", ",", " ", "\r", " ", "\n", " ")
s := strings.TrimSpace(r.Replace(name))
if s == "" {
return "proxy"
}
return s
}
// CommonTextOptions returns the shared option entries used by text proxy
// renderers (surge, loon, qx, etc.).
func CommonTextOptions(proxy model.ProxyNode) [][2]any {
return [][2]any{
{"skip-cert-verify", proxy["skip-cert-verify"]},
{"udp-relay", proxy["udp"]},
{"fast-open", fastOpenValue(proxy)},
{"alpn", FormatAlpn(proxy["alpn"])},
}
}
func fastOpenValue(proxy model.ProxyNode) any {
if v, ok := proxy["tfo"]; ok && v != nil {
return v
}
return proxy["fast-open"]
}
// AppendWsOptions appends websocket transport options for surge/loon targets.
func AppendWsOptions(entries [][2]any, proxy model.ProxyNode, target string) [][2]any {
if getString(proxy, "network") != "ws" {
return entries
}
wsOpts, _ := proxy["ws-opts"].(map[string]any)
path := "/"
if wsOpts != nil {
if p, ok := wsOpts["path"].(string); ok && p != "" {
path = p
}
}
host := WsHeaderHost(wsOpts)
if target == "surge" {
entries = append(entries, [2]any{"ws", true}, [2]any{"ws-path", path}, [2]any{"ws-headers", host})
} else {
entries = append(entries, [2]any{"path", path}, [2]any{"host", host})
}
return entries
}
// AppendPluginOptions appends shadowsocks obfs plugin options.
func AppendPluginOptions(entries [][2]any, proxy model.ProxyNode, target string) [][2]any {
if getString(proxy, "plugin") != "obfs" {
return entries
}
pluginOpts, _ := proxy["plugin-opts"].(map[string]any)
if pluginOpts == nil {
return entries
}
mode := pluginOpts["mode"]
host := pluginOpts["host"]
path := pluginOpts["path"]
if target == "surge" {
entries = append(entries,
[2]any{"obfs", mode},
[2]any{"obfs-host", host},
[2]any{"obfs-uri", path},
)
} else {
entries = append(entries,
[2]any{"obfs-name", mode},
[2]any{"obfs-host", host},
[2]any{"obfs-uri", path},
)
}
return entries
}
// AppendRealityOptions appends reality options for loon.
func AppendRealityOptions(entries [][2]any, proxy model.ProxyNode) [][2]any {
realityOpts, _ := proxy["reality-opts"].(map[string]any)
var pubKey, shortID any
if realityOpts != nil {
pubKey = realityOpts["public-key"]
shortID = realityOpts["short-id"]
}
return append(entries, [2]any{"public-key", pubKey}, [2]any{"short-id", shortID})
}
// AppendQxRealityOptions appends reality options for Quantumult X.
func AppendQxRealityOptions(entries [][2]any, proxy model.ProxyNode) [][2]any {
realityOpts, _ := proxy["reality-opts"].(map[string]any)
var pubKey, shortID any
if realityOpts != nil {
pubKey = realityOpts["public-key"]
shortID = realityOpts["short-id"]
}
return append(entries, [2]any{"reality-base64-pubkey", pubKey}, [2]any{"reality-hex-shortid", shortID})
}
// AppendQxObfs appends shadowsocks obfs options for Quantumult X.
func AppendQxObfs(entries [][2]any, proxy model.ProxyNode) [][2]any {
if getString(proxy, "plugin") != "obfs" {
return entries
}
pluginOpts, _ := proxy["plugin-opts"].(map[string]any)
if pluginOpts == nil {
return entries
}
return append(entries,
[2]any{"obfs", pluginOpts["mode"]},
[2]any{"obfs-host", pluginOpts["host"]},
[2]any{"obfs-uri", pluginOpts["path"]},
)
}
// AppendQxTransport appends websocket transport options for Quantumult X.
func AppendQxTransport(entries [][2]any, proxy model.ProxyNode) [][2]any {
if getString(proxy, "network") != "ws" {
return entries
}
wsOpts, _ := proxy["ws-opts"].(map[string]any)
path := "/"
if wsOpts != nil {
if p, ok := wsOpts["path"].(string); ok && p != "" {
path = p
}
}
transport := "ws"
if getBool(proxy, "tls") {
transport = "wss"
}
return append(entries,
[2]any{"obfs", transport},
[2]any{"obfs-uri", path},
[2]any{"obfs-host", WsHeaderHost(wsOpts)},
)
}
// WsHeaderHost extracts the Host header from ws-opts headers (case-insensitive).
func WsHeaderHost(wsOpts map[string]any) string {
if wsOpts == nil {
return ""
}
headers, ok := wsOpts["headers"].(map[string]any)
if !ok {
return ""
}
if v, ok := headers["Host"]; ok {
return fmt.Sprint(v)
}
if v, ok := headers["host"]; ok {
return fmt.Sprint(v)
}
return ""
}
// FormatAlpn formats an alpn value — joins array with commas or returns the string.
func FormatAlpn(value any) string {
switch v := value.(type) {
case nil:
return ""
case []any:
var parts []string
for _, item := range v {
s := fmt.Sprint(item)
if s != "" {
parts = append(parts, s)
}
}
return strings.Join(parts, ",")
case []string:
var parts []string
for _, item := range v {
if item != "" {
parts = append(parts, item)
}
}
return strings.Join(parts, ",")
case string:
return v
default:
return fmt.Sprint(value)
}
}
// --- internal helpers shared across renderers ---
func getString(proxy model.ProxyNode, key string) string {
v, _ := proxy[key].(string)
return v
}
func getBool(proxy model.ProxyNode, key string) bool {
switch v := proxy[key].(type) {
case bool:
return v
case string:
return v == "true" || v == "1"
default:
return false
}
}
// getInt returns an int value, handling both int and float64 from JSON.
func getInt(proxy model.ProxyNode, key string) int {
switch v := proxy[key].(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case string:
var i int
fmt.Sscanf(v, "%d", &i)
return i
}
return 0
}
// stringSetting returns the string form of a value, or "" when nil.
func stringSetting(v any) string {
if v == nil {
return ""
}
return fmt.Sprint(v)
}
// numberOrUndefined converts a value to a number, returning 0 when nil/empty.
func numberOrUndefined(v any) int {
if v == nil {
return 0
}
switch n := v.(type) {
case int:
return n
case int64:
return int(n)
case float64:
return int(n)
case string:
if n == "" {
return 0
}
var i int
fmt.Sscanf(n, "%d", &i)
return i
}
return 0
}
// hasValue returns true when value is non-nil and non-empty-string.
func hasValue(v any) bool {
if v == nil {
return false
}
if s, ok := v.(string); ok && s == "" {
return false
}
return true
}