Files
gogobee/internal/plugin/urls.go
prosolis ba7b20dfe5 url previews: stop dropping thumbnails on body cap and HEAD-403 CDNs
Two independent causes, both silent:

LimitedBody returned an error once the cap was hit, so scrapeOG failed the
whole goquery parse on any page over 2 MiB — even though og: tags live in
<head>, near the top. Hitting the cap is a truncation, not a failure: return
io.EOF and let the parser decide whether it found what it needed. Sized
against the pages actually posted here (n=14): og:title landed within the
first 7 KiB on twelve, worst case ~600 KiB.

validateImageURL HEAD-probed the image and bailed on non-200. Some publisher
CDNs (dims.apnews.com among them) answer HEAD with 403 while serving the same
URL over GET, so their thumbnails were always dropped. Probe with HEAD first,
fall back to a ranged GET asking for the first KiB. A 206 declares the full
size in Content-Range's "/119070" suffix rather than Content-Length, so the
tracking-pixel filter reads size from there.
2026-07-09 18:52:32 -07:00

244 lines
6.4 KiB
Go

package plugin
import (
"fmt"
"log/slog"
"net/http"
"os"
"regexp"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/safehttp"
"github.com/PuerkitoBio/goquery"
"maunium.net/go/mautrix"
)
var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`)
// URLsPlugin detects URLs in messages and previews og:title/og:description.
type URLsPlugin struct {
Base
enabled bool
ignoreUsers map[string]struct{}
httpClient *http.Client
}
// NewURLsPlugin creates a new URL preview plugin.
func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
enabled := os.Getenv("FEATURE_URL_PREVIEW") != ""
ignore := make(map[string]struct{})
if raw := os.Getenv("URL_PREVIEW_IGNORE_USERS"); raw != "" {
for _, u := range strings.Split(raw, ",") {
if u = strings.TrimSpace(u); u != "" {
ignore[u] = struct{}{}
}
}
}
return &URLsPlugin{
Base: NewBase(client),
enabled: enabled,
ignoreUsers: ignore,
// Route page scrapes through safehttp so a posted link can't steer the
// fetch at loopback / RFC1918 / cloud-metadata IPs (re-checked on every
// redirect), and can't OOM the parser by streaming an unbounded body.
httpClient: safehttp.NewClient(8 * time.Second),
}
}
func (p *URLsPlugin) Name() string { return "urls" }
func (p *URLsPlugin) Commands() []CommandDef {
return nil // No commands — purely passive
}
func (p *URLsPlugin) Init() error { return nil }
func (p *URLsPlugin) OnReaction(_ ReactionContext) error { return nil }
func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
if !p.enabled {
return nil
}
// Skip command messages
if ctx.IsCommand {
return nil
}
// Skip ignored users (e.g. news bots)
if _, ok := p.ignoreUsers[string(ctx.Sender)]; ok {
return nil
}
allURLs := urlRe.FindAllString(ctx.Body, -1)
// Filter out Matrix internal links (user mentions, room links, etc.)
var urls []string
for _, u := range allURLs {
if !strings.Contains(u, "matrix.to/") {
urls = append(urls, u)
}
}
// Only preview if there is exactly one URL; skip multi-URL messages
if len(urls) != 1 {
return nil
}
safeGo("url-preview", func() { p.previewURL(ctx, urls[0]) })
return nil
}
func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
title, desc, image, err := p.fetchPreview(targetURL)
if err != nil {
slog.Debug("urls: fetch preview failed", "url", targetURL, "err", err)
return
}
if title == "" && desc == "" && image == "" {
return
}
// Post the thumbnail first (best-effort), so it renders above the text.
if image != "" && validateImageURL(image) {
if err := p.SendImageFromURL(ctx.RoomID, image); err != nil {
slog.Debug("urls: thumbnail post failed", "url", targetURL, "image", image, "err", err)
}
}
if title == "" && desc == "" {
return
}
var preview strings.Builder
if title != "" {
preview.WriteString(fmt.Sprintf("Title: %s", title))
}
if desc != "" {
if preview.Len() > 0 {
preview.WriteString("\n")
}
// Truncate long descriptions
if len(desc) > 200 {
desc = desc[:200] + "..."
}
preview.WriteString(fmt.Sprintf("Description: %s", desc))
}
if err := p.SendReply(ctx.RoomID, ctx.EventID, preview.String()); err != nil {
slog.Error("urls: send preview", "err", err)
}
}
// fetchPreview retrieves og:title, og:description and og:image, checking cache first.
func (p *URLsPlugin) fetchPreview(rawURL string) (title, desc, image string, err error) {
d := db.Get()
now := time.Now().UTC().Unix()
cacheTTL := int64(24 * 60 * 60)
// Check cache
var cachedAt int64
err = d.QueryRow(
`SELECT title, description, image_url, cached_at FROM url_cache WHERE url = ?`, rawURL,
).Scan(&title, &desc, &image, &cachedAt)
if err == nil && now-cachedAt < cacheTTL {
return title, desc, image, nil
}
// Fetch from web
title, desc, image, err = p.scrapeOG(rawURL)
if err != nil {
return "", "", "", err
}
// Cache the result
db.Exec("urls: cache write",
`INSERT INTO url_cache (url, title, description, image_url, cached_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, image_url = ?, cached_at = ?`,
rawURL, title, desc, image, now, title, desc, image, now,
)
return title, desc, image, nil
}
// scrapeOG fetches a URL and extracts og:title, og:description and og:image.
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, string, error) {
if err := safehttp.ValidateURL(rawURL); err != nil {
return "", "", "", err
}
req, err := http.NewRequest("GET", rawURL, nil)
if err != nil {
return "", "", "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", "GogoBee Bot/1.0")
req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := p.httpClient.Do(req)
if err != nil {
return "", "", "", fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", "", fmt.Errorf("status %d", resp.StatusCode)
}
// Cap the parsed body at 2 MiB: big enough that <head> always fits, small
// enough to bound memory against an origin streaming an endless body. Reaching
// the cap truncates rather than failing, so an oversized tail costs nothing.
//
// Sized against the pages actually posted here (2026-07-09, n=14): og:title
// landed within the first 7 KiB on twelve of them, worst case ~600 KiB, on
// pages up to 1.44 MiB. Note failed scrapes never reach url_cache, so that
// sample can't tell you how big the pages that *blew* the old cap were.
doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, 2*1024*1024))
if err != nil {
return "", "", "", fmt.Errorf("parse HTML: %w", err)
}
title := ""
desc := ""
image := ""
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
prop, _ := s.Attr("property")
content, _ := s.Attr("content")
switch prop {
case "og:title":
title = content
case "og:description":
desc = content
case "og:image:secure_url", "og:image:url", "og:image":
if image == "" && strings.TrimSpace(content) != "" {
image = content
}
}
})
// Fallback to <title> tag if no og:title
if title == "" {
title = doc.Find("title").First().Text()
}
// Fallback to meta description
if desc == "" {
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
name, _ := s.Attr("name")
if strings.EqualFold(name, "description") {
content, _ := s.Attr("content")
desc = content
}
})
}
if image != "" {
image = normalizeImageURL(resolveURL(rawURL, strings.TrimSpace(image)))
}
return strings.TrimSpace(title), strings.TrimSpace(desc), image, nil
}