feat(workspace): link previews above bullets with a web address (M5)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s

- 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 <noreply@anthropic.com>
This commit is contained in:
sandrodachandClaude Opus 5.5 committed 2026-10-30 00:06:05 +01:00
1 parent 83408d120d
commit 4c26eb2960
16 files changed
+688 -6

No files matched your search

+1
View File
@@ -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. - 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. - 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]]`, `![](bild.png)`). - 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]]`, `![](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}`. - 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. - 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.
+2
View File
@@ -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` | 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 | | 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 | `/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) | | 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 | `/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 | | GET | `/events` | SSE-Fallback; `?ticket=` oder `Authorization`-Header |
+4
View File
@@ -95,6 +95,10 @@ Hinweis: `DATABASE_URL` mit `postgres…` führt dazu, dass der Standardpfad in
Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit `![Beschriftung](media:med_…)`; 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/<id><ext>`) und Markdown-Archiv (`attachments/`, Einbettung `![Beschriftung](../attachments/…)` 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 `![](pfad/datei.png)`); 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/`. Bilder und Audio liegen in `$DATA_DIR/media/<docId>/<id>.<ext>`, beschrieben in der Tabelle `media` (Migration 5, `internal/storage/media.go`). Ein Block verweist über seinen Inhalt darauf, z. B. Typ `image` mit `![Beschriftung](media:med_…)`; 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/<id><ext>`) und Markdown-Archiv (`attachments/`, Einbettung `![Beschriftung](../attachments/…)` 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 `![](pfad/datei.png)`); 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:*`, `<title>`, `description` und das Icon aus dem `<head>` (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 ## 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/<id>` bei speaches). 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/<id>` bei speaches).
+1
View File
@@ -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) | | `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` | | `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/<doc_id>/<id>.<ext>` | `id`, `doc_id`, `owner_id`, `kind` (`image`/`audio`), `mime`, `size`, `source_url` (kopiertes externes Bild), `created_at` | | `media` | Bilder und Audio in Notizen (Migration 5); Datei unter `$DATA_DIR/media/<doc_id>/<id>.<ext>` | `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) | | `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` | | `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` | | `api_keys` | Webhook-/API-Schlüssel | `key_prefix`, `key_hash` (SHA-256), `scopes` (nicht ausgewertet), `last_used_at` |
@@ -30,6 +30,8 @@ import { LegalLinks } from '../../components/LegalLinks';
import { AboutModal } from '../../components/AboutModal'; import { AboutModal } from '../../components/AboutModal';
import { ImportCard } from './ImportCard'; import { ImportCard } from './ImportCard';
import { SessionsPanel } from './SessionsPanel'; import { SessionsPanel } from './SessionsPanel';
import { ToggleSwitch } from '../../components/ToggleSwitch';
import { setLinkPreviewsEnabled, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { FormSheet } from '../../components/FormSheet'; import { FormSheet } from '../../components/FormSheet';
interface UserSettingsViewProps { interface UserSettingsViewProps {
@@ -99,6 +101,7 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
const [showAbout, setShowAbout] = useState(false); const [showAbout, setShowAbout] = useState(false);
// The public demo sends nothing and keeps the shared accounts intact (FLAW-33) // The public demo sends nothing and keeps the shared accounts intact (FLAW-33)
const [demoMode, setDemoMode] = useState(false); const [demoMode, setDemoMode] = useState(false);
const linkPreviewsOn = useLinkPreviewsEnabled();
useEffect(() => { useEffect(() => {
api api
.getAuthStatus() .getAuthStatus()
@@ -774,6 +777,18 @@ export const UserSettingsView: React.FC<UserSettingsViewProps> = ({
</p> </p>
</div> </div>
<div className="p-3.5 rounded-card border border-border bg-canvas flex items-center justify-between gap-4">
<div className="text-xs">
<p className="font-semibold text-main">Link-Vorschauen anzeigen</p>
<p className="text-[11px] text-faint">
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.
</p>
</div>
<ToggleSwitch checked={linkPreviewsOn} onChange={setLinkPreviewsEnabled} title="Link-Vorschauen anzeigen" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{themes.map((t) => { {themes.map((t) => {
const isSelected = activeTheme === t.id; const isSelected = activeTheme === t.id;
@@ -0,0 +1,84 @@
import React, { useEffect, useState } from 'react';
import { Globe, X } from 'lucide-react';
import { LinkPreview, loadLinkPreview } from '../../lib/linkPreview';
import { loadAuthorized } from '../../lib/media';
import { safeHref } from '../../lib/safeUrl';
interface LinkPreviewCardProps {
url: string;
canEdit: boolean;
onHide: (title: string) => void;
}
function usePicture(preview: LinkPreview | null, kind: 'image' | 'icon'): string | null {
const [src, setSrc] = useState<string | null>(null);
const has = preview ? (kind === 'image' ? preview.hasImage : preview.hasIcon) : false;
const key = preview?.key;
useEffect(() => {
if (!has || !key) return;
let alive = true;
loadAuthorized(`/api/v1/link-preview/${key}/${kind}`)
.then((p) => alive && setSrc(p.url))
.catch(() => {});
return () => {
alive = false;
};
}, [has, key, kind]);
return src;
}
// Compact card above a bullet with a web address: icon and site, title, one line of description and a
// small picture. Nothing is shown when the page offers nothing usable.
export const LinkPreviewCard: React.FC<LinkPreviewCardProps> = ({ url, canEdit, onHide }) => {
const [preview, setPreview] = useState<LinkPreview | null>(null);
const image = usePicture(preview, 'image');
const icon = usePicture(preview, 'icon');
const href = safeHref(url);
useEffect(() => {
let alive = true;
setPreview(null);
void loadLinkPreview(url).then((p) => alive && setPreview(p));
return () => {
alive = false;
};
}, [url]);
if (!preview || !href) return null;
return (
<div className="relative mb-1 max-w-xl">
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="flex items-stretch gap-3 rounded-card border border-border bg-panel hover:bg-panel-elevated transition-colors overflow-hidden no-underline"
>
<div className="min-w-0 flex-1 py-2 pl-3 pr-11 sm:pr-8 space-y-0.5">
<div className="flex items-center gap-1.5 text-[11px] text-faint min-w-0">
{icon ? (
<img src={icon} alt="" className="w-3.5 h-3.5 rounded-sm shrink-0" />
) : (
<Globe className="w-3.5 h-3.5 shrink-0" />
)}
<span className="truncate">{preview.siteName}</span>
</div>
{preview.title && <div className="text-sm font-semibold text-main line-clamp-2 leading-snug">{preview.title}</div>}
{preview.description && <div className="text-xs text-muted truncate">{preview.description}</div>}
</div>
{image && <img src={image} alt="" className="w-16 sm:w-20 object-cover shrink-0 border-l border-border" />}
</a>
{canEdit && (
<button
type="button"
onClick={() => onHide(preview.title)}
title="Vorschau für diesen Link ausblenden"
aria-label="Vorschau ausblenden"
className="absolute top-0 right-0 sm:top-1 sm:right-1 w-11 h-11 sm:w-7 sm:h-7 flex items-center justify-center rounded-full bg-panel/70 text-muted hover:text-main"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
);
};
@@ -33,6 +33,8 @@ import { AudioRecorderPicker } from './AudioRecorderPicker';
import { AudioBlock } from './AudioBlock'; import { AudioBlock } from './AudioBlock';
import { DictationBlock } from './DictationBlock'; import { DictationBlock } from './DictationBlock';
import { currentVoiceConfig } from '../../lib/voice'; import { currentVoiceConfig } from '../../lib/voice';
import { LinkPreviewCard } from './LinkPreviewCard';
import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview';
import { mediaContent, parseMediaContent } from '../../lib/media'; import { mediaContent, parseMediaContent } from '../../lib/media';
import { renderInlineMarkdown } from '../../lib/markdown'; import { renderInlineMarkdown } from '../../lib/markdown';
import { api } from '../../lib/api'; import { api } from '../../lib/api';
@@ -153,6 +155,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
const [slashSearch, setSlashSearch] = useState<string | null>(null); const [slashSearch, setSlashSearch] = useState<string | null>(null);
// Inline picker an empty bullet turns into after /image (local only, nothing synced until done) // Inline picker an empty bullet turns into after /image (local only, nothing synced until done)
const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null); const [mediaPicker, setMediaPicker] = useState<'image' | 'audio' | 'dictation' | null>(null);
// Card above bullets with a plain web address (lib/linkPreview.ts)
const previewsOn = useLinkPreviewsEnabled();
const previewUrl =
previewsOn && !mediaPicker && ['bullet', 'text', 'paragraph', 'todo', 'h1', 'h2', 'h3'].includes(block.type || 'bullet')
? firstBareUrl(localContent)
: null;
const [slashIndex, setSlashIndex] = useState(0); const [slashIndex, setSlashIndex] = useState(0);
const [showLinkModal, setShowLinkModal] = useState<boolean>(false); const [showLinkModal, setShowLinkModal] = useState<boolean>(false);
const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false); const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false);
@@ -749,7 +757,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
(t) => (t) =>
(t.startsWith('[[') && t.endsWith(']]')) || (t.startsWith('[[') && t.endsWith(']]')) ||
/^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) || /^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) ||
/^https?:\/\//.test(t) (/^https?:\/\//.test(t) && t !== previewUrl)
); );
return ( return (
@@ -851,6 +859,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
{/* Main Block Content */} {/* Main Block Content */}
<div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]"> <div className="flex-1 min-w-0 pr-1 relative min-h-[1.5rem]">
{previewUrl && (
<LinkPreviewCard
url={previewUrl}
canEdit={canEdit}
onHide={(title) => {
const content = hidePreviewContent(localContent, previewUrl, title);
setLocalContent(content);
localContentRef.current = content;
flushDebounce(content);
}}
/>
)}
{mediaPicker === 'image' ? ( {mediaPicker === 'image' ? (
<ImagePicker <ImagePicker
docId={docId} docId={docId}
@@ -1193,7 +1213,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({
</a> </a>
); );
} }
if (/^https?:\/\//.test(part) && safeHref(part)) { // The preview card above already shows this address
if (/^https?:\/\//.test(part) && safeHref(part) && part !== previewUrl) {
return ( return (
<a <a
key={idx} key={idx}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { firstBareUrl, hidePreviewContent } from '../linkPreview';
describe('link preview helpers', () => {
it('finds the first plain https address', () => {
expect(firstBareUrl('https://example.com/a?b=1')).toBe('https://example.com/a?b=1');
expect(firstBareUrl('Rezept: https://koch.de/spaetzle, lecker!')).toBe('https://koch.de/spaetzle');
expect(firstBareUrl('siehe (https://x.de/p).')).toBeNull(); // inside brackets: not a plain address
expect(firstBareUrl('[Kochseite](https://koch.de)')).toBeNull();
expect(firstBareUrl('http://unsicher.de')).toBeNull();
expect(firstBareUrl('nur Text')).toBeNull();
});
it('hides a preview by turning the address into a named link', () => {
expect(hidePreviewContent('Lesen: https://koch.de/x', 'https://koch.de/x', 'Käsespätzle [Rezept]')).toBe(
'Lesen: [Käsespätzle Rezept](https://koch.de/x)'
);
expect(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', '')).toBe('[koch.de](https://koch.de/x)');
expect(firstBareUrl(hidePreviewContent('https://koch.de/x', 'https://koch.de/x', 'T'))).toBeNull();
});
});
+85
View File
@@ -0,0 +1,85 @@
// Link previews in notes: a card above a bullet that contains a plain web address. The server fetches
// the page (https only) and keeps title, description, picture and icon; the browser only talks to
// MindSift. Hiding the card for one link turns the address into a named link [Titel](url), so the
// choice syncs with the note; a global switch (this device) turns all previews off.
import { useEffect, useState } from 'react';
import { getToken } from './api';
export interface LinkPreview {
key: string;
url: string;
title: string;
description: string;
siteName: string;
hasImage: boolean;
hasIcon: boolean;
ok: boolean;
}
// First plain https address in a text (not inside [text](url), not right after "(" or "<")
const BARE_URL_RE = /(^|[\s])(https:\/\/[^\s<>()\]]+[^\s<>()\].,;:!?'"])/;
export function firstBareUrl(content: string): string | null {
const m = BARE_URL_RE.exec(content || '');
return m ? m[2] : null;
}
/** Replaces the address by a named link, which shows no preview. */
export function hidePreviewContent(content: string, url: string, title: string): string {
const label = (title || new URL(url).hostname).replace(/[[\]]/g, '').trim() || url;
return content.replace(url, `[${label}](${url})`);
}
const cache = new Map<string, Promise<LinkPreview | null>>();
export function loadLinkPreview(url: string): Promise<LinkPreview | null> {
let p = cache.get(url);
if (!p) {
p = (async () => {
const token = getToken();
const res = await fetch(`/api/v1/link-preview?url=${encodeURIComponent(url)}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return null; // demo, http link, offline: just no card
const data = (await res.json()) as LinkPreview;
return data.ok ? data : null;
})().catch(() => null);
cache.set(url, p);
}
return p;
}
const SETTING_KEY = 'mindsift_link_previews';
const SETTING_EVENT = 'mindsift:link-previews';
export function linkPreviewsEnabled(): boolean {
try {
return localStorage.getItem(SETTING_KEY) !== 'off';
} catch {
return true;
}
}
export function setLinkPreviewsEnabled(on: boolean): void {
try {
localStorage.setItem(SETTING_KEY, on ? 'on' : 'off');
} catch {
// private mode: only for this page
}
window.dispatchEvent(new CustomEvent(SETTING_EVENT));
}
/** The global switch, kept in sync across all open notes and tabs. */
export function useLinkPreviewsEnabled(): boolean {
const [on, setOn] = useState(linkPreviewsEnabled);
useEffect(() => {
const update = () => setOn(linkPreviewsEnabled());
window.addEventListener(SETTING_EVENT, update);
window.addEventListener('storage', update);
return () => {
window.removeEventListener(SETTING_EVENT, update);
window.removeEventListener('storage', update);
};
}, []);
return on;
}
+9 -4
View File
@@ -27,19 +27,24 @@ export function mediaContent(kind: 'image' | 'audio', id: string, caption = ''):
const loaded = new Map<string, Promise<{ blob: Blob; url: string }>>(); const loaded = new Map<string, Promise<{ blob: Blob; url: string }>>();
function loadMedia(id: string): Promise<{ blob: Blob; url: string }> { function loadMedia(id: string): Promise<{ blob: Blob; url: string }> {
let p = loaded.get(id); return loadAuthorized(`/api/v1/media/${encodeURIComponent(id)}`);
}
/** Any picture of the API (needs the login, so no plain <img src>), as Blob plus blob: URL. */
export function loadAuthorized(path: string): Promise<{ blob: Blob; url: string }> {
let p = loaded.get(path);
if (!p) { if (!p) {
p = (async () => { p = (async () => {
const token = getToken(); const token = getToken();
const res = await fetch(`/api/v1/media/${encodeURIComponent(id)}`, { const res = await fetch(path, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`); if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`);
const blob = await res.blob(); const blob = await res.blob();
return { blob, url: URL.createObjectURL(blob) }; return { blob, url: URL.createObjectURL(blob) };
})(); })();
p.catch(() => loaded.delete(id)); // try again next time (e.g. was offline) p.catch(() => loaded.delete(path)); // try again next time (e.g. was offline)
loaded.set(id, p); loaded.set(path, p);
} }
return p; return p;
} }
+1
View File
@@ -271,6 +271,7 @@ func (s *Server) startCompaction() {
func (s *Server) compactOnce() { func (s *Server) compactOnce() {
s.leases.Sweep() s.leases.Sweep()
s.compactMedia(time.Now()) s.compactMedia(time.Now())
_ = s.store.PruneLinkPreviews(time.Now().Add(-30 * 24 * time.Hour))
st, err := s.store.CompactDocs(time.Now()) st, err := s.store.CompactDocs(time.Now())
if err != nil { if err != nil {
slog.Error("Verdichtung des Änderungsprotokolls fehlgeschlagen", "error", err) slog.Error("Verdichtung des Änderungsprotokolls fehlgeschlagen", "error", err)
+294
View File
@@ -0,0 +1,294 @@
package api
import (
"context"
"errors"
"html"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
"github.com/sandrodach/mindsift/internal/storage"
)
// Link previews for notes: the server fetches the page once (https only, never into its own network,
// like alarm webhooks), reads title, description, picture and icon, and keeps them. Readers' browsers
// only talk to MindSift. Off in the demo, which must not send anything out.
const (
previewTTL = 7 * 24 * time.Hour
previewFailTTL = 24 * time.Hour
previewMaxHTML = 1 << 20
previewMaxImage = 2 << 20
previewMaxIcon = 100 << 10
previewPerAddress = 300 // fetches per address in 15 minutes (cached answers do not count)
)
var previewLocks sync.Map // URL -> *sync.Mutex, one fetch per page at a time
var (
metaTagRe = regexp.MustCompile(`(?is)<meta\s[^>]*>`)
linkTagRe = regexp.MustCompile(`(?is)<link\s[^>]*>`)
titleTagRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
attrRe = regexp.MustCompile(`(?is)([a-z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))`)
headEndRe = regexp.MustCompile(`(?i)</head>|<body[\s>]`)
spacesRe = regexp.MustCompile(`\s+`)
)
func tagAttrs(tag string) map[string]string {
out := map[string]string{}
for _, m := range attrRe.FindAllStringSubmatch(tag, -1) {
v := m[3]
if v == "" {
v = m[4]
}
if v == "" {
v = m[5]
}
out[strings.ToLower(m[1])] = html.UnescapeString(v)
}
return out
}
func cleanText(s string, max int) string {
s = strings.TrimSpace(spacesRe.ReplaceAllString(html.UnescapeString(s), " "))
if utf8.RuneCountInString(s) > max {
r := []rune(s)
s = strings.TrimSpace(string(r[:max-1])) + "…"
}
return s
}
// latin1ToUTF8 for pages that still declare ISO-8859-1/windows-1252.
func latin1ToUTF8(b []byte) string {
r := make([]rune, len(b))
for i, c := range b {
r[i] = rune(c)
}
return string(r)
}
// pageMeta reads the head of an HTML page: Open Graph/Twitter tags, <title>, icon link.
type pageMeta struct {
title, description, siteName, image, icon string
}
func parsePageMeta(doc string) pageMeta {
if loc := headEndRe.FindStringIndex(doc); loc != nil {
doc = doc[:loc[0]]
}
var m pageMeta
for _, tag := range metaTagRe.FindAllString(doc, -1) {
a := tagAttrs(tag)
key := strings.ToLower(a["property"])
if key == "" {
key = strings.ToLower(a["name"])
}
v := a["content"]
switch key {
case "og:title", "twitter:title":
if m.title == "" {
m.title = v
}
case "og:description", "twitter:description", "description":
if m.description == "" {
m.description = v
}
case "og:site_name", "application-name":
if m.siteName == "" {
m.siteName = v
}
case "og:image", "og:image:url", "og:image:secure_url", "twitter:image":
if m.image == "" {
m.image = v
}
}
}
if m.title == "" {
if t := titleTagRe.FindStringSubmatch(doc); t != nil {
m.title = t[1]
}
}
for _, tag := range linkTagRe.FindAllString(doc, -1) {
a := tagAttrs(tag)
rel := " " + strings.ToLower(a["rel"]) + " "
if (strings.Contains(rel, " icon ") || strings.Contains(rel, "apple-touch-icon")) && a["href"] != "" && !strings.HasSuffix(strings.ToLower(a["href"]), ".svg") {
if m.icon == "" || strings.Contains(rel, "apple-touch-icon") {
m.icon = a["href"]
}
}
}
m.title, m.description, m.siteName = cleanText(m.title, 200), cleanText(m.description, 300), cleanText(m.siteName, 80)
return m
}
// fetchLimited GETs a public https URL and reads at most limit bytes.
func fetchLimited(ctx context.Context, raw string, limit int64, accept string) ([]byte, http.Header, error) {
if err := validateWebhookURL(raw, false, false); err != nil {
return nil, nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, nil, err
}
req.Header.Set("Accept", accept)
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MindSift link preview)")
req.Header.Set("Accept-Language", "de,en;q=0.8")
resp, err := guardedClient(false).Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, nil, errors.New(resp.Status)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
if err != nil {
return nil, nil, err
}
if int64(len(data)) > limit {
return nil, nil, errors.New("zu groß")
}
return data, resp.Header, nil
}
func resolveRef(base *url.URL, ref string) string {
u, err := base.Parse(strings.TrimSpace(ref))
if err != nil || (u.Scheme != "https" && u.Scheme != "http") {
return ""
}
if u.Scheme == "http" {
u.Scheme = "https" // only https is fetched; most sites answer there as well
}
return u.String()
}
// buildLinkPreview fetches the page and its pictures.
func buildLinkPreview(ctx context.Context, pageURL string) *storage.LinkPreview {
p := &storage.LinkPreview{URL: pageURL}
base, err := url.Parse(pageURL)
if err != nil {
return p
}
body, hdr, err := fetchLimited(ctx, pageURL, previewMaxHTML, "text/html,application/xhtml+xml")
if err != nil {
return p
}
ct := strings.ToLower(hdr.Get("Content-Type"))
if !strings.Contains(ct, "html") {
// A direct link to an image: the image itself is the preview
if _, kind, ok := storage.DetectMedia(body, ""); ok && kind == "image" && len(body) <= previewMaxImage {
p.Image, p.ImageMime, p.OK = body, http.DetectContentType(body), true
p.Title = cleanText(strings.TrimSuffix(base.Path[strings.LastIndex(base.Path, "/")+1:], "/"), 200)
p.SiteName = base.Hostname()
}
return p
}
text := string(body)
if strings.Contains(ct, "iso-8859-1") || strings.Contains(ct, "windows-1252") || !utf8.Valid(body) {
text = latin1ToUTF8(body)
}
meta := parsePageMeta(text)
p.Title, p.Description, p.SiteName = meta.title, meta.description, meta.siteName
if p.SiteName == "" {
p.SiteName = strings.TrimPrefix(base.Hostname(), "www.")
}
if img := resolveRef(base, meta.image); img != "" {
if data, _, err := fetchLimited(ctx, img, previewMaxImage, "image/*"); err == nil {
if mime, kind, ok := storage.DetectMedia(data, ""); ok && kind == "image" {
p.Image, p.ImageMime = data, mime
}
}
}
iconURL := resolveRef(base, meta.icon)
if iconURL == "" {
iconURL = resolveRef(base, "/favicon.ico")
}
if data, _, err := fetchLimited(ctx, iconURL, previewMaxIcon, "image/*"); err == nil {
if mime := http.DetectContentType(data); strings.HasPrefix(mime, "image/") && mime != "image/svg+xml" {
p.Icon, p.IconMime = data, mime
}
}
p.OK = p.Title != "" || p.Description != "" || len(p.Image) > 0
return p
}
func previewFresh(p *storage.LinkPreview, now time.Time) bool {
at, err := time.Parse(time.RFC3339, p.FetchedAt)
if err != nil {
return false
}
ttl := previewTTL
if !p.OK {
ttl = previewFailTTL
}
return now.Sub(at) < ttl
}
// handleLinkPreview: GET /link-preview?url=https://…
func (s *Server) handleLinkPreview(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimSpace(r.URL.Query().Get("url"))
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" || len(raw) > 2000 {
writeError(w, http.StatusUnprocessableEntity, "Vorschau nur für https-Links")
return
}
pageURL := u.String()
key := storage.LinkPreviewKey(pageURL)
if p, err := s.store.GetLinkPreview(key); err == nil && previewFresh(p, time.Now()) {
writeJSON(w, http.StatusOK, p)
return
}
lock, _ := previewLocks.LoadOrStore(pageURL, &sync.Mutex{})
mu := lock.(*sync.Mutex)
mu.Lock()
defer func() {
mu.Unlock()
previewLocks.Delete(pageURL)
}()
// Someone else may have fetched it while this request waited
if p, err := s.store.GetLinkPreview(key); err == nil && previewFresh(p, time.Now()) {
writeJSON(w, http.StatusOK, p)
return
}
if wait := s.loginGuard.throttle("preview", clientIPFrom(r), previewPerAddress); wait > 0 {
writeThrottled(w, wait)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
p := buildLinkPreview(ctx, pageURL)
if err := s.store.SaveLinkPreview(p); err != nil {
writeError(w, http.StatusInternalServerError, "Vorschau konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, p)
}
// handleLinkPreviewPicture: GET /link-preview/{key}/image or /icon
func (s *Server) handleLinkPreviewPicture(w http.ResponseWriter, r *http.Request) {
p, err := s.store.GetLinkPreview(chi.URLParam(r, "key"))
if err != nil {
writeError(w, http.StatusNotFound, "Kein Bild")
return
}
data, mime := p.Image, p.ImageMime
if chi.URLParam(r, "kind") == "icon" {
data, mime = p.Icon, p.IconMime
}
if len(data) == 0 {
writeError(w, http.StatusNotFound, "Kein Bild")
return
}
w.Header().Set("Content-Type", mime)
w.Header().Set("Cache-Control", "private, max-age=86400")
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox")
_, _ = w.Write(data)
}
+58
View File
@@ -0,0 +1,58 @@
package api
import (
"net/http"
"strings"
"testing"
"github.com/sandrodach/mindsift/internal/storage"
)
func TestParsePageMeta(t *testing.T) {
doc := `<!doctype html><html><head>
<meta charset="utf-8"><title>Fallback Titel</title>
<meta property="og:title" content="Rezept: Käsespätzle &amp; Salat">
<meta name="description" content="Schnell gemacht,
für 4 Personen.">
<meta property='og:image' content='/img/spaetzle.jpg'>
<meta property="og:site_name" content="Kochseite">
<link rel="icon" href="/favicon.png"><link rel="stylesheet" href="x.css">
</head><body><meta property="og:title" content="nicht im Kopf"></body></html>`
m := parsePageMeta(doc)
if m.title != "Rezept: Käsespätzle & Salat" || m.description != "Schnell gemacht, für 4 Personen." ||
m.siteName != "Kochseite" || m.image != "/img/spaetzle.jpg" || m.icon != "/favicon.png" {
t.Fatalf("unexpected meta: %+v", m)
}
if m := parsePageMeta(`<head><title> Nur Titel </title></head>`); m.title != "Nur Titel" {
t.Fatalf("title fallback: %+v", m)
}
if long := cleanText(strings.Repeat("a", 500), 200); len([]rune(long)) != 200 || !strings.HasSuffix(long, "…") {
t.Fatalf("long text not shortened: %d", len(long))
}
}
func TestLinkPreviewEndpoints(t *testing.T) {
srv, store, token := setupTestServer(t)
router := srv.Router()
if rec := sessionCall(router, "GET", "/api/v1/link-preview?url=http://example.com", token, "", ""); rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("http link: want 422, got %d", rec.Code)
}
// A cached preview is answered without fetching; its pictures are served with the login
p := &storage.LinkPreview{URL: "https://example.com/", Title: "Beispiel", OK: true, Icon: []byte("\x89PNG\r\n\x1a\n"), IconMime: "image/png"}
if err := store.SaveLinkPreview(p); err != nil {
t.Fatal(err)
}
rec := sessionCall(router, "GET", "/api/v1/link-preview?url=https://example.com/", token, "", "")
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"title":"Beispiel"`) || !strings.Contains(rec.Body.String(), `"hasIcon":true`) {
t.Fatalf("cached preview: %d %s", rec.Code, rec.Body.String())
}
if rec := sessionCall(router, "GET", "/api/v1/link-preview/"+p.Key+"/icon", token, "", ""); rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "image/png" {
t.Fatalf("icon: %d", rec.Code)
}
if rec := sessionCall(router, "GET", "/api/v1/link-preview/"+p.Key+"/image", token, "", ""); rec.Code != http.StatusNotFound {
t.Fatalf("missing image: want 404, got %d", rec.Code)
}
if rec := sessionCall(router, "GET", "/api/v1/link-preview/"+p.Key+"/icon", "", "", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("without login: want 401, got %d", rec.Code)
}
}
+2
View File
@@ -135,6 +135,8 @@ func (s *Server) Router() http.Handler {
r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia) r.Post("/api/v1/documents/{id}/media", s.handleUploadMedia)
r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia)) r.Post("/api/v1/documents/{id}/media/fetch", s.notInDemo(s.handleFetchMedia))
r.Get("/api/v1/media/{id}", s.handleGetMedia) r.Get("/api/v1/media/{id}", s.handleGetMedia)
r.Get("/api/v1/link-preview", s.notInDemo(s.handleLinkPreview))
r.Get("/api/v1/link-preview/{key}/{kind}", s.handleLinkPreviewPicture)
r.Get("/api/v1/auth/sessions", s.handleListSessions) r.Get("/api/v1/auth/sessions", s.handleListSessions)
r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession)) r.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession))
r.Post("/api/v1/auth/logout", s.handleLogout) r.Post("/api/v1/auth/logout", s.handleLogout)
+87
View File
@@ -0,0 +1,87 @@
package storage
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"time"
)
// Link previews: title, description, a small picture and the site icon of a web page, fetched once by
// the server and kept for a while, so readers' browsers never contact the page themselves.
type LinkPreview struct {
Key string `json:"key"`
URL string `json:"url"`
Title string `json:"title"`
Description string `json:"description"`
SiteName string `json:"siteName"`
HasImage bool `json:"hasImage"`
HasIcon bool `json:"hasIcon"`
OK bool `json:"ok"` // false: the page gave nothing usable (kept shorter, then tried again)
FetchedAt string `json:"fetchedAt"`
Image, Icon []byte `json:"-"`
ImageMime, IconMime string `json:"-"`
}
// LinkPreviewKey identifies the preview of a URL (also used in the picture URLs).
func LinkPreviewKey(url string) string {
sum := sha256.Sum256([]byte(url))
return hex.EncodeToString(sum[:12])
}
func (s *Store) migrateLinkPreviews() error {
return s.execInTx(stmt(`CREATE TABLE IF NOT EXISTS link_previews (
key TEXT PRIMARY KEY,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
site_name TEXT NOT NULL DEFAULT '',
image BLOB,
image_mime TEXT NOT NULL DEFAULT '',
icon BLOB,
icon_mime TEXT NOT NULL DEFAULT '',
ok INTEGER NOT NULL DEFAULT 0,
fetched_at TEXT NOT NULL
)`))
}
var ErrLinkPreviewNotFound = errors.New("Keine Vorschau vorhanden")
func (s *Store) GetLinkPreview(key string) (*LinkPreview, error) {
var p LinkPreview
var ok int
err := s.db.QueryRow(`SELECT key, url, title, description, site_name, image, image_mime, icon, icon_mime, ok, fetched_at
FROM link_previews WHERE key = ?`, key).
Scan(&p.Key, &p.URL, &p.Title, &p.Description, &p.SiteName, &p.Image, &p.ImageMime, &p.Icon, &p.IconMime, &ok, &p.FetchedAt)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrLinkPreviewNotFound
}
if err != nil {
return nil, err
}
p.OK = ok == 1
p.HasImage, p.HasIcon = len(p.Image) > 0, len(p.Icon) > 0
return &p, nil
}
func (s *Store) SaveLinkPreview(p *LinkPreview) error {
p.Key = LinkPreviewKey(p.URL)
p.FetchedAt = time.Now().UTC().Format(time.RFC3339)
_, err := s.db.Exec(`INSERT INTO link_previews (key, url, title, description, site_name, image, image_mime, icon, icon_mime, ok, fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET url = excluded.url, title = excluded.title, description = excluded.description,
site_name = excluded.site_name, image = excluded.image, image_mime = excluded.image_mime, icon = excluded.icon,
icon_mime = excluded.icon_mime, ok = excluded.ok, fetched_at = excluded.fetched_at`,
p.Key, p.URL, p.Title, p.Description, p.SiteName, p.Image, p.ImageMime, p.Icon, p.IconMime, boolInt(p.OK), p.FetchedAt)
p.HasImage, p.HasIcon = len(p.Image) > 0, len(p.Icon) > 0
return err
}
// PruneLinkPreviews forgets previews older than the given time (they are fetched again when needed).
func (s *Store) PruneLinkPreviews(olderThan time.Time) error {
_, err := s.db.Exec(`DELETE FROM link_previews WHERE fetched_at < ?`, olderThan.UTC().Format(time.RFC3339))
return err
}
+1
View File
@@ -26,6 +26,7 @@ var migrations = []migration{
{3, "Basis-Schema v0.4.1", (*Store).migrateBaseline}, {3, "Basis-Schema v0.4.1", (*Store).migrateBaseline},
{4, "Sitzungen", (*Store).migrateSessions}, {4, "Sitzungen", (*Store).migrateSessions},
{5, "Medien", (*Store).migrateMedia}, {5, "Medien", (*Store).migrateMedia},
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
} }
func (s *Store) migrate() error { func (s *Store) migrate() error {