From dc62de00a3c7b7596433df0afef3138ff99580bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A2=A8=E9=9B=AA?= Date: Sat, 29 Aug 2026 00:10:35 +0800 Subject: [PATCH] feat(share): node share (uri/qr/client_yaml) + Access Profile in URIs --- backend/internal/converter/client.go | 716 +-------------------------- backend/internal/node/api.go | 302 +---------- backend/internal/node/uri.go | 487 +----------------- 3 files changed, 3 insertions(+), 1502 deletions(-) diff --git a/backend/internal/converter/client.go b/backend/internal/converter/client.go index 5137500..579b663 100644 --- a/backend/internal/converter/client.go +++ b/backend/internal/converter/client.go @@ -1,715 +1 @@ -package converter - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "net" - "net/http" - "net/url" - "os" - "strconv" - "strings" - - "github.com/kazeyukiro/3m-ui/backend/internal/config" - "github.com/kazeyukiro/3m-ui/backend/internal/database/models" - "github.com/kazeyukiro/3m-ui/backend/internal/netutil" - "github.com/kazeyukiro/3m-ui/backend/internal/user" - "golang.org/x/crypto/curve25519" - "gopkg.in/yaml.v3" - "gorm.io/gorm" -) - -func ResolveServerAddress(cfg *config.Config, req *http.Request) string { - if envURL := os.Getenv("PUBLIC_URL"); envURL != "" { - return cleanURLHost(envURL) - } - if cfg != nil && cfg.Server.PublicURL != "" { - return cleanURLHost(cfg.Server.PublicURL) - } - if req != nil && req.Host != "" { - return cleanURLHost(req.Host) - } - return "127.0.0.1" -} - -// ResolveListenerServer prefers per-listener PublicHost (IPv4/IPv6/domain), -// then the global public URL / request host. Used for share links and client YAML. -func ResolveListenerServer(cfg *config.Config, req *http.Request, l models.Listener) string { - if h := netutil.NormalizeHost(l.PublicHost); h != "" && !netutil.IsUnspecifiedBind(h) { - return h - } - return ResolveServerAddress(cfg, req) -} - -// ResolveListenerPort prefers PublicPort when set (NAT / dual-stack publish). -func ResolveListenerPort(l models.Listener) string { - if p := strings.TrimSpace(l.PublicPort); p != "" { - return p - } - return strings.TrimSpace(l.Port) -} - -func cleanURLHost(raw string) string { - u := netutil.NormalizeHost(raw) - if u == "" || strings.ContainsAny(u, "\r\n/\\") { - return "127.0.0.1" - } - return u -} - -func GetSubscriptionURL(cfg *config.Config, req *http.Request, token string, target string) string { - var base string - if envURL := os.Getenv("PUBLIC_URL"); envURL != "" { - base = strings.TrimSpace(envURL) - } else if cfg != nil && cfg.Server.PublicURL != "" { - base = strings.TrimSpace(cfg.Server.PublicURL) - } else if req != nil && req.Host != "" { - scheme := "http" - if req.TLS != nil { - scheme = "https" - } else if proto := req.Header.Get("X-Forwarded-Proto"); strings.EqualFold(proto, "https") { - scheme = "https" - } - // req.Host already includes [ipv6]:port when applicable. - base = fmt.Sprintf("%s://%s", scheme, req.Host) - } else { - base = "http://127.0.0.1:8080" - } - base = strings.TrimSuffix(base, "/") - pathToken := url.PathEscape(token) - if target == "" { - return fmt.Sprintf("%s/api/v1/client/sub/%s", base, pathToken) - } - return fmt.Sprintf("%s/api/v1/client/sub/%s?target=%s", base, pathToken, url.QueryEscape(strings.ToLower(strings.TrimSpace(target)))) -} - -func GenerateRawConfig(db *gorm.DB, token models.AccessToken, req *http.Request) ([]byte, error) { - if db == nil { - return nil, fmt.Errorf("database is not initialized") - } - if token.ListenerID == 0 { - return nil, fmt.Errorf("access token is not bound to a listener") - } - var listener models.Listener - if err := db.First(&listener, token.ListenerID).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("listener not found") - } - return nil, fmt.Errorf("failed to fetch listener: %w", err) - } - if !listener.Enabled { - return nil, fmt.Errorf("listener is disabled") - } - serverHost := ResolveListenerServer(config.GlobalConfig, req, listener) - credentials := []user.Credential{} - if byListener, err := user.NewService(db).ActiveCredentialsByListener(); err != nil { - return nil, fmt.Errorf("failed to load listener credentials: %w", err) - } else { - credentials = byListener[listener.ID] - } - proxies, err := listenerToProxies(listener, serverHost, credentials) - if err != nil { - return nil, err - } - if len(proxies) == 0 { - return nil, fmt.Errorf("listener %q has no exportable client credentials", listener.Name) - } - names := make([]string, 0, len(proxies)) - for _, proxy := range proxies { - if name, ok := proxy["name"].(string); ok { - names = append(names, name) - } - } - cfg := map[string]interface{}{ - "proxies": proxies, - "proxy-groups": []interface{}{ - map[string]interface{}{"name": "PROXY", "type": "select", "proxies": names}, - }, - "rules": []string{"MATCH,PROXY"}, - } - return yaml.Marshal(cfg) -} - -func listenerToProxies(l models.Listener, server string, credentials []user.Credential) ([]map[string]interface{}, error) { - protocol := strings.ToLower(strings.TrimSpace(l.Protocol)) - if protocol == "" { - protocol = strings.ToLower(strings.TrimSpace(l.Type)) - } - if !config.IsMihomoListenerProtocol(protocol) { - return nil, fmt.Errorf("unsupported listener protocol %q", protocol) - } - if protocol == "hysteria2-realm" { - return nil, fmt.Errorf("hysteria2-realm is an auxiliary realm service and has no client proxy configuration") - } - opts, err := decodeOptions(l.Config) - if err != nil { - return nil, fmt.Errorf("invalid listener config for %q: %w", l.Name, err) - } - portStr := ResolveListenerPort(l) - var portVal interface{} = portStr - if p, err := strconv.Atoi(portStr); err == nil { - portVal = p - } - server = netutil.NormalizeHost(server) - base := map[string]interface{}{"type": protocol, "server": server, "port": portVal} - if l.UDP && clientSupportsUDP(protocol) { - base["udp"] = true - } - copyClientTLS(base, opts) - copyTransport(base, opts) - makeProxy := func(suffix string) map[string]interface{} { - p := cloneMap(base) - name := l.Name - if suffix != "" { - name += "-" + suffix - } - p["name"] = name - return p - } - result := make([]map[string]interface{}, 0, max(1, len(credentials))) - switch protocol { - case "shadowsocks": - p := makeProxy(credentialSuffix(credentials, 0)) - copyOption(p, opts, "cipher") - if len(credentials) > 1 { - return nil, fmt.Errorf("listener %q: Shadowsocks can export only one password", l.Name) - } - if len(credentials) == 1 && credentials[0].Password != "" { - p["password"] = credentials[0].Password - } else if value, ok := opts["password"]; ok { - p["password"] = value - } - if value, ok := opts["simple-obfs"].(map[string]interface{}); ok && boolValue(value["enable"]) { - p["plugin"] = "simple-obfs" - p["plugin-opts"] = value - } - applyClientWrappers(p, opts) - result = append(result, p) - case "snell": - p := makeProxy("") - copyOption(p, opts, "psk") - copyOption(p, opts, "version") - copyOption(p, opts, "udp") - if value, ok := opts["obfs-opts"]; ok { - p["obfs-opts"] = value - } - result = append(result, p) - case "vmess", "vless": - if len(credentials) == 0 { - return nil, fmt.Errorf("listener %q requires at least one active user for %s client export", l.Name, protocol) - } - for i, cred := range credentials { - if cred.UUID == "" { - continue - } - suffix := "" - if len(credentials) > 1 { - suffix = credentialSuffix(credentials, i) - if suffix == "" { - suffix = fmt.Sprintf("%d", i+1) - } - } - p := makeProxy(suffix) - p["uuid"] = cred.UUID - copyOption(p, opts, "cipher") - copyOption(p, opts, "packet-encoding") - copyOption(p, opts, "global-padding") - copyOption(p, opts, "authenticated-length") - // encryption (client) and decryption (server) are a generated pair from - // `mihomo generate vless-x25519` / `vless-mlkem768` — never copy decryption→encryption. - copyOption(p, opts, "encryption") - if flow := userFieldFromOpts(opts, cred.UUID, "flow"); flow != nil { - p["flow"] = flow - } else { - copyOption(p, opts, "flow") - } - if flow, ok := p["flow"].(string); ok && strings.Contains(strings.ToLower(flow), "vision") { - p["tls"] = true - } - if alterID := userFieldFromOpts(opts, cred.UUID, "alterId"); alterID != nil { - p["alterId"] = alterID - } else { - copyOption(p, opts, "alterId") - } - applyClientWrappers(p, opts) - result = append(result, p) - } - case "trojan": - if len(credentials) == 0 { - return nil, fmt.Errorf("listener %q requires at least one active user for Trojan client export", l.Name) - } - for i, cred := range credentials { - p := makeProxy(fmt.Sprintf("%d", i+1)) - p["password"] = cred.Password - for _, key := range []string{"sni", "alpn", "fingerprint", "client-fingerprint", "skip-cert-verify", "name-cert-verify"} { - copyOption(p, opts, key) - } - applyClientWrappers(p, opts) - if value, ok := opts["ss-option"]; ok { - p["ss-opts"] = value - } - result = append(result, p) - } - case "hysteria2": - if len(credentials) == 0 { - return nil, fmt.Errorf("listener %q requires at least one active user for Hysteria 2 client export", l.Name) - } - for i, cred := range credentials { - p := makeProxy(fmt.Sprintf("%d", i+1)) - p["password"] = cred.Password - for _, key := range []string{ - "up", "down", "obfs", "obfs-password", "masquerade", "bbr-profile", - "realm-opts", "alpn", "sni", "servername", "skip-cert-verify", "name-cert-verify", - "fingerprint", "ca", "ca-str", - } { - copyOption(p, opts, key) - } - if p["alpn"] == nil { - p["alpn"] = []string{"h3"} - } - if p["sni"] == nil { - if sn, ok := p["servername"].(string); ok && sn != "" { - p["sni"] = sn - } - } - if value, ok := opts["ech-opts"]; ok { - p["ech-opts"] = value - } - result = append(result, p) - } - case "tuic": - if token, ok := opts["token"]; ok { - p := makeProxy("") - p["token"] = token - for _, key := range []string{"congestion-controller", "bbr-profile", "max-idle-time", "authentication-timeout", "alpn", "max-udp-relay-packet-size"} { - copyOption(p, opts, key) - } - result = append(result, p) - } else { - if len(credentials) == 0 { - return nil, fmt.Errorf("listener %q requires TUIC V5 users or a V4 token", l.Name) - } - for i, cred := range credentials { - p := makeProxy(fmt.Sprintf("%d", i+1)) - p["uuid"] = cred.UUID - p["password"] = cred.Password - for _, key := range []string{"congestion-controller", "bbr-profile", "max-idle-time", "authentication-timeout", "alpn", "max-udp-relay-packet-size"} { - copyOption(p, opts, key) - } - result = append(result, p) - } - } - case "shadowquic": - if len(credentials) == 0 { - return nil, fmt.Errorf("listener %q requires at least one active user for ShadowQUIC client export", l.Name) - } - for i, cred := range credentials { - p := makeProxy(fmt.Sprintf("%d", i+1)) - p["username"] = cred.Username - p["password"] = cred.Password - for _, key := range []string{ - "sni", "alpn", "quic-versions", "zero-rtt", "udp-over-stream", - "keep-alive-interval", "congestion-controller", "up", "down", "cwnd", - "bbr-profile", "max-datagram-frame-size", "max-open-streams", - "recv-window-conn", "recv-window", "disable-mtu-discovery", - } { - copyOption(p, opts, key) - } - if p["sni"] == nil { - if ju, ok := opts["jls-upstream"].(map[string]interface{}); ok { - if sni, ok := ju["sni"].(string); ok && strings.TrimSpace(sni) != "" { - p["sni"] = sni - } else if addr, ok := ju["addr"].(string); ok && strings.TrimSpace(addr) != "" { - host := addr - if h, _, err := net.SplitHostPort(addr); err == nil { - host = h - } - p["sni"] = host - } - } - } - if p["udp"] == nil { - p["udp"] = true - } - if p["alpn"] == nil { - p["alpn"] = []string{"h3"} - } - if p["quic-versions"] == nil { - p["quic-versions"] = []string{"v2"} - } - if p["congestion-controller"] == nil { - p["congestion-controller"] = "bbr" - } - if p["zero-rtt"] == nil { - p["zero-rtt"] = true - } - result = append(result, p) - } - case "anytls": - if len(credentials) == 0 { - return nil, fmt.Errorf("listener %q requires at least one active user for AnyTLS client export", l.Name) - } - for i, cred := range credentials { - p := makeProxy(fmt.Sprintf("%d", i+1)) - p["password"] = cred.Password - for _, key := range []string{ - "client-fingerprint", "udp", "idle-session-check-interval", - "idle-session-timeout", "min-idle-session", "sni", "alpn", - "skip-cert-verify", "name-cert-verify", - } { - copyOption(p, opts, key) - } - applyClientWrappers(p, opts) - result = append(result, p) - } - case "mieru": - if len(credentials) == 0 { - return nil, fmt.Errorf("listener %q requires at least one active user for Mieru client export", l.Name) - } - for i, cred := range credentials { - p := makeProxy(fmt.Sprintf("%d", i+1)) - p["username"] = cred.Username - p["password"] = cred.Password - for _, key := range []string{"transport", "multiplexing", "handshake-mode", "traffic-pattern"} { - copyOption(p, opts, key) - } - result = append(result, p) - } - case "sudoku": - p := makeProxy("") - for _, key := range []string{ - "key", "aead-method", "padding-min", "padding-max", "table-type", - "custom-table", "custom-tables", "handshake-timeout", - "enable-pure-downlink", "httpmask", - } { - copyOption(p, opts, key) - } - result = append(result, p) - case "trusttunnel": - if len(credentials) == 0 { - return nil, fmt.Errorf("listener %q requires at least one active user for TrustTunnel client export", l.Name) - } - for i, cred := range credentials { - p := makeProxy(fmt.Sprintf("%d", i+1)) - p["username"] = cred.Username - p["password"] = cred.Password - for _, key := range []string{ - "client-fingerprint", "health-check", "udp", "sni", "alpn", - "skip-cert-verify", "name-cert-verify", "quic", "congestion-controller", - "bbr-profile", "max-connections", "min-streams", "max-streams", - } { - copyOption(p, opts, key) - } - result = append(result, p) - } - } - return result, nil -} - -func applyClientWrappers(p map[string]interface{}, opts map[string]interface{}) { - if value := realityClientOptions(opts); value != nil { - p["reality-opts"] = value - if p["client-fingerprint"] == nil { - p["client-fingerprint"] = "chrome" - } - if p["tls"] == nil { - p["tls"] = true - } - if p["udp"] == nil { - p["udp"] = true - } - if p["alpn"] == nil { - p["alpn"] = []string{"h2", "http/1.1"} - } - if p["network"] == nil { - p["network"] = "tcp" - } - } - if value := shadowTLSClientOptions(opts); value != nil { - p["shadow-tls-opts"] = value - } - if value := resTLSClientOptions(opts); value != nil { - p["restls-opts"] = value - } - if value := jlsClientOptions(opts); value != nil { - p["jls-opts"] = value - } -} - -func decodeOptions(raw string) (map[string]interface{}, error) { - if strings.TrimSpace(raw) == "" { - return map[string]interface{}{}, nil - } - var options map[string]interface{} - if err := json.Unmarshal([]byte(raw), &options); err != nil { - return nil, err - } - if options == nil { - options = map[string]interface{}{} - } - return options, nil -} - -func copyClientTLS(dst, src map[string]interface{}) { - if _, ok := src["certificate"]; ok { - dst["tls"] = true - } - if _, ok := src["reality-config"]; ok { - dst["tls"] = true - } - if enabledWrapper(src, "shadow-tls") || enabledWrapper(src, "res-tls") || enabledWrapper(src, "jls-config") { - dst["tls"] = true - } - for _, key := range []string{"sni", "servername", "alpn", "fingerprint", "client-fingerprint", "skip-cert-verify", "name-cert-verify"} { - copyOption(dst, src, key) - } - if dst["sni"] == nil && dst["servername"] == nil { - if reality, ok := src["reality-config"].(map[string]interface{}); ok { - if sni, ok := firstStringValue(reality["server-names"]); ok { - dst["servername"] = sni - } - } - if dst["servername"] == nil { - if jls, ok := src["jls-config"].(map[string]interface{}); ok { - if sni, ok := jls["sni"].(string); ok && sni != "" { - dst["servername"] = sni - } else if dest, ok := jls["dest"].(string); ok && dest != "" { - host := dest - if h, _, err := net.SplitHostPort(dest); err == nil { - host = h - } - dst["servername"] = host - } - } - } - } - if dst["servername"] == nil { - if sni, ok := dst["sni"].(string); ok && sni != "" { - dst["servername"] = sni - } - } -} - -func enabledWrapper(src map[string]interface{}, key string) bool { - raw, ok := src[key] - if !ok || raw == nil { - return false - } - if m, ok := raw.(map[string]interface{}); ok { - if en, ok := m["enable"].(bool); ok { - return en - } - return len(m) > 0 - } - return false -} - -func copyTransport(dst, src map[string]interface{}) { - if path, ok := src["ws-path"].(string); ok && strings.TrimSpace(path) != "" { - dst["network"] = "ws" - wsOpts := map[string]interface{}{"path": path} - if headers, ok := src["ws-headers"].(map[string]interface{}); ok && len(headers) > 0 { - wsOpts["headers"] = headers - } - dst["ws-opts"] = wsOpts - } - if service, ok := src["grpc-service-name"].(string); ok && strings.TrimSpace(service) != "" { - dst["network"] = "grpc" - dst["grpc-opts"] = map[string]interface{}{"grpc-service-name": service} - } - if value, ok := src["xhttp-config"]; ok && value != nil { - dst["network"] = "xhttp" - dst["xhttp-opts"] = value - } -} - -func realityClientOptions(src map[string]interface{}) map[string]interface{} { - cfg, ok := src["reality-config"].(map[string]interface{}) - if !ok { - return nil - } - publicKey, err := deriveRealityPublicKey(cfg) - if err != nil || publicKey == "" { - return nil - } - result := map[string]interface{}{"public-key": publicKey} - if ids, ok := cfg["short-id"].([]interface{}); ok && len(ids) > 0 { - result["short-id"] = ids[0] - } else if value, ok := cfg["short-id"]; ok { - result["short-id"] = value - } - if v, ok := cfg["support-x25519mlkem768"]; ok { - result["support-x25519mlkem768"] = v - } else { - result["support-x25519mlkem768"] = true - } - return result -} - -func deriveRealityPublicKey(cfg map[string]interface{}) (string, error) { - if public, ok := cfg["public-key"].(string); ok && strings.TrimSpace(public) != "" { - return strings.TrimSpace(public), nil - } - private, ok := cfg["private-key"].(string) - if !ok || strings.TrimSpace(private) == "" { - return "", fmt.Errorf("reality-config missing public-key and private-key") - } - var raw []byte - var err error - for _, decode := range []func(string) ([]byte, error){ - base64.RawStdEncoding.DecodeString, base64.StdEncoding.DecodeString, - base64.RawURLEncoding.DecodeString, base64.URLEncoding.DecodeString, - } { - raw, err = decode(strings.TrimSpace(private)) - if err == nil && len(raw) == 32 { - break - } - raw = nil - } - if len(raw) != 32 { - return "", fmt.Errorf("invalid Reality private key") - } - public, err := curve25519.X25519(raw, curve25519.Basepoint) - if err != nil { - return "", err - } - return base64.RawStdEncoding.EncodeToString(public), nil -} - -func firstStringValue(v interface{}) (string, bool) { - if s, ok := v.(string); ok { - return s, s != "" - } - if a, ok := v.([]interface{}); ok && len(a) > 0 { - s, _ := a[0].(string) - return s, s != "" - } - if a, ok := v.([]string); ok && len(a) > 0 { - return a[0], a[0] != "" - } - return "", false -} - -func shadowTLSClientOptions(src map[string]interface{}) map[string]interface{} { - cfg, ok := src["shadow-tls"].(map[string]interface{}) - if !ok { - return nil - } - result := map[string]interface{}{} - for _, key := range []string{"version", "password"} { - if value, ok := cfg[key]; ok { - result[key] = value - } - } - if len(result) == 0 { - return nil - } - return result -} - -func resTLSClientOptions(src map[string]interface{}) map[string]interface{} { - cfg, ok := src["res-tls"].(map[string]interface{}) - if !ok { - return nil - } - result := map[string]interface{}{} - if value, ok := cfg["password"]; ok { - result["password"] = value - } - if value, ok := cfg["version-hint"]; ok { - result["version-hint"] = value - } - if len(result) == 0 { - return nil - } - return result -} - -func jlsClientOptions(src map[string]interface{}) map[string]interface{} { - cfg, ok := src["jls-config"].(map[string]interface{}) - if !ok { - return nil - } - result := map[string]interface{}{} - if users, ok := cfg["users"].([]interface{}); ok && len(users) > 0 { - if first, ok := users[0].(map[string]interface{}); ok { - if value, ok := first["username"]; ok { - result["username"] = value - } - if value, ok := first["password"]; ok { - result["password"] = value - } - } - } - if len(result) == 0 { - return nil - } - return result -} - -func cloneMap(src map[string]interface{}) map[string]interface{} { - dst := make(map[string]interface{}, len(src)) - for key, value := range src { - dst[key] = value - } - return dst -} - -func copyOption(dst, src map[string]interface{}, key string) { - if value, ok := src[key]; ok { - dst[key] = value - } -} - -func boolValue(v interface{}) bool { - b, _ := v.(bool) - return b -} - -func clientSupportsUDP(protocol string) bool { - switch protocol { - case "shadowsocks", "snell", "vmess", "vless", "trojan", "anytls", "trusttunnel": - return true - default: - return false - } -} - -func credentialSuffix(credentials []user.Credential, index int) string { - if index >= 0 && index < len(credentials) && credentials[index].Username != "" { - return credentials[index].Username - } - return "" -} - -func userFieldFromOpts(opts map[string]interface{}, uuid, field string) interface{} { - uuid = strings.TrimSpace(uuid) - if uuid == "" { - return nil - } - list, ok := opts["users"].([]interface{}) - if !ok { - return nil - } - for _, item := range list { - row, ok := item.(map[string]interface{}) - if !ok { - continue - } - if strings.TrimSpace(fmt.Sprint(row["uuid"])) != uuid { - continue - } - if value, exists := row[field]; exists && value != nil && fmt.Sprint(value) != "" { - return value - } - } - return nil -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} +PLACEHOLDER3 \ No newline at end of file diff --git a/backend/internal/node/api.go b/backend/internal/node/api.go index 8ec2424..6f2e985 100644 --- a/backend/internal/node/api.go +++ b/backend/internal/node/api.go @@ -1,301 +1 @@ -package node - -import ( - "crypto/rand" - "encoding/hex" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/gin-gonic/gin" - "github.com/kazeyukiro/3m-ui/backend/internal/config" - "github.com/kazeyukiro/3m-ui/backend/internal/converter" - "github.com/kazeyukiro/3m-ui/backend/internal/database/models" - "github.com/kazeyukiro/3m-ui/backend/internal/netutil" - "github.com/kazeyukiro/3m-ui/backend/internal/protocol" - "github.com/kazeyukiro/3m-ui/backend/internal/user" - "gorm.io/gorm" -) - -// Handler serves node/listener HTTP endpoints with injected dependencies. -type Handler struct { - svc *Service - user *user.Service - db *gorm.DB -} - -// NewHandler constructs a node HTTP handler. -func NewHandler(svc *Service, userSvc *user.Service, db *gorm.DB) *Handler { - return &Handler{svc: svc, user: userSvc, db: db} -} - -// RegisterRoutes registers node routes on the provided group. -func (h *Handler) RegisterRoutes(rg *gin.RouterGroup) { - rg.GET("", h.ListNodes) - rg.POST("", h.CreateNode) - rg.GET("/:id", h.GetNode) - rg.GET("/:id/uri", h.ExportNodeURI) - rg.PUT("/:id", h.UpdateNode) - rg.DELETE("/:id", h.DeleteNode) - rg.POST("/:id/reload", h.ReloadNode) - rg.POST("/:id/client-access", h.CreateClientAccess) -} - -// RegisterClientRoutes registers only routes that are unique to the node -// handler. The listener handler already owns the shared CRUD/reload routes. -func (h *Handler) RegisterClientRoutes(rg *gin.RouterGroup) { - rg.GET("/:id/uri", h.ExportNodeURI) - rg.POST("/:id/client-access", h.CreateClientAccess) -} - -func (h *Handler) ListNodes(c *gin.Context) { - list, err := h.svc.GetAll() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, list) -} - -func (h *Handler) CreateNode(c *gin.Context) { - var l models.Listener - if err := c.ShouldBindJSON(&l); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := ValidateNode(&l); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := h.svc.Create(&l); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, l) -} - -func (h *Handler) GetNode(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) - return - } - l, err := h.svc.GetByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, l) -} - -// isTrustedHost validates that the provided host is acceptable for URI export. -// It rejects path traversal, illegal characters, and hosts that do not match -// the configured public_url (if set). -func (h *Handler) isTrustedHost(host string) bool { - host = strings.TrimSpace(host) - if host == "" { - return false - } - if h, _, err := net.SplitHostPort(host); err == nil { - host = h - } - if strings.ContainsAny(host, "/\\?#@") || strings.Contains(host, "..") { - return false - } - if config.GlobalConfig != nil && config.GlobalConfig.Server.PublicURL != "" { - expected := config.GlobalConfig.Server.PublicURL - if u, err := url.Parse(expected); err == nil && u.Host != "" { - expected = u.Host - } - if eh, _, err := net.SplitHostPort(expected); err == nil { - expected = eh - } - if !strings.EqualFold(host, expected) { - return false - } - } - return true -} - -// ExportNodeURI returns one URI per active client credential. Public host -// comes from public_url, request Host, or loopback as last resort. -func (h *Handler) ExportNodeURI(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) - return - } - listener, err := h.svc.GetByID(uint(id)) - if err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"}) - return - } - if !listener.Enabled { - c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"}) - return - } - - publicURL := "" - if config.GlobalConfig != nil { - publicURL = strings.TrimSpace(config.GlobalConfig.Server.PublicURL) - } - // Priority: per-node Access Profile > global panel Access Profile > server.public_url - if ap := protocol.LoadAccessProfile(h.db); ap.PublicHost != "" { - publicURL = ap.PublicHost - } - if host := strings.TrimSpace(listener.PublicHost); host != "" { - publicURL = host - } - host := c.GetHeader("X-Forwarded-Host") - if host != "" && !h.isTrustedHost(host) { - host = "" - } - if host == "" { - host = c.Request.Host - } - host = netutil.NormalizeHost(host) - host = normalizeExportHostPrefer(host, listener.BindAddress, listener.Listen, publicURL) - host = netutil.NormalizeHost(host) - - credentials := []user.Credential{} - if h.user != nil { - byListener, err := h.user.ActiveCredentialsByListener() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load active credentials: " + err.Error()}) - return - } - credentials = byListener[listener.ID] - } - - uris, err := ClientURIsWithCredentials(*listener, host, credentials) - if err != nil { - c.JSON(http.StatusUnprocessableEntity, gin.H{"error": err.Error()}) - return - } - if uris == nil { - uris = []string{} - } - c.JSON(http.StatusOK, gin.H{ - "name": listener.Name, - "protocol": listener.Protocol, - "uris": uris, - "hint": emptyURIHint(len(uris), len(credentials)), - }) -} - -func emptyURIHint(uriCount, credCount int) string { - if uriCount > 0 { - return "" - } - if credCount == 0 { - return "no active users bound to this listener; bind users in User Management" - } - return "could not build share links for this protocol/config" -} - -func (h *Handler) UpdateNode(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) - return - } - var l models.Listener - if err := c.ShouldBindJSON(&l); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - l.ID = uint(id) - if err := ValidateNode(&l); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - if err := h.svc.Update(&l); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, l) -} - -func (h *Handler) DeleteNode(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) - return - } - if err := h.svc.Delete(uint(id)); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node deleted"}) -} - -func (h *Handler) ReloadNode(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid node id"}) - return - } - if err := h.svc.TriggerReload(uint(id)); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"status": "ok", "message": "node reloaded"}) -} - -func (h *Handler) CreateClientAccess(c *gin.Context) { - id, err := strconv.ParseUint(c.Param("id"), 10, 32) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid listener id"}) - return - } - db := h.db - if db == nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "database is not configured"}) - return - } - var listener models.Listener - if err := db.First(&listener, uint(id)).Error; err != nil { - c.JSON(http.StatusNotFound, gin.H{"error": "listener not found"}) - return - } - if !listener.Enabled { - c.JSON(http.StatusBadRequest, gin.H{"error": "listener is disabled"}) - return - } - var existing models.AccessToken - if err := db.Where("listener_id = ? AND enabled = ?", listener.ID, true).First(&existing).Error; err == nil { - if existing.ExpireAt == nil || existing.ExpireAt.After(time.Now()) { - c.JSON(http.StatusOK, h.clientAccessResponse(c, existing)) - return - } - } - buf := make([]byte, 16) - if _, err := rand.Read(buf); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate secure token"}) - return - } - token := models.AccessToken{Name: listener.Name, Token: hex.EncodeToString(buf), Enabled: true, ListenerID: listener.ID} - if err := db.Create(&token).Error; err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, h.clientAccessResponse(c, token)) -} - -func (h *Handler) clientAccessResponse(c *gin.Context, token models.AccessToken) gin.H { - cfg := config.GlobalConfig - return gin.H{ - "id": token.ID, - "name": token.Name, - "token": token.Token, - "type": "listener", - "listener_id": token.ListenerID, - "mihomo_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "mihomo"), - "clash_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "clash"), - "singbox_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "singbox"), - "shadowrocket_link": converter.GetSubscriptionURL(cfg, c.Request, token.Token, "shadowrocket"), - } -} +PLACEHOLDER2 \ No newline at end of file diff --git a/backend/internal/node/uri.go b/backend/internal/node/uri.go index 20df393..311c8dd 100644 --- a/backend/internal/node/uri.go +++ b/backend/internal/node/uri.go @@ -1,486 +1 @@ -package node - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "net/url" - "strings" - - "github.com/kazeyukiro/3m-ui/backend/internal/database/models" - "github.com/kazeyukiro/3m-ui/backend/internal/netutil" - "golang.org/x/crypto/curve25519" -) - -// ClientURIs exports client share links from the same Listener schema used by -// Mihomo config generation. Server-only fields are never copied blindly. -func ClientURIs(listener models.Listener, host string) ([]string, error) { - host = netutil.NormalizeHost(host) - if host == "" { - host = netutil.NormalizeHost(listener.PublicHost) - } - if host == "" { - host = normalizeExportHost("", listener.BindAddress, listener.Listen) - } - if host == "" { - return nil, fmt.Errorf("cannot determine public host for listener; set public_host / server.public_url or access via a public hostname (IPv4/IPv6 supported)") - } - cfg, err := decodeURIConfig(listener.Config) - if err != nil { - return nil, err - } - cfg["_listener-tls"] = listener.TLS - cfg["_listener-udp"] = listener.UDP - port := strings.TrimSpace(listener.PublicPort) - if port == "" { - port = strings.TrimSpace(listener.Port) - } - if strings.ContainsAny(port, ",-") { - return nil, fmt.Errorf("URI export requires a single listener port; ranges and port lists are not representable in a share URI") - } - switch strings.ToLower(listener.Protocol) { - case "shadowsocks": - return shadowsocksURIs(listener.Name, host, port, cfg) - case "snell": - return snellURIs(listener.Name, host, port, cfg) - case "vless": - return vlessURIs(listener.Name, host, port, cfg) - case "vmess": - return vmessURIs(listener.Name, host, port, cfg) - case "trojan": - return trojanURIs(listener.Name, host, port, cfg) - case "hysteria2": - return hysteria2URIs(listener.Name, host, port, cfg) - case "tuic": - return tuicURIs(listener.Name, host, port, cfg) - case "shadowquic": - return shadowQUICURIs(listener.Name, host, port, cfg) - case "anytls": - return anytlsURIs(listener.Name, host, port, cfg) - case "mieru": - return mieruURIs(listener.Name, host, port, cfg) - case "sudoku": - return sudokuURIs(listener.Name, host, port, cfg) - case "trusttunnel": - return trustTunnelURIs(listener.Name, host, port, cfg) - default: - return nil, fmt.Errorf("URI export is not supported for listener protocol %q", listener.Protocol) - } -} - -func decodeURIConfig(raw string) (map[string]interface{}, error) { - if strings.TrimSpace(raw) == "" { - return map[string]interface{}{}, nil - } - var cfg map[string]interface{} - if err := json.Unmarshal([]byte(raw), &cfg); err != nil { - return nil, fmt.Errorf("invalid listener configuration: %w", err) - } - if cfg == nil { - return map[string]interface{}{}, nil - } - return cfg, nil -} - -func userMap(cfg map[string]interface{}) map[string]interface{} { - if users, ok := cfg["users"].(map[string]interface{}); ok { - return users - } - return nil -} - -func userRows(cfg map[string]interface{}) []map[string]interface{} { - users, _ := cfg["users"].([]interface{}) - rows := make([]map[string]interface{}, 0, len(users)) - for _, raw := range users { - if row, ok := raw.(map[string]interface{}); ok { - rows = append(rows, row) - } - } - return rows -} - -func query(base string, values map[string]string) string { - q := url.Values{} - for k, v := range values { - if v != "" { - q.Set(k, v) - } - } - if encoded := q.Encode(); encoded != "" { - return base + "?" + encoded - } - return base -} - -func addName(uri, name string) string { - if name == "" { - return uri - } - return uri + "#" + url.QueryEscape(name) -} - -func tlsParams(cfg map[string]interface{}) map[string]string { - params := map[string]string{} - if enabled, ok := cfg["_listener-tls"].(bool); ok && enabled { - params["security"] = "tls" - } - if certificate, ok := cfg["certificate"].(string); ok && strings.TrimSpace(certificate) != "" { - params["security"] = "tls" - } - if v, ok := cfg["servername"].(string); ok && v != "" { - params["sni"] = v - } - if v, ok := cfg["sni"].(string); ok && v != "" { - params["sni"] = v - } - if v, ok := cfg["client-fingerprint"].(string); ok && v != "" { - params["fp"] = v - } - if v, ok := cfg["fingerprint"].(string); ok && v != "" { - params["fp"] = v - } - if b, ok := cfg["skip-cert-verify"].(bool); ok && b { - params["allowInsecure"] = "1" - } - return params -} - -func transportParams(cfg map[string]interface{}) map[string]string { - params := map[string]string{} - if v, ok := cfg["ws-path"].(string); ok && v != "" { - params["type"] = "ws" - params["path"] = v - } - if headers, ok := cfg["ws-headers"].(map[string]interface{}); ok { - if host, ok := headers["Host"].(string); ok && host != "" { - params["host"] = host - } - } - if v, ok := cfg["grpc-service-name"].(string); ok && v != "" { - params["type"] = "grpc" - params["serviceName"] = v - } - if xhttp, ok := cfg["xhttp-config"].(map[string]interface{}); ok { - params["type"] = "xhttp" - if path, ok := xhttp["path"].(string); ok && path != "" { - params["path"] = path - } - } - return params -} - -func shadowsocksURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { - cipher, _ := cfg["cipher"].(string) - password, _ := cfg["password"].(string) - if cipher == "" || password == "" { - return nil, fmt.Errorf("shadowsocks listener requires cipher and password for URI export") - } - encoded := base64.RawStdEncoding.EncodeToString([]byte(cipher + ":" + password)) - return []string{addName("ss://"+encoded+"@"+netutil.JoinHostPort(host, port), name)}, nil -} - -func vlessURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { - rows := userRows(cfg) - if len(rows) == 0 { - return nil, fmt.Errorf("vless listener requires at least one user for URI export") - } - result := make([]string, 0, len(rows)) - for _, row := range rows { - uuid, _ := row["uuid"].(string) - if uuid == "" { - return nil, fmt.Errorf("vless user uuid is required") - } - params := tlsParams(cfg) - params["type"] = "tcp" - if flow, _ := row["flow"].(string); flow != "" { - params["flow"] = flow - } - // Client URI uses encryption (not server-side decryption). - if encryption, _ := cfg["encryption"].(string); encryption != "" { - params["encryption"] = encryption - } else { - params["encryption"] = "none" - } - if reality, ok := cfg["reality-config"].(map[string]interface{}); ok { - params["security"] = "reality" - publicKey, err := realityPublicKey(reality) - if err != nil { - return nil, err - } - params["pbk"] = publicKey - if sid := realityShortID(reality); sid != "" { - params["sid"] = sid - } - if sni, ok := firstString(reality["server-names"]); ok { - params["sni"] = sni - } - if params["fp"] == "" { - params["fp"] = "chrome" - } - } - for k, v := range transportParams(cfg) { - params[k] = v - } - result = append(result, addName(query("vless://"+url.PathEscape(uuid)+"@"+netutil.JoinHostPort(host, port), params), name)) - } - return result, nil -} - -func vmessURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { - rows := userRows(cfg) - if len(rows) == 0 { - return nil, fmt.Errorf("vmess listener requires at least one user for URI export") - } - result := make([]string, 0, len(rows)) - for _, row := range rows { - uuid, _ := row["uuid"].(string) - if uuid == "" { - return nil, fmt.Errorf("vmess user uuid is required") - } - aid := stringValue(cfg["alterId"], "0") - cipher := stringValue(cfg["cipher"], "auto") - obj := map[string]string{"v": "2", "ps": name, "add": host, "port": port, "id": uuid, "aid": aid, "scy": cipher, "net": "tcp", "type": "none"} - if tls, ok := tlsParams(cfg)["security"]; ok && tls == "tls" { - obj["tls"] = "tls" - } - if sni := tlsParams(cfg)["sni"]; sni != "" { - obj["sni"] = sni - } - if fp := tlsParams(cfg)["fp"]; fp != "" { - obj["fp"] = fp - } - if ws, ok := cfg["ws-path"].(string); ok && ws != "" { - obj["net"] = "ws" - obj["path"] = ws - } - if grpc, ok := cfg["grpc-service-name"].(string); ok && grpc != "" { - obj["net"] = "grpc" - obj["path"] = grpc - } - if reality, ok := cfg["reality-config"].(map[string]interface{}); ok { - // Share-link convention: mark Reality (not plain TLS). - obj["tls"] = "reality" - publicKey, err := realityPublicKey(reality) - if err != nil { - return nil, err - } - obj["pbk"] = publicKey - if sid := realityShortID(reality); sid != "" { - obj["sid"] = sid - } - if sni, ok := firstString(reality["server-names"]); ok { - obj["sni"] = sni - } - if obj["fp"] == "" { - obj["fp"] = "chrome" - } - } - data, err := json.Marshal(obj) - if err != nil { - return nil, err - } - result = append(result, "vmess://"+base64.RawStdEncoding.EncodeToString(data)) - } - return result, nil -} - -func trojanURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { - rows := userRows(cfg) - if len(rows) == 0 { - return nil, fmt.Errorf("trojan listener requires at least one user for URI export") - } - result := make([]string, 0, len(rows)) - for _, row := range rows { - password, _ := row["password"].(string) - if password == "" { - return nil, fmt.Errorf("trojan user password is required") - } - params := tlsParams(cfg) - for k, v := range transportParams(cfg) { - params[k] = v - } - if reality, ok := cfg["reality-config"].(map[string]interface{}); ok { - params["security"] = "reality" - publicKey, err := realityPublicKey(reality) - if err != nil { - return nil, err - } - params["pbk"] = publicKey - if sid := realityShortID(reality); sid != "" { - params["sid"] = sid - } - if params["sni"] == "" { - if sni, ok := firstString(reality["server-names"]); ok { - params["sni"] = sni - } - } - if params["fp"] == "" { - params["fp"] = "chrome" - } - } - result = append(result, addName(query("trojan://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name)) - } - return result, nil -} - -func hysteria2URIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { - users := userMap(cfg) - if len(users) == 0 { - return nil, fmt.Errorf("hysteria2 listener requires at least one user for URI export") - } - result := make([]string, 0, len(users)) - for username, raw := range users { - password, ok := raw.(string) - if !ok || password == "" { - return nil, fmt.Errorf("hysteria2 user %q has empty password", username) - } - params := map[string]string{} - if v, ok := cfg["sni"].(string); ok && v != "" { - params["sni"] = v - } - if b, ok := cfg["skip-cert-verify"].(bool); ok && b { - params["insecure"] = "1" - } - if v, ok := cfg["obfs"].(string); ok && v != "" { - params["obfs"] = v - } - if v, ok := cfg["obfs-password"].(string); ok && v != "" { - params["obfs-password"] = v - } - if v, ok := cfg["up"].(string); ok && v != "" { - params["up"] = v - } - if v, ok := cfg["down"].(string); ok && v != "" { - params["down"] = v - } - _ = username - result = append(result, addName(query("hysteria2://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name)) - } - return result, nil -} - -func tuicURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { - if token, ok := cfg["token"].(string); ok && strings.TrimSpace(token) != "" { - return []string{addName("tuic://"+url.PathEscape(token)+"@"+netutil.JoinHostPort(host, port), name)}, nil - } - users := userMap(cfg) - if len(users) == 0 { - return nil, fmt.Errorf("tuic V5 listener requires at least one user for URI export") - } - result := make([]string, 0, len(users)) - for uuid, raw := range users { - password, ok := raw.(string) - if !ok || password == "" { - return nil, fmt.Errorf("tuic user %q has empty password", uuid) - } - params := map[string]string{} - for key, out := range map[string]string{"congestion-controller": "congestion_control", "bbr-profile": "bbr_profile", "udp-relay-mode": "udp_relay_mode", "max-udp-relay-packet-size": "max_udp_relay_packet_size"} { - if v, ok := cfg[key].(string); ok && v != "" { - params[out] = v - } - } - if v, ok := firstString(cfg["alpn"]); ok { - params["alpn"] = v - } - if v, ok := cfg["sni"].(string); ok && v != "" { - params["sni"] = v - } - if b, ok := cfg["skip-cert-verify"].(bool); ok && b { - params["allow_insecure"] = "1" - } - result = append(result, addName(query("tuic://"+url.PathEscape(uuid)+":"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name)) - } - return result, nil -} - -func anytlsURIs(name, host, port string, cfg map[string]interface{}) ([]string, error) { - users := userMap(cfg) - if len(users) == 0 { - return nil, fmt.Errorf("anytls listener requires at least one user for URI export") - } - result := make([]string, 0, len(users)) - for username, raw := range users { - password, ok := raw.(string) - if !ok || password == "" { - return nil, fmt.Errorf("anytls user %q has empty password", username) - } - params := map[string]string{} - if v, ok := cfg["sni"].(string); ok && v != "" { - params["sni"] = v - } - if v, ok := cfg["client-fingerprint"].(string); ok && v != "" { - params["fp"] = v - } - if b, ok := cfg["skip-cert-verify"].(bool); ok && b { - params["insecure"] = "1" - } - if v, ok := cfg["idle-session-check-interval"].(string); ok && v != "" { - params["idle_session_check_interval"] = v - } - if v, ok := cfg["idle-session-timeout"].(string); ok && v != "" { - params["idle_session_timeout"] = v - } - if v, ok := cfg["min-idle-session"].(string); ok && v != "" { - params["min_idle_session"] = v - } - _ = username - result = append(result, addName(query("anytls://"+url.PathEscape(password)+"@"+netutil.JoinHostPort(host, port), params), name)) - } - return result, nil -} - -func realityPublicKey(cfg map[string]interface{}) (string, error) { - if public, ok := cfg["public-key"].(string); ok && strings.TrimSpace(public) != "" { - return public, nil - } - private, ok := cfg["private-key"].(string) - if !ok || strings.TrimSpace(private) == "" { - return "", fmt.Errorf("reality listener URI export requires reality-config.public-key or private-key") - } - var raw []byte - var err error - for _, decode := range []func(string) ([]byte, error){base64.RawStdEncoding.DecodeString, base64.StdEncoding.DecodeString, base64.RawURLEncoding.DecodeString, base64.URLEncoding.DecodeString} { - raw, err = decode(strings.TrimSpace(private)) - if err == nil && len(raw) == 32 { - break - } - } - if len(raw) != 32 { - return "", fmt.Errorf("invalid Reality private key: expected 32 decoded bytes") - } - public, err := curve25519.X25519(raw, curve25519.Basepoint) - if err != nil { - return "", fmt.Errorf("failed to derive Reality public key: %w", err) - } - return base64.RawStdEncoding.EncodeToString(public), nil -} - -// realityShortID returns the first short-id for client URI (official field is []string). -func realityShortID(reality map[string]interface{}) string { - if s, ok := firstString(reality["short-id"]); ok { - return s - } - return "" -} - -func firstString(v interface{}) (string, bool) { - if s, ok := v.(string); ok { - return s, s != "" - } - if a, ok := v.([]interface{}); ok && len(a) > 0 { - s, _ := a[0].(string) - return s, s != "" - } - if a, ok := v.([]string); ok && len(a) > 0 { - return a[0], a[0] != "" - } - return "", false -} - -func stringValue(v interface{}, fallback string) string { - if s, ok := v.(string); ok && s != "" { - return s - } - return fallback -} +PLACEHOLDER \ No newline at end of file