diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 459dee0..249d993 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -107,3 +107,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772141183,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"b92b203686c0629409fef055e7f3189cf9f26be5cca0253ab00cf7e8498e1115","sig":"06a13aac9d2f794e52b0416044db6ebf9dd248d254d2166d7e7f3fefd2b7d37d1a85072c3e92316898c31068e25cf37bc5afd2fcd8ae2050d0a30b1bc1973678"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142448,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 11"]],"content":"Signed commit: refactor 11","id":"bb9d5c56a291e48221df96868fb925e309cb560aa350c2cf5f9c4ddd5e5c4a6b","sig":"75662c916bf4d8bb3d70cdae4e4882382692c6f1ca67598a69abe3dc96069ef6f2bda5a1b8f91b724aa43b3cb3c6b8ad6cbce286b5d165377a34a881e7275d2a"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142558,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove redundancy"]],"content":"Signed commit: remove redundancy","id":"11ac91151bebd4dd49b91bcdef7b0b7157f0afd8ce710f7231be4860fb073d08","sig":"a7efcafa5ea83a0c37eae4562a84a7581c3d5c5dd1416f8f3e2bd2633d8523ae0eb7cc56dc4292c127ea16fb2dd5bc639483cb096263a850956b47312ed7ff6f"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772182112,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 12"]],"content":"Signed commit: refactor 12","id":"73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774","sig":"0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca"} diff --git a/src/app.html b/src/app.html index 54f3652..4f01d56 100644 --- a/src/app.html +++ b/src/app.html @@ -17,6 +17,28 @@ document.documentElement.setAttribute('data-theme', 'dark'); } })(); + + // Handle unhandled promise rejections from relay errors + // This prevents console errors from relay payment/restriction messages + window.addEventListener('unhandledrejection', (event) => { + const reason = event.reason; + const errorMessage = reason instanceof Error ? reason.message : String(reason); + + // Handle relay-specific errors gracefully (payment requirements, restrictions, etc.) + if (errorMessage.includes('restricted') || + errorMessage.includes('Pay on') || + errorMessage.includes('payment required') || + errorMessage.includes('rate limit') || + errorMessage.includes('bad req')) { + // These are expected relay errors - prevent them from showing as uncaught errors + event.preventDefault(); + // Optionally log for debugging (but don't spam console) + if (typeof console !== 'undefined' && console.debug) { + console.debug('[Relay]', errorMessage); + } + } + // Other unhandled rejections will still be logged by the browser + }); %sveltekit.head% diff --git a/src/lib/components/RepoHeaderEnhanced.svelte b/src/lib/components/RepoHeaderEnhanced.svelte index e74d262..63f46af 100644 --- a/src/lib/components/RepoHeaderEnhanced.svelte +++ b/src/lib/components/RepoHeaderEnhanced.svelte @@ -42,6 +42,7 @@ needsClone?: boolean; allMaintainers?: Array<{ pubkey: string; isOwner: boolean }>; onCopyEventId?: () => void; + onRemoveFromServer?: () => void; topics?: string[]; } @@ -84,6 +85,7 @@ needsClone = false, allMaintainers = [], onCopyEventId, + onRemoveFromServer, topics = [] }: Props = $props(); @@ -268,6 +270,14 @@ {deletingAnnouncement ? 'Deleting...' : 'Delete Announcement'} {/if} + {#if onRemoveFromServer} + + {/if} {/if} diff --git a/src/lib/components/UserBadge.svelte b/src/lib/components/UserBadge.svelte index e35e7b2..8dc7eb2 100644 --- a/src/lib/components/UserBadge.svelte +++ b/src/lib/components/UserBadge.svelte @@ -5,6 +5,8 @@ import { KIND } from '../types/nostr.js'; import { eventCache } from '../services/nostr/event-cache.js'; import { nip19 } from 'nostr-tools'; + import { userStore } from '../stores/user-store.js'; + import { hasUnlimitedAccess } from '../utils/user-access.js'; interface Props { pubkey: string; @@ -14,6 +16,38 @@ let { pubkey, disableLink = false, inline = false }: Props = $props(); + // Check if this user has unlimited access (verified) + const isVerified = $derived.by(() => { + const currentUser = $userStore; + // Check if the pubkey matches the current user's pubkey + try { + // Convert pubkey to hex for comparison + let pubkeyHex: string; + if (/^[0-9a-f]{64}$/i.test(pubkey)) { + pubkeyHex = pubkey.toLowerCase(); + } else { + try { + const decoded = nip19.decode(pubkey); + if (decoded.type === 'npub') { + pubkeyHex = decoded.data as string; + } else { + return false; + } + } catch { + return false; + } + } + + // Compare with current user's pubkey + if (currentUser?.userPubkeyHex === pubkeyHex) { + return hasUnlimitedAccess(currentUser.userLevel); + } + } catch { + // If comparison fails, not verified + } + return false; + }); + // Convert pubkey to npub for navigation (reactive) const profileUrl = $derived.by(() => { try { @@ -200,11 +234,16 @@ {/if} {:else if disableLink}
- {#if userProfile?.picture} - Profile - {:else} - Profile - {/if} +
+ {#if userProfile?.picture} + Profile + {:else} + Profile + {/if} + {#if isVerified} +
+ {/if} +
{truncateHandle(userProfile?.name)}
{:else} @@ -215,11 +254,16 @@ e.stopPropagation(); }} > - {#if userProfile?.picture} - Profile - {:else} - Profile - {/if} +
+ {#if userProfile?.picture} + Profile + {:else} + Profile + {/if} + {#if isVerified} +
+ {/if} +
{truncateHandle(userProfile?.name)} {/if} @@ -244,12 +288,18 @@ background: var(--bg-secondary); } + .user-badge-avatar-wrapper { + position: relative; + display: inline-block; + flex-shrink: 0; + } + .user-badge-avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; - flex-shrink: 0; + display: block; } .user-badge-avatar-fallback { @@ -257,6 +307,62 @@ opacity: 0.7; } + /* Theme-aware laurel wreath verification indicator */ + .verification-wreath { + position: absolute; + top: -3px; + left: -3px; + right: -3px; + bottom: -3px; + border-radius: 50%; + border: 2.5px solid var(--accent); /* Theme-aware accent color */ + pointer-events: none; + /* Create laurel wreath effect with theme-aware glow */ + box-shadow: + 0 0 0 1px var(--accent), + inset 0 0 0 1px var(--accent), + /* Decorative shadows for laurel wreath effect */ + 2px -2px 0 -1px var(--accent), + -2px 2px 0 -1px var(--accent), + 2px 2px 0 -1px var(--accent), + -2px -2px 0 -1px var(--accent), + /* Subtle outer glow */ + 0 0 4px var(--accent); + filter: opacity(0.85); + animation: verification-pulse 2s ease-in-out infinite; + } + + .user-badge-avatar-wrapper { + cursor: help; /* Show help cursor to indicate tooltip */ + } + + .user-badge-avatar-wrapper:hover .verification-wreath { + border-color: var(--accent-hover); /* Theme-aware hover color */ + filter: opacity(1); + box-shadow: + 0 0 0 1px var(--accent-hover), + inset 0 0 0 1px var(--accent-hover), + /* Enhanced laurel wreath effect on hover */ + 2px -2px 0 -1px var(--accent-hover), + -2px 2px 0 -1px var(--accent-hover), + 2px 2px 0 -1px var(--accent-hover), + -2px -2px 0 -1px var(--accent-hover), + /* Enhanced glow on hover */ + 0 0 8px var(--accent-hover), + 0 0 12px var(--accent); + } + + @keyframes verification-pulse { + 0%, 100% { + opacity: 1; + transform: scale(1); + } + 50% { + opacity: 0.85; + transform: scale(1.02); + } + } + .user-badge-name { font-size: 0.875rem; color: var(--text-primary); diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index 5e0ad59..aee8681 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -70,10 +70,28 @@ export class RepoManager { * @param event - The repo announcement event * @param selfTransferEvent - Optional self-transfer event to include in initial commit * @param isExistingRepo - Whether this is an existing repo being added to the server + * @param allowMissingDomainUrl - In development, allow provisioning even if domain URL isn't in announcement */ - async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false): Promise { + async provisionRepo(event: NostrEvent, selfTransferEvent?: NostrEvent, isExistingRepo: boolean = false, allowMissingDomainUrl: boolean = false): Promise { const cloneUrls = this.urlParser.extractCloneUrls(event); - const domainUrl = cloneUrls.find(url => url.includes(this.domain)); + let domainUrl = cloneUrls.find(url => url.includes(this.domain)); + + // In development, if domain URL not found and allowed, construct it from the event + if (!domainUrl && allowMissingDomainUrl) { + const isLocalhost = this.domain.includes('localhost') || this.domain.includes('127.0.0.1'); + if (isLocalhost) { + // Extract npub and repo name from event + const dTag = event.tags.find(t => t[0] === 'd')?.[1]; + if (dTag) { + const protocol = this.domain.startsWith('localhost') || this.domain.startsWith('127.0.0.1') ? 'http' : 'https'; + // Get npub from event pubkey + const { nip19 } = await import('nostr-tools'); + const npub = nip19.npubEncode(event.pubkey); + domainUrl = `${protocol}://${this.domain}/${npub}/${dTag}.git`; + logger.info({ domain: this.domain, npub, repo: dTag, constructedUrl: domainUrl }, 'Constructed domain URL for development provisioning'); + } + } + } if (!domainUrl) { throw new Error(`No ${this.domain} URL found in repo announcement`); @@ -125,16 +143,29 @@ export class RepoManager { const git = simpleGit(); await git.init(['--bare', repoPath.fullPath]); - // Ensure announcement event is saved to nostr/repo-events.jsonl in the repository - await this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent); - // If there are other clone URLs, sync from them after creating the repo if (otherUrls.length > 0) { const remoteUrls = this.urlParser.prepareRemoteUrls(otherUrls); await this.remoteSync.syncFromRemotes(repoPath.fullPath, remoteUrls); - } else { - // No external URLs - this is a brand new repo, create initial branch and README + } + + // Check if branches exist after sync (if any) + const repoGit = simpleGit(repoPath.fullPath); + let hasBranches = false; + try { + const branches = await repoGit.branch(['-a']); + hasBranches = branches.all.length > 0; + } catch { + hasBranches = false; + } + + if (!hasBranches) { + // No branches exist - create initial branch and README (which includes announcement) await this.createInitialBranchAndReadme(repoPath.fullPath, repoPath.npub, repoPath.repoName, event); + } else { + // Branches exist (from sync) - ensure announcement is committed to the default branch + // This must happen after syncing so we can commit it to the existing default branch + await this.announcementManager.ensureAnnouncementInRepo(repoPath.fullPath, event, selfTransferEvent); } } else { // For existing repos, check if announcement exists in repo @@ -169,8 +200,48 @@ export class RepoManager { announcementEvent: NostrEvent ): Promise { try { - // Get default branch from environment or use 'master' - const defaultBranch = process.env.DEFAULT_BRANCH || 'master'; + // Get default branch from git config, environment, or use 'master' + // Check git's init.defaultBranch config first (respects user's git settings) + let defaultBranch = process.env.DEFAULT_BRANCH || 'master'; + + try { + const git = simpleGit(); + // Try to get git's default branch setting + const defaultBranchConfig = await git.raw(['config', '--get', 'init.defaultBranch']).catch(() => null); + if (defaultBranchConfig && defaultBranchConfig.trim()) { + defaultBranch = defaultBranchConfig.trim(); + } + } catch { + // If git config fails, use environment or fallback to 'master' + } + + // Check if any branches already exist (e.g., from a remote sync) + const repoGit = simpleGit(repoPath); + let existingBranches: string[] = []; + try { + const branches = await repoGit.branch(['-a']); + existingBranches = branches.all.map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '').replace(/^refs\/heads\//, '')); + // Remove duplicates + existingBranches = [...new Set(existingBranches)]; + + // If branches exist, check if one matches our default branch preference + if (existingBranches.length > 0) { + // Prefer existing branches that match common defaults + const preferredBranches = [defaultBranch, 'main', 'master', 'dev']; + for (const preferred of preferredBranches) { + if (existingBranches.includes(preferred)) { + defaultBranch = preferred; + break; + } + } + // If no match, use the first existing branch + if (!existingBranches.includes(defaultBranch)) { + defaultBranch = existingBranches[0]; + } + } + } catch { + // No branches exist, use the determined default + } // Get repo name from d-tag or use repoName from path const dTag = announcementEvent.tags.find(t => t[0] === 'd')?.[1] || repoName; @@ -203,19 +274,9 @@ Your commits will all be signed by your Nostr keys and saved to the event files const { FileManager } = await import('./file-manager.js'); const fileManager = new FileManager(this.repoRoot); - // For a new repo with no branches, we need to create an orphan branch first - // Check if repo has any branches - const git = simpleGit(repoPath); - let hasBranches = false; - try { - const branches = await git.branch(['-a']); - hasBranches = branches.all.length > 0; - } catch { - // No branches exist - hasBranches = false; - } - - if (!hasBranches) { + // If no branches exist, create an orphan branch + // We already checked for existing branches above, so if existingBranches is empty, create one + if (existingBranches.length === 0) { // Create orphan branch first (pass undefined for fromBranch to create orphan) await fileManager.createBranch(npub, repoName, defaultBranch, undefined); } @@ -359,17 +420,25 @@ Your commits will all be signed by your Nostr keys and saved to the event files let remoteUrls: string[] = []; try { - // Prepare remote URLs (filters out localhost/our domain, converts SSH to HTTPS) - remoteUrls = this.urlParser.prepareRemoteUrls(cloneUrls); - - if (remoteUrls.length === 0) { - logger.warn({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found for on-demand fetch'); - return { success: false, needsAnnouncement: false }; - } + // Check if we're in development mode (localhost) + const isLocalhost = this.domain.includes('localhost') || this.domain.includes('127.0.0.1'); - logger.debug({ npub, repoName, cloneUrls, remoteUrls, isPublic }, 'On-demand fetch details'); + // If in development, prefer localhost URLs from GIT_DOMAIN + if (isLocalhost) { + // Construct localhost URL from GIT_DOMAIN + const protocol = this.domain.startsWith('localhost') || this.domain.startsWith('127.0.0.1') ? 'http' : 'https'; + const localhostUrl = `${protocol}://${this.domain}/${npub}/${repoName}.git`; + + // Always use localhost URL in development, even if it's not in the clone URLs + // This allows cloning from the local server during development + remoteUrls = [localhostUrl]; + logger.info({ npub, repoName, url: localhostUrl, domain: this.domain }, 'Using localhost URL for development clone'); + } else { + // Prepare remote URLs (filters out localhost/our domain, converts SSH to HTTPS) + remoteUrls = this.urlParser.prepareRemoteUrls(cloneUrls); + } - // Check if repoRoot exists and is writable + // Check if repoRoot exists and is writable (needed for both provisioning and cloning) if (!existsSync(this.repoRoot)) { try { mkdirSync(this.repoRoot, { recursive: true }); @@ -417,6 +486,42 @@ Your commits will all be signed by your Nostr keys and saved to the event files } } + if (remoteUrls.length === 0) { + // No remote URLs - this is an empty repo, provision it instead + logger.info({ npub, repoName, cloneUrls, announcementEventId: announcementEvent.id }, 'No remote clone URLs found - provisioning empty repository'); + try { + await this.provisionRepo(announcementEvent, undefined, false); + logger.info({ npub, repoName }, 'Empty repository provisioned successfully'); + return { success: true, cloneUrls, remoteUrls: [] }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + logger.error({ npub, repoName, error: error.message }, 'Failed to provision empty repository'); + return { success: false, error: error.message, cloneUrls, remoteUrls: [] }; + } + } + + logger.debug({ npub, repoName, cloneUrls, remoteUrls, isPublic }, 'On-demand fetch details'); + + // In development mode, if using localhost URL, check if repo exists locally first + // If it doesn't exist, provision it instead of trying to clone from non-existent URL + if (isLocalhost && remoteUrls[0].includes(this.domain)) { + const localRepoPath = join(this.repoRoot, npub, `${repoName}.git`); + if (!existsSync(localRepoPath)) { + // Repo doesn't exist on localhost - provision it instead + logger.info({ npub, repoName, url: remoteUrls[0] }, 'Localhost URL specified but repo does not exist locally - provisioning instead'); + try { + // In development, allow provisioning even if domain URL isn't in announcement + await this.provisionRepo(announcementEvent, undefined, false, true); + logger.info({ npub, repoName }, 'Repository provisioned successfully on localhost'); + return { success: true, cloneUrls, remoteUrls: [] }; + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)); + logger.error({ npub, repoName, error: error.message }, 'Failed to provision repository on localhost'); + return { success: false, error: error.message, cloneUrls, remoteUrls: [] }; + } + } + } + // Get git environment for URL (handles Tor proxy, etc.) const gitEnv = this.remoteSync.getGitEnvForUrl(remoteUrls[0]); @@ -430,7 +535,8 @@ Your commits will all be signed by your Nostr keys and saved to the event files repoName, sourceUrl: remoteUrls[0], cloneUrls, - authenticated: isAuthenticated + authenticated: isAuthenticated, + isLocalhost }, 'Fetching repository on-demand from remote'); // Clone as bare repository with timeout diff --git a/src/lib/services/git/repo-url-parser.ts b/src/lib/services/git/repo-url-parser.ts index 4e326ce..2e38f1f 100644 --- a/src/lib/services/git/repo-url-parser.ts +++ b/src/lib/services/git/repo-url-parser.ts @@ -90,8 +90,50 @@ export class RepoUrlParser { /** * Filter and prepare remote URLs from clone URLs * Respects the repo owner's order in the clone list + * In development (localhost), prefers localhost URLs over remote URLs */ prepareRemoteUrls(cloneUrls: string[]): string[] { + // Check if we're in development mode (localhost) + const isLocalhost = this.domain.includes('localhost') || this.domain.includes('127.0.0.1'); + + // In development, prefer localhost URLs + if (isLocalhost) { + const localhostUrls: string[] = []; + const otherUrls: string[] = []; + + for (const url of cloneUrls) { + const lowerUrl = url.toLowerCase(); + if (lowerUrl.includes('localhost') || + lowerUrl.includes('127.0.0.1') || + url.includes(this.domain)) { + localhostUrls.push(url); + } else { + otherUrls.push(url); + } + } + + // Prefer localhost URLs in development + if (localhostUrls.length > 0) { + return localhostUrls; + } + + // Fall back to other URLs if no localhost URLs found + if (otherUrls.length > 0) { + return this.prepareRemoteUrlsFromList(otherUrls); + } + + // If no URLs at all, return empty + return []; + } + + // Production mode: filter out localhost/our domain + return this.prepareRemoteUrlsFromList(cloneUrls); + } + + /** + * Helper method to prepare remote URLs from a list (filters out localhost/our domain) + */ + private prepareRemoteUrlsFromList(cloneUrls: string[]): string[] { const httpsUrls: string[] = []; const sshUrls: string[] = []; @@ -132,12 +174,6 @@ export class RepoUrlParser { remoteUrls = cloneUrls.filter(url => !url.includes(this.domain)); } - // If still no remote URLs, but there are *any* clone URLs, try the first one - // This handles cases where the only clone URL is our own domain, but the repo doesn't exist locally yet - if (remoteUrls.length === 0 && cloneUrls.length > 0) { - remoteUrls.push(cloneUrls[0]); - } - return remoteUrls; } diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index f3d64d9..d741d77 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -436,19 +436,54 @@ export class NostrClient { const failed: Array<{ relay: string; error: string }> = []; // Use nostr-tools SimplePool to publish to all relays - // Wrap in Promise.resolve().then() to catch any synchronous errors and ensure all async errors are caught + // SimplePool.publish can throw errors from WebSocket handlers that aren't caught by normal try-catch + // We need to wrap it carefully to catch all errors try { - // Create a promise that will catch all errors, including those from WebSocket event handlers - const publishPromise = Promise.resolve().then(async () => { + // Wrap publish in a promise that catches all errors, including unhandled promise rejections + const publishPromise = new Promise((resolve, reject) => { + // Set up a timeout to prevent hanging + const timeout = setTimeout(() => { + reject(new Error('Publish timeout after 30 seconds')); + }, 30000); + + // Publish to relays - wrap in try-catch to catch synchronous errors try { - await this.pool.publish(targetRelays, event); - // If publish succeeded, all relays succeeded - // Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded - return targetRelays; - } catch (error) { - // If publish failed, mark all as failed - // In a more sophisticated implementation, we could check individual relays - throw error; + // SimplePool.publish returns a promise, but errors from individual relays + // may not be properly caught. We'll handle them at multiple levels. + const poolPublishPromise = this.pool.publish(targetRelays, event); + + // Handle the promise result + poolPublishPromise + .then(() => { + clearTimeout(timeout); + // If publish succeeded, all relays succeeded + // Note: SimplePool.publish doesn't return per-relay results, so we assume all succeeded + resolve(targetRelays); + }) + .catch((error: unknown) => { + clearTimeout(timeout); + // Handle specific relay errors gracefully + const errorMessage = error instanceof Error ? error.message : String(error); + + // Check for common relay error messages that shouldn't be fatal + if (errorMessage.includes('restricted') || + errorMessage.includes('Pay on') || + errorMessage.includes('payment required') || + errorMessage.includes('rate limit')) { + // These are relay-specific restrictions, not fatal errors + // Log but don't fail - we'll mark relays as failed below + logger.debug({ error: errorMessage, eventId: event.id }, 'Relay restriction encountered (payment/rate limit)'); + // Resolve with empty success - we'll mark all as failed below + resolve([]); + } else { + // Other errors should be rejected + reject(error); + } + }); + } catch (syncError) { + // Catch any synchronous errors + clearTimeout(timeout); + reject(syncError); } }); @@ -458,14 +493,21 @@ export class NostrClient { new Promise((_, reject) => setTimeout(() => reject(new Error('Publish timeout')), 30000) ) - ]).catch(error => { + ]).catch((error: unknown) => { // Log error but don't throw - we'll mark relays as failed below - logger.debug({ error, eventId: event.id }, 'Error publishing event to relays'); - return null; + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug({ error: errorMessage, eventId: event.id }, 'Error publishing event to relays'); + return []; }); - if (publishedRelays) { + if (publishedRelays && publishedRelays.length > 0) { success.push(...publishedRelays); + // Mark any relays not in success as failed + targetRelays.forEach(relay => { + if (!publishedRelays.includes(relay)) { + failed.push({ relay, error: 'Relay did not accept event' }); + } + }); } else { // If publish failed or timed out, mark all as failed targetRelays.forEach(relay => { @@ -474,9 +516,10 @@ export class NostrClient { } } catch (error) { // Catch any synchronous errors - logger.debug({ error, eventId: event.id }, 'Synchronous error in publishEvent'); + const errorMessage = error instanceof Error ? error.message : String(error); + logger.debug({ error: errorMessage, eventId: event.id }, 'Synchronous error in publishEvent'); targetRelays.forEach(relay => { - failed.push({ relay, error: String(error) }); + failed.push({ relay, error: errorMessage }); }); } diff --git a/src/routes/api/repos/[npub]/[repo]/clone/+server.ts b/src/routes/api/repos/[npub]/[repo]/clone/+server.ts index bb9495d..004abbe 100644 --- a/src/routes/api/repos/[npub]/[repo]/clone/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/clone/+server.ts @@ -105,18 +105,28 @@ export const POST: RequestHandler = async (event) => { logger.info({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'Verified unlimited access from proof event'); userLevel = getCachedUserLevel(userPubkeyHex); // Get the cached value } else { - // Check if relays are down - if (verification.relayDown) { - // Relays are down - check cache again (might have been cached from previous request) - userLevel = getCachedUserLevel(userPubkeyHex); - if (!userLevel || !hasUnlimitedAccess(userLevel.level)) { - logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', error: verification.error }, 'Relays down and no cached unlimited access'); - throw error(503, 'Relays are temporarily unavailable and no cached access level found. Please verify your access level first by visiting your profile page.'); - } + // Verification failed - check cache before denying access + // Cache exists for exactly this reason: to allow access when verification temporarily fails + userLevel = getCachedUserLevel(userPubkeyHex); + + if (userLevel && hasUnlimitedAccess(userLevel.level)) { + // User has cached unlimited access - use it even though verification failed + // This handles cases where relays are down or proof event hasn't propagated yet + logger.info({ + userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', + error: verification.error, + cachedLevel: userLevel.level, + cachedAt: new Date(userLevel.cachedAt).toISOString() + }, 'Verification failed but using cached unlimited access'); + } else if (verification.relayDown) { + // Relays are down and no cache - temporary issue + logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', error: verification.error }, 'Relays down and no cached unlimited access'); + throw error(503, 'Relays are temporarily unavailable and no cached access level found. Please verify your access level first by visiting your profile page.'); } else { - // Verification failed - user doesn't have write access + // Verification failed and no cache - user doesn't have write access logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', error: verification.error }, 'User does not have unlimited access'); - throw error(403, `Only users with unlimited access can clone repositories to the server. ${verification.error || 'Please verify you can write to at least one default Nostr relay.'}`); + const errorMsg = verification.error || 'Please verify you can write to at least one default Nostr relay.'; + throw error(403, `Only users with unlimited access can clone repositories to the server. ${errorMsg} Note: You only need write access to ONE default relay, not all of them.`); } } } catch (err) { @@ -132,7 +142,7 @@ export const POST: RequestHandler = async (event) => { // No proof event or auth header - check if we have any cached level if (!userLevel) { logger.warn({ userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'No cached user level and no proof event or NIP-98 auth header'); - throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify your access level first by visiting your profile page or ensuring you can write to at least one default Nostr relay.'); + throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify your access level first by visiting your profile page or ensuring you can write to at least one default Nostr relay. Note: You only need write access to ONE default relay, not all of them.'); } } } @@ -143,7 +153,7 @@ export const POST: RequestHandler = async (event) => { userPubkeyHex: userPubkeyHex.slice(0, 16) + '...', cachedLevel: userLevel?.level || 'none' }, 'User does not have unlimited access'); - throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify you can write to at least one default Nostr relay.'); + throw error(403, 'Only users with unlimited access can clone repositories to the server. Please verify you can write to at least one default Nostr relay. Note: You only need write access to ONE default relay, not all of them.'); } try { diff --git a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts index 7e36652..e0fc207 100644 --- a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts @@ -183,10 +183,11 @@ export const POST: RequestHandler = createRepoPostHandler( return error(401, 'Authentication required. Please provide userPubkey.'); } - // Check if user is a maintainer + // Check if user is a maintainer or the repository owner const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, context.repoOwnerPubkey, context.repo); - if (!isMaintainer) { - return error(403, 'Only repository maintainers can verify clone URLs.'); + const isOwner = userPubkeyHex === context.repoOwnerPubkey; + if (!isMaintainer && !isOwner) { + return error(403, 'Only repository owners and maintainers can save announcements.'); } // Check if repository is cloned diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte index 250a139..a93c313 100644 --- a/src/routes/repos/+page.svelte +++ b/src/routes/repos/+page.svelte @@ -847,16 +847,6 @@ View - {#if userPubkey && canDelete} - - {/if}
diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 30a4173..de654ac 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -192,6 +192,7 @@ generateAnnouncementFileForRepo as generateAnnouncementFileForRepoService, copyVerificationToClipboard as copyVerificationToClipboardService, downloadVerificationFile as downloadVerificationFileService, + saveAnnouncementToRepo as saveAnnouncementToRepoService, verifyCloneUrl as verifyCloneUrlService, deleteAnnouncement as deleteAnnouncementService, copyEventId as copyEventIdService @@ -598,13 +599,47 @@ await generateAnnouncementFileForRepoService(state, repoOwnerPubkeyDerived); } const copyVerificationToClipboard = () => copyVerificationToClipboardService(state); + const downloadVerificationFile = () => downloadVerificationFileService(state); + async function saveAnnouncementToRepo() { + await saveAnnouncementToRepoService(state, repoOwnerPubkeyDerived); + // Reload branches and files to show the new commit + if (state.clone.isCloned) { + await loadBranches(); + await loadFiles(); + } + } async function verifyCloneUrl() { await verifyCloneUrlService(state, repoOwnerPubkeyDerived, { checkVerification }); } async function deleteAnnouncement() { await deleteAnnouncementService(state, repoOwnerPubkeyDerived, announcementEventId); } - const downloadVerificationFile = () => downloadVerificationFileService(state); + + async function removeRepoFromServer() { + if (!confirm(`Are you sure you want to remove "${state.repo}" from this server?\n\nThis will permanently delete the local clone of the repository. The announcement on Nostr will NOT be deleted.\n\nThis action cannot be undone.\n\nClick OK to delete, or Cancel to abort.`)) { + return; + } + + try { + const headers = buildApiHeaders(); + const response = await fetch(`/api/repos/${state.npub}/${state.repo}/delete`, { + method: 'DELETE', + headers + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to delete repository'); + } + + // Redirect to repos list after successful deletion + alert('Repository removed from server successfully'); + goto('/repos'); + } catch (err) { + alert(`Failed to remove repository: ${err instanceof Error ? err.message : String(err)}`); + } + } + const downloadRepository = (ref?: string, filename?: string) => downloadRepoUtil({ npub: state.npub, repo: state.repo, ref, filename }); // Safe wrapper functions for SSR @@ -613,6 +648,7 @@ const safeToggleBookmark = () => safeAsync(() => toggleBookmark()); const safeForkRepository = () => safeAsync(() => forkRepository()); const safeCloneRepository = () => safeAsync(() => cloneRepository()); + const safeRemoveRepoFromServer = () => safeAsync(removeRepoFromServer); const safeHandleBranchChange = (branch: string) => safeSync(() => handleBranchChangeDirect(branch)); // Initialize activeTab from URL query parameter @@ -855,6 +891,18 @@ await checkVerification(); if (!state.isMounted) return; + // Log verification status for maintenance (after check completes) + if (state.verification.status) { + const status = state.verification.status; + console.log('[Page Load] Verification Status:', { + verified: status.verified, + error: status.error || null, + message: status.message || null, + cloneCount: status.cloneVerifications?.length || 0, + verifiedClones: status.cloneVerifications?.filter(cv => cv.verified).length || 0 + }); + } + await loadReadme(); if (!state.isMounted) return; @@ -1055,6 +1103,7 @@ needsClone={needsClone} allMaintainers={state.maintainers.all} onCopyEventId={copyEventId} + onRemoveFromServer={repoOwnerPubkeyDerived && state.user.pubkeyHex === repoOwnerPubkeyDerived && state.clone.isCloned ? safeRemoveRepoFromServer : undefined} /> {/if} @@ -1905,6 +1954,7 @@ {state} onCopy={copyVerificationToClipboard} onDownload={downloadVerificationFile} + onSave={state.clone.isCloned && (state.maintainers.isMaintainer || state.user.pubkeyHex === repoOwnerPubkeyDerived) ? saveAnnouncementToRepo : undefined} onClose={() => state.openDialog = null} /> diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte index 0cec054..6049945 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CloneUrlVerificationDialog.svelte @@ -73,6 +73,7 @@ .verification-code { background: var(--bg-secondary, #f5f5f5); + color: var(--text-primary); padding: 0.2rem 0.4rem; border-radius: 3px; font-family: monospace; @@ -112,8 +113,14 @@ } .primary-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .primary-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .primary-button:disabled { @@ -122,7 +129,15 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover:not(:disabled) { + background: var(--bg-secondary); } .cancel-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CommitDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CommitDialog.svelte index 12ef4b1..3d6a52f 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CommitDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CommitDialog.svelte @@ -84,12 +84,26 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateBranchDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateBranchDialog.svelte index f3307c9..864499b 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateBranchDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateBranchDialog.svelte @@ -81,12 +81,26 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateFileDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateFileDialog.svelte index b63a31c..7f53612 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateFileDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateFileDialog.svelte @@ -86,12 +86,26 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateIssueDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateIssueDialog.svelte index 5ec8ad1..35904a6 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateIssueDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateIssueDialog.svelte @@ -75,18 +75,26 @@ } .cancel-button { - background: var(--cancel-bg, var(--bg-secondary, #2a2a2a)); - color: var(--text-primary, #e0e0e0); - border: 1px solid var(--border-color, #333); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; } .cancel-button:hover { - background: var(--bg-hover, #3a3a3a); + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, var(--accent-color, #2196f3)); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:hover { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CreatePRDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CreatePRDialog.svelte index bf4ae2d..3538267 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CreatePRDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CreatePRDialog.svelte @@ -83,18 +83,26 @@ } .cancel-button { - background: var(--cancel-bg, var(--bg-secondary, #2a2a2a)); - color: var(--text-primary, #e0e0e0); - border: 1px solid var(--border-color, #333); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; } .cancel-button:hover { - background: var(--bg-hover, #3a3a3a); + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, var(--accent-color, #2196f3)); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:hover { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CreatePatchDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CreatePatchDialog.svelte index 8ef42a7..caa310b 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CreatePatchDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CreatePatchDialog.svelte @@ -79,18 +79,26 @@ } .cancel-button { - background: var(--cancel-bg, var(--bg-secondary, #2a2a2a)); - color: var(--text-primary, #e0e0e0); - border: 1px solid var(--border-color, #333); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; } .cancel-button:hover { - background: var(--bg-hover, #3a3a3a); + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, var(--accent-color, #2196f3)); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:hover { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte index a7afc8e..c648812 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateReleaseDialog.svelte @@ -81,12 +81,26 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateTagDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateTagDialog.svelte index 4eaa454..3953567 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateTagDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateTagDialog.svelte @@ -71,12 +71,26 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte index 01f0941..adb742a 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/CreateThreadDialog.svelte @@ -64,12 +64,26 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/PatchCommentDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/PatchCommentDialog.svelte index f23ac7a..864b0f5 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/PatchCommentDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/PatchCommentDialog.svelte @@ -69,12 +69,26 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/PatchHighlightDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/PatchHighlightDialog.svelte index 7ae3b7b..8057a38 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/PatchHighlightDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/PatchHighlightDialog.svelte @@ -79,12 +79,26 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/ReplyDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/ReplyDialog.svelte index cd75ef6..e986ed4 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/ReplyDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/ReplyDialog.svelte @@ -71,12 +71,26 @@ } .cancel-button { - background: var(--cancel-bg, #e0e0e0); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); } .save-button { - background: var(--primary-color, #2196f3); - color: white; + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); } .save-button:disabled { diff --git a/src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte b/src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte index 89be781..9364fcc 100644 --- a/src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte +++ b/src/routes/repos/[npub]/[repo]/components/dialogs/VerificationDialog.svelte @@ -7,10 +7,11 @@ state: RepoState; onCopy: () => void; onDownload: () => void; + onSave?: () => void; onClose: () => void; } - let { open, state, onCopy, onDownload, onClose }: Props = $props(); + let { open, state, onCopy, onDownload, onSave, onClose }: Props = $props(); @@ -31,7 +32,12 @@
@@ -47,6 +53,7 @@ .verification-code { background: var(--bg-secondary, #f5f5f5); + color: var(--text-primary); padding: 0.2rem 0.4rem; border-radius: 3px; font-family: monospace; @@ -70,6 +77,7 @@ .filename { font-weight: bold; font-family: monospace; + color: var(--text-primary); } .file-actions { @@ -82,8 +90,17 @@ padding: 0.25rem 0.75rem; border: 1px solid var(--border-color, #e0e0e0); border-radius: 4px; - background: white; + background: var(--button-secondary, var(--bg-tertiary)); + color: var(--text-primary); cursor: pointer; + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .copy-button:hover, + .download-button:hover { + background: var(--button-secondary-hover, var(--bg-tertiary)); + opacity: 0.9; } .file-content { @@ -92,12 +109,15 @@ overflow-x: auto; max-height: 400px; overflow-y: auto; + background: var(--bg-primary); + color: var(--text-primary); } .file-content code { font-family: monospace; font-size: 0.85rem; white-space: pre; + color: var(--text-primary); } .modal-actions { @@ -108,10 +128,37 @@ } .cancel-button { + padding: 0.5rem 1rem; + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; + background: var(--bg-tertiary); + color: var(--text-primary); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease, color 0.2s ease; + } + + .cancel-button:hover { + background: var(--bg-secondary); + } + + .save-button { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; - background: var(--cancel-bg, #e0e0e0); + background: var(--button-primary); + color: var(--accent-text, #ffffff); + font-family: 'IBM Plex Serif', serif; + transition: background 0.2s ease; + } + + .save-button:hover:not(:disabled) { + background: var(--button-primary-hover); + } + + .save-button:disabled { + opacity: 0.5; + cursor: not-allowed; } diff --git a/src/routes/repos/[npub]/[repo]/services/repo-operations.ts b/src/routes/repos/[npub]/[repo]/services/repo-operations.ts index 072e66f..9e9d982 100644 --- a/src/routes/repos/[npub]/[repo]/services/repo-operations.ts +++ b/src/routes/repos/[npub]/[repo]/services/repo-operations.ts @@ -13,6 +13,8 @@ import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { KIND } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js'; import { goto } from '$app/navigation'; +import logger from '$lib/services/logger.js'; +import { isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; interface RepoOperationsCallbacks { checkCloneStatus: (force: boolean) => Promise; @@ -110,9 +112,94 @@ export async function cloneRepository( ): Promise { if (state.clone.cloning) return; + if (!state.user.pubkeyHex) { + alert('Please log in to clone repositories.'); + return; + } + state.clone.cloning = true; try { - const data = await apiPost<{ alreadyExists?: boolean }>(`/api/repos/${state.npub}/${state.repo}/clone`, {}); + // Create and send proof event to verify write access + // This ensures the clone endpoint can verify access even if cache is empty + let proofEvent: NostrEvent | null = null; + try { + if (isNIP07Available() && state.user.pubkeyHex) { + const { createProofEvent } = await import('$lib/services/nostr/relay-write-proof.js'); + const { signEventWithNIP07 } = await import('$lib/services/nostr/nip07-signer.js'); + + // Create proof event template + const proofEventTemplate = createProofEvent( + state.user.pubkeyHex, + `gitrepublic-clone-proof-${Date.now()}` + ); + + // Sign with NIP-07 + proofEvent = await signEventWithNIP07(proofEventTemplate); + + // Publish to relays so server can verify it + // User only needs write access to ONE relay, not all + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + let publishResult; + try { + publishResult = await nostrClient.publishEvent(proofEvent, DEFAULT_NOSTR_RELAYS).catch((err: unknown) => { + // Catch any unhandled errors from publishEvent + const errorMessage = err instanceof Error ? err.message : String(err); + logger.debug({ error: errorMessage }, '[Clone] Error in publishEvent, marking all relays as failed'); + // Return a result with all relays failed + return { + success: [], + failed: DEFAULT_NOSTR_RELAYS.map(relay => ({ relay, error: errorMessage })) + }; + }); + } catch (err) { + // Catch synchronous errors + const errorMessage = err instanceof Error ? err.message : String(err); + logger.debug({ error: errorMessage }, '[Clone] Synchronous error publishing proof event'); + publishResult = { + success: [], + failed: DEFAULT_NOSTR_RELAYS.map(relay => ({ relay, error: errorMessage })) + }; + } + + // If at least one relay accepted the event, wait for propagation + // If all relays failed, still try (might be cached or server can retry) + if (publishResult && publishResult.success.length > 0) { + logger.debug({ + successCount: publishResult.success.length, + successfulRelays: publishResult.success, + failedRelays: publishResult.failed.map(f => f.relay) + }, '[Clone] Proof event published to at least one relay, waiting for propagation'); + // Wait longer for event to propagate to relays (3 seconds should be enough) + await new Promise(resolve => setTimeout(resolve, 3000)); + } else { + const failedDetails = publishResult?.failed.map(f => `${f.relay}: ${f.error}`) || ['Unknown error']; + logger.warn({ + failedRelays: failedDetails + }, '[Clone] Proof event failed to publish to all relays, but continuing (server may retry or use cache)'); + // Still wait a bit in case some relays are slow + await new Promise(resolve => setTimeout(resolve, 2000)); + } + + // Clean up client + try { + nostrClient.close(); + } catch (closeErr) { + // Ignore close errors + logger.debug({ error: closeErr }, '[Clone] Error closing NostrClient'); + } + } + } catch (proofErr) { + // If proof creation fails, continue anyway - clone endpoint will check cache + logger.debug({ error: proofErr }, '[Clone] Failed to create proof event, will rely on cache'); + } + + // Send clone request with proof event in body (if available) + const requestBody: { proofEvent?: NostrEvent } = {}; + if (proofEvent) { + requestBody.proofEvent = proofEvent; + } + + const data = await apiPost<{ alreadyExists?: boolean }>(`/api/repos/${state.npub}/${state.repo}/clone`, requestBody); if (data.alreadyExists) { alert('Repository already exists locally.'); @@ -382,7 +469,25 @@ export async function checkVerification( state.verification.status = { verified: false, error: 'Failed to check verification' }; } finally { state.loading.verification = false; - console.log('[Verification] Status after check:', state.verification.status); + + // Log verification status for maintenance + if (state.verification.status) { + const status = state.verification.status; + console.log('[Verification Status]', { + verified: status.verified, + error: status.error || null, + message: status.message || null, + cloneCount: status.cloneVerifications?.length || 0, + verifiedClones: status.cloneVerifications?.filter(cv => cv.verified).length || 0, + cloneDetails: status.cloneVerifications?.map(cv => ({ + url: cv.url.substring(0, 50) + (cv.url.length > 50 ? '...' : ''), + verified: cv.verified, + error: cv.error || null + })) || [] + }); + } else { + console.log('[Verification Status] Not available'); + } } } @@ -593,26 +698,36 @@ export async function generateAnnouncementFileForRepo( } try { - // Fetch the repository announcement event - const { NostrClient } = await import('$lib/services/nostr/nostr-client.js'); - const { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } = await import('$lib/config.js'); - const { KIND } = await import('$lib/types/nostr.js'); - const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]); - const events = await nostrClient.fetchEvents([ - { - kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [repoOwnerPubkeyDerived], - '#d': [state.repo], - limit: 1 + // First, try to use the announcement from pageData (already loaded) + let announcement: NostrEvent | null = null; + + if (state.pageData?.announcement) { + announcement = state.pageData.announcement as NostrEvent; + } + + // If not available in pageData, fetch from Nostr + if (!announcement) { + const { NostrClient } = await import('$lib/services/nostr/nostr-client.js'); + const { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } = await import('$lib/config.js'); + const { KIND } = await import('$lib/types/nostr.js'); + const nostrClient = new NostrClient([...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]); + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkeyDerived], + '#d': [state.repo], + limit: 1 + } + ]); + + if (events.length === 0) { + state.error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.'; + return; } - ]); - if (events.length === 0) { - state.error = 'Repository announcement not found. Please ensure the repository is registered on Nostr.'; - return; + announcement = events[0] as NostrEvent; } - const announcement = events[0] as NostrEvent; // Generate announcement event JSON (for download/reference) state.verification.fileContent = JSON.stringify(announcement, null, 2) + '\n'; state.openDialog = 'verification'; @@ -636,6 +751,53 @@ export function copyVerificationToClipboard(state: RepoState): void { }); } +/** + * Save announcement to repository + * Uses the existing verify endpoint which saves and commits the announcement + */ +export async function saveAnnouncementToRepo( + state: RepoState, + repoOwnerPubkeyDerived: string | null +): Promise { + if (!repoOwnerPubkeyDerived || !state.user.pubkeyHex) { + state.error = 'Unable to save announcement: missing repository or user information'; + return; + } + + if (!state.clone.isCloned) { + state.error = 'Repository must be cloned first. Please clone the repository before saving the announcement.'; + return; + } + + // Check if user is owner or maintainer + if (!state.maintainers.isMaintainer && state.user.pubkeyHex !== repoOwnerPubkeyDerived) { + state.error = 'Only repository owners and maintainers can save announcements.'; + return; + } + + try { + state.creating.announcement = true; + state.error = null; + + // Use the existing verify endpoint which saves and commits the announcement + const data = await apiRequest<{ message?: string; announcementId?: string }>(`/api/repos/${state.npub}/${state.repo}/verify`, { + method: 'POST' + } as RequestInit); + + // Close dialog and show success + state.openDialog = null; + alert(data.message || 'Announcement saved to repository successfully!'); + + // Reload branches and files to show the new commit + // The callbacks will be passed from the component + } catch (err) { + console.error('Failed to save announcement:', err); + state.error = `Failed to save announcement: ${err instanceof Error ? err.message : String(err)}`; + } finally { + state.creating.announcement = false; + } +} + /** * Download verification file */