|
|
|
@ -4,7 +4,7 @@ |
|
|
|
|
|
|
|
|
|
|
|
import { error, json } from '@sveltejs/kit'; |
|
|
|
import { error, json } from '@sveltejs/kit'; |
|
|
|
import type { RequestHandler } from './$types'; |
|
|
|
import type { RequestHandler } from './$types'; |
|
|
|
import { fileManager } from '$lib/services/service-registry.js'; |
|
|
|
import { fileManager, repoManager, 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'; |
|
|
|
@ -13,7 +13,10 @@ 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 } from '$lib/utils/error-handler.js'; |
|
|
|
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 |
|
|
|
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT |
|
|
|
? process.env.GIT_REPO_ROOT |
|
|
|
? process.env.GIT_REPO_ROOT |
|
|
|
@ -21,20 +24,108 @@ const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT |
|
|
|
|
|
|
|
|
|
|
|
export const GET: RequestHandler = createRepoGetHandler( |
|
|
|
export const GET: RequestHandler = createRepoGetHandler( |
|
|
|
async (context: RepoRequestContext, event: RequestEvent) => { |
|
|
|
async (context: RepoRequestContext, event: RequestEvent) => { |
|
|
|
const ref = event.url.searchParams.get('ref') || 'HEAD'; |
|
|
|
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
|
|
|
|
const format = event.url.searchParams.get('format') || 'zip'; // zip or tar.gz
|
|
|
|
|
|
|
|
|
|
|
|
// Security: Validate ref to prevent command injection
|
|
|
|
// If ref is a branch name, validate it exists or use default branch
|
|
|
|
if (ref !== 'HEAD' && !isValidBranchName(ref)) { |
|
|
|
if (ref !== 'HEAD' && !ref.startsWith('refs/')) { |
|
|
|
throw error(400, 'Invalid ref format'); |
|
|
|
// 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
|
|
|
|
// Security: Validate format
|
|
|
|
if (format !== 'zip' && format !== 'tar.gz') { |
|
|
|
if (format !== 'zip' && format !== 'tar.gz') { |
|
|
|
throw error(400, 'Invalid format. Must be "zip" or "tar.gz"'); |
|
|
|
throw error(400, 'Invalid format. Must be "zip" or "tar.gz"'); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const repoPath = join(repoRoot, context.npub, `${context.repo}.git`); |
|
|
|
|
|
|
|
// Security: Ensure resolved path is within repoRoot
|
|
|
|
// Security: Ensure resolved path is within repoRoot
|
|
|
|
const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/'); |
|
|
|
const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/'); |
|
|
|
const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); |
|
|
|
const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); |
|
|
|
@ -77,6 +168,13 @@ export const GET: RequestHandler = createRepoGetHandler( |
|
|
|
// Remove .git directory using fs/promises
|
|
|
|
// Remove .git directory using fs/promises
|
|
|
|
await rm(join(workDir, '.git'), { recursive: true, force: true }); |
|
|
|
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)
|
|
|
|
// Create archive using spawn (safer than exec)
|
|
|
|
if (format === 'tar.gz') { |
|
|
|
if (format === 'tar.gz') { |
|
|
|
await new Promise<void>((resolve, reject) => { |
|
|
|
await new Promise<void>((resolve, reject) => { |
|
|
|
@ -96,21 +194,45 @@ export const GET: RequestHandler = createRepoGetHandler( |
|
|
|
}); |
|
|
|
}); |
|
|
|
} else { |
|
|
|
} else { |
|
|
|
// Use zip command (requires zip utility) - using spawn for safety
|
|
|
|
// 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) => { |
|
|
|
await new Promise<void>((resolve, reject) => { |
|
|
|
const zipProcess = spawn('zip', ['-r', archivePath, '.'], { |
|
|
|
const zipProcess = spawn('zip', ['-r', absoluteArchivePath, '.'], { |
|
|
|
cwd: workDir, |
|
|
|
cwd: workDir, |
|
|
|
stdio: ['ignore', 'pipe', 'pipe'] |
|
|
|
stdio: ['ignore', 'pipe', 'pipe'] |
|
|
|
}); |
|
|
|
}); |
|
|
|
|
|
|
|
let stdout = ''; |
|
|
|
let stderr = ''; |
|
|
|
let stderr = ''; |
|
|
|
|
|
|
|
zipProcess.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); |
|
|
|
zipProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); |
|
|
|
zipProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); |
|
|
|
zipProcess.on('close', (code) => { |
|
|
|
zipProcess.on('close', async (code) => { |
|
|
|
if (code === 0) { |
|
|
|
if (code === 0) { |
|
|
|
resolve(); |
|
|
|
// 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 { |
|
|
|
} else { |
|
|
|
reject(new Error(`zip failed: ${stderr}`)); |
|
|
|
reject(err); |
|
|
|
} |
|
|
|
} |
|
|
|
}); |
|
|
|
}); |
|
|
|
zipProcess.on('error', reject); |
|
|
|
|
|
|
|
}); |
|
|
|
}); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
@ -138,5 +260,5 @@ export const GET: RequestHandler = createRepoGetHandler( |
|
|
|
throw archiveError; |
|
|
|
throw archiveError; |
|
|
|
} |
|
|
|
} |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ operation: 'download' } |
|
|
|
{ operation: 'download', requireRepoExists: false, requireRepoAccess: false } // Handle on-demand fetching, downloads are public
|
|
|
|
); |
|
|
|
); |
|
|
|
|