diff --git a/src/hooks.server.ts b/src/hooks.server.ts index d8ebb3f..076dd7b 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -100,6 +100,7 @@ export const handle: Handle = async ({ event, resolve }) => { response.headers.set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()'); // Add CSP header (Content Security Policy) + // Allow frames from common git hosting platforms for web URL previews const csp = [ "default-src 'self'", "script-src 'self' 'unsafe-inline' 'unsafe-eval'", // unsafe-eval needed for Svelte @@ -107,6 +108,7 @@ export const handle: Handle = async ({ event, resolve }) => { "img-src 'self' data: https:", "font-src 'self' data: https://fonts.gstatic.com", "connect-src 'self' wss: https:", + "frame-src 'self' https:", // Allow iframes from same origin and HTTPS URLs (for web URL previews) "frame-ancestors 'none'", "base-uri 'self'", "form-action 'self'" diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index 716aad9..ad5ee09 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -98,7 +98,7 @@ @@ -148,6 +148,8 @@ border-bottom: 1px solid var(--border-color); margin-bottom: 2rem; background: var(--bg-primary); + position: relative; + z-index: 100; } .header-container { diff --git a/src/lib/services/messaging/event-forwarder.ts b/src/lib/services/messaging/event-forwarder.ts index b477206..0e97dbd 100644 --- a/src/lib/services/messaging/event-forwarder.ts +++ b/src/lib/services/messaging/event-forwarder.ts @@ -128,27 +128,52 @@ const GIT_PLATFORM_CONFIGS: Record> = } }; +// Only access process.env server-side (not in browser) +const getEnv = (key: string, defaultValue: string = ''): string => { + if (typeof window !== 'undefined') { + // Browser environment - return default + return defaultValue; + } + // Server-side - access process.env + return (typeof process !== 'undefined' && process.env?.[key]) || defaultValue; +}; + +const getEnvBool = (key: string, defaultValue: boolean = false): boolean => { + if (typeof window !== 'undefined') { + return defaultValue; + } + return (typeof process !== 'undefined' && process.env?.[key]) === 'true'; +}; + +const getEnvInt = (key: string, defaultValue: number): number => { + if (typeof window !== 'undefined') { + return defaultValue; + } + const value = typeof process !== 'undefined' ? process.env?.[key] : undefined; + return value ? parseInt(value, 10) : defaultValue; +}; + const MESSAGING_CONFIG: MessagingConfig = { telegram: { - botToken: process.env.TELEGRAM_BOT_TOKEN || '', - enabled: process.env.TELEGRAM_ENABLED === 'true' + botToken: getEnv('TELEGRAM_BOT_TOKEN'), + enabled: getEnvBool('TELEGRAM_ENABLED') }, simplex: { - apiUrl: process.env.SIMPLEX_API_URL || '', - apiKey: process.env.SIMPLEX_API_KEY || '', - enabled: process.env.SIMPLEX_ENABLED === 'true' + apiUrl: getEnv('SIMPLEX_API_URL'), + apiKey: getEnv('SIMPLEX_API_KEY'), + enabled: getEnvBool('SIMPLEX_ENABLED') }, email: { - smtpHost: process.env.SMTP_HOST || '', - smtpPort: parseInt(process.env.SMTP_PORT || '587', 10), - smtpUser: process.env.SMTP_USER || '', - smtpPassword: process.env.SMTP_PASSWORD || '', - fromAddress: process.env.SMTP_FROM_ADDRESS || '', - fromName: process.env.SMTP_FROM_NAME || 'GitRepublic', - enabled: process.env.EMAIL_ENABLED === 'true' + smtpHost: getEnv('SMTP_HOST'), + smtpPort: getEnvInt('SMTP_PORT', 587), + smtpUser: getEnv('SMTP_USER'), + smtpPassword: getEnv('SMTP_PASSWORD'), + fromAddress: getEnv('SMTP_FROM_ADDRESS'), + fromName: getEnv('SMTP_FROM_NAME', 'GitRepublic'), + enabled: getEnvBool('EMAIL_ENABLED') }, gitPlatforms: { - enabled: process.env.GIT_PLATFORMS_ENABLED === 'true' + enabled: getEnvBool('GIT_PLATFORMS_ENABLED') } }; @@ -441,7 +466,7 @@ async function sendEmail( } try { - const smtpUrl = process.env.SMTP_API_URL; + const smtpUrl = getEnv('SMTP_API_URL'); if (smtpUrl) { // Use SMTP API if provided @@ -449,7 +474,7 @@ async function sendEmail( method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${process.env.SMTP_API_KEY || ''}` + 'Authorization': `Bearer ${getEnv('SMTP_API_KEY')}` }, body: JSON.stringify({ from: MESSAGING_CONFIG.email.fromAddress, diff --git a/src/lib/services/messaging/preferences-storage.ts b/src/lib/services/messaging/preferences-storage.ts index 4a0cb9d..18fba04 100644 --- a/src/lib/services/messaging/preferences-storage.ts +++ b/src/lib/services/messaging/preferences-storage.ts @@ -30,15 +30,16 @@ import logger from '../logger.js'; import { getCachedUserLevel } from '../security/user-level-cache.js'; // Encryption keys from environment (NEVER commit these!) +// These are optional - if not set, messaging preferences will be disabled const ENCRYPTION_KEY = process.env.MESSAGING_PREFS_ENCRYPTION_KEY; const SALT_ENCRYPTION_KEY = process.env.MESSAGING_SALT_ENCRYPTION_KEY; const LOOKUP_SECRET = process.env.MESSAGING_LOOKUP_SECRET; -if (!ENCRYPTION_KEY || !SALT_ENCRYPTION_KEY || !LOOKUP_SECRET) { - throw new Error( - 'Missing required environment variables: ' + - 'MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET' - ); +// Check if messaging preferences are configured +const isMessagingConfigured = !!(ENCRYPTION_KEY && SALT_ENCRYPTION_KEY && LOOKUP_SECRET); + +if (!isMessagingConfigured) { + logger.warn('Messaging preferences storage is not configured. Missing environment variables: MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, MESSAGING_LOOKUP_SECRET'); } export interface MessagingPreferences { @@ -90,17 +91,19 @@ setInterval(() => { */ function getLookupKey(userPubkeyHex: string): string { if (!LOOKUP_SECRET) { - throw new Error('LOOKUP_SECRET not configured'); + throw new Error('Messaging preferences are not configured. LOOKUP_SECRET environment variable is missing.'); } return createHmac('sha256', LOOKUP_SECRET) .update(userPubkeyHex) .digest('hex'); } -/** - * Check and enforce rate limiting on decryption attempts - */ function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: number } { + // If not configured, allow all (no rate limiting) + if (!isMessagingConfigured) { + return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS }; + } + const lookupKey = getLookupKey(userPubkeyHex); const now = Date.now(); @@ -125,6 +128,7 @@ function checkRateLimit(userPubkeyHex: string): { allowed: boolean; remaining: n return { allowed: true, remaining: MAX_DECRYPTION_ATTEMPTS - attempt.count }; } + /** * Encrypt data with AES-256-GCM */ @@ -216,6 +220,10 @@ export async function storePreferences( userPubkeyHex: string, preferences: MessagingPreferences ): Promise { + if (!isMessagingConfigured) { + throw new Error('Messaging preferences are not configured. Please set MESSAGING_PREFS_ENCRYPTION_KEY, MESSAGING_SALT_ENCRYPTION_KEY, and MESSAGING_LOOKUP_SECRET environment variables.'); + } + // Verify user has unlimited access const cached = getCachedUserLevel(userPubkeyHex); if (!cached || cached.level !== 'unlimited') { @@ -257,6 +265,11 @@ export async function storePreferences( export async function getPreferences( userPubkeyHex: string ): Promise { + if (!isMessagingConfigured) { + // If not configured, return null (no preferences stored) + return null; + } + // Check rate limit const rateLimit = checkRateLimit(userPubkeyHex); if (!rateLimit.allowed) { @@ -365,27 +378,39 @@ export async function getPreferencesSummary(userPubkeyHex: string): Promise<{ }; notifyOn?: string[]; } | null> { - const preferences = await getPreferences(userPubkeyHex); - - if (!preferences) { + try { + // If not configured, return null (not configured) + if (!isMessagingConfigured) { + return null; + } + + const preferences = await getPreferences(userPubkeyHex); + + if (!preferences) { + return null; + } + + return { + configured: true, + enabled: preferences.enabled, + platforms: { + telegram: !!preferences.telegram, + simplex: !!preferences.simplex, + email: !!preferences.email, + gitPlatforms: preferences.gitPlatforms?.map(gp => ({ + platform: gp.platform, + owner: gp.owner, + repo: gp.repo, + apiUrl: gp.apiUrl + // token is intentionally omitted + })) + }, + notifyOn: preferences.notifyOn + }; + } catch (err) { + // If any error occurs (e.g., decryption fails, not configured, etc.), return null + logger.warn({ error: err, userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, + 'Failed to get preferences summary'); return null; } - - return { - configured: true, - enabled: preferences.enabled, - platforms: { - telegram: !!preferences.telegram, - simplex: !!preferences.simplex, - email: !!preferences.email, - gitPlatforms: preferences.gitPlatforms?.map(gp => ({ - platform: gp.platform, - owner: gp.owner, - repo: gp.repo, - apiUrl: gp.apiUrl - // token is intentionally omitted - })) - }, - notifyOn: preferences.notifyOn - }; } diff --git a/src/lib/services/nostr/bookmarks-service.ts b/src/lib/services/nostr/bookmarks-service.ts new file mode 100644 index 0000000..a9d8d6b --- /dev/null +++ b/src/lib/services/nostr/bookmarks-service.ts @@ -0,0 +1,199 @@ +/** + * Service for managing user bookmarks (kind 10003) + * NIP-51: Lists - Bookmarks + */ + +import { NostrClient } from './nostr-client.js'; +import type { NostrEvent } from '../../types/nostr.js'; +import { KIND } from '../../types/nostr.js'; +import { getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; +import logger from '../logger.js'; +import { truncatePubkey } from '../../utils/security.js'; + +export class BookmarksService { + private nostrClient: NostrClient; + + constructor(relays: string[]) { + this.nostrClient = new NostrClient(relays); + } + + /** + * Fetch user's bookmarks (kind 10003) + * Returns the most recent bookmark list event + */ + async getBookmarks(pubkey: string): Promise { + try { + const events = await this.nostrClient.fetchEvents([ + { + kinds: [KIND.BOOKMARKS], + authors: [pubkey], + limit: 1 + } + ]); + + if (events.length === 0) { + return null; + } + + // Sort by created_at descending and return the newest + events.sort((a, b) => b.created_at - a.created_at); + return events[0]; + } catch (error) { + logger.error({ error, pubkey: truncatePubkey(pubkey) }, 'Failed to fetch bookmarks'); + return null; + } + } + + /** + * Get all bookmarked repo addresses (a-tags) from user's bookmarks + */ + async getBookmarkedRepos(pubkey: string): Promise> { + const bookmarks = await this.getBookmarks(pubkey); + if (!bookmarks) { + return new Set(); + } + + const repoAddresses = new Set(); + for (const tag of bookmarks.tags) { + if (tag[0] === 'a' && tag[1]) { + // Check if it's a repo announcement address (kind 30617) + const address = tag[1]; + if (address.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) { + repoAddresses.add(address); + } + } + } + + return repoAddresses; + } + + /** + * Check if a repo is bookmarked + */ + async isBookmarked(pubkey: string, repoAddress: string): Promise { + const bookmarkedRepos = await this.getBookmarkedRepos(pubkey); + return bookmarkedRepos.has(repoAddress); + } + + /** + * Add a repo to bookmarks + * Creates or updates the bookmark list event + */ + async addBookmark(pubkey: string, repoAddress: string, relays: string[]): Promise { + try { + // Get existing bookmarks + const existingBookmarks = await this.getBookmarks(pubkey); + + // Extract existing a-tags (for repos) + const existingATags: string[] = []; + if (existingBookmarks) { + for (const tag of existingBookmarks.tags) { + if (tag[0] === 'a' && tag[1]) { + // Only include repo announcement addresses + if (tag[1].startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)) { + existingATags.push(tag[1]); + } + } + } + } + + // Check if already bookmarked + if (existingATags.includes(repoAddress)) { + logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Repo already bookmarked'); + return true; + } + + // Add new bookmark to the end (chronological order per NIP-51) + existingATags.push(repoAddress); + + // Create new bookmark event + const tags: string[][] = existingATags.map(addr => ['a', addr]); + + const eventTemplate: Omit = { + kind: KIND.BOOKMARKS, + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: '', // Public bookmarks use tags, not encrypted content + tags + }; + + // Sign with NIP-07 + const signedEvent = await signEventWithNIP07(eventTemplate); + + // Publish to relays + const result = await this.nostrClient.publishEvent(signedEvent, relays); + + if (result.success.length > 0) { + logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Bookmark added successfully'); + return true; + } else { + logger.error({ pubkey: truncatePubkey(pubkey), repoAddress, errors: result.failed }, 'Failed to publish bookmark'); + return false; + } + } catch (error) { + logger.error({ error, pubkey: truncatePubkey(pubkey), repoAddress }, 'Failed to add bookmark'); + return false; + } + } + + /** + * Remove a repo from bookmarks + * Creates a new bookmark list event without the specified repo + */ + async removeBookmark(pubkey: string, repoAddress: string, relays: string[]): Promise { + try { + // Get existing bookmarks + const existingBookmarks = await this.getBookmarks(pubkey); + + if (!existingBookmarks) { + logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'No bookmarks to remove from'); + return true; + } + + // Extract existing a-tags (for repos), excluding the one to remove + const existingATags: string[] = []; + for (const tag of existingBookmarks.tags) { + if (tag[0] === 'a' && tag[1]) { + // Only include repo announcement addresses, and exclude the one to remove + if (tag[1].startsWith(`${KIND.REPO_ANNOUNCEMENT}:`) && tag[1] !== repoAddress) { + existingATags.push(tag[1]); + } + } + } + + // Check if it was bookmarked + if (existingATags.length === existingBookmarks.tags.filter(t => t[0] === 'a' && t[1]?.startsWith(`${KIND.REPO_ANNOUNCEMENT}:`)).length) { + logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Repo was not bookmarked'); + return true; + } + + // Create new bookmark event without the removed bookmark + const tags: string[][] = existingATags.map(addr => ['a', addr]); + + const eventTemplate: Omit = { + kind: KIND.BOOKMARKS, + pubkey, + created_at: Math.floor(Date.now() / 1000), + content: '', // Public bookmarks use tags, not encrypted content + tags + }; + + // Sign with NIP-07 + const signedEvent = await signEventWithNIP07(eventTemplate); + + // Publish to relays + const result = await this.nostrClient.publishEvent(signedEvent, relays); + + if (result.success.length > 0) { + logger.debug({ pubkey: truncatePubkey(pubkey), repoAddress }, 'Bookmark removed successfully'); + return true; + } else { + logger.error({ pubkey: truncatePubkey(pubkey), repoAddress, errors: result.failed }, 'Failed to publish bookmark removal'); + return false; + } + } catch (error) { + logger.error({ error, pubkey: truncatePubkey(pubkey), repoAddress }, 'Failed to remove bookmark'); + return false; + } + } +} diff --git a/src/lib/services/nostr/user-relays.ts b/src/lib/services/nostr/user-relays.ts index e0c4c4c..d2421cb 100644 --- a/src/lib/services/nostr/user-relays.ts +++ b/src/lib/services/nostr/user-relays.ts @@ -17,18 +17,32 @@ export async function getUserRelays( try { // Fetch kind 10002 (relay list) - get multiple to find the newest + // Use a higher limit to ensure we get all relay list events const relayListEvents = await nostrClient.fetchEvents([ { kinds: [KIND.RELAY_LIST], authors: [pubkey], - limit: 10 // Get multiple to ensure we find the newest + limit: 20 // Get more events to ensure we find the newest } ]); + + logger.debug({ + pubkey: truncatePubkey(pubkey), + eventCount: relayListEvents.length, + eventIds: relayListEvents.map(e => e.id) + }, 'Fetched relay list events'); if (relayListEvents.length > 0) { // Sort by created_at descending to get the newest event first relayListEvents.sort((a, b) => b.created_at - a.created_at); const event = relayListEvents[0]; + logger.debug({ + pubkey: truncatePubkey(pubkey), + eventId: event.id, + tagCount: event.tags.length, + createdAt: new Date(event.created_at * 1000).toISOString() + }, 'Found kind 10002 relay list event'); + for (const tag of event.tags) { if (tag[0] === 'relay' && tag[1]) { const relay = tag[1]; @@ -39,6 +53,14 @@ export async function getUserRelays( if (write) outbox.push(relay); } } + + logger.debug({ + pubkey: truncatePubkey(pubkey), + inboxCount: inbox.length, + outboxCount: outbox.length + }, 'Extracted relays from kind 10002 event'); + } else { + logger.debug({ pubkey: truncatePubkey(pubkey) }, 'No kind 10002 relay list events found'); } // Fallback to kind 3 (contacts) for older clients diff --git a/src/lib/types/nostr.ts b/src/lib/types/nostr.ts index b4aba9e..5406c7c 100644 --- a/src/lib/types/nostr.ts +++ b/src/lib/types/nostr.ts @@ -52,6 +52,7 @@ export const KIND = { THREAD: 11, // NIP-7D: Discussion thread BRANCH_PROTECTION: 30620, // Custom: Branch protection rules RELAY_LIST: 10002, // NIP-65: Relay list metadata + BOOKMARKS: 10003, // NIP-51: Bookmarks list NIP98_AUTH: 27235, // NIP-98: HTTP authentication event HIGHLIGHT: 9802, // NIP-84: Highlight event PUBLIC_MESSAGE: 24, // NIP-24: Public message (direct chat) diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 7cb54e0..83847fe 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -14,12 +14,31 @@ let checkingLevel = $state(false); let levelMessage = $state(null); + // React to userStore changes (e.g., when user logs out) + $effect(() => { + const currentUser = $userStore; + if (!currentUser.userPubkey) { + // User has logged out - clear local state + userPubkey = null; + userPubkeyHex = null; + } + }); + onMount(() => { // Prevent body scroll when splash page is shown document.body.style.overflow = 'hidden'; - // Check auth asynchronously - checkAuth(); + // Check userStore first - if user has logged out, don't check extension + const currentUser = $userStore; + if (!currentUser.userPubkey) { + // User has logged out or never logged in + userPubkey = null; + userPubkeyHex = null; + checkingAuth = false; + } else { + // Check auth asynchronously + checkAuth(); + } // Return cleanup function return () => { @@ -30,6 +49,16 @@ async function checkAuth() { checkingAuth = true; + + // Check userStore first - if user has logged out, clear state + const currentUser = $userStore; + if (!currentUser.userPubkey) { + userPubkey = null; + userPubkeyHex = null; + checkingAuth = false; + return; + } + if (isNIP07Available()) { try { userPubkey = await getPublicKeyWithNIP07(); @@ -53,7 +82,13 @@ } } catch (err) { console.warn('Failed to load user pubkey:', err); + userPubkey = null; + userPubkeyHex = null; } + } else { + // Extension not available, clear state + userPubkey = null; + userPubkeyHex = null; } checkingAuth = false; } diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index 2e8d4e0..728cf6f 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -105,9 +105,18 @@ export const GET: RequestHandler = async (event) => { if (!isPrivate) { canView = true; // Public repos are viewable by anyone } else if (userPubkey) { - // Private repos require authentication + // Private repos require authentication - check if user owns, maintains, or has bookmarked try { + // Check if user is owner or maintainer canView = await maintainerService.canView(userPubkey, event.pubkey, repoId); + + // If not owner/maintainer, check if user has bookmarked it + if (!canView) { + const { BookmarksService } = await import('$lib/services/nostr/bookmarks-service.js'); + const bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS); + const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${event.pubkey}:${repoId}`; + canView = await bookmarksService.isBookmarked(userPubkey, repoAddress); + } } catch (err) { logger.warn({ error: err, pubkey: event.pubkey, repo: repoId }, 'Failed to check repo access in search'); canView = false; diff --git a/src/routes/api/user/messaging-preferences/summary/+server.ts b/src/routes/api/user/messaging-preferences/summary/+server.ts index ef275bc..988b7a0 100644 --- a/src/routes/api/user/messaging-preferences/summary/+server.ts +++ b/src/routes/api/user/messaging-preferences/summary/+server.ts @@ -53,6 +53,24 @@ export const GET: RequestHandler = async (event) => { }); } - return error(500, 'Failed to get messaging preferences summary'); + // If messaging is not configured, return not configured + if (err instanceof Error && ( + err.message.includes('not configured') || + err.message.includes('environment variable') || + err.message.includes('LOOKUP_SECRET') + )) { + return json({ + configured: false, + enabled: false, + platforms: {} + }); + } + + // For any other error, return not configured (graceful degradation) + return json({ + configured: false, + enabled: false, + platforms: {} + }); } }; diff --git a/src/routes/api/users/[npub]/repos/+server.ts b/src/routes/api/users/[npub]/repos/+server.ts index 96947e3..9a9bc79 100644 --- a/src/routes/api/users/[npub]/repos/+server.ts +++ b/src/routes/api/users/[npub]/repos/+server.ts @@ -7,17 +7,20 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; -import { DEFAULT_NOSTR_RELAYS, GIT_DOMAIN } from '$lib/config.js'; +import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js'; +import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, GIT_DOMAIN } from '$lib/config.js'; import { KIND } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; import { extractRequestContext } from '$lib/utils/api-context.js'; import logger from '$lib/services/logger.js'; +import { truncatePubkey } from '$lib/utils/security.js'; import type { NostrEvent } from '$lib/types/nostr.js'; import type { RequestEvent } from '@sveltejs/kit'; const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); +const bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS); export const GET: RequestHandler = async (event) => { try { @@ -51,6 +54,16 @@ export const GET: RequestHandler = async (event) => { } ]); + // Get viewer's bookmarked repos if authenticated + let bookmarkedRepos: Set = new Set(); + if (viewerPubkey) { + try { + bookmarkedRepos = await bookmarksService.getBookmarkedRepos(viewerPubkey); + } catch (err) { + logger.warn({ error: err, viewerPubkey: truncatePubkey(viewerPubkey) }, 'Failed to fetch bookmarked repos'); + } + } + const repos: NostrEvent[] = []; // Process each announcement with privacy filtering @@ -60,10 +73,6 @@ export const GET: RequestHandler = async (event) => { .flatMap(t => t.slice(1)) .filter(url => url && typeof url === 'string'); - // Filter for repos that list our domain - const hasDomain = cloneUrls.some(url => url.includes(gitDomain)); - if (!hasDomain) continue; - // Extract repo name from d-tag const dTag = event.tags.find(t => t[0] === 'd')?.[1]; if (!dTag) continue; @@ -79,9 +88,16 @@ export const GET: RequestHandler = async (event) => { if (!isPrivate) { canView = true; // Public repos are viewable by anyone } else if (viewerPubkey) { - // Private repos require authentication + // Private repos require authentication - check if viewer owns, maintains, or has bookmarked try { + // Check if viewer is owner or maintainer canView = await maintainerService.canView(viewerPubkey, userPubkey, dTag); + + // If not owner/maintainer, check if viewer has bookmarked it + if (!canView) { + const repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${userPubkey}:${dTag}`; + canView = bookmarkedRepos.has(repoAddress); + } } catch (err) { logger.warn({ error: err, pubkey: userPubkey, repo: dTag }, 'Failed to check repo access'); canView = false; diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte index c277116..1c0493a 100644 --- a/src/routes/repos/+page.svelte +++ b/src/routes/repos/+page.svelte @@ -38,6 +38,20 @@ await loadUserAndContacts(); }); + // Reload repos when page becomes visible (e.g., after returning from another page) + $effect(() => { + if (typeof document !== 'undefined') { + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + // Reload repos when page becomes visible to catch newly published repos + loadRepos().catch(err => console.warn('Failed to reload repos on visibility change:', err)); + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + } + }); + async function loadUserAndContacts() { if (!isNIP07Available()) { return; diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index a3c0e3e..148dea7 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -8,8 +8,9 @@ import ForwardingConfig from '$lib/components/ForwardingConfig.svelte'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; - import { DEFAULT_NOSTR_RELAYS, combineRelays } from '$lib/config.js'; + import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS, combineRelays } from '$lib/config.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; + import { BookmarksService } from '$lib/services/nostr/bookmarks-service.js'; import { KIND } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; @@ -127,6 +128,12 @@ let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null); let forking = $state(false); + // Bookmarks + let isBookmarked = $state(false); + let loadingBookmark = $state(false); + let bookmarksService: BookmarksService | null = null; + let repoAddress = $state(null); + // Repository images let repoImage = $state(null); let repoBanner = $state(null); @@ -745,6 +752,20 @@ }); onMount(async () => { + // Initialize bookmarks service + bookmarksService = new BookmarksService(DEFAULT_NOSTR_SEARCH_RELAYS); + + // Decode npub to get repo owner pubkey for bookmark address + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + const repoOwnerPubkey = decoded.data as string; + repoAddress = `${KIND.REPO_ANNOUNCEMENT}:${repoOwnerPubkey}:${repo}`; + } + } catch (err) { + console.warn('Failed to decode npub for bookmark address:', err); + } + await loadBranches(); // Skip other API calls if repository doesn't exist if (repoNotFound) { @@ -759,6 +780,7 @@ await checkAuth(); await loadTags(); await checkMaintainerStatus(); + await loadBookmarkStatus(); await checkVerification(); await loadReadme(); await loadForkInfo(); @@ -769,8 +791,9 @@ try { if (isNIP07Available()) { userPubkey = await getPublicKeyWithNIP07(); - // Recheck maintainer status after auth + // Recheck maintainer status and bookmark status after auth await checkMaintainerStatus(); + await loadBookmarkStatus(); } } catch (err) { console.log('NIP-07 not available or user not connected'); @@ -785,8 +808,9 @@ return; } userPubkey = await getPublicKeyWithNIP07(); - // Re-check maintainer status after login + // Re-check maintainer status and bookmark status after login await checkMaintainerStatus(); + await loadBookmarkStatus(); } catch (err) { error = err instanceof Error ? err.message : 'Failed to connect'; console.error('Login error:', err); @@ -794,6 +818,48 @@ } + async function loadBookmarkStatus() { + if (!userPubkey || !repoAddress || !bookmarksService) return; + + try { + isBookmarked = await bookmarksService.isBookmarked(userPubkey, repoAddress); + } catch (err) { + console.warn('Failed to load bookmark status:', err); + } + } + + async function toggleBookmark() { + if (!userPubkey || !repoAddress || !bookmarksService || loadingBookmark) return; + + loadingBookmark = true; + try { + // Get user's relays for publishing + const { getUserRelays } = await import('$lib/services/nostr/user-relays.js'); + const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; + const fullRelayClient = new NostrClient(allSearchRelays); + const { outbox, inbox } = await getUserRelays(userPubkey, fullRelayClient); + const userRelays = combineRelays(outbox.length > 0 ? outbox : inbox, DEFAULT_NOSTR_RELAYS); + + let success = false; + if (isBookmarked) { + success = await bookmarksService.removeBookmark(userPubkey, repoAddress, userRelays); + } else { + success = await bookmarksService.addBookmark(userPubkey, repoAddress, userRelays); + } + + if (success) { + isBookmarked = !isBookmarked; + } else { + alert(`Failed to ${isBookmarked ? 'remove' : 'add'} bookmark. Please try again.`); + } + } catch (err) { + console.error('Failed to toggle bookmark:', err); + alert(`Failed to ${isBookmarked ? 'remove' : 'add'} bookmark: ${String(err)}`); + } finally { + loadingBookmark = false; + } + } + async function checkMaintainerStatus() { if (repoNotFound || !userPubkey) { isMaintainer = false; @@ -1552,6 +1618,15 @@ + {#if isMaintainer} Settings {/if} @@ -2370,6 +2445,39 @@ align-self: flex-end; } + .bookmark-button { + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + border: 1px solid var(--border-color); + border-radius: 0.375rem; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s ease; + font-family: 'IBM Plex Serif', serif; + } + + .bookmark-button:hover:not(:disabled) { + background: var(--bg-secondary); + border-color: var(--accent); + } + + .bookmark-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .bookmark-button.bookmarked { + background: var(--accent); + color: var(--accent-text, white); + border-color: var(--accent); + } + + .bookmark-button.bookmarked:hover:not(:disabled) { + opacity: 0.9; + } + .repo-banner { width: 100%; height: 200px; diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index 9d4de3e..cc617af 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -253,6 +253,119 @@ documentation = newDocs; } + async function lookupDocumentation(index: number) { + const doc = documentation[index]?.trim(); + if (!doc) { + lookupError[`doc-${index}`] = 'Please enter a naddr to lookup'; + return; + } + + const lookupKey = `doc-${index}`; + lookupLoading[lookupKey] = true; + lookupError[lookupKey] = null; + lookupResults[lookupKey] = null; + + try { + // Try to decode as naddr + let decoded; + try { + decoded = nip19.decode(doc); + } catch { + lookupError[lookupKey] = 'Invalid naddr format'; + return; + } + + if (decoded.type !== 'naddr') { + lookupError[lookupKey] = 'Please enter a valid naddr (naddr1...)'; + return; + } + + // Extract the components from the decoded naddr + const { pubkey, kind, identifier, relays } = decoded.data as { + pubkey: string; + kind: number; + identifier: string; + relays?: string[]; + }; + + if (!pubkey || !kind || !identifier) { + lookupError[lookupKey] = 'Invalid naddr: missing required components'; + return; + } + + // Convert to the format needed for documentation tag: kind:pubkey:identifier + // If there's a relay hint, we can optionally add it, but the standard format is kind:pubkey:identifier + const docFormat = `${kind}:${pubkey}:${identifier}`; + + // Update the documentation field with the converted format + const newDocs = [...documentation]; + newDocs[index] = docFormat; + documentation = newDocs; + + // Also fetch the event to show some info + const relaysToUse = relays && relays.length > 0 ? relays : getSearchRelays(); + const client = new NostrClient(relaysToUse); + + const events = await client.fetchEvents([ + { + kinds: [kind], + authors: [pubkey], + '#d': [identifier], + limit: 1 + } + ]); + + if (events.length > 0) { + lookupResults[lookupKey] = events; + lookupError[lookupKey] = null; + } else { + // Still update the field even if we can't fetch the event + lookupError[lookupKey] = 'Documentation address converted, but event not found on relays'; + } + } catch (err) { + lookupError[lookupKey] = `Lookup failed: ${String(err)}`; + } finally { + lookupLoading[lookupKey] = false; + } + } + + /** + * Publish event with retry logic + * Retries failed relays up to maxRetries times + */ + async function publishWithRetry( + client: NostrClient, + event: NostrEvent, + relays: string[], + maxRetries: number = 2 + ): Promise<{ success: string[]; failed: Array<{ relay: string; error: string }> }> { + let result = await client.publishEvent(event, relays); + + // If we have some successes, return early + if (result.success.length > 0) { + return result; + } + + // If all failed and we have retries left, retry only the failed relays + if (result.failed.length > 0 && maxRetries > 0) { + console.log(`Retrying publish to ${result.failed.length} failed relay(s)...`); + const failedRelays = result.failed.map((f: { relay: string; error: string }) => f.relay); + + // Wait a bit before retry + await new Promise(resolve => setTimeout(resolve, 1000)); + + const retryResult = await client.publishEvent(event, failedRelays); + + // Merge results + return { + success: [...result.success, ...retryResult.success], + failed: retryResult.failed + }; + } + + return result; + } + async function handleWebUrlHover(index: number, url: string) { // Clear any existing timeout if (previewTimeout) { @@ -462,8 +575,8 @@ } // Filter private repos - events = await Promise.all( - filteredEvents.map(async (event) => { + const filteredPrivateEvents = await Promise.all( + filteredEvents.map(async (event): Promise => { const isPrivate = event.tags.some(t => (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private') @@ -503,7 +616,7 @@ return null; }) ); - events = events.filter(e => e !== null) as NostrEvent[]; + events = filteredPrivateEvents.filter((e): e is NostrEvent => e !== null); } if (events.length === 0) { @@ -1260,22 +1373,64 @@ }; // Sign with NIP-07 + console.log('Signing repository announcement event...'); const signedEvent = await signEventWithNIP07(eventTemplate); + console.log('Event signed successfully, event ID:', signedEvent.id); - // Get user's inbox/outbox relays (from kind 10002) using full relay set to find newest + // Get user's inbox/outbox relays (from kind 10002) using comprehensive relay set + console.log('Fetching user relays from comprehensive relay set...'); const { getUserRelays } = await import('../../lib/services/nostr/user-relays.js'); - // Use comprehensive relay set to ensure we get the newest kind 10002 event - const fullRelaySet = combineRelays([], [...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS]); - const fullRelayClient = new NostrClient(fullRelaySet); - const { inbox, outbox } = await getUserRelays(pubkey, fullRelayClient); - // Combine user's outbox with default relays - const userRelays = combineRelays(outbox); + // Use comprehensive relay set including ALL search relays and default relays + // This ensures we can find the user's kind 10002 event even if it's on a less common relay + const allSearchRelays = [...new Set([...DEFAULT_NOSTR_SEARCH_RELAYS, ...DEFAULT_NOSTR_RELAYS])]; + console.log('Querying kind 10002 from relays:', allSearchRelays); + const fullRelayClient = new NostrClient(allSearchRelays); + + let userRelays: string[] = []; + try { + const { inbox, outbox } = await getUserRelays(pubkey, fullRelayClient); + console.log('User relays fetched - inbox:', inbox.length, 'outbox:', outbox.length); + if (inbox.length > 0) console.log('Inbox relays:', inbox); + if (outbox.length > 0) console.log('Outbox relays:', outbox); + + // If we found user relays, use them; otherwise fall back to defaults + if (outbox.length > 0) { + // Use user's outbox relays (these are the relays the user prefers for publishing) + // Combine with defaults as fallback + userRelays = combineRelays(outbox, DEFAULT_NOSTR_RELAYS); + console.log('Using user outbox relays for publishing:', outbox); + } else if (inbox.length > 0) { + // If no outbox but have inbox, use inbox relays (some users only set inbox) + userRelays = combineRelays(inbox, DEFAULT_NOSTR_RELAYS); + console.log('Using user inbox relays for publishing (no outbox found):', inbox); + } else { + // No user relays found, use defaults + userRelays = DEFAULT_NOSTR_RELAYS; + console.warn('No user relays found in kind 10002, using default relays only'); + } + } catch (err) { + console.warn('Failed to fetch user relays, using defaults:', err); + // Fall back to default relays if user relay fetch fails + userRelays = DEFAULT_NOSTR_RELAYS; + } + + // Ensure we have at least some relays to publish to + if (userRelays.length === 0) { + console.warn('No relays available, using default relays'); + userRelays = DEFAULT_NOSTR_RELAYS; + } + + console.log('Final relay set for publishing:', userRelays); + + console.log('Using relays for publishing:', userRelays); - // Publish repository announcement - const result = await nostrClient.publishEvent(signedEvent, userRelays); + // Publish repository announcement with retry logic + let publishResult = await publishWithRetry(nostrClient, signedEvent, userRelays, 2); + console.log('Publish result:', publishResult); - if (result.success.length > 0) { + if (publishResult.success.length > 0) { + console.log(`Successfully published to ${publishResult.success.length} relay(s):`, publishResult.success); // Create and publish initial ownership proof (self-transfer event) const { OwnershipTransferService } = await import('../../lib/services/nostr/ownership-transfer-service.js'); const ownershipService = new OwnershipTransferService(userRelays); @@ -1289,11 +1444,18 @@ }); success = true; + // Redirect to the newly created repository page + // Use invalidateAll to ensure the repos list refreshes + const userNpub = nip19.npubEncode(pubkey); setTimeout(() => { - goto('/'); + // Invalidate all caches and redirect + goto(`/repos/${userNpub}/${dTag}`, { invalidateAll: true, replaceState: false }); }, 2000); } else { - error = 'Failed to publish to any relays.'; + // Show detailed error information + const errorDetails = publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('\n'); + error = `Failed to publish to any relays after retries.\n\nRelays attempted: ${userRelays.length}\n\nErrors:\n${errorDetails || 'Unknown error'}\n\nPlease check:\n• Your internet connection\n• Relay availability\n• Try again in a few moments`; + console.error('Failed to publish repository announcement:', publishResult); } } catch (e) { @@ -1837,9 +1999,18 @@ type="text" value={doc} oninput={(e) => updateDocumentation(index, e.currentTarget.value)} - placeholder="30818:pubkey:d-tag" + placeholder="naddr1... or 30818:pubkey:d-tag" disabled={loading} /> + {#if documentation.length > 1}