From 6cb776ccb110d31584cdaefc1215c2946163fcda Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 5 Feb 2026 08:27:17 +0100 Subject: [PATCH] fix lefotver opengraph data stores. more efficient caching --- README.md | 6 +- public/healthz.json | 4 +- .../components/content/OpenGraphCard.svelte | 131 ----------- .../components/write/CreateEventForm.svelte | 67 +++--- src/lib/modules/comments/CommentForm.svelte | 46 ++-- src/lib/services/cache/draft-store.ts | 93 ++++++++ src/lib/services/cache/indexeddb-store.ts | 33 ++- src/lib/services/content/opengraph-fetcher.ts | 217 ------------------ 8 files changed, 181 insertions(+), 416 deletions(-) delete mode 100644 src/lib/components/content/OpenGraphCard.svelte create mode 100644 src/lib/services/cache/draft-store.ts delete mode 100644 src/lib/services/content/opengraph-fetcher.ts diff --git a/README.md b/README.md index 8a0d103..e1ea4ab 100644 --- a/README.md +++ b/README.md @@ -494,19 +494,19 @@ aitherboard/ | Method | Implementation | Key Storage | |--------|----------------|------------| | **NIP-07** | Browser extension (Alby, nos2x, etc.) | No storage (extension manages) | -| **Nsec** | Direct bech32 nsec or hex private key, stored in the in-browser cache | **REQUIRED**: NIP-49 encrypted in localStorage | +| **Nsec** | Direct bech32 nsec or hex private key, stored in the in-browser cache | **REQUIRED**: NIP-49 encrypted in IndexedDB | | **NIP-46 Bunker** | Remote signer via `bunker://` URI | No local storage | | **Anonymous** | Generated on the fly when publishing | **REQUIRED**: NIP-49 encrypted in IndexedDB | ### Key Storage & Encryption **CRITICAL**: NO SECRET KEYS STORED ON THE SERVER -- All keys stored client-side only (IndexedDB/localStorage) +- All keys stored client-side only in IndexedDB - Server only serves static files - All key management in browser - **REQUIRED**: All nsec keys (including anonymous) MUST be encrypted with NIP-49 (password-based) before storage - Store as ncryptsec format (never plaintext nsec) -- Anonymous keys persist in IndexedDB across sessions +- All encrypted keys persist in IndexedDB across sessions - Users can provide their own anonymous key (must be encrypted) ### Anonymous User Behavior diff --git a/public/healthz.json b/public/healthz.json index 95ff5d1..f87be17 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-05T06:58:07.669Z", + "buildTime": "2026-02-05T07:24:19.725Z", "gitCommit": "unknown", - "timestamp": 1770274687669 + "timestamp": 1770276259725 } \ No newline at end of file diff --git a/src/lib/components/content/OpenGraphCard.svelte b/src/lib/components/content/OpenGraphCard.svelte deleted file mode 100644 index b6290cb..0000000 --- a/src/lib/components/content/OpenGraphCard.svelte +++ /dev/null @@ -1,131 +0,0 @@ - - - - {#if data.image} -
- {data.title -
- {/if} -
- {#if data.siteName} -
{data.siteName}
- {/if} - {#if data.title} -
{data.title}
- {/if} - {#if data.description} -
{data.description}
- {/if} -
-
- - diff --git a/src/lib/components/write/CreateEventForm.svelte b/src/lib/components/write/CreateEventForm.svelte index 4b32f9c..9de8725 100644 --- a/src/lib/components/write/CreateEventForm.svelte +++ b/src/lib/components/write/CreateEventForm.svelte @@ -4,6 +4,7 @@ import { uploadFileToServer, buildImetaTag } from '../../services/nostr/file-upload.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; import { cacheEvent } from '../../services/cache/event-cache.js'; + import { getDraft, saveDraft, deleteDraft } from '../../services/cache/draft-store.js'; import PublicationStatusModal from '../modals/PublicationStatusModal.svelte'; import MarkdownRenderer from '../content/MarkdownRenderer.svelte'; import MediaAttachments from '../content/MediaAttachments.svelte'; @@ -42,7 +43,7 @@ let { initialKind = null, initialContent: propInitialContent = null, initialTags: propInitialTags = null }: Props = $props(); - const STORAGE_KEY = 'aitherboard_writeForm_draft'; + const DRAFT_ID = 'write'; let selectedKind = $state(1); let customKindId = $state(''); @@ -50,51 +51,51 @@ let tags = $state([]); let publishing = $state(false); - // Restore draft from localStorage on mount (only if no initial props) + // Restore draft from IndexedDB on mount (only if no initial props) $effect(() => { if (typeof window === 'undefined') return; // Only restore if no initial content/tags were provided (from highlight feature) if (propInitialContent === null && propInitialTags === null) { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - const draft = JSON.parse(saved); - if (draft.content !== undefined && content === '') { - content = draft.content; - } - if (draft.tags && draft.tags.length > 0 && tags.length === 0) { - tags = draft.tags; - } - if (draft.selectedKind !== undefined && initialKind === null) { - selectedKind = draft.selectedKind; + (async () => { + try { + const draft = await getDraft(DRAFT_ID); + if (draft) { + if (draft.content !== undefined && content === '') { + content = draft.content; + } + if (draft.tags && draft.tags.length > 0 && tags.length === 0) { + tags = draft.tags; + } + if (draft.selectedKind !== undefined && initialKind === null) { + selectedKind = draft.selectedKind; + } } + } catch (error) { + console.error('Error restoring draft:', error); } - } catch (error) { - console.error('Error restoring draft:', error); - } + })(); } }); - // Save draft to localStorage when content or tags change + // Save draft to IndexedDB when content or tags change $effect(() => { if (typeof window === 'undefined') return; if (publishing) return; // Don't save while publishing - // Debounce saves to avoid excessive localStorage writes - const timeoutId = setTimeout(() => { + // Debounce saves to avoid excessive IndexedDB writes + const timeoutId = setTimeout(async () => { try { - const draft = { - content, - tags, - selectedKind - }; // Only save if there's actual content if (content.trim() || tags.length > 0) { - localStorage.setItem(STORAGE_KEY, JSON.stringify(draft)); + await saveDraft(DRAFT_ID, { + content, + tags, + selectedKind + }); } else { // Clear if empty - localStorage.removeItem(STORAGE_KEY); + await deleteDraft(DRAFT_ID); } } catch (error) { console.error('Error saving draft:', error); @@ -681,10 +682,8 @@ content = ''; tags = []; uploadedFiles = []; // Clear uploaded files after successful publish - // Clear draft from localStorage after successful publish - if (typeof window !== 'undefined') { - localStorage.removeItem(STORAGE_KEY); - } + // Clear draft from IndexedDB after successful publish + await deleteDraft(DRAFT_ID); setTimeout(() => { goto(`/event/${signedEvent.id}`); }, 5000); @@ -717,11 +716,9 @@ // Reset the initial props applied flag initialPropsApplied = false; - // Clear draft from localStorage after clearing state + // Clear draft from IndexedDB after clearing state // This prevents the save effect from running with old data - if (typeof window !== 'undefined') { - localStorage.removeItem(STORAGE_KEY); - } + await deleteDraft(DRAFT_ID); // Reset formCleared flag after a brief delay to allow effects to settle setTimeout(() => { diff --git a/src/lib/modules/comments/CommentForm.svelte b/src/lib/modules/comments/CommentForm.svelte index e984b7d..80e55fb 100644 --- a/src/lib/modules/comments/CommentForm.svelte +++ b/src/lib/modules/comments/CommentForm.svelte @@ -5,6 +5,7 @@ import { nostrClient } from '../../services/nostr/nostr-client.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; import { fetchRelayLists } from '../../services/user-data.js'; + import { getDraft, saveDraft, deleteDraft } from '../../services/cache/draft-store.js'; import PublicationStatusModal from '../../components/modals/PublicationStatusModal.svelte'; import GifPicker from '../../components/content/GifPicker.svelte'; import EmojiPicker from '../../components/content/EmojiPicker.svelte'; @@ -26,43 +27,42 @@ let { threadId, rootEvent, parentEvent, onPublished, onCancel }: Props = $props(); - // Create unique storage key based on thread and parent - const STORAGE_KEY = $derived(`aitherboard_commentForm_${threadId}_${parentEvent?.id || 'root'}`); + // Create unique draft ID based on thread and parent + const DRAFT_ID = $derived(`comment_${threadId}_${parentEvent?.id || 'root'}`); let content = $state(''); let publishing = $state(false); - // Restore draft from localStorage on mount + // Restore draft from IndexedDB on mount $effect(() => { if (typeof window === 'undefined') return; - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - const draft = JSON.parse(saved); - if (draft.content !== undefined && content === '') { + (async () => { + try { + const draft = await getDraft(DRAFT_ID); + if (draft && draft.content !== undefined && content === '') { content = draft.content; } + } catch (error) { + console.error('Error restoring comment draft:', error); } - } catch (error) { - console.error('Error restoring comment draft:', error); - } + })(); }); - // Save draft to localStorage when content changes + // Save draft to IndexedDB when content changes $effect(() => { if (typeof window === 'undefined') return; if (publishing) return; // Don't save while publishing - // Debounce saves to avoid excessive localStorage writes - const timeoutId = setTimeout(() => { + // Debounce saves to avoid excessive IndexedDB writes + const timeoutId = setTimeout(async () => { try { // Only save if there's actual content if (content.trim()) { - localStorage.setItem(STORAGE_KEY, JSON.stringify({ content })); + await saveDraft(DRAFT_ID, { content }); } else { // Clear if empty - localStorage.removeItem(STORAGE_KEY); + await deleteDraft(DRAFT_ID); } } catch (error) { console.error('Error saving comment draft:', error); @@ -241,10 +241,8 @@ if (result.success.length > 0) { content = ''; uploadedFiles = []; // Clear uploaded files after successful publish - // Clear draft from localStorage after successful publish - if (typeof window !== 'undefined') { - localStorage.removeItem(STORAGE_KEY); - } + // Clear draft from IndexedDB after successful publish + await deleteDraft(DRAFT_ID); onPublished?.(); } } catch (error) { @@ -261,14 +259,12 @@ } } - function clearForm() { + async function clearForm() { if (confirm('Are you sure you want to clear the comment? This will delete all unsaved content.')) { content = ''; uploadedFiles = []; - // Clear draft from localStorage - if (typeof window !== 'undefined') { - localStorage.removeItem(STORAGE_KEY); - } + // Clear draft from IndexedDB + await deleteDraft(DRAFT_ID); } } diff --git a/src/lib/services/cache/draft-store.ts b/src/lib/services/cache/draft-store.ts new file mode 100644 index 0000000..90220e5 --- /dev/null +++ b/src/lib/services/cache/draft-store.ts @@ -0,0 +1,93 @@ +/** + * Draft storage in IndexedDB + * Replaces localStorage for better scalability and structure + */ + +import { getDB } from './indexeddb-store.js'; + +export interface DraftData { + id: string; // e.g., 'write', 'comment_' + content: string; + tags?: string[][]; + selectedKind?: number; + updatedAt: number; +} + +/** + * Save a draft + */ +export async function saveDraft( + id: string, + data: Omit +): Promise { + try { + const db = await getDB(); + const draft: DraftData = { + id, + ...data, + updatedAt: Date.now() + }; + await db.put('drafts', draft); + } catch (error) { + console.warn('Error saving draft to IndexedDB:', error); + } +} + +/** + * Get a draft by ID + */ +export async function getDraft(id: string): Promise { + try { + const db = await getDB(); + const draft = await db.get('drafts', id); + return (draft as DraftData) || null; + } catch (error) { + console.warn('Error reading draft from IndexedDB:', error); + return null; + } +} + +/** + * Delete a draft + */ +export async function deleteDraft(id: string): Promise { + try { + const db = await getDB(); + await db.delete('drafts', id); + } catch (error) { + console.warn('Error deleting draft from IndexedDB:', error); + } +} + +/** + * List all drafts + */ +export async function listDrafts(): Promise { + try { + const db = await getDB(); + const drafts: DraftData[] = []; + const tx = db.transaction('drafts', 'readonly'); + + for await (const cursor of tx.store.iterate()) { + drafts.push(cursor.value as DraftData); + } + + await tx.done; + return drafts; + } catch (error) { + console.warn('Error listing drafts from IndexedDB:', error); + return []; + } +} + +/** + * Clear all drafts + */ +export async function clearAllDrafts(): Promise { + try { + const db = await getDB(); + await db.clear('drafts'); + } catch (error) { + console.warn('Error clearing all drafts:', error); + } +} diff --git a/src/lib/services/cache/indexeddb-store.ts b/src/lib/services/cache/indexeddb-store.ts index d99c665..6dda437 100644 --- a/src/lib/services/cache/indexeddb-store.ts +++ b/src/lib/services/cache/indexeddb-store.ts @@ -5,7 +5,7 @@ import { openDB, type IDBPDatabase } from 'idb'; const DB_NAME = 'aitherboard'; -const DB_VERSION = 3; // Incremented to add deletion_requests store +const DB_VERSION = 6; // Version 5: Added preferences and drafts stores. Version 6: Removed opengraph store export interface DatabaseSchema { events: { @@ -25,6 +25,14 @@ export interface DatabaseSchema { key: string; value: unknown; }; + preferences: { + key: string; // preference key + value: unknown; + }; + drafts: { + key: string; // draft id (e.g., 'write', 'comment_') + value: unknown; + }; } let dbInstance: IDBPDatabase | null = null; @@ -37,7 +45,12 @@ export async function getDB(): Promise> { try { dbInstance = await openDB(DB_NAME, DB_VERSION, { - upgrade(db) { + upgrade(db, oldVersion) { + // Migration: Remove opengraph store (was added in version 4, removed in version 6) + if (db.objectStoreNames.contains('opengraph')) { + db.deleteObjectStore('opengraph'); + } + // Events store if (!db.objectStoreNames.contains('events')) { const eventStore = db.createObjectStore('events', { keyPath: 'id' }); @@ -60,6 +73,16 @@ export async function getDB(): Promise> { if (!db.objectStoreNames.contains('search')) { db.createObjectStore('search', { keyPath: 'id' }); } + + // Preferences store + if (!db.objectStoreNames.contains('preferences')) { + db.createObjectStore('preferences', { keyPath: 'key' }); + } + + // Drafts store + if (!db.objectStoreNames.contains('drafts')) { + db.createObjectStore('drafts', { keyPath: 'id' }); + } }, blocked() { console.warn('IndexedDB is blocked - another tab may have it open'); @@ -77,7 +100,9 @@ export async function getDB(): Promise> { if (!dbInstance.objectStoreNames.contains('events') || !dbInstance.objectStoreNames.contains('profiles') || !dbInstance.objectStoreNames.contains('keys') || - !dbInstance.objectStoreNames.contains('search')) { + !dbInstance.objectStoreNames.contains('search') || + !dbInstance.objectStoreNames.contains('preferences') || + !dbInstance.objectStoreNames.contains('drafts')) { // Database is corrupted - close and delete it, then recreate console.warn('Database missing required stores, recreating...'); dbInstance.close(); @@ -107,6 +132,8 @@ export async function getDB(): Promise> { db.createObjectStore('profiles', { keyPath: 'pubkey' }); db.createObjectStore('keys', { keyPath: 'id' }); db.createObjectStore('search', { keyPath: 'id' }); + db.createObjectStore('preferences', { keyPath: 'key' }); + db.createObjectStore('drafts', { keyPath: 'id' }); }, blocked() { console.warn('IndexedDB is blocked - another tab may have it open'); diff --git a/src/lib/services/content/opengraph-fetcher.ts b/src/lib/services/content/opengraph-fetcher.ts deleted file mode 100644 index 48a0842..0000000 --- a/src/lib/services/content/opengraph-fetcher.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * OpenGraph metadata fetcher service - * Fetches OpenGraph metadata from URLs and caches results - */ - -export interface OpenGraphData { - title?: string; - description?: string; - image?: string; - url?: string; - siteName?: string; - type?: string; - cachedAt: number; -} - -const CACHE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 days -const CACHE_KEY_PREFIX = 'opengraph_'; - -/** - * Fetch OpenGraph metadata from a URL - * Uses a CORS proxy if needed, caches results in localStorage - */ -export async function fetchOpenGraph(url: string): Promise { - // Check cache first - const cached = getCachedOpenGraph(url); - if (cached && Date.now() - cached.cachedAt < CACHE_DURATION) { - return cached; - } - - try { - // Try to fetch the page HTML - // Note: Direct fetch may fail due to CORS, so we'll use a simple approach - // In production, you might want to use a backend proxy or service - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', - 'User-Agent': 'Mozilla/5.0 (compatible; aitherboard/1.0)' - }, - mode: 'cors', - cache: 'no-cache' - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const html = await response.text(); - const ogData = parseOpenGraph(html, url); - - // Cache the result - if (ogData) { - cacheOpenGraph(url, ogData); - } - - return ogData; - } catch (error) { - console.warn('Failed to fetch OpenGraph data:', error); - // Return cached data even if expired, or null - return cached || null; - } -} - -/** - * Parse OpenGraph metadata from HTML - */ -function parseOpenGraph(html: string, url: string): OpenGraphData | null { - const og: Partial = { - cachedAt: Date.now() - }; - - // Extract OpenGraph meta tags - const ogTitleMatch = html.match(/]*>([^<]+)<\/title>/i); - if (titleMatch) { - og.title = decodeHtmlEntities(titleMatch[1].trim()); - } - } - - if (!og.description) { - const metaDescriptionMatch = html.match(/ CACHE_DURATION) { - keysToRemove.push(key); - } - } catch { - keysToRemove.push(key); - } - } - } - - keysToRemove.forEach(key => localStorage.removeItem(key)); -}