package subscription import ( "context" "encoding/base64" "fmt" "io" "net/http" "net/url" "strings" "time" "unicode" "vpnclient/internal/config" "vpnclient/internal/linknorm" "vpnclient/internal/protocols/hysteria2" "vpnclient/internal/protocols/naive" ) // Item is one imported share link. type Item struct { Name string Protocol config.Protocol URI string } // Fetch downloads a subscription body and parses proxy share links. func Fetch(ctx context.Context, rawURL string) ([]Item, 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 подписки") } req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) if err != nil { return nil, err } req.Header.Set("User-Agent", "Navis/1.3.1") client := &http.Client{Timeout: 45 * time.Second} resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { 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 ParseBody(string(body)) } // ParseBody accepts plain text lines or base64 (Clash/V2Ray style subscription). func ParseBody(body string) ([]Item, error) { body = strings.TrimSpace(body) if body == "" { return nil, fmt.Errorf("пустая подписка") } text := body if !looksLikeLinks(body) { if decoded, err := decodeMaybeBase64(body); err == nil && looksLikeLinks(decoded) { text = decoded } } lines := splitLines(text) out := make([]Item, 0, len(lines)) seen := map[string]int{} for _, line := range lines { line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") { continue } // Some panels wrap links in quotes line = strings.Trim(line, `"'`) proto := config.DetectProtocol(line) if proto == "" { continue } normalized, detected, remark, err := linknorm.Normalize(proto, line) if err != nil { continue } name := remark if name == "" { name = defaultName(detected, normalized) } base := name for n := 2; ; n++ { if _, ok := seen[name]; !ok { break } name = fmt.Sprintf("%s-%d", base, n) } seen[name] = 1 out = append(out, Item{Name: name, Protocol: detected, URI: normalized}) } if len(out) == 0 { return nil, fmt.Errorf("в подписке нет поддерживаемых ссылок (naive / hysteria2)") } return out, nil } func looksLikeLinks(s string) bool { lower := strings.ToLower(s) return strings.Contains(lower, "hysteria2://") || strings.Contains(lower, "hy2://") || strings.Contains(lower, "naive+") || strings.Contains(lower, "https://") || strings.Contains(lower, "quic://") } func decodeMaybeBase64(s string) (string, error) { s = strings.Map(func(r rune) rune { if unicode.IsSpace(r) { return -1 } return r }, s) 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 { 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 } 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 }