owpengram-server/internal/app/files/webpage.go

525 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/<title>+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/限速)→ 上抛 errorGetOrLoad 不缓存、可重试。
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 ""
}