From 056191026df8bd9d59a8b482637aa01daf34418b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 24 Feb 2026 21:28:42 +0100 Subject: [PATCH] fix page crash on download Nostr-Signature: eafa232557affbacb430b467507febc201f0a8f54f4b9ecf57e315c32e51a589 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 53c58aabe0bfad6e432a8bb980c2046fc14bc8163825fde2ac766a449ce4418adb1049ac732c7fc7ecc7ad050539fb68c023d54f2b6c390e478616b5c0b91a31 --- nostr/commit-signatures.jsonl | 1 + src/lib/components/NavBar.svelte | 51 +- src/routes/+layout.svelte | 45 +- src/routes/+page.svelte | 27 +- .../repos/[npub]/[repo]/download/+server.ts | 673 +++++++++++------- src/routes/repos/[npub]/[repo]/+page.svelte | 581 ++++++++------- .../[npub]/[repo]/components/TagsTab.svelte | 418 +++++++++++ .../repos/[npub]/[repo]/utils/api-client.ts | 111 +++ .../repos/[npub]/[repo]/utils/download.ts | 234 ++++++ 9 files changed, 1590 insertions(+), 551 deletions(-) create mode 100644 src/routes/repos/[npub]/[repo]/components/TagsTab.svelte create mode 100644 src/routes/repos/[npub]/[repo]/utils/api-client.ts create mode 100644 src/routes/repos/[npub]/[repo]/utils/download.ts diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index ec6afd7..aa78f51 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -71,3 +71,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771949714,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fallback to API if registered clone unavailble"]],"content":"Signed commit: fallback to API if registered clone unavailble","id":"4921a95aea13f6f72329ff8a278a8ff6321776973e8db327d59ea62b90d363cc","sig":"0efffc826cad23849bd311be582a70cb0a42f3958c742470e8488803c5882955184b9241bf77fcf65fa5ea38feef8bc82de4965de1c783adf53ed05e461dc5de"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771952814,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","more work on branches"]],"content":"Signed commit: more work on branches","id":"adaaea7f2065a00cfd04c9de9bf82b1b976ac3d20c32389a8bd8aa7ad0a95677","sig":"71ce678d0a0732beab1f49f8318cbfe3d8b33d45eacf13392fdb9553e8b1f4732c28d8ffc33b50c9736a8324cf7604c223bb71ff4cfd32f41d7f3e81e1591fcc"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771956701,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","implemented releases and code serach\nadd contributors to private repos\napply/merge buttons for patches and PRs\nhighlgihts and comments on patches and prs\nadded tagged downloads"]],"content":"Signed commit: implemented releases and code serach\nadd contributors to private repos\napply/merge buttons for patches and PRs\nhighlgihts and comments on patches and prs\nadded tagged downloads","id":"e822be2b0fbf3285bbedf9d8f9d1692b5503080af17a4d28941a1dc81c96187c","sig":"70c8b6e499551ce43478116cf694992102a29572d5380cbe3b070a3026bc2c9e35177587712c7414f25d1ca50038c9614479f7758bbdc48f69cc44cd52bf4842"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1771958124,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","fix crash on download"]],"content":"Signed commit: fix crash on download","id":"3fdcc681cdda4b523f9c3752309b8cf740b58178ca02dcff4ef97ec714bf394c","sig":"e405612a5aafeef66818f0a3c683e322f862d1fc3c662c32f618f516fd8c11ece5f4539b94893583301d31fd2ecd3de3b6d7a953505e2696915afe10710a16d7"} diff --git a/src/lib/components/NavBar.svelte b/src/lib/components/NavBar.svelte index b0bb177..d70d3d3 100644 --- a/src/lib/components/NavBar.svelte +++ b/src/lib/components/NavBar.svelte @@ -123,32 +123,35 @@ }, 5 * 60 * 1000); // Check every 5 minutes }); - onDestroy(() => { - // Mark component as unmounted first - isMounted = false; - - // Clean up event listeners - try { - if (updateActivityOnInteraction) { - document.removeEventListener('click', updateActivityOnInteraction); - document.removeEventListener('keydown', updateActivityOnInteraction); - document.removeEventListener('scroll', updateActivityOnInteraction); - updateActivityOnInteraction = null; + // Only register onDestroy on client side to prevent SSR errors + if (typeof window !== 'undefined') { + onDestroy(() => { + // Mark component as unmounted first + isMounted = false; + + // Clean up event listeners + try { + if (updateActivityOnInteraction) { + document.removeEventListener('click', updateActivityOnInteraction); + document.removeEventListener('keydown', updateActivityOnInteraction); + document.removeEventListener('scroll', updateActivityOnInteraction); + updateActivityOnInteraction = null; + } + } catch (err) { + // Ignore errors during cleanup } - } catch (err) { - // Ignore errors during cleanup - } - - // Clean up interval - try { - if (expiryCheckInterval) { - clearInterval(expiryCheckInterval); - expiryCheckInterval = null; + + // Clean up interval + try { + if (expiryCheckInterval) { + clearInterval(expiryCheckInterval); + expiryCheckInterval = null; + } + } catch (err) { + // Ignore errors during cleanup } - } catch (err) { - // Ignore errors during cleanup - } - }); + }); + } function toggleMobileMenu() { if (typeof window === 'undefined') return; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 323f655..80be5c4 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -174,29 +174,32 @@ // No need for redundant checks here }); - onDestroy(() => { - // Mark component as unmounted first - isMounted = false; - - // Clean up event listeners - try { - if (handlePendingTransfersEvent) { - window.removeEventListener('pendingTransfers', handlePendingTransfersEvent); - handlePendingTransfersEvent = null; + // Only register onDestroy on client side to prevent SSR errors + if (typeof window !== 'undefined') { + onDestroy(() => { + // Mark component as unmounted first + isMounted = false; + + // Clean up event listeners + try { + if (handlePendingTransfersEvent) { + window.removeEventListener('pendingTransfers', handlePendingTransfersEvent); + handlePendingTransfersEvent = null; + } + } catch (err) { + // Ignore errors during cleanup } - } catch (err) { - // Ignore errors during cleanup - } - - try { - if (handleThemeChanged) { - window.removeEventListener('themeChanged', handleThemeChanged); - handleThemeChanged = null; + + try { + if (handleThemeChanged) { + window.removeEventListener('themeChanged', handleThemeChanged); + handleThemeChanged = null; + } + } catch (err) { + // Ignore errors during cleanup } - } catch (err) { - // Ignore errors during cleanup - } - }); + }); + } async function checkUserLevel() { // Only run client-side diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index af9ae71..4afca37 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -87,19 +87,22 @@ } }); - onDestroy(() => { - // Mark component as unmounted first - isMounted = false; - - // Re-enable scrolling when component is destroyed - try { - if (typeof document !== 'undefined' && document.body) { - document.body.style.overflow = ''; + // Only register onDestroy on client side to prevent SSR errors + if (typeof window !== 'undefined') { + onDestroy(() => { + // Mark component as unmounted first + isMounted = false; + + // Re-enable scrolling when component is destroyed + try { + if (document.body) { + document.body.style.overflow = ''; + } + } catch (err) { + // Ignore errors during cleanup } - } catch (err) { - // Ignore errors during cleanup - } - }); + }); + } async function checkAuth() { if (!isMounted || typeof window === 'undefined') return; diff --git a/src/routes/api/repos/[npub]/[repo]/download/+server.ts b/src/routes/api/repos/[npub]/[repo]/download/+server.ts index a1cb928..a0ee199 100644 --- a/src/routes/api/repos/[npub]/[repo]/download/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/download/+server.ts @@ -1,20 +1,20 @@ /** - * API endpoint for downloading repository as ZIP + * API endpoint for downloading repository as ZIP or TAR.GZ + * Refactored for better error handling and reliability */ -import { error, json } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; -import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js'; +import { fileManager, nostrClient } from '$lib/services/service-registry.js'; import { createRepoGetHandler } from '$lib/utils/api-handlers.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import { spawn } from 'child_process'; -import { mkdir, rm, readFile } from 'fs/promises'; +import { mkdir, rm, readFile, readdir } from 'fs/promises'; import { join, resolve } from 'path'; import logger from '$lib/services/logger.js'; import { isValidBranchName, sanitizeError } from '$lib/utils/security.js'; import simpleGit from 'simple-git'; -import { handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js'; -import { KIND } from '$lib/types/nostr.js'; +import { handleNotFoundError } from '$lib/utils/error-handler.js'; import { existsSync } from 'fs'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; import { eventCache } from '$lib/services/nostr/event-cache.js'; @@ -24,306 +24,471 @@ const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT ? process.env.GIT_REPO_ROOT : '/repos'; -export const GET: RequestHandler = createRepoGetHandler( - async (context: RepoRequestContext, event: RequestEvent) => { - const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); - let useTempClone = false; - let tempClonePath: string | null = null; - - // If repo doesn't exist, try to do a temporary clone - if (!existsSync(repoPath)) { - try { - // Fetch repository announcement (case-insensitive) with caching - const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); - const announcement = findRepoAnnouncement(allEvents, context.repo); - - if (announcement) { - // Try to do a temporary clone for download - logger.info({ npub: context.npub, repo: context.repo }, 'Repository not cloned locally, attempting temporary clone for download'); - - const tempDir = resolve(join(repoRoot, '..', 'temp-clones')); - await mkdir(tempDir, { recursive: true }); - tempClonePath = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}.git`); - - // Extract clone URLs and prepare remote URLs - const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js'); - const cloneUrls = extractCloneUrls(announcement); - const { RepoUrlParser } = await import('$lib/services/git/repo-url-parser.js'); - const urlParser = new RepoUrlParser(repoRoot, 'gitrepublic.com'); - const remoteUrls = urlParser.prepareRemoteUrls(cloneUrls); - - if (remoteUrls.length > 0) { - const { GitRemoteSync } = await import('$lib/services/git/git-remote-sync.js'); - const remoteSync = new GitRemoteSync(repoRoot, 'gitrepublic.com'); - const gitEnv = remoteSync.getGitEnvForUrl(remoteUrls[0]); - const authenticatedUrl = remoteSync.injectAuthToken(remoteUrls[0]); - - const { GIT_CLONE_TIMEOUT_MS } = await import('$lib/config.js'); - - await new Promise((resolve, reject) => { - const cloneProcess = spawn('git', ['clone', '--bare', authenticatedUrl, tempClonePath!], { - env: gitEnv, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - const timeoutId = setTimeout(() => { - cloneProcess.kill('SIGTERM'); - const forceKillTimeout = setTimeout(() => { - if (!cloneProcess.killed) { - cloneProcess.kill('SIGKILL'); - } - }, 5000); - cloneProcess.on('close', () => { - clearTimeout(forceKillTimeout); - }); - reject(new Error(`Git clone operation timed out after ${GIT_CLONE_TIMEOUT_MS}ms`)); - }, GIT_CLONE_TIMEOUT_MS); - - let stderr = ''; - cloneProcess.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - cloneProcess.on('close', (code) => { - clearTimeout(timeoutId); - if (code === 0) { - logger.info({ npub: context.npub, repo: context.repo, tempPath: tempClonePath }, 'Successfully created temporary clone'); - useTempClone = true; - resolve(); - } else { - reject(new Error(`Git clone failed with code ${code}: ${stderr}`)); - } - }); - cloneProcess.on('error', reject); - }); - } else { - throw new Error('No remote clone URLs available'); +interface TempCloneResult { + path: string; + cleanup: () => Promise; +} + +/** + * Attempts to create a temporary clone of a repository for download + */ +async function createTempClone( + context: RepoRequestContext, + repoPath: string +): Promise { + // Check if repo exists now (might have been created by concurrent request) + if (existsSync(repoPath)) { + return null; + } + + try { + // Fetch repository announcement + const allEvents = await fetchRepoAnnouncementsWithCache( + nostrClient, + context.repoOwnerPubkey, + eventCache + ); + const announcement = findRepoAnnouncement(allEvents, context.repo); + + if (!announcement) { + logger.debug({ npub: context.npub, repo: context.repo }, 'No announcement found for temp clone'); + return null; + } + + logger.info({ npub: context.npub, repo: context.repo }, 'Creating temporary clone for download'); + + // Setup temp clone directory + const tempDir = resolve(join(repoRoot, '..', 'temp-clones')); + await mkdir(tempDir, { recursive: true }); + const tempClonePath = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}.git`); + + // Extract and prepare clone URLs + const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js'); + const cloneUrls = extractCloneUrls(announcement); + const { RepoUrlParser } = await import('$lib/services/git/repo-url-parser.js'); + const urlParser = new RepoUrlParser(repoRoot, 'gitrepublic.com'); + const remoteUrls = urlParser.prepareRemoteUrls(cloneUrls); + + if (remoteUrls.length === 0) { + logger.warn({ npub: context.npub, repo: context.repo }, 'No remote clone URLs available'); + return null; + } + + // Setup git remote sync + const { GitRemoteSync } = await import('$lib/services/git/git-remote-sync.js'); + const remoteSync = new GitRemoteSync(repoRoot, 'gitrepublic.com'); + const gitEnv = remoteSync.getGitEnvForUrl(remoteUrls[0]); + const authenticatedUrl = remoteSync.injectAuthToken(remoteUrls[0]); + + // Clone the repository + const { GIT_CLONE_TIMEOUT_MS } = await import('$lib/config.js'); + await cloneRepository(authenticatedUrl, tempClonePath, gitEnv, GIT_CLONE_TIMEOUT_MS); + + logger.info({ npub: context.npub, repo: context.repo, tempPath: tempClonePath }, 'Temporary clone created successfully'); + + return { + path: tempClonePath, + cleanup: async () => { + try { + if (existsSync(tempClonePath)) { + await rm(tempClonePath, { recursive: true, force: true }); + logger.debug({ tempPath: tempClonePath }, 'Cleaned up temporary clone'); } - } else { - throw handleNotFoundError( - 'Repository announcement not found in Nostr', - { operation: 'download', npub: context.npub, repo: context.repo } - ); - } - } catch (err) { - // Clean up temp clone if it was created - if (tempClonePath && existsSync(tempClonePath)) { - await rm(tempClonePath, { recursive: true, force: true }).catch(() => {}); + } catch (cleanupErr) { + logger.warn({ error: cleanupErr, tempPath: tempClonePath }, 'Failed to clean up temp clone'); } - - // Check if repo was created by another concurrent request - if (existsSync(repoPath)) { - // Repo exists now, clear cache and continue with normal flow - repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); - } else { - // If fetching fails, return 404 - throw handleNotFoundError( - err instanceof Error ? err.message : 'Repository not found', - { operation: 'download', npub: context.npub, repo: context.repo } - ); + } + }; + } catch (err) { + logger.error({ error: err, npub: context.npub, repo: context.repo }, 'Failed to create temporary clone'); + return null; + } +} + +/** + * Clones a repository with timeout and proper error handling + */ +function cloneRepository( + url: string, + targetPath: string, + env: Record, + timeoutMs: number +): Promise { + return new Promise((resolve, reject) => { + const cloneProcess = spawn('git', ['clone', '--bare', url, targetPath], { + env: { ...process.env, ...env }, + stdio: ['ignore', 'pipe', 'pipe'] + }); + + const timeoutId = setTimeout(() => { + cloneProcess.kill('SIGTERM'); + const forceKillTimeout = setTimeout(() => { + if (!cloneProcess.killed) { + cloneProcess.kill('SIGKILL'); } + }, 5000); + cloneProcess.on('close', () => { + clearTimeout(forceKillTimeout); + }); + reject(new Error(`Git clone operation timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + let stderr = ''; + cloneProcess.stderr.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + cloneProcess.on('close', (code) => { + clearTimeout(timeoutId); + if (code === 0) { + resolve(); + } else { + reject(new Error(`Git clone failed with code ${code}: ${stderr.trim() || 'Unknown error'}`)); } + }); + + cloneProcess.on('error', (err) => { + clearTimeout(timeoutId); + reject(err); + }); + }); +} + +/** + * Validates and resolves the ref (branch, tag, or commit) + */ +async function resolveRef( + context: RepoRequestContext, + ref: string +): Promise { + // HEAD is always valid + if (ref === 'HEAD' || ref.startsWith('refs/')) { + return ref; + } + + // Commit hash (40-character hex) is valid + if (/^[0-9a-f]{40}$/i.test(ref)) { + return ref; + } + + // Security: Validate ref format + if (!isValidBranchName(ref)) { + throw error(400, `Invalid ref format: ${ref}`); + } + + // Check if it's a tag + try { + const tags = await fileManager.getTags(context.npub, context.repo); + if (tags && Array.isArray(tags) && tags.some(t => t.name === ref)) { + logger.debug({ ref, npub: context.npub, repo: context.repo }, 'Resolved ref as tag'); + return ref; // Tags are valid refs } + } catch (tagErr) { + logger.warn({ error: tagErr, ref, npub: context.npub, repo: context.repo }, 'Could not fetch tags, checking branches'); + // Continue to check branches - don't fail here + } - // Use temp clone path if we created one, otherwise use regular repo path - const sourceRepoPath = useTempClone && tempClonePath ? tempClonePath : repoPath; - - // Double-check source repo exists - if (!existsSync(sourceRepoPath)) { - throw handleNotFoundError( - 'Repository not found', - { operation: 'download', npub: context.npub, repo: context.repo } - ); + // Check if it's a branch + try { + const branches = await fileManager.getBranches(context.npub, context.repo); + if (branches.includes(ref)) { + return ref; } + // Branch doesn't exist, use default branch + const defaultBranch = await fileManager.getDefaultBranch(context.npub, context.repo); + logger.debug({ requestedRef: ref, defaultBranch }, 'Requested branch not found, using default'); + return defaultBranch; + } catch (branchErr) { + logger.warn({ error: branchErr, ref }, 'Could not fetch branches, falling back to HEAD'); + return 'HEAD'; + } +} + +/** + * Creates a ZIP archive + */ +function createZipArchive(workDir: string, archivePath: string): Promise { + const absoluteArchivePath = resolve(archivePath); + const archiveDir = join(absoluteArchivePath, '..'); + + return mkdir(archiveDir, { recursive: true }).then(() => { + return new Promise((resolve, reject) => { + const zipProcess = spawn('zip', ['-r', absoluteArchivePath, '.'], { + cwd: workDir, + stdio: ['ignore', 'pipe', 'pipe'] + }); - let ref = event.url.searchParams.get('ref') || 'HEAD'; - const format = event.url.searchParams.get('format') || 'zip'; // zip or tar.gz + let stdout = ''; + let stderr = ''; + zipProcess.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + zipProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - // If ref is a branch name, validate it exists or use default branch - if (ref !== 'HEAD' && !ref.startsWith('refs/')) { - // Check if ref is a commit hash (40-character hex string) - const isCommitHash = /^[0-9a-f]{40}$/i.test(ref); - - if (isCommitHash) { - // Commit hash is valid, use it directly - // Git will validate the commit exists when we try to use it - } else { - // Security: Validate ref to prevent command injection - if (!isValidBranchName(ref)) { - throw error(400, 'Invalid ref format'); + zipProcess.on('close', async (code) => { + if (code === 0) { + // Verify archive was created + try { + const { access } = await import('fs/promises'); + await access(absoluteArchivePath); + resolve(); + } catch { + reject(new Error(`Archive file was not created at ${absoluteArchivePath}`)); + } + } else { + const errorMsg = (stderr || stdout || 'Unknown error').trim(); + reject(new Error(`zip failed with code ${code}: ${errorMsg}`)); } - - // Check if it's a tag first (tags are also valid refs) - let isTag = false; - try { - const tags = await fileManager.getTags(context.npub, context.repo); - isTag = tags.some(t => t.name === ref); - } catch { - // If we can't get tags, continue with branch check + }); + + zipProcess.on('error', (err) => { + if (err.message.includes('ENOENT') || (err as any).code === 'ENOENT') { + reject(new Error('zip command not found. Please install zip utility (e.g., apt-get install zip)')); + } else { + reject(err); } + }); + }); + }); +} + +/** + * Creates a TAR.GZ archive + */ +function createTarGzArchive(workDir: string, archivePath: string): Promise { + return new Promise((resolve, reject) => { + const tarProcess = spawn('tar', ['-czf', archivePath, '-C', workDir, '.'], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + + let stderr = ''; + tarProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + + tarProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`tar failed with code ${code}: ${stderr.trim() || 'Unknown error'}`)); + } + }); + + tarProcess.on('error', reject); + }); +} + +/** + * Main download handler + */ +export const GET: RequestHandler = createRepoGetHandler( + async (context: RepoRequestContext, event: RequestEvent) => { + const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); + let tempClone: TempCloneResult | null = null; + let workDir: string | null = null; + let archivePath: string | null = null; + + try { + // Determine source repository path + let sourceRepoPath = repoPath; + + if (!existsSync(repoPath)) { + // Try to create temporary clone + tempClone = await createTempClone(context, repoPath); - if (!isTag) { - // Not a tag, validate branch exists or use default - try { - const branches = await fileManager.getBranches(context.npub, context.repo); - if (!branches.includes(ref)) { - // Branch doesn't exist, use default branch - ref = await fileManager.getDefaultBranch(context.npub, context.repo); - } - } catch { - // If we can't get branches, fall back to HEAD - ref = 'HEAD'; + if (tempClone) { + sourceRepoPath = tempClone.path; + } else if (!existsSync(repoPath)) { + // Check again if repo was created by concurrent request + if (existsSync(repoPath)) { + sourceRepoPath = repoPath; + repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); + } else { + throw handleNotFoundError( + 'Repository not found', + { operation: 'download', npub: context.npub, repo: context.repo } + ); } } - // If it's a tag, use it directly (git accepts tag names as refs) } - } - // Security: Validate format - if (format !== 'zip' && format !== 'tar.gz') { - throw error(400, 'Invalid format. Must be "zip" or "tar.gz"'); - } - // Security: Ensure resolved path is within repoRoot - const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/'); - const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); - if (!resolvedRepoPath.startsWith(resolvedRoot + '/')) { - throw error(403, 'Invalid repository path'); - } + // Verify source repo exists + if (!existsSync(sourceRepoPath)) { + throw handleNotFoundError( + 'Repository not found', + { operation: 'download', npub: context.npub, repo: context.repo } + ); + } - const tempDir = join(repoRoot, '..', 'temp-downloads'); - const workDir = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}`); - // Security: Ensure workDir is within tempDir - const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); - const resolvedTempDir = resolve(tempDir).replace(/\\/g, '/'); - if (!resolvedWorkDir.startsWith(resolvedTempDir + '/')) { - throw error(500, 'Invalid work directory path'); - } + // Parse and validate request parameters + let ref = event.url.searchParams.get('ref') || 'HEAD'; + const format = event.url.searchParams.get('format') || 'zip'; - const archiveName = `${context.repo}-${ref}.${format === 'tar.gz' ? 'tar.gz' : 'zip'}`; - const archivePath = join(tempDir, archiveName); - // Security: Ensure archive path is within tempDir - const resolvedArchivePath = resolve(archivePath).replace(/\\/g, '/'); - if (!resolvedArchivePath.startsWith(resolvedTempDir + '/')) { - throw error(500, 'Invalid archive path'); - } + if (format !== 'zip' && format !== 'tar.gz') { + throw error(400, 'Invalid format. Must be "zip" or "tar.gz"'); + } - try { - // Create temp directory using fs/promises (safer than shell commands) + // Resolve ref (branch, tag, or commit) + ref = await resolveRef(context, ref); + + // Security: Validate paths + const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); + if (!resolvedRepoPath.startsWith(resolvedRoot + '/')) { + throw error(403, 'Invalid repository path'); + } + + // Setup temporary directories + const tempDir = resolve(join(repoRoot, '..', 'temp-downloads')); await mkdir(tempDir, { recursive: true }); + + workDir = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}`); await mkdir(workDir, { recursive: true }); - // Clone repository using simple-git (safer than shell commands) + // Security: Validate workDir path + const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); + const resolvedTempDir = resolve(tempDir).replace(/\\/g, '/'); + if (!resolvedWorkDir.startsWith(resolvedTempDir + '/')) { + throw error(500, 'Invalid work directory path'); + } + + const archiveName = `${context.repo}-${ref.replace(/[^a-zA-Z0-9._-]/g, '_')}.${format === 'tar.gz' ? 'tar.gz' : 'zip'}`; + archivePath = join(tempDir, archiveName); + + // Security: Validate archive path + const resolvedArchivePath = resolve(archivePath).replace(/\\/g, '/'); + if (!resolvedArchivePath.startsWith(resolvedTempDir + '/')) { + throw error(500, 'Invalid archive path'); + } + + // Clone repository to work directory + logger.debug({ sourceRepoPath, workDir, ref }, 'Cloning repository for archive'); const git = simpleGit(); await git.clone(sourceRepoPath, workDir); - // Checkout specific ref if not HEAD + // Checkout specific ref if (ref !== 'HEAD') { const workGit = simpleGit(workDir); - await workGit.checkout(ref); + let checkoutSuccess = false; + + // Try direct checkout first + try { + await workGit.checkout(ref); + checkoutSuccess = true; + logger.debug({ ref }, 'Successfully checked out ref directly'); + } catch (checkoutErr) { + logger.debug({ error: checkoutErr, ref }, 'Direct checkout failed, trying as tag'); + } + + // If direct checkout failed, try as tag + if (!checkoutSuccess) { + try { + await workGit.checkout(`refs/tags/${ref}`); + checkoutSuccess = true; + logger.debug({ ref }, 'Successfully checked out ref as tag'); + } catch (tagErr) { + // Try as branch + try { + await workGit.checkout(`refs/heads/${ref}`); + checkoutSuccess = true; + logger.debug({ ref }, 'Successfully checked out ref as branch'); + } catch (branchErr) { + // Last resort: try to fetch the ref from remote + try { + await workGit.fetch(sourceRepoPath, ref); + await workGit.checkout(ref); + checkoutSuccess = true; + logger.debug({ ref }, 'Successfully checked out ref after fetch'); + } catch (fetchErr) { + const errorMsg = `Failed to checkout ref "${ref}". Tried as direct ref, tag, and branch. Last error: ${fetchErr instanceof Error ? fetchErr.message : String(fetchErr)}`; + logger.error({ error: fetchErr, ref, npub: context.npub, repo: context.repo }, errorMsg); + throw new Error(errorMsg); + } + } + } + } + + if (!checkoutSuccess) { + throw new Error(`Failed to checkout ref "${ref}" after all attempts`); + } } - // Remove .git directory using fs/promises + // Remove .git directory await rm(join(workDir, '.git'), { recursive: true, force: true }); - // Verify workDir has content before archiving - const { readdir } = await import('fs/promises'); + // Verify work directory has content const workDirContents = await readdir(workDir); if (workDirContents.length === 0) { throw new Error('Repository work directory is empty, cannot create archive'); } - // Create archive using spawn (safer than exec) + // Create archive + logger.debug({ format, archivePath }, 'Creating archive'); if (format === 'tar.gz') { - await new Promise((resolve, reject) => { - const tarProcess = spawn('tar', ['-czf', archivePath, '-C', workDir, '.'], { - stdio: ['ignore', 'pipe', 'pipe'] - }); - let stderr = ''; - tarProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - tarProcess.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`tar failed: ${stderr}`)); - } - }); - tarProcess.on('error', reject); - }); + await createTarGzArchive(workDir, archivePath); } else { - // Use zip command (requires zip utility) - using spawn for safety - // Make archive path absolute for zip command - const absoluteArchivePath = resolve(archivePath); - - // Ensure the archive directory exists - const archiveDir = join(absoluteArchivePath, '..'); - await mkdir(archiveDir, { recursive: true }); - - await new Promise((resolve, reject) => { - const zipProcess = spawn('zip', ['-r', absoluteArchivePath, '.'], { - cwd: workDir, - stdio: ['ignore', 'pipe', 'pipe'] - }); - let stdout = ''; - let stderr = ''; - zipProcess.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); - zipProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); - zipProcess.on('close', async (code) => { - if (code === 0) { - // Verify archive was created - try { - const fs = await import('fs/promises'); - await fs.access(absoluteArchivePath); - resolve(); - } catch { - reject(new Error(`zip command succeeded but archive file was not created at ${absoluteArchivePath}`)); - } - } else { - const errorMsg = (stderr || stdout || 'Unknown error').trim(); - reject(new Error(`zip failed with code ${code}: ${errorMsg || 'No error message'}`)); - } - }); - zipProcess.on('error', (err) => { - // If zip command doesn't exist, provide helpful error - if (err.message.includes('ENOENT') || (err as any).code === 'ENOENT') { - reject(new Error('zip command not found. Please install zip utility (e.g., apt-get install zip or brew install zip)')); - } else { - reject(err); - } - }); - }); + await createZipArchive(workDir, archivePath); } - // Read archive file using fs/promises + // Read archive file const archiveBuffer = await readFile(archivePath); - // Clean up using fs/promises - await rm(workDir, { recursive: true, force: true }).catch(() => {}); - await rm(archivePath, { force: true }).catch(() => {}); - // Clean up temp clone if we created one - if (useTempClone && tempClonePath && existsSync(tempClonePath)) { - await rm(tempClonePath, { recursive: true, force: true }).catch(() => {}); + // Clean up temporary files + if (workDir) { + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + } + if (archivePath) { + await rm(archivePath, { force: true }).catch(() => {}); + } + if (tempClone) { + await tempClone.cleanup(); } // Return archive + logger.info({ npub: context.npub, repo: context.repo, ref, format, size: archiveBuffer.length }, 'Download completed successfully'); + return new Response(archiveBuffer, { headers: { 'Content-Type': format === 'tar.gz' ? 'application/gzip' : 'application/zip', 'Content-Disposition': `attachment; filename="${archiveName}"`, - 'Content-Length': archiveBuffer.length.toString() + 'Content-Length': archiveBuffer.length.toString(), + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0' } }); - } catch (archiveError) { - // Clean up on error using fs/promises - await rm(workDir, { recursive: true, force: true }).catch(() => {}); - await rm(archivePath, { force: true }).catch(() => {}); - // Clean up temp clone if we created one - if (useTempClone && tempClonePath && existsSync(tempClonePath)) { - await rm(tempClonePath, { recursive: true, force: true }).catch(() => {}); + } catch (err) { + // Clean up on error + const cleanupPromises: Promise[] = []; + + if (workDir) { + cleanupPromises.push(rm(workDir, { recursive: true, force: true }).catch(() => {})); + } + if (archivePath) { + cleanupPromises.push(rm(archivePath, { force: true }).catch(() => {})); + } + if (tempClone) { + cleanupPromises.push(tempClone.cleanup()); } - const sanitizedError = sanitizeError(archiveError); - logger.error({ error: sanitizedError, npub: context.npub, repo: context.repo, ref, format }, 'Error creating archive'); - throw archiveError; + + await Promise.all(cleanupPromises); + + // Log error + const sanitizedError = sanitizeError(err); + logger.error( + { error: sanitizedError, npub: context.npub, repo: context.repo, ref: event.url.searchParams.get('ref') }, + 'Error creating archive' + ); + + // Re-throw if it's already a Response (from error handlers) + if (err instanceof Response) { + throw err; + } + + // Re-throw if it's a SvelteKit error + if (err && typeof err === 'object' && 'status' in err && 'body' in err) { + throw err; + } + + // Wrap other errors + throw error(500, `Failed to create archive: ${err instanceof Error ? err.message : String(err)}`); } }, - { operation: 'download', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos + { operation: 'download', requireRepoExists: false, requireRepoAccess: true } ); diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte b/src/routes/repos/[npub]/[repo]/+page.svelte index c2cb820..fdc90ce 100644 --- a/src/routes/repos/[npub]/[repo]/+page.svelte +++ b/src/routes/repos/[npub]/[repo]/+page.svelte @@ -9,6 +9,9 @@ import RepoHeaderEnhanced from '$lib/components/RepoHeaderEnhanced.svelte'; import TabsMenu from '$lib/components/TabsMenu.svelte'; import NostrLinkRenderer from '$lib/components/NostrLinkRenderer.svelte'; + import TagsTab from './components/TagsTab.svelte'; + import { downloadRepository as downloadRepoUtil } from './utils/download.js'; + import { buildApiHeaders } from './utils/api-client.js'; import '$lib/styles/repo.css'; import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; @@ -102,9 +105,58 @@ .map((t: string[]) => t[1]) .filter((t: string) => t && typeof t === 'string') as string[] || []); const repoWebsite = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'website')?.[1]); - const repoIsPrivate = $derived(repoAnnouncement?.tags.some((t: string[]) => + const repoIsPrivate = $derived(repoAnnouncement?.tags.some((t: string[]) => (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private') ) || false); + + // Safe page URL for SSR - computed from pageData or current URL + // Must be completely SSR-safe to prevent "Cannot read properties of null" errors + const pageUrl = $derived.by(() => { + try { + // First try pageData (safest) + if (pageData && typeof pageData === 'object' && pageData.repoUrl) { + const url = pageData.repoUrl; + if (typeof url === 'string' && url.trim()) { + return url; + } + } + + // During SSR, return empty string immediately + if (typeof window === 'undefined') { + return ''; + } + + // On client, try to get from current location as fallback + try { + if (window && window.location && window.location.protocol && window.location.host && window.location.pathname) { + return `${window.location.protocol}//${window.location.host}${window.location.pathname}`; + } + } catch (err) { + // Silently ignore errors during SSR or if window.location is unavailable + console.debug('Could not get page URL from window.location:', err); + } + + return ''; + } catch (err) { + // Catch any unexpected errors and return empty string + console.debug('Error computing pageUrl:', err); + return ''; + } + }); + + // Safe Twitter card type - avoid IIFE in head during SSR + const twitterCardType = $derived.by(() => { + try { + const banner = (pageData?.banner || repoBanner) || (pageData?.image || repoImage); + if (banner && typeof banner === 'string' && banner.trim()) { + return "summary_large_image"; + } + return "summary"; + } catch { + return "summary"; + } + }); + let loading = $state(true); let error = $state(null); @@ -406,6 +458,10 @@ // This ensures we use the same domain/port the user is currently viewing // Guard against SSR - $page store can only be accessed in component context if (typeof window === 'undefined') return; + // Guard against SSR - $page.url might not be available + if (typeof window === 'undefined' || !$page?.url) { + return ''; + } const currentUrl = $page.url; const host = currentUrl.host; // Includes port if present (e.g., "localhost:5173") const protocol = currentUrl.protocol.slice(0, -1); // Remove trailing ":" @@ -985,6 +1041,95 @@ // Repository images let repoImage = $state(null); let repoBanner = $state(null); + + // Safe values for head section to prevent SSR errors (must be after repoImage/repoBanner declaration) + const safeRepo = $derived(repo || 'Repository'); + const safeRepoName = $derived.by(() => { + try { + return repoName || repo || 'Repository'; + } catch { + return repo || 'Repository'; + } + }); + const safeRepoDescription = $derived.by(() => { + try { + return repoDescription || ''; + } catch { + return ''; + } + }); + const safeTitle = $derived.by(() => { + try { + return pageData?.title || `${safeRepo} - Repository`; + } catch { + return `${safeRepo} - Repository`; + } + }); + const safeDescription = $derived.by(() => { + try { + return pageData?.description || `Repository: ${safeRepo}`; + } catch { + return `Repository: ${safeRepo}`; + } + }); + const safeImage = $derived.by(() => { + try { + return pageData?.image || repoImage || null; + } catch { + return null; + } + }); + const safeBanner = $derived.by(() => { + try { + return pageData?.banner || repoBanner || null; + } catch { + return null; + } + }); + const hasImage = $derived.by(() => { + try { + return safeImage && typeof safeImage === 'string' && safeImage.trim() !== ''; + } catch { + return false; + } + }); + const hasBanner = $derived.by(() => { + try { + return safeBanner && typeof safeBanner === 'string' && safeBanner.trim() !== ''; + } catch { + return false; + } + }); + + // Additional safe values for head section to avoid IIFEs + const safeOgDescription = $derived.by(() => { + try { + return pageData?.description || safeRepoDescription || `Repository: ${safeRepoName || safeRepo || 'Repository'}`; + } catch { + return 'Repository'; + } + }); + const safeTwitterDescription = $derived.by(() => { + try { + return pageData?.description || safeRepoDescription || `Repository: ${safeRepoName || safeRepo || 'Repository'}`; + } catch { + return 'Repository'; + } + }); + const safeTwitterCard = $derived.by(() => { + try { + return twitterCardType || 'summary'; + } catch { + return 'summary'; + } + }); + const safePageUrl = $derived.by(() => { + try { + return pageUrl || ''; + } catch { + return ''; + } + }); // Repository owner pubkey (decoded from npub) - kept for backward compatibility with some functions let repoOwnerPubkeyState = $state(null); @@ -1577,13 +1722,19 @@ } async function checkCloneStatus(force: boolean = false) { - if (checkingCloneStatus || (!force && isRepoCloned !== null)) return; + if (checkingCloneStatus) return; + if (!force && isRepoCloned !== null) { + console.log(`[Clone Status] Skipping check - already checked: ${isRepoCloned}, force: ${force}`); + return; + } checkingCloneStatus = true; try { // Check if repo exists locally by trying to fetch branches // 404 = repo not cloned, 403 = repo exists but access denied (cloned), 200 = cloned and accessible - const response = await fetch(`/api/repos/${npub}/${repo}/branches`, { + const url = `/api/repos/${npub}/${repo}/branches`; + console.log(`[Clone Status] Checking clone status for ${npub}/${repo}...`); + const response = await fetch(url, { headers: buildApiHeaders() }); // If response is 403, repo exists (cloned) but user doesn't have access @@ -2456,40 +2607,34 @@ }); }); - // Cleanup on destroy - onDestroy(() => { - // Mark component as unmounted first to prevent any state updates - isMounted = false; - - // Clean up intervals and timeouts - try { - if (autoSaveInterval) { - clearInterval(autoSaveInterval); - autoSaveInterval = null; - } - } catch (err) { - // Ignore errors during cleanup - } - - try { - if (readmeAutoLoadTimeout) { - clearTimeout(readmeAutoLoadTimeout); - readmeAutoLoadTimeout = null; - } - } catch (err) { - // Ignore errors during cleanup - } - - // Clean up event listeners - try { - if (clickOutsideHandler) { - document.removeEventListener('click', clickOutsideHandler); - clickOutsideHandler = null; + // Cleanup on destroy - only register on client side to prevent SSR errors + if (typeof window !== 'undefined') { + onDestroy(() => { + try { + // Mark component as unmounted first to prevent any state updates + isMounted = false; + + // Clean up intervals and timeouts + if (autoSaveInterval) { + clearInterval(autoSaveInterval); + autoSaveInterval = null; + } + + if (readmeAutoLoadTimeout) { + clearTimeout(readmeAutoLoadTimeout); + readmeAutoLoadTimeout = null; + } + + // Clean up event listeners + if (clickOutsideHandler && typeof document !== 'undefined') { + document.removeEventListener('click', clickOutsideHandler); + clickOutsideHandler = null; + } + } catch (err) { + // Ignore all errors during cleanup - component is being destroyed anyway } - } catch (err) { - // Ignore errors - listener may not exist or already removed - } - }); + }); + } async function checkAuth() { // Check userStore first @@ -2989,19 +3134,77 @@ URL.revokeObjectURL(url); } - // Helper function to build headers with user pubkey - function buildApiHeaders(): Record { - const headers: Record = {}; - // Use $userStore directly to ensure we get the latest value - const currentUserPubkeyHex = $userStore.userPubkeyHex || userPubkeyHex; - if (currentUserPubkeyHex) { - headers['X-User-Pubkey'] = currentUserPubkeyHex; - // Debug logging (remove in production) - console.debug('[API Headers] Sending X-User-Pubkey:', currentUserPubkeyHex.substring(0, 16) + '...'); - } else { - console.debug('[API Headers] No user pubkey available, sending request without X-User-Pubkey header'); + // buildApiHeaders is now imported from utils/api-client.ts - using it directly + + // Safe wrapper functions for SSR - use function declarations that check at call time + // This ensures they're always defined and never null, even during SSR + function safeCopyCloneUrl() { + if (typeof window === 'undefined') return Promise.resolve(); + try { + return copyCloneUrl(); + } catch (err) { + console.warn('Error in copyCloneUrl:', err); + return Promise.resolve(); + } + } + + function safeDeleteBranch(branchName: string) { + if (typeof window === 'undefined') return Promise.resolve(); + try { + return deleteBranch(branchName); + } catch (err) { + console.warn('Error in deleteBranch:', err); + return Promise.resolve(); + } + } + + function safeToggleBookmark() { + if (typeof window === 'undefined') return Promise.resolve(); + try { + return toggleBookmark(); + } catch (err) { + console.warn('Error in toggleBookmark:', err); + return Promise.resolve(); + } + } + + function safeForkRepository() { + if (typeof window === 'undefined') return Promise.resolve(); + try { + return forkRepository(); + } catch (err) { + console.warn('Error in forkRepository:', err); + return Promise.resolve(); + } + } + + function safeCloneRepository() { + if (typeof window === 'undefined') return Promise.resolve(); + try { + return cloneRepository(); + } catch (err) { + console.warn('Error in cloneRepository:', err); + return Promise.resolve(); + } + } + + function safeHandleBranchChange(branch: string) { + if (typeof window === 'undefined') return; + try { + handleBranchChangeDirect(branch); + } catch (err) { + console.warn('Error in handleBranchChangeDirect:', err); } - return headers; + } + + // Download function - now using extracted utility + async function downloadRepository(ref?: string, filename?: string): Promise { + await downloadRepoUtil({ + npub, + repo, + ref, + filename + }); } async function loadBranches() { @@ -4879,77 +5082,79 @@ - {pageData.title || `${repo} - Repository`} - + {safeTitle || 'Repository'} + - - - - {#if (pageData.image || repoImage) && String(pageData.image || repoImage).trim()} - + + + + {#if hasImage && safeImage} + {/if} - {#if (pageData.banner || repoBanner) && String(pageData.banner || repoBanner).trim()} + {#if hasBanner && safeBanner} {/if} - - - - {#if pageData.banner || repoBanner} - - {:else if pageData.image || repoImage} - + + + + {#if hasBanner && safeBanner} + + {:else if hasImage && safeImage} + {/if}
- {#if repoBanner} + {#if repoBanner && typeof repoBanner === 'string' && repoBanner.trim()}
{ - console.error('[Repo Images] Failed to load banner:', repoBanner); - const target = e.target as HTMLImageElement; - if (target) target.style.display = 'none'; + if (typeof window !== 'undefined') { + console.error('[Repo Images] Failed to load banner:', repoBanner); + const target = e.target as HTMLImageElement; + if (target) target.style.display = 'none'; + } }} />
{/if} {#if repoOwnerPubkeyDerived} showRepoMenu = !showRepoMenu} - showMenu={showRepoMenu} - userPubkey={userPubkey} - isBookmarked={isBookmarked} - loadingBookmark={loadingBookmark} - onToggleBookmark={toggleBookmark} - onFork={forkRepository} - forking={forking} - onCloneToServer={cloneRepository} - cloning={cloning} - checkingCloneStatus={checkingCloneStatus} - onCreateIssue={() => showCreateIssueDialog = true} - onCreatePR={() => showCreatePRDialog = true} - onCreatePatch={() => showCreatePatchDialog = true} + repoName={repoName || ''} + repoDescription={repoDescription || ''} + ownerNpub={npub || ''} + ownerPubkey={repoOwnerPubkeyDerived || ''} + isMaintainer={isMaintainer || false} + isPrivate={repoIsPrivate || false} + cloneUrls={repoCloneUrls || []} + branches={branches || []} + currentBranch={currentBranch || null} + topics={repoTopics || []} + defaultBranch={defaultBranch || null} + isRepoCloned={isRepoCloned || false} + copyingCloneUrl={copyingCloneUrl || false} + onBranchChange={safeHandleBranchChange} + onCopyCloneUrl={safeCopyCloneUrl} + onDeleteBranch={safeDeleteBranch} + onMenuToggle={() => { if (typeof showRepoMenu !== 'undefined') showRepoMenu = !showRepoMenu; }} + showMenu={showRepoMenu || false} + userPubkey={userPubkey || null} + isBookmarked={isBookmarked || false} + loadingBookmark={loadingBookmark || false} + onToggleBookmark={safeToggleBookmark} + onFork={safeForkRepository} + forking={forking || false} + onCloneToServer={safeCloneRepository} + cloning={cloning || false} + checkingCloneStatus={checkingCloneStatus || false} + onCreateIssue={() => { if (typeof showCreateIssueDialog !== 'undefined') showCreateIssueDialog = true; }} + onCreatePR={() => { if (typeof showCreatePRDialog !== 'undefined') showCreatePRDialog = true; }} + onCreatePatch={() => { if (typeof showCreatePatchDialog !== 'undefined') showCreatePatchDialog = true; }} onCreateBranch={async () => { if (!userPubkey || !isMaintainer || needsClone) return; try { @@ -5300,66 +5505,34 @@ {/if} - {#if activeTab === 'tags' && canViewRepo} - - {/if} + selectedTag = tagName} + onTabChange={(tab) => activeTab = tab as typeof activeTab} + onToggleMobilePanel={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} + onCreateTag={() => showCreateTagDialog = true} + onCreateRelease={(tagName, tagHash) => { + newReleaseTagName = tagName; + newReleaseTagHash = tagHash; + showCreateReleaseDialog = true; + }} + onLoadTags={loadTags} + /> {#if activeTab === 'code-search' && canViewRepo} @@ -5644,7 +5817,16 @@ {/if} View Raw - Download ZIP +
{/if} - {#if activeTab === 'tags'} -
-
- -
- {#if selectedTag} - {@const tag = tags.find(t => t.name === selectedTag)} - {@const release = releases.find(r => r.tagName === selectedTag)} - {#if tag} -
-
-

{tag.name}

-
- Tag: {tag.hash?.slice(0, 7) || 'N/A'} - {#if tag.date} - Created {new Date(tag.date * 1000).toLocaleString()} - {/if} - - Download - Download ZIP - - {#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned && !release} - - {/if} -
-
- {#if tag.message} -
-

{tag.message}

-
- {/if} - {#if release} -
-

Release

-
- {#if release.isDraft} - Draft - {/if} - {#if release.isPrerelease} - Pre-release - {/if} -
- Released {new Date(release.created_at * 1000).toLocaleDateString()} -
- {#if release.releaseNotes} -
- {@html release.releaseNotes.replace(/\n/g, '
')} -
- {/if} -
-
- {/if} -
- {/if} - {:else} -
-

Select a tag from the sidebar to view details

-
- {/if} -
- {/if} + {#if activeTab === 'code-search' && canViewRepo} diff --git a/src/routes/repos/[npub]/[repo]/components/TagsTab.svelte b/src/routes/repos/[npub]/[repo]/components/TagsTab.svelte new file mode 100644 index 0000000..c983baf --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/components/TagsTab.svelte @@ -0,0 +1,418 @@ + + +{#if activeTab === 'tags' && canViewRepo} + +{/if} + +{#if activeTab === 'tags'} +
+
+ +
+ {#if selectedTag} + {@const tag = tags.find(t => t.name === selectedTag)} + {@const release = releases.find(r => r.tagName === selectedTag)} + {#if tag} +
+
+

{tag.name}

+
+ Tag: {tag.hash?.slice(0, 7) || 'N/A'} + {#if tag.date} + Created {new Date(tag.date * 1000).toLocaleString()} + {/if} + + {#if downloadError} +
+ {downloadError} +
+ {/if} + {#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned && !release} + + {/if} +
+
+ {#if tag.message} +
+

{tag.message}

+
+ {/if} + {#if release} +
+

Release

+
+ {#if release.isDraft} + Draft + {/if} + {#if release.isPrerelease} + Pre-release + {/if} +
+ Released {new Date(release.created_at * 1000).toLocaleDateString()} +
+ {#if release.releaseNotes} +
+ {@html release.releaseNotes.replace(/\n/g, '
')} +
+ {/if} +
+
+ {/if} +
+ {/if} + {:else} +
+

Select a tag from the sidebar to view details

+
+ {/if} +
+{/if} + + diff --git a/src/routes/repos/[npub]/[repo]/utils/api-client.ts b/src/routes/repos/[npub]/[repo]/utils/api-client.ts new file mode 100644 index 0000000..141a743 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/api-client.ts @@ -0,0 +1,111 @@ +/** + * API client utilities for repository operations + * Provides centralized API call functions with error handling and logging + */ + +import { get } from 'svelte/store'; +import { userStore } from '$lib/stores/user-store.js'; +import logger from '$lib/services/logger.js'; + +/** + * Builds API headers with user pubkey for authenticated requests + */ +export function buildApiHeaders(): Record { + const headers: Record = {}; + const currentUser = get(userStore); + const currentUserPubkeyHex = currentUser?.userPubkeyHex; + if (currentUserPubkeyHex) { + headers['X-User-Pubkey'] = currentUserPubkeyHex; + logger.debug({ pubkey: currentUserPubkeyHex.substring(0, 16) + '...' }, '[API] Sending X-User-Pubkey header'); + } + return headers; +} + +/** + * Makes an API request with error handling and logging + */ +export async function apiRequest( + url: string, + options: RequestInit = {} +): Promise { + const headers = { + ...buildApiHeaders(), + ...options.headers, + 'Content-Type': 'application/json', + }; + + logger.debug({ url, method: options.method || 'GET' }, '[API] Making request'); + + try { + const response = await fetch(url, { + ...options, + headers, + credentials: 'same-origin', + }); + + if (!response.ok) { + let errorMessage = `API request failed: ${response.status} ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData.message) { + errorMessage = errorData.message; + } else if (errorData.error) { + errorMessage = errorData.error; + } + } catch { + try { + const text = await response.text(); + if (text) { + errorMessage = text.substring(0, 200); + } + } catch { + // Ignore parsing errors + } + } + logger.error({ url, status: response.status, error: errorMessage }, '[API] Request failed'); + throw new Error(errorMessage); + } + + const data = await response.json(); + logger.debug({ url }, '[API] Request successful'); + return data as T; + } catch (err) { + logger.error({ url, error: err }, '[API] Request error'); + throw err; + } +} + +/** + * Makes a POST request + */ +export async function apiPost( + url: string, + body: unknown +): Promise { + return apiRequest(url, { + method: 'POST', + body: JSON.stringify(body), + }); +} + +/** + * Makes a PUT request + */ +export async function apiPut( + url: string, + body: unknown +): Promise { + return apiRequest(url, { + method: 'PUT', + body: JSON.stringify(body), + }); +} + +/** + * Makes a DELETE request + */ +export async function apiDelete(url: string): Promise { + return apiRequest(url, { + method: 'DELETE', + }); +} diff --git a/src/routes/repos/[npub]/[repo]/utils/download.ts b/src/routes/repos/[npub]/[repo]/utils/download.ts new file mode 100644 index 0000000..1649a76 --- /dev/null +++ b/src/routes/repos/[npub]/[repo]/utils/download.ts @@ -0,0 +1,234 @@ +/** + * Download utility for repository downloads + * Handles downloading repository archives with proper error handling and logging + */ + +import { get } from 'svelte/store'; +import { userStore } from '$lib/stores/user-store.js'; +import logger from '$lib/services/logger.js'; + +interface DownloadOptions { + npub: string; + repo: string; + ref?: string; + filename?: string; +} + +let isDownloading = false; + +/** + * Builds API headers with user pubkey for authenticated requests + */ +function buildApiHeaders(): Record { + const headers: Record = {}; + const currentUser = get(userStore); + const currentUserPubkeyHex = currentUser?.userPubkeyHex; + if (currentUserPubkeyHex) { + headers['X-User-Pubkey'] = currentUserPubkeyHex; + logger.debug({ pubkey: currentUserPubkeyHex.substring(0, 16) + '...' }, '[Download] Sending X-User-Pubkey header'); + } else { + logger.debug('[Download] No user pubkey available, sending request without X-User-Pubkey header'); + } + return headers; +} + +/** + * Downloads a repository archive (ZIP or TAR.GZ) + * @param options Download options including npub, repo, ref, and filename + * @returns Promise that resolves when download is initiated + */ +export async function downloadRepository(options: DownloadOptions): Promise { + const { npub, repo, ref, filename } = options; + + if (typeof window === 'undefined') { + logger.warn('[Download] Attempted download in SSR context'); + return; + } + + // Prevent multiple simultaneous downloads + if (isDownloading) { + logger.debug('[Download] Download already in progress, skipping...'); + return; + } + isDownloading = true; + + // Prevent page navigation during download + const preventReloadHandler = (e: BeforeUnloadEvent) => { + if (!isDownloading) { + return; + } + e.preventDefault(); + e.returnValue = ''; + return ''; + }; + + window.addEventListener('beforeunload', preventReloadHandler); + + try { + // Build download URL + const params = new URLSearchParams(); + if (ref) { + params.set('ref', ref); + } + params.set('format', 'zip'); + const downloadUrl = `/api/repos/${npub}/${repo}/download?${params.toString()}`; + + logger.info({ url: downloadUrl, ref }, '[Download] Starting download'); + + // Fetch with proper headers + const response = await fetch(downloadUrl, { + method: 'GET', + credentials: 'same-origin', + headers: buildApiHeaders() + }); + + logger.debug({ status: response.status, statusText: response.statusText }, '[Download] Response received'); + + if (!response.ok) { + // Try to get error message from response + let errorMessage = `Download failed: ${response.status} ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData.message) { + errorMessage = errorData.message; + } else if (errorData.error) { + errorMessage = errorData.error; + } + } catch { + // If response is not JSON, use status text + try { + const text = await response.text(); + if (text) { + errorMessage = text.substring(0, 200); // Limit length + } + } catch { + // Ignore text parsing errors + } + } + logger.error({ error: errorMessage, status: response.status }, '[Download] Download failed'); + throw new Error(errorMessage); + } + + // Check content type + const contentType = response.headers.get('content-type'); + if (!contentType || (!contentType.includes('zip') && !contentType.includes('octet-stream'))) { + logger.warn({ contentType }, '[Download] Unexpected content type'); + } + + logger.debug('[Download] Converting to blob...'); + const blob = await response.blob(); + logger.debug({ size: blob.size }, '[Download] Blob created'); + + if (blob.size === 0) { + throw new Error('Downloaded file is empty'); + } + + // Use File System Access API if available (most reliable, no navigation) + const downloadFileName = filename || `${repo}${ref ? `-${ref}` : ''}.zip`; + + if ('showSaveFilePicker' in window) { + try { + // @ts-ignore - File System Access API + const fileHandle = await window.showSaveFilePicker({ + suggestedName: downloadFileName, + types: [{ + description: 'ZIP files', + accept: { 'application/zip': ['.zip'] } + }] + }); + + const writable = await fileHandle.createWritable(); + await writable.write(blob); + await writable.close(); + + logger.info('[Download] File saved using File System Access API'); + return; // Success, exit early - no navigation possible + } catch (saveErr: any) { + // User cancelled or API not fully supported + if (saveErr.name === 'AbortError') { + logger.debug('[Download] User cancelled file save'); + return; + } + logger.debug({ error: saveErr }, '[Download] File System Access API failed, using fallback'); + } + } + + // Use direct link method (more reliable, works with CSP) + const url = window.URL.createObjectURL(blob); + logger.debug('[Download] Created blob URL, using direct link method'); + + // Create a temporary link element and trigger download + const link = document.createElement('a'); + link.href = url; + link.download = downloadFileName; + link.style.display = 'none'; + link.setAttribute('download', downloadFileName); // Ensure download attribute is set + + // Append to body temporarily + document.body.appendChild(link); + + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + try { + // Trigger click + link.click(); + logger.debug('[Download] Download triggered via direct link'); + + // Clean up after a short delay + setTimeout(() => { + try { + if (link.parentNode) { + document.body.removeChild(link); + } + // Revoke blob URL after a delay to ensure download started + setTimeout(() => { + window.URL.revokeObjectURL(url); + logger.debug('[Download] Cleaned up link and blob URL'); + }, 1000); + } catch (cleanupErr) { + logger.error({ error: cleanupErr }, '[Download] Cleanup error'); + // Still try to revoke the URL + try { + window.URL.revokeObjectURL(url); + } catch (revokeErr) { + logger.error({ error: revokeErr }, '[Download] Failed to revoke blob URL'); + } + } + }, 100); + } catch (clickErr) { + logger.error({ error: clickErr }, '[Download] Error triggering download'); + // Clean up on error + try { + if (link.parentNode) { + document.body.removeChild(link); + } + window.URL.revokeObjectURL(url); + } catch (cleanupErr) { + logger.error({ error: cleanupErr }, '[Download] Cleanup error after click failure'); + } + throw new Error('Failed to trigger download'); + } + }); + + logger.info('[Download] Download initiated successfully'); + } catch (err) { + logger.error({ error: err, npub, repo, ref }, '[Download] Download error'); + const errorMessage = err instanceof Error ? err.message : String(err); + // Show user-friendly error message + alert(`Download failed: ${errorMessage}`); + // Don't re-throw - handle error gracefully to prevent page navigation issues + } finally { + // Remove beforeunload listener + try { + window.removeEventListener('beforeunload', preventReloadHandler); + } catch (removeErr) { + logger.warn({ error: removeErr }, '[Download] Error removing beforeunload listener'); + } + + // Reset download flag after a delay + setTimeout(() => { + isDownloading = false; + }, 3000); // Longer delay to ensure download completed + } + +}