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
+49
View File
@@ -204,6 +204,55 @@ a.button:hover, button:hover {
max-width: 100%;
}
/* Linkvorschau: abgesetzte Karte unter dem Text, kleines Vorschaubild links,
Titel und Seiten- bzw. Kanalname daneben. Rahmen wie bei den Buttons,
weiß auch beim Card-Hover. */
.link-preview {
display: flex;
align-items: center;
border: 1px solid black;
background-color: white;
margin: 0 0 0.5rem;
text-decoration: none;
color: black;
}
.link-preview:hover .link-preview-title {
text-decoration: underline;
}
.card .link-preview img {
flex: 0 0 auto;
align-self: stretch;
width: 160px;
max-width: 40%;
aspect-ratio: 16 / 9;
object-fit: cover;
margin: 0;
border-right: 1px solid black;
}
.link-preview-text {
min-width: 0;
padding: 0.3rem 0.5rem;
}
.link-preview-title,
.link-preview-site {
display: block;
overflow-wrap: break-word;
}
.link-preview-title {
font-size: 1.1rem;
line-height: 1.3;
}
.link-preview-site {
font-size: 0.9rem;
color: gray;
}
/* --- Avatare --- */
img.avatar {
display: inline-block;
+17 -1
View File
@@ -1,6 +1,6 @@
import type { MouseEvent } from "react";
import { Link, useNavigate } from "react-router";
import type { Entry } from "../types";
import type { Entry, LinkPreview } from "../types";
import { avatarUrl, formatTime, linkify } from "../format";
import { Votes } from "./Votes";
import { useBump } from "./Bump";
@@ -36,6 +36,21 @@ function DeletedCard({ entry: e }: { entry: Entry }) {
);
}
// LinkPreviewCard zeigt die Vorschau des ersten Links als abgesetzte Karte:
// kleines Bild links, Titel und Seite daneben.
// Alles darin kommt von unserem Server; erst der Klick führt zur fremden Seite.
function LinkPreviewCard({ preview: p }: { preview: LinkPreview }) {
return (
<a className="link-preview" href={p.url} target="_blank" rel="noopener noreferrer">
{p.thumb && <img src={`/${p.thumb}`} alt="" loading="lazy" />}
<span className="link-preview-text">
<span className="link-preview-title">{p.title}</span>
{p.site && <span className="link-preview-site">{p.site}</span>}
</span>
</a>
);
}
function LiveCard({ entry: e }: { entry: Entry }) {
const open = useOpenOnClick(e.pid);
const bump = useBump(e);
@@ -60,6 +75,7 @@ function LiveCard({ entry: e }: { entry: Entry }) {
<div className="body">
{report.form}
<div className="content">{linkify(e.content)}</div>
{e.preview && <LinkPreviewCard preview={e.preview} />}
{e.filepath && <img src={`/${e.filepath}`} alt="" loading="lazy" />}
<div className="interactions">
<Link className="button reply" to={`/e/${e.pid}`}>Antworten ({e.reply_count})</Link>
+11
View File
@@ -15,11 +15,22 @@ export interface Entry {
last_bump: number;
username: string;
avatar: string;
// Vorschau des ersten Links, sobald der Server sie geholt hat.
preview?: LinkPreview;
// Nur in Feed und Antwortliste: jüngster Beitrag im Teilbaum, skipped =
// Beiträge auf dem Pfad dazwischen (0 = direkte Antwort).
latest?: { entry: Entry; skipped: number };
}
// Vom Server geholte Linkvorschau; thumb liegt lokal (leer = kein Bild).
export interface LinkPreview {
url: string;
kind: "youtube" | "page";
title: string;
site: string;
thumb: string;
}
export interface Thread {
entry: Entry;
ancestors: Entry[];
+1
View File
@@ -119,6 +119,7 @@ E-Mail: <a>stress@bnd.wtf</a>
<li>Optional: Hochgeladenes Bild (JPG, PNG, GIF; max. 1024x1024 Pixel)</li>
</ul>
<em>Sichtbarkeit:</em> Beiträge und Benutzername sind öffentlich einsehbar<br>
<em>Linkvorschau:</em> Enthält ein Beitrag einen Link, ruft unser Server von der verlinkten Seite Titel, Seiten- bzw. Kanalname und Vorschaubild ab (bei YouTube über den öffentlichen oEmbed-Dienst von YouTube) und speichert sie auf unserem Server. Die Vorschau wird Ihnen ausschließlich von unserem Server ausgeliefert; beim Anzeigen werden keine Daten an die verlinkte Seite oder an YouTube übermittelt. Erst wenn Sie die Vorschau oder den Link anklicken, rufen Sie die fremde Seite selbst auf.<br>
<em>Zweck:</em> Bereitstellung der Forumsfunktion<br>
<em>Rechtsgrundlage:</em> Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO)<br>
<em>Speicherdauer:</em> Bis zur Löschung durch den Nutzer