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]]`, `![](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 `![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/`) 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:*`, ``, `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 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). 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/<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) | | `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<UserSettingsViewProps> = ({ 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<UserSettingsViewProps> = ({ </p> </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"> {themes.map((t) => { const isSelected = activeTheme === t.id; diff --git a/frontend/src/features/workspace/LinkPreviewCard.tsx b/frontend/src/features/workspace/LinkPreviewCard.tsx new file mode 100644 index 0000000..654d693 --- /dev/null +++ b/frontend/src/features/workspace/LinkPreviewCard.tsx @@ -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> + ); +}; diff --git a/frontend/src/features/workspace/OutlinerBlock.tsx b/frontend/src/features/workspace/OutlinerBlock.tsx index f2cf7d6..2b2ad88 100644 --- a/frontend/src/features/workspace/OutlinerBlock.tsx +++ b/frontend/src/features/workspace/OutlinerBlock.tsx @@ -33,6 +33,8 @@ import { AudioRecorderPicker } from './AudioRecorderPicker'; import { AudioBlock } from './AudioBlock'; import { DictationBlock } from './DictationBlock'; import { currentVoiceConfig } from '../../lib/voice'; +import { LinkPreviewCard } from './LinkPreviewCard'; +import { firstBareUrl, hidePreviewContent, useLinkPreviewsEnabled } from '../../lib/linkPreview'; import { mediaContent, parseMediaContent } from '../../lib/media'; import { renderInlineMarkdown } from '../../lib/markdown'; import { api } from '../../lib/api'; @@ -153,6 +155,12 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({ const [slashSearch, setSlashSearch] = useState<string | null>(null); // Inline picker an empty bullet turns into after /image (local only, nothing synced until done) 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 [showLinkModal, setShowLinkModal] = useState<boolean>(false); const [showTodoMenu, setShowTodoMenu] = useState<boolean>(false); @@ -749,7 +757,7 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({ (t) => (t.startsWith('[[') && t.endsWith(']]')) || /^\[(.*?)\]\((https?:\/\/.*?)\)$/.test(t) || - /^https?:\/\//.test(t) + (/^https?:\/\//.test(t) && t !== previewUrl) ); return ( @@ -851,6 +859,18 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({ {/* Main Block Content */} <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' ? ( <ImagePicker docId={docId} @@ -1193,7 +1213,8 @@ export const OutlinerBlock: React.FC<OutlinerBlockProps> = ({ </a> ); } - if (/^https?:\/\//.test(part) && safeHref(part)) { + // The preview card above already shows this address + if (/^https?:\/\//.test(part) && safeHref(part) && part !== previewUrl) { return ( <a key={idx} diff --git a/frontend/src/lib/__tests__/linkPreview.test.ts b/frontend/src/lib/__tests__/linkPreview.test.ts new file mode 100644 index 0000000..b04208a --- /dev/null +++ b/frontend/src/lib/__tests__/linkPreview.test.ts @@ -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(); + }); +}); diff --git a/frontend/src/lib/linkPreview.ts b/frontend/src/lib/linkPreview.ts new file mode 100644 index 0000000..64f9e15 --- /dev/null +++ b/frontend/src/lib/linkPreview.ts @@ -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; +} diff --git a/frontend/src/lib/media.ts b/frontend/src/lib/media.ts index 750e638..81e8267 100644 --- a/frontend/src/lib/media.ts +++ b/frontend/src/lib/media.ts @@ -27,19 +27,24 @@ export function mediaContent(kind: 'image' | 'audio', id: string, caption = ''): const loaded = new Map<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) { p = (async () => { const token = getToken(); - const res = await fetch(`/api/v1/media/${encodeURIComponent(id)}`, { + const res = await fetch(path, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); if (!res.ok) throw new Error(res.status === 404 ? 'Datei nicht gefunden' : `HTTP ${res.status}`); const blob = await res.blob(); return { blob, url: URL.createObjectURL(blob) }; })(); - p.catch(() => loaded.delete(id)); // try again next time (e.g. was offline) - loaded.set(id, p); + p.catch(() => loaded.delete(path)); // try again next time (e.g. was offline) + loaded.set(path, p); } return p; } diff --git a/internal/api/docsync.go b/internal/api/docsync.go index ff385b1..6c45c34 100644 --- a/internal/api/docsync.go +++ b/internal/api/docsync.go @@ -271,6 +271,7 @@ func (s *Server) startCompaction() { func (s *Server) compactOnce() { s.leases.Sweep() s.compactMedia(time.Now()) + _ = s.store.PruneLinkPreviews(time.Now().Add(-30 * 24 * time.Hour)) st, err := s.store.CompactDocs(time.Now()) if err != nil { slog.Error("Verdichtung des Änderungsprotokolls fehlgeschlagen", "error", err) diff --git a/internal/api/linkpreview.go b/internal/api/linkpreview.go new file mode 100644 index 0000000..6a08ad2 --- /dev/null +++ b/internal/api/linkpreview.go @@ -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[^>]*>(.*?)`) + attrRe = regexp.MustCompile(`(?is)([a-z:_-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))`) + headEndRe = regexp.MustCompile(`(?i)|]`) + 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, , 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) +} diff --git a/internal/api/linkpreview_test.go b/internal/api/linkpreview_test.go new file mode 100644 index 0000000..002a310 --- /dev/null +++ b/internal/api/linkpreview_test.go @@ -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 + + + + + + ` + 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(` Nur Titel `); 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) + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 66e6df6..4bd1d36 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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/fetch", s.notInDemo(s.handleFetchMedia)) 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.Delete("/api/v1/auth/sessions/{id}", s.notInDemo(s.handleEndSession)) r.Post("/api/v1/auth/logout", s.handleLogout) diff --git a/internal/storage/linkpreview.go b/internal/storage/linkpreview.go new file mode 100644 index 0000000..7c5671b --- /dev/null +++ b/internal/storage/linkpreview.go @@ -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 +} diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go index 461c113..7da8890 100644 --- a/internal/storage/migrations.go +++ b/internal/storage/migrations.go @@ -26,6 +26,7 @@ var migrations = []migration{ {3, "Basis-Schema v0.4.1", (*Store).migrateBaseline}, {4, "Sitzungen", (*Store).migrateSessions}, {5, "Medien", (*Store).migrateMedia}, + {6, "Link-Vorschauen", (*Store).migrateLinkPreviews}, } func (s *Store) migrate() error {