diff --git a/public/healthz.json b/public/healthz.json index b1f6088..464280a 100644 --- a/public/healthz.json +++ b/public/healthz.json @@ -2,7 +2,7 @@ "status": "ok", "service": "aitherboard", "version": "0.1.0", - "buildTime": "2026-02-04T16:28:03.763Z", + "buildTime": "2026-02-04T16:38:18.760Z", "gitCommit": "unknown", - "timestamp": 1770222483764 + "timestamp": 1770223098760 } \ No newline at end of file diff --git a/src/lib/components/content/EmbeddedEvent.svelte b/src/lib/components/content/EmbeddedEvent.svelte index 6587b84..f932226 100644 --- a/src/lib/components/content/EmbeddedEvent.svelte +++ b/src/lib/components/content/EmbeddedEvent.svelte @@ -33,6 +33,18 @@ } }); + // Validate if a string is a valid bech32 or hex string + function isValidNostrId(str: string): boolean { + if (!str || typeof str !== 'string') return false; + // Check for HTML tags or other invalid characters + if (/<[^>]+>/.test(str)) return false; + // Check if it's hex (64 hex characters) + if (/^[0-9a-f]{64}$/i.test(str)) return true; + // Check if it's bech32 (npub1..., note1..., nevent1..., naddr1..., nprofile1...) + if (/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(str)) return true; + return false; + } + async function loadEvent() { // Prevent concurrent loads for the same event if (loadingEvent) { @@ -42,6 +54,15 @@ loading = true; error = false; try { + // Validate eventId before processing + if (!eventId || !isValidNostrId(eventId)) { + console.warn('Invalid event ID format:', eventId); + error = true; + loading = false; + loadingEvent = false; + return; + } + // Decode event ID let hexId: string | null = null; diff --git a/src/lib/components/content/EmojiPicker.svelte b/src/lib/components/content/EmojiPicker.svelte index a89088c..a6db029 100644 --- a/src/lib/components/content/EmojiPicker.svelte +++ b/src/lib/components/content/EmojiPicker.svelte @@ -5,10 +5,12 @@ import { loadAllEmojiPacks, getAllCustomEmojis } from '../../services/nostr/nip30-emoji.js'; import { signAndPublish } from '../../services/nostr/auth-handler.js'; import { relayManager } from '../../services/nostr/relay-manager.js'; + import { uploadFileToServer } from '../../services/nostr/file-upload.js'; import { nostrClient } from '../../services/nostr/nostr-client.js'; import { sessionManager } from '../../services/auth/session-manager.js'; import { KIND } from '../../types/kind-lookup.js'; import type { NostrEvent } from '../../types/nostr.js'; + import { cacheEvent } from '../../services/cache/event-cache.js'; interface Props { open: boolean; @@ -27,6 +29,20 @@ let fileInput: HTMLInputElement | null = $state(null); let shortcodeInput: HTMLInputElement | null = $state(null); let showUploadForm = $state(false); + + // Metadata form state (like GIF picker) + let showMetadataForm = $state(false); + let pendingUpload: { file: File; fileUrl: string; shortcode: string } | null = $state(null); + let metadataForm = $state({ + title: '', + summary: '', + alt: '', + dim: '', + blurhash: '', + thumb: '', + image: '', + content: '' + }); // Check if user is logged in let isLoggedIn = $derived(sessionManager.isLoggedIn()); @@ -132,7 +148,7 @@ onClose(); } - // Convert file to data URL + // Convert file to data URL (fallback method) function fileToDataURL(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -142,7 +158,19 @@ }); } - // Handle emoji file upload + // Upload file to media server using shared service + async function uploadEmojiFile(file: File): Promise { + const maxRecommendedSize = 5 * 1024 * 1024; // 5MB + if (file.size > maxRecommendedSize) { + console.warn(`[EmojiPicker] File ${file.name} is large (${(file.size / 1024 / 1024).toFixed(2)}MB), upload may fail`); + } + + // Use shared upload service + const result = await uploadFileToServer(file, 'EmojiPicker'); + return result.url; + } + + // Handle emoji file upload (like GIF picker) async function handleEmojiUpload(e: Event) { const target = e.target as HTMLInputElement; const files = target.files; @@ -158,12 +186,103 @@ uploadError = null; try { + // Process first file (like GIF picker, one at a time) + const file = Array.from(files)[0]; + const fileName = file.name; + + // Verify it's an image + if (!file.type.startsWith('image/')) { + uploadError = `${fileName} is not an image file`; + uploading = false; + return; + } + + // Get shortcode: use input if provided, otherwise use filename + let shortcode = ''; + if (shortcodeInput && shortcodeInput.value.trim()) { + shortcode = shortcodeInput.value.trim().toLowerCase().replace(/[^a-z0-9_+-]/g, '_'); + } else { + shortcode = fileName.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9_+-]/g, '_'); + } + + if (!shortcode) { + uploadError = `Please provide a shortcode for ${fileName}`; + uploading = false; + return; + } + + // Upload file to media server to get URL + let fileUrl: string; + + try { + fileUrl = await uploadEmojiFile(file); + console.log(`[EmojiPicker] Uploaded ${fileName} to ${fileUrl}`); + } catch (uploadError) { + console.error(`[EmojiPicker] Failed to upload ${fileName} to media server:`, uploadError); + + const errorMessage = uploadError instanceof Error ? uploadError.message : String(uploadError); + + // Only use data URL fallback for very small files (< 500KB) + const maxDataUrlSize = 500 * 1024; // 500KB + if (file.size <= maxDataUrlSize) { + console.warn(`[EmojiPicker] Using data URL fallback for ${fileName} (${(file.size / 1024).toFixed(1)}KB)`); + fileUrl = await fileToDataURL(file); + } else { + const serverUrl = localStorage.getItem('aitherboard_mediaUploadServer') || 'https://nostr.build'; + throw new Error( + `Failed to upload ${fileName} to media server: ${errorMessage}. ` + + `File is too large (${(file.size / 1024 / 1024).toFixed(2)}MB) to use data URL fallback. ` + + `If you're seeing CORS errors, try configuring a different media upload server in Preferences (current: ${serverUrl}).` + ); + } + } + + // Store pending upload for metadata form + pendingUpload = { file, fileUrl, shortcode }; + showMetadataForm = true; + + // Reset metadata form and prefill with shortcode and image URL + metadataForm = { + title: shortcode, // Prefill with shortcode + summary: '', + alt: `:${shortcode}: emoji`, + dim: '', + blurhash: '', + thumb: '', + image: fileUrl, // Prefill with the uploaded emoji URL + content: '' + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + const fileName = files && files.length > 0 ? files[0].name : 'file'; + console.error(`[EmojiPicker] Error processing ${fileName}:`, error); + uploadError = errorMessage; + } finally { + uploading = false; + } + } + + async function publishWithMetadata() { + if (!pendingUpload) return; + + const { file, fileUrl, shortcode } = pendingUpload; + const session = sessionManager.getSession(); + if (!session) { + uploadError = 'Please log in to publish emojis'; + return; + } + + uploading = true; + uploadError = null; + + try { + // Use publish relays for emoji sets const relays = relayManager.getPublishRelays( [...relayManager.getThreadReadRelays(), ...relayManager.getFeedReadRelays()], true ); - // Fetch existing emoji set to merge with new emojis + // Fetch existing emoji set to merge with new emoji const existingRelays = relayManager.getProfileReadRelays(); const existingEvents = await nostrClient.fetchEvents( [{ kinds: [KIND.EMOJI_SET], authors: [session.pubkey], limit: 1 }], @@ -181,61 +300,24 @@ } } - // Process each selected file - const newEmojiTags: string[][] = []; - const fileArray = Array.from(files); - - for (let i = 0; i < fileArray.length; i++) { - const file = fileArray[i]; - - // Verify it's an image - if (!file.type.startsWith('image/')) { - uploadError = `${file.name} is not an image file`; - continue; - } - - // Get shortcode: use input if single file, otherwise use filename - let shortcode = ''; - if (fileArray.length === 1 && shortcodeInput && shortcodeInput.value.trim()) { - shortcode = shortcodeInput.value.trim().toLowerCase().replace(/[^a-z0-9_+-]/g, '_'); - } else { - // Use filename without extension for each file - shortcode = file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9_+-]/g, '_'); - } - - if (!shortcode) { - uploadError = `Please provide a shortcode for ${file.name}`; - continue; - } - - // Check if shortcode already exists - if (existingEmojiTags.some(tag => tag[1] === shortcode) || - newEmojiTags.some(tag => tag[1] === shortcode)) { - // Append number if duplicate - let counter = 1; - let uniqueShortcode = `${shortcode}_${counter}`; - while (existingEmojiTags.some(tag => tag[1] === uniqueShortcode) || - newEmojiTags.some(tag => tag[1] === uniqueShortcode)) { - counter++; - uniqueShortcode = `${shortcode}_${counter}`; - } - shortcode = uniqueShortcode; + // Check if shortcode already exists + let finalShortcode = shortcode; + if (existingEmojiTags.some(tag => tag[1] === shortcode)) { + // Append number if duplicate + let counter = 1; + let uniqueShortcode = `${shortcode}_${counter}`; + while (existingEmojiTags.some(tag => tag[1] === uniqueShortcode)) { + counter++; + uniqueShortcode = `${shortcode}_${counter}`; } - - // Convert to data URL - const dataUrl = await fileToDataURL(file); - - // Add emoji tag - newEmojiTags.push(['emoji', shortcode, dataUrl]); + finalShortcode = uniqueShortcode; } - if (newEmojiTags.length === 0) { - uploadError = 'No valid emojis to upload'; - return; - } + // Add new emoji tag with uploaded URL + const newEmojiTag: string[] = ['emoji', finalShortcode, fileUrl]; // Merge existing and new emoji tags - const allEmojiTags = [...existingEmojiTags, ...newEmojiTags]; + const allEmojiTags = [...existingEmojiTags, newEmojiTag]; // Create or update kind 10030 emoji set event const event: Omit = { @@ -249,23 +331,41 @@ }; // Publish the event - await signAndPublish(event, relays); - - // Reload custom emojis - await loadCustomEmojis(); + const result = await signAndPublish(event, relays); - // Reset form - if (fileInput) fileInput.value = ''; - if (shortcodeInput) shortcodeInput.value = ''; - showUploadForm = false; + if (result.success.length > 0) { + console.log(`[EmojiPicker] Published emoji set event for ${file.name} (shortcode: :${finalShortcode}:) to ${result.success.length} relay(s)`); + showMetadataForm = false; + pendingUpload = null; + showUploadForm = false; + + // Reset form + if (fileInput) fileInput.value = ''; + if (shortcodeInput) shortcodeInput.value = ''; + + // Reload custom emojis + await loadCustomEmojis(); + } else { + const errorMsg = result.failed.length > 0 + ? result.failed.map(f => `${f.relay}: ${f.error}`).join(', ') + : 'No relays accepted the event'; + throw new Error(`Failed to publish ${file.name}: ${errorMsg}`); + } } catch (error) { - console.error('Error uploading emoji:', error); - uploadError = error instanceof Error ? error.message : 'Failed to upload emoji'; + const errorMessage = error instanceof Error ? error.message : String(error); + console.error(`[EmojiPicker] Error publishing ${file.name}:`, error); + uploadError = errorMessage; } finally { uploading = false; } } + function cancelMetadataForm() { + showMetadataForm = false; + pendingUpload = null; + uploading = false; + } + function triggerEmojiUpload() { if (showUploadForm) { fileInput?.click(); @@ -385,6 +485,116 @@ {/snippet} + +{#if showMetadataForm && pendingUpload} + +{/if} + diff --git a/src/lib/components/content/GifPicker.svelte b/src/lib/components/content/GifPicker.svelte index be65233..cd4f891 100644 --- a/src/lib/components/content/GifPicker.svelte +++ b/src/lib/components/content/GifPicker.svelte @@ -1,9 +1,10 @@