diff --git a/src/app.css b/src/app.css index ef03d7d..8ec2bb4 100644 --- a/src/app.css +++ b/src/app.css @@ -1204,6 +1204,98 @@ label.filter-checkbox > span, gap: 1.5rem; } +/* Repository sections */ +.repo-section { + margin-bottom: 3rem; +} + +.section-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + padding-bottom: 0.75rem; + border-bottom: 2px solid var(--card-border); +} + +.section-header h3 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.section-badge { + background: var(--accent); + color: var(--accent-text); + padding: 0.25rem 0.75rem; + border-radius: 1rem; + font-size: 0.875rem; + font-weight: 600; +} + +.section-description { + color: var(--text-secondary); + font-size: 0.875rem; + margin-left: auto; +} + +/* Visual distinction between registered and local repos */ +.repo-card-registered { + border-left: 4px solid var(--success, #10b981); +} + +.repo-card-local { + border-left: 4px solid var(--warning, #f59e0b); + background: var(--bg-secondary, rgba(245, 158, 11, 0.05)); +} + +/* Repo actions */ +.repo-actions { + display: flex; + gap: 0.5rem; + align-items: center; + flex-wrap: wrap; +} + +.delete-button, +.register-button { + padding: 0.5rem 1rem; + border-radius: 0.375rem; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid; +} + +.delete-button { + background: var(--error-bg, rgba(239, 68, 68, 0.1)); + color: var(--error, #ef4444); + border-color: var(--error, #ef4444); +} + +.delete-button:hover:not(:disabled) { + background: var(--error, #ef4444); + color: white; +} + +.delete-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.register-button { + background: var(--accent); + color: var(--accent-text, white); + border-color: var(--accent); +} + +.register-button:hover { + opacity: 0.9; + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + .repo-header { display: flex; justify-content: space-between; diff --git a/src/lib/utils/api-context.ts b/src/lib/utils/api-context.ts index e76a216..190441f 100644 --- a/src/lib/utils/api-context.ts +++ b/src/lib/utils/api-context.ts @@ -50,8 +50,9 @@ export function extractRequestContext( ): RequestContext { const requestUrl = url || event.url; - // Extract user pubkey from query params or headers + // Extract user pubkey from query params or headers (support both lowercase and capitalized) const userPubkey = requestUrl.searchParams.get('userPubkey') || + event.request.headers.get('X-User-Pubkey') || event.request.headers.get('x-user-pubkey') || null; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f8f80fc..8278a2d 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -9,15 +9,24 @@ import { ForkCountService } from '../lib/services/nostr/fork-count-service.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '../lib/services/nostr/nip07-signer.js'; - let repos = $state([]); - let allRepos = $state([]); // Store all repos for filtering + // Registered repos (with domain in clone URLs) + let registeredRepos = $state>([]); + let allRegisteredRepos = $state>([]); + + // Local clones (repos without domain in clone URLs) + let localRepos = $state>([]); + let allLocalRepos = $state>([]); + let loading = $state(true); + let loadingLocal = $state(false); let error = $state(null); let forkCounts = $state>(new Map()); let searchQuery = $state(''); let showOnlyMyContacts = $state(false); let userPubkey = $state(null); + let userPubkeyHex = $state(null); let contactPubkeys = $state>(new Set()); + let deletingRepo = $state<{ npub: string; repo: string } | null>(null); import { DEFAULT_NOSTR_RELAYS } from '../lib/config.js'; const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS); @@ -36,13 +45,24 @@ try { userPubkey = await getPublicKeyWithNIP07(); - contactPubkeys.add(userPubkey); // Include user's own repos + + // Convert npub to hex for API calls + try { + const decoded = nip19.decode(userPubkey); + if (decoded.type === 'npub') { + userPubkeyHex = decoded.data as string; + } + } catch { + userPubkeyHex = userPubkey; // Assume it's already hex + } + + contactPubkeys.add(userPubkeyHex); // Include user's own repos // Fetch user's kind 3 contact list const contactEvents = await nostrClient.fetchEvents([ { kinds: [KIND.CONTACT_LIST], - authors: [userPubkey], + authors: [userPubkeyHex], limit: 1 } ]); @@ -78,40 +98,32 @@ error = null; try { - const events = await nostrClient.fetchEvents([ - { kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 } - ]); - - // Get git domain from layout data const gitDomain = $page.data.gitDomain || 'localhost:6543'; + const url = `/api/repos/list?domain=${encodeURIComponent(gitDomain)}`; - // Filter for repos that list our domain in clone tags and are public - repos = events.filter(event => { - const cloneUrls = event.tags - .filter(t => t[0] === 'clone') - .flatMap(t => t.slice(1)) - .filter(url => url && typeof url === 'string'); - - const hasDomain = cloneUrls.some(url => url.includes(gitDomain)); - if (!hasDomain) return false; - - // Filter out private repos from public listing - const isPrivate = event.tags.some(t => - (t[0] === 'private' && t[1] === 'true') || - (t[0] === 't' && t[1] === 'private') - ); - - return !isPrivate; // Only show public repos + const response = await fetch(url, { + headers: userPubkeyHex ? { + 'X-User-Pubkey': userPubkeyHex + } : {} }); - // Sort by created_at descending - repos.sort((a, b) => b.created_at - a.created_at); - allRepos = [...repos]; // Store all repos for filtering - - // Load fork counts for all repos (in parallel, but don't block) - loadForkCounts(repos).catch(err => { + if (!response.ok) { + throw new Error(`Failed to load repositories: ${response.statusText}`); + } + + const data = await response.json(); + + // Set registered repos + registeredRepos = data.registered || []; + allRegisteredRepos = [...registeredRepos]; + + // Load fork counts for registered repos (in parallel, but don't block) + loadForkCounts(registeredRepos.map(r => r.event)).catch(err => { console.warn('[RepoList] Failed to load some fork counts:', err); }); + + // Load local repos separately (async, don't block) + loadLocalRepos(); } catch (e) { error = String(e); console.error('[RepoList] Failed to load repos:', e); @@ -119,6 +131,71 @@ loading = false; } } + + async function loadLocalRepos() { + loadingLocal = true; + + try { + const gitDomain = $page.data.gitDomain || 'localhost:6543'; + const url = `/api/repos/local?domain=${encodeURIComponent(gitDomain)}`; + + const response = await fetch(url, { + headers: userPubkeyHex ? { + 'X-User-Pubkey': userPubkeyHex + } : {} + }); + + if (!response.ok) { + console.warn('Failed to load local repos:', response.statusText); + return; + } + + const data = await response.json(); + localRepos = data || []; + allLocalRepos = [...localRepos]; + } catch (e) { + console.warn('[RepoList] Failed to load local repos:', e); + } finally { + loadingLocal = false; + } + } + + async function deleteLocalRepo(npub: string, repo: string) { + if (!confirm(`Are you sure you want to delete the local clone of ${repo}? This will remove the repository from this server but will not delete the announcement on Nostr.`)) { + return; + } + + deletingRepo = { npub, repo }; + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/delete`, { + method: 'DELETE', + headers: userPubkeyHex ? { + 'X-User-Pubkey': userPubkeyHex + } : {} + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.message || 'Failed to delete repository'); + } + + // Remove from local repos list + localRepos = localRepos.filter(r => !(r.npub === npub && r.repoName === repo)); + allLocalRepos = [...localRepos]; + + alert('Repository deleted successfully'); + } catch (e) { + alert(`Failed to delete repository: ${e instanceof Error ? e.message : String(e)}`); + } finally { + deletingRepo = null; + } + } + + function registerRepo(npub: string, repo: string) { + // Navigate to signup page with repo pre-filled + goto(`/signup?npub=${encodeURIComponent(npub)}&repo=${encodeURIComponent(repo)}`); + } async function loadForkCounts(repoEvents: NostrEvent[]) { const counts = new Map(); @@ -147,6 +224,19 @@ const repoKey = `${event.pubkey}:${dTag}`; return forkCounts.get(repoKey) || 0; } + + function isOwner(npub: string, repoName: string): boolean { + if (!userPubkeyHex) return false; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + return decoded.data === userPubkeyHex; + } + } catch { + // Invalid npub + } + return false; + } function goToSearch() { goto('/search'); @@ -256,17 +346,18 @@ function performSearch() { if (!searchQuery.trim()) { - repos = [...allRepos]; + registeredRepos = [...allRegisteredRepos]; + localRepos = [...allLocalRepos]; return; } const query = searchQuery.trim().toLowerCase(); - const results: SearchResult[] = []; - // Filter by contacts if enabled - let reposToSearch = allRepos; + // Search registered repos + let registeredToSearch = allRegisteredRepos; if (showOnlyMyContacts && contactPubkeys.size > 0) { - reposToSearch = allRepos.filter(event => { + registeredToSearch = allRegisteredRepos.filter(item => { + const event = item.event; // Check if owner is in contacts if (contactPubkeys.has(event.pubkey)) return true; @@ -290,155 +381,49 @@ }); } - for (const repo of reposToSearch) { + const registeredResults: Array<{ item: typeof allRegisteredRepos[0]; score: number }> = []; + for (const item of registeredToSearch) { + const repo = item.event; let score = 0; - let matchType = ''; - - // Extract repo fields + const name = getRepoName(repo).toLowerCase(); const dTag = repo.tags.find(t => t[0] === 'd')?.[1]?.toLowerCase() || ''; const description = getRepoDescription(repo).toLowerCase(); - const cloneUrls = getCloneUrls(repo).map(url => url.toLowerCase()); - const maintainerTags = repo.tags.filter(t => t[0] === 'maintainers'); - const maintainers: string[] = []; - for (const tag of maintainerTags) { - for (let i = 1; i < tag.length; i++) { - if (tag[i]) maintainers.push(tag[i].toLowerCase()); - } - } - - // Try to decode query as hex id, naddr, or nevent - let queryHex = ''; - try { - const decoded = nip19.decode(query); - if (decoded.type === 'nevent') { - const data = decoded.data as { id: string }; - queryHex = data.id || ''; - } else if (decoded.type === 'naddr') { - // For naddr, we can't extract an event ID directly, skip - } else if (decoded.type === 'note') { - queryHex = decoded.data as string; - } - } catch { - // Not a bech32 encoded value - } - - // Check if query is a hex pubkey or npub - let queryPubkey = ''; - try { - const decoded = nip19.decode(query); - if (decoded.type === 'npub') { - queryPubkey = decoded.data as string; - } - } catch { - // Check if it's a hex pubkey (64 hex chars) - if (/^[0-9a-f]{64}$/i.test(query)) { - queryPubkey = query; - } - } - - // Exact matches get highest score - if (name === query) { - score += 1000; - matchType = 'exact-name'; - } else if (dTag === query) { - score += 1000; - matchType = 'exact-d-tag'; - } else if (repo.id.toLowerCase() === query || repo.id.toLowerCase() === queryHex) { - score += 1000; - matchType = 'exact-id'; - } else if (repo.pubkey.toLowerCase() === queryPubkey.toLowerCase()) { - score += 800; - matchType = 'exact-pubkey'; - } - - // Name matches - if (name.includes(query)) { - score += name.startsWith(query) ? 100 : 50; - if (!matchType) matchType = 'name'; - } - - // D-tag matches - if (dTag.includes(query)) { - score += dTag.startsWith(query) ? 100 : 50; - if (!matchType) matchType = 'd-tag'; - } - - // Description matches - if (description.includes(query)) { - score += 30; - if (!matchType) matchType = 'description'; - } - - // Pubkey matches (owner) - if (repo.pubkey.toLowerCase().includes(query.toLowerCase()) || - (queryPubkey && repo.pubkey.toLowerCase() === queryPubkey.toLowerCase())) { - score += 200; - if (!matchType) matchType = 'pubkey'; - } - - // Maintainer matches - for (const maintainer of maintainers) { - if (maintainer.includes(query.toLowerCase())) { - score += 150; - if (!matchType) matchType = 'maintainer'; - break; - } - // Check if maintainer is npub and matches query - try { - const decoded = nip19.decode(maintainer); - if (decoded.type === 'npub') { - const maintainerPubkey = decoded.data as string; - if (maintainerPubkey.toLowerCase().includes(query.toLowerCase()) || - (queryPubkey && maintainerPubkey.toLowerCase() === queryPubkey.toLowerCase())) { - score += 150; - if (!matchType) matchType = 'maintainer'; - break; - } - } - } catch { - // Not an npub, already checked above - } - } - - // Clone URL matches - for (const url of cloneUrls) { - if (url.includes(query)) { - score += 40; - if (!matchType) matchType = 'clone-url'; - break; - } + + if (name.includes(query)) score += 100; + if (dTag.includes(query)) score += 100; + if (description.includes(query)) score += 30; + + if (score > 0) { + registeredResults.push({ item, score }); } - - // Fulltext search in all tags and content - const allText = [ - name, - dTag, - description, - ...cloneUrls, - ...maintainers, - repo.content.toLowerCase() - ].join(' '); - - if (allText.includes(query)) { - score += 10; - if (!matchType) matchType = 'fulltext'; + } + + registeredResults.sort((a, b) => b.score - a.score || b.item.event.created_at - a.item.event.created_at); + registeredRepos = registeredResults.map(r => r.item); + + // Search local repos + const localResults: Array<{ item: typeof allLocalRepos[0]; score: number }> = []; + for (const item of allLocalRepos) { + let score = 0; + const repoName = item.repoName.toLowerCase(); + const announcement = item.announcement; + + if (repoName.includes(query)) score += 100; + if (announcement) { + const name = getRepoName(announcement).toLowerCase(); + const description = getRepoDescription(announcement).toLowerCase(); + if (name.includes(query)) score += 100; + if (description.includes(query)) score += 30; } - + if (score > 0) { - results.push({ repo, score, matchType }); + localResults.push({ item, score }); } } - - // Sort by score (descending), then by created_at (descending) - results.sort((a, b) => { - if (b.score !== a.score) { - return b.score - a.score; - } - return b.repo.created_at - a.repo.created_at; - }); - - repos = results.map(r => r.repo); + + localResults.sort((a, b) => b.score - a.score || b.item.lastModified - a.item.lastModified); + localRepos = localResults.map(r => r.item); } // Reactive search when query or filter changes @@ -511,50 +496,143 @@ {:else if loading}
Loading repositories...
- {:else if repos.length === 0} -
No repositories found.
{:else} -
- {#each repos as repo} - {@const repoImage = getRepoImage(repo)} - {@const repoBanner = getRepoBanner(repo)} -
- {#if repoBanner} -
- Banner -
- {/if} -
-
- {#if repoImage} - Repository + +
+
+

Registered Repositories

+ {registeredRepos.length} +
+ {#if registeredRepos.length === 0} +
No registered repositories found.
+ {:else} +
+ {#each registeredRepos as item} + {@const repo = item.event} + {@const repoImage = getRepoImage(repo)} + {@const repoBanner = getRepoBanner(repo)} +
+ {#if repoBanner} +
+ Banner +
{/if} -
-

{getRepoName(repo)}

- {#if getRepoDescription(repo)} -

{getRepoDescription(repo)}

- {/if} +
+
+ {#if repoImage} + Repository + {/if} +
+

{getRepoName(repo)}

+ {#if getRepoDescription(repo)} +

{getRepoDescription(repo)}

+ {/if} +
+ + View & Edit → + +
+
+ Clone URLs: + {#each getCloneUrls(repo) as url} + {url} + {/each} +
+
+ Created: {new Date(repo.created_at * 1000).toLocaleDateString()} + {#if getForkCount(repo) > 0} + {@const forkCount = getForkCount(repo)} + 🍴 {forkCount} fork{forkCount === 1 ? '' : 's'} + {/if} +
- - View & Edit → - -
-
- Clone URLs: - {#each getCloneUrls(repo) as url} - {url} - {/each}
-
- Created: {new Date(repo.created_at * 1000).toLocaleDateString()} - {#if getForkCount(repo) > 0} - {@const forkCount = getForkCount(repo)} - 🍴 {forkCount} fork{forkCount === 1 ? '' : 's'} + {/each} +
+ {/if} +
+ + +
+
+

Local Clones

+ {localRepos.length} + Repositories cloned locally but not registered with this domain +
+ {#if loadingLocal} +
Loading local repositories...
+ {:else if localRepos.length === 0} +
No local clones found.
+ {:else} +
+ {#each localRepos as item} + {@const repo = item.announcement} + {@const repoImage = repo ? getRepoImage(repo) : null} + {@const repoBanner = repo ? getRepoBanner(repo) : null} + {@const canDelete = isOwner(item.npub, item.repoName)} +
+ {#if repoBanner} +
+ Banner +
{/if} +
+
+ {#if repoImage} + Repository + {/if} +
+

{repo ? getRepoName(repo) : item.repoName}

+ {#if repo && getRepoDescription(repo)} +

{getRepoDescription(repo)}

+ {:else} +

No description available

+ {/if} +
+
+ + View & Edit → + + {#if canDelete} + + {/if} + +
+
+ {#if repo} +
+ Clone URLs: + {#each getCloneUrls(repo) as url} + {url} + {/each} +
+ {/if} +
+ Last modified: {new Date(item.lastModified).toLocaleDateString()} + {#if repo} + Created: {new Date(repo.created_at * 1000).toLocaleDateString()} + {#if getForkCount(repo) > 0} + {@const forkCount = getForkCount(repo)} + 🍴 {forkCount} fork{forkCount === 1 ? '' : 's'} + {/if} + {/if} +
+
-
+ {/each}
- {/each} + {/if}
{/if} diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index ee5e67e..5e4d403 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -116,7 +116,7 @@ export const GET: RequestHandler = createRepoGetHandler( ); } }, - { operation: 'getBranches', requireRepoExists: false, requireRepoAccess: false } // Branches are public info, handle on-demand fetching + { operation: 'getBranches', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos ); export const POST: RequestHandler = createRepoPostHandler( diff --git a/src/routes/api/repos/[npub]/[repo]/commits/+server.ts b/src/routes/api/repos/[npub]/[repo]/commits/+server.ts index f7a244c..035c2f2 100644 --- a/src/routes/api/repos/[npub]/[repo]/commits/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/commits/+server.ts @@ -127,5 +127,5 @@ export const GET: RequestHandler = createRepoGetHandler( ); } }, - { operation: 'getCommits', requireRepoExists: false, requireRepoAccess: false } // Commits are public, handle on-demand fetching + { operation: 'getCommits', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos ); diff --git a/src/routes/api/repos/[npub]/[repo]/delete/+server.ts b/src/routes/api/repos/[npub]/[repo]/delete/+server.ts new file mode 100644 index 0000000..87031a1 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/delete/+server.ts @@ -0,0 +1,171 @@ +/** + * API endpoint for deleting local repository clones + * Only allows deletion by repo owner or admin + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { rm } from 'fs/promises'; +import { join, resolve } from 'path'; +import { existsSync } from 'fs'; +import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; +import type { RepoRequestContext } from '$lib/utils/api-context.js'; +import { handleApiError, handleAuthorizationError } from '$lib/utils/error-handler.js'; +import { auditLogger } from '$lib/services/security/audit-logger.js'; +import { nip19 } from 'nostr-tools'; +import logger from '$lib/services/logger.js'; +import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; + +// Admin pubkeys (can be set via environment variable) +const ADMIN_PUBKEYS = (typeof process !== 'undefined' && process.env?.ADMIN_PUBKEYS + ? process.env.ADMIN_PUBKEYS.split(',').map(p => p.trim()).filter(p => p.length > 0) + : []) as string[]; + +/** + * Check if user is admin + */ +function isAdmin(userPubkeyHex: string | null): boolean { + if (!userPubkeyHex) return false; + return ADMIN_PUBKEYS.some(adminPubkey => { + // Support both hex and npub formats + try { + const decoded = nip19.decode(adminPubkey); + if (decoded.type === 'npub') { + return decoded.data === userPubkeyHex; + } + } catch { + // Not an npub, compare as hex + } + return adminPubkey.toLowerCase() === userPubkeyHex.toLowerCase(); + }); +} + +/** + * Check if user is repo owner + */ +function isOwner(userPubkeyHex: string | null, repoOwnerPubkey: string): boolean { + if (!userPubkeyHex) return false; + return userPubkeyHex.toLowerCase() === repoOwnerPubkey.toLowerCase(); +} + +export const DELETE: RequestHandler = createRepoGetHandler( + async (context: RepoRequestContext, event) => { + const { npub, repo, repoOwnerPubkey, userPubkeyHex, clientIp } = context; + + // Check permissions: must be owner or admin + if (!userPubkeyHex) { + auditLogger.log({ + user: undefined, + ip: clientIp, + action: 'repo.delete', + resource: `${npub}/${repo}`, + result: 'denied', + error: 'Authentication required' + }); + return handleAuthorizationError('Authentication required to delete repositories'); + } + + const userIsOwner = isOwner(userPubkeyHex, repoOwnerPubkey); + const userIsAdmin = isAdmin(userPubkeyHex); + + if (!userIsOwner && !userIsAdmin) { + auditLogger.log({ + user: userPubkeyHex, + ip: clientIp, + action: 'repo.delete', + resource: `${npub}/${repo}`, + result: 'denied', + error: 'Insufficient permissions' + }); + return handleAuthorizationError('Only repository owners or admins can delete repositories'); + } + + // Get repository path + const repoPath = join(repoRoot, npub, `${repo}.git`); + + // Security: Ensure resolved path is within repoRoot + const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); + if (!resolvedPath.startsWith(resolvedRoot + '/')) { + auditLogger.log({ + user: userPubkeyHex, + ip: clientIp, + action: 'repo.delete', + resource: `${npub}/${repo}`, + result: 'denied', + error: 'Invalid repository path' + }); + return error(403, 'Invalid repository path'); + } + + // Check if repo exists + if (!existsSync(repoPath)) { + auditLogger.log({ + user: userPubkeyHex, + ip: clientIp, + action: 'repo.delete', + resource: `${npub}/${repo}`, + result: 'failure', + error: 'Repository not found' + }); + return error(404, 'Repository not found'); + } + + try { + // Delete the repository directory + await rm(repoPath, { recursive: true, force: true }); + + // Clear cache + repoCache.delete(RepoCache.repoExistsKey(npub, repo)); + + // Log successful deletion + auditLogger.log({ + user: userPubkeyHex, + ip: clientIp, + action: 'repo.delete', + resource: `${npub}/${repo}`, + result: 'success', + metadata: { + isOwner: userIsOwner, + isAdmin: userIsAdmin + } + }); + + logger.info({ + user: userPubkeyHex, + npub, + repo, + isOwner: userIsOwner, + isAdmin: userIsAdmin + }, 'Repository deleted'); + + return json({ + success: true, + message: 'Repository deleted successfully' + }); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : 'Unknown error'; + + auditLogger.log({ + user: userPubkeyHex, + ip: clientIp, + action: 'repo.delete', + resource: `${npub}/${repo}`, + result: 'failure', + error: errorMessage + }); + + return handleApiError(err, { operation: 'deleteRepo', npub, repo }, 'Failed to delete repository'); + } + }, + { + operation: 'deleteRepo', + requireRepoExists: true, + requireRepoAccess: false, // We check permissions manually + requireMaintainer: false // We check owner/admin manually + } +); diff --git a/src/routes/api/repos/[npub]/[repo]/download/+server.ts b/src/routes/api/repos/[npub]/[repo]/download/+server.ts index 8abdc21..2391987 100644 --- a/src/routes/api/repos/[npub]/[repo]/download/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/download/+server.ts @@ -260,5 +260,5 @@ export const GET: RequestHandler = createRepoGetHandler( throw archiveError; } }, - { operation: 'download', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, downloads are public + { operation: 'download', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos ); diff --git a/src/routes/api/repos/[npub]/[repo]/readme/+server.ts b/src/routes/api/repos/[npub]/[repo]/readme/+server.ts index 9dc23dd..2c2a9e6 100644 --- a/src/routes/api/repos/[npub]/[repo]/readme/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/readme/+server.ts @@ -118,5 +118,5 @@ export const GET: RequestHandler = createRepoGetHandler( isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown') }); }, - { operation: 'getReadme', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, readme is public + { operation: 'getReadme', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos ); diff --git a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts index e865add..228de56 100644 --- a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts @@ -15,7 +15,7 @@ export const GET: RequestHandler = createRepoGetHandler( const tags = await fileManager.getTags(context.npub, context.repo); return json(tags); }, - { operation: 'getTags', requireRepoExists: false, requireRepoAccess: false } // Tags are public, handle on-demand fetching + { operation: 'getTags', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos ); export const POST: RequestHandler = createRepoPostHandler( diff --git a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts index 02ecdee..b9e89d5 100644 --- a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts @@ -132,5 +132,5 @@ export const GET: RequestHandler = createRepoGetHandler( ); } }, - { operation: 'listFiles', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, tree is public + { operation: 'listFiles', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos ); diff --git a/src/routes/api/repos/list/+server.ts b/src/routes/api/repos/list/+server.ts new file mode 100644 index 0000000..6a93ec2 --- /dev/null +++ b/src/routes/api/repos/list/+server.ts @@ -0,0 +1,117 @@ +/** + * API endpoint for listing repositories with privacy checks + * Returns only repositories the current user can view + */ + +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 { KIND } from '$lib/types/nostr.js'; +import { nip19 } from 'nostr-tools'; +import { handleApiError } from '$lib/utils/error-handler.js'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import logger from '$lib/services/logger.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); + +interface RepoListItem { + event: NostrEvent; + npub: string; + repoName: string; + isRegistered: boolean; // Has this domain in clone URLs +} + +export const GET: RequestHandler = async (event) => { + try { + const requestContext = extractRequestContext(event); + const userPubkey = requestContext.userPubkeyHex || null; + const gitDomain = event.url.searchParams.get('domain') || GIT_DOMAIN; + + // Fetch all repository announcements + const events = await nostrClient.fetchEvents([ + { kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 } + ]); + + const repos: RepoListItem[] = []; + + // Process each announcement + for (const event of events) { + const cloneUrls = event.tags + .filter(t => t[0] === 'clone') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string'); + + // Check if repo has this domain in clone URLs + const hasDomain = cloneUrls.some(url => url.includes(gitDomain)); + + // Extract repo name from d-tag + const dTag = event.tags.find(t => t[0] === 'd')?.[1]; + if (!dTag) continue; + + // Check privacy + const isPrivate = event.tags.some(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); + + // Check if user can view this repo + let canView = false; + if (!isPrivate) { + canView = true; // Public repos are viewable by anyone + } else if (userPubkey) { + // Private repos require authentication + try { + canView = await maintainerService.canView(userPubkey, event.pubkey, dTag); + } catch (err) { + logger.warn({ error: err, pubkey: event.pubkey, repo: dTag }, 'Failed to check repo access'); + canView = false; + } + } + + // Only include repos the user can view + if (!canView) continue; + + // Extract npub from clone URLs or convert pubkey + let npub: string; + const domainUrl = cloneUrls.find(url => url.includes(gitDomain)); + if (domainUrl) { + const match = domainUrl.match(/\/(npub[a-z0-9]+)\//); + if (match) { + npub = match[1]; + } else { + npub = nip19.npubEncode(event.pubkey); + } + } else { + npub = nip19.npubEncode(event.pubkey); + } + + repos.push({ + event, + npub, + repoName: dTag, + isRegistered: hasDomain + }); + } + + // Separate into registered and unregistered + const registered = repos.filter(r => r.isRegistered); + const unregistered = repos.filter(r => !r.isRegistered); + + // Sort by created_at descending + registered.sort((a, b) => b.event.created_at - a.event.created_at); + unregistered.sort((a, b) => b.event.created_at - a.event.created_at); + + return json({ + registered, + unregistered, + total: repos.length + }); + } catch (err) { + return handleApiError(err, { operation: 'listRepos' }, 'Failed to list repositories'); + } +}; diff --git a/src/routes/api/repos/local/+server.ts b/src/routes/api/repos/local/+server.ts new file mode 100644 index 0000000..de45f19 --- /dev/null +++ b/src/routes/api/repos/local/+server.ts @@ -0,0 +1,244 @@ +/** + * API endpoint for listing local repository clones + * Returns local repos with their announcements, filtered by privacy + */ + +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { readdir, stat } from 'fs/promises'; +import { join } from 'path'; +import { existsSync } from 'fs'; +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 { KIND } from '$lib/types/nostr.js'; +import { nip19 } from 'nostr-tools'; +import { handleApiError } from '$lib/utils/error-handler.js'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import logger from '$lib/services/logger.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); + +// Cache for local repo list (5 minute TTL) +interface CacheEntry { + repos: LocalRepoItem[]; + timestamp: number; +} + +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +let cache: CacheEntry | null = null; + +interface LocalRepoItem { + npub: string; + repoName: string; + announcement: NostrEvent | null; + lastModified: number; + isRegistered: boolean; // Has this domain in clone URLs +} + +const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT + ? process.env.GIT_REPO_ROOT + : '/repos'; + +/** + * Scan filesystem for local repositories + */ +async function scanLocalRepos(): Promise { + const repos: LocalRepoItem[] = []; + + if (!existsSync(repoRoot)) { + return repos; + } + + try { + // Read all user directories + const userDirs = await readdir(repoRoot); + + for (const userDir of userDirs) { + const userPath = join(repoRoot, userDir); + + // Skip if not a directory or doesn't look like an npub + if (!userDir.startsWith('npub') || userDir.length < 60) continue; + + try { + const stats = await stat(userPath); + if (!stats.isDirectory()) continue; + + // Read repos for this user + const repoFiles = await readdir(userPath); + + for (const repoFile of repoFiles) { + if (!repoFile.endsWith('.git')) continue; + + const repoName = repoFile.replace(/\.git$/, ''); + const repoPath = join(userPath, repoFile); + + try { + const repoStats = await stat(repoPath); + if (!repoStats.isDirectory()) continue; + + repos.push({ + npub: userDir, + repoName, + announcement: null, // Will be fetched later + lastModified: repoStats.mtime.getTime(), + isRegistered: false // Will be determined from announcement + }); + } catch (err) { + logger.warn({ error: err, repoPath }, 'Failed to stat repo'); + } + } + } catch (err) { + logger.warn({ error: err, userPath }, 'Failed to read user directory'); + } + } + } catch (err) { + logger.error({ error: err }, 'Failed to scan local repos'); + throw err; + } + + return repos; +} + +/** + * Fetch announcements for local repos and check privacy + */ +async function enrichLocalRepos( + repos: LocalRepoItem[], + userPubkey: string | null, + gitDomain: string +): Promise { + const enriched: LocalRepoItem[] = []; + + // Fetch announcements in parallel (batch by owner) + const ownerMap = new Map(); // pubkey -> repo names + for (const repo of repos) { + try { + const decoded = nip19.decode(repo.npub); + if (decoded.type === 'npub') { + const pubkey = decoded.data as string; + if (!ownerMap.has(pubkey)) { + ownerMap.set(pubkey, []); + } + ownerMap.get(pubkey)!.push(repo.repoName); + } + } catch { + // Invalid npub, skip + continue; + } + } + + // Fetch announcements for each owner + for (const [pubkey, repoNames] of ownerMap.entries()) { + try { + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [pubkey], + '#d': repoNames, + limit: repoNames.length + } + ]); + + // Match announcements to repos + for (const repo of repos) { + try { + const decoded = nip19.decode(repo.npub); + if (decoded.type !== 'npub' || decoded.data !== pubkey) continue; + + const announcement = events.find(e => { + const dTag = e.tags.find(t => t[0] === 'd')?.[1]; + return dTag === repo.repoName; + }); + + if (announcement) { + // Check if registered (has domain in clone URLs) + const cloneUrls = announcement.tags + .filter(t => t[0] === 'clone') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string'); + + const hasDomain = cloneUrls.some(url => url.includes(gitDomain)); + + // Check privacy + const isPrivate = announcement.tags.some(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); + + // Check if user can view + let canView = false; + if (!isPrivate) { + canView = true; + } else if (userPubkey) { + try { + canView = await maintainerService.canView(userPubkey, pubkey, repo.repoName); + } catch (err) { + logger.warn({ error: err, pubkey, repo: repo.repoName }, 'Failed to check repo access'); + canView = false; + } + } + + // Only include repos user can view + if (canView) { + enriched.push({ + ...repo, + announcement, + isRegistered: hasDomain + }); + } + } else { + // No announcement found - only show if user is owner (for security) + // For now, skip repos without announcements + // In the future, we could allow owners to see their own repos + } + } catch { + // Skip invalid repos + } + } + } catch (err) { + logger.warn({ error: err, pubkey }, 'Failed to fetch announcements for owner'); + } + } + + return enriched; +} + +export const GET: RequestHandler = async (event) => { + try { + const requestContext = extractRequestContext(event); + const userPubkey = requestContext.userPubkeyHex || null; + const gitDomain = event.url.searchParams.get('domain') || GIT_DOMAIN; + const forceRefresh = url.searchParams.get('refresh') === 'true'; + + // Check cache + if (!forceRefresh && cache && (Date.now() - cache.timestamp) < CACHE_TTL) { + return json(cache.repos); + } + + // Scan filesystem + const localRepos = await scanLocalRepos(); + + // Enrich with announcements and filter by privacy + const enriched = await enrichLocalRepos(localRepos, userPubkey, gitDomain); + + // Filter out registered repos (they're in the main list) + const unregistered = enriched.filter(r => !r.isRegistered); + + // Sort by last modified (most recent first) + unregistered.sort((a, b) => b.lastModified - a.lastModified); + + // Update cache + cache = { + repos: unregistered, + timestamp: Date.now() + }; + + return json(unregistered); + } catch (err) { + return handleApiError(err, { operation: 'listLocalRepos' }, 'Failed to list local repositories'); + } +}; diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index fd8d23f..2e8d4e0 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -5,6 +5,7 @@ import { json, error } 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, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js'; import { KIND } from '$lib/types/nostr.js'; import { FileManager } from '$lib/services/git/file-manager.js'; @@ -12,14 +13,21 @@ import { nip19 } from 'nostr-tools'; import { existsSync } from 'fs'; import { join } from 'path'; import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import logger from '$lib/services/logger.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); +const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); -export const GET: RequestHandler = async ({ url }) => { - const query = url.searchParams.get('q'); - const type = url.searchParams.get('type') || 'repos'; // repos, code, or all - const limit = parseInt(url.searchParams.get('limit') || '20', 10); +export const GET: RequestHandler = async (event) => { + const query = event.url.searchParams.get('q'); + const type = event.url.searchParams.get('type') || 'repos'; // repos, code, or all + const limit = parseInt(event.url.searchParams.get('limit') || '20', 10); + + // Extract user pubkey for privacy filtering + const requestContext = extractRequestContext(event); + const userPubkey = requestContext.userPubkeyHex || null; if (!query || query.trim().length === 0) { return handleValidationError('Missing or empty query parameter', { operation: 'search' }); @@ -80,12 +88,37 @@ export const GET: RequestHandler = async ({ url }) => { }); } - // Process events into results + // Process events into results with privacy filtering const searchLower = query.toLowerCase(); for (const event of events) { + const repoId = event.tags.find(t => t[0] === 'd')?.[1]; + if (!repoId) continue; + + // Check privacy + const isPrivate = event.tags.some(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); + + // Check if user can view this repo + let canView = false; + if (!isPrivate) { + canView = true; // Public repos are viewable by anyone + } else if (userPubkey) { + // Private repos require authentication + try { + canView = await maintainerService.canView(userPubkey, event.pubkey, repoId); + } catch (err) { + logger.warn({ error: err, pubkey: event.pubkey, repo: repoId }, 'Failed to check repo access in search'); + canView = false; + } + } + + // Only include repos the user can view + if (!canView) continue; + const name = event.tags.find(t => t[0] === 'name')?.[1] || ''; const description = event.tags.find(t => t[0] === 'description')?.[1] || ''; - const repoId = event.tags.find(t => t[0] === 'd')?.[1] || ''; try { const npub = nip19.npubEncode(event.pubkey); @@ -155,11 +188,30 @@ export const GET: RequestHandler = async ({ url }) => { // If we can't list repos, skip code search } + // Filter repos by privacy before searching code + const accessibleRepos: Array<{ npub: string; repo: string }> = []; + for (const { npub, repo } of allRepos.slice(0, 10)) { // Limit to 10 repos for performance + try { + // Decode npub to get pubkey + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') continue; + const repoOwnerPubkey = decoded.data as string; + + // Check if user can view this repo + const canView = await maintainerService.canView(userPubkey, repoOwnerPubkey, repo); + if (canView) { + accessibleRepos.push({ npub, repo }); + } + } catch { + // Skip if can't decode npub or check access + } + } + // Search in files (limited to avoid performance issues) const searchLower = query.toLowerCase(); let codeResults: Array<{ repo: string; npub: string; file: string; matches: number }> = []; - for (const { npub, repo } of allRepos.slice(0, 10)) { // Limit to 10 repos for performance + for (const { npub, repo } of accessibleRepos) { try { const files = await fileManager.listFiles(npub, repo, 'HEAD', ''); diff --git a/src/routes/api/users/[npub]/repos/+server.ts b/src/routes/api/users/[npub]/repos/+server.ts new file mode 100644 index 0000000..96947e3 --- /dev/null +++ b/src/routes/api/users/[npub]/repos/+server.ts @@ -0,0 +1,107 @@ +/** + * API endpoint for listing a user's repositories with privacy checks + * Returns only repositories the current user can view + */ + +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 { 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 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); + +export const GET: RequestHandler = async (event) => { + try { + const { npub } = event.params; + if (!npub) { + return handleValidationError('Missing npub parameter', { operation: 'getUserRepos' }); + } + + // Decode npub to get pubkey + let userPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + return handleValidationError('Invalid npub format', { operation: 'getUserRepos', npub }); + } + userPubkey = decoded.data as string; + } catch { + return handleValidationError('Invalid npub format', { operation: 'getUserRepos', npub }); + } + + const requestContext = extractRequestContext(event); + const viewerPubkey = requestContext.userPubkeyHex || null; + const gitDomain = event.url.searchParams.get('domain') || GIT_DOMAIN; + + // Fetch user's repository announcements + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [userPubkey], + limit: 100 + } + ]); + + const repos: NostrEvent[] = []; + + // Process each announcement with privacy filtering + for (const event of events) { + const cloneUrls = event.tags + .filter(t => t[0] === 'clone') + .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; + + // Check privacy + const isPrivate = event.tags.some(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); + + // Check if viewer can view this repo + let canView = false; + if (!isPrivate) { + canView = true; // Public repos are viewable by anyone + } else if (viewerPubkey) { + // Private repos require authentication + try { + canView = await maintainerService.canView(viewerPubkey, userPubkey, dTag); + } catch (err) { + logger.warn({ error: err, pubkey: userPubkey, repo: dTag }, 'Failed to check repo access'); + canView = false; + } + } + + // Only include repos the viewer can view + if (!canView) continue; + + repos.push(event); + } + + // Sort by created_at descending + repos.sort((a, b) => b.created_at - a.created_at); + + return json({ + repos, + total: repos.length + }); + } catch (err) { + return handleApiError(err, { operation: 'getUserRepos' }, 'Failed to get user repositories'); + } +}; diff --git a/src/routes/repos/[npub]/[repo]/+page.ts b/src/routes/repos/[npub]/[repo]/+page.ts index e62d0d9..3f35814 100644 --- a/src/routes/repos/[npub]/[repo]/+page.ts +++ b/src/routes/repos/[npub]/[repo]/+page.ts @@ -4,9 +4,11 @@ import type { PageLoad } from './$types'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { KIND } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; +import { extractRequestContext } from '$lib/utils/api-context.js'; export const load: PageLoad = async ({ params, url, parent }) => { const { npub, repo } = params; @@ -49,6 +51,20 @@ export const load: PageLoad = async ({ params, url, parent }) => { } const announcement = events[0]; + + // Check privacy - for private repos, we'll let the API endpoints handle access control + // The page load function runs server-side but doesn't have access to client auth headers + // So we'll mark it as private and let the frontend handle access denial + const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + const isPrivate = announcement.tags.some(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); + + // For private repos, we can't check access here (no user context in page load) + // The frontend will need to check access via API and show appropriate error + // We still expose basic metadata (name) but the API will enforce access + const name = announcement.tags.find(t => t[0] === 'name')?.[1] || repo; const description = announcement.tags.find(t => t[0] === 'description')?.[1] || ''; const image = announcement.tags.find(t => t[0] === 'image')?.[1]; diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte index d1a7c0a..945f9ff 100644 --- a/src/routes/search/+page.svelte +++ b/src/routes/search/+page.svelte @@ -3,10 +3,13 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import UserBadge from '$lib/components/UserBadge.svelte'; + import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; + import { nip19 } from 'nostr-tools'; let query = $state(''); let searchType = $state<'repos' | 'code' | 'all'>('repos'); let loading = $state(false); + let userPubkeyHex = $state(null); let results = $state<{ repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>; code: Array<{ repo: string; npub: string; file: string; matches: number }>; @@ -14,6 +17,31 @@ } | null>(null); let error = $state(null); + onMount(async () => { + await loadUserPubkey(); + }); + + async function loadUserPubkey() { + if (!isNIP07Available()) { + return; + } + + try { + const userPubkey = await getPublicKeyWithNIP07(); + // Convert npub to hex for API calls + try { + const decoded = nip19.decode(userPubkey); + if (decoded.type === 'npub') { + userPubkeyHex = decoded.data as string; + } + } catch { + userPubkeyHex = userPubkey; // Assume it's already hex + } + } catch (err) { + console.warn('Failed to load user pubkey:', err); + } + } + async function performSearch() { if (!query.trim() || query.length < 2) { return; @@ -24,7 +52,14 @@ results = null; // Reset results try { - const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${searchType}`); + const headers: Record = {}; + if (userPubkeyHex) { + headers['X-User-Pubkey'] = userPubkeyHex; + } + + const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${searchType}`, { + headers + }); if (response.ok) { const data = await response.json(); // The API returns { query, type, results: { repos, code }, total } diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index a0f92d5..ae1747c 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -53,8 +53,106 @@ const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const searchClient = new NostrClient(DEFAULT_NOSTR_SEARCH_RELAYS); - onMount(() => { + onMount(async () => { nip07Available = isNIP07Available(); + + // Check for query params to pre-fill form (for registering local clones) + const urlParams = $page.url.searchParams; + const npubParam = urlParams.get('npub'); + const repoParam = urlParams.get('repo'); + + if (npubParam && repoParam) { + // Pre-fill repo name + repoName = repoParam; + + // Try to fetch existing announcement to pre-fill other fields + try { + const decoded = nip19.decode(npubParam); + if (decoded.type === 'npub') { + const pubkey = decoded.data as string; + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [pubkey], + '#d': [repoParam], + limit: 1 + } + ]); + + if (events.length > 0) { + const event = events[0]; + + // Pre-fill description + const descTag = event.tags.find(t => t[0] === 'description')?.[1]; + if (descTag) description = descTag; + + // Pre-fill clone URLs (add current domain URL) + const existingCloneUrls = event.tags + .filter(t => t[0] === 'clone') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string'); + + const gitDomain = $page.data.gitDomain || 'localhost:6543'; + const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; + const currentDomainUrl = `${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`; + + // Check if current domain URL already exists + const hasCurrentDomain = existingCloneUrls.some(url => url.includes(gitDomain)); + + if (!hasCurrentDomain) { + cloneUrls = [...existingCloneUrls, currentDomainUrl]; + } else { + cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : [currentDomainUrl]; + } + + // Pre-fill other fields + const nameTag = event.tags.find(t => t[0] === 'name')?.[1]; + if (nameTag && !repoName) repoName = nameTag; + + const imageTag = event.tags.find(t => t[0] === 'image')?.[1]; + if (imageTag) imageUrl = imageTag; + + const bannerTag = event.tags.find(t => t[0] === 'banner')?.[1]; + if (bannerTag) bannerUrl = bannerTag; + + const webTags = event.tags.filter(t => t[0] === 'web'); + if (webTags.length > 0) { + webUrls = webTags.flatMap(t => t.slice(1)).filter(url => url && typeof url === 'string'); + } + + const maintainerTags = event.tags.filter(t => t[0] === 'maintainers'); + if (maintainerTags.length > 0) { + maintainers = maintainerTags.flatMap(t => t.slice(1)).filter(m => m && typeof m === 'string'); + } + + const relayTags = event.tags.filter(t => t[0] === 'relays'); + if (relayTags.length > 0) { + relays = relayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string'); + } + + const isPrivateTag = event.tags.find(t => + (t[0] === 'private' && t[1] === 'true') || + (t[0] === 't' && t[1] === 'private') + ); + if (isPrivateTag) isPrivate = true; + + // Set existing repo ref for updating + existingRepoRef = event.id; + } else { + // No announcement found, just set the clone URL with current domain + const gitDomain = $page.data.gitDomain || 'localhost:6543'; + const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; + cloneUrls = [`${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`]; + } + } + } catch (err) { + console.warn('Failed to pre-fill form from query params:', err); + // Still set basic clone URL + const gitDomain = $page.data.gitDomain || 'localhost:6543'; + const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; + cloneUrls = [`${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`]; + } + } }); function addCloneUrl() { diff --git a/src/routes/users/[npub]/+page.svelte b/src/routes/users/[npub]/+page.svelte index 2629cc2..ac76e6d 100644 --- a/src/routes/users/[npub]/+page.svelte +++ b/src/routes/users/[npub]/+page.svelte @@ -7,12 +7,14 @@ import { KIND } from '$lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; import type { NostrEvent } from '$lib/types/nostr.js'; + import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; const npub = ($page.params as { npub?: string }).npub || ''; let loading = $state(true); let error = $state(null); let userPubkey = $state(null); + let viewerPubkeyHex = $state(null); let repos = $state([]); let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null); @@ -20,9 +22,31 @@ const gitDomain = $page.data.gitDomain || 'localhost:6543'; onMount(async () => { + await loadViewerPubkey(); await loadUserProfile(); }); + async function loadViewerPubkey() { + if (!isNIP07Available()) { + return; + } + + try { + const viewerPubkey = await getPublicKeyWithNIP07(); + // Convert npub to hex for API calls + try { + const decoded = nip19.decode(viewerPubkey); + if (decoded.type === 'npub') { + viewerPubkeyHex = decoded.data as string; + } + } catch { + viewerPubkeyHex = viewerPubkey; // Assume it's already hex + } + } catch (err) { + console.warn('Failed to load viewer pubkey:', err); + } + } + async function loadUserProfile() { loading = true; error = null; @@ -36,27 +60,20 @@ } userPubkey = decoded.data as string; - // Fetch user's repositories - const repoEvents = await nostrClient.fetchEvents([ - { - kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [userPubkey], - limit: 100 - } - ]); - - // Filter for repos that list our domain - repos = repoEvents.filter(event => { - const cloneUrls = event.tags - .filter(t => t[0] === 'clone') - .flatMap(t => t.slice(1)) - .filter(url => url && typeof url === 'string'); - - return cloneUrls.some(url => url.includes(gitDomain)); + // Fetch user's repositories via API (with privacy filtering) + const url = `/api/users/${npub}/repos?domain=${encodeURIComponent(gitDomain)}`; + const response = await fetch(url, { + headers: viewerPubkeyHex ? { + 'X-User-Pubkey': viewerPubkeyHex + } : {} }); - // Sort by created_at descending - repos.sort((a, b) => b.created_at - a.created_at); + if (!response.ok) { + throw new Error(`Failed to load repositories: ${response.statusText}`); + } + + const data = await response.json(); + repos = data.repos || []; // Try to fetch user profile (kind 0) const profileEvents = await nostrClient.fetchEvents([