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
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a8f392edba
commit
26b0182391
+273
@@ -0,0 +1,273 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPreviewKey(t *testing.T) {
|
||||
yt := "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
cases := map[string]string{
|
||||
"guck mal https://youtu.be/dQw4w9WgXcQ?t=42": yt,
|
||||
"https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=x": yt,
|
||||
"https://m.youtube.com/watch?feature=share&v=dQw4w9WgXcQ": yt,
|
||||
"https://youtube.com/shorts/dQw4w9WgXcQ": yt,
|
||||
"https://music.youtube.com/watch?v=dQw4w9WgXcQ": yt,
|
||||
"https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ": yt,
|
||||
"https://www.youtube.com/@kanal": "https://www.youtube.com/@kanal",
|
||||
"https://www.youtube.com/watch?v=kurz": "https://www.youtube.com/watch?v=kurz",
|
||||
"erst https://example.org/a dann https://youtu.be/dQw4w9WgXcQ": "https://example.org/a",
|
||||
"ohne Link": "",
|
||||
"ftp://example.org": "",
|
||||
"https://example.org/x danach": "https://example.org/x",
|
||||
"https://example.org/<b>": "https://example.org/",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := previewKey(in); got != want {
|
||||
t.Errorf("previewKey(%q) = %q, erwartet %q", in, got, want)
|
||||
}
|
||||
}
|
||||
if got := previewKey("https://example.org/" + strings.Repeat("a", maxPreviewURL)); got != "" {
|
||||
t.Errorf("überlange URL ergab %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPublicIP(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"93.184.216.34": true,
|
||||
"2a00:1450:4001::200e": true,
|
||||
"127.0.0.1": false,
|
||||
"10.1.2.3": false,
|
||||
"172.16.0.1": false,
|
||||
"192.168.161.4": false,
|
||||
"169.254.169.254": false, // Cloud-Metadaten
|
||||
"100.64.0.1": false,
|
||||
"0.1.2.3": false,
|
||||
"0.0.0.0": false,
|
||||
"255.255.255.255": false,
|
||||
"::1": false,
|
||||
"fd00::1": false,
|
||||
"fe80::1": false,
|
||||
"::ffff:127.0.0.1": false,
|
||||
"::ffff:93.184.216.34": true,
|
||||
"64:ff9b::a00:1": false,
|
||||
"2002:c0a8:101::1": false,
|
||||
"224.0.0.1": false,
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := isPublicIP(netip.MustParseAddr(in)); got != want {
|
||||
t.Errorf("isPublicIP(%s) = %v, erwartet %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Der echte Wächter muss interne Ziele abweisen, ohne dass ein Request das
|
||||
// Netz verlässt -- auch nach einer Weiterleitung.
|
||||
func TestPreviewClientBlocksInternal(t *testing.T) {
|
||||
internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Error("interner Server wurde erreicht")
|
||||
}))
|
||||
defer internal.Close()
|
||||
if _, err := previewGet(context.Background(), internal.URL, "text/html"); err == nil {
|
||||
t.Fatal("Abruf von 127.0.0.1 wurde nicht blockiert")
|
||||
}
|
||||
if err := previewDialGuard("8.8.8.8:5432"); err == nil {
|
||||
t.Error("Port 5432 wurde nicht blockiert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageMeta(t *testing.T) {
|
||||
doc := `<!doctype html><html><head>
|
||||
<title> Seiten
|
||||
titel </title>
|
||||
<meta property="og:title" content="OG-Titel">
|
||||
<meta property="og:title" content="zweiter OG-Titel">
|
||||
<meta name="twitter:image" content="/bild.png">
|
||||
<meta property="OG:Site_Name" content="Beispiel">
|
||||
</head><body><meta property="og:image" content="/zu-spaet.png"></body></html>`
|
||||
m := parsePageMeta(strings.NewReader(doc))
|
||||
want := map[string]string{
|
||||
"title": "Seiten titel",
|
||||
"og:title": "OG-Titel",
|
||||
"twitter:image": "/bild.png",
|
||||
"og:site_name": "Beispiel",
|
||||
}
|
||||
for k, v := range want {
|
||||
if m[k] != v {
|
||||
t.Errorf("%s = %q, erwartet %q", k, m[k], v)
|
||||
}
|
||||
}
|
||||
if _, ok := m["og:image"]; ok {
|
||||
t.Error("Meta aus dem body wurde übernommen")
|
||||
}
|
||||
}
|
||||
|
||||
// fakeRemote stellt eine Seite mit og-Tags, ein Bild und einen oEmbed-
|
||||
// Endpunkt bereit und lenkt die Vorschau-Abrufe für die Testdauer dorthin.
|
||||
func fakeRemote(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
var buf strings.Builder
|
||||
img := image.NewRGBA(image.Rect(0, 0, 1280, 720))
|
||||
png.Encode(&buf, img)
|
||||
pngData := buf.String()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/seite", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<html><head><meta property="og:title" content="Ein Artikel">
|
||||
<meta property="og:image" content="/bild.png"></head><body></body></html>`))
|
||||
})
|
||||
mux.HandleFunc("/umleitung", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/seite", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/ohne-titel", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<html><head></head><body>nix</body></html>`))
|
||||
})
|
||||
mux.HandleFunc("/bild.png", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write([]byte(pngData))
|
||||
})
|
||||
mux.HandleFunc("/oembed", func(w http.ResponseWriter, r *http.Request) {
|
||||
u, _ := url.Parse(r.URL.Query().Get("url"))
|
||||
if u == nil || u.Query().Get("v") != "dQw4w9WgXcQ" {
|
||||
http.Error(w, "Not Found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{"title": "Ein Video", "author_name": "Ein Kanal"})
|
||||
})
|
||||
mux.HandleFunc("/vi/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Write([]byte(pngData))
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
oldGuard, oldOEmbed, oldThumbs := previewDialGuard, youtubeOEmbed, youtubeThumbs
|
||||
previewDialGuard = func(string) error { return nil }
|
||||
youtubeOEmbed = srv.URL + "/oembed"
|
||||
// Erster Kandidat fehlt -> der nächste greift.
|
||||
youtubeThumbs = []string{srv.URL + "/fehlt/%s.jpg", srv.URL + "/vi/%s/hqdefault.jpg"}
|
||||
t.Cleanup(func() {
|
||||
previewDialGuard, youtubeOEmbed, youtubeThumbs = oldGuard, oldOEmbed, oldThumbs
|
||||
})
|
||||
|
||||
// Bilder landen relativ zum CWD unter static/media/preview.
|
||||
dir := t.TempDir()
|
||||
wd, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(wd) })
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestFetchPage(t *testing.T) {
|
||||
remote := fakeRemote(t)
|
||||
p, img, err := fetchPage(context.Background(), remote.URL+"/umleitung")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Title != "Ein Artikel" || p.Site != "127.0.0.1" || p.URL != remote.URL+"/umleitung" {
|
||||
t.Errorf("unerwartete Vorschau %+v", p)
|
||||
}
|
||||
// Relatives Bild gegen die Adresse NACH der Weiterleitung aufgelöst.
|
||||
if len(img) != 1 || img[0] != remote.URL+"/bild.png" {
|
||||
t.Errorf("bild = %q", img)
|
||||
}
|
||||
if _, _, err := fetchPage(context.Background(), remote.URL+"/ohne-titel"); err == nil {
|
||||
t.Error("Seite ohne Titel ergab eine Vorschau")
|
||||
}
|
||||
if _, _, err := fetchPage(context.Background(), remote.URL+"/bild.png"); err == nil {
|
||||
t.Error("Bild als Seite ergab eine Vorschau")
|
||||
}
|
||||
|
||||
name := thumbPath("x")
|
||||
if err := storeThumb(context.Background(), img[0], name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, format, err := image.DecodeConfig(f)
|
||||
if err != nil || format != "jpeg" || cfg.Width != thumbSize || cfg.Height != 180 {
|
||||
t.Errorf("thumb: %s %dx%d %v", format, cfg.Width, cfg.Height, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ende-zu-Ende: Beitrag mit YouTube-Link anlegen, Vorschau holen, im Feed
|
||||
// sehen; Bearbeiten ohne Link nimmt sie wieder weg.
|
||||
func TestLinkPreviewInFeed(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
fakeRemote(t)
|
||||
c := registerAndLogin(t, srv, "vorschau")
|
||||
|
||||
pid := createEntry(t, c, srv, "schau https://youtu.be/dQw4w9WgXcQ und https://example.org")
|
||||
key := "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
|
||||
feedPreview := func() *Preview {
|
||||
t.Helper()
|
||||
resp, err := http.Get(srv.URL + "/api/entry/feed/0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var entries []Entry
|
||||
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.PID == pid {
|
||||
return e.Preview
|
||||
}
|
||||
}
|
||||
t.Fatalf("Beitrag %d fehlt im Feed", pid)
|
||||
return nil
|
||||
}
|
||||
|
||||
if p := feedPreview(); p != nil {
|
||||
t.Errorf("Vorschau vor dem Abruf: %+v", p)
|
||||
}
|
||||
fetchPreview(context.Background(), key)
|
||||
p := feedPreview()
|
||||
if p == nil {
|
||||
t.Fatal("keine Vorschau nach dem Abruf")
|
||||
}
|
||||
if p.URL != key || p.Kind != "youtube" || p.Title != "Ein Video" || p.Site != "Ein Kanal" || p.Thumb != thumbPath(key) {
|
||||
t.Errorf("unerwartete Vorschau %+v", p)
|
||||
}
|
||||
if _, err := os.Stat(p.Thumb); err != nil {
|
||||
t.Errorf("thumb fehlt: %v", err)
|
||||
}
|
||||
|
||||
// Ein privates/gelöschtes Video ergibt keine Vorschau.
|
||||
other := "https://www.youtube.com/watch?v=aaaaaaaaaaa"
|
||||
requestPreview(other)
|
||||
fetchPreview(context.Background(), other)
|
||||
var status string
|
||||
db.QueryRow(`SELECT status FROM link_preview WHERE url = $1`, other).Scan(&status)
|
||||
if status != "none" {
|
||||
t.Errorf("status = %q, erwartet none", status)
|
||||
}
|
||||
|
||||
resp := postForm(t, c, srv.URL+"/api/entry/"+itoa(pid)+"/edit", url.Values{"content": {"jetzt ohne Link"}})
|
||||
resp.Body.Close()
|
||||
if p := feedPreview(); p != nil {
|
||||
t.Errorf("Vorschau nach Bearbeiten ohne Link: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int64) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user