ImportSubscription no longer requires only http(s): paste awg://, AWG .conf/JSON, or naive links; subscription parser also reads hosted AWG and Clash wireguard/naive. Site lists support emails and FAQ; Store listing texts/tools updated. Co-authored-by: Cursor <cursoragent@cursor.com>
646 lines
17 KiB
Go
646 lines
17 KiB
Go
package subscription
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"vpnclient/internal/config"
|
|
"vpnclient/internal/linknorm"
|
|
"vpnclient/internal/protocols/awg"
|
|
"vpnclient/internal/protocols/hysteria2"
|
|
"vpnclient/internal/protocols/naive"
|
|
)
|
|
|
|
// Item is one imported share link.
|
|
type Item struct {
|
|
Name string
|
|
Protocol config.Protocol
|
|
URI string
|
|
}
|
|
|
|
// Info is subscription usage metadata from the subscription-userinfo header
|
|
// (upload/download/total in bytes, expire as unix seconds; 0 = unknown).
|
|
type Info struct {
|
|
Upload int64 `json:"upload,omitempty"`
|
|
Download int64 `json:"download,omitempty"`
|
|
Total int64 `json:"total,omitempty"`
|
|
Expire int64 `json:"expire,omitempty"`
|
|
}
|
|
|
|
// Result is a parsed subscription: imported items plus per-line skip diagnostics.
|
|
type Result struct {
|
|
Items []Item
|
|
Skipped int
|
|
Warnings []string
|
|
Info *Info
|
|
}
|
|
|
|
const maxWarnings = 8
|
|
|
|
var (
|
|
reShareLine = regexp.MustCompile(`(?i)((?:hysteria2|hy2|vless|vmess|trojan|awg|amneziawg|wireguard|vpn|naive\+https|naive\+quic|naive|quic|https|http)://[^\s"'<>]+)`)
|
|
reClashHY2 = regexp.MustCompile(`(?is)type:\s*hysteria2\b.*?server:\s*(\S+).*?port:\s*(\d+).*?(?:password|auth):\s*["']?([^\s"']+)`)
|
|
// reURILine matches a standalone "scheme://..." line — only such lines are
|
|
// counted as skipped entries (YAML/HTML noise is ignored silently).
|
|
reURILine = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.-]*://\S`)
|
|
)
|
|
|
|
// userAgents to try in order. Remnawave and most panels return raw share links
|
|
// for a generic UA, but return a Clash YAML config for Clash-like UAs — that
|
|
// YAML loses vless/trojan entries for us, so try the plain UA first.
|
|
var userAgents = []string{
|
|
"EvilFox/4.0 (+https://evilfox.win)",
|
|
"ClashMeta/1.18.0",
|
|
}
|
|
|
|
// Fetch downloads a subscription body and parses proxy share links.
|
|
// Parsing is tolerant: unsupported or broken entries are skipped (with
|
|
// warnings), valid ones are imported.
|
|
func Fetch(ctx context.Context, rawURL string) (*Result, error) {
|
|
rawURL = strings.TrimSpace(rawURL)
|
|
if rawURL == "" {
|
|
return nil, fmt.Errorf("пустой URL конфигурации")
|
|
}
|
|
u, err := url.Parse(rawURL)
|
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return nil, fmt.Errorf("нужен http(s) URL конфигурации")
|
|
}
|
|
|
|
var (
|
|
best *Result
|
|
info *Info
|
|
lastErr error
|
|
)
|
|
for _, ua := range userAgents {
|
|
body, header, err := download(ctx, rawURL, ua)
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if info == nil {
|
|
info = ParseUserInfoHeader(header.Get("Subscription-Userinfo"))
|
|
}
|
|
res, err := ParseBodyDetailed(body)
|
|
if res != nil && (best == nil || len(res.Items) > 0 || res.Skipped > best.Skipped) {
|
|
best = res
|
|
}
|
|
if err != nil {
|
|
lastErr = err
|
|
continue
|
|
}
|
|
if res != nil && len(res.Items) > 0 {
|
|
break
|
|
}
|
|
}
|
|
if best == nil || len(best.Items) == 0 {
|
|
if lastErr != nil {
|
|
return nil, lastErr
|
|
}
|
|
return nil, fmt.Errorf("в конфигурации нет поддерживаемых ссылок")
|
|
}
|
|
best.Info = info
|
|
return best, nil
|
|
}
|
|
|
|
func download(ctx context.Context, rawURL, userAgent string) (string, http.Header, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
req.Header.Set("User-Agent", userAgent)
|
|
req.Header.Set("Accept", "*/*")
|
|
client := &http.Client{Timeout: 45 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return "", nil, fmt.Errorf("не удалось загрузить конфигурацию: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return "", nil, fmt.Errorf("конфигурация недоступна (HTTP %d)", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
return string(body), resp.Header, nil
|
|
}
|
|
|
|
// ParseUserInfoHeader parses "upload=..; download=..; total=..; expire=<unix>".
|
|
// Returns nil when the header is absent or has no recognized fields.
|
|
func ParseUserInfoHeader(v string) *Info {
|
|
v = strings.TrimSpace(v)
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
info := &Info{}
|
|
found := false
|
|
for _, part := range strings.Split(v, ";") {
|
|
kv := strings.SplitN(part, "=", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
n, err := strconv.ParseInt(strings.TrimSpace(kv[1]), 10, 64)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
switch strings.ToLower(strings.TrimSpace(kv[0])) {
|
|
case "upload":
|
|
info.Upload, found = n, true
|
|
case "download":
|
|
info.Download, found = n, true
|
|
case "total":
|
|
info.Total, found = n, true
|
|
case "expire":
|
|
info.Expire, found = n, true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
return info
|
|
}
|
|
|
|
// ParseBody accepts plain text lines, base64, or Clash-like blobs with share links.
|
|
func ParseBody(body string) ([]Item, error) {
|
|
res, err := ParseBodyDetailed(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return res.Items, nil
|
|
}
|
|
|
|
// ParseBodyDetailed parses a subscription body and reports skipped entries.
|
|
// The returned Result is non-nil even on error (zero valid entries) so callers
|
|
// can surface the collected warnings.
|
|
func ParseBodyDetailed(body string) (*Result, error) {
|
|
body = strings.TrimSpace(body)
|
|
if body == "" {
|
|
return &Result{}, fmt.Errorf("пустая конфигурация")
|
|
}
|
|
|
|
candidates := []string{body}
|
|
if decoded, err := decodeMaybeBase64(body); err == nil && decoded != "" && decoded != body {
|
|
candidates = append(candidates, decoded)
|
|
// some panels double-encode
|
|
if decoded2, err2 := decodeMaybeBase64(decoded); err2 == nil && decoded2 != "" && decoded2 != decoded {
|
|
candidates = append(candidates, decoded2)
|
|
}
|
|
}
|
|
|
|
var best *Result
|
|
for _, text := range candidates {
|
|
res := parseText(text)
|
|
if len(res.Items) > 0 {
|
|
return res, nil
|
|
}
|
|
if best == nil || res.Skipped > best.Skipped {
|
|
best = res
|
|
}
|
|
}
|
|
if best == nil {
|
|
best = &Result{}
|
|
}
|
|
if best.Skipped > 0 {
|
|
return best, fmt.Errorf("нет поддерживаемых ссылок: %d записей пропущено (%s)",
|
|
best.Skipped, strings.Join(best.Warnings, "; "))
|
|
}
|
|
return best, fmt.Errorf("в конфигурации нет поддерживаемых ссылок (naive / hysteria2 / awg / vless / vmess / trojan)")
|
|
}
|
|
|
|
func parseText(text string) *Result {
|
|
res := &Result{}
|
|
seen := map[string]int{}
|
|
seenURI := map[string]struct{}{}
|
|
|
|
// countSkips is true only for raw subscription lines; URIs extracted from
|
|
// YAML/HTML/JSON noise are best-effort and never counted as "skipped".
|
|
process := func(raw string, countSkips bool) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" || strings.HasPrefix(raw, "#") {
|
|
return
|
|
}
|
|
// Only strip surrounding quotes on single-line share links.
|
|
if !strings.Contains(raw, "\n") {
|
|
raw = strings.Trim(raw, `"'`)
|
|
}
|
|
looksURI := reURILine.MatchString(raw)
|
|
skip := func(reason string) {
|
|
if !countSkips || !looksURI {
|
|
return
|
|
}
|
|
res.Skipped++
|
|
if len(res.Warnings) < maxWarnings {
|
|
res.Warnings = append(res.Warnings, shortenLink(raw)+" — "+reason)
|
|
}
|
|
}
|
|
|
|
proto := config.DetectProtocol(raw)
|
|
if proto == "" {
|
|
skip("неподдерживаемая схема")
|
|
return
|
|
}
|
|
// Bare https:// without credentials (common noise in HTML/YAML,
|
|
// or a subscription URL pasted among the links) is not a proxy link.
|
|
if proto == config.ProtocolNaive && !naiveLinkHasAuth(raw) && !strings.HasPrefix(strings.ToLower(raw), "naive+") {
|
|
skip("нет логина и пароля в ссылке")
|
|
return
|
|
}
|
|
normalized, detected, remark, err := linknorm.Normalize(proto, raw)
|
|
if err != nil {
|
|
skip(err.Error())
|
|
return
|
|
}
|
|
if _, dup := seenURI[normalized]; dup {
|
|
return
|
|
}
|
|
seenURI[normalized] = struct{}{}
|
|
name := remark
|
|
if name == "" {
|
|
name = defaultName(detected, normalized)
|
|
}
|
|
// Clash wireguard INI may carry "# name: …" — prefer that display name.
|
|
if detected == config.ProtocolAWG {
|
|
if n := awgCommentName(raw); n != "" {
|
|
name = n
|
|
}
|
|
}
|
|
base := name
|
|
for n := 2; ; n++ {
|
|
if _, ok := seen[name]; !ok {
|
|
break
|
|
}
|
|
name = fmt.Sprintf("%s-%d", base, n)
|
|
}
|
|
seen[name] = 1
|
|
res.Items = append(res.Items, Item{Name: name, Protocol: detected, URI: normalized})
|
|
}
|
|
|
|
// Whole-body AWG/WireGuard .conf, Amnezia JSON, vpn:// — hosted as a single
|
|
// HTTP(S) config file (line-oriented parsing cannot reconstruct these).
|
|
if awg.Detect(text) {
|
|
process(text, false)
|
|
if len(res.Items) > 0 && !looksLikeShareList(text) {
|
|
return res
|
|
}
|
|
}
|
|
|
|
for _, line := range splitLines(text) {
|
|
process(line, true)
|
|
}
|
|
// Also extract share URIs embedded in YAML/HTML/JSON.
|
|
for _, m := range reShareLine.FindAllString(text, -1) {
|
|
process(strings.TrimRight(m, ".,;)]}\"'"), false)
|
|
}
|
|
for _, block := range extractClashProxies(text) {
|
|
process(block, false)
|
|
}
|
|
return res
|
|
}
|
|
|
|
// looksLikeShareList reports bodies that also contain line-oriented share links
|
|
// (mixed subscription + AWG is rare, but do not early-return on those).
|
|
func looksLikeShareList(text string) bool {
|
|
lower := strings.ToLower(text)
|
|
for _, p := range []string{"vless://", "vmess://", "trojan://", "hysteria2://", "hy2://", "naive+", "ss://"} {
|
|
if strings.Contains(lower, p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func awgCommentName(raw string) string {
|
|
for _, line := range splitLines(raw) {
|
|
line = strings.TrimSpace(line)
|
|
if !strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
rest := strings.TrimSpace(strings.TrimPrefix(line, "#"))
|
|
lower := strings.ToLower(rest)
|
|
if strings.HasPrefix(lower, "name:") {
|
|
return strings.TrimSpace(rest[len("name:"):])
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// shortenLink trims a link for warning messages, hiding query/credentials noise.
|
|
func shortenLink(line string) string {
|
|
if i := strings.IndexAny(line, "?#"); i >= 0 {
|
|
line = line[:i]
|
|
}
|
|
const max = 48
|
|
runes := []rune(line)
|
|
if len(runes) > max {
|
|
return string(runes[:max]) + "…"
|
|
}
|
|
return line
|
|
}
|
|
|
|
func naiveLinkHasAuth(line string) bool {
|
|
lower := strings.ToLower(strings.TrimSpace(line))
|
|
for _, p := range []string{"naive+https://", "naive+quic://", "naive://", "https://", "http://", "quic://"} {
|
|
if strings.HasPrefix(lower, p) {
|
|
rest := line[len(p):]
|
|
at := strings.IndexByte(rest, '@')
|
|
return at > 0
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// extractClashProxies builds share links from Clash YAML proxy blocks when the
|
|
// panel does not embed native share URIs (hysteria2, wireguard/AWG, naive).
|
|
func extractClashProxies(text string) []string {
|
|
lower := strings.ToLower(text)
|
|
if !strings.Contains(lower, "type:") {
|
|
return nil
|
|
}
|
|
need := strings.Contains(lower, "hysteria2") ||
|
|
strings.Contains(lower, "wireguard") ||
|
|
strings.Contains(lower, "type: naive") ||
|
|
strings.Contains(lower, "type:naive")
|
|
if !need {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, chunk := range splitClashItems(text) {
|
|
cl := strings.ToLower(chunk)
|
|
typ := strings.ToLower(yamlField(chunk, "type"))
|
|
switch {
|
|
case typ == "hysteria2" || (typ == "" && strings.Contains(cl, "hysteria2")):
|
|
if link := clashHY2Link(chunk); link != "" {
|
|
out = append(out, link)
|
|
}
|
|
case typ == "wireguard":
|
|
if link := clashWireguardLink(chunk); link != "" {
|
|
out = append(out, link)
|
|
}
|
|
case typ == "naive":
|
|
if link := clashNaiveLink(chunk); link != "" {
|
|
out = append(out, link)
|
|
}
|
|
}
|
|
}
|
|
_ = reClashHY2 // kept for possible future tightening
|
|
return out
|
|
}
|
|
|
|
func clashHY2Link(chunk string) string {
|
|
server := yamlField(chunk, "server")
|
|
port := yamlField(chunk, "port")
|
|
pass := yamlField(chunk, "password")
|
|
if pass == "" {
|
|
pass = yamlField(chunk, "auth")
|
|
}
|
|
if server == "" || port == "" || pass == "" {
|
|
return ""
|
|
}
|
|
name := yamlField(chunk, "name")
|
|
sni := yamlField(chunk, "sni")
|
|
obfs := yamlField(chunk, "obfs")
|
|
obfsPass := yamlField(chunk, "obfs-password")
|
|
if obfsPass == "" {
|
|
obfsPass = yamlField(chunk, "obfs_password")
|
|
}
|
|
u := url.URL{
|
|
Scheme: "hysteria2",
|
|
User: url.User(pass),
|
|
Host: server + ":" + port,
|
|
}
|
|
q := url.Values{}
|
|
if sni != "" {
|
|
q.Set("sni", sni)
|
|
}
|
|
if obfs != "" {
|
|
q.Set("obfs", obfs)
|
|
}
|
|
if obfsPass != "" {
|
|
q.Set("obfs-password", obfsPass)
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
link := u.String()
|
|
if name != "" {
|
|
link += "#" + url.PathEscape(name)
|
|
}
|
|
return link
|
|
}
|
|
|
|
func clashWireguardLink(chunk string) string {
|
|
priv := firstYAML(chunk, "private-key", "private_key")
|
|
pub := firstYAML(chunk, "public-key", "public_key")
|
|
server := yamlField(chunk, "server")
|
|
port := yamlField(chunk, "port")
|
|
ip := firstYAML(chunk, "ip", "address")
|
|
if priv == "" || pub == "" || server == "" || port == "" {
|
|
return ""
|
|
}
|
|
name := yamlField(chunk, "name")
|
|
psk := firstYAML(chunk, "pre-shared-key", "preshared-key", "presharedkey")
|
|
allowed := stripYAMLList(firstYAML(chunk, "allowed-ips", "allowed_ips"))
|
|
if allowed == "" {
|
|
allowed = "0.0.0.0/0"
|
|
}
|
|
dns := stripYAMLList(yamlField(chunk, "dns"))
|
|
mtu := yamlField(chunk, "mtu")
|
|
keepalive := firstYAML(chunk, "persistent-keepalive", "keepalive")
|
|
|
|
var b strings.Builder
|
|
b.WriteString("[Interface]\n")
|
|
b.WriteString("PrivateKey = " + priv + "\n")
|
|
if ip != "" {
|
|
b.WriteString("Address = " + ip + "\n")
|
|
}
|
|
if dns != "" {
|
|
b.WriteString("DNS = " + dns + "\n")
|
|
}
|
|
if mtu != "" {
|
|
b.WriteString("MTU = " + mtu + "\n")
|
|
}
|
|
// AmneziaWG / AWG 2.0 obfuscation (Clash Meta / Amnezia exports).
|
|
for _, k := range []string{"jc", "jmin", "jmax", "s1", "s2", "s3", "s4", "h1", "h2", "h3", "h4", "i1", "i2", "i3", "i4", "i5"} {
|
|
v := firstYAML(chunk, k, strings.ToUpper(k), strings.ToUpper(k[:1])+k[1:])
|
|
if v == "" {
|
|
continue
|
|
}
|
|
canon := strings.ToUpper(k[:1]) + k[1:]
|
|
b.WriteString(canon + " = " + v + "\n")
|
|
}
|
|
b.WriteString("\n[Peer]\n")
|
|
b.WriteString("PublicKey = " + pub + "\n")
|
|
if psk != "" {
|
|
b.WriteString("PresharedKey = " + psk + "\n")
|
|
}
|
|
b.WriteString("Endpoint = " + server + ":" + port + "\n")
|
|
b.WriteString("AllowedIPs = " + allowed + "\n")
|
|
if keepalive != "" {
|
|
b.WriteString("PersistentKeepalive = " + keepalive + "\n")
|
|
}
|
|
if name != "" {
|
|
b.WriteString("# name: " + name + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func clashNaiveLink(chunk string) string {
|
|
server := yamlField(chunk, "server")
|
|
port := yamlField(chunk, "port")
|
|
user := firstYAML(chunk, "username", "user")
|
|
pass := yamlField(chunk, "password")
|
|
if server == "" || user == "" || pass == "" {
|
|
return ""
|
|
}
|
|
if port == "" {
|
|
port = "443"
|
|
}
|
|
name := yamlField(chunk, "name")
|
|
udp := strings.ToLower(yamlField(chunk, "udp"))
|
|
scheme := "https"
|
|
if udp == "true" || udp == "yes" || strings.EqualFold(yamlField(chunk, "protocol"), "quic") {
|
|
scheme = "quic"
|
|
}
|
|
u := url.URL{
|
|
Scheme: scheme,
|
|
User: url.UserPassword(user, pass),
|
|
Host: server + ":" + port,
|
|
}
|
|
link := u.String()
|
|
if name != "" {
|
|
link += "#" + url.PathEscape(name)
|
|
}
|
|
return link
|
|
}
|
|
|
|
func firstYAML(block string, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v := yamlField(block, k); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func stripYAMLList(v string) string {
|
|
v = strings.TrimSpace(v)
|
|
v = strings.TrimPrefix(v, "[")
|
|
v = strings.TrimSuffix(v, "]")
|
|
parts := strings.Split(v, ",")
|
|
var clean []string
|
|
for _, p := range parts {
|
|
p = strings.TrimSpace(p)
|
|
p = strings.Trim(p, `"'`)
|
|
if p != "" {
|
|
clean = append(clean, p)
|
|
}
|
|
}
|
|
return strings.Join(clean, ", ")
|
|
}
|
|
|
|
// reClashItemStart matches a mapping list item ("- name: X", " - type: y"),
|
|
// but not nested scalar list entries like "- h3" under alpn.
|
|
var reClashItemStart = regexp.MustCompile(`^\s*-\s+[^\s:]+\s*:`)
|
|
|
|
// splitClashItems splits a Clash YAML proxies list into per-proxy chunks,
|
|
// tolerating any list-item indentation ("- name:" at column 0 or nested).
|
|
func splitClashItems(text string) []string {
|
|
lines := splitLines(text)
|
|
var out []string
|
|
var cur []string
|
|
flush := func() {
|
|
if len(cur) > 0 {
|
|
out = append(out, strings.Join(cur, "\n"))
|
|
cur = nil
|
|
}
|
|
}
|
|
for _, line := range lines {
|
|
if reClashItemStart.MatchString(line) {
|
|
flush()
|
|
}
|
|
cur = append(cur, line)
|
|
}
|
|
flush()
|
|
return out
|
|
}
|
|
|
|
func yamlField(block, key string) string {
|
|
re := regexp.MustCompile(`(?im)^\s*(?:-\s+)?` + regexp.QuoteMeta(key) + `\s*:\s*["']?([^"'#\n\r]+)["']?`)
|
|
m := re.FindStringSubmatch(block)
|
|
if len(m) < 2 {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(m[1])
|
|
}
|
|
|
|
func decodeMaybeBase64(s string) (string, error) {
|
|
s = strings.Map(func(r rune) rune {
|
|
if unicode.IsSpace(r) {
|
|
return -1
|
|
}
|
|
return r
|
|
}, s)
|
|
if s == "" {
|
|
return "", fmt.Errorf("empty")
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
raw, err = base64.RawStdEncoding.DecodeString(s)
|
|
}
|
|
if err != nil {
|
|
raw, err = base64.URLEncoding.DecodeString(s)
|
|
}
|
|
if err != nil {
|
|
raw, err = base64.RawURLEncoding.DecodeString(s)
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(raw), nil
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
s = strings.ReplaceAll(s, "\r\n", "\n")
|
|
s = strings.ReplaceAll(s, "\r", "\n")
|
|
return strings.Split(s, "\n")
|
|
}
|
|
|
|
func defaultName(proto config.Protocol, uri string) string {
|
|
host := ""
|
|
switch proto {
|
|
case config.ProtocolHysteria2:
|
|
if h, _, err := hysteria2.HostPort(uri); err == nil {
|
|
host = h
|
|
}
|
|
case config.ProtocolAWG:
|
|
if c, err := awg.Parse(uri); err == nil {
|
|
if c.Name != "" {
|
|
return c.Name
|
|
}
|
|
if h, _, e := net.SplitHostPort(c.Endpoint); e == nil {
|
|
host = h
|
|
} else {
|
|
host = c.Endpoint
|
|
}
|
|
}
|
|
default:
|
|
if u, _, err := naive.ParseShareLink(uri); err == nil {
|
|
if parsed, err := url.Parse(u); err == nil {
|
|
host = parsed.Hostname()
|
|
}
|
|
}
|
|
}
|
|
if host == "" {
|
|
host = string(proto)
|
|
}
|
|
return string(proto) + "-" + host
|
|
}
|