diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 2a29e62..bb510a6 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -20,7 +20,7 @@ let pollingService: RepoPollingService | null = null; if (typeof process !== 'undefined') { pollingService = new RepoPollingService(DEFAULT_NOSTR_RELAYS, repoRoot, domain); pollingService.start(); - logger.info('Started repo polling service'); + logger.info({ service: 'repo-polling', relays: DEFAULT_NOSTR_RELAYS.length }, 'Started repo polling service'); } export const handle: Handle = async ({ event, resolve }) => { diff --git a/src/lib/services/git/commit-signer.ts b/src/lib/services/git/commit-signer.ts index da3b679..51c1431 100644 --- a/src/lib/services/git/commit-signer.ts +++ b/src/lib/services/git/commit-signer.ts @@ -183,8 +183,11 @@ export async function createGitCommitSignature( } // Method 2: Use NIP-98 auth event as signature (server-side, for git operations) else if (options.nip98Event) { - // Create a commit signature event using the NIP-98 event's pubkey - // The NIP-98 event itself proves the user can sign, so we reference it + // Security: We cannot create a valid signed event without the private key. + // Instead, we reference the NIP-98 auth event which already proves authentication. + // The NIP-98 event's signature proves the user can sign commits. + // We create an unsigned event template that references the NIP-98 event. + // Note: This event should be signed by the client before being published to relays. const eventTemplate = { kind: KIND.COMMIT_SIGNATURE, pubkey: options.nip98Event.pubkey, @@ -196,11 +199,26 @@ export async function createGitCommitSignature( ], content: `Signed commit: ${commitMessage}\n\nAuthenticated via NIP-98 event: ${options.nip98Event.id}` }; - // For NIP-98, we use the auth event's signature as proof - // The commit signature event references the NIP-98 event - signedEvent = finalizeEvent(eventTemplate, new Uint8Array(32)); // Dummy key, signature comes from NIP-98 - // Note: In practice, we'd want the client to sign this, but for git operations, - // the NIP-98 event proves authentication, so we embed it as a reference + + // Create event ID without signature (will need client to sign) + const serialized = JSON.stringify([ + 0, + eventTemplate.pubkey, + eventTemplate.created_at, + eventTemplate.kind, + eventTemplate.tags, + eventTemplate.content + ]); + const eventId = createHash('sha256').update(serialized).digest('hex'); + + // Use the NIP-98 event's signature as proof of authentication + // The NIP-98 event is already signed and proves the user can sign commits + // We reference it in the commit signature event and use its signature in the trailer + signedEvent = { + ...eventTemplate, + id: eventId, + sig: options.nip98Event.sig // Use NIP-98 event's signature as proof + }; } // Method 3: Use direct nsec/hex key (server-side) else if (options.nsecKey) { @@ -225,6 +243,7 @@ export async function createGitCommitSignature( // Create a signature trailer that git can recognize // Format: Nostr-Signature: + // For NIP-98: uses the NIP-98 auth event's signature as proof const signatureTrailer = `\n\nNostr-Signature: ${signedEvent.id} ${signedEvent.pubkey} ${signedEvent.sig}`; const signedMessage = commitMessage + signatureTrailer; diff --git a/src/lib/services/git/file-manager.ts b/src/lib/services/git/file-manager.ts index 36326c6..9ddd86b 100644 --- a/src/lib/services/git/file-manager.ts +++ b/src/lib/services/git/file-manager.ts @@ -8,7 +8,6 @@ import { readFile, readdir, stat } from 'fs/promises'; import { join, dirname, normalize, resolve } from 'path'; import { existsSync } from 'fs'; import { spawn } from 'child_process'; -import { promisify } from 'util'; import { RepoManager } from './repo-manager.js'; import { createGitCommitSignature } from './commit-signer.js'; import type { NostrEvent } from '../../types/nostr.js'; @@ -392,6 +391,7 @@ export class FileManager { /** * List files and directories in a repository at a given path + * Uses caching to reduce redundant git operations */ async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise { // Validate inputs @@ -415,6 +415,13 @@ export class FileManager { throw new Error('Repository not found'); } + // Check cache first (cache for 2 minutes) + const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path); + const cached = repoCache.get(cacheKey); + if (cached !== null) { + return cached; + } + const git: SimpleGit = simpleGit(repoPath); try { @@ -422,7 +429,10 @@ export class FileManager { const tree = await git.raw(['ls-tree', '-l', ref, path || '.']); if (!tree) { - return []; + const emptyResult: FileEntry[] = []; + // Cache empty result for shorter time (30 seconds) + repoCache.set(cacheKey, emptyResult, 30 * 1000); + return emptyResult; } const entries: FileEntry[] = []; @@ -444,13 +454,18 @@ export class FileManager { } } - return entries.sort((a, b) => { + const sortedEntries = entries.sort((a, b) => { // Directories first, then files, both alphabetically if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1; } return a.name.localeCompare(b.name); }); + + // Cache the result (cache for 2 minutes) + repoCache.set(cacheKey, sortedEntries, 2 * 60 * 1000); + + return sortedEntries; } catch (error) { logger.error({ error, repoPath, ref }, 'Error listing files'); throw new Error(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`); diff --git a/src/lib/services/git/repo-manager.ts b/src/lib/services/git/repo-manager.ts index ae93e44..b41bbcd 100644 --- a/src/lib/services/git/repo-manager.ts +++ b/src/lib/services/git/repo-manager.ts @@ -7,7 +7,6 @@ import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs'; import { join } from 'path'; import { readdir } from 'fs/promises'; import { spawn } from 'child_process'; -import { promisify } from 'util'; import type { NostrEvent } from '../../types/nostr.js'; import { GIT_DOMAIN } from '../../config.js'; import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js'; diff --git a/src/lib/services/nostr/event-cache.ts b/src/lib/services/nostr/event-cache.ts new file mode 100644 index 0000000..c168143 --- /dev/null +++ b/src/lib/services/nostr/event-cache.ts @@ -0,0 +1,205 @@ +/** + * Cache for Nostr events to provide offline access + * Stores events with TTL to reduce relay load and improve resilience + */ + +import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; +import { createHash } from 'crypto'; +import logger from '../logger.js'; + +interface CacheEntry { + events: NostrEvent[]; + timestamp: number; + ttl: number; // Time to live in milliseconds +} + +/** + * Generate cache key from filter + * Creates a deterministic key based on filter parameters + */ +function generateCacheKey(filter: NostrFilter): string { + // Sort filter keys for consistency + const sortedFilter = Object.keys(filter) + .sort() + .reduce((acc, key) => { + const value = filter[key as keyof NostrFilter]; + if (value !== undefined) { + // Sort array values for consistency + if (Array.isArray(value)) { + acc[key] = [...value].sort(); + } else { + acc[key] = value; + } + } + return acc; + }, {} as Record); + + const filterStr = JSON.stringify(sortedFilter); + return createHash('sha256').update(filterStr).digest('hex'); +} + +/** + * Generate cache key for multiple filters + */ +function generateMultiFilterCacheKey(filters: NostrFilter[]): string { + const keys = filters.map(f => generateCacheKey(f)).sort(); + return createHash('sha256').update(keys.join('|')).digest('hex'); +} + +export class EventCache { + private cache: Map = new Map(); + private defaultTTL: number = 5 * 60 * 1000; // 5 minutes default + private maxCacheSize: number = 10000; // Maximum number of cache entries + + constructor(defaultTTL?: number, maxCacheSize?: number) { + if (defaultTTL) { + this.defaultTTL = defaultTTL; + } + if (maxCacheSize) { + this.maxCacheSize = maxCacheSize; + } + + // Cleanup expired entries every 5 minutes + if (typeof setInterval !== 'undefined') { + setInterval(() => { + this.cleanup(); + }, 5 * 60 * 1000); + } + } + + /** + * Get cached events for a filter + */ + get(filters: NostrFilter[]): NostrEvent[] | null { + const key = generateMultiFilterCacheKey(filters); + const entry = this.cache.get(key); + + if (!entry) { + return null; + } + + // Check if entry has expired + const now = Date.now(); + if (now - entry.timestamp > entry.ttl) { + this.cache.delete(key); + return null; + } + + // Filter events to match the current filter (in case filter changed slightly) + // For now, we return all cached events - the caller should filter if needed + return entry.events; + } + + /** + * Set cached events for filters + */ + set(filters: NostrFilter[], events: NostrEvent[], ttl?: number): void { + // Prevent cache from growing too large + if (this.cache.size >= this.maxCacheSize) { + this.evictOldest(); + } + + const key = generateMultiFilterCacheKey(filters); + this.cache.set(key, { + events, + timestamp: Date.now(), + ttl: ttl || this.defaultTTL + }); + } + + /** + * Invalidate cache entries matching a filter pattern + * Useful when events are published/updated + */ + invalidate(filters: NostrFilter[]): void { + const key = generateMultiFilterCacheKey(filters); + this.cache.delete(key); + } + + /** + * Invalidate all cache entries for a specific event ID + * Useful when an event is updated + */ + invalidateEvent(eventId: string): void { + // Find all cache entries containing this event + for (const [key, entry] of this.cache.entries()) { + if (entry.events.some(e => e.id === eventId)) { + this.cache.delete(key); + } + } + } + + /** + * Invalidate all cache entries for a specific pubkey + * Useful when a user's events are updated + */ + invalidatePubkey(pubkey: string): void { + // Find all cache entries containing events from this pubkey + for (const [key, entry] of this.cache.entries()) { + if (entry.events.some(e => e.pubkey === pubkey)) { + this.cache.delete(key); + } + } + } + + /** + * Clear all cache entries + */ + clear(): void { + this.cache.clear(); + } + + /** + * Clear expired entries + */ + cleanup(): void { + const now = Date.now(); + let cleaned = 0; + + for (const [key, entry] of this.cache.entries()) { + if (now - entry.timestamp > entry.ttl) { + this.cache.delete(key); + cleaned++; + } + } + + if (cleaned > 0) { + logger.debug({ cleaned, remaining: this.cache.size }, 'Event cache cleanup'); + } + } + + /** + * Evict oldest entries when cache is full + */ + private evictOldest(): void { + // Sort entries by timestamp (oldest first) + const entries = Array.from(this.cache.entries()) + .map(([key, entry]) => ({ key, timestamp: entry.timestamp })) + .sort((a, b) => a.timestamp - b.timestamp); + + // Remove oldest 10% of entries + const toRemove = Math.max(1, Math.floor(entries.length * 0.1)); + for (let i = 0; i < toRemove; i++) { + this.cache.delete(entries[i].key); + } + + logger.debug({ removed: toRemove, remaining: this.cache.size }, 'Event cache eviction'); + } + + /** + * Get cache statistics + */ + getStats(): { size: number; maxSize: number; entries: number } { + return { + size: this.cache.size, + maxSize: this.maxCacheSize, + entries: Array.from(this.cache.values()).reduce((sum, entry) => sum + entry.events.length, 0) + }; + } +} + +// Singleton instance +export const eventCache = new EventCache( + 5 * 60 * 1000, // 5 minutes default TTL + 10000 // Max 10k cache entries +); diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index 2222196..60e0407 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -6,6 +6,7 @@ import type { NostrEvent, NostrFilter } from '../../types/nostr.js'; import logger from '../logger.js'; import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; +import { eventCache } from './event-cache.js'; // Polyfill WebSocket for Node.js environments (lazy initialization) // Note: The 'module' import warning in browser builds is expected and harmless. @@ -192,6 +193,13 @@ export class NostrClient { } async fetchEvents(filters: NostrFilter[]): Promise { + // Check cache first + const cached = eventCache.get(filters); + if (cached !== null) { + logger.debug({ filters, cachedCount: cached.length }, 'Returning cached events'); + return cached; + } + const events: NostrEvent[] = []; // Fetch from all relays in parallel @@ -212,7 +220,17 @@ export class NostrClient { } } - return Array.from(uniqueEvents.values()); + const finalEvents = Array.from(uniqueEvents.values()); + + // Cache the results (use longer TTL for successful fetches) + if (finalEvents.length > 0 || results.some(r => r.status === 'fulfilled')) { + // Cache successful fetches for 5 minutes, empty results for 1 minute + const ttl = finalEvents.length > 0 ? 5 * 60 * 1000 : 60 * 1000; + eventCache.set(filters, finalEvents, ttl); + logger.debug({ filters, eventCount: finalEvents.length, ttl }, 'Cached events'); + } + + return finalEvents; } private async fetchFromRelay(relay: string, filters: NostrFilter[]): Promise { @@ -368,6 +386,13 @@ export class NostrClient { await Promise.allSettled(promises); + // Invalidate cache for events from this pubkey (new event published) + // This ensures fresh data on next fetch + if (success.length > 0) { + eventCache.invalidatePubkey(event.pubkey); + logger.debug({ eventId: event.id, pubkey: event.pubkey }, 'Invalidated cache after event publish'); + } + return { success, failed }; } diff --git a/src/lib/services/tor/hidden-service.ts b/src/lib/services/tor/hidden-service.ts index 98bbba9..fd2139c 100644 --- a/src/lib/services/tor/hidden-service.ts +++ b/src/lib/services/tor/hidden-service.ts @@ -57,7 +57,7 @@ export async function getTorOnionAddress(): Promise { } } - logger.warn('Tor is enabled but .onion address not found. Set TOR_ONION_ADDRESS env var or configure Tor hidden service.'); + logger.warn({ service: 'tor', checkedPaths: TOR_HOSTNAME_PATHS.length }, 'Tor is enabled but .onion address not found. Set TOR_ONION_ADDRESS env var or configure Tor hidden service.'); return null; } diff --git a/src/lib/utils/error-handler.ts b/src/lib/utils/error-handler.ts new file mode 100644 index 0000000..95e40e5 --- /dev/null +++ b/src/lib/utils/error-handler.ts @@ -0,0 +1,99 @@ +/** + * Standardized error handling utilities + * Provides consistent error handling, logging, and sanitization across the application + */ + +import { error } from '@sveltejs/kit'; +import logger from '../services/logger.js'; +import { sanitizeError } from './security.js'; + +export interface ErrorContext { + operation?: string; + npub?: string; + repo?: string; + filePath?: string; + branch?: string; + [key: string]: unknown; +} + +/** + * Standardized error handler for API endpoints + * Handles errors consistently with proper logging and sanitization + */ +export function handleApiError( + err: unknown, + context: ErrorContext = {}, + defaultMessage: string = 'An error occurred' +): ReturnType { + const sanitizedError = sanitizeError(err); + const errorMessage = err instanceof Error ? err.message : defaultMessage; + + // Log error with structured context (pino-style) + logger.error({ + error: sanitizedError, + ...context + }, `API Error: ${errorMessage}`); + + // Return sanitized error response + return error(500, sanitizedError); +} + +/** + * Handle validation errors (400 Bad Request) + */ +export function handleValidationError( + message: string, + context: ErrorContext = {} +): ReturnType { + logger.warn(context, `Validation Error: ${message}`); + return error(400, message); +} + +/** + * Handle authentication errors (401 Unauthorized) + */ +export function handleAuthError( + message: string = 'Authentication required', + context: ErrorContext = {} +): ReturnType { + logger.warn(context, `Auth Error: ${message}`); + return error(401, message); +} + +/** + * Handle authorization errors (403 Forbidden) + */ +export function handleAuthorizationError( + message: string = 'Insufficient permissions', + context: ErrorContext = {} +): ReturnType { + logger.warn(context, `Authorization Error: ${message}`); + return error(403, message); +} + +/** + * Handle not found errors (404 Not Found) + */ +export function handleNotFoundError( + message: string = 'Resource not found', + context: ErrorContext = {} +): ReturnType { + logger.info(context, `Not Found: ${message}`); + return error(404, message); +} + +/** + * Wrap async handler functions with standardized error handling + */ +export function withErrorHandling Promise>( + handler: T, + defaultContext?: ErrorContext +): T { + return (async (...args: Parameters) => { + try { + return await handler(...args); + } catch (err) { + throw handleApiError(err, defaultContext); + } + }) as T; +} diff --git a/src/routes/api/git/[...path]/+server.ts b/src/routes/api/git/[...path]/+server.ts index 240fe74..364832b 100644 --- a/src/routes/api/git/[...path]/+server.ts +++ b/src/routes/api/git/[...path]/+server.ts @@ -6,9 +6,8 @@ import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { RepoManager } from '$lib/services/git/repo-manager.js'; -import { nip19 } from 'nostr-tools'; import { requireNpubHex } from '$lib/utils/npub-utils.js'; -import { spawn, execSync } from 'child_process'; +import { spawn } from 'child_process'; import { existsSync } from 'fs'; import { join, resolve } from 'path'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; @@ -43,20 +42,58 @@ const GIT_HTTP_BACKEND_PATHS = [ /** * Find git-http-backend executable + * Security: Uses spawn instead of execSync to prevent command injection */ -function findGitHttpBackend(): string | null { +async function findGitHttpBackend(): Promise { for (const path of GIT_HTTP_BACKEND_PATHS) { if (existsSync(path)) { return path; } } - // Try to find it via which/whereis + // Try to find it via which/whereis using spawn (safer than execSync) 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; + // Try 'which' first + try { + const whichResult = await new Promise((resolve, reject) => { + const proc = spawn('which', ['git-http-backend'], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + proc.on('close', (code) => { + if (code === 0 && stdout.trim()) { + resolve(stdout.trim()); + } else { + reject(new Error('not found')); + } + }); + proc.on('error', reject); + }); + if (whichResult && existsSync(whichResult)) { + return whichResult; + } + } catch { + // Try 'whereis' as fallback + try { + const whereisResult = await new Promise((resolve, reject) => { + const proc = spawn('whereis', ['-b', 'git-http-backend'], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stdout = ''; + proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); }); + proc.on('close', (code) => { + if (code === 0 && stdout.trim()) { + resolve(stdout.trim()); + } else { + reject(new Error('not found')); + } + }); + proc.on('error', reject); + }); + const lines = whereisResult.trim().split(/\s+/); + for (const line of lines) { + if (line.includes('git-http-backend') && existsSync(line)) { + return line; + } + } + } catch { + // Ignore errors } } } catch { @@ -90,6 +127,8 @@ async function getRepoAnnouncement(npub: string, repoName: string): Promise { } // Find git-http-backend - const gitHttpBackend = findGitHttpBackend(); + const gitHttpBackend = await findGitHttpBackend(); if (!gitHttpBackend) { return error(500, 'git-http-backend not found. Please install git.'); } @@ -460,7 +499,7 @@ export const POST: RequestHandler = async ({ params, url, request }) => { } // Find git-http-backend - const gitHttpBackend = findGitHttpBackend(); + const gitHttpBackend = await findGitHttpBackend(); if (!gitHttpBackend) { return error(500, 'git-http-backend not found. Please install git.'); } diff --git a/src/routes/api/repos/[npub]/[repo]/branch-protection/+server.ts b/src/routes/api/repos/[npub]/[repo]/branch-protection/+server.ts index e718fc3..03c422f 100644 --- a/src/routes/api/repos/[npub]/[repo]/branch-protection/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branch-protection/+server.ts @@ -12,8 +12,8 @@ import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; import { nip19 } from 'nostr-tools'; import type { BranchProtectionRule } from '$lib/services/nostr/branch-protection-service.js'; -import logger from '$lib/services/logger.js'; import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; +import { handleApiError, handleValidationError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const branchProtectionService = new BranchProtectionService(DEFAULT_NOSTR_RELAYS); const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); @@ -26,7 +26,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getBranchProtection' }); } try { @@ -35,7 +35,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; try { ownerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'getBranchProtection', npub }); } const config = await branchProtectionService.getBranchProtection(ownerPubkey, repo); @@ -46,10 +46,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; return json(config); } catch (err) { - // Security: Sanitize error messages - const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to get branch protection'; - logger.error({ error: sanitizedError, npub, repo }, 'Error getting branch protection'); - return error(500, sanitizedError); + return handleApiError(err, { operation: 'getBranchProtection', npub, repo }, 'Failed to get branch protection'); } }; @@ -60,7 +57,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'updateBranchProtection' }); } try { @@ -68,11 +65,11 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub const { userPubkey, rules } = body; if (!userPubkey) { - return error(401, 'Authentication required'); + return handleAuthError('Authentication required', { operation: 'updateBranchProtection', npub, repo }); } if (!Array.isArray(rules)) { - return error(400, 'Rules must be an array'); + return handleValidationError('Rules must be an array', { operation: 'updateBranchProtection', npub, repo }); } // Decode npub to get pubkey @@ -80,7 +77,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub try { ownerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'updateBranchProtection', npub }); } const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; @@ -88,7 +85,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub // Check if user is owner const currentOwner = await ownershipTransferService.getCurrentOwner(ownerPubkey, repo); if (userPubkeyHex !== currentOwner) { - return error(403, 'Only the repository owner can update branch protection'); + return handleAuthorizationError('Only the repository owner can update branch protection', { operation: 'updateBranchProtection', npub, repo }); } // Validate rules @@ -122,9 +119,6 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub return json({ success: true, event: signedEvent, rules: validatedRules }); } catch (err) { - // Security: Sanitize error messages - const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to update branch protection'; - logger.error({ error: sanitizedError, npub, repo }, 'Error updating branch protection'); - return error(500, sanitizedError); + return handleApiError(err, { operation: 'updateBranchProtection', npub, repo }, 'Failed to update branch protection'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts index 17dc169..2735d31 100644 --- a/src/routes/api/repos/[npub]/[repo]/branches/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/branches/+server.ts @@ -10,7 +10,7 @@ import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -20,19 +20,18 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getBranches' }); } try { if (!fileManager.repoExists(npub, repo)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'getBranches', npub, repo }); } const branches = await fileManager.getBranches(npub, repo); return json(branches); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error getting branches'); - return error(500, err instanceof Error ? err.message : 'Failed to get branches'); + return handleApiError(err, { operation: 'getBranches', npub, repo }, 'Failed to get branches'); } }; @@ -40,7 +39,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'createBranch' }); } let branchName: string | undefined; @@ -51,15 +50,15 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub ({ branchName, fromBranch, userPubkey } = body); if (!branchName) { - return error(400, 'Missing branchName parameter'); + return handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub, repo }); } if (!userPubkey) { - return error(401, 'Authentication required. Please provide userPubkey.'); + return handleAuthError('Authentication required. Please provide userPubkey.', { operation: 'createBranch', npub, repo }); } if (!fileManager.repoExists(npub, repo)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'createBranch', npub, repo }); } // Check if user is a maintainer @@ -67,7 +66,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'createBranch', npub }); } // Convert userPubkey to hex if needed @@ -75,13 +74,12 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo); if (!isMaintainer) { - return error(403, 'Only repository maintainers can create branches. Please submit a pull request instead.'); + return handleAuthorizationError('Only repository maintainers can create branches. Please submit a pull request instead.', { operation: 'createBranch', npub, repo }); } await fileManager.createBranch(npub, repo, branchName, fromBranch || 'main'); return json({ success: true, message: 'Branch created successfully' }); } catch (err) { - logger.error({ error: err, npub, repo, branchName }, 'Error creating branch'); - return error(500, err instanceof Error ? err.message : 'Failed to create branch'); + return handleApiError(err, { operation: 'createBranch', npub, repo, branchName }, 'Failed to create branch'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/commits/+server.ts b/src/routes/api/repos/[npub]/[repo]/commits/+server.ts index ed77066..37de6ff 100644 --- a/src/routes/api/repos/[npub]/[repo]/commits/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/commits/+server.ts @@ -5,7 +5,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { FileManager } from '$lib/services/git/file-manager.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -18,25 +18,24 @@ export const GET: RequestHandler = async ({ params, url, request }) => { const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getCommits' }); } try { if (!fileManager.repoExists(npub, repo)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'getCommits', npub, repo }); } // Check repository privacy const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const access = await checkRepoAccess(npub, repo, userPubkey || null); if (!access.allowed) { - return error(403, access.error || 'Access denied'); + return handleAuthorizationError(access.error || 'Access denied', { operation: 'getCommits', npub, repo }); } const commits = await fileManager.getCommitHistory(npub, repo, branch, limit, path); return json(commits); } catch (err) { - logger.error({ error: err, npub, repo, branch }, 'Error getting commit history'); - return error(500, err instanceof Error ? err.message : 'Failed to get commit history'); + return handleApiError(err, { operation: 'getCommits', npub, repo, branch }, 'Failed to get commit history'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/diff/+server.ts b/src/routes/api/repos/[npub]/[repo]/diff/+server.ts index 8791443..8aecbf0 100644 --- a/src/routes/api/repos/[npub]/[repo]/diff/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/diff/+server.ts @@ -5,7 +5,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { FileManager } from '$lib/services/git/file-manager.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -18,25 +18,24 @@ export const GET: RequestHandler = async ({ params, url, request }) => { const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo || !fromRef) { - return error(400, 'Missing npub, repo, or from parameter'); + return handleValidationError('Missing npub, repo, or from parameter', { operation: 'getDiff' }); } try { if (!fileManager.repoExists(npub, repo)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'getDiff', npub, repo }); } // Check repository privacy const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const access = await checkRepoAccess(npub, repo, userPubkey || null); if (!access.allowed) { - return error(403, access.error || 'Access denied'); + return handleAuthorizationError(access.error || 'Access denied', { operation: 'getDiff', npub, repo }); } const diffs = await fileManager.getDiff(npub, repo, fromRef, toRef, filePath); return json(diffs); } catch (err) { - logger.error({ error: err, npub, repo, fromRef, toRef }, 'Error getting diff'); - return error(500, err instanceof Error ? err.message : 'Failed to get diff'); + return handleApiError(err, { operation: 'getDiff', npub, repo, fromRef, toRef }, 'Failed to get diff'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/download/+server.ts b/src/routes/api/repos/[npub]/[repo]/download/+server.ts index df0d717..c6589e1 100644 --- a/src/routes/api/repos/[npub]/[repo]/download/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/download/+server.ts @@ -7,15 +7,15 @@ 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 { requireNpubHex } from '$lib/utils/npub-utils.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { existsSync, readFileSync } from 'fs'; -import { join } from 'path'; +import { spawn } from 'child_process'; +import { mkdir, rm, readFile } from 'fs/promises'; +import { existsSync } from 'fs'; +import { join, resolve } from 'path'; import logger from '$lib/services/logger.js'; - -const execAsync = promisify(exec); +import { isValidBranchName, sanitizeError } from '$lib/utils/security.js'; +import simpleGit from 'simple-git'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); @@ -48,42 +48,102 @@ export const GET: RequestHandler = async ({ params, url, request }) => { return error(403, 'This repository is private. Only owners and maintainers can view it.'); } + // Security: Validate ref to prevent command injection + if (ref !== 'HEAD' && !isValidBranchName(ref)) { + return error(400, 'Invalid ref format'); + } + + // Security: Validate format + if (format !== 'zip' && format !== 'tar.gz') { + return error(400, 'Invalid format. Must be "zip" or "tar.gz"'); + } + const repoPath = join(repoRoot, npub, `${repo}.git`); + // Security: Ensure resolved path is within repoRoot + const resolvedRepoPath = resolve(repoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); + if (!resolvedRepoPath.startsWith(resolvedRoot + '/')) { + return error(403, 'Invalid repository path'); + } + const tempDir = join(repoRoot, '..', 'temp-downloads'); const workDir = join(tempDir, `${npub}-${repo}-${Date.now()}`); + // Security: Ensure workDir is within tempDir + const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); + const resolvedTempDir = resolve(tempDir).replace(/\\/g, '/'); + if (!resolvedWorkDir.startsWith(resolvedTempDir + '/')) { + return error(500, 'Invalid work directory path'); + } + const archiveName = `${repo}-${ref}.${format === 'tar.gz' ? 'tar.gz' : 'zip'}`; const archivePath = join(tempDir, archiveName); + // Security: Ensure archive path is within tempDir + const resolvedArchivePath = resolve(archivePath).replace(/\\/g, '/'); + if (!resolvedArchivePath.startsWith(resolvedTempDir + '/')) { + return error(500, 'Invalid archive path'); + } try { - // Create temp directory - await execAsync(`mkdir -p "${tempDir}"`); - await execAsync(`mkdir -p "${workDir}"`); + // Create temp directory using fs/promises (safer than shell commands) + await mkdir(tempDir, { recursive: true }); + await mkdir(workDir, { recursive: true }); - // Clone repository to temp directory - await execAsync(`git clone "${repoPath}" "${workDir}"`); + // Clone repository using simple-git (safer than shell commands) + const git = simpleGit(); + await git.clone(repoPath, workDir); // Checkout specific ref if not HEAD if (ref !== 'HEAD') { - await execAsync(`cd "${workDir}" && git checkout "${ref}"`); + const workGit = simpleGit(workDir); + await workGit.checkout(ref); } - // Remove .git directory - await execAsync(`rm -rf "${workDir}/.git"`); + // Remove .git directory using fs/promises + await rm(join(workDir, '.git'), { recursive: true, force: true }); - // Create archive + // Create archive using spawn (safer than exec) if (format === 'tar.gz') { - await execAsync(`cd "${tempDir}" && tar -czf "${archiveName}" -C "${workDir}" .`); + await new Promise((resolve, reject) => { + const tarProcess = spawn('tar', ['-czf', archivePath, '-C', workDir, '.'], { + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stderr = ''; + tarProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + tarProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`tar failed: ${stderr}`)); + } + }); + tarProcess.on('error', reject); + }); } else { - // Use zip command (requires zip utility) - await execAsync(`cd "${workDir}" && zip -r "${archivePath}" .`); + // Use zip command (requires zip utility) - using spawn for safety + await new Promise((resolve, reject) => { + const zipProcess = spawn('zip', ['-r', archivePath, '.'], { + cwd: workDir, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stderr = ''; + zipProcess.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + zipProcess.on('close', (code) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`zip failed: ${stderr}`)); + } + }); + zipProcess.on('error', reject); + }); } - // Read archive file - const archiveBuffer = readFileSync(archivePath); + // Read archive file using fs/promises + const archiveBuffer = await readFile(archivePath); - // Clean up - await execAsync(`rm -rf "${workDir}"`); - await execAsync(`rm -f "${archivePath}"`); + // Clean up using fs/promises + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + await rm(archivePath, { force: true }).catch(() => {}); // Return archive return new Response(archiveBuffer, { @@ -94,13 +154,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => { } }); } catch (archiveError) { - // Clean up on error - await execAsync(`rm -rf "${workDir}"`).catch(() => {}); - await execAsync(`rm -f "${archivePath}"`).catch(() => {}); + // Clean up on error using fs/promises + await rm(workDir, { recursive: true, force: true }).catch(() => {}); + await rm(archivePath, { force: true }).catch(() => {}); + const sanitizedError = sanitizeError(archiveError); + logger.error({ error: sanitizedError, npub, repo, ref, format }, 'Error creating archive'); throw archiveError; } } catch (err) { - logger.error({ error: err, npub, repo }, 'Error creating repository archive'); - return error(500, err instanceof Error ? err.message : 'Failed to create repository archive'); + return handleApiError(err, { operation: 'download', npub, repo, ref, format }, 'Failed to create repository archive'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/file/+server.ts b/src/routes/api/repos/[npub]/[repo]/file/+server.ts index 3bbf3dd..a4c6102 100644 --- a/src/routes/api/repos/[npub]/[repo]/file/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/file/+server.ts @@ -14,6 +14,7 @@ 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'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -82,10 +83,7 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { 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); + return handleApiError(err, { operation: 'readFile', npub, repo, filePath }, 'Failed to read file'); } }; @@ -249,9 +247,6 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: { 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); + return handleApiError(err, { operation: 'writeFile', npub, repo, filePath: path }, 'Failed to write file'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts index 8b11a2f..784f331 100644 --- a/src/routes/api/repos/[npub]/[repo]/fork/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/fork/+server.ts @@ -13,16 +13,17 @@ import { nip19 } from 'nostr-tools'; import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; import { existsSync } from 'fs'; -import { join } from 'path'; +import { rm } from 'fs/promises'; +import { join, resolve } from 'path'; +import simpleGit from 'simple-git'; +import { isValidBranchName } from '$lib/utils/security.js'; import { ResourceLimits } from '$lib/services/security/resource-limits.js'; import { auditLogger } from '$lib/services/security/audit-logger.js'; import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; -const execAsync = promisify(exec); const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const repoManager = new RepoManager(repoRoot); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); @@ -123,6 +124,12 @@ export const POST: RequestHandler = async ({ params, request }) => { // Check if original repo exists const originalRepoPath = join(repoRoot, npub, `${repo}.git`); + // Security: Ensure resolved path is within repoRoot + const resolvedOriginalPath = resolve(originalRepoPath).replace(/\\/g, '/'); + const resolvedRoot = resolve(repoRoot).replace(/\\/g, '/'); + if (!resolvedOriginalPath.startsWith(resolvedRoot + '/')) { + return error(403, 'Invalid repository path'); + } if (!existsSync(originalRepoPath)) { return error(404, 'Original repository not found'); } @@ -145,11 +152,16 @@ export const POST: RequestHandler = async ({ params, request }) => { // Check if fork already exists const forkRepoPath = join(repoRoot, userNpub, `${forkRepoName}.git`); + // Security: Ensure resolved path is within repoRoot + const resolvedForkPath = resolve(forkRepoPath).replace(/\\/g, '/'); + if (!resolvedForkPath.startsWith(resolvedRoot + '/')) { + return error(403, 'Invalid fork repository path'); + } if (existsSync(forkRepoPath)) { return error(409, 'Fork already exists'); } - // Clone the repository + // Clone the repository using simple-git (safer than shell commands) const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown'; auditLogger.logRepoFork( userPubkeyHex, @@ -158,7 +170,8 @@ export const POST: RequestHandler = async ({ params, request }) => { 'success' ); - await execAsync(`git clone --bare "${originalRepoPath}" "${forkRepoPath}"`); + const git = simpleGit(); + await git.clone(originalRepoPath, forkRepoPath, ['--bare']); // Invalidate resource limit cache after creating repo resourceLimits.invalidateCache(userNpub); @@ -263,7 +276,7 @@ export const POST: RequestHandler = async ({ params, request }) => { if (publishResult.success.length === 0) { // Clean up repo if announcement failed logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: publishResult.failed }, 'Fork announcement failed after all retries. Cleaning up repository.'); - await execAsync(`rm -rf "${forkRepoPath}"`).catch(() => {}); + await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {}); const errorDetails = `All relays failed: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`; return json({ success: false, @@ -290,7 +303,7 @@ export const POST: RequestHandler = async ({ params, request }) => { if (ownershipPublishResult.success.length === 0) { // Clean up repo if ownership proof failed logger.error({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}`, failed: ownershipPublishResult.failed }, 'Ownership transfer event failed after all retries. Cleaning up repository and publishing deletion request.'); - await execAsync(`rm -rf "${forkRepoPath}"`).catch(() => {}); + await rm(forkRepoPath, { recursive: true, force: true }).catch(() => {}); // Publish deletion request (NIP-09) for the announcement since it's invalid without ownership proof logger.info({ operation: 'fork', originalRepo: `${npub}/${repo}`, forkRepo: `${userNpub}/${forkRepoName}` }, 'Publishing deletion request for invalid fork announcement...'); @@ -359,11 +372,7 @@ export const POST: RequestHandler = async ({ params, request }) => { message: `Repository forked successfully! Published to ${publishResult.success.length} relay(s) for announcement and ${ownershipPublishResult.success.length} relay(s) for ownership proof.` }); } 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 fork repository'; - const context = npub && repo ? `[${npub}/${repo}]` : '[unknown]'; - logger.error({ error: sanitizedError, npub, repo }, `[Fork] ${context} Error forking repository`); - return error(500, sanitizedError); + return handleApiError(err, { operation: 'fork', npub, repo }, 'Failed to fork repository'); } }; @@ -440,10 +449,6 @@ export const GET: RequestHandler = async ({ params }) => { forkCount }); } catch (err) { - // Security: Sanitize error messages - const sanitizedError = err instanceof Error ? err.message.replace(/nsec[0-9a-z]+/gi, '[REDACTED]').replace(/[0-9a-f]{64}/g, '[REDACTED]') : 'Failed to get fork information'; - const context = npub && repo ? `[${npub}/${repo}]` : '[unknown]'; - logger.error({ error: sanitizedError, npub, repo }, `[Fork] ${context} Error getting fork information`); - return error(500, sanitizedError); + return handleApiError(err, { operation: 'getForkInfo', npub, repo }, 'Failed to get fork information'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts index 755847d..15bfe3d 100644 --- a/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/highlights/+server.ts @@ -13,7 +13,7 @@ import type { NostrEvent } from '$lib/types/nostr.js'; import { combineRelays } from '$lib/config.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { NostrClient } from '$lib/services/nostr/nostr-client.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; const highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); @@ -28,11 +28,11 @@ export const GET: RequestHandler = async ({ params, url }) => { const prAuthor = url.searchParams.get('prAuthor'); if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getHighlights' }); } if (!prId || !prAuthor) { - return error(400, 'Missing prId or prAuthor parameter'); + return handleValidationError('Missing prId or prAuthor parameter', { operation: 'getHighlights', npub, repo }); } try { @@ -41,7 +41,7 @@ export const GET: RequestHandler = async ({ params, url }) => { try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'getHighlights', npub }); } // Decode prAuthor if it's an npub @@ -63,8 +63,7 @@ export const GET: RequestHandler = async ({ params, url }) => { comments: prComments }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error fetching highlights'); - return error(500, err instanceof Error ? err.message : 'Failed to fetch highlights'); + return handleApiError(err, { operation: 'getHighlights', npub, repo, prId }, 'Failed to fetch highlights'); } }; @@ -76,7 +75,7 @@ export const POST: RequestHandler = async ({ params, request }) => { const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'createHighlight' }); } try { @@ -84,20 +83,20 @@ export const POST: RequestHandler = async ({ params, request }) => { const { type, event, userPubkey } = body; if (!type || !event || !userPubkey) { - return error(400, 'Missing type, event, or userPubkey in request body'); + return handleValidationError('Missing type, event, or userPubkey in request body', { operation: 'createHighlight', npub, repo }); } if (type !== 'highlight' && type !== 'comment') { - return error(400, 'Type must be "highlight" or "comment"'); + return handleValidationError('Type must be "highlight" or "comment"', { operation: 'createHighlight', npub, repo }); } // Verify the event is properly signed if (!event.sig || !event.id) { - return error(400, 'Invalid event: missing signature or ID'); + return handleValidationError('Invalid event: missing signature or ID', { operation: 'createHighlight', npub, repo }); } if (!verifyEvent(event)) { - return error(400, 'Invalid event signature'); + return handleValidationError('Invalid event signature', { operation: 'createHighlight', npub, repo }); } // Get user's relays and publish @@ -112,7 +111,6 @@ export const POST: RequestHandler = async ({ params, request }) => { return json({ success: true, event, published: result }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error creating highlight/comment'); - return error(500, err instanceof Error ? err.message : 'Failed to create highlight/comment'); + return handleApiError(err, { operation: 'createHighlight', npub, repo }, 'Failed to create highlight/comment'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts index a33a504..7c75f9f 100644 --- a/src/routes/api/repos/[npub]/[repo]/issues/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/issues/+server.ts @@ -7,15 +7,15 @@ import type { RequestHandler } from './$types'; import { IssuesService } from '$lib/services/nostr/issues-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; -import logger from '$lib/services/logger.js'; import { requireNpubHex } from '$lib/utils/npub-utils.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; export const GET: RequestHandler = async ({ params, url, request }) => { const { npub, repo } = params; const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getIssues' }); } try { @@ -24,14 +24,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => { try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'getIssues', npub }); } // Check repository privacy const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const access = await checkRepoAccess(npub, repo, userPubkey || null); if (!access.allowed) { - return error(403, access.error || 'Access denied'); + return handleAuthorizationError(access.error || 'Access denied', { operation: 'getIssues', npub, repo }); } const issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS); @@ -39,8 +39,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => { return json(issues); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error fetching issues'); - return error(500, err instanceof Error ? err.message : 'Failed to fetch issues'); + return handleApiError(err, { operation: 'getIssues', npub, repo }, 'Failed to fetch issues'); } }; @@ -50,7 +49,7 @@ export const POST: RequestHandler = async ({ params, request }) => { const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'createIssue' }); } try { @@ -58,12 +57,12 @@ export const POST: RequestHandler = async ({ params, request }) => { const { event } = body; if (!event) { - return error(400, 'Missing event in request body'); + return handleValidationError('Missing event in request body', { operation: 'createIssue', npub, repo }); } // Verify the event is properly signed (basic check) if (!event.sig || !event.id) { - return error(400, 'Invalid event: missing signature or ID'); + return handleValidationError('Invalid event: missing signature or ID', { operation: 'createIssue', npub, repo }); } // Publish the event to relays @@ -71,12 +70,11 @@ export const POST: RequestHandler = async ({ params, request }) => { const result = await issuesService['nostrClient'].publishEvent(event, DEFAULT_NOSTR_RELAYS); if (result.failed.length > 0 && result.success.length === 0) { - return error(500, 'Failed to publish issue to all relays'); + return handleApiError(new Error('Failed to publish issue to all relays'), { operation: 'createIssue', npub, repo }, 'Failed to publish issue to all relays'); } return json({ success: true, event, published: result }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error creating issue'); - return error(500, err instanceof Error ? err.message : 'Failed to create issue'); + return handleApiError(err, { operation: 'createIssue', npub, repo }, 'Failed to create issue'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts b/src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts index 7281328..8410b7a 100644 --- a/src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts @@ -9,7 +9,7 @@ import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); @@ -18,7 +18,7 @@ export const GET: RequestHandler = async ({ params, url }: { params: { npub?: st const userPubkey = url.searchParams.get('userPubkey'); if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getMaintainers' }); } try { @@ -27,7 +27,7 @@ export const GET: RequestHandler = async ({ params, url }: { params: { npub?: st try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'getMaintainers', npub }); } const { maintainers, owner } = await maintainerService.getMaintainers(repoOwnerPubkey, repo); @@ -47,7 +47,6 @@ export const GET: RequestHandler = async ({ params, url }: { params: { npub?: st return json({ maintainers, owner }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error checking maintainers'); - return error(500, err instanceof Error ? err.message : 'Failed to check maintainers'); + return handleApiError(err, { operation: 'getMaintainers', npub, repo }, 'Failed to check maintainers'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts index ea19d83..cf8b391 100644 --- a/src/routes/api/repos/[npub]/[repo]/prs/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/prs/+server.ts @@ -9,14 +9,14 @@ import { PRsService } from '$lib/services/nostr/prs-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; import { requireNpubHex } from '$lib/utils/npub-utils.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => { const { npub, repo } = params; const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getPRs' }); } try { @@ -25,14 +25,14 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'getPRs', npub }); } // Check repository privacy const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const access = await checkRepoAccess(npub, repo, userPubkey || null); if (!access.allowed) { - return error(403, access.error || 'Access denied'); + return handleAuthorizationError(access.error || 'Access denied', { operation: 'getPRs', npub, repo }); } const prsService = new PRsService(DEFAULT_NOSTR_RELAYS); @@ -40,8 +40,7 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { return json(prs); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error fetching pull requests'); - return error(500, err instanceof Error ? err.message : 'Failed to fetch pull requests'); + return handleApiError(err, { operation: 'getPRs', npub, repo }, 'Failed to fetch pull requests'); } }; @@ -50,7 +49,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'createPR' }); } try { @@ -58,12 +57,12 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub const { event } = body; if (!event) { - return error(400, 'Missing event in request body'); + return handleValidationError('Missing event in request body', { operation: 'createPR', npub, repo }); } // Verify the event is properly signed if (!event.sig || !event.id) { - return error(400, 'Invalid event: missing signature or ID'); + return handleValidationError('Invalid event: missing signature or ID', { operation: 'createPR', npub, repo }); } // Publish the event to relays @@ -71,12 +70,11 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub const result = await prsService['nostrClient'].publishEvent(event, DEFAULT_NOSTR_RELAYS); if (result.failed.length > 0 && result.success.length === 0) { - return error(500, 'Failed to publish pull request to all relays'); + return handleApiError(new Error('Failed to publish pull request to all relays'), { operation: 'createPR', npub, repo }, 'Failed to publish pull request to all relays'); } return json({ success: true, event, published: result }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error creating pull request'); - return error(500, err instanceof Error ? err.message : 'Failed to create pull request'); + return handleApiError(err, { operation: 'createPR', npub, repo }, 'Failed to create pull request'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/raw/+server.ts b/src/routes/api/repos/[npub]/[repo]/raw/+server.ts index 19a2af4..459c51b 100644 --- a/src/routes/api/repos/[npub]/[repo]/raw/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/raw/+server.ts @@ -9,7 +9,7 @@ import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; import { requireNpubHex } from '$lib/utils/npub-utils.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -22,12 +22,12 @@ export const GET: RequestHandler = async ({ params, url, request }) => { 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'); + return handleValidationError('Missing npub, repo, or path parameter', { operation: 'getRawFile' }); } try { if (!fileManager.repoExists(npub, repo)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'getRawFile', npub, repo }); } // Check repository privacy @@ -35,12 +35,12 @@ export const GET: RequestHandler = async ({ params, url, request }) => { try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'getRawFile', npub }); } const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); if (!canView) { - return error(403, 'This repository is private. Only owners and maintainers can view it.'); + return handleAuthorizationError('This repository is private. Only owners and maintainers can view it.', { operation: 'getRawFile', npub, repo }); } // Get file content @@ -79,7 +79,6 @@ export const GET: RequestHandler = async ({ params, url, request }) => { } }); } catch (err) { - logger.error({ error: err, npub, repo, filePath }, 'Error getting raw file'); - return error(500, err instanceof Error ? err.message : 'Failed to get raw file'); + return handleApiError(err, { operation: 'getRawFile', npub, repo, filePath }, 'Failed to get raw file'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/readme/+server.ts b/src/routes/api/repos/[npub]/[repo]/readme/+server.ts index 02798fa..541695f 100644 --- a/src/routes/api/repos/[npub]/[repo]/readme/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/readme/+server.ts @@ -9,7 +9,7 @@ import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; import { requireNpubHex } from '$lib/utils/npub-utils.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -32,12 +32,12 @@ export const GET: RequestHandler = async ({ params, url, request }) => { const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getReadme' }); } try { if (!fileManager.repoExists(npub, repo)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'getReadme', npub, repo }); } // Check repository privacy @@ -45,12 +45,12 @@ export const GET: RequestHandler = async ({ params, url, request }) => { try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'getReadme', npub }); } const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); if (!canView) { - return error(403, 'This repository is private. Only owners and maintainers can view it.'); + return handleAuthorizationError('This repository is private. Only owners and maintainers can view it.', { operation: 'getReadme', npub, repo }); } // Try to find README file @@ -88,7 +88,6 @@ export const GET: RequestHandler = async ({ params, url, request }) => { isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown') }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error getting README'); - return error(500, err instanceof Error ? err.message : 'Failed to get README'); + return handleApiError(err, { operation: 'getReadme', npub, repo }, 'Failed to get README'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/settings/+server.ts b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts index d4791fd..b889ff0 100644 --- a/src/routes/api/repos/[npub]/[repo]/settings/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/settings/+server.ts @@ -14,6 +14,7 @@ import { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js'; import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import logger from '$lib/services/logger.js'; import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); @@ -27,7 +28,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => { const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getSettings' }); } try { @@ -36,19 +37,19 @@ export const GET: RequestHandler = async ({ params, url, request }) => { try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'getSettings', npub }); } // Check if user is owner if (!userPubkey) { - return error(401, 'Authentication required'); + return handleAuthError('Authentication required', { operation: 'getSettings', npub, repo }); } const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); if (userPubkeyHex !== currentOwner) { - return error(403, 'Only the repository owner can access settings'); + return handleAuthorizationError('Only the repository owner can access settings', { operation: 'getSettings', npub, repo }); } // Get repository announcement @@ -62,7 +63,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => { ]); if (events.length === 0) { - return error(404, 'Repository announcement not found'); + return handleNotFoundError('Repository announcement not found', { operation: 'getSettings', npub, repo }); } const announcement = events[0]; @@ -89,8 +90,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => { npub }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error getting repository settings'); - return error(500, err instanceof Error ? err.message : 'Failed to get repository settings'); + return handleApiError(err, { operation: 'getSettings', npub, repo }, 'Failed to get repository settings'); } }; @@ -101,7 +101,7 @@ export const POST: RequestHandler = async ({ params, request }) => { const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'updateSettings' }); } try { @@ -109,7 +109,7 @@ export const POST: RequestHandler = async ({ params, request }) => { const { userPubkey, name, description, cloneUrls, maintainers, isPrivate } = body; if (!userPubkey) { - return error(401, 'Authentication required'); + return handleAuthError('Authentication required', { operation: 'updateSettings', npub, repo }); } // Decode npub to get pubkey @@ -117,7 +117,7 @@ export const POST: RequestHandler = async ({ params, request }) => { try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'updateSettings', npub }); } const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; @@ -125,7 +125,7 @@ export const POST: RequestHandler = async ({ params, request }) => { // Check if user is owner const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); if (userPubkeyHex !== currentOwner) { - return error(403, 'Only the repository owner can update settings'); + return handleAuthorizationError('Only the repository owner can update settings', { operation: 'updateSettings', npub, repo }); } // Get existing announcement @@ -139,7 +139,7 @@ export const POST: RequestHandler = async ({ params, request }) => { ]); if (events.length === 0) { - return error(404, 'Repository announcement not found'); + return handleNotFoundError('Repository announcement not found', { operation: 'updateSettings', npub, repo }); } const existingAnnouncement = events[0]; @@ -225,7 +225,6 @@ export const POST: RequestHandler = async ({ params, request }) => { return json({ success: true, event: signedEvent }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error updating repository settings'); - return error(500, err instanceof Error ? err.message : 'Failed to update repository settings'); + return handleApiError(err, { operation: 'updateSettings', npub, repo }, 'Failed to update repository settings'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts index 1da4f54..4bf0a50 100644 --- a/src/routes/api/repos/[npub]/[repo]/tags/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tags/+server.ts @@ -10,7 +10,7 @@ import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -21,26 +21,25 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: { const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getTags' }); } try { if (!fileManager.repoExists(npub, repo)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'getTags', npub, repo }); } // Check repository privacy const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const access = await checkRepoAccess(npub, repo, userPubkey || null); if (!access.allowed) { - return error(403, access.error || 'Access denied'); + return handleAuthorizationError(access.error || 'Access denied', { operation: 'getTags', npub, repo }); } const tags = await fileManager.getTags(npub, repo); return json(tags); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error getting tags'); - return error(500, err instanceof Error ? err.message : 'Failed to get tags'); + return handleApiError(err, { operation: 'getTags', npub, repo }, 'Failed to get tags'); } }; @@ -48,7 +47,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'createTag' }); } let tagName: string | undefined; @@ -60,15 +59,15 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub ({ tagName, ref, message, userPubkey } = body); if (!tagName) { - return error(400, 'Missing tagName parameter'); + return handleValidationError('Missing tagName parameter', { operation: 'createTag', npub, repo }); } if (!userPubkey) { - return error(401, 'Authentication required. Please provide userPubkey.'); + return handleAuthError('Authentication required. Please provide userPubkey.', { operation: 'createTag', npub, repo }); } if (!fileManager.repoExists(npub, repo)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'createTag', npub, repo }); } // Check if user is a maintainer @@ -76,7 +75,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'createTag', npub }); } // Convert userPubkey to hex if needed @@ -84,13 +83,12 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo); if (!isMaintainer) { - return error(403, 'Only repository maintainers can create tags.'); + return handleAuthorizationError('Only repository maintainers can create tags.', { operation: 'createTag', npub, repo }); } await fileManager.createTag(npub, repo, tagName, ref || 'HEAD', message); return json({ success: true, message: 'Tag created successfully' }); } catch (err) { - logger.error({ error: err, npub, repo, tagName }, 'Error creating tag'); - return error(500, err instanceof Error ? err.message : 'Failed to create tag'); + return handleApiError(err, { operation: 'createTag', npub, repo, tagName }, 'Failed to create tag'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts b/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts index 71911de..8ffd53b 100644 --- a/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/transfer/+server.ts @@ -13,7 +13,7 @@ import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; import { verifyEvent } from 'nostr-tools'; import type { NostrEvent } from '$lib/types/nostr.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleAuthError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); @@ -25,7 +25,7 @@ export const GET: RequestHandler = async ({ params }) => { const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'getOwnership' }); } try { @@ -34,7 +34,7 @@ export const GET: RequestHandler = async ({ params }) => { try { originalOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'getOwnership', npub }); } // Get current owner (may be different if transferred) @@ -69,8 +69,7 @@ export const GET: RequestHandler = async ({ params }) => { }) }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error fetching ownership info'); - return error(500, err instanceof Error ? err.message : 'Failed to fetch ownership info'); + return handleApiError(err, { operation: 'getOwnership', npub, repo }, 'Failed to fetch ownership info'); } }; @@ -82,7 +81,7 @@ export const POST: RequestHandler = async ({ params, request }) => { const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'transferOwnership' }); } try { @@ -90,16 +89,16 @@ export const POST: RequestHandler = async ({ params, request }) => { const { transferEvent, userPubkey } = body; if (!transferEvent || !userPubkey) { - return error(400, 'Missing transferEvent or userPubkey in request body'); + return handleValidationError('Missing transferEvent or userPubkey in request body', { operation: 'transferOwnership', npub, repo }); } // Verify the event is properly signed if (!transferEvent.sig || !transferEvent.id) { - return error(400, 'Invalid event: missing signature or ID'); + return handleValidationError('Invalid event: missing signature or ID', { operation: 'transferOwnership', npub, repo }); } if (!verifyEvent(transferEvent)) { - return error(400, 'Invalid event signature'); + return handleValidationError('Invalid event signature', { operation: 'transferOwnership', npub, repo }); } // Decode npub to get original owner pubkey @@ -107,7 +106,7 @@ export const POST: RequestHandler = async ({ params, request }) => { try { originalOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'transferOwnership', npub }); } // Verify user is the current owner @@ -118,24 +117,24 @@ export const POST: RequestHandler = async ({ params, request }) => { ); if (!canTransfer) { - return error(403, 'Only the current repository owner can transfer ownership'); + return handleAuthorizationError('Only the current repository owner can transfer ownership', { operation: 'transferOwnership', npub, repo }); } // Verify the transfer event is from the current owner if (transferEvent.pubkey !== userPubkey) { - return error(403, 'Transfer event must be signed by the current owner'); + return handleAuthorizationError('Transfer event must be signed by the current owner', { operation: 'transferOwnership', npub, repo }); } // Verify it's an ownership transfer event if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) { - return error(400, `Event must be kind ${KIND.OWNERSHIP_TRANSFER} (ownership transfer)`); + return handleValidationError(`Event must be kind ${KIND.OWNERSHIP_TRANSFER} (ownership transfer)`, { operation: 'transferOwnership', npub, repo }); } // Verify the 'a' tag references this repo const aTag = transferEvent.tags.find(t => t[0] === 'a'); const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`; if (!aTag || aTag[1] !== expectedRepoTag) { - return error(400, "Transfer event 'a' tag does not match this repository"); + return handleValidationError("Transfer event 'a' tag does not match this repository", { operation: 'transferOwnership', npub, repo }); } // Get user's relays and publish @@ -158,7 +157,6 @@ export const POST: RequestHandler = async ({ params, request }) => { message: 'Ownership transfer initiated successfully' }); } catch (err) { - logger.error({ error: err, npub, repo }, 'Error transferring ownership'); - return error(500, err instanceof Error ? err.message : 'Failed to transfer ownership'); + return handleApiError(err, { operation: 'transferOwnership', npub, repo }, 'Failed to transfer ownership'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts index 8a1e6f4..d7e1b1e 100644 --- a/src/routes/api/repos/[npub]/[repo]/tree/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/tree/+server.ts @@ -9,7 +9,7 @@ import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { nip19 } from 'nostr-tools'; import { requireNpubHex } from '$lib/utils/npub-utils.js'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError, handleNotFoundError, handleAuthorizationError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -22,12 +22,12 @@ export const GET: RequestHandler = async ({ params, url, request }) => { const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'listFiles' }); } try { if (!fileManager.repoExists(npub, repo)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'listFiles', npub, repo }); } // Check repository privacy @@ -35,18 +35,17 @@ export const GET: RequestHandler = async ({ params, url, request }) => { try { repoOwnerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'listFiles', npub }); } const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); if (!canView) { - return error(403, 'This repository is private. Only owners and maintainers can view it.'); + return handleAuthorizationError('This repository is private. Only owners and maintainers can view it.', { operation: 'listFiles', npub, repo }); } const files = await fileManager.listFiles(npub, repo, ref, path); return json(files); } catch (err) { - logger.error({ error: err, npub, repo, path, ref }, 'Error listing files'); - return error(500, err instanceof Error ? err.message : 'Failed to list files'); + return handleApiError(err, { operation: 'listFiles', npub, repo, path, ref }, 'Failed to list files'); } }; diff --git a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts index ca0e21f..7f2d503 100644 --- a/src/routes/api/repos/[npub]/[repo]/verify/+server.ts +++ b/src/routes/api/repos/[npub]/[repo]/verify/+server.ts @@ -16,6 +16,7 @@ import { existsSync } from 'fs'; import logger from '$lib/services/logger.js'; import { join } from 'path'; import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; +import { handleApiError, handleValidationError, handleNotFoundError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -26,7 +27,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; const { npub, repo } = params; if (!npub || !repo) { - return error(400, 'Missing npub or repo parameter'); + return handleValidationError('Missing npub or repo parameter', { operation: 'verifyRepo' }); } try { @@ -35,13 +36,13 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; try { ownerPubkey = requireNpubHex(npub); } catch { - return error(400, 'Invalid npub format'); + return handleValidationError('Invalid npub format', { operation: 'verifyRepo', npub }); } // Check if repository exists (using FileManager's internal method) const repoPath = join(repoRoot, npub, `${repo}.git`); if (!existsSync(repoPath)) { - return error(404, 'Repository not found'); + return handleNotFoundError('Repository not found', { operation: 'verifyRepo', npub, repo }); } // Fetch the repository announcement @@ -143,7 +144,6 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string; }); } } catch (err) { - logger.error({ error: err, npub, repo }, 'Error verifying repository'); - return error(500, err instanceof Error ? err.message : 'Failed to verify repository'); + return handleApiError(err, { operation: 'verifyRepo', npub, repo }, 'Failed to verify repository'); } }; diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index 0899aec..fd8d23f 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -11,7 +11,7 @@ import { FileManager } from '$lib/services/git/file-manager.js'; import { nip19 } from 'nostr-tools'; import { existsSync } from 'fs'; import { join } from 'path'; -import logger from '$lib/services/logger.js'; +import { handleApiError, handleValidationError } from '$lib/utils/error-handler.js'; const repoRoot = process.env.GIT_REPO_ROOT || '/repos'; const fileManager = new FileManager(repoRoot); @@ -22,11 +22,11 @@ export const GET: RequestHandler = async ({ url }) => { const limit = parseInt(url.searchParams.get('limit') || '20', 10); if (!query || query.trim().length === 0) { - return error(400, 'Missing or empty query parameter'); + return handleValidationError('Missing or empty query parameter', { operation: 'search' }); } if (query.length < 2) { - return error(400, 'Query must be at least 2 characters'); + return handleValidationError('Query must be at least 2 characters', { operation: 'search', query }); } try { @@ -55,10 +55,9 @@ export const GET: RequestHandler = async ({ url }) => { } ]); - logger.info({ query, eventCount: events.length }, 'NIP-50 search results'); + // NIP-50 search succeeded } catch (nip50Error) { // Fallback to manual filtering if NIP-50 fails or isn't supported - logger.warn({ error: nip50Error, query }, 'NIP-50 search failed, falling back to manual filtering'); const allEvents = await nostrClient.fetchEvents([ { @@ -189,7 +188,6 @@ export const GET: RequestHandler = async ({ url }) => { total: results.repos.length + results.code.length }); } catch (err) { - logger.error({ error: err, query }, 'Error searching'); - return error(500, err instanceof Error ? err.message : 'Failed to search'); + return handleApiError(err, { operation: 'search', query, type }, 'Failed to search'); } };