Browse Source

fix page crash on download

Nostr-Signature: eafa232557affbacb430b467507febc201f0a8f54f4b9ecf57e315c32e51a589 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 53c58aabe0bfad6e432a8bb980c2046fc14bc8163825fde2ac766a449ce4418adb1049ac732c7fc7ecc7ad050539fb68c023d54f2b6c390e478616b5c0b91a31
main
Silberengel 3 weeks ago
parent
commit
056191026d
  1. 1
      nostr/commit-signatures.jsonl
  2. 51
      src/lib/components/NavBar.svelte
  3. 45
      src/routes/+layout.svelte
  4. 27
      src/routes/+page.svelte
  5. 673
      src/routes/api/repos/[npub]/[repo]/download/+server.ts
  6. 581
      src/routes/repos/[npub]/[repo]/+page.svelte
  7. 418
      src/routes/repos/[npub]/[repo]/components/TagsTab.svelte
  8. 111
      src/routes/repos/[npub]/[repo]/utils/api-client.ts
  9. 234
      src/routes/repos/[npub]/[repo]/utils/download.ts

1
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":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":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":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"}

51
src/lib/components/NavBar.svelte

@ -123,32 +123,35 @@
}, 5 * 60 * 1000); // Check every 5 minutes }, 5 * 60 * 1000); // Check every 5 minutes
}); });
onDestroy(() => { // Only register onDestroy on client side to prevent SSR errors
// Mark component as unmounted first if (typeof window !== 'undefined') {
isMounted = false; onDestroy(() => {
// Mark component as unmounted first
// Clean up event listeners isMounted = false;
try {
if (updateActivityOnInteraction) { // Clean up event listeners
document.removeEventListener('click', updateActivityOnInteraction); try {
document.removeEventListener('keydown', updateActivityOnInteraction); if (updateActivityOnInteraction) {
document.removeEventListener('scroll', updateActivityOnInteraction); document.removeEventListener('click', updateActivityOnInteraction);
updateActivityOnInteraction = null; 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) {
// Clean up interval clearInterval(expiryCheckInterval);
try { expiryCheckInterval = null;
if (expiryCheckInterval) { }
clearInterval(expiryCheckInterval); } catch (err) {
expiryCheckInterval = null; // Ignore errors during cleanup
} }
} catch (err) { });
// Ignore errors during cleanup }
}
});
function toggleMobileMenu() { function toggleMobileMenu() {
if (typeof window === 'undefined') return; if (typeof window === 'undefined') return;

45
src/routes/+layout.svelte

@ -174,29 +174,32 @@
// No need for redundant checks here // No need for redundant checks here
}); });
onDestroy(() => { // Only register onDestroy on client side to prevent SSR errors
// Mark component as unmounted first if (typeof window !== 'undefined') {
isMounted = false; onDestroy(() => {
// Mark component as unmounted first
// Clean up event listeners isMounted = false;
try {
if (handlePendingTransfersEvent) { // Clean up event listeners
window.removeEventListener('pendingTransfers', handlePendingTransfersEvent); try {
handlePendingTransfersEvent = null; 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);
try { handleThemeChanged = null;
if (handleThemeChanged) { }
window.removeEventListener('themeChanged', handleThemeChanged); } catch (err) {
handleThemeChanged = null; // Ignore errors during cleanup
} }
} catch (err) { });
// Ignore errors during cleanup }
}
});
async function checkUserLevel() { async function checkUserLevel() {
// Only run client-side // Only run client-side

27
src/routes/+page.svelte

@ -87,19 +87,22 @@
} }
}); });
onDestroy(() => { // Only register onDestroy on client side to prevent SSR errors
// Mark component as unmounted first if (typeof window !== 'undefined') {
isMounted = false; onDestroy(() => {
// Mark component as unmounted first
// Re-enable scrolling when component is destroyed isMounted = false;
try {
if (typeof document !== 'undefined' && document.body) { // Re-enable scrolling when component is destroyed
document.body.style.overflow = ''; try {
if (document.body) {
document.body.style.overflow = '';
}
} catch (err) {
// Ignore errors during cleanup
} }
} catch (err) { });
// Ignore errors during cleanup }
}
});
async function checkAuth() { async function checkAuth() {
if (!isMounted || typeof window === 'undefined') return; if (!isMounted || typeof window === 'undefined') return;

673
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 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 { createRepoGetHandler } from '$lib/utils/api-handlers.js';
import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js'; import type { RepoRequestContext, RequestEvent } from '$lib/utils/api-context.js';
import { spawn } from 'child_process'; 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 { join, resolve } from 'path';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { isValidBranchName, sanitizeError } from '$lib/utils/security.js'; import { isValidBranchName, sanitizeError } from '$lib/utils/security.js';
import simpleGit from 'simple-git'; import simpleGit from 'simple-git';
import { handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js'; import { handleNotFoundError } from '$lib/utils/error-handler.js';
import { KIND } from '$lib/types/nostr.js';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
import { eventCache } from '$lib/services/nostr/event-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 ? process.env.GIT_REPO_ROOT
: '/repos'; : '/repos';
export const GET: RequestHandler = createRepoGetHandler( interface TempCloneResult {
async (context: RepoRequestContext, event: RequestEvent) => { path: string;
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); cleanup: () => Promise<void>;
let useTempClone = false; }
let tempClonePath: string | null = null;
/**
// If repo doesn't exist, try to do a temporary clone * Attempts to create a temporary clone of a repository for download
if (!existsSync(repoPath)) { */
try { async function createTempClone(
// Fetch repository announcement (case-insensitive) with caching context: RepoRequestContext,
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); repoPath: string
const announcement = findRepoAnnouncement(allEvents, context.repo); ): Promise<TempCloneResult | null> {
// Check if repo exists now (might have been created by concurrent request)
if (announcement) { if (existsSync(repoPath)) {
// Try to do a temporary clone for download return null;
logger.info({ npub: context.npub, repo: context.repo }, 'Repository not cloned locally, attempting temporary clone for download'); }
const tempDir = resolve(join(repoRoot, '..', 'temp-clones')); try {
await mkdir(tempDir, { recursive: true }); // Fetch repository announcement
tempClonePath = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}.git`); const allEvents = await fetchRepoAnnouncementsWithCache(
nostrClient,
// Extract clone URLs and prepare remote URLs context.repoOwnerPubkey,
const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js'); eventCache
const cloneUrls = extractCloneUrls(announcement); );
const { RepoUrlParser } = await import('$lib/services/git/repo-url-parser.js'); const announcement = findRepoAnnouncement(allEvents, context.repo);
const urlParser = new RepoUrlParser(repoRoot, 'gitrepublic.com');
const remoteUrls = urlParser.prepareRemoteUrls(cloneUrls); if (!announcement) {
logger.debug({ npub: context.npub, repo: context.repo }, 'No announcement found for temp clone');
if (remoteUrls.length > 0) { return null;
const { GitRemoteSync } = await import('$lib/services/git/git-remote-sync.js'); }
const remoteSync = new GitRemoteSync(repoRoot, 'gitrepublic.com');
const gitEnv = remoteSync.getGitEnvForUrl(remoteUrls[0]); logger.info({ npub: context.npub, repo: context.repo }, 'Creating temporary clone for download');
const authenticatedUrl = remoteSync.injectAuthToken(remoteUrls[0]);
// Setup temp clone directory
const { GIT_CLONE_TIMEOUT_MS } = await import('$lib/config.js'); const tempDir = resolve(join(repoRoot, '..', 'temp-clones'));
await mkdir(tempDir, { recursive: true });
await new Promise<void>((resolve, reject) => { const tempClonePath = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}.git`);
const cloneProcess = spawn('git', ['clone', '--bare', authenticatedUrl, tempClonePath!], {
env: gitEnv, // Extract and prepare clone URLs
stdio: ['ignore', 'pipe', 'pipe'] 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 timeoutId = setTimeout(() => { const urlParser = new RepoUrlParser(repoRoot, 'gitrepublic.com');
cloneProcess.kill('SIGTERM'); const remoteUrls = urlParser.prepareRemoteUrls(cloneUrls);
const forceKillTimeout = setTimeout(() => {
if (!cloneProcess.killed) { if (remoteUrls.length === 0) {
cloneProcess.kill('SIGKILL'); logger.warn({ npub: context.npub, repo: context.repo }, 'No remote clone URLs available');
} return null;
}, 5000); }
cloneProcess.on('close', () => {
clearTimeout(forceKillTimeout); // Setup git remote sync
}); const { GitRemoteSync } = await import('$lib/services/git/git-remote-sync.js');
reject(new Error(`Git clone operation timed out after ${GIT_CLONE_TIMEOUT_MS}ms`)); const remoteSync = new GitRemoteSync(repoRoot, 'gitrepublic.com');
}, GIT_CLONE_TIMEOUT_MS); const gitEnv = remoteSync.getGitEnvForUrl(remoteUrls[0]);
const authenticatedUrl = remoteSync.injectAuthToken(remoteUrls[0]);
let stderr = '';
cloneProcess.stderr.on('data', (chunk: Buffer) => { // Clone the repository
stderr += chunk.toString(); const { GIT_CLONE_TIMEOUT_MS } = await import('$lib/config.js');
}); await cloneRepository(authenticatedUrl, tempClonePath, gitEnv, GIT_CLONE_TIMEOUT_MS);
cloneProcess.on('close', (code) => { logger.info({ npub: context.npub, repo: context.repo, tempPath: tempClonePath }, 'Temporary clone created successfully');
clearTimeout(timeoutId);
if (code === 0) { return {
logger.info({ npub: context.npub, repo: context.repo, tempPath: tempClonePath }, 'Successfully created temporary clone'); path: tempClonePath,
useTempClone = true; cleanup: async () => {
resolve(); try {
} else { if (existsSync(tempClonePath)) {
reject(new Error(`Git clone failed with code ${code}: ${stderr}`)); await rm(tempClonePath, { recursive: true, force: true });
} logger.debug({ tempPath: tempClonePath }, 'Cleaned up temporary clone');
});
cloneProcess.on('error', reject);
});
} else {
throw new Error('No remote clone URLs available');
} }
} else { } catch (cleanupErr) {
throw handleNotFoundError( logger.warn({ error: cleanupErr, tempPath: tempClonePath }, 'Failed to clean up temp clone');
'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(() => {});
} }
}
// Check if repo was created by another concurrent request };
if (existsSync(repoPath)) { } catch (err) {
// Repo exists now, clear cache and continue with normal flow logger.error({ error: err, npub: context.npub, repo: context.repo }, 'Failed to create temporary clone');
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); return null;
} else { }
// If fetching fails, return 404 }
throw handleNotFoundError(
err instanceof Error ? err.message : 'Repository not found', /**
{ operation: 'download', npub: context.npub, repo: context.repo } * Clones a repository with timeout and proper error handling
); */
function cloneRepository(
url: string,
targetPath: string,
env: Record<string, string>,
timeoutMs: number
): Promise<void> {
return new Promise<void>((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<string> {
// 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 // Check if it's a branch
const sourceRepoPath = useTempClone && tempClonePath ? tempClonePath : repoPath; try {
const branches = await fileManager.getBranches(context.npub, context.repo);
// Double-check source repo exists if (branches.includes(ref)) {
if (!existsSync(sourceRepoPath)) { return ref;
throw handleNotFoundError(
'Repository not found',
{ operation: 'download', npub: context.npub, repo: context.repo }
);
} }
// 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<void> {
const absoluteArchivePath = resolve(archivePath);
const archiveDir = join(absoluteArchivePath, '..');
return mkdir(archiveDir, { recursive: true }).then(() => {
return new Promise<void>((resolve, reject) => {
const zipProcess = spawn('zip', ['-r', absoluteArchivePath, '.'], {
cwd: workDir,
stdio: ['ignore', 'pipe', 'pipe']
});
let ref = event.url.searchParams.get('ref') || 'HEAD'; let stdout = '';
const format = event.url.searchParams.get('format') || 'zip'; // zip or tar.gz 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 zipProcess.on('close', async (code) => {
if (ref !== 'HEAD' && !ref.startsWith('refs/')) { if (code === 0) {
// Check if ref is a commit hash (40-character hex string) // Verify archive was created
const isCommitHash = /^[0-9a-f]{40}$/i.test(ref); try {
const { access } = await import('fs/promises');
if (isCommitHash) { await access(absoluteArchivePath);
// Commit hash is valid, use it directly resolve();
// Git will validate the commit exists when we try to use it } catch {
} else { reject(new Error(`Archive file was not created at ${absoluteArchivePath}`));
// Security: Validate ref to prevent command injection }
if (!isValidBranchName(ref)) { } else {
throw error(400, 'Invalid ref format'); 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; zipProcess.on('error', (err) => {
try { if (err.message.includes('ENOENT') || (err as any).code === 'ENOENT') {
const tags = await fileManager.getTags(context.npub, context.repo); reject(new Error('zip command not found. Please install zip utility (e.g., apt-get install zip)'));
isTag = tags.some(t => t.name === ref); } else {
} catch { reject(err);
// If we can't get tags, continue with branch check
} }
});
});
});
}
/**
* Creates a TAR.GZ archive
*/
function createTarGzArchive(workDir: string, archivePath: string): Promise<void> {
return new Promise<void>((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) { if (tempClone) {
// Not a tag, validate branch exists or use default sourceRepoPath = tempClone.path;
try { } else if (!existsSync(repoPath)) {
const branches = await fileManager.getBranches(context.npub, context.repo); // Check again if repo was created by concurrent request
if (!branches.includes(ref)) { if (existsSync(repoPath)) {
// Branch doesn't exist, use default branch sourceRepoPath = repoPath;
ref = await fileManager.getDefaultBranch(context.npub, context.repo); repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo));
} } else {
} catch { throw handleNotFoundError(
// If we can't get branches, fall back to HEAD 'Repository not found',
ref = 'HEAD'; { 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 // Verify source repo exists
if (format !== 'zip' && format !== 'tar.gz') { if (!existsSync(sourceRepoPath)) {
throw error(400, 'Invalid format. Must be "zip" or "tar.gz"'); throw handleNotFoundError(
} 'Repository not found',
// Security: Ensure resolved path is within repoRoot { operation: 'download', npub: context.npub, repo: context.repo }
const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/'); );
const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); }
if (!resolvedRepoPath.startsWith(resolvedRoot + '/')) {
throw error(403, 'Invalid repository path');
}
const tempDir = join(repoRoot, '..', 'temp-downloads'); // Parse and validate request parameters
const workDir = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}`); let ref = event.url.searchParams.get('ref') || 'HEAD';
// Security: Ensure workDir is within tempDir const format = event.url.searchParams.get('format') || 'zip';
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}.${format === 'tar.gz' ? 'tar.gz' : 'zip'}`; if (format !== 'zip' && format !== 'tar.gz') {
const archivePath = join(tempDir, archiveName); throw error(400, 'Invalid format. Must be "zip" or "tar.gz"');
// Security: Ensure archive path is within tempDir }
const resolvedArchivePath = resolve(archivePath).replace(/\\/g, '/');
if (!resolvedArchivePath.startsWith(resolvedTempDir + '/')) {
throw error(500, 'Invalid archive path');
}
try { // Resolve ref (branch, tag, or commit)
// Create temp directory using fs/promises (safer than shell commands) 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 }); await mkdir(tempDir, { recursive: true });
workDir = join(tempDir, `${context.npub}-${context.repo}-${Date.now()}`);
await mkdir(workDir, { recursive: true }); 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(); const git = simpleGit();
await git.clone(sourceRepoPath, workDir); await git.clone(sourceRepoPath, workDir);
// Checkout specific ref if not HEAD // Checkout specific ref
if (ref !== 'HEAD') { if (ref !== 'HEAD') {
const workGit = simpleGit(workDir); 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 }); await rm(join(workDir, '.git'), { recursive: true, force: true });
// Verify workDir has content before archiving // Verify work directory has content
const { readdir } = await import('fs/promises');
const workDirContents = await readdir(workDir); const workDirContents = await readdir(workDir);
if (workDirContents.length === 0) { if (workDirContents.length === 0) {
throw new Error('Repository work directory is empty, cannot create archive'); 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') { if (format === 'tar.gz') {
await new Promise<void>((resolve, reject) => { await createTarGzArchive(workDir, archivePath);
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);
});
} else { } else {
// Use zip command (requires zip utility) - using spawn for safety await createZipArchive(workDir, archivePath);
// 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<void>((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);
}
});
});
} }
// Read archive file using fs/promises // Read archive file
const archiveBuffer = await readFile(archivePath); const archiveBuffer = await readFile(archivePath);
// Clean up using fs/promises // Clean up temporary files
await rm(workDir, { recursive: true, force: true }).catch(() => {}); if (workDir) {
await rm(archivePath, { force: true }).catch(() => {}); await rm(workDir, { recursive: true, force: true }).catch(() => {});
// Clean up temp clone if we created one }
if (useTempClone && tempClonePath && existsSync(tempClonePath)) { if (archivePath) {
await rm(tempClonePath, { recursive: true, force: true }).catch(() => {}); await rm(archivePath, { force: true }).catch(() => {});
}
if (tempClone) {
await tempClone.cleanup();
} }
// Return archive // Return archive
logger.info({ npub: context.npub, repo: context.repo, ref, format, size: archiveBuffer.length }, 'Download completed successfully');
return new Response(archiveBuffer, { return new Response(archiveBuffer, {
headers: { headers: {
'Content-Type': format === 'tar.gz' ? 'application/gzip' : 'application/zip', 'Content-Type': format === 'tar.gz' ? 'application/gzip' : 'application/zip',
'Content-Disposition': `attachment; filename="${archiveName}"`, '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) { } catch (err) {
// Clean up on error using fs/promises // Clean up on error
await rm(workDir, { recursive: true, force: true }).catch(() => {}); const cleanupPromises: Promise<void>[] = [];
await rm(archivePath, { force: true }).catch(() => {});
// Clean up temp clone if we created one if (workDir) {
if (useTempClone && tempClonePath && existsSync(tempClonePath)) { cleanupPromises.push(rm(workDir, { recursive: true, force: true }).catch(() => {}));
await rm(tempClonePath, { 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'); await Promise.all(cleanupPromises);
throw archiveError;
// 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 }
); );

581
src/routes/repos/[npub]/[repo]/+page.svelte

@ -9,6 +9,9 @@
import RepoHeaderEnhanced from '$lib/components/RepoHeaderEnhanced.svelte'; import RepoHeaderEnhanced from '$lib/components/RepoHeaderEnhanced.svelte';
import TabsMenu from '$lib/components/TabsMenu.svelte'; import TabsMenu from '$lib/components/TabsMenu.svelte';
import NostrLinkRenderer from '$lib/components/NostrLinkRenderer.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 '$lib/styles/repo.css';
import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { getPublicKeyWithNIP07, isNIP07Available, signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js';
@ -102,9 +105,58 @@
.map((t: string[]) => t[1]) .map((t: string[]) => t[1])
.filter((t: string) => t && typeof t === 'string') as string[] || []); .filter((t: string) => t && typeof t === 'string') as string[] || []);
const repoWebsite = $derived(repoAnnouncement?.tags.find((t: string[]) => t[0] === 'website')?.[1]); 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') (t[0] === 'private' && t[1] === 'true') || (t[0] === 't' && t[1] === 'private')
) || false); ) || 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 loading = $state(true);
let error = $state<string | null>(null); let error = $state<string | null>(null);
@ -406,6 +458,10 @@
// This ensures we use the same domain/port the user is currently viewing // 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 // Guard against SSR - $page store can only be accessed in component context
if (typeof window === 'undefined') return; 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 currentUrl = $page.url;
const host = currentUrl.host; // Includes port if present (e.g., "localhost:5173") const host = currentUrl.host; // Includes port if present (e.g., "localhost:5173")
const protocol = currentUrl.protocol.slice(0, -1); // Remove trailing ":" const protocol = currentUrl.protocol.slice(0, -1); // Remove trailing ":"
@ -985,6 +1041,95 @@
// Repository images // Repository images
let repoImage = $state<string | null>(null); let repoImage = $state<string | null>(null);
let repoBanner = $state<string | null>(null); let repoBanner = $state<string | null>(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 // Repository owner pubkey (decoded from npub) - kept for backward compatibility with some functions
let repoOwnerPubkeyState = $state<string | null>(null); let repoOwnerPubkeyState = $state<string | null>(null);
@ -1577,13 +1722,19 @@
} }
async function checkCloneStatus(force: boolean = false) { 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; checkingCloneStatus = true;
try { try {
// Check if repo exists locally by trying to fetch branches // 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 // 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() headers: buildApiHeaders()
}); });
// If response is 403, repo exists (cloned) but user doesn't have access // If response is 403, repo exists (cloned) but user doesn't have access
@ -2456,40 +2607,34 @@
}); });
}); });
// Cleanup on destroy // Cleanup on destroy - only register on client side to prevent SSR errors
onDestroy(() => { if (typeof window !== 'undefined') {
// Mark component as unmounted first to prevent any state updates onDestroy(() => {
isMounted = false; try {
// Mark component as unmounted first to prevent any state updates
// Clean up intervals and timeouts isMounted = false;
try {
if (autoSaveInterval) { // Clean up intervals and timeouts
clearInterval(autoSaveInterval); if (autoSaveInterval) {
autoSaveInterval = null; clearInterval(autoSaveInterval);
} autoSaveInterval = null;
} catch (err) { }
// Ignore errors during cleanup
} if (readmeAutoLoadTimeout) {
clearTimeout(readmeAutoLoadTimeout);
try { readmeAutoLoadTimeout = null;
if (readmeAutoLoadTimeout) { }
clearTimeout(readmeAutoLoadTimeout);
readmeAutoLoadTimeout = null; // Clean up event listeners
} if (clickOutsideHandler && typeof document !== 'undefined') {
} catch (err) { document.removeEventListener('click', clickOutsideHandler);
// Ignore errors during cleanup clickOutsideHandler = null;
} }
} catch (err) {
// Clean up event listeners // Ignore all errors during cleanup - component is being destroyed anyway
try {
if (clickOutsideHandler) {
document.removeEventListener('click', clickOutsideHandler);
clickOutsideHandler = null;
} }
} catch (err) { });
// Ignore errors - listener may not exist or already removed }
}
});
async function checkAuth() { async function checkAuth() {
// Check userStore first // Check userStore first
@ -2989,19 +3134,77 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
// Helper function to build headers with user pubkey // buildApiHeaders is now imported from utils/api-client.ts - using it directly
function buildApiHeaders(): Record<string, string> {
const headers: Record<string, string> = {}; // Safe wrapper functions for SSR - use function declarations that check at call time
// Use $userStore directly to ensure we get the latest value // This ensures they're always defined and never null, even during SSR
const currentUserPubkeyHex = $userStore.userPubkeyHex || userPubkeyHex; function safeCopyCloneUrl() {
if (currentUserPubkeyHex) { if (typeof window === 'undefined') return Promise.resolve();
headers['X-User-Pubkey'] = currentUserPubkeyHex; try {
// Debug logging (remove in production) return copyCloneUrl();
console.debug('[API Headers] Sending X-User-Pubkey:', currentUserPubkeyHex.substring(0, 16) + '...'); } catch (err) {
} else { console.warn('Error in copyCloneUrl:', err);
console.debug('[API Headers] No user pubkey available, sending request without X-User-Pubkey header'); 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<void> {
await downloadRepoUtil({
npub,
repo,
ref,
filename
});
} }
async function loadBranches() { async function loadBranches() {
@ -4879,77 +5082,79 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{pageData.title || `${repo} - Repository`}</title> <title>{safeTitle || 'Repository'}</title>
<meta name="description" content={pageData.description || `Repository: ${repo}`} /> <meta name="description" content={safeDescription || 'Repository'} />
<!-- OpenGraph / Facebook --> <!-- OpenGraph / Facebook -->
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:title" content={pageData.title || `${repoName} - Repository`} /> <meta property="og:title" content={safeTitle || 'Repository'} />
<meta property="og:description" content={pageData.description || repoDescription || `Repository: ${repoName}`} /> <meta property="og:description" content={safeOgDescription} />
<meta property="og:url" content={pageData.repoUrl || (typeof window !== 'undefined' ? `https://${$page.url.host}${$page.url.pathname}` : '')} /> <meta property="og:url" content={safePageUrl} />
{#if (pageData.image || repoImage) && String(pageData.image || repoImage).trim()} {#if hasImage && safeImage}
<meta property="og:image" content={pageData.image || repoImage} /> <meta property="og:image" content={safeImage} />
{/if} {/if}
{#if (pageData.banner || repoBanner) && String(pageData.banner || repoBanner).trim()} {#if hasBanner && safeBanner}
<meta property="og:image:width" content="1200" /> <meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" /> <meta property="og:image:height" content="630" />
{/if} {/if}
<!-- Twitter Card --> <!-- Twitter Card -->
<meta name="twitter:card" content={repoBanner || repoImage ? "summary_large_image" : "summary"} /> <meta name="twitter:card" content={safeTwitterCard} />
<meta name="twitter:title" content={pageData.title || `${repoName} - Repository`} /> <meta name="twitter:title" content={safeTitle || 'Repository'} />
<meta name="twitter:description" content={pageData.description || repoDescription || `Repository: ${repoName}`} /> <meta name="twitter:description" content={safeTwitterDescription} />
{#if pageData.banner || repoBanner} {#if hasBanner && safeBanner}
<meta name="twitter:image" content={pageData.banner || repoBanner} /> <meta name="twitter:image" content={safeBanner} />
{:else if pageData.image || repoImage} {:else if hasImage && safeImage}
<meta name="twitter:image" content={pageData.image || repoImage} /> <meta name="twitter:image" content={safeImage} />
{/if} {/if}
</svelte:head> </svelte:head>
<div class="container"> <div class="container">
<!-- Banner hidden on mobile, shown on desktop --> <!-- Banner hidden on mobile, shown on desktop -->
{#if repoBanner} {#if repoBanner && typeof repoBanner === 'string' && repoBanner.trim()}
<div class="repo-banner desktop-only"> <div class="repo-banner desktop-only">
<img src={repoBanner} alt="" onerror={(e) => { <img src={repoBanner} alt="" onerror={(e) => {
console.error('[Repo Images] Failed to load banner:', repoBanner); if (typeof window !== 'undefined') {
const target = e.target as HTMLImageElement; console.error('[Repo Images] Failed to load banner:', repoBanner);
if (target) target.style.display = 'none'; const target = e.target as HTMLImageElement;
if (target) target.style.display = 'none';
}
}} /> }} />
</div> </div>
{/if} {/if}
{#if repoOwnerPubkeyDerived} {#if repoOwnerPubkeyDerived}
<RepoHeaderEnhanced <RepoHeaderEnhanced
repoName={repoName} repoName={repoName || ''}
repoDescription={repoDescription} repoDescription={repoDescription || ''}
ownerNpub={npub} ownerNpub={npub || ''}
ownerPubkey={repoOwnerPubkeyDerived} ownerPubkey={repoOwnerPubkeyDerived || ''}
isMaintainer={isMaintainer} isMaintainer={isMaintainer || false}
isPrivate={repoIsPrivate} isPrivate={repoIsPrivate || false}
cloneUrls={repoCloneUrls} cloneUrls={repoCloneUrls || []}
branches={branches} branches={branches || []}
currentBranch={currentBranch} currentBranch={currentBranch || null}
topics={repoTopics} topics={repoTopics || []}
defaultBranch={defaultBranch} defaultBranch={defaultBranch || null}
isRepoCloned={isRepoCloned} isRepoCloned={isRepoCloned || false}
copyingCloneUrl={copyingCloneUrl} copyingCloneUrl={copyingCloneUrl || false}
onBranchChange={handleBranchChangeDirect} onBranchChange={safeHandleBranchChange}
onCopyCloneUrl={copyCloneUrl} onCopyCloneUrl={safeCopyCloneUrl}
onDeleteBranch={deleteBranch} onDeleteBranch={safeDeleteBranch}
onMenuToggle={() => showRepoMenu = !showRepoMenu} onMenuToggle={() => { if (typeof showRepoMenu !== 'undefined') showRepoMenu = !showRepoMenu; }}
showMenu={showRepoMenu} showMenu={showRepoMenu || false}
userPubkey={userPubkey} userPubkey={userPubkey || null}
isBookmarked={isBookmarked} isBookmarked={isBookmarked || false}
loadingBookmark={loadingBookmark} loadingBookmark={loadingBookmark || false}
onToggleBookmark={toggleBookmark} onToggleBookmark={safeToggleBookmark}
onFork={forkRepository} onFork={safeForkRepository}
forking={forking} forking={forking || false}
onCloneToServer={cloneRepository} onCloneToServer={safeCloneRepository}
cloning={cloning} cloning={cloning || false}
checkingCloneStatus={checkingCloneStatus} checkingCloneStatus={checkingCloneStatus || false}
onCreateIssue={() => showCreateIssueDialog = true} onCreateIssue={() => { if (typeof showCreateIssueDialog !== 'undefined') showCreateIssueDialog = true; }}
onCreatePR={() => showCreatePRDialog = true} onCreatePR={() => { if (typeof showCreatePRDialog !== 'undefined') showCreatePRDialog = true; }}
onCreatePatch={() => showCreatePatchDialog = true} onCreatePatch={() => { if (typeof showCreatePatchDialog !== 'undefined') showCreatePatchDialog = true; }}
onCreateBranch={async () => { onCreateBranch={async () => {
if (!userPubkey || !isMaintainer || needsClone) return; if (!userPubkey || !isMaintainer || needsClone) return;
try { try {
@ -5300,66 +5505,34 @@
{/if} {/if}
<!-- Tags View --> <!-- Tags View -->
{#if activeTab === 'tags' && canViewRepo} <TagsTab
<aside class="tags-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'tags'}> {npub}
<div class="tags-header"> {repo}
<TabsMenu {tags}
activeTab={activeTab} {releases}
{tabs} {selectedTag}
onTabChange={(tab) => activeTab = tab as typeof activeTab} {isMaintainer}
/> {userPubkeyHex}
<h2>Tags {#if isRepoCloned === false && canUseApiFallback}<span class="read-only-badge">Read-Only</span>{/if}</h2> repoOwnerPubkeyDerived={repoOwnerPubkeyDerived}
{#if userPubkey && isMaintainer} {isRepoCloned}
<button {canViewRepo}
onclick={() => { {canUseApiFallback}
if (!userPubkey || !isMaintainer || needsClone) return; {needsClone}
showCreateTagDialog = true; {cloneTooltip}
}} {activeTab}
class="create-tag-button" {tabs}
disabled={needsClone} {showLeftPanelOnMobile}
title={needsClone ? cloneTooltip : 'Create a new tag'} onTagSelect={(tagName) => selectedTag = tagName}
> onTabChange={(tab) => activeTab = tab as typeof activeTab}
<img src="/icons/plus.svg" alt="New Tag" class="icon" /> onToggleMobilePanel={() => showLeftPanelOnMobile = !showLeftPanelOnMobile}
</button> onCreateTag={() => showCreateTagDialog = true}
{/if} onCreateRelease={(tagName, tagHash) => {
<button newReleaseTagName = tagName;
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} newReleaseTagHash = tagHash;
class="mobile-toggle-button" showCreateReleaseDialog = true;
title="Show content" }}
> onLoadTags={loadTags}
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" /> />
</button>
</div>
{#if tags.length > 0}
<ul class="tag-list">
{#each tags as tag}
{@const tagHash = tag.hash || ''}
{#if tagHash}
<li class="tag-item" class:selected={selectedTag === tag.name}>
<button
onclick={() => selectedTag = tag.name}
class="tag-item-button"
>
<div class="tag-name">{tag.name}</div>
<div class="tag-hash">{tagHash.slice(0, 7)}</div>
{#if tag.date}
<div class="tag-date">{new Date(tag.date * 1000).toLocaleDateString()}</div>
{/if}
{#if releases.find(r => r.tagName === tag.name)}
<img src="/icons/package.svg" alt="Has release" class="tag-has-release-icon" title="This tag has a release" />
{/if}
</button>
</li>
{/if}
{/each}
</ul>
{:else}
<div class="empty-state">
<p>No tags found</p>
</div>
{/if}
</aside>
{/if}
<!-- Code Search View --> <!-- Code Search View -->
{#if activeTab === 'code-search' && canViewRepo} {#if activeTab === 'code-search' && canViewRepo}
@ -5644,7 +5817,16 @@
</button> </button>
{/if} {/if}
<a href={`/api/repos/${npub}/${repo}/raw?path=${readmePath}`} target="_blank" class="raw-link">View Raw</a> <a href={`/api/repos/${npub}/${repo}/raw?path=${readmePath}`} target="_blank" class="raw-link">View Raw</a>
<a href={`/api/repos/${npub}/${repo}/download?format=zip`} class="download-link">Download ZIP</a> <button
type="button"
class="download-link"
onclick={async (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
await downloadRepository();
}}
>Download ZIP</button>
<button <button
onclick={() => showFileListOnMobile = !showFileListOnMobile} onclick={() => showFileListOnMobile = !showFileListOnMobile}
class="mobile-toggle-button" class="mobile-toggle-button"
@ -5861,88 +6043,7 @@
</div> </div>
{/if} {/if}
{#if activeTab === 'tags'} <!-- Tags content is now handled by TagsTab component -->
<div class="tags-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'tags'}>
<div class="content-header-mobile">
<button
onclick={() => showLeftPanelOnMobile = !showLeftPanelOnMobile}
class="mobile-toggle-button"
title="Show list"
>
<img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" />
</button>
</div>
{#if selectedTag}
{@const tag = tags.find(t => t.name === selectedTag)}
{@const release = releases.find(r => r.tagName === selectedTag)}
{#if tag}
<div class="tag-detail">
<div class="tag-detail-header">
<h3>{tag.name}</h3>
<div class="tag-detail-meta">
<span>Tag: {tag.hash?.slice(0, 7) || 'N/A'}</span>
{#if tag.date}
<span class="tag-date">Created {new Date(tag.date * 1000).toLocaleString()}</span>
{/if}
<a
href={`/api/repos/${npub}/${repo}/download?ref=${tag.name}&format=zip`}
download={`${repo}-${tag.name}.zip`}
class="download-tag-button"
title="Download source code as ZIP"
>
<img src="/icons/download.svg" alt="Download" class="icon-inline" />
Download ZIP
</a>
{#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned && !release}
<button
onclick={() => {
newReleaseTagName = tag.name;
newReleaseTagHash = tag.hash || '';
showCreateReleaseDialog = true;
}}
class="release-tag-button"
title="Create a release for this tag"
>
Release this tag
</button>
{/if}
</div>
</div>
{#if tag.message}
<div class="tag-message">
<p>{tag.message}</p>
</div>
{/if}
{#if release}
<div class="tag-release-section">
<h4>Release</h4>
<div class="release-info">
{#if release.isDraft}
<span class="release-badge draft">Draft</span>
{/if}
{#if release.isPrerelease}
<span class="release-badge prerelease">Pre-release</span>
{/if}
<div class="release-meta">
<span>Released {new Date(release.created_at * 1000).toLocaleDateString()}</span>
</div>
{#if release.releaseNotes}
<div class="release-notes">
{@html release.releaseNotes.replace(/\n/g, '<br>')}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
{:else}
<div class="empty-state">
<p>Select a tag from the sidebar to view details</p>
</div>
{/if}
</div>
{/if}
{#if activeTab === 'code-search' && canViewRepo} {#if activeTab === 'code-search' && canViewRepo}

418
src/routes/repos/[npub]/[repo]/components/TagsTab.svelte

@ -0,0 +1,418 @@
<script lang="ts">
import { downloadRepository } from '../utils/download.js';
import { buildApiHeaders } from '../utils/api-client.js';
import logger from '$lib/services/logger.js';
import TabsMenu from '$lib/components/TabsMenu.svelte';
interface Props {
npub: string;
repo: string;
tags: Array<{ name: string; hash: string; message?: string; date?: number }>;
releases: Array<{
id: string;
tagName: string;
tagHash?: string;
releaseNotes?: string;
isDraft?: boolean;
isPrerelease?: boolean;
created_at: number;
pubkey: string;
}>;
selectedTag: string | null;
isMaintainer: boolean;
userPubkeyHex: string | null;
repoOwnerPubkeyDerived: string;
isRepoCloned: boolean | null;
canViewRepo: boolean;
canUseApiFallback: boolean;
needsClone: boolean;
cloneTooltip: string;
activeTab: string;
tabs: Array<{ id: string; label: string; icon?: string }>;
showLeftPanelOnMobile: boolean;
onTagSelect: (tagName: string) => void;
onTabChange: (tab: string) => void;
onToggleMobilePanel: () => void;
onCreateTag: () => void;
onCreateRelease: (tagName: string, tagHash: string) => void;
onLoadTags: () => Promise<void>;
}
let {
npub,
repo,
tags,
releases,
selectedTag,
isMaintainer,
userPubkeyHex,
repoOwnerPubkeyDerived,
isRepoCloned,
canViewRepo,
canUseApiFallback,
needsClone,
cloneTooltip,
activeTab,
tabs,
showLeftPanelOnMobile,
onTagSelect,
onTabChange,
onToggleMobilePanel,
onCreateTag,
onCreateRelease,
onLoadTags
}: Props = $props();
let loadingTags = $state(false);
let downloadError = $state<string | null>(null);
async function handleDownloadTag(tagName: string) {
downloadError = null;
try {
logger.info({ npub, repo, tag: tagName }, '[TagsTab] Starting tag download');
await downloadRepository({
npub,
repo,
ref: tagName,
filename: `${repo}-${tagName}.zip`
});
logger.info({ npub, repo, tag: tagName }, '[TagsTab] Tag download completed');
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to download tag';
logger.error({ error: err, npub, repo, tag: tagName }, '[TagsTab] Tag download failed');
downloadError = errorMessage;
// Show error to user
alert(`Download failed: ${errorMessage}`);
}
}
async function handleCreateTag() {
if (!userPubkeyHex) {
alert('Please connect your NIP-07 extension');
return;
}
onCreateTag();
}
function handleCreateRelease(tagName: string, tagHash: string) {
onCreateRelease(tagName, tagHash);
}
</script>
{#if activeTab === 'tags' && canViewRepo}
<aside class="tags-sidebar" class:hide-on-mobile={!showLeftPanelOnMobile && activeTab === 'tags'}>
<div class="tags-header">
<TabsMenu
{activeTab}
{tabs}
onTabChange={(tab) => onTabChange(tab as string)}
/>
<h2>Tags {#if isRepoCloned === false && canUseApiFallback}<span class="read-only-badge">Read-Only</span>{/if}</h2>
{#if userPubkeyHex && isMaintainer}
<button
onclick={handleCreateTag}
class="create-tag-button"
disabled={needsClone}
title={needsClone ? cloneTooltip : 'Create a new tag'}
>
<img src="/icons/plus.svg" alt="New Tag" class="icon" />
</button>
{/if}
<button
onclick={onToggleMobilePanel}
class="mobile-toggle-button"
title="Show content"
>
<img src="/icons/arrow-right.svg" alt="Show content" class="icon-inline" />
</button>
</div>
{#if loadingTags}
<div class="loading">Loading tags...</div>
{:else if tags.length > 0}
<ul class="tag-list">
{#each tags as tag}
{@const tagHash = tag.hash || ''}
{#if tagHash}
<li class="tag-item" class:selected={selectedTag === tag.name}>
<button
onclick={() => onTagSelect(tag.name)}
class="tag-item-button"
>
<div class="tag-name">{tag.name}</div>
<div class="tag-hash">{tagHash.slice(0, 7)}</div>
{#if tag.date}
<div class="tag-date">{new Date(tag.date * 1000).toLocaleDateString()}</div>
{/if}
{#if releases.find(r => r.tagName === tag.name)}
<img src="/icons/package.svg" alt="Has release" class="tag-has-release-icon" title="This tag has a release" />
{/if}
</button>
</li>
{/if}
{/each}
</ul>
{:else}
<div class="empty-state">
<p>No tags found</p>
</div>
{/if}
</aside>
{/if}
{#if activeTab === 'tags'}
<div class="tags-content" class:hide-on-mobile={showLeftPanelOnMobile && activeTab === 'tags'}>
<div class="content-header-mobile">
<button
onclick={onToggleMobilePanel}
class="mobile-toggle-button"
title="Show list"
>
<img src="/icons/arrow-right.svg" alt="Show list" class="icon-inline mobile-toggle-left" />
</button>
</div>
{#if selectedTag}
{@const tag = tags.find(t => t.name === selectedTag)}
{@const release = releases.find(r => r.tagName === selectedTag)}
{#if tag}
<div class="tag-detail">
<div class="tag-detail-header">
<h3>{tag.name}</h3>
<div class="tag-detail-meta">
<span>Tag: {tag.hash?.slice(0, 7) || 'N/A'}</span>
{#if tag.date}
<span class="tag-date">Created {new Date(tag.date * 1000).toLocaleString()}</span>
{/if}
<button
type="button"
class="download-tag-button"
title="Download source code as ZIP"
onclick={async (e) => {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
await handleDownloadTag(tag.name);
}}
>
<img src="/icons/download.svg" alt="Download" class="icon-inline" />
Download ZIP
</button>
{#if downloadError}
<div class="error-message" style="color: red; margin-top: 0.5rem;">
{downloadError}
</div>
{/if}
{#if (isMaintainer || userPubkeyHex === repoOwnerPubkeyDerived) && isRepoCloned && !release}
<button
onclick={() => handleCreateRelease(tag.name, tag.hash || '')}
class="release-tag-button"
title="Create a release for this tag"
>
Release this tag
</button>
{/if}
</div>
</div>
{#if tag.message}
<div class="tag-message">
<p>{tag.message}</p>
</div>
{/if}
{#if release}
<div class="tag-release-section">
<h4>Release</h4>
<div class="release-info">
{#if release.isDraft}
<span class="release-badge draft">Draft</span>
{/if}
{#if release.isPrerelease}
<span class="release-badge prerelease">Pre-release</span>
{/if}
<div class="release-meta">
<span>Released {new Date(release.created_at * 1000).toLocaleDateString()}</span>
</div>
{#if release.releaseNotes}
<div class="release-notes">
{@html release.releaseNotes.replace(/\n/g, '<br>')}
</div>
{/if}
</div>
</div>
{/if}
</div>
{/if}
{:else}
<div class="empty-state">
<p>Select a tag from the sidebar to view details</p>
</div>
{/if}
</div>
{/if}
<style>
.tags-sidebar {
width: 300px;
border-right: 1px solid var(--border-color);
overflow-y: auto;
background: var(--bg-primary);
}
.tags-header {
padding: 1rem;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 0.5rem;
}
.tags-header h2 {
flex: 1;
margin: 0;
font-size: 1.2rem;
}
.create-tag-button {
padding: 0.5rem;
background: var(--button-primary);
border: none;
border-radius: 4px;
cursor: pointer;
}
.tag-list {
list-style: none;
padding: 0;
margin: 0;
}
.tag-item {
border-bottom: 1px solid var(--border-color);
}
.tag-item-button {
width: 100%;
padding: 0.75rem 1rem;
text-align: left;
background: transparent;
border: none;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tag-item-button:hover {
background: var(--bg-secondary);
}
.tag-item.selected .tag-item-button {
background: var(--bg-secondary);
font-weight: bold;
}
.tag-name {
font-weight: 500;
}
.tag-hash {
font-size: 0.85rem;
color: var(--text-muted);
font-family: monospace;
}
.tag-date {
font-size: 0.8rem;
color: var(--text-muted);
}
.tags-content {
flex: 1;
padding: 1rem;
overflow-y: auto;
}
.tag-detail {
max-width: 800px;
}
.tag-detail-header h3 {
margin: 0 0 0.5rem 0;
}
.tag-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
margin-top: 0.5rem;
}
.tag-message {
margin-top: 1rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 4px;
}
.tag-release-section {
margin-top: 1rem;
padding: 1rem;
background: var(--bg-secondary);
border-radius: 4px;
}
.release-badge {
display: inline-block;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.85rem;
margin-right: 0.5rem;
}
.release-badge.draft {
background: var(--warning-bg);
color: var(--warning-text);
}
.release-badge.prerelease {
background: var(--info-bg);
color: var(--info-text);
}
.download-tag-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--button-primary);
color: var(--accent-text, #ffffff);
border: none;
border-radius: 4px;
text-decoration: none;
font-size: 0.9rem;
font-family: 'IBM Plex Serif', serif;
transition: background 0.2s ease;
cursor: pointer;
}
.download-tag-button:hover {
background: var(--button-primary-hover);
}
.download-tag-button .icon-inline {
width: 16px;
height: 16px;
}
.tag-has-release-icon {
width: 16px;
height: 16px;
vertical-align: middle;
opacity: 0.8;
}
.error-message {
color: var(--error-color, red);
font-size: 0.85rem;
margin-top: 0.5rem;
}
</style>

111
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<string, string> {
const headers: Record<string, string> = {};
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<T>(
url: string,
options: RequestInit = {}
): Promise<T> {
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<T>(
url: string,
body: unknown
): Promise<T> {
return apiRequest<T>(url, {
method: 'POST',
body: JSON.stringify(body),
});
}
/**
* Makes a PUT request
*/
export async function apiPut<T>(
url: string,
body: unknown
): Promise<T> {
return apiRequest<T>(url, {
method: 'PUT',
body: JSON.stringify(body),
});
}
/**
* Makes a DELETE request
*/
export async function apiDelete<T>(url: string): Promise<T> {
return apiRequest<T>(url, {
method: 'DELETE',
});
}

234
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<string, string> {
const headers: Record<string, string> = {};
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<void> {
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
}
}
Loading…
Cancel
Save