12 changed files with 2253 additions and 25 deletions
@ -0,0 +1,110 @@
@@ -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'); |
||||
} |
||||
}; |
||||
@ -0,0 +1,257 @@
@@ -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'); |
||||
} |
||||
}; |
||||
@ -0,0 +1,88 @@
@@ -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<string, string> = { |
||||
'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'); |
||||
} |
||||
}; |
||||
@ -0,0 +1,97 @@
@@ -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'); |
||||
} |
||||
}; |
||||
@ -0,0 +1,219 @@
@@ -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'); |
||||
} |
||||
}; |
||||
@ -0,0 +1,160 @@
@@ -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'); |
||||
} |
||||
}; |
||||
@ -0,0 +1,80 @@
@@ -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' |
||||
}; |
||||
} |
||||
}; |
||||
@ -0,0 +1,348 @@
@@ -0,0 +1,348 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import { page } from '$app/stores'; |
||||
import { goto } from '$app/navigation'; |
||||
import { getPublicKeyWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; |
||||
|
||||
const npub = ($page.params as { npub?: string; repo?: string }).npub || ''; |
||||
const repo = ($page.params as { npub?: string; repo?: string }).repo || ''; |
||||
|
||||
let loading = $state(true); |
||||
let saving = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let userPubkey = $state<string | null>(null); |
||||
|
||||
let name = $state(''); |
||||
let description = $state(''); |
||||
let cloneUrls = $state<string[]>(['']); |
||||
let maintainers = $state<string[]>(['']); |
||||
let isPrivate = $state(false); |
||||
|
||||
onMount(async () => { |
||||
await checkAuth(); |
||||
await loadSettings(); |
||||
}); |
||||
|
||||
async function checkAuth() { |
||||
try { |
||||
if (typeof window !== 'undefined' && window.nostr) { |
||||
userPubkey = await getPublicKeyWithNIP07(); |
||||
} |
||||
} catch (err) { |
||||
console.error('Auth check failed:', err); |
||||
} |
||||
} |
||||
|
||||
async function loadSettings() { |
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const response = await fetch(`/api/repos/${npub}/${repo}/settings?userPubkey=${userPubkey}`); |
||||
if (response.ok) { |
||||
const data = await response.json(); |
||||
name = data.name || ''; |
||||
description = data.description || ''; |
||||
cloneUrls = data.cloneUrls?.length > 0 ? data.cloneUrls : ['']; |
||||
maintainers = data.maintainers?.length > 0 ? data.maintainers : ['']; |
||||
isPrivate = data.isPrivate || false; |
||||
} else { |
||||
const data = await response.json(); |
||||
error = data.error || 'Failed to load settings'; |
||||
if (response.status === 403) { |
||||
setTimeout(() => goto(`/repos/${npub}/${repo}`), 2000); |
||||
} |
||||
} |
||||
} catch (err) { |
||||
error = err instanceof Error ? err.message : 'Failed to load settings'; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
async function saveSettings() { |
||||
if (!userPubkey) { |
||||
error = 'Please connect your NIP-07 extension'; |
||||
return; |
||||
} |
||||
|
||||
saving = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const response = await fetch(`/api/repos/${npub}/${repo}/settings`, { |
||||
method: 'POST', |
||||
headers: { 'Content-Type': 'application/json' }, |
||||
body: JSON.stringify({ |
||||
userPubkey, |
||||
name, |
||||
description, |
||||
cloneUrls: cloneUrls.filter(url => url.trim()), |
||||
maintainers: maintainers.filter(m => m.trim()), |
||||
isPrivate |
||||
}) |
||||
}); |
||||
|
||||
if (response.ok) { |
||||
alert('Settings saved successfully!'); |
||||
goto(`/repos/${npub}/${repo}`); |
||||
} else { |
||||
const data = await response.json(); |
||||
error = data.error || 'Failed to save settings'; |
||||
} |
||||
} catch (err) { |
||||
error = err instanceof Error ? err.message : 'Failed to save settings'; |
||||
} finally { |
||||
saving = false; |
||||
} |
||||
} |
||||
|
||||
function addCloneUrl() { |
||||
cloneUrls = [...cloneUrls, '']; |
||||
} |
||||
|
||||
function removeCloneUrl(index: number) { |
||||
cloneUrls = cloneUrls.filter((_, i) => i !== index); |
||||
} |
||||
|
||||
function addMaintainer() { |
||||
maintainers = [...maintainers, '']; |
||||
} |
||||
|
||||
function removeMaintainer(index: number) { |
||||
maintainers = maintainers.filter((_, i) => i !== index); |
||||
} |
||||
</script> |
||||
|
||||
<div class="container"> |
||||
<header> |
||||
<a href={`/repos/${npub}/${repo}`} class="back-link">← Back to Repository</a> |
||||
<h1>Repository Settings</h1> |
||||
</header> |
||||
|
||||
<main> |
||||
{#if loading} |
||||
<div class="loading">Loading settings...</div> |
||||
{:else if error && !userPubkey} |
||||
<div class="error"> |
||||
{error} |
||||
<p>Redirecting to repository...</p> |
||||
</div> |
||||
{:else} |
||||
<form onsubmit={(e) => { e.preventDefault(); saveSettings(); }} class="settings-form"> |
||||
<div class="form-section"> |
||||
<h2>Basic Information</h2> |
||||
|
||||
<label> |
||||
Repository Name |
||||
<input type="text" bind:value={name} required /> |
||||
</label> |
||||
|
||||
<label> |
||||
Description |
||||
<textarea bind:value={description} rows="3"></textarea> |
||||
</label> |
||||
|
||||
<label> |
||||
<input type="checkbox" bind:checked={isPrivate} /> |
||||
Private Repository (only owners and maintainers can view) |
||||
</label> |
||||
</div> |
||||
|
||||
<div class="form-section"> |
||||
<h2>Clone URLs</h2> |
||||
<p class="help-text">Additional clone URLs (your server URL is automatically included)</p> |
||||
{#each cloneUrls as url, index} |
||||
<div class="array-input"> |
||||
<input type="url" bind:value={cloneUrls[index]} placeholder="https://example.com/repo.git" /> |
||||
{#if cloneUrls.length > 1} |
||||
<button type="button" onclick={() => removeCloneUrl(index)} class="remove-button">Remove</button> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
<button type="button" onclick={addCloneUrl} class="add-button">+ Add Clone URL</button> |
||||
</div> |
||||
|
||||
<div class="form-section"> |
||||
<h2>Maintainers</h2> |
||||
<p class="help-text">Additional maintainers (npub or hex pubkey)</p> |
||||
{#each maintainers as maintainer, index} |
||||
<div class="array-input"> |
||||
<input type="text" bind:value={maintainers[index]} placeholder="npub1..." /> |
||||
{#if maintainers.length > 1} |
||||
<button type="button" onclick={() => removeMaintainer(index)} class="remove-button">Remove</button> |
||||
{/if} |
||||
</div> |
||||
{/each} |
||||
<button type="button" onclick={addMaintainer} class="add-button">+ Add Maintainer</button> |
||||
</div> |
||||
|
||||
{#if error} |
||||
<div class="error">{error}</div> |
||||
{/if} |
||||
|
||||
<div class="form-actions"> |
||||
<button type="button" onclick={() => goto(`/repos/${npub}/${repo}`)} class="cancel-button">Cancel</button> |
||||
<button type="submit" disabled={saving} class="save-button"> |
||||
{saving ? 'Saving...' : 'Save Settings'} |
||||
</button> |
||||
</div> |
||||
</form> |
||||
{/if} |
||||
</main> |
||||
</div> |
||||
|
||||
<style> |
||||
.container { |
||||
max-width: 800px; |
||||
margin: 0 auto; |
||||
padding: 2rem; |
||||
} |
||||
|
||||
.back-link { |
||||
display: inline-block; |
||||
margin-bottom: 1rem; |
||||
color: #007bff; |
||||
text-decoration: none; |
||||
} |
||||
|
||||
.back-link:hover { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.settings-form { |
||||
background: white; |
||||
border-radius: 8px; |
||||
padding: 2rem; |
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
.form-section { |
||||
margin-bottom: 2rem; |
||||
padding-bottom: 2rem; |
||||
border-bottom: 1px solid #e0e0e0; |
||||
} |
||||
|
||||
.form-section:last-of-type { |
||||
border-bottom: none; |
||||
} |
||||
|
||||
.form-section h2 { |
||||
margin-bottom: 1rem; |
||||
color: #333; |
||||
} |
||||
|
||||
label { |
||||
display: block; |
||||
margin-bottom: 1rem; |
||||
font-weight: 500; |
||||
} |
||||
|
||||
label input[type="text"], |
||||
label input[type="url"], |
||||
label textarea { |
||||
width: 100%; |
||||
padding: 0.75rem; |
||||
border: 1px solid #ddd; |
||||
border-radius: 4px; |
||||
font-size: 1rem; |
||||
margin-top: 0.5rem; |
||||
} |
||||
|
||||
label input[type="checkbox"] { |
||||
margin-right: 0.5rem; |
||||
} |
||||
|
||||
.help-text { |
||||
color: #666; |
||||
font-size: 0.9rem; |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.array-input { |
||||
display: flex; |
||||
gap: 0.5rem; |
||||
margin-bottom: 0.5rem; |
||||
align-items: center; |
||||
} |
||||
|
||||
.array-input input { |
||||
flex: 1; |
||||
} |
||||
|
||||
.add-button, .remove-button { |
||||
padding: 0.5rem 1rem; |
||||
border: 1px solid #ddd; |
||||
border-radius: 4px; |
||||
background: white; |
||||
cursor: pointer; |
||||
font-size: 0.9rem; |
||||
} |
||||
|
||||
.add-button { |
||||
color: #007bff; |
||||
border-color: #007bff; |
||||
} |
||||
|
||||
.add-button:hover { |
||||
background: #f0f8ff; |
||||
} |
||||
|
||||
.remove-button { |
||||
color: #d32f2f; |
||||
border-color: #d32f2f; |
||||
} |
||||
|
||||
.remove-button:hover { |
||||
background: #ffebee; |
||||
} |
||||
|
||||
.form-actions { |
||||
display: flex; |
||||
gap: 1rem; |
||||
justify-content: flex-end; |
||||
margin-top: 2rem; |
||||
} |
||||
|
||||
.cancel-button, .save-button { |
||||
padding: 0.75rem 1.5rem; |
||||
border: none; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
.cancel-button { |
||||
background: #f5f5f5; |
||||
color: #333; |
||||
} |
||||
|
||||
.cancel-button:hover { |
||||
background: #e0e0e0; |
||||
} |
||||
|
||||
.save-button { |
||||
background: #007bff; |
||||
color: white; |
||||
} |
||||
|
||||
.save-button:hover:not(:disabled) { |
||||
background: #0056b3; |
||||
} |
||||
|
||||
.save-button:disabled { |
||||
background: #ccc; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.loading, .error { |
||||
text-align: center; |
||||
padding: 2rem; |
||||
} |
||||
|
||||
.error { |
||||
color: #d32f2f; |
||||
background: #ffebee; |
||||
border-radius: 4px; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,255 @@
@@ -0,0 +1,255 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import { goto } from '$app/navigation'; |
||||
import { page } from '$app/stores'; |
||||
|
||||
let query = $state(''); |
||||
let searchType = $state<'repos' | 'code' | 'all'>('repos'); |
||||
let loading = $state(false); |
||||
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 }>; |
||||
total: number; |
||||
} | null>(null); |
||||
let error = $state<string | null>(null); |
||||
|
||||
async function performSearch() { |
||||
if (!query.trim() || query.length < 2) { |
||||
return; |
||||
} |
||||
|
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${searchType}`); |
||||
if (response.ok) { |
||||
results = await response.json(); |
||||
} else { |
||||
const data = await response.json(); |
||||
error = data.error || 'Search failed'; |
||||
} |
||||
} catch (err) { |
||||
error = err instanceof Error ? err.message : 'Search failed'; |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
function handleSearch(e: Event) { |
||||
e.preventDefault(); |
||||
performSearch(); |
||||
} |
||||
</script> |
||||
|
||||
<div class="container"> |
||||
<header> |
||||
<a href="/" class="back-link">← Back to Repositories</a> |
||||
<h1>Search</h1> |
||||
</header> |
||||
|
||||
<main> |
||||
<form onsubmit={handleSearch} class="search-form"> |
||||
<input |
||||
type="text" |
||||
bind:value={query} |
||||
placeholder="Search repositories or code..." |
||||
class="search-input" |
||||
autofocus |
||||
/> |
||||
<select bind:value={searchType} class="search-type-select"> |
||||
<option value="repos">Repositories</option> |
||||
<option value="code">Code</option> |
||||
<option value="all">All</option> |
||||
</select> |
||||
<button type="submit" disabled={loading || !query.trim()} class="search-button"> |
||||
{loading ? 'Searching...' : 'Search'} |
||||
</button> |
||||
</form> |
||||
|
||||
{#if error} |
||||
<div class="error">{error}</div> |
||||
{/if} |
||||
|
||||
{#if results} |
||||
<div class="results"> |
||||
<div class="results-header"> |
||||
<h2>Results ({results.total})</h2> |
||||
</div> |
||||
|
||||
{#if (searchType === 'repos' || searchType === 'all') && results.repos.length > 0} |
||||
<section class="results-section"> |
||||
<h3>Repositories ({results.repos.length})</h3> |
||||
<div class="repo-list"> |
||||
{#each results.repos as repo} |
||||
<div class="repo-item" onclick={() => goto(`/repos/${repo.npub}/${repo.name.toLowerCase().replace(/\s+/g, '-')}`)}> |
||||
<h4>{repo.name}</h4> |
||||
{#if repo.description} |
||||
<p class="repo-description">{repo.description}</p> |
||||
{/if} |
||||
<div class="repo-meta"> |
||||
<a href={`/users/${repo.npub}`} onclick={(e) => e.stopPropagation()}> |
||||
{repo.npub.slice(0, 16)}... |
||||
</a> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</section> |
||||
{/if} |
||||
|
||||
{#if (searchType === 'code' || searchType === 'all') && results.code.length > 0} |
||||
<section class="results-section"> |
||||
<h3>Code Files ({results.code.length})</h3> |
||||
<div class="code-list"> |
||||
{#each results.code as file} |
||||
<div class="code-item" onclick={() => goto(`/repos/${file.npub}/${file.repo}?file=${encodeURIComponent(file.file)}`)}> |
||||
<div class="code-file-path">{file.file}</div> |
||||
<div class="code-repo"> |
||||
<a href={`/repos/${file.npub}/${file.repo}`} onclick={(e) => e.stopPropagation()}> |
||||
{file.repo} |
||||
</a> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
</section> |
||||
{/if} |
||||
|
||||
{#if results.total === 0} |
||||
<div class="no-results">No results found</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</main> |
||||
</div> |
||||
|
||||
<style> |
||||
.container { |
||||
max-width: 1200px; |
||||
margin: 0 auto; |
||||
padding: 2rem; |
||||
} |
||||
|
||||
.back-link { |
||||
display: inline-block; |
||||
margin-bottom: 1rem; |
||||
color: #007bff; |
||||
text-decoration: none; |
||||
} |
||||
|
||||
.back-link:hover { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.search-form { |
||||
display: flex; |
||||
gap: 1rem; |
||||
margin-bottom: 2rem; |
||||
align-items: center; |
||||
} |
||||
|
||||
.search-input { |
||||
flex: 1; |
||||
padding: 0.75rem; |
||||
font-size: 1rem; |
||||
border: 1px solid #ddd; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.search-type-select { |
||||
padding: 0.75rem; |
||||
border: 1px solid #ddd; |
||||
border-radius: 4px; |
||||
} |
||||
|
||||
.search-button { |
||||
padding: 0.75rem 1.5rem; |
||||
background: #007bff; |
||||
color: white; |
||||
border: none; |
||||
border-radius: 4px; |
||||
cursor: pointer; |
||||
font-size: 1rem; |
||||
} |
||||
|
||||
.search-button:hover:not(:disabled) { |
||||
background: #0056b3; |
||||
} |
||||
|
||||
.search-button:disabled { |
||||
background: #ccc; |
||||
cursor: not-allowed; |
||||
} |
||||
|
||||
.results-header { |
||||
margin-bottom: 1.5rem; |
||||
} |
||||
|
||||
.results-section { |
||||
margin-bottom: 2rem; |
||||
} |
||||
|
||||
.results-section h3 { |
||||
margin-bottom: 1rem; |
||||
color: #333; |
||||
} |
||||
|
||||
.repo-list, .code-list { |
||||
display: flex; |
||||
flex-direction: column; |
||||
gap: 1rem; |
||||
} |
||||
|
||||
.repo-item, .code-item { |
||||
border: 1px solid #e0e0e0; |
||||
border-radius: 8px; |
||||
padding: 1rem; |
||||
cursor: pointer; |
||||
transition: box-shadow 0.2s; |
||||
} |
||||
|
||||
.repo-item:hover, .code-item:hover { |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
.repo-item h4 { |
||||
margin: 0 0 0.5rem 0; |
||||
color: #007bff; |
||||
} |
||||
|
||||
.repo-description { |
||||
color: #666; |
||||
margin: 0.5rem 0; |
||||
} |
||||
|
||||
.repo-meta { |
||||
margin-top: 0.5rem; |
||||
font-size: 0.9rem; |
||||
color: #999; |
||||
} |
||||
|
||||
.code-file-path { |
||||
font-family: monospace; |
||||
color: #333; |
||||
margin-bottom: 0.5rem; |
||||
} |
||||
|
||||
.code-repo { |
||||
font-size: 0.9rem; |
||||
color: #666; |
||||
} |
||||
|
||||
.no-results, .error { |
||||
text-align: center; |
||||
padding: 2rem; |
||||
color: #666; |
||||
} |
||||
|
||||
.error { |
||||
color: #d32f2f; |
||||
background: #ffebee; |
||||
border-radius: 4px; |
||||
padding: 1rem; |
||||
} |
||||
</style> |
||||
@ -0,0 +1,280 @@
@@ -0,0 +1,280 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import { page } from '$app/stores'; |
||||
import { goto } from '$app/navigation'; |
||||
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'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
|
||||
const npub = ($page.params as { npub?: string }).npub || ''; |
||||
|
||||
let loading = $state(true); |
||||
let error = $state<string | null>(null); |
||||
let userPubkey = $state<string | null>(null); |
||||
let repos = $state<NostrEvent[]>([]); |
||||
let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null); |
||||
|
||||
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
||||
|
||||
onMount(async () => { |
||||
await loadUserProfile(); |
||||
}); |
||||
|
||||
async function loadUserProfile() { |
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
// Decode npub to get pubkey |
||||
const decoded = nip19.decode(npub); |
||||
if (decoded.type !== 'npub') { |
||||
error = 'Invalid npub format'; |
||||
return; |
||||
} |
||||
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)); |
||||
}); |
||||
|
||||
// Sort by created_at descending |
||||
repos.sort((a, b) => b.created_at - a.created_at); |
||||
|
||||
// Try to fetch user profile (kind 0) |
||||
const profileEvents = await nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [0], |
||||
authors: [userPubkey], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (profileEvents.length > 0) { |
||||
try { |
||||
const profile = JSON.parse(profileEvents[0].content); |
||||
userProfile = { |
||||
name: profile.name, |
||||
about: profile.about, |
||||
picture: profile.picture |
||||
}; |
||||
} catch { |
||||
// Invalid JSON, ignore |
||||
} |
||||
} |
||||
} catch (err) { |
||||
error = err instanceof Error ? err.message : 'Failed to load user profile'; |
||||
console.error('Error loading user profile:', err); |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
function getRepoName(event: NostrEvent): string { |
||||
return event.tags.find(t => t[0] === 'name')?.[1] || |
||||
event.tags.find(t => t[0] === 'd')?.[1] || |
||||
'Unnamed'; |
||||
} |
||||
|
||||
function getRepoDescription(event: NostrEvent): string { |
||||
return event.tags.find(t => t[0] === 'description')?.[1] || ''; |
||||
} |
||||
|
||||
function getRepoId(event: NostrEvent): string { |
||||
return event.tags.find(t => t[0] === 'd')?.[1] || ''; |
||||
} |
||||
</script> |
||||
|
||||
<div class="container"> |
||||
<header> |
||||
<a href="/" class="back-link">← Back to Repositories</a> |
||||
<div class="profile-header"> |
||||
{#if userProfile?.picture} |
||||
<img src={userProfile.picture} alt="Profile" class="profile-picture" /> |
||||
{:else} |
||||
<div class="profile-picture-placeholder"> |
||||
{npub.slice(0, 2).toUpperCase()} |
||||
</div> |
||||
{/if} |
||||
<div class="profile-info"> |
||||
<h1>{userProfile?.name || npub.slice(0, 16)}...</h1> |
||||
{#if userProfile?.about} |
||||
<p class="profile-about">{userProfile.about}</p> |
||||
{/if} |
||||
<p class="profile-npub">npub: {npub}</p> |
||||
</div> |
||||
</div> |
||||
</header> |
||||
|
||||
<main> |
||||
{#if error} |
||||
<div class="error">Error: {error}</div> |
||||
{/if} |
||||
|
||||
{#if loading} |
||||
<div class="loading">Loading profile...</div> |
||||
{:else} |
||||
<div class="repos-section"> |
||||
<h2>Repositories ({repos.length})</h2> |
||||
{#if repos.length === 0} |
||||
<div class="empty">No repositories found</div> |
||||
{:else} |
||||
<div class="repo-grid"> |
||||
{#each repos as event} |
||||
<div class="repo-card" onclick={() => goto(`/repos/${npub}/${getRepoId(event)}`)}> |
||||
<h3>{getRepoName(event)}</h3> |
||||
{#if getRepoDescription(event)} |
||||
<p class="repo-description">{getRepoDescription(event)}</p> |
||||
{/if} |
||||
<div class="repo-meta"> |
||||
<span class="repo-date"> |
||||
{new Date(event.created_at * 1000).toLocaleDateString()} |
||||
</span> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</main> |
||||
</div> |
||||
|
||||
<style> |
||||
.container { |
||||
max-width: 1200px; |
||||
margin: 0 auto; |
||||
padding: 2rem; |
||||
} |
||||
|
||||
.back-link { |
||||
display: inline-block; |
||||
margin-bottom: 1rem; |
||||
color: #007bff; |
||||
text-decoration: none; |
||||
} |
||||
|
||||
.back-link:hover { |
||||
text-decoration: underline; |
||||
} |
||||
|
||||
.profile-header { |
||||
display: flex; |
||||
gap: 1.5rem; |
||||
align-items: flex-start; |
||||
margin-bottom: 2rem; |
||||
padding-bottom: 2rem; |
||||
border-bottom: 1px solid #e0e0e0; |
||||
} |
||||
|
||||
.profile-picture { |
||||
width: 80px; |
||||
height: 80px; |
||||
border-radius: 50%; |
||||
object-fit: cover; |
||||
} |
||||
|
||||
.profile-picture-placeholder { |
||||
width: 80px; |
||||
height: 80px; |
||||
border-radius: 50%; |
||||
background: #007bff; |
||||
color: white; |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
font-size: 2rem; |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.profile-info h1 { |
||||
margin: 0 0 0.5rem 0; |
||||
} |
||||
|
||||
.profile-about { |
||||
color: #666; |
||||
margin: 0.5rem 0; |
||||
} |
||||
|
||||
.profile-npub { |
||||
color: #999; |
||||
font-size: 0.9rem; |
||||
margin: 0.5rem 0 0 0; |
||||
font-family: monospace; |
||||
} |
||||
|
||||
.repos-section { |
||||
margin-top: 2rem; |
||||
} |
||||
|
||||
.repos-section h2 { |
||||
margin-bottom: 1rem; |
||||
} |
||||
|
||||
.repo-grid { |
||||
display: grid; |
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); |
||||
gap: 1.5rem; |
||||
} |
||||
|
||||
.repo-card { |
||||
border: 1px solid #e0e0e0; |
||||
border-radius: 8px; |
||||
padding: 1.5rem; |
||||
cursor: pointer; |
||||
transition: box-shadow 0.2s; |
||||
} |
||||
|
||||
.repo-card:hover { |
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); |
||||
} |
||||
|
||||
.repo-card h3 { |
||||
margin: 0 0 0.5rem 0; |
||||
color: #007bff; |
||||
} |
||||
|
||||
.repo-description { |
||||
color: #666; |
||||
margin: 0.5rem 0; |
||||
font-size: 0.9rem; |
||||
} |
||||
|
||||
.repo-meta { |
||||
margin-top: 1rem; |
||||
padding-top: 1rem; |
||||
border-top: 1px solid #f0f0f0; |
||||
font-size: 0.85rem; |
||||
color: #999; |
||||
} |
||||
|
||||
.loading, .empty, .error { |
||||
text-align: center; |
||||
padding: 2rem; |
||||
color: #666; |
||||
} |
||||
|
||||
.error { |
||||
color: #d32f2f; |
||||
background: #ffebee; |
||||
border-radius: 4px; |
||||
padding: 1rem; |
||||
} |
||||
</style> |
||||
Loading…
Reference in new issue