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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user