diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index 4187364..a04e5df 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -8,6 +8,7 @@ import { onMount } from 'svelte'; import { userStore } from '../stores/user-store.js'; import { clearActivity, updateActivity } from '../services/activity-tracker.js'; + import { determineUserLevel, decodePubkey } from '../services/nostr/user-level-service.js'; let userPubkey = $state(null); let mobileMenuOpen = $state(false); @@ -61,14 +62,72 @@ } async function login() { + if (!isNIP07Available()) { + alert('Nostr extension not found. Please install a Nostr extension like nos2x or Alby to login.'); + return; + } + try { - if (!isNIP07Available()) { - alert('NIP-07 extension not found. Please install a Nostr extension like Alby or nos2x.'); + // Get public key directly from NIP-07 + let pubkey: string; + try { + pubkey = await getPublicKeyWithNIP07(); + if (!pubkey) { + throw new Error('No public key returned from extension'); + } + } catch (err) { + console.error('Failed to get public key from NIP-07:', err); + alert('Failed to connect to Nostr extension. Please make sure your extension is unlocked and try again.'); return; } - userPubkey = await getPublicKeyWithNIP07(); + + // Convert npub to hex for API calls + let pubkeyHex: string; + if (/^[0-9a-f]{64}$/i.test(pubkey)) { + // Already hex format + pubkeyHex = pubkey.toLowerCase(); + userPubkey = pubkey; + } else { + // Try to decode as npub + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + pubkeyHex = decoded.data as string; + userPubkey = pubkey; // Keep original npub format + } else { + throw new Error('Invalid pubkey format'); + } + } catch (decodeErr) { + console.error('Failed to decode pubkey:', decodeErr); + alert('Invalid public key format. Please try again.'); + return; + } + } + + // Determine user level (checks relay write access) + const levelResult = await determineUserLevel(userPubkey, pubkeyHex); + + // Update user store + userStore.setUser( + levelResult.userPubkey, + levelResult.userPubkeyHex, + levelResult.level, + levelResult.error || null + ); + + // Update activity tracking on successful login + updateActivity(); + + // Show success message + if (levelResult.level === 'unlimited') { + console.log('Unlimited access granted!'); + } else if (levelResult.level === 'rate_limited') { + console.log('Logged in with rate-limited access.'); + } } catch (err) { console.error('Login error:', err); + const errorMessage = err instanceof Error ? err.message : String(err); + alert(`Failed to login: ${errorMessage}. Please make sure your Nostr extension is unlocked and try again.`); } } @@ -99,7 +158,6 @@ Repositories Search Register - Verify Repo Docs diff --git a/src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts b/src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts index 65cf254..ca6a745 100644 --- a/src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts @@ -14,13 +14,14 @@ export const GET: RequestHandler = createRepoGetHandler( const { maintainers, owner } = await maintainerService.getMaintainers(context.repoOwnerPubkey, context.repo); // If userPubkey provided, check if they're a maintainer + // SECURITY: Do NOT leak userPubkey in response - only return boolean status if (context.userPubkeyHex) { const isMaintainer = maintainers.includes(context.userPubkeyHex); return json({ maintainers, owner, - isMaintainer, - userPubkey: context.userPubkeyHex + isMaintainer + // SECURITY: Removed userPubkey leak - client already knows their own pubkey }); } diff --git a/src/routes/api/user/level/+server.ts b/src/routes/api/user/level/+server.ts index e979f2e..8a58ac3 100644 --- a/src/routes/api/user/level/+server.ts +++ b/src/routes/api/user/level/+server.ts @@ -93,9 +93,9 @@ export const POST: RequestHandler = async (event) => { logger.info({ userPubkeyHex, level: cached.level }, '[API] Using cached user level (proof event signature validated)'); return json({ level: cached.level, - userPubkeyHex, verified: true, cached: true + // SECURITY: Removed userPubkeyHex - client already knows their own pubkey }); } @@ -120,10 +120,10 @@ export const POST: RequestHandler = async (event) => { ); return json({ level: cachedOnRelayDown.level, - userPubkeyHex, verified: true, cached: true, relayDown: true + // SECURITY: Removed userPubkeyHex - client already knows their own pubkey }); } @@ -153,8 +153,8 @@ export const POST: RequestHandler = async (event) => { return json({ level: 'unlimited', - userPubkeyHex, verified: true + // SECURITY: Removed userPubkeyHex - client already knows their own pubkey }); } else { // User is logged in but no write access - rate limited @@ -171,9 +171,9 @@ export const POST: RequestHandler = async (event) => { return json({ level: 'rate_limited', - userPubkeyHex, verified: true, error: verification.error + // SECURITY: Removed userPubkeyHex - client already knows their own pubkey }); } } catch (err) { diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index e21e4af..18a226c 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -14,6 +14,8 @@ import { KIND } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; import { userStore } from '$lib/stores/user-store.js'; + import { generateVerificationFile, VERIFICATION_FILE_PATH } from '$lib/services/nostr/repo-verification.js'; + import type { NostrEvent } from '$lib/types/nostr.js'; // Get page data for OpenGraph metadata - use $derived to make it reactive const pageData = $derived($page.data as { @@ -123,6 +125,8 @@ // Verification status let verificationStatus = $state<{ verified: boolean; error?: string; message?: string } | null>(null); + let showVerificationDialog = $state(false); + let verificationFileContent = $state(null); let loadingVerification = $state(false); // Issues @@ -988,6 +992,63 @@ } } + async function generateVerificationFileForRepo() { + if (!pageData.repoOwnerPubkey || !userPubkeyHex) { + error = 'Unable to generate verification file: missing repository or user information'; + return; + } + + try { + // Fetch the repository announcement event + const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]); + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [pageData.repoOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length === 0) { + error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.'; + return; + } + + const announcement = events[0] as NostrEvent; + verificationFileContent = generateVerificationFile(announcement, pageData.repoOwnerPubkey); + showVerificationDialog = true; + } catch (err) { + console.error('Failed to generate verification file:', err); + error = `Failed to generate verification file: ${err instanceof Error ? err.message : String(err)}`; + } + } + + function copyVerificationToClipboard() { + if (!verificationFileContent) return; + + navigator.clipboard.writeText(verificationFileContent).then(() => { + alert('Verification file content copied to clipboard!'); + }).catch((err) => { + console.error('Failed to copy:', err); + alert('Failed to copy to clipboard. Please select and copy manually.'); + }); + } + + function downloadVerificationFile() { + if (!verificationFileContent) return; + + const blob = new Blob([verificationFileContent], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = VERIFICATION_FILE_PATH; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + async function loadBranches() { try { const response = await fetch(`/api/repos/${npub}/${repo}/branches`); @@ -1721,20 +1782,21 @@ {#if isMaintainer} Settings {/if} + {#if pageData.repoOwnerPubkey && userPubkeyHex === pageData.repoOwnerPubkey && verificationStatus?.verified !== true} + + {/if} {#if isMaintainer} {/if} - - Verified - {#if isMaintainer} - Maintainer - {:else} - Authenticated (Contributor) - {/if} - {/if} @@ -1749,8 +1811,8 @@ {#if verificationStatus} {#if verificationStatus.verified} - Verified - Verified + Verified Repo Ownership + Verified Repo Ownership {:else} Unverified Unverified @@ -2508,6 +2570,46 @@ {/if} + + + {#if showVerificationDialog && verificationFileContent} + + {/if}