Files
irrlichtandClaude Opus 5.5 26b0182391 Linkvorschau für den ersten Link eines Beitrags
Der Server holt zum ersten Link Titel, Seiten- bzw. Kanalname und ein
kleines Vorschaubild (320 px) und speichert das Bild lokal; der Browser
lädt nur von uns, Besucher-IPs gehen nie an die verlinkte Seite.
YouTube über oEmbed (alle Linkformen kanonisch als watch?v=<id>), alle
übrigen Seiten über OpenGraph bzw. <title>. Abruf asynchron in einem
Worker, eine Zeile je Link in link_preview, Auffrischen nach 30 Tagen.

Der Abruf-Client lässt nur öffentliche IPs auf Port 80/443 zu, geprüft
nach DNS-Auflösung und bei jeder Weiterleitung (SSRF). Bestehende
Beiträge: kver link-previews. Neue Abhängigkeit golang.org/x/net, damit
go 1.26. DEV bekommt ein beschreibbares Volume für die Vorschaubilder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiXsPUqw7oeomZ8wZrQW5q
2026-10-06 18:47:21 +02:00

568 lines
18 KiB
Go

package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image"
"image/jpeg"
"io"
"log"
"mime"
"net"
"net/http"
"net/netip"
"net/url"
"os"
"regexp"
"strings"
"syscall"
"time"
"golang.org/x/image/webp"
"golang.org/x/net/html"
"golang.org/x/net/html/charset"
xdraw "golang.org/x/image/draw"
)
// Linkvorschau: Zum ersten Link eines Beitrags holt der Server Titel, Seiten-
// bzw. Kanalname und Vorschaubild und speichert das Bild lokal. Der Browser
// lädt die Vorschau damit ausschließlich von uns -- Besucher-IPs gehen nie an
// die verlinkte Seite (kein Einwilligungsbedarf, CSP bleibt 'self').
//
// Ablauf: Beim Anlegen/Bearbeiten wird der erste Link kanonisiert (previewKey)
// und als entry.link_url gespeichert. requestPreview legt dazu eine Zeile in
// link_preview an und reicht sie an den Worker; der holt die Daten im
// Hintergrund (das Posten wartet nie). Mehrere Beiträge mit demselben Link
// teilen sich eine Zeile und ein Bild. entrySelect joint die fertige Vorschau
// an jeden Beitrag.
//
// Provider: YouTube (oEmbed, liefert auch ohne Cookie-Banner Titel und Kanal)
// und allgemein OpenGraph/<title> für alle übrigen Seiten. Weitere Spezialfälle
// kommen als eigener Zweig in previewKey/fetchPreview dazu.
// Preview ist die fertige Vorschau eines Links, wie die API sie ausliefert.
type Preview struct {
URL string `json:"url"` // Linkziel der Vorschau (kanonisch)
Kind string `json:"kind"` // "youtube" | "page"
Title string `json:"title"` // Video- bzw. Seitentitel
Site string `json:"site"` // Kanal- bzw. Seitenname
Thumb string `json:"thumb"` // lokales Bild unter static/media/preview, ggf. ""
}
const (
previewRefreshOK = 30 * 24 * 60 * 60 // erfolgreiche Vorschau nach 30 Tagen auffrischen
previewRetryFailed = 24 * 60 * 60 // fehlgeschlagene frühestens nach einem Tag erneut
maxPreviewURL = 2048
maxPageBytes = 1 << 20 // gelesener HTML-Anfang
maxThumbBytes = 8 << 20
thumbSize = 320 // Zielkante des gespeicherten Vorschaubilds (klein, steht neben dem Text)
previewDir = "static/media/preview"
)
// linkPattern findet Links wie linkify im Frontend (format.tsx), damit die
// Vorschau exakt zum ersten dort verlinkten Text gehört. \p{Z} und U+FEFF
// gleichen Gos ASCII-\s an das Unicode-\s von JavaScript an.
var linkPattern = regexp.MustCompile(`https?://[^\s\p{Z}\x{feff}<]+`)
var youtubeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{11}$`)
// previewKey liefert die kanonische URL des ersten Links in content oder "".
// YouTube-Links aller Formen werden auf watch?v=<id> gebracht, damit dasselbe
// Video nur einmal geholt wird.
func previewKey(content string) string {
raw := linkPattern.FindString(content)
if raw == "" || len(raw) > maxPreviewURL {
return ""
}
u, err := url.Parse(raw)
if err != nil || u.Hostname() == "" {
return ""
}
if id := youtubeID(u); id != "" {
return "https://www.youtube.com/watch?v=" + id
}
return raw
}
// youtubeID erkennt die Video-ID in watch?v=, youtu.be/, /shorts/, /live/,
// /embed/ und /v/ auf youtube.com, m./music./www.-Varianten und
// youtube-nocookie.com. "" für alles andere (Kanäle, Playlists, ...).
func youtubeID(u *url.URL) string {
host := strings.ToLower(u.Hostname())
for _, p := range []string{"www.", "m.", "music."} {
host = strings.TrimPrefix(host, p)
}
seg := strings.Split(strings.Trim(u.Path, "/"), "/")
var id string
switch host {
case "youtu.be":
id = seg[0]
case "youtube.com", "youtube-nocookie.com":
switch {
case u.Path == "/watch":
id = u.Query().Get("v")
case len(seg) >= 2 && (seg[0] == "shorts" || seg[0] == "live" || seg[0] == "embed" || seg[0] == "v"):
id = seg[1]
}
}
if !youtubeIDPattern.MatchString(id) {
return ""
}
return id
}
// requestPreview sorgt dafür, dass zu key eine Vorschau existiert: neue Links
// werden angelegt, veraltete oder vor über einem Tag gescheiterte erneut
// angestoßen. Nur dann geht der Key an den Worker. Ist dessen Puffer voll,
// bleibt die Zeile auf pending und wird beim nächsten Start nachgeholt.
func requestPreview(key string) {
if key == "" {
return
}
now := time.Now().Unix()
res, err := db.Exec(
`INSERT INTO link_preview (url) VALUES ($1)
ON CONFLICT (url) DO UPDATE SET status = 'pending'
WHERE (link_preview.status = 'ok' AND link_preview.fetched_at < $2)
OR (link_preview.status = 'none' AND link_preview.fetched_at < $3)`,
key, now-previewRefreshOK, now-previewRetryFailed)
if err != nil {
log.Printf("linkvorschau %s: %v", key, err)
return
}
if n, _ := res.RowsAffected(); n == 0 {
return
}
select {
case previewCh <- key:
default:
}
}
// Ein einzelner Worker holt die Vorschauen nacheinander, wie beim Impression-
// Worker deckelt das die ausgehenden Requests (Link-Spam erzeugt keine Flut).
// Beim Shutdown bricht previewCtx einen laufenden Abruf ab; was noch im Puffer
// liegt, bleibt pending und wird beim nächsten Start nachgeholt.
var (
previewCh = make(chan string, 64)
previewDone = make(chan struct{})
previewCtx, previewShutdown = context.WithCancel(context.Background())
)
// startPreviewWorker startet den Worker (nach initDB) und holt zuerst die
// liegengebliebenen pending-Zeilen nach.
func startPreviewWorker() {
go func() {
defer close(previewDone)
for _, key := range pendingPreviews(64) {
fetchPreview(previewCtx, key)
}
for key := range previewCh {
fetchPreview(previewCtx, key)
}
}()
}
// stopPreviewWorker bricht laufende Abrufe ab und wartet auf den Worker (vor
// db.Close rufen).
func stopPreviewWorker() {
previewShutdown()
close(previewCh)
<-previewDone
}
func pendingPreviews(limit int) []string {
rows, err := db.Query(`SELECT url FROM link_preview WHERE status = 'pending' LIMIT $1`, limit)
if err != nil {
log.Printf("linkvorschau: %v", err)
return nil
}
defer rows.Close()
var keys []string
for rows.Next() {
var k string
if rows.Scan(&k) == nil {
keys = append(keys, k)
}
}
return keys
}
// fetchPreview holt die Vorschau zu key und speichert das Ergebnis: status ok
// mit Daten oder none (Seite ohne verwertbaren Titel, Video privat/gelöscht,
// Fehler). Bei abgebrochenem ctx (Shutdown) bleibt die Zeile pending.
func fetchPreview(ctx context.Context, key string) {
if ctx.Err() != nil {
return
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
var p Preview
var imgs []string // Bildkandidaten, der erste ladbare gewinnt
var err error
if u, _ := url.Parse(key); u != nil && youtubeID(u) != "" {
p, imgs, err = fetchYouTube(ctx, youtubeID(u))
} else {
p, imgs, err = fetchPage(ctx, key)
}
if errors.Is(err, context.Canceled) && previewCtx.Err() != nil {
return
}
now := time.Now().Unix()
thumb := thumbPath(key)
if err != nil {
log.Printf("linkvorschau %s: %v", key, err)
os.Remove(thumb)
db.Exec(`UPDATE link_preview SET status = 'none', title = '', site = '', thumb = '', fetched_at = $2 WHERE url = $1`, key, now)
return
}
stored := false
for _, img := range imgs {
if err := storeThumb(ctx, img, thumb); err != nil {
log.Printf("linkvorschau %s: bild %s: %v", key, img, err)
continue
}
stored = true
break
}
if !stored {
os.Remove(thumb)
thumb = ""
}
if previewCtx.Err() != nil {
return // Shutdown mitten im Bildabruf: pending lassen, nicht ohne Bild speichern
}
db.Exec(
`UPDATE link_preview SET status = 'ok', kind = $2, title = $3, site = $4, thumb = $5, fetched_at = $6 WHERE url = $1`,
key, p.Kind, truncateRunes(p.Title, 300), truncateRunes(p.Site, 100), thumb, now)
}
// thumbPath leitet den Bildnamen aus dem Key ab: deterministisch (Auffrischen
// überschreibt dasselbe Bild) und ohne Nutzereingabe im Dateinamen.
func thumbPath(key string) string {
sum := sha256.Sum256([]byte(key))
return previewDir + "/" + hex.EncodeToString(sum[:10]) + ".jpg"
}
// YouTube-Endpunkte; Variablen, damit Tests sie auf einen lokalen Server lenken.
// mqdefault (320x180) gibt es für jedes Video, in 16:9 ohne schwarze Balken
// und genau in der Größe, in der wir es zeigen.
var (
youtubeOEmbed = "https://www.youtube.com/oembed"
youtubeThumbs = []string{"https://i.ytimg.com/vi/%s/mqdefault.jpg"}
)
// fetchYouTube fragt den öffentlichen oEmbed-Endpunkt (ohne API-Key). Das
// Bild kommt von der festen Thumbnail-Adresse statt aus thumbnail_url, so
// bestimmt allein die geprüfte ID, welche Hosts wir ansprechen.
func fetchYouTube(ctx context.Context, id string) (Preview, []string, error) {
watch := "https://www.youtube.com/watch?v=" + id
resp, err := previewGet(ctx, youtubeOEmbed+"?format=json&url="+url.QueryEscape(watch), "application/json")
if err != nil {
return Preview{}, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// 401/403: privat oder nicht einbettbar, 404: gelöscht.
return Preview{}, nil, fmt.Errorf("oembed: status %d", resp.StatusCode)
}
var o struct {
Title string `json:"title"`
AuthorName string `json:"author_name"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&o); err != nil {
return Preview{}, nil, fmt.Errorf("oembed: %w", err)
}
if o.Title == "" {
return Preview{}, nil, errors.New("oembed: kein Titel")
}
imgs := make([]string, len(youtubeThumbs))
for i, f := range youtubeThumbs {
imgs[i] = fmt.Sprintf(f, id)
}
return Preview{URL: watch, Kind: "youtube", Title: o.Title, Site: o.AuthorName}, imgs, nil
}
// fetchPage liest den Kopf einer HTML-Seite und nimmt OpenGraph- bzw.
// Twitter-Angaben, ersatzweise <title>. Ohne Titel gibt es keine Vorschau.
func fetchPage(ctx context.Context, pageURL string) (Preview, []string, error) {
resp, err := previewGet(ctx, pageURL, "text/html,application/xhtml+xml")
if err != nil {
return Preview{}, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return Preview{}, nil, fmt.Errorf("status %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if mt, _, _ := mime.ParseMediaType(ct); mt != "text/html" && mt != "application/xhtml+xml" {
return Preview{}, nil, fmt.Errorf("kein HTML: %q", ct)
}
body, err := charset.NewReader(io.LimitReader(resp.Body, maxPageBytes), ct)
if err != nil {
return Preview{}, nil, err
}
m := parsePageMeta(body)
title := firstNonEmpty(m["og:title"], m["twitter:title"], m["title"])
if title == "" {
return Preview{}, nil, errors.New("kein Titel")
}
// Nach Redirects gilt die endgültige Adresse als Basis für relative Bild-URLs.
base := resp.Request.URL
site := firstNonEmpty(m["og:site_name"], strings.TrimPrefix(base.Hostname(), "www."))
var imgs []string
if ref := firstNonEmpty(m["og:image:secure_url"], m["og:image"], m["og:image:url"], m["twitter:image"], m["twitter:image:src"]); ref != "" {
if u, err := base.Parse(ref); err == nil && (u.Scheme == "http" || u.Scheme == "https") {
imgs = []string{u.String()}
}
}
return Preview{URL: pageURL, Kind: "page", Title: title, Site: site}, imgs, nil
}
// parsePageMeta sammelt <meta property|name=... content=...> und den
// <title>-Text aus dem Dokumentkopf. Kleingeschriebene Schlüssel, jeweils der
// erste Treffer zählt; Schluss beim <body> bzw. </head>.
func parsePageMeta(r io.Reader) map[string]string {
m := map[string]string{}
set := func(k, v string) {
v = strings.Join(strings.Fields(strings.ToValidUTF8(v, "")), " ")
if _, ok := m[k]; !ok && v != "" {
m[k] = v
}
}
z := html.NewTokenizer(r)
inTitle := false
for {
switch z.Next() {
case html.ErrorToken:
return m
case html.StartTagToken, html.SelfClosingTagToken:
t := z.Token()
switch t.Data {
case "body":
return m
case "title":
inTitle = true
case "meta":
var key, content string
for _, a := range t.Attr {
switch a.Key {
case "property", "name":
if key == "" {
key = strings.ToLower(a.Val)
}
case "content":
content = a.Val
}
}
if key != "" && key != "title" {
set(key, content)
}
}
case html.TextToken:
if inTitle {
set("title", string(z.Text()))
}
case html.EndTagToken:
switch z.Token().Data {
case "title":
inTitle = false
case "head":
return m
}
}
}
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// storeThumb lädt ein Bild, verkleinert es auf thumbSize und speichert es als
// JPEG unter name. Das Re-Encode verwirft Metadaten und alles, was kein Bild
// ist; animierte GIFs werden zum ersten Frame.
func storeThumb(ctx context.Context, imgURL, name string) error {
resp, err := previewGet(ctx, imgURL, "image/*")
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxThumbBytes+1))
if err != nil {
return err
}
if len(data) > maxThumbBytes {
return errors.New("bild zu groß")
}
// WebP gezielt dekodieren statt global zu registrieren -- sonst nähme
// auch der Upload (storeImage) plötzlich WebP an.
decodeConfig, decode := image.DecodeConfig, func(r io.Reader) (image.Image, error) {
img, _, err := image.Decode(r)
return img, err
}
if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
decodeConfig = func(r io.Reader) (image.Config, string, error) {
c, err := webp.DecodeConfig(r)
return c, "webp", err
}
decode = webp.Decode
}
cfg, _, err := decodeConfig(bytes.NewReader(data))
if err != nil {
return err
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width*cfg.Height > maxImagePixels {
return fmt.Errorf("bild zu groß: %dx%d", cfg.Width, cfg.Height)
}
img, err := decode(bytes.NewReader(data))
if err != nil {
return err
}
b := img.Bounds()
nw, nh := b.Dx(), b.Dy()
if nw > thumbSize || nh > thumbSize {
if nw > nh {
nw, nh = thumbSize, max(nh*thumbSize/nw, 1)
} else {
nw, nh = max(nw*thumbSize/nh, 1), thumbSize
}
}
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
// Weißer Grund für transparente PNG/WebP, JPEG kennt keinen Alphakanal.
xdraw.Draw(dst, dst.Bounds(), image.White, image.Point{}, xdraw.Src)
xdraw.CatmullRom.Scale(dst, dst.Bounds(), img, b, xdraw.Over, nil)
if err := os.MkdirAll(previewDir, 0o755); err != nil {
return err
}
// Erst in eine Temp-Datei, dann umbenennen: ein Auffrischen ersetzt das
// alte Bild atomar, Besucher sehen nie ein halb geschriebenes.
tmp, err := os.CreateTemp(previewDir, ".tmp-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if err := jpeg.Encode(tmp, dst, &jpeg.Options{Quality: 80}); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
return os.Rename(tmp.Name(), name)
}
// previewGet ist ein GET über previewClient mit ehrlichem User-Agent.
func previewGet(ctx context.Context, target, accept string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "kver-linkvorschau/1.0")
req.Header.Set("Accept", accept)
req.Header.Set("Accept-Language", "de,en;q=0.5")
return previewClient.Do(req)
}
// previewClient ruft beliebige, von Nutzern gepostete URLs ab und muss daher
// gegen SSRF gehärtet sein: Wer "http://postgres:5432/" oder eine Adresse im
// internen Netz postet, darf unseren Server nicht dorthin schicken. Geprüft
// wird in Dialer.Control, also NACH der DNS-Auflösung auf der tatsächlich
// angewählten IP -- das deckt auch DNS-Rebinding und jeden Redirect-Schritt ab.
// Kein Proxy aus der Umgebung (der würde die Prüfung umgehen).
var previewClient = &http.Client{
Timeout: 20 * time.Second,
Transport: &http.Transport{
Proxy: nil,
DialContext: (&net.Dialer{
Timeout: 5 * time.Second,
Control: func(network, address string, _ syscall.RawConn) error {
return previewDialGuard(address)
},
}).DialContext,
TLSHandshakeTimeout: 5 * time.Second,
ResponseHeaderTimeout: 10 * time.Second,
MaxIdleConns: 4,
IdleConnTimeout: 30 * time.Second,
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 5 {
return errors.New("zu viele Weiterleitungen")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("weiterleitung auf %q", req.URL.Scheme)
}
return nil
},
}
// previewDialGuard lässt nur öffentliche Adressen auf Port 80/443 zu.
// Variable, damit Tests gegen einen lokalen httptest-Server laufen können.
var previewDialGuard = func(address string) error {
host, port, err := net.SplitHostPort(address)
if err != nil {
return err
}
if port != "80" && port != "443" {
return fmt.Errorf("port %s nicht erlaubt", port)
}
ip, err := netip.ParseAddr(host)
if err != nil {
return err
}
if !isPublicIP(ip) {
return fmt.Errorf("adresse %s nicht öffentlich", ip)
}
return nil
}
// Nicht-öffentliche Bereiche, die IsGlobalUnicast/IsPrivate nicht abdecken.
var reservedNets = []netip.Prefix{
netip.MustParsePrefix("0.0.0.0/8"), // "dieses Netz"
netip.MustParsePrefix("100.64.0.0/10"), // Carrier-Grade-NAT
netip.MustParsePrefix("192.0.0.0/24"), // IETF-Protokollzuweisungen
netip.MustParsePrefix("192.0.2.0/24"), // Dokumentation
netip.MustParsePrefix("198.18.0.0/15"), // Benchmarking
netip.MustParsePrefix("198.51.100.0/24"), // Dokumentation
netip.MustParsePrefix("203.0.113.0/24"), // Dokumentation
netip.MustParsePrefix("240.0.0.0/4"), // reserviert, inkl. Broadcast
netip.MustParsePrefix("64:ff9b::/96"), // NAT64: bildet IPv4 inkl. interner Netze ab
netip.MustParsePrefix("64:ff9b:1::/48"), // lokales NAT64
netip.MustParsePrefix("2001:db8::/32"), // Dokumentation
netip.MustParsePrefix("2002::/16"), // 6to4: kann interne IPv4 einbetten
}
func isPublicIP(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsGlobalUnicast() || ip.IsPrivate() {
return false
}
for _, p := range reservedNets {
if p.Contains(ip) {
return false
}
}
return true
}