You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

258 lines
8.5 KiB

/**
* 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<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 {
// 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');
}
};