Link thumbnails: post og:image previews + WOTD default-off

URL link previews now post the page's og:image as an inline m.image
thumbnail above the title/description reply. All outbound fetches in the
preview path (page scrape, image HEAD probe, image download) route through
a new SSRF/DoS-hardened safehttp client so a posted link can't steer
fetches at loopback/RFC1918/cloud-metadata IPs or OOM the parser.

Also flips the daily WOTD auto-post to opt-in (ENABLE_WOTD_POST=true)
instead of opt-out.
This commit is contained in:
prosolis
2026-06-24 21:55:25 -07:00
parent cbfca525f5
commit 8122973b74
7 changed files with 443 additions and 37 deletions

View File

@@ -0,0 +1,184 @@
package plugin
import (
"bytes"
"context"
"fmt"
"image"
_ "image/gif" // register GIF decoder for DecodeConfig
_ "image/jpeg" // register JPEG decoder for DecodeConfig
_ "image/png" // register PNG decoder for DecodeConfig
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"gogobee/internal/safehttp"
_ "golang.org/x/image/webp" // register WebP decoder for DecodeConfig
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// thumbnailUserAgent identifies the bot when probing and downloading the
// preview image for a posted link.
const thumbnailUserAgent = "GogoBee/1.0 (+link thumbnail fetch)"
// thumbnailClient validates and downloads link-supplied image URLs. It routes
// through safehttp so a posted link can't steer fetches at loopback / RFC1918 /
// cloud-metadata IPs — the dial-time guard re-checks every redirect target too.
// The 10 MiB download ceiling below bounds memory regardless.
var thumbnailClient = safehttp.NewClient(15 * time.Second)
// resolveURL turns a possibly-relative ref into an absolute URL against base.
func resolveURL(base, ref string) string {
ref = strings.TrimSpace(ref)
if ref == "" {
return ""
}
b, err := url.Parse(base)
if err != nil {
return ref
}
r, err := url.Parse(ref)
if err != nil {
return ref
}
return b.ResolveReference(r).String()
}
// normalizeImageURL rewrites known CDN thumbnail URLs to a higher-resolution
// variant. Currently handles The Guardian's i.guim.co.uk, whose pages hand out
// narrow thumbnails. Signed URLs are left alone (re-signing isn't possible),
// as are unrecognized hosts.
func normalizeImageURL(raw string) string {
if raw == "" {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.Host != "i.guim.co.uk" {
return raw
}
q := u.Query()
if q.Get("width") == "" || q.Get("s") != "" {
return raw
}
q.Set("width", "1200")
u.RawQuery = q.Encode()
return u.String()
}
// validateImageURL HEAD-probes an image URL: it must be http(s), return 200,
// have an image/* content type, and (if a length is declared) exceed 5 KiB so
// tracking pixels are filtered. Returns false with no error on any failure.
func validateImageURL(rawURL string) bool {
if rawURL == "" || safehttp.ValidateURL(rawURL) != nil {
return false
}
req, err := http.NewRequest("HEAD", rawURL, nil)
if err != nil {
return false
}
req.Header.Set("User-Agent", thumbnailUserAgent)
resp, err := thumbnailClient.Do(req)
if err != nil {
slog.Debug("thumbnail: image HEAD failed", "url", rawURL, "err", err)
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false
}
if !strings.HasPrefix(resp.Header.Get("Content-Type"), "image/") {
return false
}
if cl := resp.Header.Get("Content-Length"); cl != "" {
if size, err := strconv.ParseInt(cl, 10, 64); err == nil && size <= 5120 {
return false // tracking pixel
}
}
return true
}
// SendImageFromURL downloads imageURL, uploads it to Matrix, and posts it as an
// m.image event in roomID. Returns an error on any failure so callers can fall
// back to a text-only preview. The fetch is SSRF-guarded and size-capped.
func (b *Base) SendImageFromURL(roomID id.RoomID, imageURL string) error {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", imageURL, nil)
if err != nil {
return fmt.Errorf("create image request: %w", err)
}
req.Header.Set("User-Agent", thumbnailUserAgent)
resp, err := thumbnailClient.Do(req)
if err != nil {
return fmt.Errorf("download image: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("image download status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 10*1024*1024))
if err != nil {
return fmt.Errorf("read image: %w", err)
}
contentType := resp.Header.Get("Content-Type")
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = strings.TrimSpace(contentType[:i])
}
if contentType == "" {
contentType = "image/jpeg"
}
if !strings.HasPrefix(contentType, "image/") {
return fmt.Errorf("not an image: %s", contentType)
}
// Decode dimensions so clients render the image inline rather than as a
// downloadable file attachment.
var width, height int
if cfg, _, decErr := image.DecodeConfig(bytes.NewReader(data)); decErr == nil {
width, height = cfg.Width, cfg.Height
}
ext := ".jpg"
switch contentType {
case "image/png":
ext = ".png"
case "image/gif":
ext = ".gif"
case "image/webp":
ext = ".webp"
}
filename := "thumbnail" + ext
uri, err := b.UploadContent(data, contentType, filename)
if err != nil {
return fmt.Errorf("upload image: %w", err)
}
content := &event.MessageEventContent{
MsgType: event.MsgImage,
Body: filename,
FileName: filename,
URL: uri.CUString(),
Info: &event.FileInfo{
MimeType: contentType,
Size: len(data),
Width: width,
Height: height,
},
}
_, err = b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
return err
}

View File

@@ -0,0 +1,40 @@
package plugin
import "testing"
func TestResolveURL(t *testing.T) {
cases := []struct {
base, ref, want string
}{
{"https://e.com/news/story", "https://cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"},
{"https://e.com/news/story", "//cdn.e.com/a.jpg", "https://cdn.e.com/a.jpg"},
{"https://e.com/news/story", "/img/a.jpg", "https://e.com/img/a.jpg"},
{"https://e.com/news/story", "a.jpg", "https://e.com/news/a.jpg"},
{"https://e.com/news/story", "", ""},
}
for _, c := range cases {
if got := resolveURL(c.base, c.ref); got != c.want {
t.Errorf("resolveURL(%q, %q) = %q, want %q", c.base, c.ref, got, c.want)
}
}
}
func TestNormalizeImageURL(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"non-guardian passthrough", "https://e.com/a.jpg?width=140", "https://e.com/a.jpg?width=140"},
{"signed left alone", "https://i.guim.co.uk/x.jpg?width=140&s=abc", "https://i.guim.co.uk/x.jpg?width=140&s=abc"},
{"no width passthrough", "https://i.guim.co.uk/x.jpg", "https://i.guim.co.uk/x.jpg"},
{"empty", "", ""},
}
for _, c := range cases {
if got := normalizeImageURL(c.in); got != c.want {
t.Errorf("%s: normalizeImageURL(%q) = %q, want %q", c.name, c.in, got, c.want)
}
}
// Unsigned Guardian thumbnail gets bumped to width=1200.
if got := normalizeImageURL("https://i.guim.co.uk/x.jpg?width=140"); got != "https://i.guim.co.uk/x.jpg?width=1200" {
t.Errorf("normalizeImageURL unsigned = %q, want width=1200", got)
}
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"gogobee/internal/db"
"gogobee/internal/safehttp"
"github.com/PuerkitoBio/goquery"
"maunium.net/go/mautrix"
@@ -40,9 +41,10 @@ func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
Base: NewBase(client),
enabled: enabled,
ignoreUsers: ignore,
httpClient: &http.Client{
Timeout: 3 * time.Second,
},
// 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),
}
}
@@ -90,12 +92,23 @@ func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
}
func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
title, desc, err := p.fetchPreview(targetURL)
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
}
@@ -120,63 +133,69 @@ func (p *URLsPlugin) previewURL(ctx MessageContext, targetURL string) {
}
}
// fetchPreview retrieves og:title and og:description, checking cache first.
func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) {
// 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 title, desc string
var cachedAt int64
err := d.QueryRow(
`SELECT title, description, cached_at FROM url_cache WHERE url = ?`, rawURL,
).Scan(&title, &desc, &cachedAt)
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, nil
return title, desc, image, nil
}
// Fetch from web
title, desc, err = p.scrapeOG(rawURL)
title, desc, image, err = p.scrapeOG(rawURL)
if err != nil {
return "", "", err
return "", "", "", err
}
// Cache the result
db.Exec("urls: cache write",
`INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?)
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`,
rawURL, title, desc, now, title, desc, now,
`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, nil
return title, desc, image, nil
}
// scrapeOG fetches a URL and extracts og:title and og:description.
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
// 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)
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)
return "", "", "", fmt.Errorf("fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", "", fmt.Errorf("status %d", resp.StatusCode)
return "", "", "", fmt.Errorf("status %d", resp.StatusCode)
}
doc, err := goquery.NewDocumentFromReader(resp.Body)
// Cap the parsed body at 2 MiB — og: tags live in <head>, near the top.
doc, err := goquery.NewDocumentFromReader(safehttp.LimitedBody(resp.Body, 2*1024*1024))
if err != nil {
return "", "", fmt.Errorf("parse HTML: %w", err)
return "", "", "", fmt.Errorf("parse HTML: %w", err)
}
title := ""
desc := ""
image := ""
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
prop, _ := s.Attr("property")
@@ -186,6 +205,10 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
title = content
case "og:description":
desc = content
case "og:image:secure_url", "og:image:url", "og:image":
if image == "" && strings.TrimSpace(content) != "" {
image = content
}
}
})
@@ -205,5 +228,9 @@ func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
})
}
return strings.TrimSpace(title), strings.TrimSpace(desc), nil
if image != "" {
image = normalizeImageURL(resolveURL(rawURL, strings.TrimSpace(image)))
}
return strings.TrimSpace(title), strings.TrimSpace(desc), image, nil
}