Files
irrlichtandClaude Opus 5.5 26b0182391 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
2026-10-06 18:47:21 +02:00

97 lines
2.8 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"os"
"strings"
)
// runCommand führt einen Admin-Unterbefehl aus (kver <befehl> ...). Im
// Container: podman exec kver kver <befehl> ...
func runCommand(args []string) error {
switch args[0] {
case "reset-link":
if len(args) != 2 {
return errors.New("Verwendung: kver reset-link <nutzername>")
}
return cmdResetLink(args[1])
case "link-previews":
if len(args) != 1 {
return errors.New("Verwendung: kver link-previews")
}
return cmdLinkPreviews()
default:
return fmt.Errorf("unbekannter Befehl %q (bekannt: reset-link, link-previews)", args[0])
}
}
// cmdResetLink erzeugt einen Passwort-Reset-Link (siehe reset.go) und gibt ihn
// aus. KVER_BASE_URL (z. B. https://example.org) macht daraus einen fertigen
// Link; ohne steht nur der Pfad da.
func cmdResetLink(username string) error {
username = sanitizeUsername(username)
token, expires, err := createResetToken(username)
if errors.Is(err, errUserNotFound) {
return fmt.Errorf("Nutzer %q nicht gefunden", username)
} else if err != nil {
return err
}
base := strings.TrimSuffix(os.Getenv("KVER_BASE_URL"), "/")
fmt.Printf("Reset-Link für %s (gültig bis %s, einmalig):\n\n %s/reset#%s\n\n",
username, expires.Format("02.01.2006 15:04 MST"), base, token)
fmt.Println("Ältere Reset-Links dieses Nutzers sind damit ungültig.")
return nil
}
// cmdLinkPreviews trägt bei bestehenden Beiträgen den ersten Link nach
// (entry.link_url) und holt alle offenen Vorschauen nacheinander. Läuft der
// Server parallel, holt er höchstens dieselben Links noch einmal -- harmlos.
func cmdLinkPreviews() error {
rows, err := db.Query(`SELECT pid, content FROM entry WHERE deleted = 0 AND link_url = '' AND content LIKE '%http%'`)
if err != nil {
return err
}
links := map[int64]string{}
for rows.Next() {
var pid int64
var content string
if err := rows.Scan(&pid, &content); err != nil {
rows.Close()
return err
}
if key := previewKey(content); key != "" {
links[pid] = key
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
for pid, key := range links {
if _, err := db.Exec(`UPDATE entry SET link_url = $1 WHERE pid = $2`, key, pid); err != nil {
return err
}
if _, err := db.Exec(`INSERT INTO link_preview (url) VALUES ($1) ON CONFLICT DO NOTHING`, key); err != nil {
return err
}
}
fmt.Printf("%d Beiträge mit Link nachgetragen.\n", len(links))
pending := pendingPreviews(100000)
for i, key := range pending {
fmt.Printf("[%d/%d] %s\n", i+1, len(pending), key)
fetchPreview(context.Background(), key)
}
var ok, none int
if err := db.QueryRow(
`SELECT COUNT(*) FILTER (WHERE status = 'ok'), COUNT(*) FILTER (WHERE status = 'none') FROM link_preview`).
Scan(&ok, &none); err != nil {
return err
}
fmt.Printf("Vorschauen: %d fertig, %d ohne Vorschau.\n", ok, none)
return nil
}