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
97 lines
2.8 KiB
Go
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
|
|
}
|