diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index f4bc95f..fdb5613 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -60,6 +60,10 @@ } } + function goToSearch() { + goto('/search'); + } + function getRepoName(event: NostrEvent): string { const nameTag = event.tags.find(t => t[0] === 'name' && t[1]); if (nameTag?.[1]) return nameTag[1]; @@ -75,6 +79,16 @@ return descTag?.[1] || ''; } + function getRepoImage(event: NostrEvent): string | null { + const imageTag = event.tags.find(t => t[0] === 'image' && t[1]); + return imageTag?.[1] || null; + } + + function getRepoBanner(event: NostrEvent): string | null { + const bannerTag = event.tags.find(t => t[0] === 'banner' && t[1]); + return bannerTag?.[1] || null; + } + function getCloneUrls(event: NostrEvent): string[] { const urls: string[] = []; @@ -143,6 +157,7 @@

gitrepublic

@@ -167,24 +182,38 @@ {:else}
{#each repos as repo} + {@const repoImage = getRepoImage(repo)} + {@const repoBanner = getRepoBanner(repo)}
-
-

{getRepoName(repo)}

- - View & Edit → - -
- {#if getRepoDescription(repo)} -

{getRepoDescription(repo)}

+ {#if repoBanner} +
+ Banner +
{/if} -
- Clone URLs: - {#each getCloneUrls(repo) as url} - {url} - {/each} -
-
- Created: {new Date(repo.created_at * 1000).toLocaleDateString()} +
+
+ {#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()} +
{/each} @@ -234,15 +263,45 @@ .repo-card { border: 1px solid #e5e7eb; border-radius: 0.5rem; - padding: 1.5rem; background: white; + overflow: hidden; + } + + .repo-card-banner { + width: 100%; + height: 200px; + overflow: hidden; + background: #f3f4f6; + } + + .repo-card-banner img { + width: 100%; + height: 100%; + object-fit: cover; + } + + .repo-card-content { + padding: 1.5rem; } .repo-header { display: flex; justify-content: space-between; - align-items: center; + align-items: flex-start; margin-bottom: 0.5rem; + gap: 1rem; + } + + .repo-header-text { + flex: 1; + } + + .repo-card-image { + width: 64px; + height: 64px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; } .repo-card h3 { diff --git a/src/routes/api/repos/[npub]/[repo]/download/+server.ts b/src/routes/api/repos/[npub]/[repo]/download/+server.ts new file mode 100644 index 0000000..e79dcfa --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/download/+server.ts @@ -0,0 +1,110 @@ +/** + * API endpoint for downloading repository as ZIP + */ + +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { FileManager } from '$lib/services/git/file-manager.js'; +import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { nip19 } from 'nostr-tools'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { createReadStream } from 'fs'; + +const execAsync = promisify(exec); +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 ({ params, url, request }) => { + const { npub, repo } = params; + const ref = url.searchParams.get('ref') || 'HEAD'; + const format = url.searchParams.get('format') || 'zip'; // zip or tar.gz + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + try { + if (!fileManager.repoExists(npub, repo)) { + return error(404, 'Repository not found'); + } + + // Check repository privacy + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); + if (!canView) { + return error(403, 'This repository is private. Only owners and maintainers can view it.'); + } + + const repoPath = join(repoRoot, npub, `${repo}.git`); + const tempDir = join(repoRoot, '..', 'temp-downloads'); + const workDir = join(tempDir, `${npub}-${repo}-${Date.now()}`); + const archiveName = `${repo}-${ref}.${format === 'tar.gz' ? 'tar.gz' : 'zip'}`; + const archivePath = join(tempDir, archiveName); + + try { + // Create temp directory + await execAsync(`mkdir -p "${tempDir}"`); + await execAsync(`mkdir -p "${workDir}"`); + + // Clone repository to temp directory + await execAsync(`git clone "${repoPath}" "${workDir}"`); + + // Checkout specific ref if not HEAD + if (ref !== 'HEAD') { + await execAsync(`cd "${workDir}" && git checkout "${ref}"`); + } + + // Remove .git directory + await execAsync(`rm -rf "${workDir}/.git"`); + + // Create archive + if (format === 'tar.gz') { + await execAsync(`cd "${tempDir}" && tar -czf "${archiveName}" -C "${workDir}" .`); + } else { + // Use zip command (requires zip utility) + await execAsync(`cd "${workDir}" && zip -r "${archivePath}" .`); + } + + // Read archive file + const archiveBuffer = readFileSync(archivePath); + + // Clean up + await execAsync(`rm -rf "${workDir}"`); + await execAsync(`rm -f "${archivePath}"`); + + // Return archive + return new Response(archiveBuffer, { + headers: { + 'Content-Type': format === 'tar.gz' ? 'application/gzip' : 'application/zip', + 'Content-Disposition': `attachment; filename="${archiveName}"`, + 'Content-Length': archiveBuffer.length.toString() + } + }); + } catch (archiveError) { + // Clean up on error + await execAsync(`rm -rf "${workDir}"`).catch(() => {}); + await execAsync(`rm -f "${archivePath}"`).catch(() => {}); + throw archiveError; + } + } catch (err) { + console.error('Error creating repository archive:', err); + return error(500, err instanceof Error ? err.message : 'Failed to create repository archive'); + } +}; diff --git a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts new file mode 100644 index 0000000..2b2ebbb --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts @@ -0,0 +1,257 @@ +/** + * API endpoint for forking repositories + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { RepoManager } from '$lib/services/git/repo-manager.js'; +import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js'; +import { getUserRelays } from '$lib/services/nostr/user-relays.js'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { KIND } from '$lib/types/nostr.js'; +import { nip19 } from 'nostr-tools'; +import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; +import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +const execAsync = promisify(exec); +const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; +const repoManager = new RepoManager(repoRoot); +const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + +/** + * POST - Fork a repository + * Body: { userPubkey, forkName? } + */ +export const POST: RequestHandler = async ({ params, request }) => { + const { npub, repo } = params; + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + try { + const body = await request.json(); + const { userPubkey, forkName } = body; + + if (!userPubkey) { + return error(401, 'Authentication required. Please provide userPubkey.'); + } + + // Decode original repo owner npub + let originalOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + originalOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + // Decode user pubkey if needed + let userPubkeyHex = userPubkey; + try { + const userDecoded = nip19.decode(userPubkey); + if (userDecoded.type === 'npub') { + userPubkeyHex = userDecoded.data as string; + } + } catch { + // Assume it's already hex + } + + // Check if original repo exists + const originalRepoPath = join(repoRoot, npub, `${repo}.git`); + if (!existsSync(originalRepoPath)) { + return error(404, 'Original repository not found'); + } + + // Get original repo announcement + const originalAnnouncements = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [originalOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (originalAnnouncements.length === 0) { + return error(404, 'Original repository announcement not found'); + } + + const originalAnnouncement = originalAnnouncements[0]; + + // Determine fork name (use original name if not specified) + const forkRepoName = forkName || repo; + const userNpub = nip19.npubEncode(userPubkeyHex); + + // Check if fork already exists + const forkRepoPath = join(repoRoot, userNpub, `${forkRepoName}.git`); + if (existsSync(forkRepoPath)) { + return error(409, 'Fork already exists'); + } + + // Clone the repository + await execAsync(`git clone --bare "${originalRepoPath}" "${forkRepoPath}"`); + + // Create fork announcement + const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543'; + const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; + const forkGitUrl = `${protocol}://${gitDomain}/${userNpub}/${forkRepoName}.git`; + + // Extract original clone URLs and earliest unique commit + const originalCloneUrls = originalAnnouncement.tags + .filter(t => t[0] === 'clone') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string') as string[]; + + const earliestCommitTag = originalAnnouncement.tags.find(t => t[0] === 'r' && t[2] === 'euc'); + const earliestCommit = earliestCommitTag?.[1]; + + // Get original repo name and description + const originalName = originalAnnouncement.tags.find(t => t[0] === 'name')?.[1] || repo; + const originalDescription = originalAnnouncement.tags.find(t => t[0] === 'description')?.[1] || ''; + + // Build fork announcement tags + const tags: string[][] = [ + ['d', forkRepoName], + ['name', `${originalName} (fork)`], + ['description', `Fork of ${originalName}${originalDescription ? `: ${originalDescription}` : ''}`], + ['clone', forkGitUrl, ...originalCloneUrls.filter(url => !url.includes(gitDomain))], + ['relays', ...DEFAULT_NOSTR_RELAYS], + ['t', 'fork'], // Mark as fork + ['a', `30617:${originalOwnerPubkey}:${repo}`], // Reference to original repo + ['p', originalOwnerPubkey], // Original owner + ]; + + // Add earliest unique commit if available + if (earliestCommit) { + tags.push(['r', earliestCommit, 'euc']); + } + + // Create fork announcement event + const forkAnnouncementTemplate = { + kind: KIND.REPO_ANNOUNCEMENT, + pubkey: userPubkeyHex, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags + }; + + // Sign and publish fork announcement + const signedForkAnnouncement = await signEventWithNIP07(forkAnnouncementTemplate); + + const { outbox } = await getUserRelays(userPubkeyHex, nostrClient); + const combinedRelays = combineRelays(outbox); + + const publishResult = await nostrClient.publishEvent(signedForkAnnouncement, combinedRelays); + + if (publishResult.success.length === 0) { + // Clean up repo if announcement failed + await execAsync(`rm -rf "${forkRepoPath}"`).catch(() => {}); + return error(500, 'Failed to publish fork announcement to relays'); + } + + // Create and publish initial ownership proof (self-transfer event) + const ownershipService = new OwnershipTransferService(combinedRelays); + const initialOwnershipEvent = ownershipService.createInitialOwnershipEvent(userPubkeyHex, forkRepoName); + const signedOwnershipEvent = await signEventWithNIP07(initialOwnershipEvent); + + await nostrClient.publishEvent(signedOwnershipEvent, combinedRelays).catch(err => { + console.warn('Failed to publish initial ownership event for fork:', err); + }); + + // Provision the fork repo (this will create verification file and include self-transfer) + await repoManager.provisionRepo(signedForkAnnouncement, signedOwnershipEvent, false); + + return json({ + success: true, + fork: { + npub: userNpub, + repo: forkRepoName, + url: forkGitUrl, + announcementId: signedForkAnnouncement.id + } + }); + } catch (err) { + console.error('Error forking repository:', err); + return error(500, err instanceof Error ? err.message : 'Failed to fork repository'); + } +}; + +/** + * GET - Get fork information + * Returns whether this is a fork and what it's forked from + */ +export const GET: RequestHandler = async ({ params }) => { + const { npub, repo } = params; + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + try { + // Decode repo owner npub + let ownerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + ownerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + // Get repo announcement + const announcements = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [ownerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (announcements.length === 0) { + return error(404, 'Repository announcement not found'); + } + + const announcement = announcements[0]; + const isFork = announcement.tags.some(t => t[0] === 't' && t[1] === 'fork'); + + // Get original repo reference + const originalRepoTag = announcement.tags.find(t => t[0] === 'a' && t[1]?.startsWith('30617:')); + const originalOwnerTag = announcement.tags.find(t => t[0] === 'p' && t[1] !== ownerPubkey); + + let originalRepo: { npub: string; repo: string } | null = null; + if (originalRepoTag && originalRepoTag[1]) { + const match = originalRepoTag[1].match(/^30617:([a-f0-9]{64}):(.+)$/); + if (match) { + const [, originalOwnerPubkey, originalRepoName] = match; + try { + const originalNpub = nip19.npubEncode(originalOwnerPubkey); + originalRepo = { npub: originalNpub, repo: originalRepoName }; + } catch { + // Invalid pubkey + } + } + } + + return json({ + isFork, + originalRepo, + forkCount: 0 // TODO: Count forks of this repo + }); + } catch (err) { + console.error('Error getting fork information:', err); + return error(500, err instanceof Error ? err.message : 'Failed to get fork information'); + } +}; diff --git a/src/routes/api/repos/[npub]/[repo]/raw/+server.ts b/src/routes/api/repos/[npub]/[repo]/raw/+server.ts new file mode 100644 index 0000000..358b87d --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/raw/+server.ts @@ -0,0 +1,88 @@ +/** + * API endpoint for raw file access + */ + +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { FileManager } from '$lib/services/git/file-manager.js'; +import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { nip19 } from 'nostr-tools'; + +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 ({ params, url, request }) => { + const { npub, repo } = params; + const filePath = url.searchParams.get('path'); + const ref = url.searchParams.get('ref') || 'HEAD'; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); + + if (!npub || !repo || !filePath) { + return error(400, 'Missing npub, repo, or path parameter'); + } + + try { + if (!fileManager.repoExists(npub, repo)) { + return error(404, 'Repository not found'); + } + + // Check repository privacy + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); + if (!canView) { + return error(403, 'This repository is private. Only owners and maintainers can view it.'); + } + + // Get file content + const fileData = await fileManager.getFileContent(npub, repo, filePath, ref); + + // Determine content type based on file extension + const ext = filePath.split('.').pop()?.toLowerCase(); + const contentTypeMap: Record = { + 'js': 'application/javascript', + 'ts': 'application/typescript', + 'json': 'application/json', + 'css': 'text/css', + 'html': 'text/html', + 'xml': 'application/xml', + 'svg': 'image/svg+xml', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'webp': 'image/webp', + 'pdf': 'application/pdf', + 'txt': 'text/plain', + 'md': 'text/markdown', + 'yml': 'text/yaml', + 'yaml': 'text/yaml', + }; + + const contentType = contentTypeMap[ext || ''] || 'text/plain'; + + // Return raw file content + return new Response(fileData.content, { + headers: { + 'Content-Type': contentType, + 'Content-Disposition': `inline; filename="${filePath.split('/').pop()}"`, + 'Cache-Control': 'public, max-age=3600' + } + }); + } catch (err) { + console.error('Error getting raw file:', err); + return error(500, err instanceof Error ? err.message : 'Failed to get raw file'); + } +}; diff --git a/src/routes/api/repos/[npub]/[repo]/readme/+server.ts b/src/routes/api/repos/[npub]/[repo]/readme/+server.ts new file mode 100644 index 0000000..dbada51 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/readme/+server.ts @@ -0,0 +1,97 @@ +/** + * API endpoint for getting README content + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { FileManager } from '$lib/services/git/file-manager.js'; +import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { nip19 } from 'nostr-tools'; + +const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; +const fileManager = new FileManager(repoRoot); +const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); + +const README_PATTERNS = [ + 'README.md', + 'README.markdown', + 'README.txt', + 'readme.md', + 'readme.markdown', + 'readme.txt', + 'README', + 'readme' +]; + +export const GET: RequestHandler = async ({ params, url, request }) => { + const { npub, repo } = params; + const ref = url.searchParams.get('ref') || 'HEAD'; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + try { + if (!fileManager.repoExists(npub, repo)) { + return error(404, 'Repository not found'); + } + + // Check repository privacy + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); + if (!canView) { + return error(403, 'This repository is private. Only owners and maintainers can view it.'); + } + + // Try to find README file + let readmeContent: string | null = null; + let readmePath: string | null = null; + + for (const pattern of README_PATTERNS) { + try { + // Try root directory first + const content = await fileManager.getFileContent(npub, repo, pattern, ref); + readmeContent = content.content; + readmePath = pattern; + break; + } catch { + // Try in root directory with different paths + try { + const content = await fileManager.getFileContent(npub, repo, `/${pattern}`, ref); + readmeContent = content.content; + readmePath = `/${pattern}`; + break; + } catch { + continue; + } + } + } + + if (!readmeContent) { + return json({ found: false }); + } + + return json({ + found: true, + content: readmeContent, + path: readmePath, + isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown') + }); + } catch (err) { + console.error('Error getting README:', err); + return error(500, err instanceof Error ? err.message : 'Failed to get README'); + } +}; diff --git a/src/routes/api/repos/[npub]/[repo]/settings/+server.ts b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts new file mode 100644 index 0000000..e0ea149 --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts @@ -0,0 +1,219 @@ +/** + * API endpoint for repository settings + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { DEFAULT_NOSTR_RELAYS, combineRelays, getGitUrl } from '$lib/config.js'; +import { getUserRelays } from '$lib/services/nostr/user-relays.js'; +import { KIND } from '$lib/types/nostr.js'; +import { nip19 } from 'nostr-tools'; +import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; +import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; +import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; + +const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); +const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); +const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); + +/** + * GET - Get repository settings + */ +export const GET: RequestHandler = async ({ params, url, request }) => { + const { npub, repo } = params; + const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + try { + // Decode npub to get pubkey + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + // Check if user is owner + if (!userPubkey) { + return error(401, 'Authentication required'); + } + + let userPubkeyHex = userPubkey; + try { + const userDecoded = nip19.decode(userPubkey); + if (userDecoded.type === 'npub') { + userPubkeyHex = userDecoded.data as string; + } + } catch { + // Assume it's already hex + } + + const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); + if (userPubkeyHex !== currentOwner) { + return error(403, 'Only the repository owner can access settings'); + } + + // Get repository announcement + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [currentOwner], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length === 0) { + return error(404, 'Repository announcement not found'); + } + + const announcement = events[0]; + const name = announcement.tags.find(t => t[0] === 'name')?.[1] || repo; + const description = announcement.tags.find(t => t[0] === 'description')?.[1] || ''; + const cloneUrls = announcement.tags + .filter(t => t[0] === 'clone') + .flatMap(t => t.slice(1)) + .filter(url => url && typeof url === 'string') as string[]; + const maintainers = announcement.tags + .filter(t => t[0] === 'maintainers') + .flatMap(t => t.slice(1)) + .filter(m => m && typeof m === 'string') as string[]; + const isPrivate = await maintainerService.isRepoPrivate(currentOwner, repo); + + return json({ + name, + description, + cloneUrls, + maintainers, + isPrivate, + owner: currentOwner, + npub + }); + } catch (err) { + console.error('Error getting repository settings:', err); + return error(500, err instanceof Error ? err.message : 'Failed to get repository settings'); + } +}; + +/** + * POST - Update repository settings + */ +export const POST: RequestHandler = async ({ params, request }) => { + const { npub, repo } = params; + + if (!npub || !repo) { + return error(400, 'Missing npub or repo parameter'); + } + + try { + const body = await request.json(); + const { userPubkey, name, description, cloneUrls, maintainers, isPrivate } = body; + + if (!userPubkey) { + return error(401, 'Authentication required'); + } + + // Decode npub to get pubkey + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + repoOwnerPubkey = decoded.data as string; + } else { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + let userPubkeyHex = userPubkey; + try { + const userDecoded = nip19.decode(userPubkey); + if (userDecoded.type === 'npub') { + userPubkeyHex = userDecoded.data as string; + } + } catch { + // Assume it's already hex + } + + // Check if user is owner + const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); + if (userPubkeyHex !== currentOwner) { + return error(403, 'Only the repository owner can update settings'); + } + + // Get existing announcement + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [currentOwner], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length === 0) { + return error(404, 'Repository announcement not found'); + } + + const existingAnnouncement = events[0]; + + // Build updated tags + const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543'; + const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; + const gitUrl = `${protocol}://${gitDomain}/${npub}/${repo}.git`; + + const tags: string[][] = [ + ['d', repo], + ['name', name || repo], + ...(description ? [['description', description]] : []), + ['clone', gitUrl, ...(cloneUrls || []).filter((url: string) => url && !url.includes(gitDomain))], + ['relays', ...DEFAULT_NOSTR_RELAYS], + ...(isPrivate ? [['private', 'true']] : []), + ...(maintainers || []).map((m: string) => ['maintainers', m]) + ]; + + // Preserve other tags from original announcement + const preserveTags = ['r', 'web', 't']; + for (const tag of existingAnnouncement.tags) { + if (preserveTags.includes(tag[0]) && !tags.some(t => t[0] === tag[0])) { + tags.push(tag); + } + } + + // Create updated announcement + const updatedAnnouncement = { + kind: KIND.REPO_ANNOUNCEMENT, + pubkey: currentOwner, + created_at: Math.floor(Date.now() / 1000), + content: '', + tags + }; + + // Sign and publish + const signedEvent = await signEventWithNIP07(updatedAnnouncement); + + const { outbox } = await getUserRelays(currentOwner, nostrClient); + const combinedRelays = combineRelays(outbox); + + const result = await nostrClient.publishEvent(signedEvent, combinedRelays); + + if (result.success.length === 0) { + return error(500, 'Failed to publish updated announcement to relays'); + } + + return json({ success: true, event: signedEvent }); + } catch (err) { + console.error('Error updating repository settings:', err); + return error(500, err instanceof Error ? err.message : 'Failed to update repository settings'); + } +}; diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts new file mode 100644 index 0000000..fb7d222 --- /dev/null +++ b/src/routes/api/search/+server.ts @@ -0,0 +1,160 @@ +/** + * API endpoint for searching repositories and code + */ + +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { KIND } from '$lib/types/nostr.js'; +import { FileManager } from '$lib/services/git/file-manager.js'; +import { nip19 } from 'nostr-tools'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; +const fileManager = new FileManager(repoRoot); +const nostrClient = new NostrClient(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); + + if (!query || query.trim().length === 0) { + return error(400, 'Missing or empty query parameter'); + } + + if (query.length < 2) { + return error(400, 'Query must be at least 2 characters'); + } + + try { + const results: { + repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>; + code: Array<{ repo: string; npub: string; file: string; matches: number }>; + } = { + repos: [], + code: [] + }; + + // Search repositories + if (type === 'repos' || type === 'all') { + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + limit: 100 + } + ]); + + const searchLower = query.toLowerCase(); + + for (const event of events) { + 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] || ''; + + const nameMatch = name.toLowerCase().includes(searchLower); + const descMatch = description.toLowerCase().includes(searchLower); + const repoMatch = repoId.toLowerCase().includes(searchLower); + + if (nameMatch || descMatch || repoMatch) { + try { + const npub = nip19.npubEncode(event.pubkey); + results.repos.push({ + id: event.id, + name: name || repoId, + description: description || '', + owner: event.pubkey, + npub + }); + } catch { + // Skip if npub encoding fails + } + } + } + + // Sort by relevance (name matches first) + results.repos.sort((a, b) => { + const aNameMatch = a.name.toLowerCase().includes(searchLower); + const bNameMatch = b.name.toLowerCase().includes(searchLower); + if (aNameMatch && !bNameMatch) return -1; + if (!aNameMatch && bNameMatch) return 1; + return 0; + }); + + results.repos = results.repos.slice(0, limit); + } + + // Search code (basic file content search) + if (type === 'code' || type === 'all') { + // Get all repos on this server + const allRepos: Array<{ npub: string; repo: string }> = []; + + // This is a simplified search - in production, you'd want to index files + // For now, we'll search through known repos + try { + const repoDirs = await import('fs/promises').then(fs => + fs.readdir(repoRoot, { withFileTypes: true }) + ); + + for (const dir of repoDirs) { + if (dir.isDirectory()) { + const npub = dir.name; + try { + const repoFiles = await import('fs/promises').then(fs => + fs.readdir(join(repoRoot, npub), { withFileTypes: true }) + ); + + for (const repoFile of repoFiles) { + if (repoFile.isDirectory() && repoFile.name.endsWith('.git')) { + const repo = repoFile.name.replace('.git', ''); + allRepos.push({ npub, repo }); + } + } + } catch { + // Skip if can't read directory + } + } + } + } catch { + // If we can't list repos, skip code search + } + + // 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 + try { + const files = await fileManager.listFiles(npub, repo, 'HEAD', ''); + + for (const file of files.slice(0, 50)) { // Limit to 50 files per repo + if (file.type === 'file' && file.name.toLowerCase().includes(searchLower)) { + codeResults.push({ + repo, + npub, + file: file.path, + matches: 1 + }); + } + } + } catch { + // Skip if can't access repo + } + } + + results.code = codeResults.slice(0, limit); + } + + return json({ + query, + type, + results, + total: results.repos.length + results.code.length + }); + } catch (err) { + console.error('Error searching:', err); + return error(500, err instanceof Error ? err.message : 'Failed to search'); + } +}; diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index 1a93e56..625844a 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -10,6 +10,17 @@ import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { nip19 } from 'nostr-tools'; + // Get page data for OpenGraph metadata + const pageData = $page.data as { + title?: string; + description?: string; + image?: string; + banner?: string; + repoName?: string; + repoDescription?: string; + repoUrl?: string; + }; + const npub = ($page.params as { npub?: string; repo?: string }).npub || ''; const repo = ($page.params as { npub?: string; repo?: string }).repo || ''; @@ -84,6 +95,132 @@ let newPRLabels = $state(['']); let selectedPR = $state(null); + // README + let readmeContent = $state(null); + let readmePath = $state(null); + let readmeIsMarkdown = $state(false); + let loadingReadme = $state(false); + let readmeHtml = $state(''); + + // Fork + let forkInfo = $state<{ isFork: boolean; originalRepo: { npub: string; repo: string } | null } | null>(null); + let forking = $state(false); + + // Repository images + let repoImage = $state(null); + let repoBanner = $state(null); + + async function loadReadme() { + loadingReadme = true; + try { + const response = await fetch(`/api/repos/${npub}/${repo}/readme?ref=${currentBranch}`); + if (response.ok) { + const data = await response.json(); + if (data.found) { + readmeContent = data.content; + readmePath = data.path; + readmeIsMarkdown = data.isMarkdown; + + // Render markdown if needed + if (readmeIsMarkdown && readmeContent) { + const { marked } = await import('marked'); + readmeHtml = marked.parse(readmeContent) as string; + } + } + } + } catch (err) { + console.error('Error loading README:', err); + } finally { + loadingReadme = false; + } + } + + async function loadForkInfo() { + try { + const response = await fetch(`/api/repos/${npub}/${repo}/fork`); + if (response.ok) { + forkInfo = await response.json(); + } + } catch (err) { + console.error('Error loading fork info:', err); + } + } + + async function forkRepository() { + if (!userPubkey) { + alert('Please connect your NIP-07 extension'); + return; + } + + forking = true; + error = null; + + try { + const response = await fetch(`/api/repos/${npub}/${repo}/fork`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ userPubkey }) + }); + + if (response.ok) { + const data = await response.json(); + alert(`Repository forked successfully! Visit /repos/${data.fork.npub}/${data.fork.repo}`); + goto(`/repos/${data.fork.npub}/${data.fork.repo}`); + } else { + const data = await response.json(); + error = data.error || 'Failed to fork repository'; + } + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to fork repository'; + } finally { + forking = false; + } + } + + async function loadRepoImages() { + try { + // Get images from page data (loaded from announcement) + if (pageData.image) { + repoImage = pageData.image; + } + if (pageData.banner) { + repoBanner = pageData.banner; + } + + // Also fetch from announcement directly as fallback + if (!repoImage && !repoBanner) { + const decoded = nip19.decode(npub); + if (decoded.type === 'npub') { + const repoOwnerPubkey = decoded.data as string; + const client = new NostrClient(DEFAULT_NOSTR_RELAYS); + const events = await client.fetchEvents([ + { + kinds: [30617], // REPO_ANNOUNCEMENT + authors: [repoOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length > 0) { + const announcement = events[0]; + const imageTag = announcement.tags.find((t: string[]) => t[0] === 'image'); + const bannerTag = announcement.tags.find((t: string[]) => t[0] === 'banner'); + + if (imageTag?.[1]) { + repoImage = imageTag[1]; + } + if (bannerTag?.[1]) { + repoBanner = bannerTag[1]; + } + } + } + } + } catch (err) { + console.error('Error loading repo images:', err); + } + } + onMount(async () => { await loadBranches(); await loadFiles(); @@ -91,6 +228,9 @@ await loadTags(); await checkMaintainerStatus(); await checkVerification(); + await loadReadme(); + await loadForkInfo(); + await loadRepoImages(); }); async function checkAuth() { @@ -685,26 +825,86 @@ loadPRs(); } }); + + $effect(() => { + if (currentBranch) { + loadReadme(); + } + }); + + {pageData.title || `${repo} - Repository`} + + + + + + + + {#if pageData.image || repoImage} + + {/if} + {#if pageData.banner || repoBanner} + + + {/if} + + + + + + {#if pageData.banner || repoBanner} + + {:else if pageData.image || repoImage} + + {/if} + +
+ {#if repoBanner} +
+ +
+ {/if}
← Back to Repositories -

{repo}

- by {npub.slice(0, 16)}... +
+ {#if repoImage} + Repository image + {/if} +
+

{pageData.repoName || repo}

+ {#if pageData.repoDescription} +

{pageData.repoDescription}

+ {/if} +
+
+ + by {npub.slice(0, 16)}... + 📖 + {#if forkInfo?.isFork && forkInfo.originalRepo} + Forked from {forkInfo.originalRepo.repo} + {/if}
-
+
- {#if userPubkey && isMaintainer} - - {/if} {#if userPubkey} + + {#if isMaintainer} + Settings + {/if} + {#if isMaintainer} + + {/if} {#if isMaintainer} ✓ Maintainer @@ -944,8 +1144,29 @@ {/if} - +
+ {#if activeTab === 'files' && readmeContent && !currentFile} +
+
+

README

+ +
+ {#if loadingReadme} +
Loading README...
+ {:else if readmeIsMarkdown && readmeHtml} +
+ {@html readmeHtml} +
+ {:else if readmeContent} +
{readmeContent}
+ {/if} +
+ {/if} + {#if activeTab === 'files' && currentFile}
{currentFile} @@ -1343,10 +1564,64 @@ background: white; } + .repo-banner { + width: 100%; + height: 300px; + overflow: hidden; + background: #f3f4f6; + margin-bottom: 1rem; + } + + .repo-banner img { + width: 100%; + height: 100%; + object-fit: cover; + } + .header-left { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .repo-title-section { display: flex; align-items: center; gap: 1rem; + margin-bottom: 0.5rem; + } + + .repo-image { + width: 64px; + height: 64px; + border-radius: 8px; + object-fit: cover; + flex-shrink: 0; + } + + .repo-description-header { + margin: 0.25rem 0 0 0; + color: #666; + font-size: 0.9rem; + } + + .fork-badge { + padding: 0.25rem 0.5rem; + background: #e0e7ff; + color: #3730a3; + border-radius: 4px; + font-size: 0.85rem; + margin-left: 0.5rem; + } + + .fork-badge a { + color: #3730a3; + text-decoration: none; + } + + .fork-badge a:hover { + text-decoration: underline; } .back-link { diff --git a/src/routes/repos/[npub]/[repo]/+page.ts b/src/routes/repos/[npub]/[repo]/+page.ts new file mode 100644 index 0000000..568b456 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/+page.ts @@ -0,0 +1,80 @@ +/** + * Page metadata and OpenGraph tags for repository pages + */ + +import type { PageLoad } from './$types'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { KIND } from '$lib/types/nostr.js'; +import { nip19 } from 'nostr-tools'; + +export const load: PageLoad = async ({ params, url, parent }) => { + const { npub, repo } = params; + + if (!npub || !repo) { + return { + title: 'Repository Not Found', + description: 'Repository not found' + }; + } + + try { + // Decode npub to get pubkey + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + return { + title: 'Invalid Repository', + description: 'Invalid repository identifier' + }; + } + + const repoOwnerPubkey = decoded.data as string; + + // Fetch repository announcement + const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length === 0) { + return { + title: `${repo} - Repository Not Found`, + description: 'Repository announcement not found' + }; + } + + const announcement = events[0]; + 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]; + const banner = announcement.tags.find(t => t[0] === 'banner')?.[1]; + + // Get git domain for constructing URLs + const layoutData = await parent(); + const gitDomain = (layoutData as { gitDomain?: string }).gitDomain || url.host || 'localhost:6543'; + const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https'; + const repoUrl = `${protocol}://${gitDomain}/repos/${npub}/${repo}`; + + return { + title: `${name} - ${repo}`, + description: description || `Repository: ${name}`, + image: image || banner || undefined, + banner: banner || image || undefined, + repoName: name, + repoDescription: description, + repoUrl, + ogType: 'website' + }; + } catch (error) { + console.error('Error loading repository metadata:', error); + return { + title: `${repo} - Repository`, + description: 'Repository' + }; + } +}; diff --git a/src/routes/repos/[npub]/[repo]/settings/+page.svelte b/src/routes/repos/[npub]/[repo]/settings/+page.svelte new file mode 100644 index 0000000..f49b87a --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/settings/+page.svelte @@ -0,0 +1,348 @@ + + +
+
+ ← Back to Repository +

Repository Settings

+
+ +
+ {#if loading} +
Loading settings...
+ {:else if error && !userPubkey} +
+ {error} +

Redirecting to repository...

+
+ {:else} +
{ e.preventDefault(); saveSettings(); }} class="settings-form"> +
+

Basic Information

+ + + + + + +
+ +
+

Clone URLs

+

Additional clone URLs (your server URL is automatically included)

+ {#each cloneUrls as url, index} +
+ + {#if cloneUrls.length > 1} + + {/if} +
+ {/each} + +
+ +
+

Maintainers

+

Additional maintainers (npub or hex pubkey)

+ {#each maintainers as maintainer, index} +
+ + {#if maintainers.length > 1} + + {/if} +
+ {/each} + +
+ + {#if error} +
{error}
+ {/if} + +
+ + +
+
+ {/if} +
+
+ + diff --git a/src/routes/search/+page.svelte b/src/routes/search/+page.svelte new file mode 100644 index 0000000..42e5cd3 --- /dev/null +++ b/src/routes/search/+page.svelte @@ -0,0 +1,255 @@ + + +
+
+ ← Back to Repositories +

Search

+
+ +
+
+ + + +
+ + {#if error} +
{error}
+ {/if} + + {#if results} +
+
+

Results ({results.total})

+
+ + {#if (searchType === 'repos' || searchType === 'all') && results.repos.length > 0} +
+

Repositories ({results.repos.length})

+
+ {#each results.repos as repo} +
goto(`/repos/${repo.npub}/${repo.name.toLowerCase().replace(/\s+/g, '-')}`)}> +

{repo.name}

+ {#if repo.description} +

{repo.description}

+ {/if} + +
+ {/each} +
+
+ {/if} + + {#if (searchType === 'code' || searchType === 'all') && results.code.length > 0} +
+

Code Files ({results.code.length})

+
+ {#each results.code as file} +
goto(`/repos/${file.npub}/${file.repo}?file=${encodeURIComponent(file.file)}`)}> +
{file.file}
+ +
+ {/each} +
+
+ {/if} + + {#if results.total === 0} +
No results found
+ {/if} +
+ {/if} +
+
+ + diff --git a/src/routes/users/[npub]/+page.svelte b/src/routes/users/[npub]/+page.svelte new file mode 100644 index 0000000..aa9485b --- /dev/null +++ b/src/routes/users/[npub]/+page.svelte @@ -0,0 +1,280 @@ + + +
+
+ ← Back to Repositories +
+ {#if userProfile?.picture} + Profile + {:else} +
+ {npub.slice(0, 2).toUpperCase()} +
+ {/if} +
+

{userProfile?.name || npub.slice(0, 16)}...

+ {#if userProfile?.about} +

{userProfile.about}

+ {/if} +

npub: {npub}

+
+
+
+ +
+ {#if error} +
Error: {error}
+ {/if} + + {#if loading} +
Loading profile...
+ {:else} +
+

Repositories ({repos.length})

+ {#if repos.length === 0} +
No repositories found
+ {:else} +
+ {#each repos as event} +
goto(`/repos/${npub}/${getRepoId(event)}`)}> +

{getRepoName(event)}

+ {#if getRepoDescription(event)} +

{getRepoDescription(event)}

+ {/if} +
+ + {new Date(event.created_at * 1000).toLocaleDateString()} + +
+
+ {/each} +
+ {/if} +
+ {/if} +
+
+ +