You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
264 lines
11 KiB
264 lines
11 KiB
/** |
|
* API endpoint for downloading repository as ZIP |
|
*/ |
|
|
|
import { error, json } from '@sveltejs/kit'; |
|
import type { RequestHandler } from './$types'; |
|
import { fileManager, repoManager, 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 { 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 { existsSync } from 'fs'; |
|
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; |
|
|
|
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`); |
|
|
|
// If repo doesn't exist, try to fetch it on-demand |
|
if (!existsSync(repoPath)) { |
|
try { |
|
// Fetch repository announcement from Nostr |
|
const events = await nostrClient.fetchEvents([ |
|
{ |
|
kinds: [KIND.REPO_ANNOUNCEMENT], |
|
authors: [context.repoOwnerPubkey], |
|
'#d': [context.repo], |
|
limit: 1 |
|
} |
|
]); |
|
|
|
if (events.length > 0) { |
|
// Try to fetch the repository from remote clone URLs |
|
const fetched = await repoManager.fetchRepoOnDemand( |
|
context.npub, |
|
context.repo, |
|
events[0] |
|
); |
|
|
|
// Always check if repo exists after fetch attempt (might have been created) |
|
// Also clear cache to ensure fileManager sees it |
|
if (existsSync(repoPath)) { |
|
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); |
|
// Repo exists, continue with normal flow |
|
} else if (!fetched) { |
|
// Fetch failed and repo doesn't exist |
|
throw handleNotFoundError( |
|
'Repository not found and could not be fetched from remote. The repository may not have any accessible clone URLs.', |
|
{ operation: 'download', npub: context.npub, repo: context.repo } |
|
); |
|
} else { |
|
// Fetch returned true but repo doesn't exist - this shouldn't happen, but clear cache anyway |
|
repoCache.delete(RepoCache.repoExistsKey(context.npub, context.repo)); |
|
// Wait a moment for filesystem to sync, then check again |
|
await new Promise(resolve => setTimeout(resolve, 100)); |
|
if (!existsSync(repoPath)) { |
|
throw handleNotFoundError( |
|
'Repository fetch completed but repository is not accessible', |
|
{ operation: 'download', npub: context.npub, repo: context.repo } |
|
); |
|
} |
|
} |
|
} else { |
|
throw handleNotFoundError( |
|
'Repository announcement not found in Nostr', |
|
{ operation: 'download', npub: context.npub, repo: context.repo } |
|
); |
|
} |
|
} catch (err) { |
|
// 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( |
|
'Repository not found', |
|
{ operation: 'download', npub: context.npub, repo: context.repo } |
|
); |
|
} |
|
} |
|
} |
|
|
|
// Double-check repo exists after on-demand fetch |
|
if (!existsSync(repoPath)) { |
|
throw handleNotFoundError( |
|
'Repository not found', |
|
{ operation: 'download', npub: context.npub, repo: context.repo } |
|
); |
|
} |
|
|
|
let ref = event.url.searchParams.get('ref') || 'HEAD'; |
|
const format = event.url.searchParams.get('format') || 'zip'; // zip or tar.gz |
|
|
|
// If ref is a branch name, validate it exists or use default branch |
|
if (ref !== 'HEAD' && !ref.startsWith('refs/')) { |
|
// Security: Validate ref to prevent command injection |
|
if (!isValidBranchName(ref)) { |
|
throw error(400, 'Invalid ref format'); |
|
} |
|
|
|
// 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'; |
|
} |
|
} |
|
|
|
// 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'); |
|
} |
|
|
|
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'); |
|
} |
|
|
|
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'); |
|
} |
|
|
|
try { |
|
// Create temp directory using fs/promises (safer than shell commands) |
|
await mkdir(tempDir, { recursive: true }); |
|
await mkdir(workDir, { recursive: true }); |
|
|
|
// Clone repository using simple-git (safer than shell commands) |
|
const git = simpleGit(); |
|
await git.clone(repoPath, workDir); |
|
|
|
// Checkout specific ref if not HEAD |
|
if (ref !== 'HEAD') { |
|
const workGit = simpleGit(workDir); |
|
await workGit.checkout(ref); |
|
} |
|
|
|
// Remove .git directory using fs/promises |
|
await rm(join(workDir, '.git'), { recursive: true, force: true }); |
|
|
|
// Verify workDir has content before archiving |
|
const { readdir } = await import('fs/promises'); |
|
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) |
|
if (format === 'tar.gz') { |
|
await 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: ${stderr}`)); |
|
} |
|
}); |
|
tarProcess.on('error', reject); |
|
}); |
|
} 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<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 |
|
const archiveBuffer = await readFile(archivePath); |
|
|
|
// Clean up using fs/promises |
|
await rm(workDir, { recursive: true, force: true }).catch(() => {}); |
|
await rm(archivePath, { force: true }).catch(() => {}); |
|
|
|
// Return archive |
|
return new Response(archiveBuffer, { |
|
headers: { |
|
'Content-Type': format === 'tar.gz' ? 'application/gzip' : 'application/zip', |
|
'Content-Disposition': `attachment; filename="${archiveName}"`, |
|
'Content-Length': archiveBuffer.length.toString() |
|
} |
|
}); |
|
} catch (archiveError) { |
|
// Clean up on error using fs/promises |
|
await rm(workDir, { recursive: true, force: true }).catch(() => {}); |
|
await rm(archivePath, { force: true }).catch(() => {}); |
|
const sanitizedError = sanitizeError(archiveError); |
|
logger.error({ error: sanitizedError, npub: context.npub, repo: context.repo, ref, format }, 'Error creating archive'); |
|
throw archiveError; |
|
} |
|
}, |
|
{ operation: 'download', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, downloads are public |
|
);
|
|
|