diff --git a/app/web/src/App.svelte b/app/web/src/App.svelte index 8493e03..be23a71 100644 --- a/app/web/src/App.svelte +++ b/app/web/src/App.svelte @@ -2,6 +2,7 @@ import LoginModal from './LoginModal.svelte'; import { initializeNostrClient, fetchUserProfile, fetchAllEvents, fetchUserEvents, searchEvents, fetchEventById, fetchDeleteEventsByTarget, nostrClient, NostrClient } from './nostr.js'; import { NDKPrivateKeySigner } from '@nostr-dev-kit/ndk'; + import { publishEventWithAuth } from './websocket-auth.js'; let isDarkTheme = false; let showLoginModal = false; @@ -58,6 +59,9 @@ let sprocketEnabled = false; let sprocketUploadFile = null; + // Compose tab state + let composeEventJson = ''; + // Kind name mapping based on repository kind definitions const kindNames = { 0: "ProfileMetadata", @@ -170,6 +174,51 @@ expandedEvents = expandedEvents; // Trigger reactivity } + async function copyEventToClipboard(eventData, clickEvent) { + try { + // Create minified JSON (no indentation) + const minifiedJson = JSON.stringify(eventData); + await navigator.clipboard.writeText(minifiedJson); + + // Show temporary feedback + const button = clickEvent.target.closest('.copy-json-btn'); + if (button) { + const originalText = button.textContent; + button.textContent = '✅'; + button.style.backgroundColor = '#4CAF50'; + setTimeout(() => { + button.textContent = originalText; + button.style.backgroundColor = ''; + }, 2000); + } + } catch (error) { + console.error('Failed to copy to clipboard:', error); + // Fallback for older browsers + try { + const textArea = document.createElement('textarea'); + textArea.value = JSON.stringify(eventData); + document.body.appendChild(textArea); + textArea.select(); + document.execCommand('copy'); + document.body.removeChild(textArea); + + const button = clickEvent.target.closest('.copy-json-btn'); + if (button) { + const originalText = button.textContent; + button.textContent = '✅'; + button.style.backgroundColor = '#4CAF50'; + setTimeout(() => { + button.textContent = originalText; + button.style.backgroundColor = ''; + }, 2000); + } + } catch (fallbackError) { + console.error('Fallback copy also failed:', fallbackError); + alert('Failed to copy to clipboard. Please copy manually.'); + } + } + } + async function handleToggleChange() { // Toggle state is already updated by bind:checked console.log('Toggle changed, showOnlyMyEvents:', showOnlyMyEvents); @@ -238,6 +287,21 @@ console.log('Signed delete event pubkey:', signedDeleteEvent.pubkey); console.log('Delete event tags:', signedDeleteEvent.tags); + // Publish to the ORLY relay using WebSocket authentication + const relayUrl = `wss://${window.location.host}`; + + try { + const result = await publishEventWithAuth(relayUrl, signedDeleteEvent, userSigner, userPubkey); + + if (result.success) { + console.log('Delete event published successfully to ORLY relay'); + } else { + console.error('Failed to publish delete event:', result.reason); + } + } catch (error) { + console.error('Error publishing delete event:', error); + } + // Determine if we should publish to external relays // Only publish to external relays if: // 1. User is deleting their own event, OR @@ -820,6 +884,7 @@ {id: 'export', icon: '📤', label: 'Export'}, {id: 'import', icon: '💾', label: 'Import', requiresAdmin: true}, {id: 'events', icon: '📡', label: 'Events'}, + {id: 'compose', icon: '✏️', label: 'Compose'}, {id: 'sprocket', icon: '⚙️', label: 'Sprocket', requiresOwner: true}, ]; @@ -1478,6 +1543,106 @@ return base64Event; } + + // Compose tab functions + function reformatJson() { + try { + if (!composeEventJson.trim()) { + alert('Please enter some JSON to reformat'); + return; + } + const parsed = JSON.parse(composeEventJson); + composeEventJson = JSON.stringify(parsed, null, 2); + } catch (error) { + alert('Invalid JSON: ' + error.message); + } + } + + async function signEvent() { + try { + if (!composeEventJson.trim()) { + alert('Please enter an event to sign'); + return; + } + + if (!isLoggedIn || !userPubkey) { + alert('Please log in to sign events'); + return; + } + + if (!userSigner) { + alert('No signer available. Please log in with a valid authentication method.'); + return; + } + + const event = JSON.parse(composeEventJson); + + // Update event with current user's pubkey and timestamp + event.pubkey = userPubkey; + event.created_at = Math.floor(Date.now() / 1000); + + // Remove any existing id and sig to ensure fresh signing + delete event.id; + delete event.sig; + + // Sign the event using the real signer + const signedEvent = await userSigner.signEvent(event); + + // Update the compose area with the signed event + composeEventJson = JSON.stringify(signedEvent, null, 2); + + // Show success feedback + alert('Event signed successfully!'); + } catch (error) { + console.error('Error signing event:', error); + alert('Error signing event: ' + error.message); + } + } + + async function publishEvent() { + try { + if (!composeEventJson.trim()) { + alert('Please enter an event to publish'); + return; + } + + if (!isLoggedIn) { + alert('Please log in to publish events'); + return; + } + + if (!userSigner) { + alert('No signer available. Please log in with a valid authentication method.'); + return; + } + + const event = JSON.parse(composeEventJson); + + // Validate that the event has required fields + if (!event.id || !event.sig) { + alert('Event must be signed before publishing. Please click "Sign" first.'); + return; + } + + // Publish to the ORLY relay using WebSocket (same address as current page) + const relayUrl = `wss://${window.location.host}`; + + // Use the authentication module to publish the event + const result = await publishEventWithAuth(relayUrl, event, userSigner, userPubkey); + + if (result.success) { + alert('Event published successfully to ORLY relay!'); + // Optionally clear the editor after successful publish + // composeEventJson = ''; + } else { + alert(`Event publishing failed: ${result.reason || 'Unknown error'}`); + } + + } catch (error) { + console.error('Error publishing event:', error); + alert('Error publishing event: ' + error.message); + } + } @@ -1650,7 +1815,12 @@ {#if expandedEvents.has(event.id)}
-
{JSON.stringify(event, null, 2)}
+
+
{JSON.stringify(event, null, 2)}
+ +
{/if} @@ -1709,6 +1879,22 @@ {/if} + {:else if selectedTab === 'compose'} +
+
+ + + +
+
+ +
+
{:else if selectedTab === 'sprocket'}

Sprocket Script Management

@@ -1878,7 +2064,12 @@
{#if expandedEvents.has(event.id)}
-
{JSON.stringify(event, null, 2)}
+
+
{JSON.stringify(event, null, 2)}
+ +
{/if} @@ -2873,6 +3064,76 @@ margin: 0 auto; } + .compose-view { + position: fixed; + top: 3em; + left: 200px; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + background: transparent; + } + + .compose-header { + display: flex; + gap: 0.5em; + padding: 0.5em; + background: transparent; + border-bottom: 1px solid var(--border-color); + } + + .compose-btn { + padding: 0.5em 1em; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background: var(--button-bg); + color: var(--button-text); + cursor: pointer; + font-size: 0.9rem; + transition: background-color 0.2s, border-color 0.2s; + } + + .compose-btn:hover { + background: var(--button-hover-bg); + border-color: var(--button-hover-border); + } + + .publish-btn { + background: var(--accent-color, #007bff); + color: white; + border-color: var(--accent-color, #007bff); + } + + .publish-btn:hover { + background: var(--accent-hover-color, #0056b3); + border-color: var(--accent-hover-color, #0056b3); + } + + .compose-editor { + flex: 1; + padding: 0.5em; + } + + .compose-textarea { + width: 100%; + height: 100%; + border: 1px solid var(--border-color); + border-radius: 0.25rem; + background: var(--bg-color); + color: var(--text-color); + font-family: 'Courier New', monospace; + font-size: 0.9rem; + padding: 1rem; + resize: none; + outline: none; + } + + .compose-textarea:focus { + border-color: var(--accent-color, #007bff); + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); + } + .export-view h2, .import-view h2 { margin: 0 0 2rem 0; color: var(--text-color); @@ -3241,6 +3502,37 @@ padding: 1rem; } + .json-container { + position: relative; + } + + .copy-json-btn { + position: absolute; + top: 0.5rem; + right: 0.5rem; + background: var(--button-bg); + color: var(--button-text); + border: 1px solid var(--border-color); + border-radius: 0.25rem; + padding: 0.5rem 1rem; + font-size: 1.6rem; + cursor: pointer; + transition: background-color 0.2s, border-color 0.2s; + z-index: 10; + opacity: 0.8; + width: auto; + height: auto; + display: flex; + align-items: center; + justify-content: center; + } + + .copy-json-btn:hover { + background: var(--button-hover-bg); + border-color: var(--button-hover-border); + opacity: 1; + } + .event-json { background: var(--bg-color); border: 1px solid var(--border-color); @@ -3478,6 +3770,10 @@ left: 60px; } + .compose-view { + left: 60px; + } + .search-results-view { left: 60px; } @@ -3533,6 +3829,10 @@ .events-view-container { left: 160px; } + + .compose-view { + left: 160px; + } .events-view-info { width: 8rem; diff --git a/app/web/src/websocket-auth.js b/app/web/src/websocket-auth.js new file mode 100644 index 0000000..c1f2210 --- /dev/null +++ b/app/web/src/websocket-auth.js @@ -0,0 +1,251 @@ +/** + * WebSocket Authentication Module for Nostr Relays + * Implements NIP-42 authentication with proper challenge handling + */ + +export class NostrWebSocketAuth { + constructor(relayUrl, userSigner, userPubkey) { + this.relayUrl = relayUrl; + this.userSigner = userSigner; + this.userPubkey = userPubkey; + this.ws = null; + this.challenge = null; + this.isAuthenticated = false; + this.authPromise = null; + } + + /** + * Connect to relay and handle authentication + */ + async connect() { + return new Promise((resolve, reject) => { + this.ws = new WebSocket(this.relayUrl); + + this.ws.onopen = () => { + console.log('WebSocket connected to relay:', this.relayUrl); + resolve(); + }; + + this.ws.onmessage = async (message) => { + try { + const data = JSON.parse(message.data); + await this.handleMessage(data); + } catch (error) { + console.error('Error parsing relay message:', error); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + reject(new Error('Failed to connect to relay')); + }; + + this.ws.onclose = () => { + console.log('WebSocket connection closed'); + this.isAuthenticated = false; + this.challenge = null; + }; + + // Timeout for connection + setTimeout(() => { + if (this.ws.readyState !== WebSocket.OPEN) { + reject(new Error('Connection timeout')); + } + }, 10000); + }); + } + + /** + * Handle incoming messages from relay + */ + async handleMessage(data) { + const [messageType, ...params] = data; + + switch (messageType) { + case 'AUTH': + // Relay sent authentication challenge + this.challenge = params[0]; + console.log('Received AUTH challenge:', this.challenge); + await this.authenticate(); + break; + + case 'OK': + const [eventId, success, reason] = params; + if (eventId && success) { + console.log('Authentication successful for event:', eventId); + this.isAuthenticated = true; + if (this.authPromise) { + this.authPromise.resolve(); + this.authPromise = null; + } + } else if (eventId && !success) { + console.error('Authentication failed:', reason); + if (this.authPromise) { + this.authPromise.reject(new Error(reason || 'Authentication failed')); + this.authPromise = null; + } + } + break; + + case 'NOTICE': + console.log('Relay notice:', params[0]); + break; + + default: + console.log('Unhandled message type:', messageType, params); + } + } + + /** + * Authenticate with the relay using NIP-42 + */ + async authenticate() { + if (!this.challenge) { + throw new Error('No challenge received from relay'); + } + + if (!this.userSigner) { + throw new Error('No signer available for authentication'); + } + + try { + // Create NIP-42 authentication event + const authEvent = { + kind: 22242, // ClientAuthentication kind + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['relay', this.relayUrl], + ['challenge', this.challenge] + ], + content: '', + pubkey: this.userPubkey + }; + + // Sign the authentication event + const signedAuthEvent = await this.userSigner.signEvent(authEvent); + + // Send AUTH message to relay + const authMessage = ["AUTH", signedAuthEvent]; + this.ws.send(JSON.stringify(authMessage)); + + console.log('Sent authentication event to relay'); + + // Wait for authentication response + return new Promise((resolve, reject) => { + this.authPromise = { resolve, reject }; + + // Timeout for authentication + setTimeout(() => { + if (this.authPromise) { + this.authPromise.reject(new Error('Authentication timeout')); + this.authPromise = null; + } + }, 10000); + }); + + } catch (error) { + console.error('Authentication error:', error); + throw error; + } + } + + /** + * Publish an event to the relay + */ + async publishEvent(event) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('WebSocket not connected'); + } + + return new Promise((resolve, reject) => { + // Send EVENT message + const eventMessage = ["EVENT", event]; + this.ws.send(JSON.stringify(eventMessage)); + + // Set up message handler for this specific event + const originalOnMessage = this.ws.onmessage; + const timeout = setTimeout(() => { + this.ws.onmessage = originalOnMessage; + reject(new Error('Publish timeout')); + }, 15000); + + this.ws.onmessage = async (message) => { + try { + const data = JSON.parse(message.data); + const [messageType, eventId, success, reason] = data; + + if (messageType === 'OK' && eventId === event.id) { + clearTimeout(timeout); + this.ws.onmessage = originalOnMessage; + + if (success) { + console.log('Event published successfully:', eventId); + resolve({ success: true, eventId, reason }); + } else { + console.error('Event publish failed:', reason); + + // Check if authentication is required + if (reason && reason.includes('auth-required')) { + console.log('Authentication required, attempting to authenticate...'); + try { + await this.authenticate(); + // Re-send the event after authentication + const retryMessage = ["EVENT", event]; + this.ws.send(JSON.stringify(retryMessage)); + // Don't resolve yet, wait for the retry response + return; + } catch (authError) { + reject(new Error(`Authentication failed: ${authError.message}`)); + return; + } + } + + reject(new Error(`Publish failed: ${reason}`)); + } + } else { + // Handle other messages normally + await this.handleMessage(data); + } + } catch (error) { + clearTimeout(timeout); + this.ws.onmessage = originalOnMessage; + reject(error); + } + }; + }); + } + + /** + * Close the WebSocket connection + */ + close() { + if (this.ws) { + this.ws.close(); + this.ws = null; + } + this.isAuthenticated = false; + this.challenge = null; + } + + /** + * Check if currently authenticated + */ + getAuthenticated() { + return this.isAuthenticated; + } +} + +/** + * Convenience function to publish an event with authentication + */ +export async function publishEventWithAuth(relayUrl, event, userSigner, userPubkey) { + const auth = new NostrWebSocketAuth(relayUrl, userSigner, userPubkey); + + try { + await auth.connect(); + const result = await auth.publishEvent(event); + return result; + } finally { + auth.close(); + } +} diff --git a/pkg/version/version b/pkg/version/version index 0ce90be..c06eba1 100644 --- a/pkg/version/version +++ b/pkg/version/version @@ -1 +1 @@ -v0.14.4 \ No newline at end of file +v0.15.0 \ No newline at end of file