Browse Source

more bug-fixes

deeper caching
standardized error handling
main
Silberengel 4 weeks ago
parent
commit
78ccf5cd80
  1. 2
      src/hooks.server.ts
  2. 33
      src/lib/services/git/commit-signer.ts
  3. 21
      src/lib/services/git/file-manager.ts
  4. 1
      src/lib/services/git/repo-manager.ts
  5. 205
      src/lib/services/nostr/event-cache.ts
  6. 27
      src/lib/services/nostr/nostr-client.ts
  7. 2
      src/lib/services/tor/hidden-service.ts
  8. 99
      src/lib/utils/error-handler.ts
  9. 61
      src/routes/api/git/[...path]/+server.ts
  10. 26
      src/routes/api/repos/[npub]/[repo]/branch-protection/+server.ts
  11. 24
      src/routes/api/repos/[npub]/[repo]/branches/+server.ts
  12. 11
      src/routes/api/repos/[npub]/[repo]/commits/+server.ts
  13. 11
      src/routes/api/repos/[npub]/[repo]/diff/+server.ts
  14. 119
      src/routes/api/repos/[npub]/[repo]/download/+server.ts
  15. 11
      src/routes/api/repos/[npub]/[repo]/file/+server.ts
  16. 41
      src/routes/api/repos/[npub]/[repo]/fork/+server.ts
  17. 24
      src/routes/api/repos/[npub]/[repo]/highlights/+server.ts
  18. 22
      src/routes/api/repos/[npub]/[repo]/issues/+server.ts
  19. 9
      src/routes/api/repos/[npub]/[repo]/maintainers/+server.ts
  20. 22
      src/routes/api/repos/[npub]/[repo]/prs/+server.ts
  21. 13
      src/routes/api/repos/[npub]/[repo]/raw/+server.ts
  22. 13
      src/routes/api/repos/[npub]/[repo]/readme/+server.ts
  23. 27
      src/routes/api/repos/[npub]/[repo]/settings/+server.ts
  24. 26
      src/routes/api/repos/[npub]/[repo]/tags/+server.ts
  25. 30
      src/routes/api/repos/[npub]/[repo]/transfer/+server.ts
  26. 13
      src/routes/api/repos/[npub]/[repo]/tree/+server.ts
  27. 10
      src/routes/api/repos/[npub]/[repo]/verify/+server.ts
  28. 12
      src/routes/api/search/+server.ts

2
src/hooks.server.ts

@ -20,7 +20,7 @@ let pollingService: RepoPollingService | null = null;
if (typeof process !== 'undefined') { if (typeof process !== 'undefined') {
pollingService = new RepoPollingService(DEFAULT_NOSTR_RELAYS, repoRoot, domain); pollingService = new RepoPollingService(DEFAULT_NOSTR_RELAYS, repoRoot, domain);
pollingService.start(); 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 }) => { export const handle: Handle = async ({ event, resolve }) => {

33
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) // Method 2: Use NIP-98 auth event as signature (server-side, for git operations)
else if (options.nip98Event) { else if (options.nip98Event) {
// Create a commit signature event using the NIP-98 event's pubkey // Security: We cannot create a valid signed event without the private key.
// The NIP-98 event itself proves the user can sign, so we reference it // 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 = { const eventTemplate = {
kind: KIND.COMMIT_SIGNATURE, kind: KIND.COMMIT_SIGNATURE,
pubkey: options.nip98Event.pubkey, 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}` 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 // Create event ID without signature (will need client to sign)
signedEvent = finalizeEvent(eventTemplate, new Uint8Array(32)); // Dummy key, signature comes from NIP-98 const serialized = JSON.stringify([
// Note: In practice, we'd want the client to sign this, but for git operations, 0,
// the NIP-98 event proves authentication, so we embed it as a reference 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) // Method 3: Use direct nsec/hex key (server-side)
else if (options.nsecKey) { else if (options.nsecKey) {
@ -225,6 +243,7 @@ export async function createGitCommitSignature(
// Create a signature trailer that git can recognize // Create a signature trailer that git can recognize
// Format: Nostr-Signature: <event-id> <pubkey> <signature> // Format: Nostr-Signature: <event-id> <pubkey> <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 signatureTrailer = `\n\nNostr-Signature: ${signedEvent.id} ${signedEvent.pubkey} ${signedEvent.sig}`;
const signedMessage = commitMessage + signatureTrailer; const signedMessage = commitMessage + signatureTrailer;

21
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 { join, dirname, normalize, resolve } from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { promisify } from 'util';
import { RepoManager } from './repo-manager.js'; import { RepoManager } from './repo-manager.js';
import { createGitCommitSignature } from './commit-signer.js'; import { createGitCommitSignature } from './commit-signer.js';
import type { NostrEvent } from '../../types/nostr.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 * 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<FileEntry[]> { async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise<FileEntry[]> {
// Validate inputs // Validate inputs
@ -415,6 +415,13 @@ export class FileManager {
throw new Error('Repository not found'); 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<FileEntry[]>(cacheKey);
if (cached !== null) {
return cached;
}
const git: SimpleGit = simpleGit(repoPath); const git: SimpleGit = simpleGit(repoPath);
try { try {
@ -422,7 +429,10 @@ export class FileManager {
const tree = await git.raw(['ls-tree', '-l', ref, path || '.']); const tree = await git.raw(['ls-tree', '-l', ref, path || '.']);
if (!tree) { 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[] = []; 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 // Directories first, then files, both alphabetically
if (a.type !== b.type) { if (a.type !== b.type) {
return a.type === 'directory' ? -1 : 1; return a.type === 'directory' ? -1 : 1;
} }
return a.name.localeCompare(b.name); return a.name.localeCompare(b.name);
}); });
// Cache the result (cache for 2 minutes)
repoCache.set(cacheKey, sortedEntries, 2 * 60 * 1000);
return sortedEntries;
} catch (error) { } catch (error) {
logger.error({ error, repoPath, ref }, 'Error listing files'); logger.error({ error, repoPath, ref }, 'Error listing files');
throw new Error(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`); throw new Error(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`);

1
src/lib/services/git/repo-manager.ts

@ -7,7 +7,6 @@ import { existsSync, mkdirSync, writeFileSync, statSync } from 'fs';
import { join } from 'path'; import { join } from 'path';
import { readdir } from 'fs/promises'; import { readdir } from 'fs/promises';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { promisify } from 'util';
import type { NostrEvent } from '../../types/nostr.js'; import type { NostrEvent } from '../../types/nostr.js';
import { GIT_DOMAIN } from '../../config.js'; import { GIT_DOMAIN } from '../../config.js';
import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js'; import { generateVerificationFile, VERIFICATION_FILE_PATH } from '../nostr/repo-verification.js';

205
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<string, unknown>);
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<string, CacheEntry> = 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
);

27
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 logger from '../logger.js';
import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js';
import { shouldUseTor, getTorProxy } from '../../utils/tor.js'; import { shouldUseTor, getTorProxy } from '../../utils/tor.js';
import { eventCache } from './event-cache.js';
// Polyfill WebSocket for Node.js environments (lazy initialization) // Polyfill WebSocket for Node.js environments (lazy initialization)
// Note: The 'module' import warning in browser builds is expected and harmless. // Note: The 'module' import warning in browser builds is expected and harmless.
@ -192,6 +193,13 @@ export class NostrClient {
} }
async fetchEvents(filters: NostrFilter[]): Promise<NostrEvent[]> { async fetchEvents(filters: NostrFilter[]): Promise<NostrEvent[]> {
// 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[] = []; const events: NostrEvent[] = [];
// Fetch from all relays in parallel // 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<NostrEvent[]> { private async fetchFromRelay(relay: string, filters: NostrFilter[]): Promise<NostrEvent[]> {
@ -368,6 +386,13 @@ export class NostrClient {
await Promise.allSettled(promises); 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 }; return { success, failed };
} }

2
src/lib/services/tor/hidden-service.ts

@ -57,7 +57,7 @@ export async function getTorOnionAddress(): Promise<string | null> {
} }
} }
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; return null;
} }

99
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<typeof error> {
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<typeof error> {
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<typeof error> {
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<typeof error> {
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<typeof error> {
logger.info(context, `Not Found: ${message}`);
return error(404, message);
}
/**
* Wrap async handler functions with standardized error handling
*/
export function withErrorHandling<T extends (...args: any[]) => Promise<any>>(
handler: T,
defaultContext?: ErrorContext
): T {
return (async (...args: Parameters<T>) => {
try {
return await handler(...args);
} catch (err) {
throw handleApiError(err, defaultContext);
}
}) as T;
}

61
src/routes/api/git/[...path]/+server.ts

@ -6,9 +6,8 @@
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { RepoManager } from '$lib/services/git/repo-manager.js'; import { RepoManager } from '$lib/services/git/repo-manager.js';
import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js'; import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { spawn, execSync } from 'child_process'; import { spawn } from 'child_process';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { join, resolve } from 'path'; import { join, resolve } from 'path';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
@ -43,20 +42,58 @@ const GIT_HTTP_BACKEND_PATHS = [
/** /**
* Find git-http-backend executable * Find git-http-backend executable
* Security: Uses spawn instead of execSync to prevent command injection
*/ */
function findGitHttpBackend(): string | null { async function findGitHttpBackend(): Promise<string | null> {
for (const path of GIT_HTTP_BACKEND_PATHS) { for (const path of GIT_HTTP_BACKEND_PATHS) {
if (existsSync(path)) { if (existsSync(path)) {
return path; return path;
} }
} }
// Try to find it via which/whereis // Try to find it via which/whereis using spawn (safer than execSync)
try { try {
const result = execSync('which git-http-backend 2>/dev/null || whereis -b git-http-backend 2>/dev/null', { encoding: 'utf-8' }); // Try 'which' first
const lines = result.trim().split(/\s+/); try {
for (const line of lines) { const whichResult = await new Promise<string>((resolve, reject) => {
if (line.includes('git-http-backend') && existsSync(line)) { const proc = spawn('which', ['git-http-backend'], { stdio: ['ignore', 'pipe', 'pipe'] });
return line; 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<string>((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 { } catch {
@ -90,6 +127,8 @@ async function getRepoAnnouncement(npub: string, repoName: string): Promise<Nost
/** /**
* Extract clone URLs from repository announcement * Extract clone URLs from repository announcement
* Note: This duplicates logic from RepoManager.extractCloneUrls, but is kept here
* for performance (avoiding instantiation of RepoManager just for this)
*/ */
function extractCloneUrls(event: NostrEvent): string[] { function extractCloneUrls(event: NostrEvent): string[] {
const urls: string[] = []; const urls: string[] = [];
@ -197,7 +236,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
} }
// Find git-http-backend // Find git-http-backend
const gitHttpBackend = findGitHttpBackend(); const gitHttpBackend = await findGitHttpBackend();
if (!gitHttpBackend) { if (!gitHttpBackend) {
return error(500, 'git-http-backend not found. Please install git.'); 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 // Find git-http-backend
const gitHttpBackend = findGitHttpBackend(); const gitHttpBackend = await findGitHttpBackend();
if (!gitHttpBackend) { if (!gitHttpBackend) {
return error(500, 'git-http-backend not found. Please install git.'); return error(500, 'git-http-backend not found. Please install git.');
} }

26
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 { NostrClient } from '$lib/services/nostr/nostr-client.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import type { BranchProtectionRule } from '$lib/services/nostr/branch-protection-service.js'; 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 { 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 branchProtectionService = new BranchProtectionService(DEFAULT_NOSTR_RELAYS);
const ownershipTransferService = new OwnershipTransferService(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; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getBranchProtection' });
} }
try { try {
@ -35,7 +35,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
try { try {
ownerPubkey = requireNpubHex(npub); ownerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'getBranchProtection', npub });
} }
const config = await branchProtectionService.getBranchProtection(ownerPubkey, repo); const config = await branchProtectionService.getBranchProtection(ownerPubkey, repo);
@ -46,10 +46,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
return json(config); return json(config);
} catch (err) { } catch (err) {
// Security: Sanitize error messages return handleApiError(err, { operation: 'getBranchProtection', npub, repo }, 'Failed to get branch protection');
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);
} }
}; };
@ -60,7 +57,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'updateBranchProtection' });
} }
try { try {
@ -68,11 +65,11 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
const { userPubkey, rules } = body; const { userPubkey, rules } = body;
if (!userPubkey) { if (!userPubkey) {
return error(401, 'Authentication required'); return handleAuthError('Authentication required', { operation: 'updateBranchProtection', npub, repo });
} }
if (!Array.isArray(rules)) { 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 // Decode npub to get pubkey
@ -80,7 +77,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
try { try {
ownerPubkey = requireNpubHex(npub); ownerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'updateBranchProtection', npub });
} }
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey;
@ -88,7 +85,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
// Check if user is owner // Check if user is owner
const currentOwner = await ownershipTransferService.getCurrentOwner(ownerPubkey, repo); const currentOwner = await ownershipTransferService.getCurrentOwner(ownerPubkey, repo);
if (userPubkeyHex !== currentOwner) { 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 // Validate rules
@ -122,9 +119,6 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
return json({ success: true, event: signedEvent, rules: validatedRules }); return json({ success: true, event: signedEvent, rules: validatedRules });
} catch (err) { } catch (err) {
// Security: Sanitize error messages return handleApiError(err, { operation: 'updateBranchProtection', npub, repo }, 'Failed to update branch protection');
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);
} }
}; };

24
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 { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; 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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); const fileManager = new FileManager(repoRoot);
@ -20,19 +20,18 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getBranches' });
} }
try { try {
if (!fileManager.repoExists(npub, repo)) { 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); const branches = await fileManager.getBranches(npub, repo);
return json(branches); return json(branches);
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error getting branches'); return handleApiError(err, { operation: 'getBranches', npub, repo }, 'Failed to get branches');
return error(500, err instanceof Error ? err.message : 'Failed to get branches');
} }
}; };
@ -40,7 +39,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'createBranch' });
} }
let branchName: string | undefined; let branchName: string | undefined;
@ -51,15 +50,15 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
({ branchName, fromBranch, userPubkey } = body); ({ branchName, fromBranch, userPubkey } = body);
if (!branchName) { if (!branchName) {
return error(400, 'Missing branchName parameter'); return handleValidationError('Missing branchName parameter', { operation: 'createBranch', npub, repo });
} }
if (!userPubkey) { 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)) { 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 // Check if user is a maintainer
@ -67,7 +66,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'createBranch', npub });
} }
// Convert userPubkey to hex if needed // 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); const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo);
if (!isMaintainer) { 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'); await fileManager.createBranch(npub, repo, branchName, fromBranch || 'main');
return json({ success: true, message: 'Branch created successfully' }); return json({ success: true, message: 'Branch created successfully' });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo, branchName }, 'Error creating branch'); return handleApiError(err, { operation: 'createBranch', npub, repo, branchName }, 'Failed to create branch');
return error(500, err instanceof Error ? err.message : 'Failed to create branch');
} }
}; };

11
src/routes/api/repos/[npub]/[repo]/commits/+server.ts

@ -5,7 +5,7 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; 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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); 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'); const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getCommits' });
} }
try { try {
if (!fileManager.repoExists(npub, repo)) { if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found'); return handleNotFoundError('Repository not found', { operation: 'getCommits', npub, repo });
} }
// Check repository privacy // Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null); const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) { 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); const commits = await fileManager.getCommitHistory(npub, repo, branch, limit, path);
return json(commits); return json(commits);
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo, branch }, 'Error getting commit history'); return handleApiError(err, { operation: 'getCommits', npub, repo, branch }, 'Failed to get commit history');
return error(500, err instanceof Error ? err.message : 'Failed to get commit history');
} }
}; };

11
src/routes/api/repos/[npub]/[repo]/diff/+server.ts

@ -5,7 +5,7 @@
import { json, error } from '@sveltejs/kit'; import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { FileManager } from '$lib/services/git/file-manager.js'; 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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); 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'); const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo || !fromRef) { if (!npub || !repo || !fromRef) {
return error(400, 'Missing npub, repo, or from parameter'); return handleValidationError('Missing npub, repo, or from parameter', { operation: 'getDiff' });
} }
try { try {
if (!fileManager.repoExists(npub, repo)) { if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found'); return handleNotFoundError('Repository not found', { operation: 'getDiff', npub, repo });
} }
// Check repository privacy // Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null); const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) { 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); const diffs = await fileManager.getDiff(npub, repo, fromRef, toRef, filePath);
return json(diffs); return json(diffs);
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo, fromRef, toRef }, 'Error getting diff'); return handleApiError(err, { operation: 'getDiff', npub, repo, fromRef, toRef }, 'Failed to get diff');
return error(500, err instanceof Error ? err.message : 'Failed to get diff');
} }
}; };

119
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 { FileManager } from '$lib/services/git/file-manager.js';
import { MaintainerService } from '$lib/services/nostr/maintainer-service.js'; import { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js'; import { requireNpubHex } from '$lib/utils/npub-utils.js';
import { exec } from 'child_process'; import { spawn } from 'child_process';
import { promisify } from 'util'; import { mkdir, rm, readFile } from 'fs/promises';
import { existsSync, readFileSync } from 'fs'; import { existsSync } from 'fs';
import { join } from 'path'; import { join, resolve } from 'path';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { isValidBranchName, sanitizeError } from '$lib/utils/security.js';
const execAsync = promisify(exec); 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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); const fileManager = new FileManager(repoRoot);
const maintainerService = new MaintainerService(DEFAULT_NOSTR_RELAYS); 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.'); 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`); 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 tempDir = join(repoRoot, '..', 'temp-downloads');
const workDir = join(tempDir, `${npub}-${repo}-${Date.now()}`); 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 archiveName = `${repo}-${ref}.${format === 'tar.gz' ? 'tar.gz' : 'zip'}`;
const archivePath = join(tempDir, archiveName); 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 { try {
// Create temp directory // Create temp directory using fs/promises (safer than shell commands)
await execAsync(`mkdir -p "${tempDir}"`); await mkdir(tempDir, { recursive: true });
await execAsync(`mkdir -p "${workDir}"`); await mkdir(workDir, { recursive: true });
// Clone repository to temp directory // Clone repository using simple-git (safer than shell commands)
await execAsync(`git clone "${repoPath}" "${workDir}"`); const git = simpleGit();
await git.clone(repoPath, workDir);
// Checkout specific ref if not HEAD // Checkout specific ref if not HEAD
if (ref !== 'HEAD') { if (ref !== 'HEAD') {
await execAsync(`cd "${workDir}" && git checkout "${ref}"`); const workGit = simpleGit(workDir);
await workGit.checkout(ref);
} }
// Remove .git directory // Remove .git directory using fs/promises
await execAsync(`rm -rf "${workDir}/.git"`); await rm(join(workDir, '.git'), { recursive: true, force: true });
// Create archive // Create archive using spawn (safer than exec)
if (format === 'tar.gz') { if (format === 'tar.gz') {
await execAsync(`cd "${tempDir}" && tar -czf "${archiveName}" -C "${workDir}" .`); await new Promise<void>((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 { } else {
// Use zip command (requires zip utility) // Use zip command (requires zip utility) - using spawn for safety
await execAsync(`cd "${workDir}" && zip -r "${archivePath}" .`); await new Promise<void>((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 // Read archive file using fs/promises
const archiveBuffer = readFileSync(archivePath); const archiveBuffer = await readFile(archivePath);
// Clean up // Clean up using fs/promises
await execAsync(`rm -rf "${workDir}"`); await rm(workDir, { recursive: true, force: true }).catch(() => {});
await execAsync(`rm -f "${archivePath}"`); await rm(archivePath, { force: true }).catch(() => {});
// Return archive // Return archive
return new Response(archiveBuffer, { return new Response(archiveBuffer, {
@ -94,13 +154,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
} }
}); });
} catch (archiveError) { } catch (archiveError) {
// Clean up on error // Clean up on error using fs/promises
await execAsync(`rm -rf "${workDir}"`).catch(() => {}); await rm(workDir, { recursive: true, force: true }).catch(() => {});
await execAsync(`rm -f "${archivePath}"`).catch(() => {}); await rm(archivePath, { force: true }).catch(() => {});
const sanitizedError = sanitizeError(archiveError);
logger.error({ error: sanitizedError, npub, repo, ref, format }, 'Error creating archive');
throw archiveError; throw archiveError;
} }
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error creating repository archive'); return handleApiError(err, { operation: 'download', npub, repo, ref, format }, 'Failed to create repository archive');
return error(500, err instanceof Error ? err.message : 'Failed to create repository archive');
} }
}; };

11
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 logger from '$lib/services/logger.js';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); const fileManager = new FileManager(repoRoot);
@ -82,10 +83,7 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
throw err; throw err;
} }
} catch (err) { } catch (err) {
// Security: Sanitize error messages to prevent leaking sensitive data return handleApiError(err, { operation: 'readFile', npub, repo, filePath }, 'Failed to read file');
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);
} }
}; };
@ -249,9 +247,6 @@ export const POST: RequestHandler = async ({ params, url, request }: { params: {
return error(400, 'Invalid action or missing content'); return error(400, 'Invalid action or missing content');
} }
} catch (err) { } catch (err) {
// Security: Sanitize error messages to prevent leaking sensitive data return handleApiError(err, { operation: 'writeFile', npub, repo, filePath: path }, 'Failed to write file');
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);
} }
}; };

41
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 { signEventWithNIP07 } from '$lib/services/nostr/nip07-signer.js';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.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 { 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 { ResourceLimits } from '$lib/services/security/resource-limits.js';
import { auditLogger } from '$lib/services/security/audit-logger.js'; import { auditLogger } from '$lib/services/security/audit-logger.js';
import { ForkCountService } from '$lib/services/nostr/fork-count-service.js'; import { ForkCountService } from '$lib/services/nostr/fork-count-service.js';
import logger from '$lib/services/logger.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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const repoManager = new RepoManager(repoRoot); const repoManager = new RepoManager(repoRoot);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
@ -123,6 +124,12 @@ export const POST: RequestHandler = async ({ params, request }) => {
// Check if original repo exists // Check if original repo exists
const originalRepoPath = join(repoRoot, npub, `${repo}.git`); 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)) { if (!existsSync(originalRepoPath)) {
return error(404, 'Original repository not found'); return error(404, 'Original repository not found');
} }
@ -145,11 +152,16 @@ export const POST: RequestHandler = async ({ params, request }) => {
// Check if fork already exists // Check if fork already exists
const forkRepoPath = join(repoRoot, userNpub, `${forkRepoName}.git`); 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)) { if (existsSync(forkRepoPath)) {
return error(409, 'Fork already exists'); 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'; const clientIp = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown';
auditLogger.logRepoFork( auditLogger.logRepoFork(
userPubkeyHex, userPubkeyHex,
@ -158,7 +170,8 @@ export const POST: RequestHandler = async ({ params, request }) => {
'success' 'success'
); );
await execAsync(`git clone --bare "${originalRepoPath}" "${forkRepoPath}"`); const git = simpleGit();
await git.clone(originalRepoPath, forkRepoPath, ['--bare']);
// Invalidate resource limit cache after creating repo // Invalidate resource limit cache after creating repo
resourceLimits.invalidateCache(userNpub); resourceLimits.invalidateCache(userNpub);
@ -263,7 +276,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
if (publishResult.success.length === 0) { if (publishResult.success.length === 0) {
// Clean up repo if announcement failed // 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.'); 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('; ')}`; const errorDetails = `All relays failed: ${publishResult.failed.map(f => `${f.relay}: ${f.error}`).join('; ')}`;
return json({ return json({
success: false, success: false,
@ -290,7 +303,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
if (ownershipPublishResult.success.length === 0) { if (ownershipPublishResult.success.length === 0) {
// Clean up repo if ownership proof failed // 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.'); 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 // 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...'); 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.` message: `Repository forked successfully! Published to ${publishResult.success.length} relay(s) for announcement and ${ownershipPublishResult.success.length} relay(s) for ownership proof.`
}); });
} catch (err) { } catch (err) {
// Security: Sanitize error messages to prevent leaking sensitive data return handleApiError(err, { operation: 'fork', npub, repo }, 'Failed to fork repository');
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);
} }
}; };
@ -440,10 +449,6 @@ export const GET: RequestHandler = async ({ params }) => {
forkCount forkCount
}); });
} catch (err) { } catch (err) {
// Security: Sanitize error messages return handleApiError(err, { operation: 'getForkInfo', npub, repo }, 'Failed to get fork information');
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);
} }
}; };

24
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 { combineRelays } from '$lib/config.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { getUserRelays } from '$lib/services/nostr/user-relays.js';
import { NostrClient } from '$lib/services/nostr/nostr-client.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 highlightsService = new HighlightsService(DEFAULT_NOSTR_RELAYS);
const nostrClient = new NostrClient(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'); const prAuthor = url.searchParams.get('prAuthor');
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getHighlights' });
} }
if (!prId || !prAuthor) { if (!prId || !prAuthor) {
return error(400, 'Missing prId or prAuthor parameter'); return handleValidationError('Missing prId or prAuthor parameter', { operation: 'getHighlights', npub, repo });
} }
try { try {
@ -41,7 +41,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'getHighlights', npub });
} }
// Decode prAuthor if it's an npub // Decode prAuthor if it's an npub
@ -63,8 +63,7 @@ export const GET: RequestHandler = async ({ params, url }) => {
comments: prComments comments: prComments
}); });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error fetching highlights'); return handleApiError(err, { operation: 'getHighlights', npub, repo, prId }, 'Failed to fetch highlights');
return error(500, err instanceof Error ? err.message : 'Failed to fetch highlights');
} }
}; };
@ -76,7 +75,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'createHighlight' });
} }
try { try {
@ -84,20 +83,20 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { type, event, userPubkey } = body; const { type, event, userPubkey } = body;
if (!type || !event || !userPubkey) { 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') { 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 // Verify the event is properly signed
if (!event.sig || !event.id) { 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)) { if (!verifyEvent(event)) {
return error(400, 'Invalid event signature'); return handleValidationError('Invalid event signature', { operation: 'createHighlight', npub, repo });
} }
// Get user's relays and publish // Get user's relays and publish
@ -112,7 +111,6 @@ export const POST: RequestHandler = async ({ params, request }) => {
return json({ success: true, event, published: result }); return json({ success: true, event, published: result });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error creating highlight/comment'); return handleApiError(err, { operation: 'createHighlight', npub, repo }, 'Failed to create highlight/comment');
return error(500, err instanceof Error ? err.message : 'Failed to create highlight/comment');
} }
}; };

22
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 { IssuesService } from '$lib/services/nostr/issues-service.js';
import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js'; import { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import logger from '$lib/services/logger.js';
import { requireNpubHex } from '$lib/utils/npub-utils.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 }) => { export const GET: RequestHandler = async ({ params, url, request }) => {
const { npub, repo } = params; const { npub, repo } = params;
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getIssues' });
} }
try { try {
@ -24,14 +24,14 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'getIssues', npub });
} }
// Check repository privacy // Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null); const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) { 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); const issuesService = new IssuesService(DEFAULT_NOSTR_RELAYS);
@ -39,8 +39,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
return json(issues); return json(issues);
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error fetching issues'); return handleApiError(err, { operation: 'getIssues', npub, repo }, 'Failed to fetch issues');
return error(500, err instanceof Error ? err.message : 'Failed to fetch issues');
} }
}; };
@ -50,7 +49,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'createIssue' });
} }
try { try {
@ -58,12 +57,12 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { event } = body; const { event } = body;
if (!event) { 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) // Verify the event is properly signed (basic check)
if (!event.sig || !event.id) { 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 // 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); const result = await issuesService['nostrClient'].publishEvent(event, DEFAULT_NOSTR_RELAYS);
if (result.failed.length > 0 && result.success.length === 0) { 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 }); return json({ success: true, event, published: result });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error creating issue'); return handleApiError(err, { operation: 'createIssue', npub, repo }, 'Failed to create issue');
return error(500, err instanceof Error ? err.message : 'Failed to create issue');
} }
}; };

9
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 { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; 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); 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'); const userPubkey = url.searchParams.get('userPubkey');
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getMaintainers' });
} }
try { try {
@ -27,7 +27,7 @@ export const GET: RequestHandler = async ({ params, url }: { params: { npub?: st
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'getMaintainers', npub });
} }
const { maintainers, owner } = await maintainerService.getMaintainers(repoOwnerPubkey, repo); 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 }); return json({ maintainers, owner });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error checking maintainers'); return handleApiError(err, { operation: 'getMaintainers', npub, repo }, 'Failed to check maintainers');
return error(500, err instanceof Error ? err.message : 'Failed to check maintainers');
} }
}; };

22
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 { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js'; 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 }) => { export const GET: RequestHandler = async ({ params, url, request }: { params: { npub?: string; repo?: string }; url: URL; request: Request }) => {
const { npub, repo } = params; const { npub, repo } = params;
const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey'); const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getPRs' });
} }
try { try {
@ -25,14 +25,14 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'getPRs', npub });
} }
// Check repository privacy // Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null); const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) { 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); const prsService = new PRsService(DEFAULT_NOSTR_RELAYS);
@ -40,8 +40,7 @@ export const GET: RequestHandler = async ({ params, url, request }: { params: {
return json(prs); return json(prs);
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error fetching pull requests'); return handleApiError(err, { operation: 'getPRs', npub, repo }, 'Failed to fetch pull requests');
return error(500, err instanceof Error ? err.message : 'Failed to fetch pull requests');
} }
}; };
@ -50,7 +49,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'createPR' });
} }
try { try {
@ -58,12 +57,12 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
const { event } = body; const { event } = body;
if (!event) { 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 // Verify the event is properly signed
if (!event.sig || !event.id) { 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 // 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); const result = await prsService['nostrClient'].publishEvent(event, DEFAULT_NOSTR_RELAYS);
if (result.failed.length > 0 && result.success.length === 0) { 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 }); return json({ success: true, event, published: result });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error creating pull request'); return handleApiError(err, { operation: 'createPR', npub, repo }, 'Failed to create pull request');
return error(500, err instanceof Error ? err.message : 'Failed to create pull request');
} }
}; };

13
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 { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js'; 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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); 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'); const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo || !filePath) { if (!npub || !repo || !filePath) {
return error(400, 'Missing npub, repo, or path parameter'); return handleValidationError('Missing npub, repo, or path parameter', { operation: 'getRawFile' });
} }
try { try {
if (!fileManager.repoExists(npub, repo)) { if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found'); return handleNotFoundError('Repository not found', { operation: 'getRawFile', npub, repo });
} }
// Check repository privacy // Check repository privacy
@ -35,12 +35,12 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'getRawFile', npub });
} }
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo);
if (!canView) { 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 // Get file content
@ -79,7 +79,6 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
} }
}); });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo, filePath }, 'Error getting raw file'); return handleApiError(err, { operation: 'getRawFile', npub, repo, filePath }, 'Failed to get raw file');
return error(500, err instanceof Error ? err.message : 'Failed to get raw file');
} }
}; };

13
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 { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js'; 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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); 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'); const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getReadme' });
} }
try { try {
if (!fileManager.repoExists(npub, repo)) { if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found'); return handleNotFoundError('Repository not found', { operation: 'getReadme', npub, repo });
} }
// Check repository privacy // Check repository privacy
@ -45,12 +45,12 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'getReadme', npub });
} }
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo);
if (!canView) { 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 // 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') isMarkdown: readmePath?.toLowerCase().endsWith('.md') || readmePath?.toLowerCase().endsWith('.markdown')
}); });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error getting README'); return handleApiError(err, { operation: 'getReadme', npub, repo }, 'Failed to get README');
return error(500, err instanceof Error ? err.message : 'Failed to get README');
} }
}; };

27
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 { MaintainerService } from '$lib/services/nostr/maintainer-service.js';
import logger from '$lib/services/logger.js'; import logger from '$lib/services/logger.js';
import { OwnershipTransferService } from '$lib/services/nostr/ownership-transfer-service.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 nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
const maintainerService = new MaintainerService(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'); const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getSettings' });
} }
try { try {
@ -36,19 +37,19 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'getSettings', npub });
} }
// Check if user is owner // Check if user is owner
if (!userPubkey) { if (!userPubkey) {
return error(401, 'Authentication required'); return handleAuthError('Authentication required', { operation: 'getSettings', npub, repo });
} }
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey;
const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo);
if (userPubkeyHex !== currentOwner) { 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 // Get repository announcement
@ -62,7 +63,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
]); ]);
if (events.length === 0) { 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]; const announcement = events[0];
@ -89,8 +90,7 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
npub npub
}); });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error getting repository settings'); return handleApiError(err, { operation: 'getSettings', npub, repo }, 'Failed to get repository settings');
return error(500, err instanceof Error ? err.message : 'Failed to get repository settings');
} }
}; };
@ -101,7 +101,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'updateSettings' });
} }
try { try {
@ -109,7 +109,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { userPubkey, name, description, cloneUrls, maintainers, isPrivate } = body; const { userPubkey, name, description, cloneUrls, maintainers, isPrivate } = body;
if (!userPubkey) { if (!userPubkey) {
return error(401, 'Authentication required'); return handleAuthError('Authentication required', { operation: 'updateSettings', npub, repo });
} }
// Decode npub to get pubkey // Decode npub to get pubkey
@ -117,7 +117,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'updateSettings', npub });
} }
const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey; const userPubkeyHex = decodeNpubToHex(userPubkey) || userPubkey;
@ -125,7 +125,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
// Check if user is owner // Check if user is owner
const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo); const currentOwner = await ownershipTransferService.getCurrentOwner(repoOwnerPubkey, repo);
if (userPubkeyHex !== currentOwner) { 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 // Get existing announcement
@ -139,7 +139,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
]); ]);
if (events.length === 0) { 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]; const existingAnnouncement = events[0];
@ -225,7 +225,6 @@ export const POST: RequestHandler = async ({ params, request }) => {
return json({ success: true, event: signedEvent }); return json({ success: true, event: signedEvent });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error updating repository settings'); return handleApiError(err, { operation: 'updateSettings', npub, repo }, 'Failed to update repository settings');
return error(500, err instanceof Error ? err.message : 'Failed to update repository settings');
} }
}; };

26
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 { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.js'; 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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); 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'); const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getTags' });
} }
try { try {
if (!fileManager.repoExists(npub, repo)) { if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found'); return handleNotFoundError('Repository not found', { operation: 'getTags', npub, repo });
} }
// Check repository privacy // Check repository privacy
const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js'); const { checkRepoAccess } = await import('$lib/utils/repo-privacy.js');
const access = await checkRepoAccess(npub, repo, userPubkey || null); const access = await checkRepoAccess(npub, repo, userPubkey || null);
if (!access.allowed) { 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); const tags = await fileManager.getTags(npub, repo);
return json(tags); return json(tags);
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error getting tags'); return handleApiError(err, { operation: 'getTags', npub, repo }, 'Failed to get tags');
return error(500, err instanceof Error ? err.message : 'Failed to get tags');
} }
}; };
@ -48,7 +47,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'createTag' });
} }
let tagName: string | undefined; let tagName: string | undefined;
@ -60,15 +59,15 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
({ tagName, ref, message, userPubkey } = body); ({ tagName, ref, message, userPubkey } = body);
if (!tagName) { if (!tagName) {
return error(400, 'Missing tagName parameter'); return handleValidationError('Missing tagName parameter', { operation: 'createTag', npub, repo });
} }
if (!userPubkey) { 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)) { 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 // Check if user is a maintainer
@ -76,7 +75,7 @@ export const POST: RequestHandler = async ({ params, request }: { params: { npub
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'createTag', npub });
} }
// Convert userPubkey to hex if needed // 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); const isMaintainer = await maintainerService.isMaintainer(userPubkeyHex, repoOwnerPubkey, repo);
if (!isMaintainer) { 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); await fileManager.createTag(npub, repo, tagName, ref || 'HEAD', message);
return json({ success: true, message: 'Tag created successfully' }); return json({ success: true, message: 'Tag created successfully' });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo, tagName }, 'Error creating tag'); return handleApiError(err, { operation: 'createTag', npub, repo, tagName }, 'Failed to create tag');
return error(500, err instanceof Error ? err.message : 'Failed to create tag');
} }
}; };

30
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 { verifyEvent } from 'nostr-tools';
import type { NostrEvent } from '$lib/types/nostr.js'; import type { NostrEvent } from '$lib/types/nostr.js';
import { getUserRelays } from '$lib/services/nostr/user-relays.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 ownershipTransferService = new OwnershipTransferService(DEFAULT_NOSTR_RELAYS);
const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS);
@ -25,7 +25,7 @@ export const GET: RequestHandler = async ({ params }) => {
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'getOwnership' });
} }
try { try {
@ -34,7 +34,7 @@ export const GET: RequestHandler = async ({ params }) => {
try { try {
originalOwnerPubkey = requireNpubHex(npub); originalOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'getOwnership', npub });
} }
// Get current owner (may be different if transferred) // Get current owner (may be different if transferred)
@ -69,8 +69,7 @@ export const GET: RequestHandler = async ({ params }) => {
}) })
}); });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error fetching ownership info'); return handleApiError(err, { operation: 'getOwnership', npub, repo }, 'Failed to fetch ownership info');
return error(500, err instanceof Error ? err.message : 'Failed to fetch ownership info');
} }
}; };
@ -82,7 +81,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'transferOwnership' });
} }
try { try {
@ -90,16 +89,16 @@ export const POST: RequestHandler = async ({ params, request }) => {
const { transferEvent, userPubkey } = body; const { transferEvent, userPubkey } = body;
if (!transferEvent || !userPubkey) { 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 // Verify the event is properly signed
if (!transferEvent.sig || !transferEvent.id) { 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)) { 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 // Decode npub to get original owner pubkey
@ -107,7 +106,7 @@ export const POST: RequestHandler = async ({ params, request }) => {
try { try {
originalOwnerPubkey = requireNpubHex(npub); originalOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'transferOwnership', npub });
} }
// Verify user is the current owner // Verify user is the current owner
@ -118,24 +117,24 @@ export const POST: RequestHandler = async ({ params, request }) => {
); );
if (!canTransfer) { 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 // Verify the transfer event is from the current owner
if (transferEvent.pubkey !== userPubkey) { 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 // Verify it's an ownership transfer event
if (transferEvent.kind !== KIND.OWNERSHIP_TRANSFER) { 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 // Verify the 'a' tag references this repo
const aTag = transferEvent.tags.find(t => t[0] === 'a'); const aTag = transferEvent.tags.find(t => t[0] === 'a');
const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`; const expectedRepoTag = `${KIND.REPO_ANNOUNCEMENT}:${originalOwnerPubkey}:${repo}`;
if (!aTag || aTag[1] !== expectedRepoTag) { 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 // Get user's relays and publish
@ -158,7 +157,6 @@ export const POST: RequestHandler = async ({ params, request }) => {
message: 'Ownership transfer initiated successfully' message: 'Ownership transfer initiated successfully'
}); });
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error transferring ownership'); return handleApiError(err, { operation: 'transferOwnership', npub, repo }, 'Failed to transfer ownership');
return error(500, err instanceof Error ? err.message : 'Failed to transfer ownership');
} }
}; };

13
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 { DEFAULT_NOSTR_RELAYS } from '$lib/config.js';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
import { requireNpubHex } from '$lib/utils/npub-utils.js'; 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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); 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'); const userPubkey = url.searchParams.get('userPubkey') || request.headers.get('x-user-pubkey');
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'listFiles' });
} }
try { try {
if (!fileManager.repoExists(npub, repo)) { if (!fileManager.repoExists(npub, repo)) {
return error(404, 'Repository not found'); return handleNotFoundError('Repository not found', { operation: 'listFiles', npub, repo });
} }
// Check repository privacy // Check repository privacy
@ -35,18 +35,17 @@ export const GET: RequestHandler = async ({ params, url, request }) => {
try { try {
repoOwnerPubkey = requireNpubHex(npub); repoOwnerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'listFiles', npub });
} }
const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo); const canView = await maintainerService.canView(userPubkey || null, repoOwnerPubkey, repo);
if (!canView) { 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); const files = await fileManager.listFiles(npub, repo, ref, path);
return json(files); return json(files);
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo, path, ref }, 'Error listing files'); return handleApiError(err, { operation: 'listFiles', npub, repo, path, ref }, 'Failed to list files');
return error(500, err instanceof Error ? err.message : 'Failed to list files');
} }
}; };

10
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 logger from '$lib/services/logger.js';
import { join } from 'path'; import { join } from 'path';
import { requireNpubHex, decodeNpubToHex } from '$lib/utils/npub-utils.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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); const fileManager = new FileManager(repoRoot);
@ -26,7 +27,7 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
const { npub, repo } = params; const { npub, repo } = params;
if (!npub || !repo) { if (!npub || !repo) {
return error(400, 'Missing npub or repo parameter'); return handleValidationError('Missing npub or repo parameter', { operation: 'verifyRepo' });
} }
try { try {
@ -35,13 +36,13 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
try { try {
ownerPubkey = requireNpubHex(npub); ownerPubkey = requireNpubHex(npub);
} catch { } catch {
return error(400, 'Invalid npub format'); return handleValidationError('Invalid npub format', { operation: 'verifyRepo', npub });
} }
// Check if repository exists (using FileManager's internal method) // Check if repository exists (using FileManager's internal method)
const repoPath = join(repoRoot, npub, `${repo}.git`); const repoPath = join(repoRoot, npub, `${repo}.git`);
if (!existsSync(repoPath)) { if (!existsSync(repoPath)) {
return error(404, 'Repository not found'); return handleNotFoundError('Repository not found', { operation: 'verifyRepo', npub, repo });
} }
// Fetch the repository announcement // Fetch the repository announcement
@ -143,7 +144,6 @@ export const GET: RequestHandler = async ({ params }: { params: { npub?: string;
}); });
} }
} catch (err) { } catch (err) {
logger.error({ error: err, npub, repo }, 'Error verifying repository'); return handleApiError(err, { operation: 'verifyRepo', npub, repo }, 'Failed to verify repository');
return error(500, err instanceof Error ? err.message : 'Failed to verify repository');
} }
}; };

12
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 { nip19 } from 'nostr-tools';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { join } from 'path'; 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 repoRoot = process.env.GIT_REPO_ROOT || '/repos';
const fileManager = new FileManager(repoRoot); const fileManager = new FileManager(repoRoot);
@ -22,11 +22,11 @@ export const GET: RequestHandler = async ({ url }) => {
const limit = parseInt(url.searchParams.get('limit') || '20', 10); const limit = parseInt(url.searchParams.get('limit') || '20', 10);
if (!query || query.trim().length === 0) { 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) { 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 { 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) { } catch (nip50Error) {
// Fallback to manual filtering if NIP-50 fails or isn't supported // 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([ const allEvents = await nostrClient.fetchEvents([
{ {
@ -189,7 +188,6 @@ export const GET: RequestHandler = async ({ url }) => {
total: results.repos.length + results.code.length total: results.repos.length + results.code.length
}); });
} catch (err) { } catch (err) {
logger.error({ error: err, query }, 'Error searching'); return handleApiError(err, { operation: 'search', query, type }, 'Failed to search');
return error(500, err instanceof Error ? err.message : 'Failed to search');
} }
}; };

Loading…
Cancel
Save