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
568 lines
18 KiB
Go
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
|
|
}
|