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.
327 lines
16 KiB
327 lines
16 KiB
/** |
|
* API endpoint for listing files and directories in a repository |
|
*/ |
|
|
|
import { 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 { handleApiError, handleNotFoundError } from '$lib/utils/error-handler.js'; |
|
import { KIND } from '$lib/types/nostr.js'; |
|
import { join, resolve } from 'path'; |
|
import { existsSync } from 'fs'; |
|
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js'; |
|
import logger from '$lib/services/logger.js'; |
|
import { eventCache } from '$lib/services/nostr/event-cache.js'; |
|
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; |
|
|
|
// Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths) |
|
const repoRootEnv = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT |
|
? process.env.GIT_REPO_ROOT |
|
: '/repos'; |
|
const repoRoot = resolve(repoRootEnv); |
|
|
|
export const GET: RequestHandler = createRepoGetHandler( |
|
async (context: RepoRequestContext) => { |
|
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 (case-insensitive) with caching |
|
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); |
|
const announcement = findRepoAnnouncement(allEvents, context.repo); |
|
|
|
if (announcement) { |
|
// Try API-based fetching first (no cloning) |
|
const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); |
|
const { extractCloneUrls: extractCloneUrlsHelper } = await import('$lib/utils/nostr-utils.js'); |
|
const cloneUrlsForLogging = extractCloneUrlsHelper(announcement); |
|
|
|
logger.debug({ npub: context.npub, repo: context.repo, cloneUrlCount: cloneUrlsForLogging.length, cloneUrls: cloneUrlsForLogging, path: context.path }, 'Attempting API fallback for tree'); |
|
|
|
const apiData = await tryApiFetch(announcement, context.npub, context.repo); |
|
|
|
if (apiData && apiData.files !== undefined) { |
|
// Return empty array if no files (legitimate for empty repos) |
|
// Only proceed if we have files to filter |
|
if (apiData.files.length === 0) { |
|
logger.debug({ npub: context.npub, repo: context.repo, path: context.path }, 'API fallback returned empty files array (repo may be empty)'); |
|
return json([]); |
|
} |
|
logger.debug({ npub: context.npub, repo: context.repo, fileCount: apiData.files.length }, 'Successfully fetched files via API fallback'); |
|
// Return API data directly without cloning |
|
const path = context.path || ''; |
|
// Filter files by path if specified |
|
let filteredFiles: typeof apiData.files; |
|
if (path) { |
|
// Normalize path: ensure it ends with / for directory matching |
|
const normalizedPath = path.endsWith('/') ? path : `${path}/`; |
|
// Filter files that are directly in this directory (not in subdirectories) |
|
filteredFiles = apiData.files.filter(f => { |
|
// File must start with the normalized path |
|
if (!f.path.startsWith(normalizedPath)) { |
|
return false; |
|
} |
|
// Get the relative path after the directory prefix |
|
const relativePath = f.path.slice(normalizedPath.length); |
|
// If relative path is empty, skip (this would be the directory itself) |
|
if (!relativePath) { |
|
return false; |
|
} |
|
// Remove trailing slash from relative path for directories |
|
const cleanRelativePath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; |
|
// Check if it's directly in this directory (no additional / in the relative path) |
|
// This works for both files (e.g., "icon.svg") and directories (e.g., "subfolder") |
|
return !cleanRelativePath.includes('/'); |
|
}); |
|
} else { |
|
// Root directory: show only files and directories in root |
|
filteredFiles = apiData.files.filter(f => { |
|
// Remove trailing slash for directories |
|
const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; |
|
const pathParts = cleanPath.split('/'); |
|
// Include only items in root (single path segment) |
|
return pathParts.length === 1; |
|
}); |
|
} |
|
|
|
// Normalize type: API returns 'dir' but frontend expects 'directory' |
|
// Also update name to be just the filename/dirname for display |
|
const normalizedFiles = filteredFiles.map(f => { |
|
// Extract display name from path |
|
const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; |
|
const pathParts = cleanPath.split('/'); |
|
const displayName = pathParts[pathParts.length - 1] || f.name; |
|
return { |
|
name: displayName, |
|
path: f.path, |
|
type: (f.type === 'dir' ? 'directory' : 'file') as 'file' | 'directory', |
|
size: f.size |
|
}; |
|
}); |
|
|
|
return json(normalizedFiles); |
|
} |
|
|
|
// API fetch failed - repo is not cloned and API fetch didn't work |
|
// Check if announcement has clone URLs to provide better error message |
|
const { extractCloneUrls } = await import('$lib/utils/nostr-utils.js'); |
|
const cloneUrls = extractCloneUrls(announcement); |
|
const hasCloneUrls = cloneUrls.length > 0; |
|
|
|
logger.debug({ npub: context.npub, repo: context.repo, hasCloneUrls, cloneUrlCount: cloneUrls.length }, 'API fallback failed or no clone URLs available'); |
|
|
|
throw handleNotFoundError( |
|
hasCloneUrls |
|
? 'Repository is not cloned locally and could not be fetched via API. Privileged users can clone this repository using the "Clone to Server" button.' |
|
: 'Repository is not cloned locally and has no external clone URLs for API fallback. Privileged users can clone this repository using the "Clone to Server" button.', |
|
{ operation: 'listFiles', npub: context.npub, repo: context.repo } |
|
); |
|
} else { |
|
throw handleNotFoundError( |
|
'Repository announcement not found in Nostr', |
|
{ operation: 'listFiles', 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: 'listFiles', npub: context.npub, repo: context.repo } |
|
); |
|
} |
|
} |
|
} |
|
|
|
// Double-check repo exists (should be true if we got here) |
|
if (!existsSync(repoPath)) { |
|
throw handleNotFoundError( |
|
'Repository not found', |
|
{ operation: 'listFiles', npub: context.npub, repo: context.repo } |
|
); |
|
} |
|
|
|
// Get default branch if no ref specified |
|
let ref = context.ref || 'HEAD'; |
|
// If ref is a branch name, validate it exists or use default branch |
|
if (ref !== 'HEAD' && !ref.startsWith('refs/')) { |
|
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'; |
|
} |
|
} |
|
const path = context.path || ''; |
|
|
|
try { |
|
const files = await fileManager.listFiles(context.npub, context.repo, ref, path); |
|
|
|
// If repo exists but has no files (empty repo), try API fallback |
|
if (files.length === 0) { |
|
logger.debug({ npub: context.npub, repo: context.repo, path, ref }, 'Repo exists but is empty, attempting API fallback for tree'); |
|
|
|
try { |
|
// Fetch repository announcement for API fallback |
|
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); |
|
const announcement = findRepoAnnouncement(allEvents, context.repo); |
|
|
|
if (announcement) { |
|
const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); |
|
const apiData = await tryApiFetch(announcement, context.npub, context.repo); |
|
|
|
if (apiData && apiData.files && apiData.files.length > 0) { |
|
logger.info({ npub: context.npub, repo: context.repo, fileCount: apiData.files.length }, 'Successfully fetched files via API fallback for empty repo'); |
|
|
|
// Filter files by path if specified (same logic as above) |
|
let filteredFiles: typeof apiData.files; |
|
if (path) { |
|
const normalizedPath = path.endsWith('/') ? path : `${path}/`; |
|
filteredFiles = apiData.files.filter(f => { |
|
if (!f.path.startsWith(normalizedPath)) { |
|
return false; |
|
} |
|
const relativePath = f.path.slice(normalizedPath.length); |
|
if (!relativePath) { |
|
return false; |
|
} |
|
const cleanRelativePath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; |
|
return !cleanRelativePath.includes('/'); |
|
}); |
|
} else { |
|
filteredFiles = apiData.files.filter(f => { |
|
const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; |
|
const pathParts = cleanPath.split('/'); |
|
return pathParts.length === 1; |
|
}); |
|
} |
|
|
|
// Normalize type and name |
|
const normalizedFiles = filteredFiles.map(f => { |
|
const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; |
|
const pathParts = cleanPath.split('/'); |
|
const displayName = pathParts[pathParts.length - 1] || f.name; |
|
return { |
|
name: displayName, |
|
path: f.path, |
|
type: (f.type === 'dir' ? 'directory' : 'file') as 'file' | 'directory', |
|
size: f.size |
|
}; |
|
}); |
|
|
|
return json(normalizedFiles); |
|
} |
|
} |
|
} catch (apiErr) { |
|
logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed for empty repo, returning empty files'); |
|
} |
|
} |
|
|
|
// Debug logging to help diagnose missing files |
|
logger.debug({ |
|
npub: context.npub, |
|
repo: context.repo, |
|
path, |
|
ref, |
|
fileCount: files.length, |
|
files: files.map(f => ({ name: f.name, path: f.path, type: f.type })) |
|
}, '[Tree] Returning files from fileManager.listFiles'); |
|
return json(files); |
|
} catch (err) { |
|
// If error occurs, try API fallback before giving up |
|
logger.debug({ error: err, npub: context.npub, repo: context.repo }, '[Tree] Error listing files, attempting API fallback'); |
|
|
|
try { |
|
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, context.repoOwnerPubkey, eventCache); |
|
const announcement = findRepoAnnouncement(allEvents, context.repo); |
|
|
|
if (announcement) { |
|
const { tryApiFetch } = await import('$lib/utils/api-repo-helper.js'); |
|
const apiData = await tryApiFetch(announcement, context.npub, context.repo); |
|
|
|
if (apiData && apiData.files && apiData.files.length > 0) { |
|
logger.info({ npub: context.npub, repo: context.repo, fileCount: apiData.files.length }, 'Successfully fetched files via API fallback after error'); |
|
|
|
// Filter and normalize files (same logic as above) |
|
const path = context.path || ''; |
|
let filteredFiles: typeof apiData.files; |
|
if (path) { |
|
const normalizedPath = path.endsWith('/') ? path : `${path}/`; |
|
filteredFiles = apiData.files.filter(f => { |
|
if (!f.path.startsWith(normalizedPath)) return false; |
|
const relativePath = f.path.slice(normalizedPath.length); |
|
if (!relativePath) return false; |
|
const cleanRelativePath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath; |
|
return !cleanRelativePath.includes('/'); |
|
}); |
|
} else { |
|
filteredFiles = apiData.files.filter(f => { |
|
const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; |
|
return cleanPath.split('/').length === 1; |
|
}); |
|
} |
|
|
|
const normalizedFiles = filteredFiles.map(f => { |
|
const cleanPath = f.path.endsWith('/') ? f.path.slice(0, -1) : f.path; |
|
const pathParts = cleanPath.split('/'); |
|
const displayName = pathParts[pathParts.length - 1] || f.name; |
|
return { |
|
name: displayName, |
|
path: f.path, |
|
type: (f.type === 'dir' ? 'directory' : 'file') as 'file' | 'directory', |
|
size: f.size |
|
}; |
|
}); |
|
|
|
return json(normalizedFiles); |
|
} |
|
} |
|
} catch (apiErr) { |
|
logger.debug({ error: apiErr, npub: context.npub, repo: context.repo }, 'API fallback failed after error'); |
|
} |
|
|
|
// Log the actual error for debugging |
|
logger.error({ error: err, npub: context.npub, repo: context.repo, path: context.path }, '[Tree] Error listing files'); |
|
|
|
// For optional paths (like "docs"), return empty array instead of 404 |
|
// This allows components to gracefully handle missing directories |
|
const optionalPaths = ['docs']; |
|
if (context.path && optionalPaths.includes(context.path.toLowerCase())) { |
|
logger.debug({ npub: context.npub, repo: context.repo, path: context.path }, '[Tree] Optional path not found, returning empty array'); |
|
return json([]); |
|
} |
|
|
|
// Check if it's a "not found" error for the repo itself |
|
if (err instanceof Error && (err.message.includes('Repository not found') || err.message.includes('not cloned'))) { |
|
throw handleNotFoundError( |
|
err.message, |
|
{ operation: 'listFiles', npub: context.npub, repo: context.repo } |
|
); |
|
} |
|
|
|
// For other errors with optional paths, return empty array |
|
if (context.path && optionalPaths.includes(context.path.toLowerCase())) { |
|
return json([]); |
|
} |
|
|
|
// Otherwise, it's a server error |
|
throw handleApiError( |
|
err, |
|
{ operation: 'listFiles', npub: context.npub, repo: context.repo }, |
|
'Failed to list files' |
|
); |
|
} |
|
}, |
|
{ operation: 'listFiles', requireRepoExists: false, requireRepoAccess: true } // Handle on-demand fetching, but check access for private repos |
|
);
|
|
|