From c639a7eae2ba05b4cdafd6bdb7f47ef55baa8ea5 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 18 Feb 2026 16:01:37 +0100 Subject: [PATCH] bug-fix --- src/hooks.server.ts | 9 +- src/lib/services/git/api-repo-fetcher.ts | 412 ++++++++++++++++++ src/lib/services/git/repo-manager.ts | 86 +++- src/lib/utils/user-access.ts | 20 + .../repos/[npub]/[repo]/branches/+server.ts | 30 +- .../api/repos/[npub]/[repo]/clone/+server.ts | 124 ++++++ src/routes/repos/+page.svelte | 3 +- src/routes/repos/[npub]/[repo]/+page.svelte | 73 ++++ src/routes/signup/+page.svelte | 11 +- src/routes/users/[npub]/+page.svelte | 50 ++- 10 files changed, 782 insertions(+), 36 deletions(-) create mode 100644 src/lib/services/git/api-repo-fetcher.ts create mode 100644 src/lib/utils/user-access.ts create mode 100644 src/routes/api/repos/[npub]/[repo]/clone/+server.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index f30feec..a7c6570 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -84,8 +84,13 @@ export const handle: Handle = async ({ event, resolve }) => { url.pathname.endsWith('/access') || // GET /access is read-only url.pathname.endsWith('/maintainers')); // GET /maintainers is read-only - // Check rate limit (skip for Vite internal requests and read-only repo requests) - const rateLimitResult = (isViteInternalRequest || isReadOnlyRepoRequest) + // Skip rate limiting for read-only GET requests to user endpoints (profile pages) + const isReadOnlyUserRequest = event.request.method === 'GET' && + url.pathname.startsWith('/api/users/') && + (url.pathname.endsWith('/repos')); // GET /users/[npub]/repos is read-only + + // Check rate limit (skip for Vite internal requests and read-only requests) + const rateLimitResult = (isViteInternalRequest || isReadOnlyRepoRequest || isReadOnlyUserRequest) ? { allowed: true, resetAt: Date.now() } : rateLimiter.check(rateLimitType, rateLimitIdentifier, isAnonymous); if (!rateLimitResult.allowed) { diff --git a/src/lib/services/git/api-repo-fetcher.ts b/src/lib/services/git/api-repo-fetcher.ts new file mode 100644 index 0000000..d638071 --- /dev/null +++ b/src/lib/services/git/api-repo-fetcher.ts @@ -0,0 +1,412 @@ +/** + * API-based repository fetcher service + * Fetches repository metadata from external platforms without cloning + * Supports GitHub, GitLab, Gitea, GRASP, and other git hosting services + * + * This is used by default for displaying repos. Only privileged users + * can explicitly clone repos to the server. + */ + +import logger from '../logger.js'; + +export interface ApiRepoInfo { + name: string; + description?: string; + url: string; + defaultBranch: string; + branches: ApiBranch[]; + commits: ApiCommit[]; + files: ApiFile[]; + readme?: { + path: string; + content: string; + format: 'markdown' | 'asciidoc'; + }; + platform: 'github' | 'gitlab' | 'gitea' | 'grasp' | 'unknown'; + isCloned: boolean; // Whether repo exists locally +} + +export interface ApiBranch { + name: string; + commit: { + sha: string; + message: string; + author: string; + date: string; + }; +} + +export interface ApiCommit { + sha: string; + message: string; + author: string; + date: string; +} + +export interface ApiFile { + name: string; + path: string; + type: 'file' | 'dir'; + size?: number; +} + +type GitPlatform = 'github' | 'gitlab' | 'gitea' | 'grasp' | 'unknown'; + +/** + * Check if a URL is a GRASP (Git Repository Access via Secure Protocol) URL + * GRASP URLs contain npub (Nostr public key) in the path: https://host/npub.../repo.git + */ +export function isGraspUrl(url: string): boolean { + return /\/npub1[a-z0-9]+/i.test(url); +} + +/** + * Parse git URL to extract platform, owner, and repo + */ +function parseGitUrl(url: string): { platform: GitPlatform; owner: string; repo: string; baseUrl: string } | null { + // Handle GRASP URLs - they use Gitea-compatible API but with npub as owner + if (isGraspUrl(url)) { + const graspMatch = url.match(/(https?:\/\/[^/]+)\/(npub1[a-z0-9]+)\/([^/]+?)(?:\.git)?\/?$/i); + if (graspMatch) { + const [, baseHost, npub, repo] = graspMatch; + return { + platform: 'grasp', + owner: npub, + repo: repo.replace(/\.git$/, ''), + baseUrl: `${baseHost}/api/v1` + }; + } + return null; + } + + // GitHub + const githubMatch = url.match(/github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (githubMatch) { + return { + platform: 'github', + owner: githubMatch[1], + repo: githubMatch[2].replace(/\.git$/, ''), + baseUrl: 'https://api.github.com' + }; + } + + // GitLab (both gitlab.com and self-hosted instances) + const gitlabMatch = url.match(/(https?:\/\/[^/]*gitlab[^/]*)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (gitlabMatch) { + const baseHost = gitlabMatch[1]; + const baseUrl = baseHost.includes('gitlab.com') + ? 'https://gitlab.com/api/v4' + : `${baseHost}/api/v4`; + return { + platform: 'gitlab', + owner: gitlabMatch[2], + repo: gitlabMatch[3].replace(/\.git$/, ''), + baseUrl + }; + } + + // Gitea and other Git hosting services (generic pattern) + const giteaMatch = url.match(/(https?:\/\/[^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/); + if (giteaMatch) { + // Double-check it's not a GRASP URL (npub in owner position) + if (giteaMatch[2].startsWith('npub1')) { + return null; + } + return { + platform: 'gitea', + owner: giteaMatch[2], + repo: giteaMatch[3].replace(/\.git$/, ''), + baseUrl: `${giteaMatch[1]}/api/v1` + }; + } + + return null; +} + +/** + * Check if a repository exists locally + */ +async function checkLocalRepo(npub: string, repoName: string): Promise { + try { + // Dynamic import to avoid bundling Node.js fs in browser + const { existsSync } = await import('fs'); + const { join } = await import('path'); + const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; + const repoPath = join(repoRoot, npub, `${repoName}.git`); + return existsSync(repoPath); + } catch { + // If we can't check (e.g., in browser), assume not cloned + return false; + } +} + +/** + * Fetch repository metadata from GitHub API + */ +async function fetchFromGitHub(owner: string, repo: string): Promise | null> { + try { + const githubToken = process.env.GITHUB_TOKEN; + const headers: Record = { + 'Accept': 'application/vnd.github.v3+json', + 'User-Agent': 'GitRepublic' + }; + + if (githubToken) { + headers['Authorization'] = `Bearer ${githubToken}`; + } + + const repoResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}`, { headers }); + if (!repoResponse.ok) { + if (repoResponse.status === 404) { + return null; + } + logger.warn({ status: repoResponse.status, owner, repo }, 'GitHub API error'); + return null; + } + + const repoData = await repoResponse.json(); + const defaultBranch = repoData.default_branch || 'main'; + + // Fetch branches, commits, and tree in parallel + const [branchesResponse, commitsResponse, treeResponse] = await Promise.all([ + fetch(`https://api.github.com/repos/${owner}/${repo}/branches`, { headers }).catch(() => null), + fetch(`https://api.github.com/repos/${owner}/${repo}/commits?per_page=10`, { headers }).catch(() => null), + fetch(`https://api.github.com/repos/${owner}/${repo}/git/trees/${defaultBranch}?recursive=1`, { headers }).catch(() => null) + ]); + + const branches: ApiBranch[] = branchesResponse?.ok + ? (await branchesResponse.json()).map((b: any) => ({ + name: b.name, + commit: { + sha: b.commit.sha, + message: b.commit.commit?.message?.split('\n')[0] || 'No commit message', + author: b.commit.commit?.author?.name || 'Unknown', + date: b.commit.commit?.author?.date || new Date().toISOString() + } + })) + : []; + + const commits: ApiCommit[] = commitsResponse?.ok + ? (await commitsResponse.json()).map((c: any) => ({ + sha: c.sha, + message: c.commit?.message?.split('\n')[0] || 'No commit message', + author: c.commit?.author?.name || 'Unknown', + date: c.commit?.author?.date || new Date().toISOString() + })) + : []; + + const files: ApiFile[] = treeResponse?.ok + ? (await treeResponse.json()).tree + ?.filter((item: any) => item.type === 'blob' || item.type === 'tree') + .map((item: any) => ({ + name: item.path.split('/').pop(), + path: item.path, + type: item.type === 'tree' ? 'dir' : 'file', + size: item.size + })) || [] + : []; + + // Try to fetch README + let readme: { path: string; content: string; format: 'markdown' | 'asciidoc' } | undefined; + const readmeFiles = ['README.adoc', 'README.md', 'README.rst', 'README.txt']; + for (const readmeFile of readmeFiles) { + try { + const readmeResponse = await fetch( + `https://api.github.com/repos/${owner}/${repo}/contents/${readmeFile}?ref=${defaultBranch}`, + { headers } + ); + if (readmeResponse.ok) { + const readmeData = await readmeResponse.json(); + if (readmeData.content) { + const content = atob(readmeData.content.replace(/\s/g, '')); + readme = { + path: readmeFile, + content, + format: readmeFile.toLowerCase().endsWith('.adoc') ? 'asciidoc' : 'markdown' + }; + break; + } + } + } catch { + continue; + } + } + + return { + name: repoData.name, + description: repoData.description, + url: repoData.html_url, + defaultBranch, + branches, + commits, + files, + readme, + platform: 'github' + }; + } catch (error) { + logger.error({ error, owner, repo }, 'Error fetching from GitHub'); + return null; + } +} + +/** + * Fetch repository metadata from GitLab API + * Note: This is a simplified version. For full implementation, see aitherboard's git-repo-fetcher.ts + */ +async function fetchFromGitLab(owner: string, repo: string, baseUrl: string): Promise | null> { + try { + const projectPath = encodeURIComponent(`${owner}/${repo}`); + const repoResponse = await fetch(`${baseUrl}/projects/${projectPath}`); + + if (!repoResponse.ok) { + if (repoResponse.status === 404) { + return null; + } + return null; + } + + const repoData = await repoResponse.json(); + const defaultBranch = repoData.default_branch || 'master'; + + // For now, return basic info. Full implementation would fetch branches, commits, files + return { + name: repoData.name, + description: repoData.description, + url: repoData.web_url, + defaultBranch, + branches: [], + commits: [], + files: [], + platform: 'gitlab' + }; + } catch (error) { + logger.error({ error, owner, repo }, 'Error fetching from GitLab'); + return null; + } +} + +/** + * Fetch repository metadata from Gitea API + */ +async function fetchFromGitea(owner: string, repo: string, baseUrl: string): Promise | null> { + try { + const encodedOwner = encodeURIComponent(owner); + const encodedRepo = encodeURIComponent(repo); + const repoResponse = await fetch(`${baseUrl}/repos/${encodedOwner}/${encodedRepo}`); + + if (!repoResponse.ok) { + if (repoResponse.status === 404) { + return null; + } + return null; + } + + const repoData = await repoResponse.json(); + const defaultBranch = repoData.default_branch || 'master'; + + return { + name: repoData.name, + description: repoData.description, + url: repoData.html_url || repoData.clone_url, + defaultBranch, + branches: [], + commits: [], + files: [], + platform: 'gitea' + }; + } catch (error) { + logger.error({ error, owner, repo }, 'Error fetching from Gitea'); + return null; + } +} + +/** + * Fetch repository metadata from GRASP + * GRASP repos use git protocol, so we can't easily fetch metadata via API + * For now, return minimal info indicating it's a GRASP repo + */ +async function fetchFromGrasp(npub: string, repo: string, baseUrl: string, originalUrl: string): Promise | null> { + // GRASP repos typically don't have REST APIs + // Full implementation would use git protocol (info/refs, git-upload-pack) + // For now, return basic structure + return { + name: repo, + description: undefined, + url: originalUrl, + defaultBranch: 'main', + branches: [], + commits: [], + files: [], + platform: 'grasp' + }; +} + +/** + * Fetch repository metadata from a git URL + * This is the main entry point for API-based fetching + */ +export async function fetchRepoMetadata( + url: string, + npub: string, + repoName: string +): Promise { + const parsed = parseGitUrl(url); + if (!parsed) { + logger.warn({ url }, 'Unable to parse git URL'); + return null; + } + + const { platform, owner, repo, baseUrl } = parsed; + const isCloned = await checkLocalRepo(npub, repoName); + + let metadata: Partial | null = null; + + switch (platform) { + case 'github': + metadata = await fetchFromGitHub(owner, repo); + break; + case 'gitlab': + metadata = await fetchFromGitLab(owner, repo, baseUrl); + break; + case 'gitea': + metadata = await fetchFromGitea(owner, repo, baseUrl); + break; + case 'grasp': + metadata = await fetchFromGrasp(owner, repo, baseUrl, url); + break; + default: + logger.warn({ platform, url }, 'Unsupported platform'); + return null; + } + + if (!metadata) { + return null; + } + + return { + ...metadata, + isCloned, + platform + } as ApiRepoInfo; +} + +/** + * Extract git URLs from a Nostr repo announcement event + */ +export function extractGitUrls(event: { tags: string[][] }): string[] { + const urls: string[] = []; + + for (const tag of event.tags) { + if (tag[0] === 'clone') { + // Clone tags can have multiple URLs: ["clone", "url1", "url2", "url3"] + for (let i = 1; i < tag.length; i++) { + const url = tag[i]; + if (url && typeof url === 'string' && (url.startsWith('http') || url.startsWith('git@'))) { + urls.push(url); + } + } + } + } + + return [...new Set(urls)]; // Deduplicate +} diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index a12c610..7d540c8 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -163,6 +163,41 @@ export class RepoManager { * Get git environment variables with Tor proxy if needed for .onion addresses * Security: Only whitelist necessary environment variables */ + /** + * Inject authentication token into a git URL if needed + * Supports GitHub tokens via GITHUB_TOKEN environment variable + * Returns the original URL if no token is needed or available + */ + private injectAuthToken(url: string): string { + try { + const urlObj = new URL(url); + + // If URL already has credentials, don't modify it + if (urlObj.username) { + return url; + } + + // Check for GitHub token + if (urlObj.hostname === 'github.com' || urlObj.hostname.endsWith('.github.com')) { + const githubToken = process.env.GITHUB_TOKEN; + if (githubToken) { + // Inject token into URL: https://token@github.com/user/repo.git + urlObj.username = githubToken; + urlObj.password = ''; // GitHub uses token as username, password is empty + return urlObj.toString(); + } + } + + // Add support for other git hosting services here if needed + // e.g., GitLab: GITLAB_TOKEN, Gitea: GITEA_TOKEN, etc. + + return url; + } catch { + // URL parsing failed, return original URL + return url; + } + } + private getGitEnvForUrl(url: string): Record { // Whitelist only necessary environment variables for security const env: Record = { @@ -213,14 +248,23 @@ export class RepoManager { private async syncFromSingleRemote(repoPath: string, url: string, index: number): Promise { const remoteName = `remote-${index}`; const git = simpleGit(repoPath); - const gitEnv = this.getGitEnvForUrl(url); + // Inject authentication token if available (e.g., GITHUB_TOKEN) + const authenticatedUrl = this.injectAuthToken(url); + const gitEnv = this.getGitEnvForUrl(authenticatedUrl); try { // Add remote if not exists (ignore error if already exists) + // Use authenticated URL so git can access private repos try { - await git.addRemote(remoteName, url); + await git.addRemote(remoteName, authenticatedUrl); } catch { - // Remote might already exist, that's okay + // Remote might already exist, that's okay - try to update it + try { + await git.removeRemote(remoteName); + await git.addRemote(remoteName, authenticatedUrl); + } catch { + // If update fails, continue - might be using old URL + } } // Configure git proxy for this remote if it's a .onion address @@ -545,16 +589,25 @@ export class RepoManager { } // Try to clone from the first available remote URL - // Use simple-git for safer cloning + // Inject authentication token if available (e.g., GITHUB_TOKEN) + const authenticatedUrl = this.injectAuthToken(remoteUrls[0]); const git = simpleGit(); - const gitEnv = this.getGitEnvForUrl(remoteUrls[0]); + const gitEnv = this.getGitEnvForUrl(authenticatedUrl); - logger.info({ npub, repoName, sourceUrl: remoteUrls[0], cloneUrls }, 'Fetching repository on-demand from remote'); + // Log if we're using authentication (but don't log the token) + const isAuthenticated = authenticatedUrl !== remoteUrls[0]; + logger.info({ + npub, + repoName, + sourceUrl: remoteUrls[0], + cloneUrls, + authenticated: isAuthenticated + }, 'Fetching repository on-demand from remote'); // Clone as bare repository // Use gitEnv which already contains necessary whitelisted environment variables await new Promise((resolve, reject) => { - const cloneProcess = spawn('git', ['clone', '--bare', remoteUrls[0], repoPath], { + const cloneProcess = spawn('git', ['clone', '--bare', authenticatedUrl, repoPath], { env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] }); @@ -574,13 +627,28 @@ export class RepoManager { resolve(); } else { const errorMsg = `Git clone failed with code ${code}: ${stderr || stdout}`; - logger.error({ npub, repoName, sourceUrl: remoteUrls[0], code, stderr, stdout }, 'Git clone failed'); + // Don't log the authenticated URL (might contain token) + logger.error({ + npub, + repoName, + sourceUrl: remoteUrls[0], + code, + stderr, + stdout, + authenticated: isAuthenticated + }, 'Git clone failed'); reject(new Error(errorMsg)); } }); cloneProcess.on('error', (err) => { - logger.error({ npub, repoName, sourceUrl: remoteUrls[0], error: err }, 'Git clone process error'); + logger.error({ + npub, + repoName, + sourceUrl: remoteUrls[0], + error: err, + authenticated: isAuthenticated + }, 'Git clone process error'); reject(err); }); }); diff --git a/src/lib/utils/user-access.ts b/src/lib/utils/user-access.ts new file mode 100644 index 0000000..668bf2a --- /dev/null +++ b/src/lib/utils/user-access.ts @@ -0,0 +1,20 @@ +/** + * Utility functions for checking user access levels + */ + +import type { UserLevel } from '../services/nostr/user-level-service.js'; + +/** + * Check if a user has unlimited/write access + * Only unlimited users can clone repos and register new repos + */ +export function hasUnlimitedAccess(userLevel: UserLevel | null | undefined): boolean { + return userLevel === 'unlimited'; +} + +/** + * Check if a user is logged in (has any access level) + */ +export function isLoggedIn(userLevel: UserLevel | null | undefined): boolean { + return userLevel !== null && userLevel !== undefined && userLevel !== 'strictly_rate_limited'; +} diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index 8891fd2..9d88dec 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -13,6 +13,9 @@ import { KIND } from '$lib/types/nostr.js'; import { join } from 'path'; import { existsSync } from 'fs'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; +import { DEFAULT_NOSTR_RELAYS, DEFAULT_NOSTR_SEARCH_RELAYS } from '$lib/config.js'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { eventCache } from '$lib/services/nostr/event-cache.js'; const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT @@ -25,15 +28,30 @@ export const GET: RequestHandler = createRepoGetHandler( // If repo doesn't exist, try to fetch it on-demand if (!existsSync(repoPath)) { try { - // Fetch repository announcement from Nostr - const events = await nostrClient.fetchEvents([ + // Try cached client first (cache-first lookup) + const filters = [ { kinds: [KIND.REPO_ANNOUNCEMENT], authors: [context.repoOwnerPubkey], '#d': [context.repo], limit: 1 } - ]); + ]; + + let events = await nostrClient.fetchEvents(filters); + + // If no events found in cache/default relays, try all relays (default + search) + // But first invalidate the cache entry so we don't get the same cached empty result + if (events.length === 0) { + const allRelays = [...new Set([...DEFAULT_NOSTR_RELAYS, ...DEFAULT_NOSTR_SEARCH_RELAYS])]; + // Only create new client if we have additional relays to try + if (allRelays.length > DEFAULT_NOSTR_RELAYS.length) { + // Invalidate the cache entry so we can try fresh with all relays + eventCache.invalidate(filters); + const allRelaysClient = new NostrClient(allRelays); + events = await allRelaysClient.fetchEvents(filters); + } + } if (events.length > 0) { // Try to fetch the repository from remote clone URLs @@ -74,8 +92,12 @@ export const GET: RequestHandler = createRepoGetHandler( } } } else { + // No events found - could be because: + // 1. Repository doesn't exist + // 2. Relays are unreachable + // 3. Repository is on different relays throw handleNotFoundError( - 'Repository announcement not found in Nostr', + 'Repository announcement not found in Nostr. This could mean: (1) the repository does not exist, (2) the configured Nostr relays are unreachable, or (3) the repository is published on different relays. Try configuring additional relays via the NOSTR_RELAYS environment variable.', { operation: 'getBranches', npub: context.npub, repo: context.repo } ); } diff --git a/src/routes/api/repos/[npub]/[repo]/clone/+server.ts b/src/routes/api/repos/[npub]/[repo]/clone/+server.ts new file mode 100644 index 0000000..36bca5b --- /dev/null +++ b/src/routes/api/repos/[npub]/[repo]/clone/+server.ts @@ -0,0 +1,124 @@ +/** + * Clone repository endpoint + * Only privileged users (unlimited access) can clone repos to the server + */ + +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { RepoManager } from '$lib/services/git/repo-manager.js'; +import { requireNpubHex } from '$lib/utils/npub-utils.js'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { KIND } from '$lib/types/nostr.js'; +import { extractRequestContext } from '$lib/utils/api-context.js'; +import { getCachedUserLevel } from '$lib/services/security/user-level-cache.js'; +import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; + +const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; +const repoManager = new RepoManager(repoRoot); +const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + +export const POST: RequestHandler = async (event) => { + const { npub, repo } = event.params; + + if (!npub || !repo) { + throw handleValidationError('Missing npub or repo parameter', { operation: 'cloneRepo', npub, repo }); + } + + // Extract user context + const requestContext = extractRequestContext(event); + const userPubkeyHex = requestContext.userPubkeyHex; + + if (!userPubkeyHex) { + throw error(401, 'Authentication required. Please log in to clone repositories.'); + } + + // Check if user has unlimited access + const userLevel = getCachedUserLevel(userPubkeyHex); + if (!userLevel || userLevel.level !== 'unlimited') { + throw error(403, 'Only users with unlimited access can clone repositories to the server.'); + } + + try { + // Decode npub to get pubkey + const repoOwnerPubkey = requireNpubHex(npub); + const repoPath = join(repoRoot, npub, `${repo}.git`); + + // Check if repo already exists + if (existsSync(repoPath)) { + return json({ + success: true, + message: 'Repository already exists locally', + alreadyExists: true + }); + } + + // Fetch repository announcement + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [repoOwnerPubkey], + '#d': [repo], + limit: 1 + } + ]); + + if (events.length === 0) { + throw handleValidationError( + 'Repository announcement not found in Nostr', + { operation: 'cloneRepo', npub, repo } + ); + } + + const announcementEvent = events[0]; + + // Attempt to clone the repository + const cloned = await repoManager.fetchRepoOnDemand(npub, repo, announcementEvent); + + if (!cloned) { + throw handleApiError( + new Error('Failed to clone repository from remote URLs'), + { operation: 'cloneRepo', npub, repo }, + 'Could not clone repository. Please check that the repository has valid clone URLs and is accessible.' + ); + } + + // Verify repo exists after cloning + if (!existsSync(repoPath)) { + // Wait a moment for filesystem to sync + await new Promise(resolve => setTimeout(resolve, 500)); + if (!existsSync(repoPath)) { + throw handleApiError( + new Error('Repository clone completed but repository is not accessible'), + { operation: 'cloneRepo', npub, repo }, + 'Repository clone completed but repository is not accessible' + ); + } + } + + logger.info({ npub, repo, userPubkeyHex: userPubkeyHex.slice(0, 16) + '...' }, 'Repository cloned successfully'); + + return json({ + success: true, + message: 'Repository cloned successfully', + alreadyExists: false + }); + } catch (err) { + logger.error({ error: err, npub, repo }, 'Error cloning repository'); + + // Re-throw auth errors as-is + if (err instanceof Error && (err.message.includes('401') || err.message.includes('403'))) { + throw err; + } + + const error = err instanceof Error ? err : new Error(String(err)); + throw handleApiError( + error, + { operation: 'cloneRepo', npub, repo }, + 'Failed to clone repository' + ); + } +}; diff --git a/src/routes/repos/+page.svelte b/src/routes/repos/+page.svelte index 75e9aac..9583203 100644 --- a/src/routes/repos/+page.svelte +++ b/src/routes/repos/+page.svelte @@ -9,6 +9,7 @@ import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; import { getPublicKeyWithNIP07, isNIP07Available } from '$lib/services/nostr/nip07-signer.js'; import { userStore } from '$lib/stores/user-store.js'; + import { hasUnlimitedAccess } from '$lib/utils/user-access.js'; // Registered repos (with domain in clone URLs) let registeredRepos = $state>([]); @@ -692,7 +693,7 @@ > {deletingRepo?.npub === item.npub && deletingRepo?.repo === item.repoName ? 'Deleting...' : 'Delete'} - {:else} + {:else if hasUnlimitedAccess($userStore.userLevel)} + {#if hasUnlimitedAccess($userStore.userLevel) && (isRepoCloned === false || (isRepoCloned === null && !checkingCloneStatus))} + + {/if} {#if isMaintainer} Settings {/if} diff --git a/src/routes/signup/+page.svelte b/src/routes/signup/+page.svelte index cc617af..6334df2 100644 --- a/src/routes/signup/+page.svelte +++ b/src/routes/signup/+page.svelte @@ -8,6 +8,8 @@ import { KIND } from '../../lib/types/nostr.js'; import type { NostrEvent } from '../../lib/types/nostr.js'; import { nip19 } from 'nostr-tools'; + import { userStore } from '../../lib/stores/user-store.js'; + import { hasUnlimitedAccess } from '../../lib/utils/user-access.js'; let nip07Available = $state(false); let loading = $state(false); @@ -1472,8 +1474,13 @@
- - {#if !nip07Available} + {#if !hasUnlimitedAccess($userStore.userLevel)} +
+

Only users with unlimited access can create or register repositories.

+

Please log in with an account that has write access to Nostr relays.

+ +
+ {:else if !nip07Available}

NIP-07 browser extension is required to sign repository announcements.

Please install a Nostr browser extension (like Alby, nos2x, or similar).

diff --git a/src/routes/users/[npub]/+page.svelte b/src/routes/users/[npub]/+page.svelte index b714c9f..9c6ccfa 100644 --- a/src/routes/users/[npub]/+page.svelte +++ b/src/routes/users/[npub]/+page.svelte @@ -21,28 +21,32 @@ let error = $state(null); let userPubkey = $state(null); let viewerPubkeyHex = $state(null); + let lastViewerPubkeyHex = $state(null); // Track last viewer pubkey to detect changes + let isReloading = $state(false); // Guard to prevent concurrent reloads - // Sync with userStore + // Sync with userStore - only reload if viewer pubkey actually changed $effect(() => { const currentUser = $userStore; - const wasLoggedIn = userPubkey !== null || viewerPubkeyHex !== null; + const newViewerPubkeyHex = currentUser.userPubkeyHex; - if (currentUser.userPubkey && currentUser.userPubkeyHex) { - const wasDifferent = userPubkey !== currentUser.userPubkey || viewerPubkeyHex !== currentUser.userPubkeyHex; - userPubkey = currentUser.userPubkey; - viewerPubkeyHex = currentUser.userPubkeyHex; + // Only update if viewer pubkey actually changed (not just any store change) + if (newViewerPubkeyHex !== lastViewerPubkeyHex) { + const wasLoggedIn = viewerPubkeyHex !== null; + const isNowLoggedIn = newViewerPubkeyHex !== null; - // Reload profile and repos when user logs in or pubkey changes - if (wasDifferent) { - loadUserProfile().catch(err => console.warn('Failed to reload user profile after login:', err)); - } - } else { - userPubkey = null; - viewerPubkeyHex = null; + // Update viewer pubkey + viewerPubkeyHex = newViewerPubkeyHex; + lastViewerPubkeyHex = newViewerPubkeyHex; - // Reload profile when user logs out to hide private repos - if (wasLoggedIn) { - loadUserProfile().catch(err => console.warn('Failed to reload user profile after logout:', err)); + // Only reload if login state actually changed (logged in -> logged out or vice versa) + // AND we're not already loading/reloading + if ((wasLoggedIn !== isNowLoggedIn) && !loading && !isReloading) { + isReloading = true; + loadUserProfile() + .catch(err => console.warn('Failed to reload user profile after login state change:', err)) + .finally(() => { + isReloading = false; + }); } } }); @@ -105,17 +109,27 @@ } async function loadUserProfile() { + // Prevent concurrent loads + if (loading && !isReloading) { + return; + } + loading = true; error = null; try { - // Decode npub to get pubkey + // Decode npub to get pubkey (this is the profile owner, not the viewer) const decoded = nip19.decode(npub); if (decoded.type !== 'npub') { error = 'Invalid npub format'; return; } - userPubkey = decoded.data as string; + const profileOwnerPubkey = decoded.data as string; + + // Only update userPubkey if it's different (avoid triggering effects) + if (userPubkey !== profileOwnerPubkey) { + userPubkey = profileOwnerPubkey; + } // Fetch user's repositories via API (with privacy filtering) const url = `/api/users/${npub}/repos?domain=${encodeURIComponent(gitDomain)}`;