Browse Source

expand landing page to include local, unregistered repos

tightened landing-page security
main
Silberengel 4 weeks ago
parent
commit
75d93c993e
  1. 92
      src/app.css
  2. 3
      src/lib/utils/api-context.ts
  3. 420
      src/routes/+page.svelte
  4. 2
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  5. 2
      src/routes/api/repos/[npub]/[repo]/commits/+server.ts
  6. 171
      src/routes/api/repos/[npub]/[repo]/delete/+server.ts
  7. 2
      src/routes/api/repos/[npub]/[repo]/download/+server.ts
  8. 2
      src/routes/api/repos/[npub]/[repo]/readme/+server.ts
  9. 2
      src/routes/api/repos/[npub]/[repo]/tags/+server.ts
  10. 2
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  11. 117
      src/routes/api/repos/list/+server.ts
  12. 244
      src/routes/api/repos/local/+server.ts
  13. 66
      src/routes/api/search/+server.ts
  14. 107
      src/routes/api/users/[npub]/repos/+server.ts
  15. 16
      src/routes/repos/[npub]/[repo]/+page.ts
  16. 37
      src/routes/search/+page.svelte
  17. 100
      src/routes/signup/+page.svelte
  18. 55
      src/routes/users/[npub]/+page.svelte

92
src/app.css

@ -1204,6 +1204,98 @@ label.filter-checkbox > span, @@ -1204,6 +1204,98 @@ label.filter-checkbox > span,
gap: 1.5rem;
}
/* Repository sections */
.repo-section {
margin-bottom: 3rem;
}
.section-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
padding-bottom: 0.75rem;
border-bottom: 2px solid var(--card-border);
}
.section-header h3 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
}
.section-badge {
background: var(--accent);
color: var(--accent-text);
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: 600;
}
.section-description {
color: var(--text-secondary);
font-size: 0.875rem;
margin-left: auto;
}
/* Visual distinction between registered and local repos */
.repo-card-registered {
border-left: 4px solid var(--success, #10b981);
}
.repo-card-local {
border-left: 4px solid var(--warning, #f59e0b);
background: var(--bg-secondary, rgba(245, 158, 11, 0.05));
}
/* Repo actions */
.repo-actions {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
}
.delete-button,
.register-button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid;
}
.delete-button {
background: var(--error-bg, rgba(239, 68, 68, 0.1));
color: var(--error, #ef4444);
border-color: var(--error, #ef4444);
}
.delete-button:hover:not(:disabled) {
background: var(--error, #ef4444);
color: white;
}
.delete-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.register-button {
background: var(--accent);
color: var(--accent-text, white);
border-color: var(--accent);
}
.register-button:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.repo-header {
display: flex;
justify-content: space-between;

3
src/lib/utils/api-context.ts

@ -50,8 +50,9 @@ export function extractRequestContext( @@ -50,8 +50,9 @@ export function extractRequestContext(
): RequestContext {
const requestUrl = url || event.url;
// Extract user pubkey from query params or headers
// Extract user pubkey from query params or headers (support both lowercase and capitalized)
const userPubkey = requestUrl.searchParams.get('userPubkey') ||
event.request.headers.get('X-User-Pubkey') ||
event.request.headers.get('x-user-pubkey') ||
null;

420
src/routes/+page.svelte

@ -9,15 +9,24 @@ @@ -9,15 +9,24 @@
import { ForkCountService } from '../lib/services/nostr/fork-count-service.js';
import { getPublicKeyWithNIP07, isNIP07Available } from '../lib/services/nostr/nip07-signer.js';
let repos = $state<NostrEvent[]>([]);
let allRepos = $state<NostrEvent[]>([]); // Store all repos for filtering
// 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);
@ -36,13 +45,24 @@ @@ -36,13 +45,24 @@
try {
userPubkey = await getPublicKeyWithNIP07();
contactPubkeys.add(userPubkey); // Include user's own repos
// Convert npub to hex for API calls
try {
const decoded = nip19.decode(userPubkey);
if (decoded.type === 'npub') {
userPubkeyHex = decoded.data as string;
}
} catch {
userPubkeyHex = userPubkey; // Assume it's already hex
}
contactPubkeys.add(userPubkeyHex); // Include user's own repos
// Fetch user's kind 3 contact list
const contactEvents = await nostrClient.fetchEvents([
{
kinds: [KIND.CONTACT_LIST],
authors: [userPubkey],
authors: [userPubkeyHex],
limit: 1
}
]);
@ -78,40 +98,32 @@ @@ -78,40 +98,32 @@
error = null;
try {
const events = await nostrClient.fetchEvents([
{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 }
]);
// Get git domain from layout data
const gitDomain = $page.data.gitDomain || 'localhost:6543';
const url = `/api/repos/list?domain=${encodeURIComponent(gitDomain)}`;
// Filter for repos that list our domain in clone tags and are public
repos = events.filter(event => {
const cloneUrls = event.tags
.filter(t => t[0] === 'clone')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string');
const hasDomain = cloneUrls.some(url => url.includes(gitDomain));
if (!hasDomain) return false;
const response = await fetch(url, {
headers: userPubkeyHex ? {
'X-User-Pubkey': userPubkeyHex
} : {}
});
// Filter out private repos from public listing
const isPrivate = event.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
);
if (!response.ok) {
throw new Error(`Failed to load repositories: ${response.statusText}`);
}
return !isPrivate; // Only show public repos
});
const data = await response.json();
// Sort by created_at descending
repos.sort((a, b) => b.created_at - a.created_at);
allRepos = [...repos]; // Store all repos for filtering
// Set registered repos
registeredRepos = data.registered || [];
allRegisteredRepos = [...registeredRepos];
// Load fork counts for all repos (in parallel, but don't block)
loadForkCounts(repos).catch(err => {
// 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);
@ -120,6 +132,71 @@ @@ -120,6 +132,71 @@
}
}
async function loadLocalRepos() {
loadingLocal = true;
try {
const gitDomain = $page.data.gitDomain || 'localhost:6543';
const url = `/api/repos/local?domain=${encodeURIComponent(gitDomain)}`;
const response = await fetch(url, {
headers: userPubkeyHex ? {
'X-User-Pubkey': userPubkeyHex
} : {}
});
if (!response.ok) {
console.warn('Failed to load local repos:', response.statusText);
return;
}
const data = await response.json();
localRepos = data || [];
allLocalRepos = [...localRepos];
} catch (e) {
console.warn('[RepoList] Failed to load local repos:', e);
} finally {
loadingLocal = false;
}
}
async function deleteLocalRepo(npub: string, repo: string) {
if (!confirm(`Are you sure you want to delete the local clone of ${repo}? This will remove the repository from this server but will not delete the announcement on Nostr.`)) {
return;
}
deletingRepo = { npub, repo };
try {
const response = await fetch(`/api/repos/${npub}/${repo}/delete`, {
method: 'DELETE',
headers: userPubkeyHex ? {
'X-User-Pubkey': userPubkeyHex
} : {}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to delete repository');
}
// Remove from local repos list
localRepos = localRepos.filter(r => !(r.npub === npub && r.repoName === repo));
allLocalRepos = [...localRepos];
alert('Repository deleted successfully');
} catch (e) {
alert(`Failed to delete repository: ${e instanceof Error ? e.message : String(e)}`);
} finally {
deletingRepo = null;
}
}
function registerRepo(npub: string, repo: string) {
// Navigate to signup page with repo pre-filled
goto(`/signup?npub=${encodeURIComponent(npub)}&repo=${encodeURIComponent(repo)}`);
}
async function loadForkCounts(repoEvents: NostrEvent[]) {
const counts = new Map<string, number>();
@ -148,6 +225,19 @@ @@ -148,6 +225,19 @@
return forkCounts.get(repoKey) || 0;
}
function isOwner(npub: string, repoName: string): boolean {
if (!userPubkeyHex) return false;
try {
const decoded = nip19.decode(npub);
if (decoded.type === 'npub') {
return decoded.data === userPubkeyHex;
}
} catch {
// Invalid npub
}
return false;
}
function goToSearch() {
goto('/search');
}
@ -256,17 +346,18 @@ @@ -256,17 +346,18 @@
function performSearch() {
if (!searchQuery.trim()) {
repos = [...allRepos];
registeredRepos = [...allRegisteredRepos];
localRepos = [...allLocalRepos];
return;
}
const query = searchQuery.trim().toLowerCase();
const results: SearchResult[] = [];
// Filter by contacts if enabled
let reposToSearch = allRepos;
// Search registered repos
let registeredToSearch = allRegisteredRepos;
if (showOnlyMyContacts && contactPubkeys.size > 0) {
reposToSearch = allRepos.filter(event => {
registeredToSearch = allRegisteredRepos.filter(item => {
const event = item.event;
// Check if owner is in contacts
if (contactPubkeys.has(event.pubkey)) return true;
@ -290,155 +381,49 @@ @@ -290,155 +381,49 @@
});
}
for (const repo of reposToSearch) {
const registeredResults: Array<{ item: typeof allRegisteredRepos[0]; score: number }> = [];
for (const item of registeredToSearch) {
const repo = item.event;
let score = 0;
let matchType = '';
// Extract repo fields
const name = getRepoName(repo).toLowerCase();
const dTag = repo.tags.find(t => t[0] === 'd')?.[1]?.toLowerCase() || '';
const description = getRepoDescription(repo).toLowerCase();
const cloneUrls = getCloneUrls(repo).map(url => url.toLowerCase());
const maintainerTags = repo.tags.filter(t => t[0] === 'maintainers');
const maintainers: string[] = [];
for (const tag of maintainerTags) {
for (let i = 1; i < tag.length; i++) {
if (tag[i]) maintainers.push(tag[i].toLowerCase());
}
}
// Try to decode query as hex id, naddr, or nevent
let queryHex = '';
try {
const decoded = nip19.decode(query);
if (decoded.type === 'nevent') {
const data = decoded.data as { id: string };
queryHex = data.id || '';
} else if (decoded.type === 'naddr') {
// For naddr, we can't extract an event ID directly, skip
} else if (decoded.type === 'note') {
queryHex = decoded.data as string;
}
} catch {
// Not a bech32 encoded value
}
// Check if query is a hex pubkey or npub
let queryPubkey = '';
try {
const decoded = nip19.decode(query);
if (decoded.type === 'npub') {
queryPubkey = decoded.data as string;
}
} catch {
// Check if it's a hex pubkey (64 hex chars)
if (/^[0-9a-f]{64}$/i.test(query)) {
queryPubkey = query;
}
}
// Exact matches get highest score
if (name === query) {
score += 1000;
matchType = 'exact-name';
} else if (dTag === query) {
score += 1000;
matchType = 'exact-d-tag';
} else if (repo.id.toLowerCase() === query || repo.id.toLowerCase() === queryHex) {
score += 1000;
matchType = 'exact-id';
} else if (repo.pubkey.toLowerCase() === queryPubkey.toLowerCase()) {
score += 800;
matchType = 'exact-pubkey';
}
if (name.includes(query)) score += 100;
if (dTag.includes(query)) score += 100;
if (description.includes(query)) score += 30;
// Name matches
if (name.includes(query)) {
score += name.startsWith(query) ? 100 : 50;
if (!matchType) matchType = 'name';
}
// D-tag matches
if (dTag.includes(query)) {
score += dTag.startsWith(query) ? 100 : 50;
if (!matchType) matchType = 'd-tag';
}
// Description matches
if (description.includes(query)) {
score += 30;
if (!matchType) matchType = 'description';
}
// Pubkey matches (owner)
if (repo.pubkey.toLowerCase().includes(query.toLowerCase()) ||
(queryPubkey && repo.pubkey.toLowerCase() === queryPubkey.toLowerCase())) {
score += 200;
if (!matchType) matchType = 'pubkey';
}
// Maintainer matches
for (const maintainer of maintainers) {
if (maintainer.includes(query.toLowerCase())) {
score += 150;
if (!matchType) matchType = 'maintainer';
break;
}
// Check if maintainer is npub and matches query
try {
const decoded = nip19.decode(maintainer);
if (decoded.type === 'npub') {
const maintainerPubkey = decoded.data as string;
if (maintainerPubkey.toLowerCase().includes(query.toLowerCase()) ||
(queryPubkey && maintainerPubkey.toLowerCase() === queryPubkey.toLowerCase())) {
score += 150;
if (!matchType) matchType = 'maintainer';
break;
}
}
} catch {
// Not an npub, already checked above
if (score > 0) {
registeredResults.push({ item, score });
}
}
// Clone URL matches
for (const url of cloneUrls) {
if (url.includes(query)) {
score += 40;
if (!matchType) matchType = 'clone-url';
break;
}
}
registeredResults.sort((a, b) => b.score - a.score || b.item.event.created_at - a.item.event.created_at);
registeredRepos = registeredResults.map(r => r.item);
// Fulltext search in all tags and content
const allText = [
name,
dTag,
description,
...cloneUrls,
...maintainers,
repo.content.toLowerCase()
].join(' ');
// 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 (allText.includes(query)) {
score += 10;
if (!matchType) matchType = 'fulltext';
if (repoName.includes(query)) score += 100;
if (announcement) {
const name = getRepoName(announcement).toLowerCase();
const description = getRepoDescription(announcement).toLowerCase();
if (name.includes(query)) score += 100;
if (description.includes(query)) score += 30;
}
if (score > 0) {
results.push({ repo, score, matchType });
localResults.push({ item, score });
}
}
// Sort by score (descending), then by created_at (descending)
results.sort((a, b) => {
if (b.score !== a.score) {
return b.score - a.score;
}
return b.repo.created_at - a.repo.created_at;
});
repos = results.map(r => r.repo);
localResults.sort((a, b) => b.score - a.score || b.item.lastModified - a.item.lastModified);
localRepos = localResults.map(r => r.item);
}
// Reactive search when query or filter changes
@ -511,14 +496,22 @@ @@ -511,14 +496,22 @@
</div>
{:else if loading}
<div class="loading">Loading repositories...</div>
{:else if repos.length === 0}
<div class="empty">No repositories found.</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 repos as repo}
{#each registeredRepos as item}
{@const repo = item.event}
{@const repoImage = getRepoImage(repo)}
{@const repoBanner = getRepoBanner(repo)}
<div class="repo-card">
<div class="repo-card repo-card-registered">
{#if repoBanner}
<div class="repo-card-banner">
<img src={repoBanner} alt="Banner" />
@ -535,28 +528,113 @@ @@ -535,28 +528,113 @@
<p class="description">{getRepoDescription(repo)}</p>
{/if}
</div>
<a href="/repos/{getNpubFromEvent(repo)}/{getRepoNameFromUrl(repo)}" class="view-button">
<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>

2
src/routes/api/repos/[npub]/[repo]/branches/+server.ts

@ -116,7 +116,7 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -116,7 +116,7 @@ export const GET: RequestHandler = createRepoGetHandler(
);
}
},
{ operation: 'getBranches', requireRepoExists: false, requireRepoAccess: false } // Branches are public info, handle on-demand fetching
{ operation: 'getBranches', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos
);
export const POST: RequestHandler = createRepoPostHandler(

2
src/routes/api/repos/[npub]/[repo]/commits/+server.ts

@ -127,5 +127,5 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -127,5 +127,5 @@ export const GET: RequestHandler = createRepoGetHandler(
);
}
},
{ operation: 'getCommits', requireRepoExists: false, requireRepoAccess: false } // Commits are public, handle on-demand fetching
{ operation: 'getCommits', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos
);

171
src/routes/api/repos/[npub]/[repo]/delete/+server.ts

@ -0,0 +1,171 @@ @@ -0,0 +1,171 @@
/**
* API endpoint for deleting local repository clones
* Only allows deletion by repo owner or admin
*/
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { rm } from 'fs/promises';
import { join, resolve } from 'path';
import { existsSync } from 'fs';
import { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext } from '$lib/utils/api-context.js';
import { handleApiError, handleAuthorizationError } from '$lib/utils/error-handler.js';
import { auditLogger } from '$lib/services/security/audit-logger.js';
import { nip19 } from 'nostr-tools';
import logger from '$lib/services/logger.js';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT
: '/repos';
// Admin pubkeys (can be set via environment variable)
const ADMIN_PUBKEYS = (typeof process !== 'undefined' && process.env?.ADMIN_PUBKEYS
? process.env.ADMIN_PUBKEYS.split(',').map(p => p.trim()).filter(p => p.length > 0)
: []) as string[];
/**
* Check if user is admin
*/
function isAdmin(userPubkeyHex: string | null): boolean {
if (!userPubkeyHex) return false;
return ADMIN_PUBKEYS.some(adminPubkey => {
// Support both hex and npub formats
try {
const decoded = nip19.decode(adminPubkey);
if (decoded.type === 'npub') {
return decoded.data === userPubkeyHex;
}
} catch {
// Not an npub, compare as hex
}
return adminPubkey.toLowerCase() === userPubkeyHex.toLowerCase();
});
}
/**
* Check if user is repo owner
*/
function isOwner(userPubkeyHex: string | null, repoOwnerPubkey: string): boolean {
if (!userPubkeyHex) return false;
return userPubkeyHex.toLowerCase() === repoOwnerPubkey.toLowerCase();
}
export const DELETE: RequestHandler = createRepoGetHandler(
async (context: RepoRequestContext, event) => {
const { npub, repo, repoOwnerPubkey, userPubkeyHex, clientIp } = context;
// Check permissions: must be owner or admin
if (!userPubkeyHex) {
auditLogger.log({
user: undefined,
ip: clientIp,
action: 'repo.delete',
resource: `${npub}/${repo}`,
result: 'denied',
error: 'Authentication required'
});
return handleAuthorizationError('Authentication required to delete repositories');
}
const userIsOwner = isOwner(userPubkeyHex, repoOwnerPubkey);
const userIsAdmin = isAdmin(userPubkeyHex);
if (!userIsOwner && !userIsAdmin) {
auditLogger.log({
user: userPubkeyHex,
ip: clientIp,
action: 'repo.delete',
resource: `${npub}/${repo}`,
result: 'denied',
error: 'Insufficient permissions'
});
return handleAuthorizationError('Only repository owners or admins can delete repositories');
}
// Get repository path
const repoPath = join(repoRoot, npub, `${repo}.git`);
// Security: Ensure resolved path is within repoRoot
const resolvedPath = resolve(repoPath).replace(/\\/g, '/');
const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/');
if (!resolvedPath.startsWith(resolvedRoot + '/')) {
auditLogger.log({
user: userPubkeyHex,
ip: clientIp,
action: 'repo.delete',
resource: `${npub}/${repo}`,
result: 'denied',
error: 'Invalid repository path'
});
return error(403, 'Invalid repository path');
}
// Check if repo exists
if (!existsSync(repoPath)) {
auditLogger.log({
user: userPubkeyHex,
ip: clientIp,
action: 'repo.delete',
resource: `${npub}/${repo}`,
result: 'failure',
error: 'Repository not found'
});
return error(404, 'Repository not found');
}
try {
// Delete the repository directory
await rm(repoPath, { recursive: true, force: true });
// Clear cache
repoCache.delete(RepoCache.repoExistsKey(npub, repo));
// Log successful deletion
auditLogger.log({
user: userPubkeyHex,
ip: clientIp,
action: 'repo.delete',
resource: `${npub}/${repo}`,
result: 'success',
metadata: {
isOwner: userIsOwner,
isAdmin: userIsAdmin
}
});
logger.info({
user: userPubkeyHex,
npub,
repo,
isOwner: userIsOwner,
isAdmin: userIsAdmin
}, 'Repository deleted');
return json({
success: true,
message: 'Repository deleted successfully'
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
auditLogger.log({
user: userPubkeyHex,
ip: clientIp,
action: 'repo.delete',
resource: `${npub}/${repo}`,
result: 'failure',
error: errorMessage
});
return handleApiError(err, { operation: 'deleteRepo', npub, repo }, 'Failed to delete repository');
}
},
{
operation: 'deleteRepo',
requireRepoExists: true,
requireRepoAccess: false, // We check permissions manually
requireMaintainer: false // We check owner/admin manually
}
);

2
src/routes/api/repos/[npub]/[repo]/download/+server.ts

@ -260,5 +260,5 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -260,5 +260,5 @@ export const GET: RequestHandler = createRepoGetHandler(
throw archiveError;
}
},
{ operation: 'download', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, downloads are public
{ operation: 'download', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos
);

2
src/routes/api/repos/[npub]/[repo]/readme/+server.ts

@ -118,5 +118,5 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -118,5 +118,5 @@ export const GET: RequestHandler = createRepoGetHandler(
isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown')
});
},
{ operation: 'getReadme', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, readme is public
{ operation: 'getReadme', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos
);

2
src/routes/api/repos/[npub]/[repo]/tags/+server.ts

@ -15,7 +15,7 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -15,7 +15,7 @@ export const GET: RequestHandler = createRepoGetHandler(
const tags = await fileManager.getTags(context.npub, context.repo);
return json(tags);
},
{ operation: 'getTags', requireRepoExists: false, requireRepoAccess: false } // Tags are public, handle on-demand fetching
{ operation: 'getTags', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos
);
export const POST: RequestHandler = createRepoPostHandler(

2
src/routes/api/repos/[npub]/[repo]/tree/+server.ts

@ -132,5 +132,5 @@ export const GET: RequestHandler = createRepoGetHandler( @@ -132,5 +132,5 @@ export const GET: RequestHandler = createRepoGetHandler(
);
}
},
{ operation: 'listFiles', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, tree is public
{ operation: 'listFiles', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos
);

117
src/routes/api/repos/list/+server.ts

@ -0,0 +1,117 @@ @@ -0,0 +1,117 @@
/**
* API endpoint for listing repositories with privacy checks
* Returns only repositories the current user can view
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS, GIT_DOMAIN } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { handleApiError } from '$lib/utils/error-handler.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import logger from '$lib/services/logger.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import type { RequestEvent } from '@sveltejs/kit';
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
interface RepoListItem {
event: NostrEvent;
npub: string;
repoName: string;
isRegistered: boolean; // Has this domain in clone URLs
}
export const GET: RequestHandler = async (event) => {
try {
const requestContext = extractRequestContext(event);
const userPubkey = requestContext.userPubkeyHex || null;
const gitDomain = event.url.searchParams.get('domain') || GIT_DOMAIN;
// Fetch all repository announcements
const events = await nostrClient.fetchEvents([
{ kinds: [KIND.REPO_ANNOUNCEMENT], limit: 100 }
]);
const repos: RepoListItem[] = [];
// Process each announcement
for (const event of events) {
const cloneUrls = event.tags
.filter(t => t[0] === 'clone')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string');
// Check if repo has this domain in clone URLs
const hasDomain = cloneUrls.some(url => url.includes(gitDomain));
// Extract repo name from d-tag
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) continue;
// Check privacy
const isPrivate = event.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
);
// Check if user can view this repo
let canView = false;
if (!isPrivate) {
canView = true; // Public repos are viewable by anyone
} else if (userPubkey) {
// Private repos require authentication
try {
canView = await maintainerService.canView(userPubkey, event.pubkey, dTag);
} catch (err) {
logger.warn({ error: err, pubkey: event.pubkey, repo: dTag }, 'Failed to check repo access');
canView = false;
}
}
// Only include repos the user can view
if (!canView) continue;
// Extract npub from clone URLs or convert pubkey
let npub: string;
const domainUrl = cloneUrls.find(url => url.includes(gitDomain));
if (domainUrl) {
const match = domainUrl.match(/\/(npub[a-z0-9]+)\//);
if (match) {
npub = match[1];
} else {
npub = nip19.npubEncode(event.pubkey);
}
} else {
npub = nip19.npubEncode(event.pubkey);
}
repos.push({
event,
npub,
repoName: dTag,
isRegistered: hasDomain
});
}
// Separate into registered and unregistered
const registered = repos.filter(r => r.isRegistered);
const unregistered = repos.filter(r => !r.isRegistered);
// Sort by created_at descending
registered.sort((a, b) => b.event.created_at - a.event.created_at);
unregistered.sort((a, b) => b.event.created_at - a.event.created_at);
return json({
registered,
unregistered,
total: repos.length
});
} catch (err) {
return handleApiError(err, { operation: 'listRepos' }, 'Failed to list repositories');
}
};

244
src/routes/api/repos/local/+server.ts

@ -0,0 +1,244 @@ @@ -0,0 +1,244 @@
/**
* API endpoint for listing local repository clones
* Returns local repos with their announcements, filtered by privacy
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { readdir, stat } from 'fs/promises';
import { join } from 'path';
import { existsSync } from 'fs';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS, GIT_DOMAIN } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { handleApiError } from '$lib/utils/error-handler.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import logger from '$lib/services/logger.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import type { RequestEvent } from '@sveltejs/kit';
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
// Cache for local repo list (5 minute TTL)
interface CacheEntry {
repos: LocalRepoItem[];
timestamp: number;
}
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
let cache: CacheEntry | null = null;
interface LocalRepoItem {
npub: string;
repoName: string;
announcement: NostrEvent | null;
lastModified: number;
isRegistered: boolean; // Has this domain in clone URLs
}
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT
: '/repos';
/**
* Scan filesystem for local repositories
*/
async function scanLocalRepos(): Promise<LocalRepoItem[]> {
const repos: LocalRepoItem[] = [];
if (!existsSync(repoRoot)) {
return repos;
}
try {
// Read all user directories
const userDirs = await readdir(repoRoot);
for (const userDir of userDirs) {
const userPath = join(repoRoot, userDir);
// Skip if not a directory or doesn't look like an npub
if (!userDir.startsWith('npub') || userDir.length < 60) continue;
try {
const stats = await stat(userPath);
if (!stats.isDirectory()) continue;
// Read repos for this user
const repoFiles = await readdir(userPath);
for (const repoFile of repoFiles) {
if (!repoFile.endsWith('.git')) continue;
const repoName = repoFile.replace(/\.git$/, '');
const repoPath = join(userPath, repoFile);
try {
const repoStats = await stat(repoPath);
if (!repoStats.isDirectory()) continue;
repos.push({
npub: userDir,
repoName,
announcement: null, // Will be fetched later
lastModified: repoStats.mtime.getTime(),
isRegistered: false // Will be determined from announcement
});
} catch (err) {
logger.warn({ error: err, repoPath }, 'Failed to stat repo');
}
}
} catch (err) {
logger.warn({ error: err, userPath }, 'Failed to read user directory');
}
}
} catch (err) {
logger.error({ error: err }, 'Failed to scan local repos');
throw err;
}
return repos;
}
/**
* Fetch announcements for local repos and check privacy
*/
async function enrichLocalRepos(
repos: LocalRepoItem[],
userPubkey: string | null,
gitDomain: string
): Promise<LocalRepoItem[]> {
const enriched: LocalRepoItem[] = [];
// Fetch announcements in parallel (batch by owner)
const ownerMap = new Map<string, string[]>(); // pubkey -> repo names
for (const repo of repos) {
try {
const decoded = nip19.decode(repo.npub);
if (decoded.type === 'npub') {
const pubkey = decoded.data as string;
if (!ownerMap.has(pubkey)) {
ownerMap.set(pubkey, []);
}
ownerMap.get(pubkey)!.push(repo.repoName);
}
} catch {
// Invalid npub, skip
continue;
}
}
// Fetch announcements for each owner
for (const [pubkey, repoNames] of ownerMap.entries()) {
try {
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [pubkey],
'#d': repoNames,
limit: repoNames.length
}
]);
// Match announcements to repos
for (const repo of repos) {
try {
const decoded = nip19.decode(repo.npub);
if (decoded.type !== 'npub' || decoded.data !== pubkey) continue;
const announcement = events.find(e => {
const dTag = e.tags.find(t => t[0] === 'd')?.[1];
return dTag === repo.repoName;
});
if (announcement) {
// Check if registered (has domain in clone URLs)
const cloneUrls = announcement.tags
.filter(t => t[0] === 'clone')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string');
const hasDomain = cloneUrls.some(url => url.includes(gitDomain));
// Check privacy
const isPrivate = announcement.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
);
// Check if user can view
let canView = false;
if (!isPrivate) {
canView = true;
} else if (userPubkey) {
try {
canView = await maintainerService.canView(userPubkey, pubkey, repo.repoName);
} catch (err) {
logger.warn({ error: err, pubkey, repo: repo.repoName }, 'Failed to check repo access');
canView = false;
}
}
// Only include repos user can view
if (canView) {
enriched.push({
...repo,
announcement,
isRegistered: hasDomain
});
}
} else {
// No announcement found - only show if user is owner (for security)
// For now, skip repos without announcements
// In the future, we could allow owners to see their own repos
}
} catch {
// Skip invalid repos
}
}
} catch (err) {
logger.warn({ error: err, pubkey }, 'Failed to fetch announcements for owner');
}
}
return enriched;
}
export const GET: RequestHandler = async (event) => {
try {
const requestContext = extractRequestContext(event);
const userPubkey = requestContext.userPubkeyHex || null;
const gitDomain = event.url.searchParams.get('domain') || GIT_DOMAIN;
const forceRefresh = url.searchParams.get('refresh') === 'true';
// Check cache
if (!forceRefresh && cache && (Date.now() - cache.timestamp) < CACHE_TTL) {
return json(cache.repos);
}
// Scan filesystem
const localRepos = await scanLocalRepos();
// Enrich with announcements and filter by privacy
const enriched = await enrichLocalRepos(localRepos, userPubkey, gitDomain);
// Filter out registered repos (they're in the main list)
const unregistered = enriched.filter(r => !r.isRegistered);
// Sort by last modified (most recent first)
unregistered.sort((a, b) => b.lastModified - a.lastModified);
// Update cache
cache = {
repos: unregistered,
timestamp: Date.now()
};
return json(unregistered);
} catch (err) {
return handleApiError(err, { operation: 'listLocalRepos' }, 'Failed to list local repositories');
}
};

66
src/routes/api/search/+server.ts

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { FileManager } from '$lib/services/git/file-manager.js';
@ -12,14 +13,21 @@ import { nip19 } from 'nostr-tools'; @@ -12,14 +13,21 @@ import { nip19 } from 'nostr-tools';
import { existsSync } from 'fs';
import { join } from 'path';
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import logger from '$lib/services/logger.js';
const repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('q');
const type = url.searchParams.get('type') || 'repos'; // repos, code, or all
const limit = parseInt(url.searchParams.get('limit') || '20', 10);
export const GET: RequestHandler = async (event) => {
const query = event.url.searchParams.get('q');
const type = event.url.searchParams.get('type') || 'repos'; // repos, code, or all
const limit = parseInt(event.url.searchParams.get('limit') || '20', 10);
// Extract user pubkey for privacy filtering
const requestContext = extractRequestContext(event);
const userPubkey = requestContext.userPubkeyHex || null;
if (!query || query.trim().length === 0) {
return handleValidationError('Missing or empty query parameter', { operation: 'search' });
@ -80,12 +88,37 @@ export const GET: RequestHandler = async ({ url }) => { @@ -80,12 +88,37 @@ export const GET: RequestHandler = async ({ url }) => {
});
}
// Process events into results
// Process events into results with privacy filtering
const searchLower = query.toLowerCase();
for (const event of events) {
const repoId = event.tags.find(t => t[0] === 'd')?.[1];
if (!repoId) continue;
// Check privacy
const isPrivate = event.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
);
// Check if user can view this repo
let canView = false;
if (!isPrivate) {
canView = true; // Public repos are viewable by anyone
} else if (userPubkey) {
// Private repos require authentication
try {
canView = await maintainerService.canView(userPubkey, event.pubkey, repoId);
} catch (err) {
logger.warn({ error: err, pubkey: event.pubkey, repo: repoId }, 'Failed to check repo access in search');
canView = false;
}
}
// Only include repos the user can view
if (!canView) continue;
const name = event.tags.find(t => t[0] === 'name')?.[1] || '';
const description = event.tags.find(t => t[0] === 'description')?.[1] || '';
const repoId = event.tags.find(t => t[0] === 'd')?.[1] || '';
try {
const npub = nip19.npubEncode(event.pubkey);
@ -155,11 +188,30 @@ export const GET: RequestHandler = async ({ url }) => { @@ -155,11 +188,30 @@ export const GET: RequestHandler = async ({ url }) => {
// If we can't list repos, skip code search
}
// Filter repos by privacy before searching code
const accessibleRepos: Array<{ npub: string; repo: string }> = [];
for (const { npub, repo } of allRepos.slice(0, 10)) { // Limit to 10 repos for performance
try {
// Decode npub to get pubkey
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') continue;
const repoOwnerPubkey = decoded.data as string;
// Check if user can view this repo
const canView = await maintainerService.canView(userPubkey, repoOwnerPubkey, repo);
if (canView) {
accessibleRepos.push({ npub, repo });
}
} catch {
// Skip if can't decode npub or check access
}
}
// Search in files (limited to avoid performance issues)
const searchLower = query.toLowerCase();
let codeResults: Array<{ repo: string; npub: string; file: string; matches: number }> = [];
for (const { npub, repo } of allRepos.slice(0, 10)) { // Limit to 10 repos for performance
for (const { npub, repo } of accessibleRepos) {
try {
const files = await fileManager.listFiles(npub, repo, 'HEAD', '');

107
src/routes/api/users/[npub]/repos/+server.ts

@ -0,0 +1,107 @@ @@ -0,0 +1,107 @@
/**
* API endpoint for listing a user's repositories with privacy checks
* Returns only repositories the current user can view
*/
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS, GIT_DOMAIN } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import logger from '$lib/services/logger.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import type { RequestEvent } from '@sveltejs/kit';
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async (event) => {
try {
const { npub } = event.params;
if (!npub) {
return handleValidationError('Missing npub parameter', { operation: 'getUserRepos' });
}
// Decode npub to get pubkey
let userPubkey: string;
try {
const decoded = nip19.decode(npub);
if (decoded.type !== 'npub') {
return handleValidationError('Invalid npub format', { operation: 'getUserRepos', npub });
}
userPubkey = decoded.data as string;
} catch {
return handleValidationError('Invalid npub format', { operation: 'getUserRepos', npub });
}
const requestContext = extractRequestContext(event);
const viewerPubkey = requestContext.userPubkeyHex || null;
const gitDomain = event.url.searchParams.get('domain') || GIT_DOMAIN;
// Fetch user's repository announcements
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [userPubkey],
limit: 100
}
]);
const repos: NostrEvent[] = [];
// Process each announcement with privacy filtering
for (const event of events) {
const cloneUrls = event.tags
.filter(t => t[0] === 'clone')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string');
// Filter for repos that list our domain
const hasDomain = cloneUrls.some(url => url.includes(gitDomain));
if (!hasDomain) continue;
// Extract repo name from d-tag
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) continue;
// Check privacy
const isPrivate = event.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
);
// Check if viewer can view this repo
let canView = false;
if (!isPrivate) {
canView = true; // Public repos are viewable by anyone
} else if (viewerPubkey) {
// Private repos require authentication
try {
canView = await maintainerService.canView(viewerPubkey, userPubkey, dTag);
} catch (err) {
logger.warn({ error: err, pubkey: userPubkey, repo: dTag }, 'Failed to check repo access');
canView = false;
}
}
// Only include repos the viewer can view
if (!canView) continue;
repos.push(event);
}
// Sort by created_at descending
repos.sort((a, b) => b.created_at - a.created_at);
return json({
repos,
total: repos.length
});
} catch (err) {
return handleApiError(err, { operation: 'getUserRepos' }, 'Failed to get user repositories');
}
};

16
src/routes/repos/[npub]/[repo]/+page.ts

@ -4,9 +4,11 @@ @@ -4,9 +4,11 @@
import type { PageLoad } from './$types';
import { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import { extractRequestContext } from '$lib/utils/api-context.js';
export const load: PageLoad = async ({ params, url, parent }) => {
const { npub, repo } = params;
@ -49,6 +51,20 @@ export const load: PageLoad = async ({ params, url, parent }) => { @@ -49,6 +51,20 @@ export const load: PageLoad = async ({ params, url, parent }) => {
}
const announcement = events[0];
// Check privacy - for private repos, we'll let the API endpoints handle access control
// The page load function runs server-side but doesn't have access to client auth headers
// So we'll mark it as private and let the frontend handle access denial
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
const isPrivate = announcement.tags.some(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
);
// For private repos, we can't check access here (no user context in page load)
// The frontend will need to check access via API and show appropriate error
// We still expose basic metadata (name) but the API will enforce access
const name = announcement.tags.find(t => t[0] === 'name')?.[1] || repo;
const description = announcement.tags.find(t => t[0] === 'description')?.[1] || '';
const image = announcement.tags.find(t => t[0] === 'image')?.[1];

37
src/routes/search/+page.svelte

@ -3,10 +3,13 @@ @@ -3,10 +3,13 @@
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import UserBadge from '$lib/components/UserBadge.svelte';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
import { nip19 } from 'nostr-tools';
let query = $state('');
let searchType = $state<'repos' | 'code' | 'all'>('repos');
let loading = $state(false);
let userPubkeyHex = $state<string | null>(null);
let results = $state<{
repos: Array<{ id: string; name: string; description: string; owner: string; npub: string }>;
code: Array<{ repo: string; npub: string; file: string; matches: number }>;
@ -14,6 +17,31 @@ @@ -14,6 +17,31 @@
} | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
await loadUserPubkey();
});
async function loadUserPubkey() {
if (!isNIP07Available()) {
return;
}
try {
const userPubkey = await getPublicKeyWithNIP07();
// Convert npub to hex for API calls
try {
const decoded = nip19.decode(userPubkey);
if (decoded.type === 'npub') {
userPubkeyHex = decoded.data as string;
}
} catch {
userPubkeyHex = userPubkey; // Assume it's already hex
}
} catch (err) {
console.warn('Failed to load user pubkey:', err);
}
}
async function performSearch() {
if (!query.trim() || query.length < 2) {
return;
@ -24,7 +52,14 @@ @@ -24,7 +52,14 @@
results = null; // Reset results
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${searchType}`);
const headers: Record<string, string> = {};
if (userPubkeyHex) {
headers['X-User-Pubkey'] = userPubkeyHex;
}
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}&type=${searchType}`, {
headers
});
if (response.ok) {
const data = await response.json();
// The API returns { query, type, results: { repos, code }, total }

100
src/routes/signup/+page.svelte

@ -53,8 +53,106 @@ @@ -53,8 +53,106 @@
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const searchClient = new NostrClient(DEFAULT_NOSTR_SEARCH_RELAYS);
onMount(() => {
onMount(async () => {
nip07Available = isNIP07Available();
// Check for query params to pre-fill form (for registering local clones)
const urlParams = $page.url.searchParams;
const npubParam = urlParams.get('npub');
const repoParam = urlParams.get('repo');
if (npubParam && repoParam) {
// Pre-fill repo name
repoName = repoParam;
// Try to fetch existing announcement to pre-fill other fields
try {
const decoded = nip19.decode(npubParam);
if (decoded.type === 'npub') {
const pubkey = decoded.data as string;
const events = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [pubkey],
'#d': [repoParam],
limit: 1
}
]);
if (events.length > 0) {
const event = events[0];
// Pre-fill description
const descTag = event.tags.find(t => t[0] === 'description')?.[1];
if (descTag) description = descTag;
// Pre-fill clone URLs (add current domain URL)
const existingCloneUrls = event.tags
.filter(t => t[0] === 'clone')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string');
const gitDomain = $page.data.gitDomain || 'localhost:6543';
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https';
const currentDomainUrl = `${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`;
// Check if current domain URL already exists
const hasCurrentDomain = existingCloneUrls.some(url => url.includes(gitDomain));
if (!hasCurrentDomain) {
cloneUrls = [...existingCloneUrls, currentDomainUrl];
} else {
cloneUrls = existingCloneUrls.length > 0 ? existingCloneUrls : [currentDomainUrl];
}
// Pre-fill other fields
const nameTag = event.tags.find(t => t[0] === 'name')?.[1];
if (nameTag && !repoName) repoName = nameTag;
const imageTag = event.tags.find(t => t[0] === 'image')?.[1];
if (imageTag) imageUrl = imageTag;
const bannerTag = event.tags.find(t => t[0] === 'banner')?.[1];
if (bannerTag) bannerUrl = bannerTag;
const webTags = event.tags.filter(t => t[0] === 'web');
if (webTags.length > 0) {
webUrls = webTags.flatMap(t => t.slice(1)).filter(url => url && typeof url === 'string');
}
const maintainerTags = event.tags.filter(t => t[0] === 'maintainers');
if (maintainerTags.length > 0) {
maintainers = maintainerTags.flatMap(t => t.slice(1)).filter(m => m && typeof m === 'string');
}
const relayTags = event.tags.filter(t => t[0] === 'relays');
if (relayTags.length > 0) {
relays = relayTags.flatMap(t => t.slice(1)).filter(r => r && typeof r === 'string');
}
const isPrivateTag = event.tags.find(t =>
(t[0] === 'private' && t[1] === 'true') ||
(t[0] === 't' && t[1] === 'private')
);
if (isPrivateTag) isPrivate = true;
// Set existing repo ref for updating
existingRepoRef = event.id;
} else {
// No announcement found, just set the clone URL with current domain
const gitDomain = $page.data.gitDomain || 'localhost:6543';
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https';
cloneUrls = [`${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`];
}
}
} catch (err) {
console.warn('Failed to pre-fill form from query params:', err);
// Still set basic clone URL
const gitDomain = $page.data.gitDomain || 'localhost:6543';
const protocol = gitDomain.startsWith('localhost') ? 'http' : 'https';
cloneUrls = [`${protocol}://${gitDomain}/${npubParam}/${repoParam}.git`];
}
}
});
function addCloneUrl() {

55
src/routes/users/[npub]/+page.svelte

@ -7,12 +7,14 @@ @@ -7,12 +7,14 @@
import { KIND } from '$lib/types/nostr.js';
import { nip19 } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js';
import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js';
const npub = ($page.params as { npub?: string }).npub || '';
let loading = $state(true);
let error = $state<string | null>(null);
let userPubkey = $state<string | null>(null);
let viewerPubkeyHex = $state<string | null>(null);
let repos = $state<NostrEvent[]>([]);
let userProfile = $state<{ name?: string; about?: string; picture?: string } | null>(null);
@ -20,9 +22,31 @@ @@ -20,9 +22,31 @@
const gitDomain = $page.data.gitDomain || 'localhost:6543';
onMount(async () => {
await loadViewerPubkey();
await loadUserProfile();
});
async function loadViewerPubkey() {
if (!isNIP07Available()) {
return;
}
try {
const viewerPubkey = await getPublicKeyWithNIP07();
// Convert npub to hex for API calls
try {
const decoded = nip19.decode(viewerPubkey);
if (decoded.type === 'npub') {
viewerPubkeyHex = decoded.data as string;
}
} catch {
viewerPubkeyHex = viewerPubkey; // Assume it's already hex
}
} catch (err) {
console.warn('Failed to load viewer pubkey:', err);
}
}
async function loadUserProfile() {
loading = true;
error = null;
@ -36,27 +60,20 @@ @@ -36,27 +60,20 @@
}
userPubkey = decoded.data as string;
// Fetch user's repositories
const repoEvents = await nostrClient.fetchEvents([
{
kinds: [KIND.REPO_ANNOUNCEMENT],
authors: [userPubkey],
limit: 100
}
]);
// Filter for repos that list our domain
repos = repoEvents.filter(event => {
const cloneUrls = event.tags
.filter(t => t[0] === 'clone')
.flatMap(t => t.slice(1))
.filter(url => url && typeof url === 'string');
return cloneUrls.some(url => url.includes(gitDomain));
// Fetch user's repositories via API (with privacy filtering)
const url = `/api/users/${npub}/repos?domain=${encodeURIComponent(gitDomain)}`;
const response = await fetch(url, {
headers: viewerPubkeyHex ? {
'X-User-Pubkey': viewerPubkeyHex
} : {}
});
// Sort by created_at descending
repos.sort((a, b) => b.created_at - a.created_at);
if (!response.ok) {
throw new Error(`Failed to load repositories: ${response.statusText}`);
}
const data = await response.json();
repos = data.repos || [];
// Try to fetch user profile (kind 0)
const profileEvents = await nostrClient.fetchEvents([

Loading…
Cancel
Save