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/ 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 }