Files
navi/internal/subscription/fetch.go
T

163 lines
3.8 KiB
Go

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.4.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
}