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.
|
||||
- Diktat in Notizen: `/diktat` in einem leeren Punkt hört sofort zu; der Punkt wird zu einer Fläche mit Live-Wellen und „Stopp“, ein Tipp setzt den erkannten Text in den Punkt. Nutzt die eingestellte Spracherkennung (eigener Server oder Browser) und wird ohne Spracherkennung nicht angeboten.
|
||||
- Backup und Export mit Medien: Das Backup-ZIP (auch die automatischen Server-Backups pro Konto) enthält die Bilder und Sprachnotizen der eigenen Notizen, das Markdown-Archiv legt sie unter `attachments/` ab und bettet sie Obsidian-kompatibel ein. Beim Zurückspielen kommen sie wieder als Bild- und Audioblöcke. Der Obsidian-Import übernimmt jetzt auch eingebettete Bilder und Audiodateien (`![[bild.png]]`, ``).
|
||||
- Link-Vorschau in Notizen: Über einem Punkt mit einer Web-Adresse (https) erscheint eine kompakte Karte mit Icon, Seitenname, Titel, einer Zeile Beschreibung und kleinem Bild. Der MindSift-Server ruft die Seite ab (wie bei Bildern nur öffentliche https-Adressen) und speichert die Vorschau 7 Tage; Browser der Leser kontaktieren die Seite nicht. ✕ blendet die Vorschau für diesen Link aus (er wird zu `[Titel](Adresse)`), Darstellung → „Link-Vorschauen anzeigen“ schaltet alle ab. In der Demo aus. API: `GET /link-preview?url=`.
|
||||
- Medienablage für Notizen (Grundlage für Bilder und Audio): Hochladen, Kopieren externer Bilder, Abruf nur mit Leserecht; Grenzen per `MEDIA_*`; nicht mehr verwendete Dateien werden automatisch entfernt. API: `POST /documents/{id}/media`, `POST /documents/{id}/media/fetch`, `GET /media/{id}`.
|
||||
- Code-Blöcke in Notizen: Sprachauswahl (rund 50 Sprachen, bei Bedarf nachgeladen), Farben beim Tippen im Stil von VS Code Dark+/Light+ für alle Themes, „Kopieren“, Tab/Shift+Tab rückt ein und aus. `/code yaml` oder ```` ```yaml ```` legt die Sprache direkt fest.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
## 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
|
||||
|
||||
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) |
|
||||
| `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` |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 { 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}
|
||||
|
||||
@@ -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 }>>();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/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)
|
||||
|
||||
@@ -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},
|
||||
{4, "Sitzungen", (*Store).migrateSessions},
|
||||
{5, "Medien", (*Store).migrateMedia},
|
||||
{6, "Link-Vorschauen", (*Store).migrateLinkPreviews},
|
||||
}
|
||||
|
||||
func (s *Store) migrate() error {
|
||||
|
||||
Reference in new issue
Block a user