package files import ( "bytes" "context" "errors" "fmt" "hash/fnv" "image" "io" "net" "net/http" "net/url" "strings" "sync" "syscall" "time" "go.uber.org/zap" "golang.org/x/net/html" "telesrv/internal/domain" "telesrv/internal/readmodelcache" ) // 链接预览(webpage preview):抓取消息里的 URL,解析 OpenGraph/Twitter-card/+meta // 元数据,铸造预览卡片(含可选预览图)。安全模型与 external_media 同构(SSRF 拨号期 IP 校验、 // 仅 http/https、重定向/大小/超时上限、全局限速),但用独立的限速器与缓存,避免与外链媒体抓取 // 争用同一预算。HTML 与预览图共用一个总时长预算(父 ctx deadline)。 // // 解析结果(done / empty)经 L1 进程内缓存(singleflight 折叠并发同 URL 抓取)+ L3 web_pages // 表(按规范化 URL 哈希跨实例去重)。瞬时失败(网络/限速/SSRF 拦截)返回 error 不缓存,避免一次 // 抖动把热门链接毒成"无预览"。 var ( // ErrWebPagePreviewDisabled 表示未启用链接预览抓取。 ErrWebPagePreviewDisabled = errors.New("web page preview disabled") // ErrWebPagePreviewInvalid 表示 URL 不合法/被 SSRF 拦截/上游失败/超限。 ErrWebPagePreviewInvalid = errors.New("web page preview invalid") // errWebPageTerminal 标记「确定性、短期不会变」的失败(SSRF 拦截/4xx/非法 URL)。这类 // 解析为终态空预览并负缓存,避免每次按键/发送重复打 PG+外网;瞬时失败(5xx/超时/限速/ // dial 失败)不带此标记、不缓存、可重试。 errWebPageTerminal = errors.New("web page terminal") ) // terminalFetchErr 构造一个终态失败错误(会被负缓存)。 func terminalFetchErr(msg string) error { return fmt.Errorf("%w: %w: %s", ErrWebPagePreviewInvalid, errWebPageTerminal, msg) } const ( webpageRequestTimeout = 15 * time.Second webpageTotalTimeout = 20 * time.Second webpageMaxRedirects = 5 // DefaultWebPagePreviewMaxBytes 覆盖 HTML 抓取与预览图抓取(head 在页首,足够)。 DefaultWebPagePreviewMaxBytes = int64(5 << 20) // DefaultWebPagePreviewRatePerMin 是全局每分钟抓取上限;一次解析最多 2 次上游(HTML+图)。 // 60 是单用户口径,多用户实例偏低(输入预览与真实发送共用此预算易互相饿死),上调到 300。 DefaultWebPagePreviewRatePerMin = 300 webpageRateWindow = time.Minute // maxWebpageImagePixels 是预览图解压炸弹上界(解码前按 DecodeConfig 尺寸拦截)。 maxWebpageImagePixels = int64(25_000_000) webpageCacheMaxEntries = 4096 webpageCacheTTL = 10 * time.Minute // webPageRefreshTTL 是已解析卡片的陈旧阈值:L3 命中且超过此龄时后台 stale-while-revalidate // 刷新(返回的仍是旧卡片,不阻塞)。webPageRefreshConcurrency 限并发刷新 goroutine。 webPageRefreshTTL = 24 * time.Hour webPageRefreshConcurrency = 8 // webpageUserAgent 用 Telegram 爬虫标识:很多站点只对已知爬虫吐 OG 标签。 webpageUserAgent = "TelegramBot (like TwitterBot)" acceptHTML = "text/html,application/xhtml+xml" acceptImage = "image/*" ) type webpageFetcher struct { client *http.Client maxBytes int64 rateLimit int cache *readmodelcache.Cache[int64, domain.MessageWebPage] refreshSem chan struct{} mu sync.Mutex fetchTimes []time.Time } // WithWebPagePreview 启用链接预览抓取。maxBytes<=0 / ratePerMin<=0 用默认。SSRF 防护恒开。 func WithWebPagePreview(maxBytes int64, ratePerMin int) Option { return func(s *Service) { if maxBytes <= 0 { maxBytes = DefaultWebPagePreviewMaxBytes } if ratePerMin <= 0 { ratePerMin = DefaultWebPagePreviewRatePerMin } s.webpage = newWebpageFetcher(maxBytes, ratePerMin, false) } } // newWebpageFetcher 构造抓取器。allowPrivate 仅供测试(指向 httptest loopback);生产恒 false。 func newWebpageFetcher(maxBytes int64, ratePerMin int, allowPrivate bool) *webpageFetcher { dialer := &net.Dialer{Timeout: webpageRequestTimeout} dialer.Control = func(_, address string, _ syscall.RawConn) error { host, _, err := net.SplitHostPort(address) if err != nil { return ErrWebPagePreviewInvalid } ip := net.ParseIP(host) if ip == nil { return ErrWebPagePreviewInvalid } if !allowPrivate && isBlockedExternalIP(ip) { // SSRF 拦截是确定性失败 → 标记终态供负缓存(否则每次按键重打 PG+重 dial)。 return terminalFetchErr("blocked address " + host + " (SSRF guard)") } return nil } client := &http.Client{ Timeout: webpageRequestTimeout, Transport: &http.Transport{DialContext: dialer.DialContext, DisableKeepAlives: true, Proxy: nil}, CheckRedirect: func(req *http.Request, via []*http.Request) error { if len(via) >= webpageMaxRedirects { return fmt.Errorf("%w: too many redirects", ErrWebPagePreviewInvalid) } if req.URL.Scheme != "http" && req.URL.Scheme != "https" { return fmt.Errorf("%w: blocked redirect scheme %q", ErrWebPagePreviewInvalid, req.URL.Scheme) } return nil }, } return &webpageFetcher{ client: client, maxBytes: maxBytes, rateLimit: ratePerMin, refreshSem: make(chan struct{}, webPageRefreshConcurrency), cache: readmodelcache.New(readmodelcache.Config[int64, domain.MessageWebPage]{ MaxEntries: webpageCacheMaxEntries, TTL: webpageCacheTTL, }), } } func (f *webpageFetcher) allowFetch() bool { f.mu.Lock() defer f.mu.Unlock() now := time.Now() kept := f.fetchTimes[:0] for _, at := range f.fetchTimes { if now.Sub(at) <= webpageRateWindow { kept = append(kept, at) } } f.fetchTimes = kept if len(f.fetchTimes) >= f.rateLimit { return false } f.fetchTimes = append(f.fetchTimes, now) return true } // fetch 抓取 URL,返回 (字节, content-type)。SSRF 检查在 dial 阶段发生;ctx 承载共享总预算。 func (f *webpageFetcher) fetch(ctx context.Context, rawURL, accept string) ([]byte, string, error) { u, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" { return nil, "", terminalFetchErr("bad url") // 非法 URL 是终态 } if !f.allowFetch() { return nil, "", fmt.Errorf("%w: rate limited", ErrWebPagePreviewInvalid) // 限速=瞬时,可重试 } req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) if err != nil { return nil, "", terminalFetchErr("bad request") } req.Header.Set("User-Agent", webpageUserAgent) req.Header.Set("Accept", accept) resp, err := f.client.Do(req) if err != nil { // SSRF 拦截(dial Control 返回的 terminal)经 url.Error 传上来,errors.Is 仍能识别; // 其余 dial/超时错误是瞬时。 return nil, "", fmt.Errorf("%w: %v", ErrWebPagePreviewInvalid, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { // 4xx=确定性(404/403/410…)终态负缓存;5xx=瞬时可重试。 if resp.StatusCode >= 400 && resp.StatusCode < 500 { return nil, "", terminalFetchErr(fmt.Sprintf("upstream status %d", resp.StatusCode)) } return nil, "", fmt.Errorf("%w: upstream status %d", ErrWebPagePreviewInvalid, resp.StatusCode) } data, err := io.ReadAll(io.LimitReader(resp.Body, f.maxBytes+1)) if err != nil { return nil, "", fmt.Errorf("%w: read body: %v", ErrWebPagePreviewInvalid, err) } if len(data) == 0 || int64(len(data)) > f.maxBytes { return nil, "", fmt.Errorf("%w: body size %d", ErrWebPagePreviewInvalid, len(data)) } contentType := resp.Header.Get("Content-Type") if i := strings.IndexByte(contentType, ';'); i >= 0 { contentType = contentType[:i] } return data, strings.TrimSpace(strings.ToLower(contentType)), nil } // WebPagePreviewEnabled 报告链接预览抓取是否启用。 func (s *Service) WebPagePreviewEnabled() bool { return s != nil && s.webpage != nil } // LookupWebPage 仅查缓存(L1 进程内 → L3 web_pages 表)返回已解析的链接预览,不抓取。命中 // 返回 (page,true);未缓存或未启用返回 false。发送路径用它在 echo 直接带 done 卡片。 // 先 Peek L1:客户端输入时 getWebPagePreview 多半已把同一 URL 解析进 L1,发送时即免一次 PG。 func (s *Service) LookupWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, bool) { if s == nil || s.webpage == nil { return domain.MessageWebPage{}, false } normalized, ok := domain.NormalizeWebPageURL(rawURL) if !ok { return domain.MessageWebPage{}, false } urlHash := domain.WebPageURLHash(normalized) if page, ok := s.webpage.cache.Peek(urlHash); ok { return page, true } page, _, found, err := s.media.GetWebPageByURLHash(ctx, urlHash) if err != nil || !found { return domain.MessageWebPage{}, false } s.webpage.cache.Store(urlHash, page) // 回填 L1,后续 Peek 命中。 return page, true } // ResolveWebPage 解析链接预览,经 L1 缓存(singleflight 去重)+ L3 web_pages 持久去重。 // 返回 done / empty 形态的 MessageWebPage;瞬时失败返回 error(调用方降级为空,不报错给用户)。 func (s *Service) ResolveWebPage(ctx context.Context, rawURL string) (domain.MessageWebPage, error) { if s == nil || s.webpage == nil { return domain.MessageWebPage{}, ErrWebPagePreviewDisabled } normalized, ok := domain.NormalizeWebPageURL(rawURL) if !ok { return domain.MessageWebPage{}, ErrWebPagePreviewInvalid } urlHash := domain.WebPageURLHash(normalized) return s.webpage.cache.GetOrLoad(ctx, urlHash, func() (domain.MessageWebPage, error) { // L3 durable 命中:直接复用,跨实例去重;超龄则后台刷新(返回旧卡片不阻塞)。 if page, refreshedAt, found, err := s.media.GetWebPageByURLHash(ctx, urlHash); err == nil && found { s.webpage.maybeRefresh(s, normalized, urlHash, refreshedAt) return page, nil } // miss:抓取 + 解析(+ 图)。瞬时失败返回 error → GetOrLoad 不缓存(热门链接不被毒化)。 page, err := s.webpage.resolve(ctx, s, normalized, urlHash) if err != nil { return domain.MessageWebPage{}, err } // 终态(done / empty):落 L3 供跨实例 + 重启复用。 if perr := s.media.PutWebPage(ctx, urlHash, page, int(time.Now().Unix())); perr != nil { s.log.Warn("persist web page preview failed", zap.Int64("url_hash", urlHash), zap.Error(perr)) } return page, nil }) } // maybeRefresh 在卡片超过 webPageRefreshTTL 龄时后台 stale-while-revalidate 刷新一次。 // 并发受 refreshSem 限;满则跳过(下次再刷)。瞬时失败保留旧卡片。 func (f *webpageFetcher) maybeRefresh(s *Service, normalizedURL string, urlHash int64, refreshedAt int) { if refreshedAt == 0 || time.Now().Unix()-int64(refreshedAt) < int64(webPageRefreshTTL/time.Second) { return } select { case f.refreshSem <- struct{}{}: default: return } go func() { defer func() { <-f.refreshSem }() ctx, cancel := context.WithTimeout(context.Background(), webpageTotalTimeout) defer cancel() page, err := f.resolve(ctx, s, normalizedURL, urlHash) if err != nil { return // 瞬时失败:保留旧卡片。 } if perr := s.media.PutWebPage(ctx, urlHash, page, int(time.Now().Unix())); perr != nil { return } f.cache.Store(urlHash, page) // 刷新 L1,使后续读到新卡片。 }() } // resolve 实际抓取并构造卡片。HTML 与预览图共享 ctx 总时长预算。 func (f *webpageFetcher) resolve(ctx context.Context, s *Service, normalizedURL string, urlHash int64) (domain.MessageWebPage, error) { ctx, cancel := context.WithTimeout(ctx, webpageTotalTimeout) defer cancel() data, contentType, err := f.fetch(ctx, normalizedURL, acceptHTML) if err != nil { // 终态失败(SSRF/4xx/非法 URL)→ 负缓存为空预览,避免重复按键/发送重打 PG+外网。 // 瞬时失败(5xx/超时/dial/限速)→ 上抛 error,GetOrLoad 不缓存、可重试。 if errors.Is(err, errWebPageTerminal) { return emptyWebPage(normalizedURL, urlHash), nil } return domain.MessageWebPage{}, err } if !isHTMLContentType(contentType) { // 非 HTML(如直接指向图片/二进制):终态空预览。 return emptyWebPage(normalizedURL, urlHash), nil } meta := parseWebPageMeta(data, normalizedURL) if meta.empty() { return emptyWebPage(normalizedURL, urlHash), nil } page := doneWebPage(meta, normalizedURL, urlHash) if meta.image != "" { if photo, ok := f.fetchImage(ctx, s, meta.image); ok { page.Photo = &photo page.HasLargeMedia = true } } return page, nil } // fetchImage 抓取并铸造预览图(best-effort)。解码前按尺寸拦截解压炸弹;非图片/失败丢弃。 func (f *webpageFetcher) fetchImage(ctx context.Context, s *Service, imageURL string) (domain.Photo, bool) { data, _, err := f.fetch(ctx, imageURL, acceptImage) if err != nil { return domain.Photo{}, false } cfg, _, derr := image.DecodeConfig(bytes.NewReader(data)) if derr != nil || cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxWebpageImagePixels { return domain.Photo{}, false } photo, err := s.CreatePhotoFromBytes(ctx, data) if err != nil { return domain.Photo{}, false } return photo, true } func emptyWebPage(rawURL string, urlHash int64) domain.MessageWebPage { return domain.MessageWebPage{State: domain.MessageWebPageStateEmpty, ID: urlHash, URL: rawURL} } func doneWebPage(meta webpageMeta, rawURL string, urlHash int64) domain.MessageWebPage { page := domain.MessageWebPage{ State: domain.MessageWebPageStateDone, ID: urlHash, URL: rawURL, DisplayURL: webpageDisplayURL(rawURL), Type: meta.pageType(), SiteName: meta.siteName, Title: meta.title, Description: meta.description, Author: meta.author, } page.Hash = webpageContentHash(page) return page } // webpageMeta 是从 HTML head 提取的预览元数据(已按 og>twitter>title/meta 优先级归并)。 type webpageMeta struct { title string description string siteName string image string author string ogType string } func (m webpageMeta) empty() bool { return m.title == "" && m.description == "" && m.siteName == "" && m.image == "" && m.author == "" } func (m webpageMeta) pageType() string { if m.ogType != "" { return m.ogType } if m.image != "" && m.title == "" && m.description == "" { return "photo" } return "article" } // parseWebPageMeta 扫描 HTML head 的 <meta>/<title>,提取 OpenGraph/Twitter-card/标准元数据。 // 遇到 <body> 或 </head> 即停止(元数据都在 head)。og:image 相对 URL 按 baseURL 解析为绝对。 func parseWebPageMeta(htmlBytes []byte, baseURL string) webpageMeta { var ( m webpageMeta ogTitle, twTitle, docTitle string ogDesc, twDesc, metaDesc string ogImage, twImage string inTitle bool ) z := html.NewTokenizer(bytes.NewReader(htmlBytes)) scan: for { switch z.Next() { case html.ErrorToken: break scan case html.StartTagToken, html.SelfClosingTagToken: name, hasAttr := z.TagName() switch string(name) { case "meta": key, content := metaKeyContent(z, hasAttr) if content == "" { continue } switch key { case "og:title": ogTitle = content case "og:description": ogDesc = content case "og:site_name": m.siteName = content case "og:type": m.ogType = content case "og:image", "og:image:url", "og:image:secure_url": if ogImage == "" { ogImage = content } case "twitter:title": twTitle = content case "twitter:description": twDesc = content case "twitter:image", "twitter:image:src": if twImage == "" { twImage = content } case "description": metaDesc = content case "author", "article:author": if m.author == "" { m.author = content } } case "title": inTitle = true case "body": break scan } case html.TextToken: if inTitle && docTitle == "" { docTitle = strings.TrimSpace(string(z.Text())) } case html.EndTagToken: name, _ := z.TagName() switch string(name) { case "title": inTitle = false case "head": break scan } } } m.title = firstNonEmpty(ogTitle, twTitle, docTitle) m.description = firstNonEmpty(ogDesc, twDesc, metaDesc) if img := firstNonEmpty(ogImage, twImage); img != "" { if abs, ok := resolveAbsoluteURL(baseURL, img); ok { m.image = abs } } return m } // metaKeyContent 从一个 <meta> 标签收集 (property|name) 与 content。 func metaKeyContent(z *html.Tokenizer, hasAttr bool) (string, string) { var key, content string for hasAttr { var k, v []byte k, v, hasAttr = z.TagAttr() switch strings.ToLower(string(k)) { case "property", "name": if key == "" { key = strings.ToLower(strings.TrimSpace(string(v))) } case "content": content = strings.TrimSpace(string(v)) } } return key, content } func resolveAbsoluteURL(base, ref string) (string, bool) { b, err := url.Parse(base) if err != nil { return "", false } r, err := url.Parse(strings.TrimSpace(ref)) if err != nil { return "", false } abs := b.ResolveReference(r) if abs.Scheme != "http" && abs.Scheme != "https" { return "", false } return abs.String(), true } func webpageDisplayURL(raw string) string { u, err := url.Parse(raw) if err != nil || u.Host == "" { return raw } return strings.TrimPrefix(u.Host, "www.") } // webpageContentHash 对卡片内容算稳定 31-bit 哈希(webPage.hash 是 TL int,用于 getWebPage // NotModified 短路)。仅覆盖文本字段,预览图变化不计入(同 URL 预览图按内容寻址已去重)。 func webpageContentHash(p domain.MessageWebPage) int { h := fnv.New32a() for _, s := range []string{p.URL, p.Title, p.Description, p.SiteName, p.Author, p.Type} { _, _ = h.Write([]byte(s)) _, _ = h.Write([]byte{0}) } return int(h.Sum32() & 0x7fffffff) } func isHTMLContentType(ct string) bool { return ct == "" || ct == "text/html" || ct == "application/xhtml+xml" } func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { return v } } return "" }