5 changed files with 598 additions and 62 deletions
@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
|
||||
/** |
||||
* API endpoint for checking repository access |
||||
* Returns whether the current user can view the repository |
||||
*/ |
||||
|
||||
import { json } from '@sveltejs/kit'; |
||||
import type { RequestHandler } from './$types'; |
||||
import { maintainerService } from '$lib/services/service-registry.js'; |
||||
import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; |
||||
import type { RepoRequestContext } from '$lib/utils/api-context.js'; |
||||
|
||||
export const GET: RequestHandler = createRepoGetHandler( |
||||
async (context: RepoRequestContext) => { |
||||
const { isPrivate, maintainers, owner } = await maintainerService.getMaintainers( |
||||
context.repoOwnerPubkey, |
||||
context.repo |
||||
); |
||||
|
||||
// Check if user can view
|
||||
const canView = await maintainerService.canView( |
||||
context.userPubkeyHex || null, |
||||
context.repoOwnerPubkey, |
||||
context.repo |
||||
); |
||||
|
||||
return json({ |
||||
canView, |
||||
isPrivate, |
||||
isMaintainer: context.userPubkeyHex ? maintainers.includes(context.userPubkeyHex) : false, |
||||
isOwner: context.userPubkeyHex ? context.userPubkeyHex === owner : false |
||||
}); |
||||
}, |
||||
{ operation: 'checkAccess', requireRepoExists: false, requireRepoAccess: false } // This endpoint IS the access check
|
||||
); |
||||
@ -0,0 +1,562 @@
@@ -0,0 +1,562 @@
|
||||
<script lang="ts"> |
||||
import { onMount } from 'svelte'; |
||||
import { goto } from '$app/navigation'; |
||||
import { page } from '$app/stores'; |
||||
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; |
||||
import { KIND } from '$lib/types/nostr.js'; |
||||
import type { NostrEvent } from '$lib/types/nostr.js'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; |
||||
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; |
||||
|
||||
// Registered repos (with domain in clone URLs) |
||||
let registeredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]); |
||||
let allRegisteredRepos = $state<Array<{ event: NostrEvent; npub: string; repoName: string }>>([]); |
||||
|
||||
// Local clones (repos without domain in clone URLs) |
||||
let localRepos = $state<Array<{ npub: string; repoName: string; announcement: NostrEvent | null; lastModified: number }>>([]); |
||||
let allLocalRepos = $state<Array<{ npub: string; repoName: string; announcement: NostrEvent | null; lastModified: number }>>([]); |
||||
|
||||
let loading = $state(true); |
||||
let loadingLocal = $state(false); |
||||
let error = $state<string | null>(null); |
||||
let forkCounts = $state<Map<string, number>>(new Map()); |
||||
let searchQuery = $state(''); |
||||
let showOnlyMyContacts = $state(false); |
||||
let userPubkey = $state<string | null>(null); |
||||
let userPubkeyHex = $state<string | null>(null); |
||||
let contactPubkeys = $state<Set<string>>(new Set()); |
||||
let deletingRepo = $state<{ npub: string; repo: string } | null>(null); |
||||
|
||||
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; |
||||
const forkCountService = new ForkCountService(DEFAULT_NOSTR_RELAYS); |
||||
|
||||
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); |
||||
|
||||
onMount(async () => { |
||||
await loadRepos(); |
||||
await loadUserAndContacts(); |
||||
}); |
||||
|
||||
async function loadUserAndContacts() { |
||||
if (!isNIP07Available()) { |
||||
return; |
||||
} |
||||
|
||||
try { |
||||
userPubkey = await getPublicKeyWithNIP07(); |
||||
if (!userPubkey) return; |
||||
|
||||
// Convert npub to hex for API calls |
||||
try { |
||||
const decoded = nip19.decode(userPubkey); |
||||
if (decoded.type === 'npub') { |
||||
userPubkeyHex = decoded.data as string; |
||||
contactPubkeys.add(userPubkeyHex); // Include user's own repos |
||||
|
||||
// Fetch user's kind 3 contact list |
||||
const contactEvents = await nostrClient.fetchEvents([ |
||||
{ |
||||
kinds: [KIND.CONTACT_LIST], |
||||
authors: [userPubkeyHex], |
||||
limit: 1 |
||||
} |
||||
]); |
||||
|
||||
if (contactEvents.length > 0) { |
||||
const contactEvent = contactEvents[0]; |
||||
// Extract pubkeys from 'p' tags |
||||
for (const tag of contactEvent.tags) { |
||||
if (tag[0] === 'p' && tag[1]) { |
||||
let pubkey = tag[1]; |
||||
// Try to decode if it's an npub |
||||
try { |
||||
const decoded = nip19.decode(pubkey); |
||||
if (decoded.type === 'npub') { |
||||
pubkey = decoded.data as string; |
||||
} |
||||
} catch { |
||||
// Assume it's already a hex pubkey |
||||
} |
||||
if (pubkey) { |
||||
contactPubkeys.add(pubkey); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} catch (err) { |
||||
console.warn('Failed to decode user pubkey:', err); |
||||
} |
||||
} catch (err) { |
||||
console.warn('Failed to load user or contacts:', err); |
||||
} |
||||
} |
||||
|
||||
async function loadRepos() { |
||||
loading = true; |
||||
error = null; |
||||
|
||||
try { |
||||
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
||||
const url = `/api/repos/list?domain=${encodeURIComponent(gitDomain)}`; |
||||
|
||||
const response = await fetch(url, { |
||||
headers: userPubkeyHex ? { |
||||
'X-User-Pubkey': userPubkeyHex |
||||
} : {} |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
throw new Error(`Failed to load repositories: ${response.statusText}`); |
||||
} |
||||
|
||||
const data = await response.json(); |
||||
|
||||
// API returns { registered, unregistered, total } |
||||
registeredRepos = data.registered || []; |
||||
allRegisteredRepos = [...registeredRepos]; |
||||
|
||||
// Load fork counts for registered repos (in parallel, but don't block) |
||||
loadForkCounts(registeredRepos.map(r => r.event)).catch(err => { |
||||
console.warn('[RepoList] Failed to load some fork counts:', err); |
||||
}); |
||||
|
||||
// Load local repos separately (async, don't block) |
||||
loadLocalRepos(); |
||||
} catch (e) { |
||||
error = String(e); |
||||
console.error('[RepoList] Failed to load repos:', e); |
||||
} finally { |
||||
loading = false; |
||||
} |
||||
} |
||||
|
||||
async function loadLocalRepos() { |
||||
loadingLocal = true; |
||||
|
||||
try { |
||||
const gitDomain = $page.data.gitDomain || 'localhost:6543'; |
||||
const url = `/api/repos/local?domain=${encodeURIComponent(gitDomain)}`; |
||||
|
||||
const response = await fetch(url, { |
||||
headers: userPubkeyHex ? { |
||||
'X-User-Pubkey': userPubkeyHex |
||||
} : {} |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
console.warn('Failed to load local repos:', response.statusText); |
||||
return; |
||||
} |
||||
|
||||
const data = await response.json(); |
||||
// API returns array of { npub, repoName, announcement } |
||||
localRepos = data.map((item: { npub: string; repoName: string; announcement: NostrEvent }) => ({ |
||||
npub: item.npub, |
||||
repoName: item.repoName, |
||||
announcement: item.announcement, |
||||
lastModified: item.announcement?.created_at ? item.announcement.created_at * 1000 : Date.now() |
||||
})); |
||||
allLocalRepos = [...localRepos]; |
||||
} catch (e) { |
||||
console.warn('[RepoList] Failed to load local repos:', e); |
||||
} finally { |
||||
loadingLocal = false; |
||||
} |
||||
} |
||||
|
||||
async function deleteLocalRepo(npub: string, repo: string) { |
||||
if (!confirm(`Are you sure you want to delete the local clone of ${repo}? This will remove the repository from this server but will not delete the announcement on Nostr.`)) { |
||||
return; |
||||
} |
||||
|
||||
deletingRepo = { npub, repo }; |
||||
|
||||
try { |
||||
const response = await fetch(`/api/repos/${npub}/${repo}/delete`, { |
||||
method: 'DELETE', |
||||
headers: userPubkeyHex ? { |
||||
'X-User-Pubkey': userPubkeyHex |
||||
} : {} |
||||
}); |
||||
|
||||
if (!response.ok) { |
||||
const error = await response.json(); |
||||
throw new Error(error.message || 'Failed to delete repository'); |
||||
} |
||||
|
||||
// Remove from local repos list |
||||
localRepos = localRepos.filter(r => !(r.npub === npub && r.repoName === repo)); |
||||
allLocalRepos = [...localRepos]; |
||||
|
||||
alert('Repository deleted successfully'); |
||||
} catch (e) { |
||||
alert(`Failed to delete repository: ${e instanceof Error ? e.message : String(e)}`); |
||||
} finally { |
||||
deletingRepo = null; |
||||
} |
||||
} |
||||
|
||||
function registerRepo(npub: string, repo: string) { |
||||
// Navigate to signup page with repo pre-filled |
||||
goto(`/signup?npub=${encodeURIComponent(npub)}&repo=${encodeURIComponent(repo)}`); |
||||
} |
||||
|
||||
async function loadForkCounts(repoEvents: NostrEvent[]) { |
||||
const counts = new Map<string, number>(); |
||||
|
||||
// Extract owner pubkey and repo name for each repo |
||||
const forkCountPromises = repoEvents.map(async (event) => { |
||||
try { |
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
||||
if (!dTag) return; |
||||
|
||||
const repoKey = `${event.pubkey}:${dTag}`; |
||||
const count = await forkCountService.getForkCount(event.pubkey, dTag); |
||||
counts.set(repoKey, count); |
||||
} catch (err) { |
||||
// Ignore individual failures |
||||
} |
||||
}); |
||||
|
||||
await Promise.all(forkCountPromises); |
||||
forkCounts = counts; |
||||
} |
||||
|
||||
function getForkCount(event: NostrEvent): number { |
||||
const dTag = event.tags.find(t => t[0] === 'd')?.[1]; |
||||
if (!dTag) return 0; |
||||
const repoKey = `${event.pubkey}:${dTag}`; |
||||
return forkCounts.get(repoKey) || 0; |
||||
} |
||||
|
||||
function isOwner(npub: string, repoName: string): boolean { |
||||
if (!userPubkeyHex) return false; |
||||
try { |
||||
const decoded = nip19.decode(npub); |
||||
if (decoded.type === 'npub') { |
||||
return (decoded.data as string) === userPubkeyHex; |
||||
} |
||||
} catch { |
||||
// Invalid npub |
||||
} |
||||
return false; |
||||
} |
||||
|
||||
function getRepoName(event: NostrEvent): string { |
||||
const nameTag = event.tags.find((t: string[]) => t[0] === 'name' && t[1]); |
||||
if (nameTag?.[1]) return nameTag[1]; |
||||
|
||||
const dTag = event.tags.find((t: string[]) => t[0] === 'd')?.[1]; |
||||
if (dTag) return dTag; |
||||
|
||||
return `Repository ${event.id.slice(0, 8)}`; |
||||
} |
||||
|
||||
function getRepoDescription(event: NostrEvent): string { |
||||
const descTag = event.tags.find((t: string[]) => t[0] === 'description' && t[1]); |
||||
return descTag?.[1] || ''; |
||||
} |
||||
|
||||
function getRepoImage(event: NostrEvent): string | null { |
||||
const imageTag = event.tags.find((t: string[]) => t[0] === 'image' && t[1]); |
||||
return imageTag?.[1] || null; |
||||
} |
||||
|
||||
function getRepoBanner(event: NostrEvent): string | null { |
||||
const bannerTag = event.tags.find((t: string[]) => t[0] === 'banner' && t[1]); |
||||
return bannerTag?.[1] || null; |
||||
} |
||||
|
||||
function getCloneUrls(event: NostrEvent): string[] { |
||||
const urls: string[] = []; |
||||
|
||||
for (const tag of event.tags) { |
||||
if (tag[0] === 'clone') { |
||||
for (let i = 1; i < tag.length; i++) { |
||||
const url = tag[i]; |
||||
if (url && typeof url === 'string') { |
||||
urls.push(url); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
return urls; |
||||
} |
||||
|
||||
function performSearch() { |
||||
if (!searchQuery.trim()) { |
||||
registeredRepos = [...allRegisteredRepos]; |
||||
localRepos = [...allLocalRepos]; |
||||
return; |
||||
} |
||||
|
||||
const query = searchQuery.trim().toLowerCase(); |
||||
|
||||
// Search registered repos |
||||
let registeredToSearch = allRegisteredRepos; |
||||
if (showOnlyMyContacts && contactPubkeys.size > 0) { |
||||
registeredToSearch = allRegisteredRepos.filter(item => { |
||||
const event = item.event; |
||||
// Check if owner is in contacts |
||||
if (contactPubkeys.has(event.pubkey)) return true; |
||||
|
||||
// Check if any maintainer is in contacts |
||||
const maintainerTags = event.tags.filter((t: string[]) => t[0] === 'maintainers'); |
||||
for (const tag of maintainerTags) { |
||||
for (let i = 1; i < tag.length; i++) { |
||||
let maintainerPubkey = tag[i]; |
||||
try { |
||||
const decoded = nip19.decode(maintainerPubkey); |
||||
if (decoded.type === 'npub') { |
||||
maintainerPubkey = decoded.data as string; |
||||
} |
||||
} catch { |
||||
// Assume it's already a hex pubkey |
||||
} |
||||
if (contactPubkeys.has(maintainerPubkey)) return true; |
||||
} |
||||
} |
||||
return false; |
||||
}); |
||||
} |
||||
|
||||
const registeredResults: Array<{ item: typeof allRegisteredRepos[0]; score: number }> = []; |
||||
for (const item of registeredToSearch) { |
||||
const repo = item.event; |
||||
let score = 0; |
||||
|
||||
const name = getRepoName(repo).toLowerCase(); |
||||
const dTag = repo.tags.find((t: string[]) => t[0] === 'd')?.[1]?.toLowerCase() || ''; |
||||
const description = getRepoDescription(repo).toLowerCase(); |
||||
|
||||
if (name.includes(query)) score += 100; |
||||
if (dTag.includes(query)) score += 100; |
||||
if (description.includes(query)) score += 30; |
||||
|
||||
if (score > 0) { |
||||
registeredResults.push({ item, score }); |
||||
} |
||||
} |
||||
|
||||
registeredResults.sort((a, b) => b.score - a.score || b.item.event.created_at - a.item.event.created_at); |
||||
registeredRepos = registeredResults.map(r => r.item); |
||||
|
||||
// Search local repos |
||||
const localResults: Array<{ item: typeof allLocalRepos[0]; score: number }> = []; |
||||
for (const item of allLocalRepos) { |
||||
let score = 0; |
||||
const repoName = item.repoName.toLowerCase(); |
||||
const announcement = item.announcement; |
||||
|
||||
if (repoName.includes(query)) score += 100; |
||||
if (announcement) { |
||||
const name = getRepoName(announcement).toLowerCase(); |
||||
const description = getRepoDescription(announcement).toLowerCase(); |
||||
if (name.includes(query)) score += 100; |
||||
if (description.includes(query)) score += 30; |
||||
} |
||||
|
||||
if (score > 0) { |
||||
localResults.push({ item, score }); |
||||
} |
||||
} |
||||
|
||||
localResults.sort((a, b) => b.score - a.score || b.item.lastModified - a.item.lastModified); |
||||
localRepos = localResults.map(r => r.item); |
||||
} |
||||
|
||||
// Reactive search when query or filter changes |
||||
$effect(() => { |
||||
if (!loading) { |
||||
performSearch(); |
||||
} |
||||
}); |
||||
</script> |
||||
|
||||
<svelte:head> |
||||
<title>Repositories - GitRepublic</title> |
||||
<meta name="description" content="Browse repositories on GitRepublic - Decentralized Git Hosting on Nostr" /> |
||||
</svelte:head> |
||||
|
||||
<div class="container"> |
||||
<main> |
||||
<div class="repos-header"> |
||||
<h2>Repositories on {$page.data.gitDomain || 'localhost:6543'}</h2> |
||||
<button onclick={loadRepos} disabled={loading}> |
||||
{loading ? 'Loading...' : 'Refresh'} |
||||
</button> |
||||
</div> |
||||
|
||||
<div class="search-section"> |
||||
<div class="search-bar-container"> |
||||
<input |
||||
type="text" |
||||
bind:value={searchQuery} |
||||
placeholder="Search by name, d-tag, description..." |
||||
class="search-input" |
||||
disabled={loading} |
||||
oninput={performSearch} |
||||
/> |
||||
</div> |
||||
{#if isNIP07Available() && userPubkey} |
||||
<label class="filter-checkbox"> |
||||
<input |
||||
type="checkbox" |
||||
bind:checked={showOnlyMyContacts} |
||||
onchange={performSearch} |
||||
/> |
||||
<span>Show only my repos and those of my contacts</span> |
||||
</label> |
||||
{/if} |
||||
</div> |
||||
|
||||
{#if error} |
||||
<div class="error"> |
||||
Error loading repositories: {error} |
||||
</div> |
||||
{:else if loading} |
||||
<div class="loading">Loading repositories...</div> |
||||
{:else} |
||||
<!-- Registered Repositories Section --> |
||||
<div class="repo-section"> |
||||
<div class="section-header"> |
||||
<h3>Registered Repositories</h3> |
||||
<span class="section-badge">{registeredRepos.length}</span> |
||||
</div> |
||||
{#if registeredRepos.length === 0} |
||||
<div class="empty">No registered repositories found.</div> |
||||
{:else} |
||||
<div class="repos-list"> |
||||
{#each registeredRepos as item} |
||||
{@const repo = item.event} |
||||
{@const repoImage = getRepoImage(repo)} |
||||
{@const repoBanner = getRepoBanner(repo)} |
||||
<div class="repo-card repo-card-registered"> |
||||
{#if repoBanner} |
||||
<div class="repo-card-banner"> |
||||
<img src={repoBanner} alt="Banner" /> |
||||
</div> |
||||
{/if} |
||||
<div class="repo-card-content"> |
||||
<div class="repo-header"> |
||||
{#if repoImage} |
||||
<img src={repoImage} alt="Repository" class="repo-card-image" /> |
||||
{/if} |
||||
<div class="repo-header-text"> |
||||
<h3>{getRepoName(repo)}</h3> |
||||
{#if getRepoDescription(repo)} |
||||
<p class="description">{getRepoDescription(repo)}</p> |
||||
{/if} |
||||
</div> |
||||
<a href="/repos/{item.npub}/{item.repoName}" class="view-button"> |
||||
View & Edit → |
||||
</a> |
||||
</div> |
||||
<div class="clone-urls"> |
||||
<strong>Clone URLs:</strong> |
||||
{#each getCloneUrls(repo) as url} |
||||
<code>{url}</code> |
||||
{/each} |
||||
</div> |
||||
<div class="repo-meta"> |
||||
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> |
||||
{#if getForkCount(repo) > 0} |
||||
{@const forkCount = getForkCount(repo)} |
||||
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span> |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
|
||||
<!-- Local Clones Section --> |
||||
<div class="repo-section"> |
||||
<div class="section-header"> |
||||
<h3>Local Clones</h3> |
||||
<span class="section-badge">{localRepos.length}</span> |
||||
<span class="section-description">Repositories cloned locally but not registered with this domain</span> |
||||
</div> |
||||
{#if loadingLocal} |
||||
<div class="loading">Loading local repositories...</div> |
||||
{:else if localRepos.length === 0} |
||||
<div class="empty">No local clones found.</div> |
||||
{:else} |
||||
<div class="repos-list"> |
||||
{#each localRepos as item} |
||||
{@const repo = item.announcement} |
||||
{@const repoImage = repo ? getRepoImage(repo) : null} |
||||
{@const repoBanner = repo ? getRepoBanner(repo) : null} |
||||
{@const canDelete = isOwner(item.npub, item.repoName)} |
||||
<div class="repo-card repo-card-local"> |
||||
{#if repoBanner} |
||||
<div class="repo-card-banner"> |
||||
<img src={repoBanner} alt="Banner" /> |
||||
</div> |
||||
{/if} |
||||
<div class="repo-card-content"> |
||||
<div class="repo-header"> |
||||
{#if repoImage} |
||||
<img src={repoImage} alt="Repository" class="repo-card-image" /> |
||||
{/if} |
||||
<div class="repo-header-text"> |
||||
<h3>{repo ? getRepoName(repo) : item.repoName}</h3> |
||||
{#if repo && getRepoDescription(repo)} |
||||
<p class="description">{getRepoDescription(repo)}</p> |
||||
{:else} |
||||
<p class="description">No description available</p> |
||||
{/if} |
||||
</div> |
||||
<div class="repo-actions"> |
||||
<a href="/repos/{item.npub}/{item.repoName}" class="view-button"> |
||||
View & Edit → |
||||
</a> |
||||
{#if canDelete} |
||||
<button |
||||
class="delete-button" |
||||
onclick={() => deleteLocalRepo(item.npub, item.repoName)} |
||||
disabled={deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName} |
||||
> |
||||
{deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName ? 'Deleting...' : 'Delete'} |
||||
</button> |
||||
{/if} |
||||
<button |
||||
class="register-button" |
||||
onclick={() => registerRepo(item.npub, item.repoName)} |
||||
> |
||||
Register |
||||
</button> |
||||
</div> |
||||
</div> |
||||
{#if repo} |
||||
<div class="clone-urls"> |
||||
<strong>Clone URLs:</strong> |
||||
{#each getCloneUrls(repo) as url} |
||||
<code>{url}</code> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
<div class="repo-meta"> |
||||
<span>Last modified: {new Date(item.lastModified).toLocaleDateString()}</span> |
||||
{#if repo} |
||||
<span>Created: {new Date(repo.created_at * 1000).toLocaleDateString()}</span> |
||||
{#if getForkCount(repo) > 0} |
||||
{@const forkCount = getForkCount(repo)} |
||||
<span class="fork-count">🍴 {forkCount} fork{forkCount === 1 ? '' : 's'}</span> |
||||
{/if} |
||||
{/if} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
{/each} |
||||
</div> |
||||
{/if} |
||||
</div> |
||||
{/if} |
||||
</main> |
||||
</div> |
||||
Loading…
Reference in new issue