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.
257 lines
9.1 KiB
257 lines
9.1 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 } from '$lib/services/git/file-manager.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'; |
|
|
|
const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; |
|
const fileManager = new FileManager(repoRoot); |
|
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); |
|
|
|
export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => { |
|
const { npub, repo } = params; |
|
const filePath = url.searchParams.get('path'); |
|
const ref = url.searchParams.get('ref') || 'HEAD'; |
|
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); |
|
|
|
if (!npub || !repo || !filePath) { |
|
return error(400, 'Missing npub, repo, or path parameter'); |
|
} |
|
|
|
try { |
|
if (!fileManager.repoExists(npub, repo)) { |
|
return error(404, 'Repository not found'); |
|
} |
|
|
|
// Check repository privacy |
|
let repoOwnerPubkey: string; |
|
try { |
|
repoOwnerPubkey = requireNpubHex(npub); |
|
} catch { |
|
return error(400, 'Invalid npub format'); |
|
} |
|
|
|
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); |
|
if (!canView) { |
|
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; |
|
auditLogger.logFileOperation( |
|
userPubkey || null, |
|
clientIp, |
|
'read', |
|
`${npub}/${repo}`, |
|
filePath, |
|
'denied', |
|
'Insufficient permissions' |
|
); |
|
return error(403, 'This repository is private. Only owners and maintainers can view it.'); |
|
} |
|
|
|
const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; |
|
try { |
|
const fileContent = await fileManager.getFileContent(npub, repo, filePath, ref); |
|
auditLogger.logFileOperation( |
|
userPubkey || null, |
|
clientIp, |
|
'read', |
|
`${npub}/${repo}`, |
|
filePath, |
|
'success' |
|
); |
|
return json(fileContent); |
|
} catch (err) { |
|
auditLogger.logFileOperation( |
|
userPubkey || null, |
|
clientIp, |
|
'read', |
|
`${npub}/${repo}`, |
|
filePath, |
|
'failure', |
|
err instanceof Error ? err.message : String(err) |
|
); |
|
throw err; |
|
} |
|
} catch (err) { |
|
// Security: Sanitize error messages to prevent leaking sensitive data |
|
const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to read file'; |
|
logger.error({ error: sanitizedError, npub, repo, filePath }, 'Error reading file'); |
|
return error(500, sanitizedError); |
|
} |
|
}; |
|
|
|
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 } = 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 || !authorName || !authorEmail) { |
|
return error(400, 'Missing required fields: path, commitMessage, authorName, authorEmail'); |
|
} |
|
|
|
if (!userPubkey) { |
|
return error(401, 'Authentication required. Please provide userPubkey.'); |
|
} |
|
|
|
if (!fileManager.repoExists(npub, repo)) { |
|
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; |
|
} = {}; |
|
|
|
if (useNIP07) { |
|
signingOptions.useNIP07 = true; |
|
} else if (nip98Event) { |
|
signingOptions.nip98Event = nip98Event; |
|
} |
|
// Explicitly ignore nsecKey from client requests - it's a security risk |
|
// Server-side signing should use NOSTRGIT_SECRET_KEY environment variable instead |
|
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 { |
|
await fileManager.deleteFile( |
|
npub, |
|
repo, |
|
path, |
|
commitMessage, |
|
authorName, |
|
authorEmail, |
|
branch || 'main', |
|
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 { |
|
await fileManager.writeFile( |
|
npub, |
|
repo, |
|
path, |
|
content, |
|
commitMessage, |
|
authorName, |
|
authorEmail, |
|
branch || 'main', |
|
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) { |
|
// Security: Sanitize error messages to prevent leaking sensitive data |
|
const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to write file'; |
|
logger.error({ error: sanitizedError, npub, repo, path }, 'Error writing file'); |
|
return error(500, sanitizedError); |
|
} |
|
};
|
|
|