/** * 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'; import { eventCache } from '$lib/services/nostr/event-cache.js'; import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; 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; // Track server startup time to invalidate cache on first request after startup let serverStartTime = Date.now(); const STARTUP_GRACE_PERIOD = 1000; // 1 second - minimal grace period for cache /** * Invalidate cache (internal use only - not exported to avoid SvelteKit build errors) */ function invalidateLocalReposCache(): void { cache = null; serverStartTime = Date.now(); logger.debug('Local repos cache invalidated'); } interface LocalRepoItem { npub: string; repoName: string; announcement: NostrEvent | null; lastModified: number; isRegistered: boolean; // Has this domain in clone URLs } const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT : '/repos'; /** * Scan filesystem for local repositories */ async function scanLocalRepos(): Promise { const repos: LocalRepoItem[] = []; if (!existsSync(repoRoot)) { return repos; } try { // Read all user directories const userDirs = await readdir(repoRoot); for (const userDir of userDirs) { const userPath = join(repoRoot, userDir); // Skip if not a directory or doesn't look like an npub if (!userDir.startsWith('npub') || userDir.length < 60) continue; try { const stats = await stat(userPath); if (!stats.isDirectory()) continue; // Read repos for this user const repoFiles = await readdir(userPath); for (const repoFile of repoFiles) { if (!repoFile.endsWith('.git')) continue; const repoName = repoFile.replace(/\.git$/, ''); const repoPath = join(userPath, repoFile); try { const repoStats = await stat(repoPath); if (!repoStats.isDirectory()) continue; repos.push({ npub: userDir, repoName, announcement: null, // Will be fetched later lastModified: repoStats.mtime.getTime(), isRegistered: false // Will be determined from announcement }); } catch (err) { logger.warn({ error: err, repoPath }, 'Failed to stat repo'); } } } catch (err) { logger.warn({ error: err, userPath }, 'Failed to read user directory'); } } } catch (err) { logger.error({ error: err }, 'Failed to scan local repos'); throw err; } return repos; } /** * Fetch announcements for local repos and check privacy */ async function enrichLocalRepos( repos: LocalRepoItem[], userPubkey: string | null, gitDomain: string ): Promise { const enriched: LocalRepoItem[] = []; // Fetch announcements in parallel (batch by owner) const ownerMap = new Map(); // pubkey -> repo names for (const repo of repos) { try { const decoded = nip19.decode(repo.npub); if (decoded.type === 'npub') { const pubkey = decoded.data as string; if (!ownerMap.has(pubkey)) { ownerMap.set(pubkey, []); } ownerMap.get(pubkey)!.push(repo.repoName); } } catch { // Invalid npub, skip continue; } } // Fetch announcements for each owner for (const [pubkey, repoNames] of ownerMap.entries()) { try { // Fetch all announcements by this author (case-insensitive matching) with caching const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, pubkey, eventCache); // Match announcements to repos (case-insensitive) for (const repo of repos) { try { const decoded = nip19.decode(repo.npub); if (decoded.type !== 'npub' || decoded.data !== pubkey) continue; const announcement = findRepoAnnouncement(allEvents, 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 = event.url.searchParams.get('refresh') === 'true'; // If server just started, always refresh to ensure we get latest repos const timeSinceStartup = Date.now() - serverStartTime; const isRecentStartup = timeSinceStartup < STARTUP_GRACE_PERIOD; // Check cache (but skip if recent startup or force refresh) if (!forceRefresh && !isRecentStartup && cache && (Date.now() - cache.timestamp) < CACHE_TTL) { return json(cache.repos); } if (isRecentStartup) { logger.debug({ timeSinceStartup }, 'Skipping cache due to recent server startup'); } // 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'); } };