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.
 
 
 
 
 

573 lines
22 KiB

/**
* API endpoint for reading and writing files in a repository
*/
import { json, error } from '@sveltejs/kit';
// @ts-ignore - SvelteKit generates this type
import type { RequestHandler } from './$types';
import { fileManager, repoManager, nostrClient } from '$lib/services/service-registry.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
import { verifyNIP98Auth } from '$lib/services/nostr/nip98-auth.js';
import { auditLogger } from '$lib/services/security/audit-logger.js';
import logger from '$lib/services/logger.js';
import type { NostrEvent } from '$lib/types/nostr.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { handleApiError, handleValidationError, handleNotFoundError } from '$lib/utils/error-handler.js';
import { KIND } from '$lib/types/nostr.js';
import { join } from 'path';
import { existsSync } from 'fs';
import { repoCache, RepoCache } from '$lib/services/git/repo-cache.js';
import { extractRequestContext } from '$lib/utils/api-context.js';
import { fetchUserEmail, fetchUserName } from '$lib/utils/user-profile.js';
import { eventCache } from '$lib/services/nostr/event-cache.js';
import { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js';
const repoRoot = typeof process !== 'undefined' && process.env?.GIT_REPO_ROOT
? process.env.GIT_REPO_ROOT
: '/repos';
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS);
export const GET: RequestHandler = async (event) => {
const { params, url, request } = event;
const { npub, repo } = params;
const filePath = url.searchParams.get('path');
let ref = url.searchParams.get('ref') || 'HEAD';
// Extract user pubkey using the same method as other endpoints
const requestContext = extractRequestContext(event);
const userPubkey = requestContext.userPubkey;
const userPubkeyHex = requestContext.userPubkeyHex;
// Debug logging for file endpoint
logger.debug({
hasUserPubkey: !!userPubkey,
hasUserPubkeyHex: !!userPubkeyHex,
userPubkeyHex: userPubkeyHex ? userPubkeyHex.substring(0, 16) + '...' : null,
npub,
repo,
filePath
}, 'File endpoint - extracted user context');
if (!npub || !repo || !filePath) {
return error(400, 'Missing npub, repo, or path parameter');
}
try {
const repoPath = join(repoRoot, npub, `${repo}.git`);
// If repo doesn't exist, try to fetch it on-demand
if (!existsSync(repoPath)) {
try {
// Get repo owner pubkey
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return error(400, 'Invalid npub format');
}
// Fetch repository announcement (case-insensitive) with caching
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache);
const announcement = findRepoAnnouncement(allEvents, repo);
if (announcement) {
// Try API-based fetching first (no cloning)
try {
const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js');
const fileContent = await tryApiFetchFile(announcement, npub, repo, filePath, ref);
if (fileContent && fileContent.content) {
logger.debug({ npub, repo, filePath, ref }, 'Successfully fetched file via API fallback');
return json(fileContent);
}
} catch (apiErr) {
// Log the error but don't throw - we'll return a helpful error message below
logger.debug({ error: apiErr, npub, repo, filePath, ref }, 'API file fetch failed, will return 404');
}
// 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, repo, filePath, hasCloneUrls, cloneUrlCount: cloneUrls.length }, 'API fallback failed or no clone URLs available');
return error(404, hasCloneUrls
? 'Repository is not cloned locally and could not fetch file 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.');
} else {
return error(404, 'Repository announcement not found in Nostr');
}
} catch (err) {
logger.error({ error: err, npub, repo, filePath }, 'Error in on-demand file fetch');
// 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(npub, repo));
} else {
// If fetching fails, return 404
return error(404, 'Repository not found');
}
}
}
// Double-check repo exists (should be true if we got here)
if (!existsSync(repoPath)) {
return error(404, 'Repository not found');
}
// Get repo owner pubkey for access check (already validated above if we did on-demand fetch)
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return error(400, 'Invalid npub format');
}
// 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(npub, repo);
if (!branches.includes(ref)) {
// Branch doesn't exist, try to get default branch
try {
ref = await fileManager.getDefaultBranch(npub, repo);
logger.debug({ npub, repo, originalRef: url.searchParams.get('ref'), newRef: ref }, 'Branch not found, using default branch');
} catch (defaultBranchErr) {
// If we can't get default branch, fall back to HEAD
logger.warn({ error: defaultBranchErr, npub, repo, ref }, 'Could not get default branch, falling back to HEAD');
ref = 'HEAD';
}
}
} catch (branchErr) {
// If we can't get branches, fall back to HEAD
logger.warn({ error: branchErr, npub, repo, ref }, 'Could not get branches, falling back to HEAD');
ref = 'HEAD';
}
}
// Check repository privacy (repoOwnerPubkey already declared above)
logger.debug({
userPubkeyHex: userPubkeyHex ? userPubkeyHex.substring(0, 16) + '...' : null,
repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...',
repo
}, 'File endpoint - checking canView before access check');
const canView = await maintainerService.canView(userPubkeyHex || null, repoOwnerPubkey, repo);
logger.debug({
canView,
userPubkeyHex: userPubkeyHex ? userPubkeyHex.substring(0, 16) + '...' : null,
repoOwnerPubkey: repoOwnerPubkey.substring(0, 16) + '...',
repo
}, 'File endpoint - canView result');
if (!canView) {
auditLogger.logFileOperation(
userPubkeyHex || null,
requestContext.clientIp,
'read',
`${npub}/${repo}`,
filePath,
'denied',
'Insufficient permissions'
);
return error(403, 'This repository is private. Only owners and maintainers can view it.');
}
try {
// Log what we're trying to do
logger.debug({ npub, repo, filePath, ref }, 'Attempting to read file from cloned repository');
let fileContent;
try {
fileContent = await fileManager.getFileContent(npub, repo, filePath, ref);
} catch (firstErr) {
// If the first attempt fails and ref is not HEAD, try with HEAD as fallback
if (ref !== 'HEAD' && !ref.startsWith('refs/')) {
logger.warn({
error: firstErr,
npub,
repo,
filePath,
originalRef: ref
}, 'Failed to read file with specified ref, trying HEAD as fallback');
try {
fileContent = await fileManager.getFileContent(npub, repo, filePath, 'HEAD');
ref = 'HEAD'; // Update ref for logging
} catch (headErr) {
// If HEAD also fails, try API fallback before throwing
logger.debug({ error: headErr, npub, repo, filePath }, 'Failed to read file from local repo, attempting API fallback');
try {
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache);
const announcement = findRepoAnnouncement(allEvents, repo);
if (announcement) {
const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js');
// Use the original ref, or 'main' as fallback
const apiRef = url.searchParams.get('ref') || 'main';
const apiFileContent = await tryApiFetchFile(announcement, npub, repo, filePath, apiRef);
if (apiFileContent && apiFileContent.content) {
logger.info({ npub, repo, filePath, ref: apiRef }, 'Successfully fetched file via API fallback for empty repo');
auditLogger.logFileOperation(
userPubkeyHex || null,
requestContext.clientIp,
'read',
`${npub}/${repo}`,
filePath,
'success'
);
return json(apiFileContent);
}
}
} catch (apiErr) {
logger.debug({ error: apiErr, npub, repo, filePath }, 'API fallback failed for file');
}
// If API fallback also fails, throw the original error
throw firstErr;
}
} else {
// Try API fallback before throwing
logger.debug({ error: firstErr, npub, repo, filePath }, 'Failed to read file from local repo, attempting API fallback');
try {
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache);
const announcement = findRepoAnnouncement(allEvents, repo);
if (announcement) {
const { tryApiFetchFile } = await import('$lib/utils/api-repo-helper.js');
const apiRef = ref === 'HEAD' ? 'main' : ref;
const apiFileContent = await tryApiFetchFile(announcement, npub, repo, filePath, apiRef);
if (apiFileContent && apiFileContent.content) {
logger.info({ npub, repo, filePath, ref: apiRef }, 'Successfully fetched file via API fallback for empty repo');
auditLogger.logFileOperation(
userPubkeyHex || null,
requestContext.clientIp,
'read',
`${npub}/${repo}`,
filePath,
'success'
);
return json(apiFileContent);
}
}
} catch (apiErr) {
logger.debug({ error: apiErr, npub, repo, filePath }, 'API fallback failed for file');
}
throw firstErr;
}
}
auditLogger.logFileOperation(
userPubkeyHex || null,
requestContext.clientIp,
'read',
`${npub}/${repo}`,
filePath,
'success'
);
return json(fileContent);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
const errorLower = errorMessage.toLowerCase();
const errorStack = err instanceof Error ? err.stack : undefined;
logger.error({
error: err,
errorStack,
npub,
repo,
filePath,
ref,
repoExists: existsSync(repoPath),
errorMessage
}, 'Error reading file from cloned repository');
auditLogger.logFileOperation(
userPubkeyHex || null,
requestContext.clientIp,
'read',
`${npub}/${repo}`,
filePath,
'failure',
errorMessage
);
// If file not found or path doesn't exist, return 404 instead of 500
if (errorLower.includes('not found') ||
errorLower.includes('no such file') ||
errorLower.includes('does not exist') ||
errorLower.includes('fatal:') ||
errorMessage.includes('pathspec')) {
return error(404, `File not found: ${filePath} at ref ${ref}`);
}
// For other errors, return 500 with a more helpful message
return error(500, `Failed to read file: ${errorMessage}`);
}
} catch (err) {
// This catch block handles errors that occur outside the file reading try-catch
// (e.g., in branch validation, access checks, etc.)
// If it's already a Response (from error handlers), return it
if (err instanceof Response) {
return err;
}
// If it's a SvelteKit HttpError (from error() function), re-throw it
// SvelteKit errors have a status property and body property
if (err && typeof err === 'object' && 'status' in err && 'body' in err) {
throw err;
}
const errorMessage = err instanceof Error ? err.message : String(err);
const errorStack = err instanceof Error ? err.stack : undefined;
logger.error({
error: err,
errorStack,
npub,
repo,
filePath,
ref: url.searchParams.get('ref'),
errorMessage
}, 'Unexpected error in file endpoint (outside file reading block)');
// Check if it's a "not found" type error
const errorLower = errorMessage.toLowerCase();
if (errorLower.includes('not found') ||
errorLower.includes('repository not found')) {
return error(404, errorMessage);
}
return handleApiError(err, { operation: 'readFile', npub, repo, filePath }, 'Failed to read file');
}
};
export const POST: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => {
const { npub, repo } = params;
if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter');
}
let path: string | undefined;
try {
const body = await request.json();
path = body.path;
const { content, commitMessage, authorName, authorEmail, branch, action, userPubkey, useNIP07, nsecKey, commitSignatureEvent } = body;
// Check for NIP-98 authentication (for git operations)
const authHeader = request.headers.get('Authorization');
let nip98Event = null;
if (authHeader && authHeader.startsWith('Nostr ')) {
const requestUrl = `${request.headers.get('x-forwarded-proto') || (url.protocol === 'https:' ? 'https' : 'http')}://${request.headers.get('host') || url.host}${url.pathname}${url.search}`;
const authResult = verifyNIP98Auth(authHeader, requestUrl, request.method);
if (authResult.valid && authResult.event) {
nip98Event = authResult.event;
}
}
if (!path || !commitMessage) {
return error(400, 'Missing required fields: path, commitMessage');
}
// Fetch authorName and authorEmail from kind 0 event if not provided
let finalAuthorName = authorName;
let finalAuthorEmail = authorEmail;
if (!finalAuthorName || !finalAuthorEmail) {
if (!userPubkey) {
return error(400, 'Missing userPubkey. Cannot fetch author information without userPubkey.');
}
const userPubkeyHexForProfile = decodeNpubToHex(userPubkey) || userPubkey;
try {
if (!finalAuthorName) {
finalAuthorName = await fetchUserName(userPubkeyHexForProfile, userPubkey, DEFAULT_NOSTR_RELAYS);
}
if (!finalAuthorEmail) {
finalAuthorEmail = await fetchUserEmail(userPubkeyHexForProfile, userPubkey, DEFAULT_NOSTR_RELAYS);
}
} catch (err) {
logger.warn({ error: err, userPubkey }, 'Failed to fetch user profile for author info, using fallbacks');
// Use fallbacks if fetch fails
if (!finalAuthorName) {
const npub = userPubkey.startsWith('npub') ? userPubkey : nip19.npubEncode(userPubkeyHexForProfile);
finalAuthorName = npub.substring(0, 20);
}
if (!finalAuthorEmail) {
const npub = userPubkey.startsWith('npub') ? userPubkey : nip19.npubEncode(userPubkeyHexForProfile);
finalAuthorEmail = `${npub.substring(0, 20)}@gitrepublic.web`;
}
}
}
if (!userPubkey) {
return error(401, 'Authentication required. Please provide userPubkey.');
}
// Check if repo exists locally
if (!fileManager.repoExists(npub, repo)) {
// Try to fetch announcement to see if repo exists in Nostr
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return error(400, 'Invalid npub format');
}
// Fetch repository announcement (case-insensitive) with caching
const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache);
const announcement = findRepoAnnouncement(allEvents, repo);
if (announcement) {
// Repository exists in Nostr but is not cloned locally
// For file editing, we need a local clone
return error(404, 'Repository is not cloned locally. To edit files, the repository must be cloned to the server first. Please use the "Clone to Server" button if you have unlimited access, or contact a server administrator.');
} else {
return error(404, 'Repository not found');
}
}
// Check if user is a maintainer
let repoOwnerPubkey: string;
try {
repoOwnerPubkey = requireNpubHex(npub);
} catch {
return error(400, 'Invalid npub format');
}
// Convert userPubkey to hex if needed
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey;
const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo);
if (!isMaintainer) {
return error(403, 'Only repository maintainers can edit files directly. Please submit a pull request instead.');
}
// Prepare signing options
// NOTE: nsecKey is intentionally NOT supported from client requests for security reasons.
// Clients should use NIP-07 (browser extension) or NIP-98 (HTTP auth) instead.
// nsecKey is only for server-side use via environment variables.
const signingOptions: {
useNIP07?: boolean;
nip98Event?: NostrEvent;
nsecKey?: string;
commitSignatureEvent?: NostrEvent;
} = {};
// If client sent a pre-signed commit signature event (from NIP-07), use it
if (commitSignatureEvent && commitSignatureEvent.sig && commitSignatureEvent.id) {
signingOptions.commitSignatureEvent = commitSignatureEvent;
} else if (nip98Event) {
signingOptions.nip98Event = nip98Event;
}
// Note: useNIP07 is no longer used since signing happens client-side
// Explicitly ignore nsecKey from client requests - it's a security risk
// Server-side signing is not recommended - commits should be signed by their authors
if (nsecKey) {
// Security: Log warning but never log the actual key value
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
logger.warn({ clientIp, npub, repo }, '[SECURITY] Client attempted to send nsecKey in request. This is not allowed for security reasons.');
auditLogger.log({
user: userPubkeyHex || undefined,
ip: clientIp,
action: 'auth_attempt',
resource: 'file_operation',
result: 'failure',
error: 'Client attempted to send private key in request body',
metadata: { reason: 'security_violation' }
});
}
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
if (action === 'delete') {
try {
// Get default branch if not provided
const targetBranch = branch || await fileManager.getDefaultBranch(npub, repo);
await fileManager.deleteFile(
npub,
repo,
path,
commitMessage,
finalAuthorName,
finalAuthorEmail,
targetBranch,
Object.keys(signingOptions).length > 0 ? signingOptions : undefined
);
auditLogger.logFileOperation(
userPubkeyHex,
clientIp,
'delete',
`${npub}/${repo}`,
path,
'success'
);
return json({ success: true, message: 'File deleted and committed' });
} catch (err) {
auditLogger.logFileOperation(
userPubkeyHex,
clientIp,
'delete',
`${npub}/${repo}`,
path,
'failure',
err instanceof Error ? err.message : String(err)
);
throw err;
}
} else if (action === 'create' || content !== undefined) {
if (content === undefined) {
return error(400, 'Content is required for create/update operations');
}
try {
// Get default branch if not provided
const targetBranch = branch || await fileManager.getDefaultBranch(npub, repo);
await fileManager.writeFile(
npub,
repo,
path,
content,
commitMessage,
finalAuthorName,
finalAuthorEmail,
targetBranch,
Object.keys(signingOptions).length > 0 ? signingOptions : undefined
);
auditLogger.logFileOperation(
userPubkeyHex,
clientIp,
action === 'create' ? 'create' : 'write',
`${npub}/${repo}`,
path,
'success'
);
return json({ success: true, message: 'File saved and committed' });
} catch (err) {
auditLogger.logFileOperation(
userPubkeyHex,
clientIp,
action === 'create' ? 'create' : 'write',
`${npub}/${repo}`,
path,
'failure',
err instanceof Error ? err.message : String(err)
);
throw err;
}
} else {
return error(400, 'Invalid action or missing content');
}
} catch (err) {
return handleApiError(err, { operation: 'writeFile', npub, repo, filePath: path }, 'Failed to write file');
}
};