feat(workspace): link previews above bullets with a web address (M5)
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m52s
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:
1 parent
83408d120d
commit
4c26eb2960
16 files changed
+688
-6
No files matched your search
@@ -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]]`, ``).
|
- 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}`.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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 ``; 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 `` 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/`.
|
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 ``; 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 `` 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:*`, `<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).
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 & 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user