From 4c26eb2960026014808369b32e0e7c0fbc048043 Mon Sep 17 00:00:00 2001
From: Sandro
Date: Fri, 30 Oct 2026 00:06:05 +0100
Subject: [PATCH] feat(workspace): link previews above bullets with a web
address (M5)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- GET /link-preview?url= fetches the page on the server (https only,
SSRF-guarded like webhooks), reads Open Graph/Twitter tags, title,
description and icon from the head, loads picture and icon (no SVG) and
keeps them in link_previews (migration 6) for 7 days; readers' browsers
never contact the page. Off in the demo; 300 fetches per address in
15 minutes.
- Compact card above the bullet (icon, site, title, one line, small
picture); the duplicate link badge is left out.
- ✕ turns the address into [title](url), so hiding syncs with the note;
"Link-Vorschauen anzeigen" under appearance switches all off per device.
Co-Authored-By: Claude Opus 5.5
---
CHANGELOG.md | 1 +
Doku/api/rest.md | 2 +
Doku/architecture.md | 4 +
Doku/database.md | 1 +
.../features/settings/UserSettingsView.tsx | 15 +
.../features/workspace/LinkPreviewCard.tsx | 84 +++++
.../src/features/workspace/OutlinerBlock.tsx | 25 +-
.../src/lib/__tests__/linkPreview.test.ts | 21 ++
frontend/src/lib/linkPreview.ts | 85 +++++
frontend/src/lib/media.ts | 13 +-
internal/api/docsync.go | 1 +
internal/api/linkpreview.go | 294 ++++++++++++++++++
internal/api/linkpreview_test.go | 58 ++++
internal/api/server.go | 2 +
internal/storage/linkpreview.go | 87 ++++++
internal/storage/migrations.go | 1 +
16 files changed, 688 insertions(+), 6 deletions(-)
create mode 100644 frontend/src/features/workspace/LinkPreviewCard.tsx
create mode 100644 frontend/src/lib/__tests__/linkPreview.test.ts
create mode 100644 frontend/src/lib/linkPreview.ts
create mode 100644 internal/api/linkpreview.go
create mode 100644 internal/api/linkpreview_test.go
create mode 100644 internal/storage/linkpreview.go
diff --git a/CHANGELOG.md b/CHANGELOG.md
index bf2cbfc..91bf7d0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@ Das Format folgt [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), die V
- Sprachnotizen in Notizen: `/audio` in einem leeren Punkt zeigt eine Aufnahme-Blase – grüner Knopf startet, er wird rot und links laufen Live-Wellen mit der Zeit, ein Tipp speichert die Aufnahme als Audio-Player im Punkt. Alternativ eine Audiodatei hochladen. Mit eigener Spracherkennung macht „Transkribieren“ aus der Aufnahme die (durchsuchbare) Beschriftung.
- Diktat in Notizen: `/diktat` in einem leeren Punkt hört sofort zu; der Punkt wird zu einer Fläche mit Live-Wellen und „Stopp“, ein Tipp setzt den erkannten Text in den Punkt. Nutzt die eingestellte Spracherkennung (eigener Server oder Browser) und wird ohne Spracherkennung nicht angeboten.
- Backup und Export mit Medien: Das Backup-ZIP (auch die automatischen Server-Backups pro Konto) enthält die Bilder und Sprachnotizen der eigenen Notizen, das Markdown-Archiv legt sie unter `attachments/` ab und bettet sie Obsidian-kompatibel ein. Beim Zurückspielen kommen sie wieder als Bild- und Audioblöcke. Der Obsidian-Import übernimmt jetzt auch eingebettete Bilder und Audiodateien (`![[bild.png]]`, ``).
+- Link-Vorschau in Notizen: Über einem Punkt mit einer Web-Adresse (https) erscheint eine kompakte Karte mit Icon, Seitenname, Titel, einer Zeile Beschreibung und kleinem Bild. Der MindSift-Server ruft die Seite ab (wie bei Bildern nur öffentliche https-Adressen) und speichert die Vorschau 7 Tage; Browser der Leser kontaktieren die Seite nicht. ✕ blendet die Vorschau für diesen Link aus (er wird zu `[Titel](Adresse)`), Darstellung → „Link-Vorschauen anzeigen“ schaltet alle ab. In der Demo aus. API: `GET /link-preview?url=`.
- Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`.
- Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest.
diff --git a/Doku/api/rest.md b/Doku/api/rest.md
index d5d4cf7..1785ff9 100644
--- a/Doku/api/rest.md
+++ b/Doku/api/rest.md
@@ -20,6 +20,8 @@ Basis: `/api/v1`. Antworten sind JSON; Fehler haben die Form `{"error": "Text"}`
| POST | `/documents/{id}/media` | Bild oder Audio zu einer Notiz hochladen (`multipart/form-data`, Feld `file`; Schreibrecht nötig) → `{id, kind, mime, size, url}`. `415` falsches Format, `413` zu groß oder Kontingent voll |
| POST | `/documents/{id}/media/fetch` | Externes Bild kopieren: `{url}` (nur https, keine internen Adressen; nicht in der Demo) → wie oben |
| GET | `/media/{id}` | Datei abrufen (Leserecht auf eine Notiz, die sie zeigt), mit Range-Anfragen; sonst `404` |
+| GET | `/link-preview?url=https://…` | Vorschau einer Web-Seite (`title`, `description`, `siteName`, `hasImage`, `hasIcon`, `ok`, `key`), vom Server geholt und 7 Tage zwischengespeichert; `422` ohne https, `403` in der Demo, `429` zu viele Abrufe |
+| GET | `/link-preview/{key}/image` bzw. `/icon` | Bild bzw. Icon einer Vorschau |
| POST | `/voice/transcribe` | Nur im Server-Modus der Spracherkennung: `multipart/form-data` mit `file` (Aufnahme, z. B. WebM/Opus oder MP4/AAC) → `{text}`. `404` ohne `STT_URL`, `413` zu groß, `429` Stundenlimit, `503` ausgelastet, `502` Whisper-Fehler (Details im Log) |
| GET | `/ws`, `/ws/spaces/{id}`, `/ws/lists/{id}` | WebSocket, siehe [websocket.md](websocket.md); `?ticket=` oder `Authorization`-Header |
| GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
diff --git a/Doku/architecture.md b/Doku/architecture.md
index 87cb9cd..4cc550e 100644
--- a/Doku/architecture.md
+++ b/Doku/architecture.md
@@ -95,6 +95,10 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
Bilder und Audio liegen in `$DATA_DIR/media//.`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit ``; damit laufen Verweise ohne Änderung am Sync-Modell durch Synchronisation, Rückgängig und Konflikte. Hochladen (`POST /documents/{id}/media`) braucht Schreibrecht auf die Notiz, Lesen (`GET /media/{id}`) Leserecht auf die Notiz der Datei oder auf eine Notiz, deren Blöcke sie zeigen (kopierte Blöcke). Das Format wird am Inhalt erkannt (JPEG, PNG, GIF, WebP; WebM, Ogg, MP3, WAV, M4A), SVG und alles andere wird abgelehnt; Antworten tragen eine eigene CSP mit `sandbox`. Externe Bilder kopiert der Server einmal (`POST /documents/{id}/media/fetch`, nur https und öffentliche Adressen wie bei Webhooks, nicht in der Demo). Grenzen: `MEDIA_MAX_IMAGE_MB`, `MEDIA_MAX_AUDIO_MB`, `MEDIA_QUOTA_MB`, in der Demo `MEDIA_DEMO_MAX_MB`. Backup-ZIP (`media/`) und Markdown-Archiv (`attachments/`, Einbettung `` relativ zur Notiz) enthalten die Dateien der eigenen Notizen; beim Import bekommen sie neue IDs. Der Obsidian-Import übernimmt Bilder und Audio, die eine Notiz allein in einem Punkt einbettet (`![[datei.png]]` oder ``); andere Dateien erscheinen in der Vorschau als übersprungen. Die Verdichtung (alle 6 Stunden) löscht Dateien, die kein Block mehr erwähnt (auch keine gelöschten im Papierkorb oder Protokoll), frühestens einen Tag nach dem Hochladen. Der Demo-Reset leert `media/`.
+## Link-Vorschau
+
+Punkte mit einer reinen https-Adresse zeigen darüber eine Karte (`LinkPreviewCard`, `lib/linkPreview.ts`). `GET /link-preview?url=` holt die Seite serverseitig mit demselben Schutz wie Webhooks (nur https, keine internen Adressen, Weiterleitungen geprüft), liest `og:*`/`twitter:*`, ``, `description` und das Icon aus dem `` (höchstens 1 MB HTML, Bild 2 MB, Icon 100 KB, kein SVG) und speichert alles in `link_previews` (Migration 6): Erfolg 7 Tage, nichts Brauchbares 1 Tag, nach 30 Tagen gelöscht. Bilder kommen über `GET /link-preview/{key}/image|icon` mit Login. Pro Adresse höchstens 300 Abrufe in 15 Minuten (Antworten aus dem Cache zählen nicht). Ausblenden einer Karte macht aus der Adresse `[Titel](url)` (ein benannter Link hat keine Karte), damit synchronisiert die Wahl mit der Notiz; „Link-Vorschauen anzeigen“ unter Darstellung gilt pro Gerät. Im Demo-Modus aus.
+
## Spracherkennung
Nur über Umgebungsvariablen (`STT_*`, siehe oben), nicht in der Oberfläche; Administration → Server & Isolation zeigt den aktiven Modus, den Zustand des Whisper-Servers und bietet eine Testaufnahme. Alle Mikrofon-Knöpfe (Quick-Dump-Leiste, mobiles Erfassen-Sheet, Halten des +-Knopfs) nutzen denselben Hook `useVoiceInput` (`frontend/src/lib/voice.ts`, `useVoiceInput.ts`): `server` nimmt auf und zeigt „Wird erkannt…“, `browser` liefert Text schon beim Sprechen, `off` oder ein Browser ohne die nötige Funktion blendet das Mikrofon aus. Im Server-Modus zeigen Wellen den echten Mikrofonpegel (`VoiceWave`), die Aufnahme endet spätestens nach `STT_MAX_SECONDS`. Der Browser nimmt per `MediaRecorder` auf (WebM/Opus, in Safari MP4/AAC) und schickt die Aufnahme an `POST /voice/transcribe`; MindSift reicht sie an `STT_URL/v1/audio/transcriptions` weiter (`model`, `language`) und gibt nur den Text zurück. Nichts wird gespeichert oder protokolliert außer Fehlern (`internal/api/voice.go`). Beim Start wartet MindSift auf den Whisper-Server und stößt den Download des Modells an, falls es fehlt (`POST /v1/models/` bei speaches).
diff --git a/Doku/database.md b/Doku/database.md
index 9b05e51..b5ecbba 100644
--- a/Doku/database.md
+++ b/Doku/database.md
@@ -11,6 +11,7 @@ SQLite-Datei `mindsift.db` (Standard: `$DATA_DIR/mindsift.db`), geöffnet mit `j
| `users` | Konten | `email` (eindeutig), `username`, `password_hash`, `display_name`, `role` (`admin`/`member`), `is_active`, `token_version`, `alarm_channels` (kommagetrennt), `alarm_email`, `discord_webhook_url`, `custom_webhook_url`, `notify_before_minutes`, `avatar_url`, `organization_id` (leer = keine Organisation) |
| `user_invites` | Einladungscodes | `code`, `email`, `role`, `organization_id`, `expires_at`, `used_by` |
| `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media//.` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` |
+| `link_previews` | Zwischengespeicherte Link-Vorschauen (Migration 6) | `key` (Hash der URL), `url`, `title`, `description`, `site_name`, `image`/`icon` (BLOB, mit `*_mime`), `ok`, `fetched_at` |
| `sessions` | Angemeldete Geräte (Migration 4, ersetzt `revoked_tokens`) | `id` (= `jti` des Tokens), `user_id`, `created_at`, `last_used_at` (höchstens alle 5 Minuten aktualisiert), `user_agent`, `ip`, `revoked_at` (leer = aktiv) |
| `user_themes` | Eigene CSS-Themes eines Benutzers | `user_id` (FK, `ON DELETE CASCADE`), `name`, `css_content` |
| `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
diff --git a/frontend/src/features/settings/UserSettingsView.tsx b/frontend/src/features/settings/UserSettingsView.tsx
index ececeda..5ade494 100644
--- a/frontend/src/features/settings/UserSettingsView.tsx
+++ b/frontend/src/features/settings/UserSettingsView.tsx
@@ -30,6 +30,8 @@ import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard';
import { SessionsPanel } from './SessionsPanel';
+import { ToggleSwitch } from '../../components/ToggleSwitch';
+import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps {
@@ -99,6 +101,7 @@ export const UserSettingsView: React.FC = ({
const [showAbout, setShowAbout] = useState(false);
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
const [demoMode, setDemoMode] = useState(false);
+ const linkPreviewsOn = useLinkPreviewsEnabled();
useEffect(() => {
api
.getAuthStatus()
@@ -774,6 +777,18 @@ export const UserSettingsView: React.FC = ({
+
+
+
Link-Vorschauen anzeigen
+
+ Karte mit Titel, Beschreibung und kleinem Bild über Punkten mit einer Web-Adresse. Die Seite wird
+ dafür vom MindSift-Server abgerufen, nicht von deinem Browser. Gilt für dieses Gerät; einzelne
+ Vorschauen lassen sich mit ✕ ausblenden.
+