diff --git a/app/server.go b/app/server.go index 01cf6d6..49cea09 100644 --- a/app/server.go +++ b/app/server.go @@ -34,6 +34,17 @@ type Server struct { } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Set CORS headers for all responses + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + + // Handle preflight OPTIONS requests + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + // log.T.C( // func() string { // return fmt.Sprintf("path %v header %v", r.URL, r.Header) diff --git a/app/web/public/index.html b/app/web/public/index.html index 7e1613f..7246b3e 100644 --- a/app/web/public/index.html +++ b/app/web/public/index.html @@ -4,8 +4,9 @@ Nostr Relay + - +
diff --git a/app/web/src/App.jsx b/app/web/src/App.jsx index 50ae915..8691902 100644 --- a/app/web/src/App.jsx +++ b/app/web/src/App.jsx @@ -4,12 +4,28 @@ function App() { const [user, setUser] = useState(null); const [status, setStatus] = useState('Ready to authenticate'); const [statusType, setStatusType] = useState('info'); + const [profileData, setProfileData] = useState(null); useEffect(() => { // Check authentication status on page load checkStatus(); }, []); + // Effect to fetch profile when user changes + useEffect(() => { + if (user?.pubkey) { + fetchUserProfile(user.pubkey); + } + }, [user?.pubkey]); + + function relayURL() { + try { + return window.location.protocol.replace('http', 'ws') + '//' + window.location.host; + } catch (_) { + return 'ws://localhost:3333'; + } + } + async function checkStatus() { try { const response = await fetch('/api/auth/status'); @@ -17,7 +33,7 @@ function App() { if (data.authenticated) { setUser(data.pubkey); updateStatus(`Already authenticated as: ${data.pubkey.slice(0, 16)}...`, 'success'); - + // Check permissions if authenticated if (data.pubkey) { const permResponse = await fetch(`/api/permissions/${data.pubkey}`); @@ -37,6 +53,19 @@ function App() { setStatusType(type); } + function statusClassName() { + const base = 'mt-5 mb-5 p-3 rounded'; + switch (statusType) { + case 'success': + return base + ' bg-green-100 text-green-800'; + case 'error': + return base + ' bg-red-100 text-red-800'; + case 'info': + default: + return base + ' bg-cyan-100 text-cyan-800'; + } + } + async function getChallenge() { try { const response = await fetch('/api/auth/challenge'); @@ -53,38 +82,199 @@ function App() { updateStatus('No Nostr extension found. Please install a NIP-07 compatible extension like nos2x or Alby.', 'error'); return; } - + try { updateStatus('Connecting to extension...', 'info'); - + // Get public key from extension const pubkey = await window.nostr.getPublicKey(); - + // Get challenge from server const challenge = await getChallenge(); - + // Create authentication event const authEvent = { kind: 22242, created_at: Math.floor(Date.now() / 1000), tags: [ - ['relay', window.location.protocol.replace('http', 'ws') + '//' + window.location.host], + ['relay', relayURL()], ['challenge', challenge] ], content: '' }; - + // Sign the event with extension const signedEvent = await window.nostr.signEvent(authEvent); - + // Send to server await authenticate(signedEvent); - + } catch (error) { updateStatus('Extension login failed: ' + error.message, 'error'); } } + async function fetchKind0FromRelay(pubkeyHex, timeoutMs = 4000) { + return new Promise((resolve) => { + let resolved = false; + let events = []; + let ws; + try { + ws = new WebSocket(relayURL()); + } catch (e) { + resolve(null); + return; + } + + const subId = 'profile-' + Math.random().toString(36).slice(2); + const timer = setTimeout(() => { + if (ws && ws.readyState === 1) { + try { ws.close(); } catch (_) {} + } + if (!resolved) { + resolved = true; + resolve(null); + } + }, timeoutMs); + + ws.onopen = () => { + try { + const req = [ + 'REQ', + subId, + { kinds: [0], authors: [pubkeyHex] } + ]; + ws.send(JSON.stringify(req)); + } catch (_) {} + }; + + ws.onmessage = (msg) => { + try { + const data = JSON.parse(msg.data); + const type = data[0]; + if (type === 'EVENT' && data[1] === subId) { + const event = data[2]; + if (event && event.kind === 0 && event.content) { + events.push(event); + } + } else if (type === 'EOSE' && data[1] === subId) { + try { + ws.send(JSON.stringify(['CLOSE', subId])); + } catch (_) {} + try { ws.close(); } catch (_) {} + clearTimeout(timer); + if (!resolved) { + resolved = true; + if (events.length) { + const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b)); + try { + const meta = JSON.parse(latest.content); + resolve(meta || null); + } catch (_) { + resolve(null); + } + } else { + resolve(null); + } + } + } + } catch (_) { + // ignore malformed messages + } + }; + + ws.onerror = () => { + try { ws.close(); } catch (_) {} + clearTimeout(timer); + if (!resolved) { + resolved = true; + resolve(null); + } + }; + + ws.onclose = () => { + clearTimeout(timer); + if (!resolved) { + resolved = true; + if (events.length) { + const latest = events.reduce((a, b) => (a.created_at > b.created_at ? a : b)); + try { + const meta = JSON.parse(latest.content); + resolve(meta || null); + } catch (_) { + resolve(null); + } + } else { + resolve(null); + } + } + }; + }); + } + + // Function to fetch user profile metadata (kind 0) + async function fetchUserProfile(pubkeyHex) { + try { + // Create a simple placeholder with the pubkey + const placeholderProfile = { + name: `user:${pubkeyHex.slice(0, 8)}`, + about: 'No profile data available' + }; + + // Always set the placeholder profile first + setProfileData(placeholderProfile); + + // First, try to get profile kind:0 from the relay itself + let relayMetadata = null; + try { + relayMetadata = await fetchKind0FromRelay(pubkeyHex); + } catch (_) {} + + if (relayMetadata) { + const parsed = typeof relayMetadata === 'string' ? JSON.parse(relayMetadata) : relayMetadata; + setProfileData({ + name: parsed.name || placeholderProfile.name, + display_name: parsed.display_name, + picture: parsed.picture, + banner: parsed.banner, + about: parsed.about || placeholderProfile.about + }); + return parsed; + } + + // Fallback: try extension metadata if available + if (window.nostr && window.nostr.getPublicKey) { + try { + if (window.nostr.getUserMetadata) { + const metadata = await window.nostr.getUserMetadata(); + if (metadata) { + try { + const parsedMetadata = typeof metadata === 'string' ? JSON.parse(metadata) : metadata; + setProfileData({ + name: parsedMetadata.name || placeholderProfile.name, + display_name: parsedMetadata.display_name, + picture: parsedMetadata.picture, + banner: parsedMetadata.banner, + about: parsedMetadata.about || placeholderProfile.about + }); + return parsedMetadata; + } catch (parseError) { + console.log('Error parsing user metadata:', parseError); + } + } + } + } catch (nostrError) { + console.log('Could not get profile from extension:', nostrError); + } + } + + return placeholderProfile; + } catch (error) { + console.error('Error handling profile data:', error); + return null; + } + } + async function authenticate(signedEvent) { try { const response = await fetch('/api/auth/login', { @@ -92,18 +282,21 @@ function App() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(signedEvent) }); - + const result = await response.json(); - + if (result.success) { setUser(result.pubkey); updateStatus('Successfully authenticated as: ' + result.pubkey.slice(0, 16) + '...', 'success'); - + // Check permissions after login const permResponse = await fetch(`/api/permissions/${result.pubkey}`); const permData = await permResponse.json(); if (permData && permData.permission) { setUser({pubkey: result.pubkey, permission: permData.permission}); + + // Fetch user profile data + await fetchUserProfile(result.pubkey); } } else { updateStatus('Authentication failed: ' + result.error, 'error'); @@ -119,40 +312,58 @@ function App() { } return ( -
- {user?.permission && ( -
-
- Logo -
- {user.permission === "admin" ? "Admin Dashboard" : "Subscriber Dashboard"} + <> + {user?.permission ? ( + // Logged in view with user profile +
+
+
+ Logo
- +
+ {profileData?.banner && ( +
+ )} +
+ {profileData?.picture && User Avatar} +
+
+ {profileData?.display_name || profileData?.name || user.pubkey.slice(0, 8)} + {profileData?.name && profileData?.display_name && ` (${profileData.name})`} +
+
+ {user.permission === "admin" ? "Admin Dashboard" : "Subscriber Dashboard"} +
+
+
+
+
+ +
+
+
+ ) : ( + // Not logged in view - shows the login form +
+

Nostr Relay Authentication

+

Connect to this Nostr relay using your private key or browser extension.

+ +
+ {status} +
+ +
+ +
+ +
+ + +
)} - -

Nostr Relay Authentication

-

Connect to this Nostr relay using your private key or browser extension.

- -
- {status} -
- -
- -
- -
- - - -
- -
- -
-
+ ); } diff --git a/app/web/src/styles.css b/app/web/src/styles.css index 00ff721..c7b584a 100644 --- a/app/web/src/styles.css +++ b/app/web/src/styles.css @@ -9,7 +9,7 @@ body { background: #f9f9f9; padding: 30px; border-radius: 8px; - margin-top: 80px; /* Space for the header panel */ + margin-top: 20px; /* Reduced space since header is now sticky */ } .form-group { @@ -73,32 +73,100 @@ button:hover { } .header-panel { - position: fixed; + position: sticky; top: 0; left: 0; width: 100%; background-color: #f8f9fa; box-shadow: 0 2px 4px rgba(0,0,0,0.1); z-index: 1000; + height: 60px; + display: flex; + align-items: center; + background-size: cover; + background-position: center; + overflow: hidden; } .header-content { display: flex; align-items: center; - justify-content: space-between; - padding: 10px 20px; + height: 100%; + padding: 0 0 0 12px; + width: 100%; + margin: 0 auto; + box-sizing: border-box; +} + +.header-left { + display: flex; + align-items: center; + justify-content: flex-start; + height: 100%; +} + +.header-center { + display: flex; + flex-grow: 1; + align-items: center; + justify-content: flex-start; + position: relative; + overflow: hidden; +} + +.header-right { + display: flex; + align-items: center; + justify-content: flex-end; + height: 100%; } .header-logo { - height: 40px; + height: 100%; + aspect-ratio: 1 / 1; + width: auto; + border-radius: 0; + object-fit: cover; + flex-shrink: 0; +} + +.user-avatar { width: 40px; - border-radius: 4px; + height: 40px; + border-radius: 50%; + object-fit: cover; + border: 2px solid white; + margin-right: 10px; + box-shadow: 0 1px 3px rgba(0,0,0,0.2); +} + +.user-profile { + display: flex; + align-items: center; + position: relative; + z-index: 1; } .user-info { - flex-grow: 1; - padding-left: 20px; font-weight: bold; + font-size: 1.2em; + text-align: left; +} + +.user-name { + font-weight: bold; + font-size: 1em; + display: block; +} + +.profile-banner { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + z-index: -1; + opacity: 0.7; } .logout-button { @@ -111,12 +179,14 @@ button:hover { display: flex; align-items: center; justify-content: center; - width: 40px; - height: 40px; + width: 48px; + height: 100%; + margin-left: 10px; margin-right: 0; + flex-shrink: 0; } .logout-button:hover { - background: rgba(108, 117, 125, 0.1); + background: transparent; color: #343a40; } \ No newline at end of file