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:
irrlicht
2026-10-06 18:47:21 +02:00
co-authored by Claude Opus 5.5
parent a8f392edba
commit 26b0182391
15 changed files with 1052 additions and 26 deletions
+24 -9
View File
@@ -34,6 +34,8 @@ type Entry struct {
LastBump int64 `json:"last_bump"`
Username string `json:"username"`
Avatar string `json:"avatar"`
// Preview ist die fertige Vorschau des ersten Links, siehe preview.go.
Preview *Preview `json:"preview,omitempty"`
// Latest ist nur in Feed und Antwortliste gesetzt, siehe attachLatest.
Latest *Latest `json:"latest,omitempty"`
}
@@ -47,20 +49,29 @@ type Latest struct {
}
// LEFT JOIN + COALESCE, damit soft-gelöschte Beiträge (uid auf 0 gesetzt) als
// [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen.
// [deleted]-Platzhalter erhalten bleiben und Threads nicht verwaisen. Die
// Linkvorschau hängt nur dran, wenn sie fertig ist (status ok).
const entrySelect = `
SELECT e.pid, e.uid, e.created_at, e.content, e.filepath,
e.reply_to, e.reply_count, e.last_activity, e.deleted,
e.bump_count, e.last_bump,
COALESCE(u.username, ''), COALESCE(u.avatar, '')
FROM entry e LEFT JOIN account u ON u.uid = e.uid`
COALESCE(u.username, ''), COALESCE(u.avatar, ''),
COALESCE(lp.url, ''), COALESCE(lp.kind, ''), COALESCE(lp.title, ''),
COALESCE(lp.site, ''), COALESCE(lp.thumb, '')
FROM entry e LEFT JOIN account u ON u.uid = e.uid
LEFT JOIN link_preview lp ON lp.url = e.link_url AND lp.status = 'ok'`
// scanEntry liest eine Zeile mit entrySelect-Spalten (aus *sql.Row oder *sql.Rows).
func scanEntry(row interface{ Scan(...any) error }) (Entry, error) {
var e Entry
var p Preview
err := row.Scan(&e.PID, &e.UID, &e.CreatedAt, &e.Content, &e.Filepath,
&e.ReplyTo, &e.ReplyCount, &e.LastActivity, &e.Deleted,
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar)
&e.BumpCount, &e.LastBump, &e.Username, &e.Avatar,
&p.URL, &p.Kind, &p.Title, &p.Site, &p.Thumb)
if p.URL != "" {
e.Preview = &p
}
return e, err
}
@@ -308,6 +319,7 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
}
now := time.Now().Unix()
linkURL := previewKey(content)
tx, err := db.Begin()
if err != nil {
@@ -320,10 +332,10 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
for try := 0; ; try++ {
pid = int64(rand.IntN(999999999999)) + 1
res, err := tx.Exec(
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
`INSERT INTO entry (pid, uid, created_at, content, filepath, reply_to, reply_count, last_activity, link_url)
VALUES ($1, $2, $3, $4, $5, $6, 0, $7, $8)
ON CONFLICT (pid) DO NOTHING`,
pid, uid, now, content, filepath, replyTo, now,
pid, uid, now, content, filepath, replyTo, now, linkURL,
)
if err != nil {
tx.Rollback()
@@ -362,6 +374,7 @@ func handleCreateEntry(w http.ResponseWriter, r *http.Request) error {
if err := tx.Commit(); err != nil {
return Internal(err)
}
requestPreview(linkURL)
writeJSON(w, http.StatusCreated, map[string]any{
"pid": pid,
@@ -389,9 +402,11 @@ func handleEditEntry(w http.ResponseWriter, r *http.Request) error {
return Invalid("entry.empty", "Inhalt darf nicht leer sein.").At("content")
}
if _, err := db.Exec(`UPDATE entry SET content = $1 WHERE pid = $2`, content, pid); err != nil {
linkURL := previewKey(content)
if _, err := db.Exec(`UPDATE entry SET content = $1, link_url = $2 WHERE pid = $3`, content, linkURL, pid); err != nil {
return Internal(err)
}
requestPreview(linkURL)
writeJSON(w, http.StatusOK, map[string]any{"pid": pid, "content": content})
return nil
@@ -429,7 +444,7 @@ func handleDeleteEntry(w http.ResponseWriter, r *http.Request) error {
}
if _, err := db.Exec(
`UPDATE entry SET deleted = 1, content = '', filepath = '', uid = 0 WHERE pid = $1`, pid,
`UPDATE entry SET deleted = 1, content = '', filepath = '', link_url = '', uid = 0 WHERE pid = $1`, pid,
); err != nil {
return Internal(err)
}