| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809 |
- // Package link provides parsers for VPN share links (vmess://, vless://, etc.)
- // and subscription bodies (typically base64-encoded newline lists of such links).
- // The output shape matches the wire format used by the panel's Xray template
- // outbounds array so that parsed objects can be injected directly.
- package link
- import (
- "encoding/base64"
- "encoding/json"
- "fmt"
- "net/url"
- "regexp"
- "strconv"
- "strings"
- )
- // Outbound is the minimal shape we emit for each parsed link.
- // Extra fields (mux, etc.) are carried inside settings/streamSettings.
- type Outbound map[string]any
- // ParseResult holds a parsed outbound together with a stable identity string
- // that can be used to correlate the same logical server across refreshes
- // (even if the remark changes).
- type ParseResult struct {
- Outbound Outbound
- Identity string
- }
- // ParseSubscriptionBody accepts the raw body returned by a subscription URL.
- // It handles the common case where the body is a base64-encoded blob of
- // newline-separated links, and also tolerates an already-decoded text body.
- // It returns the list of successfully parsed outbounds (in order) and their
- // corresponding identities.
- func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
- text := strings.TrimSpace(string(body))
- if text == "" {
- return nil, nil, nil
- }
- // Try base64 decode first (standard and URL-safe variants).
- if decoded, ok := tryBase64(text); ok {
- text = strings.TrimSpace(decoded)
- }
- lines := splitLines(text)
- var outbounds []Outbound
- var identities []string
- for _, ln := range lines {
- ln = strings.TrimSpace(ln)
- if ln == "" || strings.HasPrefix(ln, "#") {
- continue
- }
- res, err := ParseLink(ln)
- if err != nil || res == nil {
- // Ignore unparseable lines (comments, unsupported protocols, etc.)
- continue
- }
- outbounds = append(outbounds, res.Outbound)
- identities = append(identities, res.Identity)
- }
- return outbounds, identities, nil
- }
- func tryBase64(s string) (string, bool) {
- // Remove whitespace that some providers insert.
- clean := strings.Map(func(r rune) rune {
- if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
- return -1
- }
- return r
- }, s)
- // Common padding fix
- for len(clean)%4 != 0 {
- clean += "="
- }
- // Standard
- if b, err := base64.StdEncoding.DecodeString(clean); err == nil {
- return string(b), true
- }
- // URL-safe (no padding)
- if b, err := base64.RawURLEncoding.DecodeString(clean); err == nil {
- return string(b), true
- }
- // URL-safe with padding
- if b, err := base64.URLEncoding.DecodeString(clean); err == nil {
- return string(b), true
- }
- return "", false
- }
- func splitLines(s string) []string {
- // Accept \n, \r\n, and also some providers use literal \n in the text.
- s = strings.ReplaceAll(s, `\n`, "\n")
- return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' })
- }
- // ParseLink parses a single share link and returns the outbound object plus
- // a stable identity for tag correlation. Supported schemes:
- // - vmess://
- // - vless://
- // - trojan://
- // - ss:// (modern and legacy)
- // - hysteria2:// (also hy2://)
- // - wireguard:// (also wg://)
- func ParseLink(link string) (*ParseResult, error) {
- link = strings.TrimSpace(link)
- switch {
- case strings.HasPrefix(link, "vmess://"):
- return parseVmess(link)
- case strings.HasPrefix(link, "vless://"):
- return parseVless(link)
- case strings.HasPrefix(link, "trojan://"):
- return parseTrojan(link)
- case strings.HasPrefix(link, "ss://"):
- return parseShadowsocks(link)
- case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
- return parseHysteria2(link)
- case strings.HasPrefix(link, "wireguard://"), strings.HasPrefix(link, "wg://"):
- return parseWireguard(link)
- default:
- return nil, fmt.Errorf("unsupported link scheme")
- }
- }
- // --- vmess ---
- func parseVmess(link string) (*ParseResult, error) {
- b64 := strings.TrimPrefix(link, "vmess://")
- // vmess:// base64(json)
- raw, err := base64.StdEncoding.DecodeString(padBase64(b64))
- if err != nil {
- // Some providers use raw URL-safe
- raw, err = base64.RawURLEncoding.DecodeString(b64)
- }
- if err != nil {
- return nil, fmt.Errorf("vmess decode: %w", err)
- }
- var j map[string]any
- if err := json.Unmarshal(raw, &j); err != nil {
- return nil, fmt.Errorf("vmess json: %w", err)
- }
- identity := vmessIdentity(j)
- network := getString(j, "net", "tcp")
- security := "none"
- if tls, _ := j["tls"].(string); tls == "tls" {
- security = "tls"
- }
- stream := buildStream(network, security)
- // Map known fields (best effort, matching frontend parser coverage)
- switch network {
- case "ws":
- if host, ok := j["host"].(string); ok {
- setWS(stream, host, getString(j, "path", "/"))
- }
- case "grpc":
- svc := getString(j, "path", "")
- if auth, ok := j["authority"].(string); ok && auth != "" {
- (stream["grpcSettings"].(map[string]any))["authority"] = auth
- }
- (stream["grpcSettings"].(map[string]any))["serviceName"] = svc
- (stream["grpcSettings"].(map[string]any))["multiMode"] = getString(j, "type", "") == "multi"
- case "httpupgrade":
- setHTTPUpgrade(stream, getString(j, "host", ""), getString(j, "path", "/"))
- case "xhttp":
- xh := stream["xhttpSettings"].(map[string]any)
- xh["host"] = getString(j, "host", "")
- xh["path"] = getString(j, "path", "/")
- if m := getString(j, "mode", ""); m != "" {
- xh["mode"] = m
- }
- // xhttp advanced keys are passed through if present in the json
- for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
- if v, ok := j[k]; ok {
- xh[k] = v
- }
- }
- case "tcp":
- if getString(j, "type", "") == "http" {
- stream["tcpSettings"] = map[string]any{
- "header": map[string]any{
- "type": "http",
- "request": map[string]any{
- "version": "1.1",
- "method": "GET",
- "path": splitComma(getString(j, "path", "/")),
- "headers": map[string]any{"Host": splitComma(getString(j, "host", ""))},
- },
- },
- }
- }
- }
- if security == "tls" {
- tls := stream["tlsSettings"].(map[string]any)
- tls["serverName"] = getString(j, "sni", "")
- tls["fingerprint"] = getString(j, "fp", "")
- if alpn := getString(j, "alpn", ""); alpn != "" {
- tls["alpn"] = splitComma(alpn)
- }
- }
- port := num(j["port"])
- ob := Outbound{
- "protocol": "vmess",
- "tag": getString(j, "ps", ""),
- "settings": map[string]any{
- "vnext": []any{
- map[string]any{
- "address": getString(j, "add", ""),
- "port": port,
- "users": []any{
- map[string]any{
- "id": getString(j, "id", ""),
- "security": getString(j, "scy", "auto"),
- },
- },
- },
- },
- },
- "streamSettings": stream,
- }
- return &ParseResult{Outbound: ob, Identity: identity}, nil
- }
- func vmessIdentity(j map[string]any) string {
- // Remove ps (remark) for identity
- core := map[string]any{}
- for k, v := range j {
- if k == "ps" {
- continue
- }
- core[k] = v
- }
- b, _ := json.Marshal(core)
- return "vmess:" + string(b)
- }
- // --- vless / trojan (URL forms) ---
- func parseVless(link string) (*ParseResult, error) {
- u, err := url.Parse(link)
- if err != nil {
- return nil, err
- }
- if u.Scheme != "vless" {
- return nil, fmt.Errorf("not vless")
- }
- id := u.User.Username()
- host := u.Hostname()
- port := defaultPort(u.Port(), 443)
- params := u.Query()
- network := params.Get("type")
- if network == "" {
- network = "tcp"
- }
- security := params.Get("security")
- if security == "" {
- security = "none"
- }
- stream := buildStream(network, security)
- applyTransport(stream, params)
- applySecurity(stream, params)
- applyFinalMask(stream, params)
- identity := "vless:" + u.Scheme + "://" + id + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
- ob := Outbound{
- "protocol": "vless",
- "tag": decodeHash(u.Fragment),
- "settings": map[string]any{
- "address": host,
- "port": port,
- "id": id,
- "flow": params.Get("flow"),
- "encryption": firstNonEmpty(params.Get("encryption"), "none"),
- },
- "streamSettings": stream,
- }
- return &ParseResult{Outbound: ob, Identity: identity}, nil
- }
- func parseTrojan(link string) (*ParseResult, error) {
- u, err := url.Parse(link)
- if err != nil {
- return nil, err
- }
- if u.Scheme != "trojan" {
- return nil, fmt.Errorf("not trojan")
- }
- pw := u.User.Username()
- host := u.Hostname()
- port := defaultPort(u.Port(), 443)
- params := u.Query()
- network := params.Get("type")
- if network == "" {
- network = "tcp"
- }
- security := params.Get("security")
- if security == "" {
- security = "tls"
- }
- stream := buildStream(network, security)
- applyTransport(stream, params)
- applySecurity(stream, params)
- applyFinalMask(stream, params)
- identity := "trojan:" + u.Scheme + "://" + pw + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
- ob := Outbound{
- "protocol": "trojan",
- "tag": decodeHash(u.Fragment),
- "settings": map[string]any{
- "servers": []any{
- map[string]any{"address": host, "port": port, "password": pw},
- },
- },
- "streamSettings": stream,
- }
- return &ParseResult{Outbound: ob, Identity: identity}, nil
- }
- // --- shadowsocks ---
- func parseShadowsocks(link string) (*ParseResult, error) {
- // Two shapes:
- // ss://base64(method:pass)@host:port#remark
- // ss://base64(method:pass@host:port)#remark
- remark := ""
- if i := strings.Index(link, "#"); i >= 0 {
- remark, _ = url.QueryUnescape(link[i+1:])
- link = link[:i]
- }
- core := strings.TrimPrefix(link, "ss://")
- at := strings.Index(core, "@")
- if at >= 0 {
- // modern
- userB64 := core[:at]
- hp := core[at+1:]
- userInfo, err := base64DecodeFlexible(userB64)
- if err != nil {
- userInfo = userB64 // not b64, rare
- }
- colon := strings.LastIndex(hp, ":")
- if colon < 0 {
- return nil, fmt.Errorf("bad ss host:port")
- }
- host := hp[:colon]
- port, _ := strconv.Atoi(hp[colon+1:])
- method, pass := splitMethodPass(userInfo)
- identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
- ob := Outbound{
- "protocol": "shadowsocks",
- "tag": remark,
- "settings": map[string]any{
- "servers": []any{
- map[string]any{"address": host, "port": port, "password": pass, "method": method},
- },
- },
- }
- return &ParseResult{Outbound: ob, Identity: identity}, nil
- }
- // legacy: whole thing b64
- dec, err := base64DecodeFlexible(core)
- if err != nil {
- return nil, err
- }
- at = strings.Index(dec, "@")
- if at < 0 {
- return nil, fmt.Errorf("bad legacy ss")
- }
- userInfo := dec[:at]
- hp := dec[at+1:]
- colon := strings.LastIndex(hp, ":")
- if colon < 0 {
- return nil, fmt.Errorf("bad legacy ss hp")
- }
- host := hp[:colon]
- port, _ := strconv.Atoi(hp[colon+1:])
- method, pass := splitMethodPass(userInfo)
- identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
- ob := Outbound{
- "protocol": "shadowsocks",
- "tag": remark,
- "settings": map[string]any{
- "servers": []any{
- map[string]any{"address": host, "port": port, "password": pass, "method": method},
- },
- },
- }
- return &ParseResult{Outbound: ob, Identity: identity}, nil
- }
- func splitMethodPass(userInfo string) (string, string) {
- colon := strings.Index(userInfo, ":")
- if colon < 0 {
- return "2022-blake3-aes-128-gcm", userInfo // guess
- }
- return userInfo[:colon], userInfo[colon+1:]
- }
- // --- hysteria2 ---
- func parseHysteria2(link string) (*ParseResult, error) {
- u, err := url.Parse(link)
- if err != nil {
- return nil, err
- }
- if u.Scheme != "hysteria2" && u.Scheme != "hy2" {
- return nil, fmt.Errorf("not hysteria2")
- }
- auth := u.User.Username()
- host := u.Hostname()
- port := defaultPort(u.Port(), 443)
- params := u.Query()
- stream := map[string]any{
- "network": "hysteria",
- "security": "tls",
- "hysteriaSettings": map[string]any{
- "version": 2,
- "auth": auth,
- "udpIdleTimeout": 60,
- },
- "tlsSettings": map[string]any{
- "serverName": params.Get("sni"),
- "alpn": splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
- "fingerprint": params.Get("fp"),
- "echConfigList": params.Get("ech"),
- "verifyPeerCertByName": "",
- "pinnedPeerCertSha256": params.Get("pinSHA256"),
- },
- }
- applyFinalMask(stream, params)
- identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
- ob := Outbound{
- "protocol": "hysteria",
- "tag": decodeHash(u.Fragment),
- "settings": map[string]any{"address": host, "port": port, "version": 2},
- "streamSettings": stream,
- }
- return &ParseResult{Outbound: ob, Identity: identity}, nil
- }
- // --- wireguard ---
- func parseWireguard(link string) (*ParseResult, error) {
- u, err := url.Parse(link)
- if err != nil {
- return nil, err
- }
- if u.Scheme != "wireguard" && u.Scheme != "wg" {
- return nil, fmt.Errorf("not wireguard")
- }
- secret, _ := url.QueryUnescape(u.User.Username())
- params := u.Query()
- host := u.Hostname()
- portStr := u.Port()
- endpoint := host
- if portStr != "" {
- endpoint = host + ":" + portStr
- }
- addrRaw := firstParam(params, "address", "ip")
- allowedRaw := firstParam(params, "allowedips", "allowed_ips")
- addrs := splitComma(addrRaw)
- if len(addrs) == 0 {
- addrs = []string{"0.0.0.0/0", "::/0"}
- }
- allowed := splitComma(allowedRaw)
- if len(allowed) == 0 {
- allowed = []string{"0.0.0.0/0", "::/0"}
- }
- peer := map[string]any{
- "publicKey": firstParam(params, "publickey", "publicKey", "public_key", "peerPublicKey"),
- "endpoint": endpoint,
- "allowedIPs": allowed,
- }
- if psk := firstParam(params, "presharedkey", "preshared_key", "pre-shared-key", "psk"); psk != "" {
- peer["preSharedKey"] = psk
- }
- if ka := firstParam(params, "keepalive", "persistentkeepalive", "persistent_keepalive"); ka != "" {
- if n, err := strconv.Atoi(ka); err == nil {
- peer["keepAlive"] = n
- }
- }
- settings := map[string]any{
- "secretKey": secret,
- "address": addrs,
- "peers": []any{peer},
- }
- if mtu := params.Get("mtu"); mtu != "" {
- if n, err := strconv.Atoi(mtu); err == nil {
- settings["mtu"] = n
- }
- }
- if res := params.Get("reserved"); res != "" {
- parts := splitComma(res)
- var iv []int
- for _, p := range parts {
- if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
- iv = append(iv, n)
- }
- }
- if len(iv) > 0 {
- settings["reserved"] = iv
- }
- }
- identity := "wireguard:" + secret + "@" + endpoint + "?" + canonicalQuery(params)
- ob := Outbound{
- "protocol": "wireguard",
- "tag": decodeHash(u.Fragment),
- "settings": settings,
- }
- return &ParseResult{Outbound: ob, Identity: identity}, nil
- }
- // --- helpers ---
- func buildStream(network, security string) map[string]any {
- stream := map[string]any{"network": network, "security": security}
- switch network {
- case "tcp":
- stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
- case "kcp":
- stream["kcpSettings"] = map[string]any{
- "mtu": 1350, "tti": 20, "uplinkCapacity": 5, "downlinkCapacity": 20,
- "cwndMultiplier": 1, "maxSendingWindow": 2097152,
- }
- case "ws":
- stream["wsSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}, "heartbeatPeriod": 0}
- case "grpc":
- stream["grpcSettings"] = map[string]any{"serviceName": "", "authority": "", "multiMode": false}
- case "httpupgrade":
- stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
- case "xhttp":
- stream["xhttpSettings"] = map[string]any{
- "path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
- "xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
- }
- default:
- stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
- }
- if security == "tls" {
- stream["tlsSettings"] = map[string]any{
- "serverName": "", "alpn": []any{}, "fingerprint": "",
- "echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
- }
- } else if security == "reality" {
- stream["realitySettings"] = map[string]any{
- "publicKey": "", "fingerprint": "chrome", "serverName": "",
- "shortId": "", "spiderX": "", "mldsa65Verify": "",
- }
- }
- return stream
- }
- func setWS(stream map[string]any, host, path string) {
- ws := stream["wsSettings"].(map[string]any)
- ws["host"] = host
- ws["path"] = path
- }
- func setHTTPUpgrade(stream map[string]any, host, path string) {
- h := stream["httpupgradeSettings"].(map[string]any)
- h["host"] = host
- h["path"] = path
- }
- func applyTransport(stream map[string]any, p url.Values) {
- net := stream["network"].(string)
- host := p.Get("host")
- path := firstNonEmpty(p.Get("path"), "/")
- switch net {
- case "ws":
- setWS(stream, host, path)
- case "grpc":
- gs := stream["grpcSettings"].(map[string]any)
- gs["serviceName"] = firstNonEmpty(p.Get("serviceName"), p.Get("path"))
- gs["authority"] = p.Get("authority")
- gs["multiMode"] = p.Get("mode") == "multi"
- case "httpupgrade":
- setHTTPUpgrade(stream, host, path)
- case "xhttp":
- xh := stream["xhttpSettings"].(map[string]any)
- xh["host"] = host
- xh["path"] = path
- if m := p.Get("mode"); m != "" {
- xh["mode"] = m
- }
- // A few advanced xhttp fields that are commonly carried
- for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
- if v := p.Get(k); v != "" {
- xh[k] = v
- }
- }
- case "tcp":
- if p.Get("headerType") == "http" || p.Get("type") == "http" {
- stream["tcpSettings"] = map[string]any{
- "header": map[string]any{
- "type": "http",
- "request": map[string]any{
- "version": "1.1",
- "method": "GET",
- "path": splitComma(path),
- "headers": map[string]any{"Host": splitComma(host)},
- },
- },
- }
- }
- }
- }
- func applySecurity(stream map[string]any, p url.Values) {
- sec := stream["security"].(string)
- if sec == "tls" {
- tls := stream["tlsSettings"].(map[string]any)
- tls["serverName"] = p.Get("sni")
- tls["fingerprint"] = p.Get("fp")
- if alpn := p.Get("alpn"); alpn != "" {
- tls["alpn"] = splitComma(alpn)
- }
- tls["echConfigList"] = p.Get("ech")
- tls["pinnedPeerCertSha256"] = p.Get("pcs")
- } else if sec == "reality" {
- re := stream["realitySettings"].(map[string]any)
- re["serverName"] = p.Get("sni")
- re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
- re["publicKey"] = p.Get("pbk")
- re["shortId"] = p.Get("sid")
- re["spiderX"] = p.Get("spx")
- re["mldsa65Verify"] = p.Get("pqv")
- }
- }
- func applyFinalMask(stream map[string]any, p url.Values) {
- if fm := p.Get("fm"); fm != "" {
- var parsed any
- if json.Unmarshal([]byte(fm), &parsed) == nil {
- stream["finalmask"] = parsed
- }
- }
- }
- func firstNonEmpty(a, b string) string {
- if a != "" {
- return a
- }
- return b
- }
- func firstParam(p url.Values, keys ...string) string {
- for _, k := range keys {
- if v := p.Get(k); v != "" {
- return v
- }
- }
- return ""
- }
- func canonicalQuery(p url.Values) string {
- // Sort keys for stable identity
- keys := make([]string, 0, len(p))
- for k := range p {
- keys = append(keys, k)
- }
- // simple sort
- for i := 0; i < len(keys); i++ {
- for j := i + 1; j < len(keys); j++ {
- if keys[j] < keys[i] {
- keys[i], keys[j] = keys[j], keys[i]
- }
- }
- }
- parts := make([]string, 0, len(keys))
- for _, k := range keys {
- for _, v := range p[k] {
- parts = append(parts, k+"="+v)
- }
- }
- return strings.Join(parts, "&")
- }
- func decodeHash(h string) string {
- if h == "" {
- return ""
- }
- if dec, err := url.QueryUnescape(h); err == nil {
- return dec
- }
- return h
- }
- func defaultPort(p string, def int) int {
- if p == "" {
- return def
- }
- n, err := strconv.Atoi(p)
- if err != nil || n <= 0 {
- return def
- }
- return n
- }
- func num(v any) int {
- switch x := v.(type) {
- case float64:
- return int(x)
- case int:
- return x
- case int64:
- return int(x)
- case string:
- n, _ := strconv.Atoi(x)
- return n
- }
- return 0
- }
- func getString(m map[string]any, key, def string) string {
- if v, ok := m[key]; ok {
- if s, ok := v.(string); ok {
- return s
- }
- }
- return def
- }
- func splitComma(s string) []string {
- if s == "" {
- return nil
- }
- parts := strings.Split(s, ",")
- out := make([]string, 0, len(parts))
- for _, p := range parts {
- p = strings.TrimSpace(p)
- if p != "" {
- out = append(out, p)
- }
- }
- return out
- }
- func splitCommaOrDefault(s string, def []string) []string {
- if s == "" {
- return def
- }
- return splitComma(s)
- }
- func padBase64(s string) string {
- for len(s)%4 != 0 {
- s += "="
- }
- return s
- }
- func base64DecodeFlexible(s string) (string, error) {
- s = padBase64(s)
- if b, err := base64.StdEncoding.DecodeString(s); err == nil {
- return string(b), nil
- }
- if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil {
- return string(b), nil
- }
- return "", fmt.Errorf("base64 decode failed")
- }
- // SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
- var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
- func SlugRemark(remark string) string {
- s := strings.ToLower(strings.TrimSpace(remark))
- s = slugRe.ReplaceAllString(s, "-")
- s = strings.Trim(s, "-")
- if s == "" {
- return ""
- }
- // collapse runs of dashes
- for strings.Contains(s, "--") {
- s = strings.ReplaceAll(s, "--", "-")
- }
- return s
- }
- // SuggestTag builds a tag from a prefix and a remark (or index fallback).
- // It is intended for initial assignment; stability is handled by the service layer.
- func SuggestTag(prefix, remark string, idx int) string {
- base := SlugRemark(remark)
- if base == "" {
- base = fmt.Sprintf("%d", idx)
- }
- p := strings.TrimSuffix(prefix, "-")
- if p != "" {
- return p + "-" + base
- }
- return base
- }
|