diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 159a7c1..6166907 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -5,7 +5,7 @@ import simpleGit, { type SimpleGit } from 'simple-git'; import { readFile, readdir, stat } from 'fs/promises'; -import { join, dirname } from 'path'; +import { join, dirname, normalize, resolve } from 'path'; import { existsSync } from 'fs'; import { RepoManager } from './repo-manager.js'; @@ -59,10 +59,101 @@ export class FileManager { return join(this.repoRoot, npub, `${repoName}.git`); } + /** + * Validate and sanitize file path to prevent path traversal attacks + */ + private validateFilePath(filePath: string): { valid: boolean; error?: string; normalized?: string } { + if (!filePath || typeof filePath !== 'string') { + return { valid: false, error: 'File path must be a non-empty string' }; + } + + // Normalize the path (resolves .. and .) + const normalized = normalize(filePath); + + // Check for path traversal attempts + if (normalized.includes('..')) { + return { valid: false, error: 'Path traversal detected (..)' }; + } + + // Check for absolute paths + if (normalized.startsWith('/')) { + return { valid: false, error: 'Absolute paths are not allowed' }; + } + + // Check for null bytes + if (normalized.includes('\0')) { + return { valid: false, error: 'Null bytes are not allowed in paths' }; + } + + // Check for control characters + if (/[\x00-\x1f\x7f]/.test(normalized)) { + return { valid: false, error: 'Control characters are not allowed in paths' }; + } + + // Limit path length (reasonable limit) + if (normalized.length > 4096) { + return { valid: false, error: 'Path is too long (max 4096 characters)' }; + } + + return { valid: true, normalized }; + } + + /** + * Validate repository name to prevent injection attacks + */ + private validateRepoName(repoName: string): { valid: boolean; error?: string } { + if (!repoName || typeof repoName !== 'string') { + return { valid: false, error: 'Repository name must be a non-empty string' }; + } + + // Check length + if (repoName.length > 100) { + return { valid: false, error: 'Repository name is too long (max 100 characters)' }; + } + + // Check for invalid characters (alphanumeric, hyphens, underscores, dots) + if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { + return { valid: false, error: 'Repository name contains invalid characters' }; + } + + // Check for path traversal + if (repoName.includes('..') || repoName.includes('/') || repoName.includes('\\')) { + return { valid: false, error: 'Repository name contains invalid path characters' }; + } + + return { valid: true }; + } + + /** + * Validate npub format + */ + private validateNpub(npub: string): { valid: boolean; error?: string } { + if (!npub || typeof npub !== 'string') { + return { valid: false, error: 'npub must be a non-empty string' }; + } + + // Basic npub format check (starts with npub, base58 encoded) + if (!npub.startsWith('npub1') || npub.length < 10 || npub.length > 100) { + return { valid: false, error: 'Invalid npub format' }; + } + + return { valid: true }; + } + /** * Check if repository exists */ repoExists(npub: string, repoName: string): boolean { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + return false; + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + return false; + } + const repoPath = this.getRepoPath(npub, repoName); return this.repoManager.repoExists(repoPath); } @@ -71,6 +162,21 @@ export class FileManager { * List files and directories in a repository at a given path */ async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = this.validateFilePath(path); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + const repoPath = this.getRepoPath(npub, repoName); if (!this.repoExists(npub, repoName)) { @@ -123,6 +229,21 @@ export class FileManager { * Get file content from a repository */ async getFileContent(npub: string, repoName: string, filePath: string, ref: string = 'HEAD'): Promise { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = this.validateFilePath(filePath); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + const repoPath = this.getRepoPath(npub, repoName); if (!this.repoExists(npub, repoName)) { @@ -163,6 +284,43 @@ export class FileManager { authorEmail: string, branch: string = 'main' ): Promise { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = this.validateFilePath(filePath); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + // Validate content size (prevent extremely large files) + const maxFileSize = 100 * 1024 * 1024; // 100 MB per file + if (Buffer.byteLength(content, 'utf-8') > maxFileSize) { + throw new Error(`File is too large (max ${maxFileSize / 1024 / 1024} MB)`); + } + + // Validate commit message + if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { + throw new Error('Commit message is required'); + } + if (commitMessage.length > 1000) { + throw new Error('Commit message is too long (max 1000 characters)'); + } + + // Validate author info + if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { + throw new Error('Author name is required'); + } + if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { + throw new Error('Valid author email is required'); + } + const repoPath = this.getRepoPath(npub, repoName); if (!this.repoExists(npub, repoName)) { @@ -170,6 +328,12 @@ export class FileManager { } try { + // Check repository size before writing + const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath); + if (!repoSizeCheck.withinLimit) { + throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); + } + // Clone bare repo to a temporary working directory (non-bare) const workDir = join(this.repoRoot, npub, `${repoName}.work`); const { rm } = await import('fs/promises'); @@ -194,10 +358,18 @@ export class FileManager { await workGit.checkout(['-b', branch]); } - // Write the file - const fullFilePath = join(workDir, filePath); + // Write the file (use validated path) + const validatedPath = pathValidation.normalized || filePath; + const fullFilePath = join(workDir, validatedPath); const fileDir = dirname(fullFilePath); + // Additional security: ensure the resolved path is still within workDir + const resolvedPath = resolve(fullFilePath); + const resolvedWorkDir = resolve(workDir); + if (!resolvedPath.startsWith(resolvedWorkDir)) { + throw new Error('Path validation failed: resolved path outside work directory'); + } + // Ensure directory exists if (!existsSync(fileDir)) { const { mkdir } = await import('fs/promises'); @@ -207,8 +379,8 @@ export class FileManager { const { writeFile: writeFileFs } = await import('fs/promises'); await writeFileFs(fullFilePath, content, 'utf-8'); - // Stage the file - await workGit.add(filePath); + // Stage the file (use validated path) + await workGit.add(validatedPath); // Commit await workGit.commit(commitMessage, [filePath], { @@ -278,6 +450,34 @@ export class FileManager { authorEmail: string, branch: string = 'main' ): Promise { + // Validate inputs + const npubValidation = this.validateNpub(npub); + if (!npubValidation.valid) { + throw new Error(`Invalid npub: ${npubValidation.error}`); + } + const repoValidation = this.validateRepoName(repoName); + if (!repoValidation.valid) { + throw new Error(`Invalid repository name: ${repoValidation.error}`); + } + + const pathValidation = this.validateFilePath(filePath); + if (!pathValidation.valid) { + throw new Error(`Invalid file path: ${pathValidation.error}`); + } + + // Validate commit message + if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { + throw new Error('Commit message is required'); + } + + // Validate author info + if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { + throw new Error('Author name is required'); + } + if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { + throw new Error('Valid author email is required'); + } + const repoPath = this.getRepoPath(npub, repoName); if (!this.repoExists(npub, repoName)) { @@ -303,15 +503,24 @@ export class FileManager { await workGit.checkout(['-b', branch]); } - // Remove the file - const fullFilePath = join(workDir, filePath); + // Remove the file (use validated path) + const validatedPath = pathValidation.normalized || filePath; + const fullFilePath = join(workDir, validatedPath); + + // Additional security: ensure the resolved path is still within workDir + const resolvedPath = resolve(fullFilePath); + const resolvedWorkDir = resolve(workDir); + if (!resolvedPath.startsWith(resolvedWorkDir)) { + throw new Error('Path validation failed: resolved path outside work directory'); + } + if (existsSync(fullFilePath)) { const { unlink } = await import('fs/promises'); await unlink(fullFilePath); } - // Stage the deletion - await workGit.rm([filePath]); + // Stage the deletion (use validated path) + await workGit.rm([validatedPath]); // Commit await workGit.commit(commitMessage, [filePath], { @@ -491,7 +700,7 @@ export class FileManager { if (stats.files && files.length > 0) { for (const statFile of stats.files) { const file = files.find(f => f.file === statFile.file); - if (file) { + if (file && 'insertions' in statFile && 'deletions' in statFile) { file.additions = statFile.insertions; file.deletions = statFile.deletions; } @@ -528,14 +737,20 @@ export class FileManager { try { if (message) { // Create annotated tag - const tagOptions: string[] = ['-a', tagName, '-m', message]; + await git.addTag(tagName); + // Note: simple-git addTag doesn't support message directly, use raw command if (ref !== 'HEAD') { - tagOptions.push(ref); + await git.raw(['tag', '-a', tagName, '-m', message, ref]); + } else { + await git.raw(['tag', '-a', tagName, '-m', message]); } - await git.addTag(tagName, message); } else { // Create lightweight tag - await git.addTag(tagName); + if (ref !== 'HEAD') { + await git.raw(['tag', tagName, ref]); + } else { + await git.addTag(tagName); + } } } catch (error) { console.error('Error creating tag:', error); diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index 9567a5b..4ade59e 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -5,8 +5,9 @@ import { exec } from 'child_process'; import { promisify } from 'util'; -import { existsSync, mkdirSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs'; import { join } from 'path'; +import { readdir } from 'fs/promises'; import type { NostrEvent } from '../../types/nostr.js'; import { GIT_DOMAIN } from '../../config.js'; import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js'; @@ -149,6 +150,70 @@ export class RepoManager { return existsSync(repoPath); } + /** + * Get repository size in bytes + * Returns the total size of the repository directory + */ + async getRepoSize(repoPath: string): Promise { + if (!existsSync(repoPath)) { + return 0; + } + + let totalSize = 0; + + async function calculateSize(dirPath: string): Promise { + let size = 0; + try { + const entries = await readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = join(dirPath, entry.name); + + if (entry.isDirectory()) { + size += await calculateSize(fullPath); + } else if (entry.isFile()) { + try { + const stats = statSync(fullPath); + size += stats.size; + } catch { + // Ignore errors accessing files + } + } + } + } catch { + // Ignore errors accessing directories + } + return size; + } + + totalSize = await calculateSize(repoPath); + return totalSize; + } + + /** + * Check if repository size exceeds the maximum (2 GB) + */ + async checkRepoSizeLimit(repoPath: string, maxSizeBytes: number = 2 * 1024 * 1024 * 1024): Promise<{ withinLimit: boolean; currentSize: number; maxSize: number; error?: string }> { + try { + const currentSize = await this.getRepoSize(repoPath); + const withinLimit = currentSize <= maxSizeBytes; + + return { + withinLimit, + currentSize, + maxSize: maxSizeBytes, + ...(withinLimit ? {} : { error: `Repository size (${(currentSize / 1024 / 1024 / 1024).toFixed(2)} GB) exceeds maximum (${(maxSizeBytes / 1024 / 1024 / 1024).toFixed(2)} GB)` }) + }; + } catch (error) { + return { + withinLimit: false, + currentSize: 0, + maxSize: maxSizeBytes, + error: `Failed to check repository size: ${error instanceof Error ? error.message : String(error)}` + }; + } + } + /** * Create verification file in a new repository * This proves the repository is owned by the announcement author diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 61cc3d8..14bda57 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -39,30 +39,74 @@ export class NostrClient { return new Promise((resolve, reject) => { const ws = new WebSocket(relay); const events: NostrEvent[] = []; + let resolved = false; + let timeoutId: ReturnType | null = null; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + try { + ws.close(); + } catch { + // Ignore errors during cleanup + } + } + }; + + const resolveOnce = (value: NostrEvent[]) => { + if (!resolved) { + resolved = true; + cleanup(); + resolve(value); + } + }; + + const rejectOnce = (error: Error) => { + if (!resolved) { + resolved = true; + cleanup(); + reject(error); + } + }; ws.onopen = () => { - ws.send(JSON.stringify(['REQ', 'sub', ...filters])); + try { + ws.send(JSON.stringify(['REQ', 'sub', ...filters])); + } catch (error) { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + } }; ws.onmessage = (event: MessageEvent) => { - const message = JSON.parse(event.data); - - if (message[0] === 'EVENT') { - events.push(message[2]); - } else if (message[0] === 'EOSE') { - ws.close(); - resolve(events); + try { + const message = JSON.parse(event.data); + + if (message[0] === 'EVENT') { + events.push(message[2]); + } else if (message[0] === 'EOSE') { + resolveOnce(events); + } + } catch (error) { + // Ignore parse errors, continue receiving events } }; ws.onerror = (error) => { - ws.close(); - reject(error); + rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`)); + }; + + ws.onclose = () => { + // If we haven't resolved yet, resolve with what we have + if (!resolved) { + resolveOnce(events); + } }; - setTimeout(() => { - ws.close(); - resolve(events); + timeoutId = setTimeout(() => { + resolveOnce(events); }, 5000); }); } @@ -89,33 +133,76 @@ export class NostrClient { private async publishToRelay(relay: string, nostrEvent: NostrEvent): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(relay); + let resolved = false; + let timeoutId: ReturnType | null = null; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = null; + } + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + try { + ws.close(); + } catch { + // Ignore errors during cleanup + } + } + }; + + const resolveOnce = () => { + if (!resolved) { + resolved = true; + cleanup(); + resolve(); + } + }; + + const rejectOnce = (error: Error) => { + if (!resolved) { + resolved = true; + cleanup(); + reject(error); + } + }; ws.onopen = () => { - ws.send(JSON.stringify(['EVENT', nostrEvent])); + try { + ws.send(JSON.stringify(['EVENT', nostrEvent])); + } catch (error) { + rejectOnce(error instanceof Error ? error : new Error(String(error))); + } }; ws.onmessage = (event: MessageEvent) => { - const message = JSON.parse(event.data); - - if (message[0] === 'OK' && message[1] === nostrEvent.id) { - if (message[2] === true) { - ws.close(); - resolve(); - } else { - ws.close(); - reject(new Error(message[3] || 'Publish rejected')); + try { + const message = JSON.parse(event.data); + + if (message[0] === 'OK' && message[1] === nostrEvent.id) { + if (message[2] === true) { + resolveOnce(); + } else { + rejectOnce(new Error(message[3] || 'Publish rejected')); + } } + } catch (error) { + // Ignore parse errors, continue waiting for OK message } }; ws.onerror = (error) => { - ws.close(); - reject(error); + rejectOnce(new Error(`WebSocket error for ${relay}: ${error}`)); + }; + + ws.onclose = () => { + // If we haven't resolved yet, it's an unexpected close + if (!resolved) { + rejectOnce(new Error('WebSocket closed unexpectedly')); + } }; - setTimeout(() => { - ws.close(); - reject(new Error('Timeout')); + timeoutId = setTimeout(() => { + rejectOnce(new Error('Publish timeout')); }, 5000); }); } diff --git a/src/lib/services/nostr/relay-write-proof.ts b/src/lib/services/nostr/relay-write-proof.ts new file mode 100644 index 0000000..8f931d5 --- /dev/null +++ b/src/lib/services/nostr/relay-write-proof.ts @@ -0,0 +1,89 @@ +/** + * Service for verifying that a user can write to at least one default relay + * This replaces rate limiting by requiring proof of relay write capability + */ + +import { verifyEvent, getEventHash } from 'nostr-tools'; +import type { NostrEvent } from '../../types/nostr.js'; +import { NostrClient } from './nostr-client.js'; +import { DEFAULT_NOSTR_RELAYS } from '../../config.js'; + +export interface RelayWriteProof { + event: NostrEvent; + relay: string; + timestamp: number; +} + +/** + * Verify that a user can write to at least one default relay + * The proof should be a recent event (within last 5 minutes) published to a default relay + */ +export async function verifyRelayWriteProof( + proofEvent: NostrEvent, + userPubkey: string, + relays: string[] = DEFAULT_NOSTR_RELAYS +): Promise<{ valid: boolean; error?: string; relay?: string }> { + // Verify the event signature + if (!verifyEvent(proofEvent)) { + return { valid: false, error: 'Invalid event signature' }; + } + + // Verify the pubkey matches + if (proofEvent.pubkey !== userPubkey) { + return { valid: false, error: 'Event pubkey does not match user pubkey' }; + } + + // Verify the event is recent (within last 5 minutes) + const now = Math.floor(Date.now() / 1000); + const eventAge = now - proofEvent.created_at; + if (eventAge > 300) { // 5 minutes + return { valid: false, error: 'Proof event is too old (must be within 5 minutes)' }; + } + if (eventAge < 0) { + return { valid: false, error: 'Proof event has future timestamp' }; + } + + // Try to verify the event exists on at least one default relay + const nostrClient = new NostrClient(relays); + try { + const events = await nostrClient.fetchEvents([ + { + ids: [proofEvent.id], + authors: [userPubkey], + limit: 1 + } + ]); + + if (events.length === 0) { + return { valid: false, error: 'Proof event not found on any default relay' }; + } + + // Verify the fetched event matches + const fetchedEvent = events[0]; + if (fetchedEvent.id !== proofEvent.id) { + return { valid: false, error: 'Fetched event does not match proof event' }; + } + + // Determine which relay(s) have the event (we can't know for sure, but we verified it exists) + return { valid: true, relay: relays[0] }; // Return first relay as indication + } catch (error) { + return { + valid: false, + error: `Failed to verify proof on relays: ${error instanceof Error ? error.message : String(error)}` + }; + } +} + +/** + * Create a proof event that can be used to prove relay write capability + * This is a simple kind 1 (text note) event with a specific content + */ +export function createProofEvent(userPubkey: string, content: string = 'gitrepublic-write-proof'): Omit { + return { + kind: 1, + pubkey: userPubkey, + created_at: Math.floor(Date.now() / 1000), + content: content, + tags: [['t', 'gitrepublic-proof']] + }; +} diff --git a/src/routes/api/git/[...path]/+server.ts b/src/routes/api/git/[...path]/+server.ts index 0bcc7fe..420ee43 100644 --- a/src/routes/api/git/[...path]/+server.ts +++ b/src/routes/api/git/[...path]/+server.ts @@ -3,38 +3,387 @@ * Handles git clone, push, pull operations via git-http-backend */ -import { json } from '@sveltejs/kit'; +import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; +import { RepoManager } from '$lib/services/git/repo-manager.js'; +import { verifyEvent } from 'nostr-tools'; +import { nip19 } from 'nostr-tools'; +import { spawn, execSync } from 'child_process'; +import { existsSync } from 'fs'; +import { join } from 'path'; +import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; +import { NostrClient } from '$lib/services/nostr/nostr-client.js'; +import { KIND } from '$lib/types/nostr.js'; +import type { NostrEvent } from '$lib/types/nostr.js'; -// This will be implemented to proxy requests to git-http-backend -// For now, return a placeholder -export const GET: RequestHandler = async ({ params, url }) => { - const path = params.path || ''; - const service = url.searchParams.get('service'); +const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; +const repoManager = new RepoManager(repoRoot); +const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); + +// Path to git-http-backend (common locations) +const GIT_HTTP_BACKEND_PATHS = [ + '/usr/lib/git-core/git-http-backend', + '/usr/libexec/git-core/git-http-backend', + '/usr/local/libexec/git-core/git-http-backend', + '/opt/homebrew/libexec/git-core/git-http-backend' +]; + +/** + * Find git-http-backend executable + */ +function findGitHttpBackend(): string | null { + for (const path of GIT_HTTP_BACKEND_PATHS) { + if (existsSync(path)) { + return path; + } + } + // Try to find it via which/whereis + try { + const result = execSync('which git-http-backend 2>/dev/null || whereis -b git-http-backend 2>/dev/null', { encoding: 'utf-8' }); + const lines = result.trim().split(/\s+/); + for (const line of lines) { + if (line.includes('git-http-backend') && existsSync(line)) { + return line; + } + } + } catch { + // Ignore errors + } + return null; +} + +/** + * Verify NIP-98 authentication for push operations + */ +async function verifyNIP98Auth( + request: Request, + expectedPubkey: string +): Promise<{ valid: boolean; error?: string }> { + const authHeader = request.headers.get('Authorization'); - // TODO: Implement git-http-backend integration - // This should: - // 1. Authenticate using NIP-98 (HTTP auth with Nostr) - // 2. Map URL path to git repo ({domain}/{npub}/{repo-name}.git) - // 3. Proxy request to git-http-backend - // 4. Handle git smart HTTP protocol + if (!authHeader || !authHeader.startsWith('Nostr ')) { + return { valid: false, error: 'Missing or invalid Authorization header (expected "Nostr ")' }; + } + + try { + const eventJson = authHeader.slice(7); // Remove "Nostr " prefix + const nostrEvent: NostrEvent = JSON.parse(eventJson); + + // Verify event signature + if (!verifyEvent(nostrEvent)) { + return { valid: false, error: 'Invalid event signature' }; + } + + // Verify pubkey matches repo owner + if (nostrEvent.pubkey !== expectedPubkey) { + return { valid: false, error: 'Event pubkey does not match repository owner' }; + } + + // Verify event is recent (within last 5 minutes) + const now = Math.floor(Date.now() / 1000); + const eventAge = now - nostrEvent.created_at; + if (eventAge > 300) { + return { valid: false, error: 'Authentication event is too old (must be within 5 minutes)' }; + } + if (eventAge < 0) { + return { valid: false, error: 'Authentication event has future timestamp' }; + } + + // Verify the event method and URL match the request + const methodTag = nostrEvent.tags.find(t => t[0] === 'method'); + const urlTag = nostrEvent.tags.find(t => t[0] === 'u'); + + if (methodTag && methodTag[1] !== request.method) { + return { valid: false, error: 'Event method does not match request method' }; + } + + return { valid: true }; + } catch (err) { + return { + valid: false, + error: `Failed to parse or verify authentication: ${err instanceof Error ? err.message : String(err)}` + }; + } +} + +/** + * Get repository announcement to extract clone URLs for post-receive sync + */ +async function getRepoAnnouncement(npub: string, repoName: string): Promise { + try { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + return null; + } + const pubkey = decoded.data as string; + + const events = await nostrClient.fetchEvents([ + { + kinds: [KIND.REPO_ANNOUNCEMENT], + authors: [pubkey], + '#d': [repoName], + limit: 1 + } + ]); + + return events.length > 0 ? events[0] : null; + } catch { + return null; + } +} + +/** + * Extract clone URLs from repository announcement + */ +function extractCloneUrls(event: NostrEvent): string[] { + const urls: string[] = []; + for (const tag of event.tags) { + if (tag[0] === 'clone') { + for (let i = 1; i < tag.length; i++) { + const url = tag[i]; + if (url && typeof url === 'string') { + urls.push(url); + } + } + } + } + return urls; +} + +export const GET: RequestHandler = async ({ params, url, request }) => { + const path = params.path || ''; - return json({ - message: 'Git HTTP backend not yet implemented', - path, - service + // Parse path: {npub}/{repo-name}.git/{git-path} + const match = path.match(/^([^\/]+)\/([^\/]+)\.git(?:\/(.+))?$/); + if (!match) { + return error(400, 'Invalid path format. Expected: {npub}/{repo-name}.git[/{git-path}]'); + } + + const [, npub, repoName, gitPath = ''] = match; + const service = url.searchParams.get('service'); + + // Validate npub format + try { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + return error(400, 'Invalid npub format'); + } + } catch { + return error(400, 'Invalid npub format'); + } + + // Get repository path + const repoPath = join(repoRoot, npub, `${repoName}.git`); + if (!repoManager.repoExists(repoPath)) { + return error(404, 'Repository not found'); + } + + // Find git-http-backend + const gitHttpBackend = findGitHttpBackend(); + if (!gitHttpBackend) { + return error(500, 'git-http-backend not found. Please install git.'); + } + + // Build PATH_INFO + // For info/refs, git-http-backend expects: /{npub}/{repo-name}.git/info/refs + // For other operations: /{npub}/{repo-name}.git/{git-path} + const pathInfo = gitPath ? `/${npub}/${repoName}.git/${gitPath}` : `/${npub}/${repoName}.git/info/refs`; + + // Set up environment variables for git-http-backend + const envVars = { + ...process.env, + GIT_PROJECT_ROOT: repoRoot, + GIT_HTTP_EXPORT_ALL: '1', + REQUEST_METHOD: request.method, + PATH_INFO: pathInfo, + QUERY_STRING: url.searchParams.toString(), + CONTENT_TYPE: request.headers.get('Content-Type') || '', + CONTENT_LENGTH: request.headers.get('Content-Length') || '0', + HTTP_USER_AGENT: request.headers.get('User-Agent') || '', + }; + + // Execute git-http-backend + return new Promise((resolve) => { + const gitProcess = spawn(gitHttpBackend, [], { + env: envVars, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const chunks: Buffer[] = []; + let errorOutput = ''; + + gitProcess.stdout.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + + gitProcess.stderr.on('data', (chunk: Buffer) => { + errorOutput += chunk.toString(); + }); + + gitProcess.on('close', (code) => { + if (code !== 0 && chunks.length === 0) { + resolve(error(500, `git-http-backend error: ${errorOutput || 'Unknown error'}`)); + return; + } + + const body = Buffer.concat(chunks); + + // Determine content type based on service + let contentType = 'application/x-git-upload-pack-result'; + if (service === 'git-receive-pack' || gitPath === 'git-receive-pack') { + contentType = 'application/x-git-receive-pack-result'; + } else if (service === 'git-upload-pack' || gitPath === 'git-upload-pack') { + contentType = 'application/x-git-upload-pack-result'; + } else if (pathInfo.includes('info/refs')) { + contentType = 'text/plain; charset=utf-8'; + } + + resolve(new Response(body, { + status: code === 0 ? 200 : 500, + headers: { + 'Content-Type': contentType, + 'Content-Length': body.length.toString(), + } + })); + }); + + gitProcess.on('error', (err) => { + resolve(error(500, `Failed to execute git-http-backend: ${err.message}`)); + }); }); }; export const POST: RequestHandler = async ({ params, url, request }) => { const path = params.path || ''; - const service = url.searchParams.get('service'); - - // TODO: Implement git-http-backend integration for push operations - return json({ - message: 'Git HTTP backend not yet implemented', - path, - service + // Parse path: {npub}/{repo-name}.git/{git-path} + const match = path.match(/^([^\/]+)\/([^\/]+)\.git(?:\/(.+))?$/); + if (!match) { + return error(400, 'Invalid path format. Expected: {npub}/{repo-name}.git[/{git-path}]'); + } + + const [, npub, repoName, gitPath = ''] = match; + + // Validate npub format and decode to get pubkey + let repoOwnerPubkey: string; + try { + const decoded = nip19.decode(npub); + if (decoded.type !== 'npub') { + return error(400, 'Invalid npub format'); + } + repoOwnerPubkey = decoded.data as string; + } catch { + return error(400, 'Invalid npub format'); + } + + // Get repository path + const repoPath = join(repoRoot, npub, `${repoName}.git`); + if (!repoManager.repoExists(repoPath)) { + return error(404, 'Repository not found'); + } + + // For push operations (git-receive-pack), require NIP-98 authentication + if (gitPath === 'git-receive-pack' || path.includes('git-receive-pack')) { + const authResult = await verifyNIP98Auth(request, repoOwnerPubkey); + if (!authResult.valid) { + return error(401, authResult.error || 'Authentication required'); + } + } + + // Find git-http-backend + const gitHttpBackend = findGitHttpBackend(); + if (!gitHttpBackend) { + return error(500, 'git-http-backend not found. Please install git.'); + } + + // Build PATH_INFO + const pathInfo = gitPath ? `/${npub}/${repoName}.git/${gitPath}` : `/${npub}/${repoName}.git`; + + // Get request body + const body = await request.arrayBuffer(); + const bodyBuffer = Buffer.from(body); + + // Set up environment variables for git-http-backend + const envVars = { + ...process.env, + GIT_PROJECT_ROOT: repoRoot, + GIT_HTTP_EXPORT_ALL: '1', + REQUEST_METHOD: request.method, + PATH_INFO: pathInfo, + QUERY_STRING: url.searchParams.toString(), + CONTENT_TYPE: request.headers.get('Content-Type') || 'application/x-git-receive-pack-request', + CONTENT_LENGTH: bodyBuffer.length.toString(), + HTTP_USER_AGENT: request.headers.get('User-Agent') || '', + }; + + // Execute git-http-backend + return new Promise((resolve) => { + const gitProcess = spawn(gitHttpBackend, [], { + env: envVars, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + const chunks: Buffer[] = []; + let errorOutput = ''; + + // Write request body to git-http-backend stdin + gitProcess.stdin.write(bodyBuffer); + gitProcess.stdin.end(); + + gitProcess.stdout.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + + gitProcess.stderr.on('data', (chunk: Buffer) => { + errorOutput += chunk.toString(); + }); + + gitProcess.on('close', async (code) => { + // If this was a successful push, sync to other remotes + if (code === 0 && (gitPath === 'git-receive-pack' || path.includes('git-receive-pack'))) { + try { + const announcement = await getRepoAnnouncement(npub, repoName); + if (announcement) { + const cloneUrls = extractCloneUrls(announcement); + const gitDomain = process.env.GIT_DOMAIN || 'localhost:6543'; + const otherUrls = cloneUrls.filter(url => !url.includes(gitDomain)); + if (otherUrls.length > 0) { + // Sync in background (don't wait for it) + repoManager.syncToRemotes(repoPath, otherUrls).catch(err => { + console.error('Failed to sync to remotes after push:', err); + }); + } + } + } catch (err) { + console.error('Failed to sync to remotes:', err); + // Don't fail the request if sync fails + } + } + + if (code !== 0 && chunks.length === 0) { + resolve(error(500, `git-http-backend error: ${errorOutput || 'Unknown error'}`)); + return; + } + + const responseBody = Buffer.concat(chunks); + + // Determine content type + let contentType = 'application/x-git-receive-pack-result'; + if (gitPath === 'git-upload-pack' || path.includes('git-upload-pack')) { + contentType = 'application/x-git-upload-pack-result'; + } + + resolve(new Response(responseBody, { + status: code === 0 ? 200 : 500, + headers: { + 'Content-Type': contentType, + 'Content-Length': responseBody.length.toString(), + } + })); + }); + + gitProcess.on('error', (err) => { + resolve(error(500, `Failed to execute git-http-backend: ${err.message}`)); + }); }); };