// Package linkmeta 从正文里的链接抓一份「链接卡片」素材:标题、描述、站点名、封面图。 // // 抓取由站主写作时触发(不是读者请求触发),但目标地址仍是任意公网 URL, // 所以按对外抓取的标准对待:只放行 http/https、限时限量限跳转, // 并在拨号那一刻解析并拒绝内网地址——防的是「服务器自己打自己」这类 SSRF, // 以及 DNS 先返回公网 IP、拨号时换成内网 IP 的重绑定把戏。 package linkmeta import ( "context" "errors" "fmt" "io" "net" "net/http" "net/url" "regexp" "strings" "time" "oneblog/internal/model" ) const ( // maxBody 只读文档开头就够拿到 ,多一个字节都不多读。 maxBody = 512 << 10 // maxRedirects 跳转链上限。 maxRedirects = 3 // 浏览器形态的 UA:不少站点(X、微博、部分门户)对未知爬虫 UA 给的 // og 元数据不全或直接拒绝,对浏览器 UA 则照常输出 里的标签。 userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36" ) // Card 是抓取结果的数据形状;定义在 model 里(要落库、要给前端), // 这里用别名保持本包的写法。 type Card = model.LinkCard var ( urlRe = regexp.MustCompile(`https?://[^\s<>"'\)\]]+`) trailingCut = ".,;:!?、。)]》」》" ) // FirstURL 取正文里第一个 http(s) 链接(Markdown 原文,含代码块里的也算, // 站主自己不会在代码块里贴想展示的链接)。没有则空串。 func FirstURL(md string) string { m := urlRe.FindString(md) if m == "" { return "" } return strings.TrimRight(m, trailingCut) } // Fetcher 抓取器。Dial 为 nil 时用带 SSRF 防护的默认拨号器; // 测试里注入普通拨号器才能打到 httptest 的回环地址(默认会被拦掉)。 type Fetcher struct { Dial func(ctx context.Context, network, addr string) (net.Conn, error) Timeout time.Duration // 0 = 默认 5s } // Fetch 用默认安全拨号器抓 rawURL 的元信息。ctx 控制整体时限。 func Fetch(ctx context.Context, rawURL string) (*Card, error) { return (&Fetcher{}).Fetch(ctx, rawURL) } // Fetch 抓 rawURL 的元信息。 func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return nil, fmt.Errorf("linkmeta: bad url: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return nil, fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme) } if u.Host == "" { return nil, errors.New("linkmeta: empty host") } client := f.client() req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, err } req.Header.Set("User-Agent", userAgent) req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5") resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("linkmeta: fetch: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { return nil, fmt.Errorf("linkmeta: status %d", resp.StatusCode) } if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") { return nil, fmt.Errorf("linkmeta: not html (%q)", ct) } body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) if err != nil { return nil, fmt.Errorf("linkmeta: read: %w", err) } final := resp.Request.URL // 跟完跳转后的最终地址 card := parse(decode(body, resp.Header.Get("Content-Type")), final) if card.Empty() { return nil, errors.New("linkmeta: no usable metadata") } return card, nil } // client 组装带 SSRF 防护的 HTTP 客户端(Fetch 与 FetchBytes 共用) func (f *Fetcher) client() *http.Client { dial := f.Dial if dial == nil { dial = safeDial } timeout := f.Timeout if timeout <= 0 { timeout = 5 * time.Second } return &http.Client{ Transport: &http.Transport{ DialContext: dial, TLSHandshakeTimeout: 3 * time.Second, // 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验 ForceAttemptHTTP2: false, }, Timeout: timeout, CheckRedirect: limitRedirects, } } // FetchBytes 抓二进制内容(外链图片转存用):与 Fetch 共用同一套 // SSRF 防护与跳转限制,字节数有 maxBytes 硬上限。 // 返回内容与 Content-Type(响应头缺失时用内容嗅探兜底)。 func FetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) { return (&Fetcher{}).fetchBytes(ctx, rawURL, maxBytes) } func (f *Fetcher) fetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil { return nil, "", fmt.Errorf("linkmeta: bad url: %w", err) } if u.Scheme != "http" && u.Scheme != "https" { return nil, "", fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme) } if u.Host == "" { return nil, "", errors.New("linkmeta: empty host") } client := f.client() // 二进制传输放宽时限:大图慢链路 5 秒的元信息默认值不够用 if f.Timeout <= 0 { client.Timeout = 30 * time.Second } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, "", err } req.Header.Set("User-Agent", userAgent) resp, err := client.Do(req) if err != nil { return nil, "", fmt.Errorf("linkmeta: fetch: %w", err) } defer resp.Body.Close() if resp.StatusCode >= 300 { return nil, "", fmt.Errorf("linkmeta: status %d", resp.StatusCode) } data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1)) if err != nil { return nil, "", fmt.Errorf("linkmeta: read: %w", err) } if int64(len(data)) > maxBytes { return nil, "", fmt.Errorf("linkmeta: exceeds %d bytes", maxBytes) } ct := resp.Header.Get("Content-Type") if ct == "" { ct = http.DetectContentType(data) } return data, ct, nil } func limitRedirects(req *http.Request, via []*http.Request) error { if len(via) > maxRedirects { return fmt.Errorf("linkmeta: too many redirects") } if req.URL.Scheme != "http" && req.URL.Scheme != "https" { return fmt.Errorf("linkmeta: redirect to %q", req.URL.Scheme) } return nil } // safeDial 解析域名后逐个筛掉内网地址,再直接拨那个 IP: // 校验和连接之间不再重新解析,DNS 重绑定就没有窗口。 func safeDial(ctx context.Context, network, addr string) (net.Conn, error) { host, port, err := net.SplitHostPort(addr) if err != nil { return nil, err } var d net.Dialer if ip := net.ParseIP(host); ip != nil { if blocked(ip) { return nil, fmt.Errorf("linkmeta: %s is not a public address", ip) } return d.DialContext(ctx, network, addr) } ips, err := d.Resolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } var lastErr error for _, ia := range ips { if blocked(ia.IP) { continue } conn, err := d.DialContext(ctx, network, net.JoinHostPort(ia.IP.String(), port)) if err == nil { return conn, nil } lastErr = err } if lastErr != nil { return nil, lastErr } return nil, fmt.Errorf("linkmeta: no public address for %s", host) } // blocked 报告 IP 是否属于不该由本站去连的地址段。 func blocked(ip net.IP) bool { if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() || ip.IsMulticast() { return true } // 运营商级 NAT 与 6to4 前缀:IsPrivate 不覆盖,但同样不该出现在公网抓取里 if ip4 := ip.To4(); ip4 != nil { return ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 // 100.64.0.0/10 } if ip.To16() != nil { return ip[0] == 0x20 || ip[0] == 0x3f // 2001::/32 Teredo、3ffe::/16 等保留段 } return false }