From 8155665d3f282af8e4561b8c2ad39adc57b7a64d Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 27 Feb 2026 09:48:32 +0100 Subject: [PATCH] refactor 12 Nostr-Signature: 73671ae6535309f9eae164f7a3ec403b1bc818ef811b9692fd0122d0b72c2774 573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc 0df56b009f5afb77de334225ab30cff55586ac0cf48f5ee435428201a1e72dc357a0fb5e80ef196f5bd76d6d448056d25f0feab0b1bcbe45f9af1a2a0d5453ca --- nostr/commit-signatures.jsonl | 1 + src/hooks.server.ts | 56 +- src/lib/services/git/file-manager.ts.backup | 2398 ------ src/lib/services/nostr/nostr-client.ts | 24 +- src/lib/services/nostr/repo-polling.ts | 46 +- src/lib/utils/git-process.ts | 53 +- src/lib/utils/nostr-event-utils.ts | 35 + src/lib/utils/process-cleanup.ts | 147 + src/routes/api/git/[...path]/+server.ts | 192 +- src/routes/api/repos/local/+server.ts | 25 +- src/routes/api/search/+server.ts | 24 +- .../repos/[npub]/[repo]/+page.svelte.backup | 6619 ----------------- .../[npub]/[repo]/utils/discussion-utils.ts | 33 +- 13 files changed, 460 insertions(+), 9193 deletions(-) delete mode 100644 src/lib/services/git/file-manager.ts.backup create mode 100644 src/lib/utils/nostr-event-utils.ts create mode 100644 src/lib/utils/process-cleanup.ts delete mode 100644 src/routes/repos/[npub]/[repo]/+page.svelte.backup diff --git a/nostr/commit-signatures.jsonl b/nostr/commit-signatures.jsonl index 069f223..459dee0 100644 --- a/nostr/commit-signatures.jsonl +++ b/nostr/commit-signatures.jsonl @@ -106,3 +106,4 @@ {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772136696,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 10"]],"content":"Signed commit: refactor 10","id":"7fb8d54e26ab59486f3b56d97e225ed02f893140025c03ccb95a991e523e6182","sig":"f4bb5a037c48d06854d9346ebf96aa9f65f11d3f96e23d08b7d38d0ebea9bab242ffa917239aa432d83a55f369586d66603f439f40eac8156aeaaf80737b81a1"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772141183,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","bug-fixes"]],"content":"Signed commit: bug-fixes","id":"b92b203686c0629409fef055e7f3189cf9f26be5cca0253ab00cf7e8498e1115","sig":"06a13aac9d2f794e52b0416044db6ebf9dd248d254d2166d7e7f3fefd2b7d37d1a85072c3e92316898c31068e25cf37bc5afd2fcd8ae2050d0a30b1bc1973678"} {"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142448,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","refactor 11"]],"content":"Signed commit: refactor 11","id":"bb9d5c56a291e48221df96868fb925e309cb560aa350c2cf5f9c4ddd5e5c4a6b","sig":"75662c916bf4d8bb3d70cdae4e4882382692c6f1ca67598a69abe3dc96069ef6f2bda5a1b8f91b724aa43b3cb3c6b8ad6cbce286b5d165377a34a881e7275d2a"} +{"kind":1640,"pubkey":"573634b648634cbad10f2451776089ea21090d9407f715e83c577b4611ae6edc","created_at":1772142558,"tags":[["author","Silberengel","silberengel7@protonmail.com"],["message","remove redundancy"]],"content":"Signed commit: remove redundancy","id":"11ac91151bebd4dd49b91bcdef7b0b7157f0afd8ce710f7231be4860fb073d08","sig":"a7efcafa5ea83a0c37eae4562a84a7581c3d5c5dd1416f8f3e2bd2633d8523ae0eb7cc56dc4292c127ea16fb2dd5bc639483cb096263a850956b47312ed7ff6f"} diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 996e050..f444cd9 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -29,8 +29,60 @@ if (typeof process !== 'undefined') { }); pollingService = new RepoPollingService(DEFAULT_NOSTR_RELAYS, repoRoot, domain); - pollingService.start(); - logger.info({ service: 'repo-polling', relays: DEFAULT_NOSTR_RELAYS.length }, 'Started repo polling service'); + + // Start polling - the initial poll will complete asynchronously + // The local repos endpoint will skip cache for the first 10 seconds after startup + pollingService.start().then(() => { + logger.info({ service: 'repo-polling', relays: DEFAULT_NOSTR_RELAYS.length }, 'Repo polling service ready (initial poll completed)'); + }).catch((err) => { + logger.error({ error: err, service: 'repo-polling' }, 'Initial repo poll failed, but continuing'); + }); + + logger.info({ service: 'repo-polling', relays: DEFAULT_NOSTR_RELAYS.length }, 'Started repo polling service (initial poll in progress)'); + + // Cleanup on server shutdown + const cleanup = (signal: string) => { + logger.info({ signal }, 'Received shutdown signal, cleaning up...'); + if (pollingService) { + logger.info('Stopping repo polling service...'); + pollingService.stop(); + pollingService = null; + } + // Give a moment for cleanup, then exit + setTimeout(() => { + process.exit(0); + }, 1000); + }; + + process.on('SIGTERM', () => cleanup('SIGTERM')); + process.on('SIGINT', () => { + // SIGINT (Ctrl-C) - exit immediately after cleanup + cleanup('SIGINT'); + // Force exit after 2 seconds if cleanup takes too long + setTimeout(() => { + logger.warn('Forcing exit after SIGINT'); + process.exit(0); + }, 2000); + }); + + // Also cleanup on process exit (last resort) + process.on('exit', () => { + if (pollingService) { + pollingService.stop(); + } + }); + + // Periodic zombie process cleanup check + // This helps catch any processes that weren't properly cleaned up + if (typeof setInterval !== 'undefined') { + setInterval(() => { + // Check for zombie processes by attempting to reap them + // Node.js handles this automatically via 'close' events, but this is a safety net + // We can't directly check for zombies, but we can ensure our cleanup is working + // The real cleanup happens in process handlers, this is just monitoring + logger.debug('Zombie cleanup check (process handlers should prevent zombies)'); + }, 60000); // Check every minute + } } export const handle: Handle = async ({ event, resolve }) => { diff --git a/src/lib/services/git/file-manager.ts.backup b/src/lib/services/git/file-manager.ts.backup deleted file mode 100644 index f2c49c0..0000000 --- a/src/lib/services/git/file-manager.ts.backup +++ /dev/null @@ -1,2398 +0,0 @@ -/** - * File manager for git repositories - * Handles reading, writing, and listing files in git repos - */ - -import simpleGit, { type SimpleGit } from 'simple-git'; -import { readdir } from 'fs/promises'; -import { join, dirname, normalize, resolve } from 'path'; -import { spawn } from 'child_process'; -import { RepoManager } from './repo-manager.js'; -import { createGitCommitSignature } from './commit-signer.js'; -import type { NostrEvent } from '../../types/nostr.js'; -import logger from '../logger.js'; -import { sanitizeError, isValidBranchName } from '../../utils/security.js'; -import { repoCache, RepoCache } from './repo-cache.js'; - -export interface FileEntry { - name: string; - path: string; - type: 'file' | 'directory'; - size?: number; -} - -export interface FileContent { - content: string; - encoding: string; - size: number; -} - -export interface Commit { - hash: string; - message: string; - author: string; - date: string; - files: string[]; -} - -export interface Diff { - file: string; - additions: number; - deletions: number; - diff: string; -} - -export interface Tag { - name: string; - hash: string; - message?: string; - date?: number; // Unix timestamp of the commit the tag points to -} - -export class FileManager { - private repoManager: RepoManager; - private repoRoot: string; - // Cache for directory existence checks (5 minute TTL) - private dirExistenceCache: Map = new Map(); - private readonly DIR_CACHE_TTL = 5 * 60 * 1000; // 5 minutes - // Lazy-loaded fs modules (cached after first import) - private fsPromises: typeof import('fs/promises') | null = null; - private fsSync: typeof import('fs') | null = null; - - constructor(repoRoot: string = '/repos') { - this.repoRoot = repoRoot; - this.repoManager = new RepoManager(repoRoot); - } - - /** - * Lazy load fs/promises module (cached after first load) - */ - private async getFsPromises(): Promise { - if (!this.fsPromises) { - this.fsPromises = await import('fs/promises'); - } - return this.fsPromises; - } - - /** - * Lazy load fs module (cached after first load) - */ - private async getFsSync(): Promise { - if (!this.fsSync) { - this.fsSync = await import('fs'); - } - return this.fsSync; - } - - /** - * Check if running in a container environment (async) - * Note: This is cached after first check since it won't change during runtime - */ - private containerEnvCache: boolean | null = null; - private async isContainerEnvironment(): Promise { - // Cache the result since it won't change during runtime - if (this.containerEnvCache !== null) { - return this.containerEnvCache; - } - - if (process.env.DOCKER_CONTAINER === 'true') { - this.containerEnvCache = true; - return true; - } - - // Check for /.dockerenv file (async) - this.containerEnvCache = await this.pathExists('/.dockerenv'); - return this.containerEnvCache; - } - - /** - * Check if a path exists (async, non-blocking) - * Uses fs.access() which is the recommended async way to check existence - */ - private async pathExists(path: string): Promise { - try { - const fs = await this.getFsPromises(); - await fs.access(path); - return true; - } catch { - return false; - } - } - - /** - * Sanitize error messages to prevent leaking sensitive path information - * Only shows relative paths, not absolute paths - */ - private sanitizePathForError(path: string): string { - // If path is within repoRoot, show relative path - const resolvedPath = resolve(path).replace(/\\/g, '/'); - const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); - if (resolvedPath.startsWith(resolvedRoot + '/')) { - return resolvedPath.slice(resolvedRoot.length + 1); - } - // For paths outside repoRoot, only show last component for security - return path.split(/[/\\]/).pop() || path; - } - - /** - * Generate container-specific error message for permission issues - */ - private getContainerPermissionError(path: string, operation: string): string { - const sanitizedPath = this.sanitizePathForError(path); - return `Permission denied: ${operation} at ${sanitizedPath}. In Docker, check that the volume mount has correct permissions. The container runs as user 'gitrepublic' (UID 1001). Ensure the host directory is writable by this user or adjust ownership: chown -R 1001:1001 ./repos`; - } - - /** - * Ensure directory exists with proper error handling and security - * @param dirPath - Directory path to ensure exists - * @param description - Description for error messages - * @param checkParent - Whether to check parent directory permissions first - */ - private async ensureDirectoryExists( - dirPath: string, - description: string, - checkParent: boolean = false - ): Promise { - // Check cache first (with TTL) - const cacheKey = `dir:${dirPath}`; - const cached = this.dirExistenceCache.get(cacheKey); - const now = Date.now(); - if (cached && (now - cached.timestamp) < this.DIR_CACHE_TTL) { - if (cached.exists) { - return; // Directory exists, skip - } - } - - // Use async path existence check - const exists = await this.pathExists(dirPath); - if (exists) { - // Update cache - this.dirExistenceCache.set(cacheKey, { exists: true, timestamp: now }); - return; - } - - // Check parent directory if requested - if (checkParent) { - const parentDir = dirname(dirPath); - const parentExists = await this.pathExists(parentDir); - if (!parentExists) { - await this.ensureDirectoryExists(parentDir, `Parent of ${description}`, true); - } else { - // Verify parent is writable (async) - try { - const fs = await this.getFsPromises(); - await fs.access(parentDir, fs.constants.W_OK); - } catch (accessErr) { - const isContainer = await this.isContainerEnvironment(); - const errorMsg = isContainer - ? this.getContainerPermissionError(parentDir, `writing to parent directory of ${description}`) - : `Parent directory ${this.sanitizePathForError(parentDir)} is not writable`; - logger.error({ error: accessErr, parentDir, description }, errorMsg); - throw new Error(errorMsg); - } - } - } - - // Create directory - try { - const { mkdir } = await this.getFsPromises(); - await mkdir(dirPath, { recursive: true }); - logger.debug({ dirPath: this.sanitizePathForError(dirPath), description }, 'Created directory'); - // Update cache - this.dirExistenceCache.set(cacheKey, { exists: true, timestamp: now }); - } catch (mkdirErr) { - // Clear cache on error - this.dirExistenceCache.delete(cacheKey); - const isContainer = await this.isContainerEnvironment(); - const errorMsg = isContainer - ? this.getContainerPermissionError(dirPath, `creating ${description}`) - : `Failed to create ${description}: ${mkdirErr instanceof Error ? mkdirErr.message : String(mkdirErr)}`; - logger.error({ error: mkdirErr, dirPath: this.sanitizePathForError(dirPath), description }, errorMsg); - throw new Error(errorMsg); - } - } - - /** - * Verify directory is writable (for security checks) - async - */ - private async verifyDirectoryWritable(dirPath: string, description: string): Promise { - const exists = await this.pathExists(dirPath); - if (!exists) { - throw new Error(`${description} does not exist at ${this.sanitizePathForError(dirPath)}`); - } - - try { - const fs = await this.getFsPromises(); - await fs.access(dirPath, fs.constants.W_OK); - } catch (accessErr) { - const isContainer = await this.isContainerEnvironment(); - const errorMsg = isContainer - ? this.getContainerPermissionError(dirPath, `writing to ${description}`) - : `${description} at ${this.sanitizePathForError(dirPath)} is not writable`; - logger.error({ error: accessErr, dirPath: this.sanitizePathForError(dirPath), description }, errorMsg); - throw new Error(errorMsg); - } - } - - /** - * Clear directory existence cache (useful after operations that create directories) - */ - private clearDirCache(dirPath?: string): void { - if (dirPath) { - const cacheKey = `dir:${dirPath}`; - this.dirExistenceCache.delete(cacheKey); - } else { - // Clear all cache - this.dirExistenceCache.clear(); - } - } - - /** - * Sanitize error messages to prevent information leakage - * Uses sanitizeError from security utils and adds path sanitization - */ - private sanitizeErrorMessage(error: unknown, context?: { npub?: string; repoName?: string; filePath?: string }): string { - let message = sanitizeError(error); - - // Remove sensitive context if present - if (context?.npub) { - const { truncateNpub } = require('../../utils/security.js'); - message = message.replace(new RegExp(context.npub.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), truncateNpub(context.npub)); - } - - // Sanitize file paths - if (context?.filePath) { - const sanitizedPath = this.sanitizePathForError(context.filePath); - message = message.replace(new RegExp(context.filePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), sanitizedPath); - } - - return message; - } - - /** - * Create or get a git worktree for a repository - * More efficient than cloning the entire repo for each operation - * Security: Validates branch name to prevent path traversal attacks - */ - async getWorktree(repoPath: string, branch: string, npub: string, repoName: string): Promise { - // Security: Validate branch name to prevent path traversal - if (!isValidBranchName(branch)) { - throw new Error(`Invalid branch name: ${branch}`); - } - - const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); - // Use resolve to ensure we have an absolute path (important for git worktree add) - const worktreePath = resolve(join(worktreeRoot, branch)); - const resolvedWorktreeRoot = resolve(worktreeRoot); - - // Additional security: Ensure resolved path is still within worktreeRoot - const resolvedPath = worktreePath.replace(/\\/g, '/'); - const resolvedRoot = resolvedWorktreeRoot.replace(/\\/g, '/'); - if (!resolvedPath.startsWith(resolvedRoot + '/')) { - throw new Error('Path traversal detected: worktree path outside allowed root'); - } - const { rm } = await this.getFsPromises(); - - // Ensure worktree root exists (use resolved path) with parent check - await this.ensureDirectoryExists(resolvedWorktreeRoot, 'worktree root directory', true); - - const git = simpleGit(repoPath); - - // Check for existing worktrees for this branch and clean them up if they're in the wrong location - try { - const worktreeList = await git.raw(['worktree', 'list', '--porcelain']); - const lines = worktreeList.split('\n'); - let currentWorktreePath: string | null = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - if (line.startsWith('worktree ')) { - currentWorktreePath = line.substring(9).trim(); - } else if (line.startsWith('branch ') && line.includes(`refs/heads/${branch}`)) { - // Found a worktree for this branch - if (currentWorktreePath && currentWorktreePath !== worktreePath) { - // Worktree exists but in wrong location - remove it - logger.warn({ oldPath: currentWorktreePath, newPath: worktreePath, branch }, 'Removing worktree from incorrect location'); - try { - await git.raw(['worktree', 'remove', currentWorktreePath, '--force']); - } catch (err) { - // If git worktree remove fails, try to remove the directory manually - logger.warn({ error: err, path: currentWorktreePath }, 'Failed to remove worktree via git, will try manual removal'); - try { - await rm(currentWorktreePath, { recursive: true, force: true }); - } catch (rmErr) { - logger.error({ error: rmErr, path: currentWorktreePath }, 'Failed to manually remove worktree directory'); - } - } - } - break; - } - } - } catch (err) { - // If worktree list fails, continue - might be no worktrees yet - logger.debug({ error: err }, 'Could not list worktrees (this is okay if no worktrees exist)'); - } - - // Check if worktree already exists at the correct location - if (await this.pathExists(worktreePath)) { - // Verify it's a valid worktree - try { - const worktreeGit = simpleGit(worktreePath); - await worktreeGit.status(); - return worktreePath; - } catch { - // Invalid worktree, remove it - const { rm } = await this.getFsPromises(); - await rm(worktreePath, { recursive: true, force: true }); - } - } - - // Create new worktree - try { - // First, check if the branch exists and has commits - let branchExists = false; - let branchHasCommits = false; - try { - // Check if branch exists - const branchList = await git.branch(['-a']); - const branchNames = branchList.all.map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '')); - branchExists = branchNames.includes(branch); - - if (branchExists) { - // Check if branch has commits by trying to get the latest commit - try { - const commitHash = await git.raw(['rev-parse', `refs/heads/${branch}`]); - branchHasCommits = !!(commitHash && commitHash.trim().length > 0); - } catch { - // Branch exists but has no commits (orphan branch) - branchHasCommits = false; - } - } - } catch (err) { - logger.debug({ error: err, branch }, 'Could not check branch status, will try to create worktree'); - } - - // If branch exists but has no commits, create an initial empty commit first - if (branchExists && !branchHasCommits) { - logger.debug({ branch }, 'Branch exists but has no commits, creating initial empty commit'); - try { - // Fetch repo announcement to use as commit message - let commitMessage = 'Initial commit'; - try { - const { NostrClient } = await import('../nostr/nostr-client.js'); - const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); - const { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } = await import('../../utils/nostr-utils.js'); - const { eventCache } = await import('../nostr/event-cache.js'); - const { nip19 } = await import('nostr-tools'); - const { requireNpubHex } = await import('../../utils/npub-utils.js'); - - const repoOwnerPubkey = requireNpubHex(npub); - const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache); - const announcement = findRepoAnnouncement(allEvents, repoName); - - if (announcement) { - // Format announcement as commit message - const name = announcement.tags.find((t: string[]) => t[0] === 'name')?.[1] || repoName; - const description = announcement.tags.find((t: string[]) => t[0] === 'description')?.[1] || ''; - commitMessage = `Repository announcement: ${name}${description ? '\n\n' + description : ''}\n\nEvent ID: ${announcement.id}`; - logger.debug({ branch, announcementId: announcement.id }, 'Using repo announcement as initial commit message'); - } - } catch (announcementErr) { - logger.debug({ error: announcementErr, branch }, 'Failed to fetch announcement, using default commit message'); - } - - // Create a temporary worktree with a temp branch name to make the initial commit - const tempBranchName = `.temp-init-${Date.now()}`; - const tempWorktreePath = resolve(join(this.repoRoot, npub, `${repoName}.worktrees`, tempBranchName)); - const { mkdir } = await import('fs/promises'); - await mkdir(dirname(tempWorktreePath), { recursive: true }); - - // Create orphan worktree with temp branch - await new Promise((resolve, reject) => { - const orphanProcess = spawn('git', ['worktree', 'add', '--orphan', tempBranchName, tempWorktreePath], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let orphanStderr = ''; - orphanProcess.stderr.on('data', (chunk: Buffer) => { - orphanStderr += chunk.toString(); - }); - - orphanProcess.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Failed to create orphan worktree: ${orphanStderr}`)); - } - }); - - orphanProcess.on('error', reject); - }); - - // Create initial empty commit in temp branch with announcement as message - const tempGit = simpleGit(tempWorktreePath); - await tempGit.commit(commitMessage, ['--allow-empty'], { - '--author': 'GitRepublic ' - }); - - // Get the commit hash - const commitHash = await tempGit.revparse(['HEAD']); - - // Update the actual branch to point to this commit - await git.raw(['update-ref', `refs/heads/${branch}`, commitHash.trim()]); - - // Remove temporary worktree and temp branch - await this.removeWorktree(repoPath, tempWorktreePath); - await git.raw(['branch', '-D', tempBranchName]).catch(() => { - // Ignore if branch deletion fails - }); - - logger.debug({ branch, commitHash }, 'Created initial empty commit on orphan branch with announcement'); - } catch (err) { - logger.warn({ error: err, branch }, 'Failed to create initial commit, will try normal worktree creation'); - } - } - - // Use spawn for worktree add (safer than exec) - await new Promise((resolve, reject) => { - const gitProcess = spawn('git', ['worktree', 'add', worktreePath, branch], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let stderr = ''; - gitProcess.stderr.on('data', (chunk: Buffer) => { - stderr += chunk.toString(); - }); - - gitProcess.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - // If branch doesn't exist, create it first using git branch (works on bare repos) - if (stderr.includes('fatal: invalid reference') || stderr.includes('fatal: not a valid object name') || stderr.includes('Ungültige Referenz')) { - // First, try to find a source branch (HEAD, main, or master) - const findSourceBranch = async (): Promise => { - try { - const branches = await git.branch(['-a']); - // Try HEAD first, then main, then master - if (branches.all.includes('HEAD') || branches.all.includes('origin/HEAD')) { - return 'HEAD'; - } - if (branches.all.includes('main') || branches.all.includes('origin/main')) { - return 'main'; - } - if (branches.all.includes('master') || branches.all.includes('origin/master')) { - return 'master'; - } - // Use the first available branch - const firstBranch = branches.all.find(b => !b.includes('HEAD')); - if (firstBranch) { - return firstBranch.replace(/^origin\//, ''); - } - throw new Error('No source branch found'); - } catch { - // Default to HEAD - return 'HEAD'; - } - }; - - findSourceBranch().then((sourceBranch) => { - // Create branch using git branch command (works on bare repos) - return new Promise((resolveBranch, rejectBranch) => { - const branchProcess = spawn('git', ['branch', branch, sourceBranch], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let branchStderr = ''; - branchProcess.stderr.on('data', (chunk: Buffer) => { - branchStderr += chunk.toString(); - }); - - branchProcess.on('close', (branchCode) => { - if (branchCode === 0) { - resolveBranch(); - } else { - // If creating branch from source fails, try creating orphan branch - if (branchStderr.includes('fatal: invalid reference') || branchStderr.includes('Ungültige Referenz')) { - // Create orphan branch instead - const orphanProcess = spawn('git', ['branch', branch], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let orphanStderr = ''; - orphanProcess.stderr.on('data', (chunk: Buffer) => { - orphanStderr += chunk.toString(); - }); - - orphanProcess.on('close', (orphanCode) => { - if (orphanCode === 0) { - resolveBranch(); - } else { - rejectBranch(new Error(`Failed to create orphan branch: ${orphanStderr}`)); - } - }); - - orphanProcess.on('error', rejectBranch); - } else { - rejectBranch(new Error(`Failed to create branch: ${branchStderr}`)); - } - } - }); - - branchProcess.on('error', rejectBranch); - }); - }).then(() => { - // Retry worktree add after creating the branch - return new Promise((resolve2, reject2) => { - const gitProcess2 = spawn('git', ['worktree', 'add', worktreePath, branch], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let retryStderr = ''; - gitProcess2.stderr.on('data', (chunk: Buffer) => { - retryStderr += chunk.toString(); - }); - - gitProcess2.on('close', (code2) => { - if (code2 === 0) { - resolve2(); - } else { - // If still failing, try with --orphan - if (retryStderr.includes('fatal: invalid reference') || retryStderr.includes('Ungültige Referenz')) { - const orphanWorktreeProcess = spawn('git', ['worktree', 'add', '--orphan', branch, worktreePath], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - let orphanWorktreeStderr = ''; - orphanWorktreeProcess.stderr.on('data', (chunk: Buffer) => { - orphanWorktreeStderr += chunk.toString(); - }); - - orphanWorktreeProcess.on('close', (orphanWorktreeCode) => { - if (orphanWorktreeCode === 0) { - resolve2(); - } else { - reject2(new Error(`Failed to create orphan worktree: ${orphanWorktreeStderr}`)); - } - }); - - orphanWorktreeProcess.on('error', reject2); - } else { - reject2(new Error(`Failed to create worktree after creating branch: ${retryStderr}`)); - } - } - }); - - gitProcess2.on('error', reject2); - }); - }).then(resolve).catch(reject); - } else { - reject(new Error(`Failed to create worktree: ${stderr}`)); - } - } - }); - - gitProcess.on('error', reject); - }); - - // Verify the worktree directory was actually created (after the promise resolves) - if (!(await this.pathExists(worktreePath))) { - throw new Error(`Worktree directory was not created: ${this.sanitizePathForError(worktreePath)}`); - } - - // Verify it's a valid git repository - const worktreeGit = simpleGit(worktreePath); - try { - await worktreeGit.status(); - } catch (err) { - throw new Error(`Created worktree directory is not a valid git repository: ${worktreePath}`); - } - - return worktreePath; - } catch (error) { - const sanitizedError = sanitizeError(error); - logger.error({ error: sanitizedError, repoPath, branch, worktreePath }, 'Failed to create worktree'); - throw new Error(`Failed to create worktree: ${sanitizedError}`); - } - } - - /** - * Remove a worktree - */ - async removeWorktree(repoPath: string, worktreePath: string): Promise { - try { - // Use spawn for worktree remove (safer than exec) - await new Promise((resolve, reject) => { - const gitProcess = spawn('git', ['worktree', 'remove', worktreePath], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - gitProcess.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - // If worktree remove fails, try force remove - const gitProcess2 = spawn('git', ['worktree', 'remove', '--force', worktreePath], { - cwd: repoPath, - stdio: ['ignore', 'pipe', 'pipe'] - }); - - gitProcess2.on('close', (code2) => { - if (code2 === 0) { - resolve(); - } else { - // Last resort: just delete the directory - import('fs/promises').then(({ rm }) => { - return rm(worktreePath, { recursive: true, force: true }); - }).then(() => resolve()).catch(reject); - } - }); - - gitProcess2.on('error', reject); - } - }); - - gitProcess.on('error', reject); - }); - } catch (error) { - const sanitizedError = sanitizeError(error); - logger.warn({ error: sanitizedError, repoPath, worktreePath }, 'Failed to remove worktree cleanly'); - // Try to remove directory directly as fallback - try { - const { rm } = await import('fs/promises'); - await rm(worktreePath, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - } - } - - /** - * Get the full path to a repository - */ - getRepoPath(npub: string, repoName: string): string { - const repoPath = join(this.repoRoot, npub, `${repoName}.git`); - // Security: Ensure the resolved path is within repoRoot to prevent path traversal - // Normalize paths to handle Windows/Unix differences - const resolvedPath = resolve(repoPath).replace(/\\/g, '/'); - const resolvedRoot = resolve(this.repoRoot).replace(/\\/g, '/'); - // Must be a subdirectory of repoRoot, not equal to it - if (!resolvedPath.startsWith(resolvedRoot + '/')) { - throw new Error('Path traversal detected: repository path outside allowed root'); - } - return repoPath; - } - - /** - * Validate and sanitize file path to prevent path traversal attacks - */ - private validateFilePath(filePath: string): { valid: boolean; error?: string; normalized?: string } { - // Allow empty string for root directory - if (filePath === '') { - return { valid: true, normalized: '' }; - } - if (!filePath || typeof filePath !== 'string') { - return { valid: false, error: 'File path must be a non-empty string' }; - } - - // Normalize the path (resolves .. and .) - const normalized = normalize(filePath); - - // Check for path traversal attempts - if (normalized.includes('..')) { - return { valid: false, error: 'Path traversal detected (..)' }; - } - - // Check for absolute paths - if (normalized.startsWith('/')) { - return { valid: false, error: 'Absolute paths are not allowed' }; - } - - // Check for null bytes - if (normalized.includes('\0')) { - return { valid: false, error: 'Null bytes are not allowed in paths' }; - } - - // Check for control characters - if (/[\x00-\x1f\x7f]/.test(normalized)) { - return { valid: false, error: 'Control characters are not allowed in paths' }; - } - - // Limit path length (reasonable limit) - if (normalized.length > 4096) { - return { valid: false, error: 'Path is too long (max 4096 characters)' }; - } - - return { valid: true, normalized }; - } - - /** - * Validate repository name to prevent injection attacks - */ - private validateRepoName(repoName: string): { valid: boolean; error?: string } { - if (!repoName || typeof repoName !== 'string') { - return { valid: false, error: 'Repository name must be a non-empty string' }; - } - - // Check length - if (repoName.length > 100) { - return { valid: false, error: 'Repository name is too long (max 100 characters)' }; - } - - // Check for invalid characters (alphanumeric, hyphens, underscores, dots) - if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) { - return { valid: false, error: 'Repository name contains invalid characters' }; - } - - // Check for path traversal - if (repoName.includes('..') || repoName.includes('/') || repoName.includes('\\')) { - return { valid: false, error: 'Repository name contains invalid path characters' }; - } - - return { valid: true }; - } - - /** - * Validate npub format - */ - private validateNpub(npub: string): { valid: boolean; error?: string } { - if (!npub || typeof npub !== 'string') { - return { valid: false, error: 'npub must be a non-empty string' }; - } - - // Basic npub format check (starts with npub, base58 encoded) - if (!npub.startsWith('npub1') || npub.length < 10 || npub.length > 100) { - return { valid: false, error: 'Invalid npub format' }; - } - - return { valid: true }; - } - - /** - * Check if repository exists (with caching) - */ - repoExists(npub: string, repoName: string): boolean { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - return false; - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - return false; - } - - // Check cache first - const cacheKey = RepoCache.repoExistsKey(npub, repoName); - const cached = repoCache.get(cacheKey); - if (cached !== null) { - return cached; - } - - const repoPath = this.getRepoPath(npub, repoName); - const exists = this.repoManager.repoExists(repoPath); - - // Cache the result (cache for 1 minute) - repoCache.set(cacheKey, exists, 60 * 1000); - - return exists; - } - - /** - * List files and directories in a repository at a given path - * Uses caching to reduce redundant git operations - */ - async listFiles(npub: string, repoName: string, ref: string = 'HEAD', path: string = ''): Promise { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - throw new Error(`Invalid npub: ${npubValidation.error}`); - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - throw new Error(`Invalid repository name: ${repoValidation.error}`); - } - - const pathValidation = this.validateFilePath(path); - if (!pathValidation.valid) { - throw new Error(`Invalid file path: ${pathValidation.error}`); - } - - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - // Check cache first (cache for 2 minutes) - const cacheKey = RepoCache.fileListKey(npub, repoName, ref, path); - const cached = repoCache.get(cacheKey); - if (cached !== null) { - logger.debug({ npub, repoName, path, ref, cachedCount: cached.length }, '[FileManager] Returning cached file list'); - return cached; - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // Get the tree for the specified path - // For directories, git ls-tree needs a trailing slash to list contents - // For root, use '.' - // Note: git ls-tree returns paths relative to repo root, not relative to the specified path - const gitPath = path ? (path.endsWith('/') ? path : `${path}/`) : '.'; - logger.debug({ npub, repoName, path, ref, gitPath }, '[FileManager] Calling git ls-tree'); - - let tree: string; - try { - tree = await git.raw(['ls-tree', '-l', ref, gitPath]); - } catch (lsTreeError) { - // Handle empty branches (orphan branches with no commits) - // git ls-tree will fail with "fatal: not a valid object name" or similar - const errorMsg = lsTreeError instanceof Error ? lsTreeError.message : String(lsTreeError); - const errorStr = String(lsTreeError).toLowerCase(); - const errorMsgLower = errorMsg.toLowerCase(); - - // Check for various error patterns that indicate empty branch/no commits - const isEmptyBranchError = - errorMsgLower.includes('not a valid object') || - errorMsgLower.includes('not found') || - errorMsgLower.includes('bad revision') || - errorMsgLower.includes('ambiguous argument') || - errorStr.includes('not a valid object') || - errorStr.includes('not found') || - errorStr.includes('bad revision') || - errorStr.includes('ambiguous argument') || - errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads')); - - if (isEmptyBranchError) { - logger.debug({ npub, repoName, path, ref, gitPath, error: errorMsg, errorStr }, '[FileManager] Branch has no commits, returning empty list'); - const emptyResult: FileEntry[] = []; - // Cache empty result for shorter time (30 seconds) - repoCache.set(cacheKey, emptyResult, 30 * 1000); - return emptyResult; - } - // Log the error for debugging - logger.error({ npub, repoName, path, ref, gitPath, error: lsTreeError, errorMsg, errorStr }, '[FileManager] Unexpected error from git ls-tree'); - // Re-throw if it's a different error - throw lsTreeError; - } - - if (!tree || !tree.trim()) { - const emptyResult: FileEntry[] = []; - // Cache empty result for shorter time (30 seconds) - repoCache.set(cacheKey, emptyResult, 30 * 1000); - logger.debug({ npub, repoName, path, ref, gitPath }, '[FileManager] git ls-tree returned empty result'); - return emptyResult; - } - - logger.debug({ npub, repoName, path, ref, gitPath, treeLength: tree.length, firstLines: tree.split('\n').slice(0, 5) }, '[FileManager] git ls-tree output'); - - const entries: FileEntry[] = []; - const lines = tree.trim().split('\n').filter(line => line.length > 0); - - // Normalize the path for comparison (ensure it ends with / for directory matching) - const normalizedPath = path ? (path.endsWith('/') ? path : `${path}/`) : ''; - - logger.debug({ path, normalizedPath, lineCount: lines.length }, '[FileManager] Starting to parse entries'); - - for (const line of lines) { - // Format: \t - // Note: git ls-tree uses a tab character between size and filename - // The format is: mode type object sizepath - // Important: git ls-tree returns paths relative to repo root, not relative to the specified path - // We need to handle both spaces and tabs - const tabIndex = line.lastIndexOf('\t'); - if (tabIndex === -1) { - // Try space-separated format as fallback - const match = line.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)\s+(.+)$/); - if (match) { - const [, , type, , size, gitPath] = match; - // git ls-tree always returns paths relative to repo root - // If we're listing a subdirectory, the returned paths will start with that directory - let fullPath: string; - let displayName: string; - - if (normalizedPath) { - // We're listing a subdirectory - if (gitPath.startsWith(normalizedPath)) { - // Path already includes the directory prefix (normal case) - fullPath = gitPath; - // Extract just the filename/dirname (relative to the requested path) - const relativePath = gitPath.slice(normalizedPath.length); - // Remove any leading/trailing slashes - const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); - // For display name, get the first component (the immediate child) - // This handles both files (image.png) and nested dirs (screenshots/image.png -> screenshots) - displayName = cleanRelative.split('/')[0] || cleanRelative; - } else { - // Path doesn't start with directory prefix - this shouldn't happen normally - // but handle it by joining - logger.debug({ path, normalizedPath, gitPath }, '[FileManager] Path does not start with normalized path, joining (space-separated)'); - fullPath = join(path, gitPath); - displayName = gitPath.split('/').pop() || gitPath; - } - } else { - // Root directory listing - paths are relative to root - fullPath = gitPath; - displayName = gitPath.split('/')[0]; // Get first component - } - - logger.debug({ gitPath, path, normalizedPath, fullPath, displayName, type }, '[FileManager] Parsed entry (space-separated)'); - - entries.push({ - name: displayName, - path: fullPath, - type: type === 'tree' ? 'directory' : 'file', - size: size !== '-' ? parseInt(size, 10) : undefined - }); - } else { - logger.debug({ line, path, ref }, '[FileManager] Line did not match expected format (space-separated)'); - } - } else { - // Tab-separated format (standard) - const beforeTab = line.substring(0, tabIndex); - const gitPath = line.substring(tabIndex + 1); - const match = beforeTab.match(/^(\d+)\s+(\w+)\s+(\w+)\s+(\d+|-)$/); - if (match) { - const [, , type, , size] = match; - // git ls-tree always returns paths relative to repo root - // If we're listing a subdirectory, the returned paths will start with that directory - let fullPath: string; - let displayName: string; - - if (normalizedPath) { - // We're listing a subdirectory - if (gitPath.startsWith(normalizedPath)) { - // Path already includes the directory prefix (normal case) - fullPath = gitPath; - // Extract just the filename/dirname (relative to the requested path) - const relativePath = gitPath.slice(normalizedPath.length); - // Remove any leading/trailing slashes - const cleanRelative = relativePath.replace(/^\/+|\/+$/g, ''); - // For display name, get the first component (the immediate child) - // This handles both files (image.png) and nested dirs (screenshots/image.png -> screenshots) - displayName = cleanRelative.split('/')[0] || cleanRelative; - } else { - // Path doesn't start with directory prefix - this shouldn't happen normally - // but handle it by joining - logger.debug({ path, normalizedPath, gitPath }, '[FileManager] Path does not start with normalized path, joining (tab-separated)'); - fullPath = join(path, gitPath); - displayName = gitPath.split('/').pop() || gitPath; - } - } else { - // Root directory listing - paths are relative to root - fullPath = gitPath; - displayName = gitPath.split('/')[0]; // Get first component - } - - logger.debug({ gitPath, path, normalizedPath, fullPath, displayName, type }, '[FileManager] Parsed entry (tab-separated)'); - - entries.push({ - name: displayName, - path: fullPath, - type: type === 'tree' ? 'directory' : 'file', - size: size !== '-' ? parseInt(size, 10) : undefined - }); - } else { - logger.debug({ line, path, ref, beforeTab }, '[FileManager] Line did not match expected format (tab-separated)'); - } - } - } - - // Debug logging to help diagnose missing files - logger.debug({ - npub, - repoName, - path, - ref, - entryCount: entries.length, - entries: entries.map(e => ({ name: e.name, path: e.path, type: e.type })) - }, '[FileManager] Parsed file entries'); - - const sortedEntries = entries.sort((a, b) => { - // Directories first, then files, both alphabetically - if (a.type !== b.type) { - return a.type === 'directory' ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - - // Cache the result (cache for 2 minutes) - repoCache.set(cacheKey, sortedEntries, 2 * 60 * 1000); - - return sortedEntries; - } catch (error) { - // Check if this is an empty branch error that wasn't caught earlier - const errorMsg = error instanceof Error ? error.message : String(error); - const errorStr = String(error).toLowerCase(); - const errorMsgLower = errorMsg.toLowerCase(); - - const isEmptyBranchError = - errorMsgLower.includes('not a valid object') || - errorMsgLower.includes('not found') || - errorMsgLower.includes('bad revision') || - errorMsgLower.includes('ambiguous argument') || - errorStr.includes('not a valid object') || - errorStr.includes('not found') || - errorStr.includes('bad revision') || - errorStr.includes('ambiguous argument') || - (errorMsgLower.includes('fatal:') && (errorMsgLower.includes('master') || errorMsgLower.includes('refs/heads'))); - - if (isEmptyBranchError) { - logger.debug({ npub, repoName, path, ref, error: errorMsg, errorStr }, '[FileManager] Branch has no commits (caught in outer catch), returning empty list'); - const emptyResult: FileEntry[] = []; - // Cache empty result for shorter time (30 seconds) - repoCache.set(cacheKey, emptyResult, 30 * 1000); - return emptyResult; - } - - logger.error({ error, repoPath, ref, errorMsg, errorStr }, 'Error listing files'); - throw new Error(`Failed to list files: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Get file content from a repository - */ - async getFileContent(npub: string, repoName: string, filePath: string, ref: string = 'HEAD'): Promise { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - throw new Error(`Invalid npub: ${npubValidation.error}`); - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - throw new Error(`Invalid repository name: ${repoValidation.error}`); - } - - const pathValidation = this.validateFilePath(filePath); - if (!pathValidation.valid) { - throw new Error(`Invalid file path: ${pathValidation.error}`); - } - - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // Get file content using git show - // Use raw() for better error handling and to catch stderr - let content: string; - try { - content = await git.raw(['show', `${ref}:${filePath}`]); - } catch (gitError: any) { - // simple-git might throw errors in different formats - // Check stderr if available - const stderr = gitError?.stderr || gitError?.message || String(gitError); - const stderrLower = stderr.toLowerCase(); - - logger.debug({ gitError, repoPath, filePath, ref, stderr }, 'git.raw() error details'); - - // Check if it's a "not found" type error - if (stderrLower.includes('not found') || - stderrLower.includes('no such file') || - stderrLower.includes('does not exist') || - stderrLower.includes('fatal:') || - stderr.includes('pathspec') || - stderr.includes('ambiguous argument') || - stderr.includes('unknown revision') || - stderr.includes('bad revision')) { - throw new Error(`File not found: ${filePath} at ref ${ref}`); - } - - // Re-throw with more context - throw new Error(`Git command failed: ${stderr}`); - } - - // Check if content is undefined or null (indicates error) - if (content === undefined || content === null) { - throw new Error(`File not found: ${filePath} at ref ${ref}`); - } - - // Try to determine encoding (assume UTF-8 for text files) - const encoding = 'utf-8'; - const size = Buffer.byteLength(content, encoding); - - return { - content, - encoding, - size - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorLower = errorMessage.toLowerCase(); - const errorString = String(error); - const errorStringLower = errorString.toLowerCase(); - - logger.error({ error, repoPath, filePath, ref, errorMessage, errorString }, 'Error reading file'); - - // Check if it's a "not found" type error (check both errorMessage and errorString) - if (errorLower.includes('not found') || - errorStringLower.includes('not found') || - errorLower.includes('no such file') || - errorStringLower.includes('no such file') || - errorLower.includes('does not exist') || - errorStringLower.includes('does not exist') || - errorLower.includes('fatal:') || - errorStringLower.includes('fatal:') || - errorMessage.includes('pathspec') || - errorString.includes('pathspec') || - errorMessage.includes('ambiguous argument') || - errorString.includes('ambiguous argument') || - errorString.includes('unknown revision') || - errorString.includes('bad revision')) { - throw new Error(`File not found: ${filePath} at ref ${ref}`); - } - - throw new Error(`Failed to read file: ${errorMessage}`); - } - } - - /** - * Write file and commit changes - * @param signingOptions - Optional commit signing options: - * - commitSignatureEvent: Pre-signed commit signature event from client (NIP-07, recommended) - * - useNIP07: Use NIP-07 browser extension (DEPRECATED: use commitSignatureEvent instead) - * - nip98Event: Use NIP-98 auth event as signature (server-side, for git operations) - * - nsecKey: Use direct nsec/hex key (server-side ONLY, via environment variables - NOT for client requests) - */ - async writeFile( - npub: string, - repoName: string, - filePath: string, - content: string, - commitMessage: string, - authorName: string, - authorEmail: string, - branch: string = 'main', - signingOptions?: { - commitSignatureEvent?: NostrEvent; - useNIP07?: boolean; - nip98Event?: NostrEvent; - nsecKey?: string; - } - ): Promise { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - throw new Error(`Invalid npub: ${npubValidation.error}`); - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - throw new Error(`Invalid repository name: ${repoValidation.error}`); - } - - const pathValidation = this.validateFilePath(filePath); - if (!pathValidation.valid) { - throw new Error(`Invalid file path: ${pathValidation.error}`); - } - - // Security: Validate branch name to prevent path traversal - if (!isValidBranchName(branch)) { - throw new Error(`Invalid branch name: ${branch}`); - } - - // Validate content size (prevent extremely large files) - const maxFileSize = 500 * 1024 * 1024; // 500 MB per file (allows for images and demo videos) - if (Buffer.byteLength(content, 'utf-8') > maxFileSize) { - throw new Error(`File is too large (max ${maxFileSize / 1024 / 1024} MB)`); - } - - // Validate commit message - if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { - throw new Error('Commit message is required'); - } - if (commitMessage.length > 1000) { - throw new Error('Commit message is too long (max 1000 characters)'); - } - - // Validate author info - if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { - throw new Error('Author name is required'); - } - if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { - throw new Error('Valid author email is required'); - } - - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - try { - // Check repository size before writing - const repoSizeCheck = await this.repoManager.checkRepoSizeLimit(repoPath); - if (!repoSizeCheck.withinLimit) { - throw new Error(repoSizeCheck.error || 'Repository size limit exceeded'); - } - - // Use git worktree instead of cloning (much more efficient) - const workDir = await this.getWorktree(repoPath, branch, npub, repoName); - const workGit: SimpleGit = simpleGit(workDir); - - // Write the file (use validated path) - const validatedPath = pathValidation.normalized || filePath; - const fullFilePath = join(workDir, validatedPath); - const fileDir = dirname(fullFilePath); - - // Additional security: ensure the resolved path is still within workDir - // Use trailing slash to ensure directory boundary (prevents sibling directory attacks) - const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); - const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); - if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { - throw new Error('Path validation failed: resolved path outside work directory'); - } - - // Ensure directory exists - await this.ensureDirectoryExists(fileDir, 'directory for file', true); - - const { writeFile: writeFileFs } = await import('fs/promises'); - await writeFileFs(fullFilePath, content, 'utf-8'); - - // Stage the file (use validated path) - await workGit.add(validatedPath); - - // Sign commit if signing options are provided - let finalCommitMessage = commitMessage; - if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { - try { - const { signedMessage } = await createGitCommitSignature( - commitMessage, - authorName, - authorEmail, - signingOptions - ); - finalCommitMessage = signedMessage; - } catch (err) { - // Security: Sanitize error messages (never log private keys) - const sanitizedErr = sanitizeError(err); - logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); - // Continue without signature if signing fails - } - } - - // Commit - const commitResult = await workGit.commit(finalCommitMessage, [filePath], { - '--author': `${authorName} <${authorEmail}>` - }) as string | { commit: string }; - - // Get commit hash from result - let commitHash: string; - if (typeof commitResult === 'string') { - commitHash = commitResult.trim(); - } else if (commitResult && typeof commitResult === 'object' && 'commit' in commitResult) { - commitHash = String(commitResult.commit); - } else { - // Fallback: get latest commit hash - commitHash = await workGit.revparse(['HEAD']); - } - - // Save commit signature event to nostr folder if signing was used - if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { - try { - // Get the signature event that was used (already created above) - let signatureEvent: NostrEvent; - if (signingOptions.commitSignatureEvent) { - signatureEvent = signingOptions.commitSignatureEvent; - } else { - // Re-create it to get the event object - const { signedMessage: _, signatureEvent: event } = await createGitCommitSignature( - commitMessage, - authorName, - authorEmail, - signingOptions - ); - signatureEvent = event; - } - - // Save to nostr/commit-signatures.jsonl (use workDir since we have it) - await this.saveCommitSignatureEventToWorktree(workDir, signatureEvent); - - // Check if repo is private - only publish to relays if public - const isPrivate = await this.isRepoPrivate(npub, repoName); - if (!isPrivate) { - // Public repo: publish commit signature event to relays - try { - const { NostrClient } = await import('../nostr/nostr-client.js'); - const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); - const { getUserRelays } = await import('../nostr/user-relays.js'); - const { combineRelays } = await import('../../config.js'); - - // Get user's preferred relays (outbox/inbox from kind 10002) - const { nip19 } = await import('nostr-tools'); - const { requireNpubHex } = await import('../../utils/npub-utils.js'); - const userPubkeyHex = requireNpubHex(npub); - - const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const { inbox, outbox } = await getUserRelays(userPubkeyHex, nostrClient); - - // Use user's outbox relays if available, otherwise inbox, otherwise defaults - const userRelays = outbox.length > 0 - ? combineRelays(outbox, DEFAULT_NOSTR_RELAYS) - : inbox.length > 0 - ? combineRelays(inbox, DEFAULT_NOSTR_RELAYS) - : DEFAULT_NOSTR_RELAYS; - - // Publish to relays (non-blocking - don't fail if publishing fails) - const publishResult = await nostrClient.publishEvent(signatureEvent, userRelays); - if (publishResult.success.length > 0) { - logger.debug({ - eventId: signatureEvent.id, - commitHash, - relays: publishResult.success - }, 'Published commit signature event to relays'); - } - if (publishResult.failed.length > 0) { - logger.warn({ - eventId: signatureEvent.id, - failed: publishResult.failed - }, 'Some relays failed to publish commit signature event'); - } - } catch (publishErr) { - // Log but don't fail - publishing is nice-to-have, saving to repo is the important part - const sanitizedErr = sanitizeError(publishErr); - logger.debug({ error: sanitizedErr, repoPath, filePath }, 'Failed to publish commit signature event to relays'); - } - } else { - // Private repo: only save to repo, don't publish to relays - logger.debug({ repoPath, filePath }, 'Private repo - commit signature event saved to repo only (not published to relays)'); - } - } catch (err) { - // Log but don't fail - saving event is nice-to-have - const sanitizedErr = sanitizeError(err); - logger.debug({ error: sanitizedErr, repoPath, filePath }, 'Failed to save commit signature event'); - } - } - - // Note: No push needed - worktrees of bare repos share the same object database, - // so the commit is already in the bare repository. We don't push to remote origin - // to avoid requiring remote authentication and to keep changes local-only.t - - // Clean up worktree (but keep it for potential reuse) - // Note: We could keep worktrees for better performance, but clean up for now - await this.removeWorktree(repoPath, workDir); - } catch (error) { - logger.error({ error, repoPath, filePath, npub }, 'Error writing file'); - throw new Error(`Failed to write file: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Save commit signature event to nostr/commit-signatures.jsonl in a worktree - */ - private async saveCommitSignatureEventToWorktree(worktreePath: string, event: NostrEvent): Promise { - try { - const { mkdir, writeFile } = await import('fs/promises'); - const { join } = await import('path'); - - // Create nostr directory in worktree - const nostrDir = join(worktreePath, 'nostr'); - await mkdir(nostrDir, { recursive: true }); - - // Append to commit-signatures.jsonl - const jsonlFile = join(nostrDir, 'commit-signatures.jsonl'); - const eventLine = JSON.stringify(event) + '\n'; - await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); - } catch (err) { - logger.debug({ error: err, worktreePath }, 'Failed to save commit signature event to nostr folder'); - // Don't throw - this is a nice-to-have feature - } - } - - /** - * Save a repo event (announcement or transfer) to nostr/repo-events.jsonl - * This provides a standard location for all repo-related Nostr events for easy analysis - */ - async saveRepoEventToWorktree( - worktreePath: string, - event: NostrEvent, - eventType: 'announcement' | 'transfer', - skipIfExists: boolean = true - ): Promise { - try { - const { mkdir, writeFile, readFile } = await import('fs/promises'); - const { join } = await import('path'); - - // Create nostr directory in worktree - const nostrDir = join(worktreePath, 'nostr'); - await mkdir(nostrDir, { recursive: true }); - - // Append to repo-events.jsonl with event type metadata - const jsonlFile = join(nostrDir, 'repo-events.jsonl'); - - // Check if event already exists if skipIfExists is true - if (skipIfExists) { - try { - const existingContent = await readFile(jsonlFile, 'utf-8'); - const lines = existingContent.trim().split('\n').filter(l => l.trim()); - for (const line of lines) { - try { - const parsed = JSON.parse(line); - if (parsed.event && parsed.event.id === event.id) { - logger.debug({ eventId: event.id, worktreePath }, 'Event already exists in nostr/repo-events.jsonl, skipping'); - return false; - } - } catch { - // Skip invalid JSON lines - } - } - } catch { - // File doesn't exist yet, that's fine - } - } - - const eventLine = JSON.stringify({ - type: eventType, - timestamp: event.created_at, - event - }) + '\n'; - await writeFile(jsonlFile, eventLine, { flag: 'a', encoding: 'utf-8' }); - return true; - } catch (err) { - logger.debug({ error: err, worktreePath, eventType }, 'Failed to save repo event to nostr/repo-events.jsonl'); - // Don't throw - this is a nice-to-have feature - return false; - } - } - - /** - * Check if a repository is private by fetching its announcement event - */ - private async isRepoPrivate(npub: string, repoName: string): Promise { - try { - const { requireNpubHex } = await import('../../utils/npub-utils.js'); - const repoOwnerPubkey = requireNpubHex(npub); - - // Fetch the repository announcement - const { NostrClient } = await import('../nostr/nostr-client.js'); - const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); - const { KIND } = await import('../../types/nostr.js'); - - const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const events = await nostrClient.fetchEvents([ - { - kinds: [KIND.REPO_ANNOUNCEMENT], - authors: [repoOwnerPubkey], - '#d': [repoName], - limit: 1 - } - ]); - - if (events.length === 0) { - // No announcement found - assume public (default) - return false; - } - - const announcement = events[0]; - - // Use shared utility to check if repo is private - const { isPrivateRepo: checkIsPrivateRepo } = await import('../../utils/repo-privacy.js'); - return checkIsPrivateRepo(announcement); - } catch (err) { - // If we can't determine, default to public (safer - allows publishing) - logger.debug({ error: err, npub, repoName }, 'Failed to check repo privacy, defaulting to public'); - return false; - } - } - - /** - * Get list of branches (with caching) - */ - /** - * Get the default branch name for a repository - * Tries to detect the actual default branch (master, main, etc.) - */ - async getDefaultBranch(npub: string, repoName: string): Promise { - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // Try to get the default branch from symbolic-ref - // For bare repos, this points to the default branch - const defaultRef = await git.raw(['symbolic-ref', 'HEAD']); - if (defaultRef) { - const match = defaultRef.trim().match(/^refs\/heads\/(.+)$/); - if (match) { - return match[1]; - } - } - } catch { - // If symbolic-ref fails, try to get from remote HEAD - try { - const remoteHead = await git.raw(['symbolic-ref', 'refs/remotes/origin/HEAD']); - if (remoteHead) { - const match = remoteHead.trim().match(/^refs\/remotes\/origin\/(.+)$/); - if (match) { - return match[1]; - } - } - } catch { - // Fall through to branch detection - } - } - - // Fallback: get branches and prefer 'main', then 'master', then first branch - try { - const branches = await git.branch(['-r']); - const branchList = branches.all - .map(b => b.replace(/^origin\//, '')) - .filter(b => !b.includes('HEAD')); - - if (branchList.length === 0) { - return 'main'; // Ultimate fallback - } - - // Prefer 'main', then 'master', then first branch - if (branchList.includes('main')) return 'main'; - if (branchList.includes('master')) return 'master'; - return branchList[0]; - } catch { - return 'main'; // Ultimate fallback - } - } - - async getBranches(npub: string, repoName: string): Promise { - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - // Check cache first - const cacheKey = RepoCache.branchesKey(npub, repoName); - const cached = repoCache.get(cacheKey); - if (cached !== null) { - return cached; - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // For bare repositories, list local branches (they're stored in refs/heads/) - // Also check remote branches in case the repo has remotes configured - const [localBranches, remoteBranches] = await Promise.all([ - git.branch(['-a']).catch(() => ({ all: [] })), // List all branches (local and remote) - git.branch(['-r']).catch(() => ({ all: [] })) // Also try remote branches separately - ]); - - // Combine local and remote branches, removing duplicates - const allBranches = new Set(); - - // Add local branches (from -a, filter out remotes) - localBranches.all - .filter(b => !b.startsWith('remotes/') && !b.includes('HEAD')) - .forEach(b => allBranches.add(b)); - - // Add remote branches (remove origin/ prefix) - remoteBranches.all - .map(b => b.replace(/^origin\//, '')) - .filter(b => !b.includes('HEAD')) - .forEach(b => allBranches.add(b)); - - // If no branches found, try listing refs directly (for bare repos) - if (allBranches.size === 0) { - try { - const refs = await git.raw(['for-each-ref', '--format=%(refname:short)', 'refs/heads/']); - if (refs) { - refs.trim().split('\n').forEach(b => { - if (b && !b.includes('HEAD')) { - allBranches.add(b); - } - }); - } - } catch { - // If that fails too, continue with empty set - } - } - - // Sort branches: default branch first, then alphabetically - let branchList = Array.from(allBranches); - try { - const defaultBranch = await this.getDefaultBranch(npub, repoName); - if (defaultBranch) { - branchList.sort((a, b) => { - if (a === defaultBranch) return -1; - if (b === defaultBranch) return 1; - return a.localeCompare(b); - }); - } else { - // No default branch found, just sort alphabetically - branchList.sort(); - } - } catch { - // If we can't get default branch, just sort alphabetically - branchList.sort(); - } - - // Cache the result (cache for 2 minutes) - repoCache.set(cacheKey, branchList, 2 * 60 * 1000); - - return branchList; - } catch (error) { - logger.error({ error, repoPath }, 'Error getting branches'); - const defaultBranches = ['main', 'master']; - // Cache default branches for shorter time (30 seconds) - repoCache.set(cacheKey, defaultBranches, 30 * 1000); - return defaultBranches; - } - } - - /** - * Create a new file - * @param signingOptions - Optional commit signing options (see writeFile) - */ - async createFile( - npub: string, - repoName: string, - filePath: string, - content: string, - commitMessage: string, - authorName: string, - authorEmail: string, - branch: string = 'main', - signingOptions?: { - useNIP07?: boolean; - nip98Event?: NostrEvent; - nsecKey?: string; - } - ): Promise { - // Reuse writeFile logic - it will create the file if it doesn't exist - return this.writeFile(npub, repoName, filePath, content, commitMessage, authorName, authorEmail, branch, signingOptions); - } - - /** - * Delete a file - * @param signingOptions - Optional commit signing options (see writeFile) - */ - async deleteFile( - npub: string, - repoName: string, - filePath: string, - commitMessage: string, - authorName: string, - authorEmail: string, - branch: string = 'main', - signingOptions?: { - commitSignatureEvent?: NostrEvent; - useNIP07?: boolean; - nip98Event?: NostrEvent; - nsecKey?: string; - } - ): Promise { - // Validate inputs - const npubValidation = this.validateNpub(npub); - if (!npubValidation.valid) { - throw new Error(`Invalid npub: ${npubValidation.error}`); - } - const repoValidation = this.validateRepoName(repoName); - if (!repoValidation.valid) { - throw new Error(`Invalid repository name: ${repoValidation.error}`); - } - - const pathValidation = this.validateFilePath(filePath); - if (!pathValidation.valid) { - throw new Error(`Invalid file path: ${pathValidation.error}`); - } - - // Security: Validate branch name to prevent path traversal - if (!isValidBranchName(branch)) { - throw new Error(`Invalid branch name: ${branch}`); - } - - // Validate commit message - if (!commitMessage || typeof commitMessage !== 'string' || commitMessage.trim().length === 0) { - throw new Error('Commit message is required'); - } - - // Validate author info - if (!authorName || typeof authorName !== 'string' || authorName.trim().length === 0) { - throw new Error('Author name is required'); - } - if (!authorEmail || typeof authorEmail !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(authorEmail)) { - throw new Error('Valid author email is required'); - } - - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - try { - // Use git worktree instead of cloning (much more efficient) - const workDir = await this.getWorktree(repoPath, branch, npub, repoName); - const workGit: SimpleGit = simpleGit(workDir); - - // Remove the file (use validated path) - const validatedPath = pathValidation.normalized || filePath; - const fullFilePath = join(workDir, validatedPath); - - // Additional security: ensure the resolved path is still within workDir - // Use trailing slash to ensure directory boundary (prevents sibling directory attacks) - const resolvedPath = resolve(fullFilePath).replace(/\\/g, '/'); - const resolvedWorkDir = resolve(workDir).replace(/\\/g, '/'); - if (!resolvedPath.startsWith(resolvedWorkDir + '/') && resolvedPath !== resolvedWorkDir) { - throw new Error('Path validation failed: resolved path outside work directory'); - } - - if (await this.pathExists(fullFilePath)) { - const { unlink } = await this.getFsPromises(); - await unlink(fullFilePath); - } - - // Stage the deletion (use validated path) - await workGit.rm([validatedPath]); - - // Sign commit if signing options are provided - let finalCommitMessage = commitMessage; - if (signingOptions && (signingOptions.commitSignatureEvent || signingOptions.useNIP07 || signingOptions.nip98Event || signingOptions.nsecKey)) { - try { - const { signedMessage } = await createGitCommitSignature( - commitMessage, - authorName, - authorEmail, - signingOptions - ); - finalCommitMessage = signedMessage; - } catch (err) { - // Security: Sanitize error messages (never log private keys) - const sanitizedErr = sanitizeError(err); - logger.warn({ error: sanitizedErr, repoPath, filePath }, 'Failed to sign commit'); - // Continue without signature if signing fails - } - } - - // Commit - await workGit.commit(finalCommitMessage, [filePath], { - '--author': `${authorName} <${authorEmail}>` - }); - - // Note: No push needed - worktrees of bare repos share the same object database, - // so the commit is already in the bare repository. We don't push to remote origin - // to avoid requiring remote authentication and to keep changes local-only. - - // Clean up worktree - await this.removeWorktree(repoPath, workDir); - } catch (error) { - logger.error({ error, repoPath, filePath, npub }, 'Error deleting file'); - throw new Error(`Failed to delete file: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Create a new branch - */ - async createBranch( - npub: string, - repoName: string, - branchName: string, - fromBranch?: string, - announcement?: NostrEvent - ): Promise { - // Security: Validate branch names to prevent path traversal - if (!isValidBranchName(branchName)) { - throw new Error(`Invalid branch name: ${branchName}`); - } - - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - try { - const git: SimpleGit = simpleGit(repoPath); - - // Check if repo has any branches - let hasBranches = false; - try { - const branches = await git.branch(['-a']); - const branchList = branches.all - .map(b => b.replace(/^remotes\/origin\//, '').replace(/^remotes\//, '')) - .filter(b => !b.includes('HEAD') && !b.startsWith('*')); - hasBranches = branchList.length > 0; - } catch { - // If branch listing fails, assume no branches exist - hasBranches = false; - } - - // If no branches exist, create an orphan branch (branch with no parent) - if (!hasBranches) { - // Use provided announcement or fetch repo announcement to use as initial commit message and save to file - let commitMessage = 'Initial commit'; - let announcementEvent: NostrEvent | null = announcement || null; - - // If announcement not provided, try to fetch it - if (!announcementEvent) { - try { - const { NostrClient } = await import('../nostr/nostr-client.js'); - const { DEFAULT_NOSTR_RELAYS } = await import('../../config.js'); - const { fetchRepoAnnouncementsWithCache, findRepoAnnouncement } = await import('../../utils/nostr-utils.js'); - const { eventCache } = await import('../nostr/event-cache.js'); - const { requireNpubHex } = await import('../../utils/npub-utils.js'); - - const repoOwnerPubkey = requireNpubHex(npub); - const nostrClient = new NostrClient(DEFAULT_NOSTR_RELAYS); - const allEvents = await fetchRepoAnnouncementsWithCache(nostrClient, repoOwnerPubkey, eventCache); - announcementEvent = findRepoAnnouncement(allEvents, repoName); - } catch (announcementErr) { - logger.debug({ error: announcementErr, branchName }, 'Failed to fetch announcement, using default commit message'); - } - } - - if (announcementEvent) { - // Format announcement as commit message - const name = announcementEvent.tags.find((t: string[]) => t[0] === 'name')?.[1] || repoName; - const description = announcementEvent.tags.find((t: string[]) => t[0] === 'description')?.[1] || ''; - commitMessage = `Repository announcement: ${name}${description ? '\n\n' + description : ''}\n\nEvent ID: ${announcementEvent.id}`; - logger.debug({ branchName, announcementId: announcementEvent.id }, 'Using repo announcement as initial commit message'); - } - - // Create worktree for the new branch directly (orphan branch) - const worktreeRoot = join(this.repoRoot, npub, `${repoName}.worktrees`); - const worktreePath = resolve(join(worktreeRoot, branchName)); - const { rm } = await this.getFsPromises(); - - // Ensure repoRoot is writable if it exists - if (await this.pathExists(this.repoRoot)) { - await this.verifyDirectoryWritable(this.repoRoot, 'GIT_REPO_ROOT directory'); - } - - // Ensure parent directory exists (npub directory) - const parentDir = join(this.repoRoot, npub); - await this.ensureDirectoryExists(parentDir, 'parent directory for worktree', true); - - // Create worktree root directory - await this.ensureDirectoryExists(worktreeRoot, 'worktree root directory', false); - - // Remove existing worktree if it exists - if (await this.pathExists(worktreePath)) { - try { - await git.raw(['worktree', 'remove', worktreePath, '--force']); - } catch { - const { rm } = await this.getFsPromises(); - await rm(worktreePath, { recursive: true, force: true }); - } - } - - // Create worktree with orphan branch - // Note: --orphan must come before branch name, path comes last - await git.raw(['worktree', 'add', '--orphan', branchName, worktreePath]); - - // Save announcement to nostr/repo-events.jsonl if we have it - let announcementSaved = false; - if (announcementEvent) { - try { - announcementSaved = await this.saveRepoEventToWorktree(worktreePath, announcementEvent, 'announcement', true); - logger.debug({ branchName, announcementId: announcementEvent.id }, 'Saved announcement to nostr/repo-events.jsonl'); - } catch (saveErr) { - logger.warn({ error: saveErr, branchName }, 'Failed to save announcement to nostr/repo-events.jsonl, continuing with empty commit'); - } - } - - // Stage files if announcement was saved - const workGit: SimpleGit = simpleGit(worktreePath); - const filesToAdd: string[] = []; - if (announcementSaved) { - filesToAdd.push('nostr/repo-events.jsonl'); - } - - // Create initial commit with announcement file (if saved) or empty commit - if (filesToAdd.length > 0) { - await workGit.add(filesToAdd); - await workGit.commit(commitMessage, filesToAdd, { - '--author': 'GitRepublic ' - }); - } else { - await workGit.commit(commitMessage, ['--allow-empty'], { - '--author': 'GitRepublic ' - }); - } - - // Set the default branch to the new branch in the bare repo - await git.raw(['symbolic-ref', 'HEAD', `refs/heads/${branchName}`]); - - logger.debug({ branchName, announcementSaved }, 'Created orphan branch with initial commit'); - - // Clean up worktree - await this.removeWorktree(repoPath, worktreePath); - } else { - // Repo has branches - use normal branch creation - // Validate fromBranch if provided - if (fromBranch && !isValidBranchName(fromBranch)) { - throw new Error(`Invalid source branch name: ${fromBranch}`); - } - - // Use default branch if fromBranch not provided - const sourceBranch = fromBranch || await this.getDefaultBranch(npub, repoName).catch(() => 'main'); - - // Use git worktree instead of cloning (much more efficient) - const workDir = await this.getWorktree(repoPath, sourceBranch, npub, repoName); - const workGit: SimpleGit = simpleGit(workDir); - - // Create and checkout new branch - await workGit.checkout(['-b', branchName]); - - // Note: No push needed - worktrees of bare repos share the same object database, - // so the branch is already in the bare repository. We don't push to remote origin - // to avoid requiring remote authentication and to keep changes local-only. - - // Clean up worktree - await this.removeWorktree(repoPath, workDir); - } - } catch (error) { - logger.error({ error, repoPath, branchName, npub }, 'Error creating branch'); - throw new Error(`Failed to create branch: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Delete a branch - */ - async deleteBranch( - npub: string, - repoName: string, - branchName: string - ): Promise { - // Security: Validate branch name to prevent path traversal - if (!isValidBranchName(branchName)) { - throw new Error(`Invalid branch name: ${branchName}`); - } - - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - // Prevent deleting the default branch - const defaultBranch = await this.getDefaultBranch(npub, repoName); - if (branchName === defaultBranch) { - throw new Error(`Cannot delete the default branch (${defaultBranch}). Please switch to a different branch first.`); - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // Check if branch exists - const branches = await git.branch(['-a']); - const branchExists = branches.all.some(b => - b === branchName || - b === `refs/heads/${branchName}` || - b.replace(/^origin\//, '') === branchName - ); - - if (!branchExists) { - throw new Error(`Branch ${branchName} does not exist`); - } - - // Delete the branch (use -D to force delete even if not merged) - // For bare repos, we delete the ref directly - await git.raw(['branch', '-D', branchName]).catch(async () => { - // If branch -D fails (might be a remote branch reference), try deleting the ref directly - try { - await git.raw(['update-ref', '-d', `refs/heads/${branchName}`]); - } catch (refError) { - // If that also fails, the branch might not exist locally - throw new Error(`Failed to delete branch: ${branchName}`); - } - }); - - // Invalidate branches cache - const cacheKey = RepoCache.branchesKey(npub, repoName); - repoCache.delete(cacheKey); - } catch (error) { - logger.error({ error, repoPath, branchName, npub }, 'Error deleting branch'); - throw new Error(`Failed to delete branch: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Get commit history - */ - async getCommitHistory( - npub: string, - repoName: string, - branch: string = 'main', - limit: number = 50, - path?: string - ): Promise { - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - const logOptions: { - maxCount: number; - from: string; - file?: string; - } = { - maxCount: limit, - from: branch - }; - - if (path) { - logOptions.file = path; - } - - const log = await git.log(logOptions); - - return log.all.map(commit => ({ - hash: commit.hash, - message: commit.message, - author: `${commit.author_name} <${commit.author_email}>`, - date: commit.date, - files: commit.diff?.files?.map((f: { file: string }) => f.file) || [] - })); - } catch (error) { - logger.error({ error, repoPath, branch, limit }, 'Error getting commit history'); - throw new Error(`Failed to get commit history: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Get diff between two commits or for a file - */ - async getDiff( - npub: string, - repoName: string, - fromRef: string, - toRef: string = 'HEAD', - filePath?: string - ): Promise { - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - const diffOptions: string[] = [fromRef, toRef]; - if (filePath) { - diffOptions.push('--', filePath); - } - - const diff = await git.diff(diffOptions); - const stats = await git.diffSummary(diffOptions); - - // Parse diff output - const files: Diff[] = []; - const diffLines = diff.split('\n'); - let currentFile = ''; - let currentDiff = ''; - let inFileHeader = false; - - for (const line of diffLines) { - if (line.startsWith('diff --git')) { - if (currentFile) { - files.push({ - file: currentFile, - additions: 0, - deletions: 0, - diff: currentDiff - }); - } - const match = line.match(/diff --git a\/(.+?) b\/(.+?)$/); - if (match) { - currentFile = match[2]; - currentDiff = line + '\n'; - inFileHeader = true; - } - } else { - currentDiff += line + '\n'; - if (line.startsWith('@@')) { - inFileHeader = false; - } - if (!inFileHeader && (line.startsWith('+') || line.startsWith('-'))) { - // Count additions/deletions - } - } - } - - if (currentFile) { - files.push({ - file: currentFile, - additions: 0, - deletions: 0, - diff: currentDiff - }); - } - - // Add stats from diffSummary - if (stats.files && files.length > 0) { - for (const statFile of stats.files) { - const file = files.find(f => f.file === statFile.file); - if (file && 'insertions' in statFile && 'deletions' in statFile) { - file.additions = statFile.insertions; - file.deletions = statFile.deletions; - } - } - } - - return files; - } catch (error) { - const sanitizedError = sanitizeError(error); - logger.error({ error: sanitizedError, repoPath: this.sanitizePathForError(repoPath), fromRef, toRef }, 'Error getting diff'); - throw new Error(`Failed to get diff: ${sanitizedError}`); - } - } - - /** - * Create a tag - */ - async createTag( - npub: string, - repoName: string, - tagName: string, - ref: string = 'HEAD', - message?: string, - authorName?: string, - authorEmail?: string - ): Promise { - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - // Check if repository has any commits - let hasCommits = false; - try { - // Try to get HEAD commit - const headCommit = await git.raw(['rev-parse', 'HEAD']).catch(() => null); - hasCommits = !!(headCommit && headCommit.trim().length > 0); - } catch { - // Check if any branch has commits - try { - const branches = await git.branch(['-a']); - for (const branch of branches.all) { - const branchName = branch.replace(/^remotes\/origin\//, '').replace(/^remotes\//, ''); - if (branchName.includes('HEAD')) continue; - try { - const commitHash = await git.raw(['rev-parse', `refs/heads/${branchName}`]).catch(() => null); - if (commitHash && commitHash.trim().length > 0) { - hasCommits = true; - // If ref is HEAD and we found a branch with commits, use that branch - if (ref === 'HEAD') { - ref = branchName; - } - break; - } - } catch { - // Continue checking other branches - } - } - } catch { - // Could not check branches - } - } - - if (!hasCommits) { - throw new Error('Cannot create tag: repository has no commits. Please create at least one commit first.'); - } - - // Validate that the ref exists - try { - await git.raw(['rev-parse', '--verify', ref]); - } catch (refErr) { - throw new Error(`Invalid reference '${ref}': ${refErr instanceof Error ? refErr.message : String(refErr)}`); - } - - if (message) { - // Create annotated tag - // Note: simple-git addTag doesn't support message directly, use raw command - if (ref !== 'HEAD') { - await git.raw(['tag', '-a', tagName, '-m', message, ref]); - } else { - await git.raw(['tag', '-a', tagName, '-m', message]); - } - } else { - // Create lightweight tag - if (ref !== 'HEAD') { - await git.raw(['tag', tagName, ref]); - } else { - await git.addTag(tagName); - } - } - } catch (error) { - logger.error({ error, repoPath, tagName, ref, message }, 'Error creating tag'); - throw new Error(`Failed to create tag: ${error instanceof Error ? error.message : String(error)}`); - } - } - - /** - * Get list of tags - */ - async getTags(npub: string, repoName: string): Promise { - const repoPath = this.getRepoPath(npub, repoName); - - if (!this.repoExists(npub, repoName)) { - throw new Error('Repository not found'); - } - - const git: SimpleGit = simpleGit(repoPath); - - try { - const tags = await git.tags(); - const tagList: Tag[] = []; - - for (const tagName of tags.all) { - try { - // Get the commit hash the tag points to - const hash = await git.raw(['rev-parse', tagName]); - const commitHash = hash.trim(); - - // Get the commit date (Unix timestamp) - let commitDate: number | undefined; - try { - const dateStr = await git.raw(['log', '-1', '--format=%at', commitHash]); - commitDate = parseInt(dateStr.trim(), 10); - if (isNaN(commitDate)) { - commitDate = undefined; - } - } catch { - // If we can't get the date, continue without it - commitDate = undefined; - } - - // Try to get tag message (for annotated tags) - try { - const tagInfo = await git.raw(['cat-file', '-p', tagName]); - const messageMatch = tagInfo.match(/^(.+)$/m); - - tagList.push({ - name: tagName, - hash: commitHash, - message: messageMatch ? messageMatch[1] : undefined, - date: commitDate - }); - } catch { - // Lightweight tag - no message - tagList.push({ - name: tagName, - hash: commitHash, - date: commitDate - }); - } - } catch (err) { - // If we can't process this tag, skip it - logger.warn({ error: err, tagName }, 'Error processing tag, skipping'); - } - } - - return tagList; - } catch (error) { - logger.error({ error, repoPath }, 'Error getting tags'); - return []; - } - } - - /** - * Get the current owner from the most recent announcement file in the repository - * Ownership is determined by the most recent announcement file checked into the git repo - * - * @param npub - Repository owner npub (for path construction) - * @param repoName - The repository name - * @returns The current owner pubkey from the most recent announcement file, or null if not found - */ - async getCurrentOwnerFromRepo(npub: string, repoName: string): Promise { - try { - if (!this.repoExists(npub, repoName)) { - return null; - } - - const repoPath = this.getRepoPath(npub, repoName); - const git: SimpleGit = simpleGit(repoPath); - - // Get git log for nostr/repo-events.jsonl, most recent first - // Use --all to check all branches, --reverse to get chronological order - const logOutput = await git.raw(['log', '--all', '--format=%H', '--reverse', '--', 'nostr/repo-events.jsonl']); - const commitHashes = logOutput.trim().split('\n').filter(Boolean); - - if (commitHashes.length === 0) { - return null; // No announcement in repo - } - - // Get the most recent repo-events.jsonl content (last commit in the list) - const mostRecentCommit = commitHashes[commitHashes.length - 1]; - const repoEventsFile = await this.getFileContent(npub, repoName, 'nostr/repo-events.jsonl', mostRecentCommit); - - // Parse the repo-events.jsonl file and find the most recent announcement - let announcementEvent: any = null; - let latestTimestamp = 0; - try { - const lines = repoEventsFile.content.trim().split('\n').filter(Boolean); - for (const line of lines) { - try { - const entry = JSON.parse(line); - if (entry.type === 'announcement' && entry.event && entry.timestamp) { - if (entry.timestamp > latestTimestamp) { - latestTimestamp = entry.timestamp; - announcementEvent = entry.event; - } - } - } catch { - // Skip invalid lines - continue; - } - } - } catch (parseError) { - logger.warn({ error: parseError, npub, repoName, commit: mostRecentCommit }, 'Failed to parse repo-events.jsonl'); - return null; - } - - if (!announcementEvent) { - return null; - } - - // Validate the announcement event to prevent fake announcements - const { validateAnnouncementEvent } = await import('../nostr/repo-verification.js'); - const validation = validateAnnouncementEvent(announcementEvent, repoName); - - if (!validation.valid) { - logger.warn({ - error: validation.error, - npub, - repoName, - commit: mostRecentCommit, - eventId: announcementEvent.id, - eventPubkey: announcementEvent.pubkey?.substring(0, 16) + '...' - }, 'Announcement file validation failed - possible fake announcement'); - return null; - } - - // Return the pubkey from the validated announcement - return announcementEvent.pubkey; - } catch (error) { - logger.error({ error, npub, repoName }, 'Error getting current owner from repo'); - return null; - } - } -} diff --git a/src/lib/services/nostr/nostr-client.ts b/src/lib/services/nostr/nostr-client.ts index a2b5676..f3d64d9 100644 --- a/src/lib/services/nostr/nostr-client.ts +++ b/src/lib/services/nostr/nostr-client.ts @@ -8,33 +8,11 @@ import logger from '../logger.js'; import { isNIP07Available, getPublicKeyWithNIP07, signEventWithNIP07 } from './nip07-signer.js'; import { SimplePool, type Filter } from 'nostr-tools'; import { KIND } from '../../types/nostr.js'; +import { isParameterizedReplaceable } from '../../utils/nostr-event-utils.js'; // Replaceable event kinds (only latest per pubkey matters) const REPLACEABLE_KINDS = [0, 3, 10002]; // Profile, Contacts, Relay List -/** - * Check if an event is a parameterized replaceable event (NIP-33) - * Parameterized replaceable events have: - * - kind >= 10000 && kind < 20000 (replaceable range) with a 'd' tag, OR - * - kind >= 30000 && kind < 40000 (addressable range) with a 'd' tag - */ -function isParameterizedReplaceable(event: NostrEvent): boolean { - const hasDTag = event.tags.some(t => t[0] === 'd' && t[1]); - if (!hasDTag) return false; - - // Replaceable range (NIP-33) - if (event.kind >= 10000 && event.kind < 20000) { - return true; - } - - // Addressable range (NIP-34) - also parameterized replaceable - if (event.kind >= 30000 && event.kind < 40000) { - return true; - } - - return false; -} - /** * Get the deduplication key for an event * For replaceable events: kind:pubkey diff --git a/src/lib/services/nostr/repo-polling.ts b/src/lib/services/nostr/repo-polling.ts index 759b61c..32b7ca5 100644 --- a/src/lib/services/nostr/repo-polling.ts +++ b/src/lib/services/nostr/repo-polling.ts @@ -18,6 +18,8 @@ export class RepoPollingService { private intervalId: NodeJS.Timeout | null = null; private domain: string; private relays: string[]; + private initialPollPromise: Promise | null = null; + private isInitialPollComplete: boolean = false; constructor( relays: string[], @@ -34,29 +36,52 @@ export class RepoPollingService { /** * Start polling for repo announcements + * Returns a promise that resolves when the initial poll completes */ - start(): void { + start(): Promise { if (this.intervalId) { this.stop(); } - // Poll immediately - this.poll(); + // Poll immediately and wait for it to complete + this.initialPollPromise = this.poll(); // Then poll at intervals this.intervalId = setInterval(() => { this.poll(); }, this.pollingInterval); + + return this.initialPollPromise; } /** - * Stop polling + * Wait for initial poll to complete (useful for server startup) + */ + async waitForInitialPoll(): Promise { + if (this.initialPollPromise) { + await this.initialPollPromise; + } + } + + /** + * Check if initial poll has completed + */ + isReady(): boolean { + return this.isInitialPollComplete; + } + + /** + * Stop polling and cleanup resources */ stop(): void { if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } + // Close Nostr client connections + if (this.nostrClient) { + this.nostrClient.close(); + } } /** @@ -64,6 +89,7 @@ export class RepoPollingService { */ private async poll(): Promise { try { + logger.debug('Starting repo poll...'); const events = await this.nostrClient.fetchEvents([ { kinds: [KIND.REPO_ANNOUNCEMENT], @@ -186,8 +212,20 @@ export class RepoPollingService { logger.error({ error, eventId: event.id }, 'Failed to provision repo from announcement'); } } + + // Mark initial poll as complete + if (!this.isInitialPollComplete) { + this.isInitialPollComplete = true; + logger.info('Initial repo poll completed'); + } } catch (error) { logger.error({ error }, 'Error polling for repo announcements'); + + // Still mark as complete even on error (to prevent blocking) + if (!this.isInitialPollComplete) { + this.isInitialPollComplete = true; + logger.warn('Initial repo poll completed with errors'); + } } } diff --git a/src/lib/utils/git-process.ts b/src/lib/utils/git-process.ts index eb82ffd..76ecf9b 100644 --- a/src/lib/utils/git-process.ts +++ b/src/lib/utils/git-process.ts @@ -5,6 +5,7 @@ import { spawn, type ChildProcess } from 'child_process'; import logger from '../services/logger.js'; +import { killProcessGroup, forceKillProcessGroup, cleanupProcess, closeProcessStreams } from './process-cleanup.js'; export interface GitProcessOptions { cwd?: string; @@ -50,33 +51,21 @@ export function spawnGitProcess( let stderr = ''; let resolved = false; - // Set timeout to prevent hanging processes + // Set timeout to prevent hanging processes with aggressive cleanup + let forceKillTimeout: NodeJS.Timeout | null = null; const timeoutId = timeoutMs > 0 ? setTimeout(() => { if (!resolved && !gitProcess.killed) { resolved = true; - logger.warn({ args, timeoutMs }, 'Git process timeout, killing process'); + logger.warn({ args, timeoutMs }, 'Git process timeout, killing process group'); - // Kill the process tree to prevent zombies - try { - gitProcess.kill('SIGTERM'); - // Force kill after grace period - const forceKillTimeout = setTimeout(() => { - if (gitProcess.pid && !gitProcess.killed) { - try { - gitProcess.kill('SIGKILL'); - } catch (err) { - logger.warn({ err, pid: gitProcess.pid }, 'Failed to force kill git process'); - } - } - }, 5000); - - // Clear force kill timeout if process terminates - gitProcess.once('close', () => { - clearTimeout(forceKillTimeout); - }); - } catch (err) { - logger.warn({ err }, 'Error killing timed out git process'); - } + // Kill entire process group to prevent zombies + killProcessGroup(gitProcess, 'SIGTERM'); + + // Force kill after grace period + forceKillTimeout = forceKillProcessGroup(gitProcess, 5000); + + // Ensure streams are closed + closeProcessStreams(gitProcess); reject(new Error(`Git command timeout after ${timeoutMs}ms: ${args.join(' ')}`)); } @@ -98,21 +87,12 @@ export function spawnGitProcess( // Handle process close (main cleanup point) gitProcess.on('close', (code, signal) => { - if (timeoutId) clearTimeout(timeoutId); + // Aggressive cleanup: clear timeouts and ensure streams are closed + cleanupProcess(gitProcess, [timeoutId, forceKillTimeout]); if (resolved) return; resolved = true; - // Ensure process is fully cleaned up - if (gitProcess.pid) { - try { - // Check if process still exists (this helps ensure cleanup) - process.kill(gitProcess.pid, 0); - } catch { - // Process already dead, that's fine - } - } - resolve({ stdout, stderr, @@ -121,9 +101,10 @@ export function spawnGitProcess( }); }); - // Handle process errors + // Handle process errors with aggressive cleanup gitProcess.on('error', (err) => { - if (timeoutId) clearTimeout(timeoutId); + // Aggressive cleanup on error + cleanupProcess(gitProcess, [timeoutId, forceKillTimeout], 'SIGTERM'); if (resolved) return; resolved = true; diff --git a/src/lib/utils/nostr-event-utils.ts b/src/lib/utils/nostr-event-utils.ts new file mode 100644 index 0000000..42146fe --- /dev/null +++ b/src/lib/utils/nostr-event-utils.ts @@ -0,0 +1,35 @@ +/** + * Shared utilities for Nostr event handling + * Consolidates duplicate functions used across the codebase + * + * Based on NIP-01: https://github.com/nostr-protocol/nips/blob/master/01.md + */ + +import type { NostrEvent } from '../types/nostr.js'; + +/** + * Check if an event is a parameterized replaceable event (addressable event per NIP-01) + * + * According to NIP-01: + * - Replaceable events (10000-19999, 0, 3): Replaceable by kind+pubkey only (no d-tag needed) + * - Addressable events (30000-39999): Addressable by kind+pubkey+d-tag (d-tag required) + * + * This function returns true only for addressable events (30000-39999) that have a d-tag, + * as these are the events that require a parameter (d-tag) to be uniquely identified. + * + * @param event - The Nostr event to check + * @returns true if the event is an addressable event (30000-39999) with a d-tag + */ +export function isParameterizedReplaceable(event: NostrEvent): boolean { + // Addressable events (30000-39999) require a d-tag to be addressable + // Per NIP-01: "for kind n such that 30000 <= n < 40000, events are addressable + // by their kind, pubkey and d tag value" + if (event.kind >= 30000 && event.kind < 40000) { + const hasDTag = event.tags.some(t => t[0] === 'd' && t[1]); + return hasDTag; + } + + // Replaceable events (10000-19999, 0, 3) are NOT parameterized replaceable + // They are replaceable by kind+pubkey only, without needing a d-tag + return false; +} diff --git a/src/lib/utils/process-cleanup.ts b/src/lib/utils/process-cleanup.ts new file mode 100644 index 0000000..b8d28a2 --- /dev/null +++ b/src/lib/utils/process-cleanup.ts @@ -0,0 +1,147 @@ +/** + * Aggressive process cleanup utilities to prevent zombie processes + * Implements process group killing and explicit reaping + */ + +import { spawn, type ChildProcess } from 'child_process'; +import logger from '../services/logger.js'; + +/** + * Kill a process and attempt to kill its process group to prevent zombies + * On Unix systems, tries to kill the process group using negative PID + * Falls back to killing just the process if group kill fails + */ +export function killProcessGroup(proc: ChildProcess, signal: NodeJS.Signals = 'SIGTERM'): void { + if (!proc.pid) { + return; + } + + try { + // First, try to kill just the process (most reliable) + if (!proc.killed) { + proc.kill(signal); + logger.debug({ pid: proc.pid, signal }, 'Killed process'); + } + } catch (err) { + logger.debug({ pid: proc.pid, error: err }, 'Error killing process directly'); + } + + // On Unix systems, try to kill the process group using negative PID + // This only works if the process is in its own process group + // Note: This may fail if the process wasn't spawned with its own group + if (process.platform !== 'win32') { + try { + // Try killing the process group (negative PID) + // This will fail if the process isn't a group leader, which is fine + process.kill(-proc.pid, signal); + logger.debug({ pid: proc.pid, signal }, 'Killed process group'); + } catch (err) { + // Expected to fail if process isn't in its own group - that's okay + // We already killed the main process above + logger.debug({ pid: proc.pid }, 'Process group kill not applicable (process not in own group)'); + } + } +} + +/** + * Force kill a process group with SIGKILL after a grace period + */ +export function forceKillProcessGroup( + proc: ChildProcess, + gracePeriodMs: number = 5000 +): NodeJS.Timeout { + return setTimeout(() => { + if (proc.pid && !proc.killed) { + try { + killProcessGroup(proc, 'SIGKILL'); + logger.warn({ pid: proc.pid }, 'Force killed process group with SIGKILL'); + } catch (err) { + logger.warn({ pid: proc.pid, error: err }, 'Failed to force kill process group'); + } + } + }, gracePeriodMs); +} + +/** + * Ensure all streams are closed to prevent resource leaks + */ +export function closeProcessStreams(proc: ChildProcess): void { + try { + if (proc.stdin && !proc.stdin.destroyed) { + proc.stdin.destroy(); + } + if (proc.stdout && !proc.stdout.destroyed) { + proc.stdout.destroy(); + } + if (proc.stderr && !proc.stderr.destroyed) { + proc.stderr.destroy(); + } + } catch (err) { + logger.debug({ error: err }, 'Error closing process streams'); + } +} + +/** + * Comprehensive cleanup: kill process group, close streams, and clear timeouts + */ +export function cleanupProcess( + proc: ChildProcess, + timeouts: Array, + signal: NodeJS.Signals = 'SIGTERM' +): void { + // Clear all timeouts + for (const timeout of timeouts) { + if (timeout) { + clearTimeout(timeout); + } + } + + // Close all streams + closeProcessStreams(proc); + + // Kill process group + if (proc.pid && !proc.killed) { + killProcessGroup(proc, signal); + } +} + +/** + * Spawn a process with process group isolation to enable group killing + * This is critical for preventing zombies when the process spawns children + */ +export function spawnWithProcessGroup( + command: string, + args: string[], + options: Parameters[2] = {} +): ChildProcess { + // Create a new process group by making the process a session leader + // This allows us to kill the entire process tree + const proc = spawn(command, args, { + ...options, + detached: false, // Keep attached but use process groups + // On Unix, we can't directly set process group in spawn options, + // but we can use setsid-like behavior by ensuring proper cleanup + }); + + // On Unix systems, we need to ensure the process can be killed as a group + // The key is to ensure proper cleanup and use negative PID when killing + if (proc.pid) { + logger.debug({ pid: proc.pid, command, args: args.slice(0, 3) }, 'Spawned process with group cleanup support'); + } + + return proc; +} + +/** + * Reap zombie processes by explicitly waiting for them + * This should be called periodically to clean up any zombies + */ +export function reapZombies(): void { + // On Unix systems, we can check for zombie processes + // However, Node.js doesn't expose waitpid directly + // The best we can do is ensure all our tracked processes are properly cleaned up + + // This is a placeholder for potential future implementation + // In practice, proper cleanup in process handlers should prevent zombies + logger.debug('Zombie reaping check (process handlers should prevent zombies)'); +} diff --git a/src/routes/api/git/[...path]/+server.ts b/src/routes/api/git/[...path]/+server.ts index aeb7c9a..d9f369d 100644 --- a/src/routes/api/git/[...path]/+server.ts +++ b/src/routes/api/git/[...path]/+server.ts @@ -19,6 +19,7 @@ import { isValidBranchName, sanitizeError, validateRepoPath } from '$lib/utils/s import { extractCloneUrls, fetchRepoAnnouncementsWithCache, findRepoAnnouncement } from '$lib/utils/nostr-utils.js'; import { eventCache } from '$lib/services/nostr/event-cache.js'; import { repoManager, maintainerService, ownershipTransferService, branchProtectionService, nostrClient } from '$lib/services/service-registry.js'; +import { killProcessGroup, forceKillProcessGroup, cleanupProcess, closeProcessStreams } from '$lib/utils/process-cleanup.js'; // Resolve GIT_REPO_ROOT to absolute path (handles both relative and absolute paths) const repoRootEnv = process.env.GIT_REPO_ROOT || '/repos'; @@ -353,6 +354,8 @@ export const GET: RequestHandler = async ({ params, url, request }) => { // Security: Set timeout for git operations const timeoutMs = GIT_OPERATION_TIMEOUT_MS; let timeoutId: NodeJS.Timeout; + let forceKillTimeout: NodeJS.Timeout | null = null; + let resolved = false; const gitProcess = spawn(gitHttpBackend, [], { env: envVars, @@ -361,19 +364,16 @@ export const GET: RequestHandler = async ({ params, url, request }) => { shell: false }); - timeoutId = setTimeout(() => { - gitProcess.kill('SIGTERM'); - // Force kill after grace period if process doesn't terminate - const forceKillTimeout = setTimeout(() => { - if (!gitProcess.killed) { - gitProcess.kill('SIGKILL'); - } - }, 5000); // 5 second grace period + const chunks: Buffer[] = []; + let errorOutput = ''; + + // Set up error handlers BEFORE writing to stdin to prevent race conditions + gitProcess.on('error', (err) => { + // Aggressive cleanup on error + cleanupProcess(gitProcess, [timeoutId, forceKillTimeout], 'SIGTERM'); - // Clear force kill timeout if process terminates - gitProcess.on('close', () => { - clearTimeout(forceKillTimeout); - }); + if (resolved) return; + resolved = true; auditLogger.logRepoAccess( originalOwnerPubkey, @@ -381,13 +381,11 @@ export const GET: RequestHandler = async ({ params, url, request }) => { operation, `${npub}/${repoName}`, 'failure', - 'Operation timeout' + `Process error: ${err.message}` ); - resolve(error(504, 'Git operation timeout')); - }, timeoutMs); - - const chunks: Buffer[] = []; - let errorOutput = ''; + const sanitizedError = sanitizeError(err); + resolve(error(500, `Failed to execute git-http-backend: ${sanitizedError}`)); + }); gitProcess.stdout.on('data', (chunk: Buffer) => { chunks.push(chunk); @@ -397,8 +395,48 @@ export const GET: RequestHandler = async ({ params, url, request }) => { errorOutput += chunk.toString(); }); + // Set up timeout handler with aggressive cleanup + timeoutId = setTimeout(() => { + if (resolved) return; + + // Kill entire process group (prevents zombies from child processes) + killProcessGroup(gitProcess, 'SIGTERM'); + + // Force kill after grace period if process doesn't terminate + forceKillTimeout = forceKillProcessGroup(gitProcess, 5000); + + // Ensure streams are closed + closeProcessStreams(gitProcess); + + auditLogger.logRepoAccess( + originalOwnerPubkey, + clientIp, + operation, + `${npub}/${repoName}`, + 'failure', + 'Operation timeout' + ); + + if (!resolved) { + resolved = true; + resolve(error(504, 'Git operation timeout')); + } + }, timeoutMs); + + // Add exit handler as backup cleanup (in case close doesn't fire) + gitProcess.on('exit', (code, signal) => { + // Ensure cleanup happens even if close event doesn't fire + if (!resolved) { + cleanupProcess(gitProcess, [timeoutId, forceKillTimeout]); + } + }); + gitProcess.on('close', (code) => { - clearTimeout(timeoutId); + // Aggressive cleanup: clear timeouts and ensure streams are closed + cleanupProcess(gitProcess, [timeoutId, forceKillTimeout]); + + if (resolved) return; + resolved = true; // Log audit entry after operation completes if (code === 0) { @@ -892,6 +930,8 @@ export const POST: RequestHandler = async ({ params, url, request }) => { // Security: Set timeout for git operations const timeoutMs = GIT_OPERATION_TIMEOUT_MS; let timeoutId: NodeJS.Timeout; + let forceKillTimeout: NodeJS.Timeout | null = null; + let resolved = false; const gitProcess = spawn(gitHttpBackend, [], { env: envVars, @@ -900,19 +940,16 @@ export const POST: RequestHandler = async ({ params, url, request }) => { shell: false }); - timeoutId = setTimeout(() => { - gitProcess.kill('SIGTERM'); - // Force kill after grace period if process doesn't terminate - const forceKillTimeout = setTimeout(() => { - if (!gitProcess.killed) { - gitProcess.kill('SIGKILL'); - } - }, 5000); // 5 second grace period + const chunks: Buffer[] = []; + let errorOutput = ''; + + // Set up error handlers BEFORE writing to stdin to prevent race conditions + gitProcess.on('error', (err) => { + // Aggressive cleanup on error + cleanupProcess(gitProcess, [timeoutId, forceKillTimeout], 'SIGTERM'); - // Clear force kill timeout if process terminates - gitProcess.on('close', () => { - clearTimeout(forceKillTimeout); - }); + if (resolved) return; + resolved = true; auditLogger.logRepoAccess( currentOwnerPubkey, @@ -920,17 +957,31 @@ export const POST: RequestHandler = async ({ params, url, request }) => { operation, `${npub}/${repoName}`, 'failure', - 'Operation timeout' + `Process error: ${err.message}` ); - resolve(error(504, 'Git operation timeout')); - }, timeoutMs); - - const chunks: Buffer[] = []; - let errorOutput = ''; + const sanitizedError = sanitizeError(err); + resolve(error(500, `Failed to execute git-http-backend: ${sanitizedError}`)); + }); - // Write request body to git-http-backend stdin - gitProcess.stdin.write(bodyBuffer); - gitProcess.stdin.end(); + // Handle stdin errors + gitProcess.stdin.on('error', (err) => { + // Aggressive cleanup on stdin error + cleanupProcess(gitProcess, [timeoutId, forceKillTimeout], 'SIGTERM'); + + if (resolved) return; + resolved = true; + + auditLogger.logRepoAccess( + currentOwnerPubkey, + clientIp, + operation, + `${npub}/${repoName}`, + 'failure', + `Stdin error: ${err.message}` + ); + const sanitizedError = sanitizeError(err); + resolve(error(500, `Failed to write to git-http-backend: ${sanitizedError}`)); + }); gitProcess.stdout.on('data', (chunk: Buffer) => { chunks.push(chunk); @@ -940,8 +991,52 @@ export const POST: RequestHandler = async ({ params, url, request }) => { errorOutput += chunk.toString(); }); + // Set up timeout handler with aggressive cleanup + timeoutId = setTimeout(() => { + if (resolved) return; + + // Kill entire process group (prevents zombies from child processes) + killProcessGroup(gitProcess, 'SIGTERM'); + + // Force kill after grace period if process doesn't terminate + forceKillTimeout = forceKillProcessGroup(gitProcess, 5000); + + // Ensure streams are closed + closeProcessStreams(gitProcess); + + auditLogger.logRepoAccess( + currentOwnerPubkey, + clientIp, + operation, + `${npub}/${repoName}`, + 'failure', + 'Operation timeout' + ); + + if (!resolved) { + resolved = true; + resolve(error(504, 'Git operation timeout')); + } + }, timeoutMs); + + // Add exit handler as backup cleanup (in case close doesn't fire) + gitProcess.on('exit', (code, signal) => { + // Ensure cleanup happens even if close event doesn't fire + if (!resolved) { + cleanupProcess(gitProcess, [timeoutId, forceKillTimeout]); + } + }); + + // Write request body to git-http-backend stdin AFTER error handlers are set up + gitProcess.stdin.write(bodyBuffer); + gitProcess.stdin.end(); + gitProcess.on('close', async (code) => { - clearTimeout(timeoutId); + // Aggressive cleanup: clear timeouts and ensure streams are closed + cleanupProcess(gitProcess, [timeoutId, forceKillTimeout]); + + if (resolved) return; + resolved = true; // Log audit entry after operation completes if (code === 0) { @@ -1040,20 +1135,5 @@ export const POST: RequestHandler = async ({ params, url, request }) => { } })); }); - - gitProcess.on('error', (err) => { - clearTimeout(timeoutId); - // Log audit entry for process error - auditLogger.logRepoAccess( - currentOwnerPubkey, - clientIp, - operation, - `${npub}/${repoName}`, - 'failure', - `Process error: ${err.message}` - ); - const sanitizedError = sanitizeError(err); - resolve(error(500, `Failed to execute git-http-backend: ${sanitizedError}`)); - }); }); }; diff --git a/src/routes/api/repos/local/+server.ts b/src/routes/api/repos/local/+server.ts index 255e8ca..7dd77cb 100644 --- a/src/routes/api/repos/local/+server.ts +++ b/src/routes/api/repos/local/+server.ts @@ -33,6 +33,19 @@ interface CacheEntry { const CACHE_TTL = 5 * 60 * 1000; // 5 minutes let cache: CacheEntry | null = null; +// Track server startup time to invalidate cache on first request after startup +let serverStartTime = Date.now(); +const STARTUP_GRACE_PERIOD = 10000; // 10 seconds - allow time for initial poll + +/** + * Invalidate cache (internal use only - not exported to avoid SvelteKit build errors) + */ +function invalidateLocalReposCache(): void { + cache = null; + serverStartTime = Date.now(); + logger.debug('Local repos cache invalidated'); +} + interface LocalRepoItem { npub: string; repoName: string; @@ -207,11 +220,19 @@ export const GET: RequestHandler = async (event) => { const gitDomain = event.url.searchParams.get('domain') || GIT_DOMAIN; const forceRefresh = event.url.searchParams.get('refresh') === 'true'; - // Check cache - if (!forceRefresh && cache && (Date.now() - cache.timestamp) < CACHE_TTL) { + // If server just started, always refresh to ensure we get latest repos + const timeSinceStartup = Date.now() - serverStartTime; + const isRecentStartup = timeSinceStartup < STARTUP_GRACE_PERIOD; + + // Check cache (but skip if recent startup or force refresh) + if (!forceRefresh && !isRecentStartup && cache && (Date.now() - cache.timestamp) < CACHE_TTL) { return json(cache.repos); } + if (isRecentStartup) { + logger.debug({ timeSinceStartup }, 'Skipping cache due to recent server startup'); + } + // Scan filesystem const localRepos = await scanLocalRepos(); diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index aa78b17..bd373b3 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -18,33 +18,11 @@ import { getUserRelays } from '$lib/services/nostr/user-relays.js'; import { eventCache } from '$lib/services/nostr/event-cache.js'; import { decodeNostrAddress } from '$lib/services/nostr/nip19-utils.js'; import logger from '$lib/services/logger.js'; +import { isParameterizedReplaceable } from '$lib/utils/nostr-event-utils.js'; // Replaceable event kinds (only latest per pubkey matters) const REPLACEABLE_KINDS = [0, 3, 10002]; // Profile, Contacts, Relay List -/** - * Check if an event is a parameterized replaceable event (NIP-33) - * Parameterized replaceable events have: - * - kind >= 10000 && kind < 20000 (replaceable range) with a 'd' tag, OR - * - kind >= 30000 && kind < 40000 (addressable range) with a 'd' tag - */ -function isParameterizedReplaceable(event: NostrEvent): boolean { - const hasDTag = event.tags.some(t => t[0] === 'd' && t[1]); - if (!hasDTag) return false; - - // Replaceable range (NIP-33) - if (event.kind >= 10000 && event.kind < 20000) { - return true; - } - - // Addressable range (NIP-34) - also parameterized replaceable - if (event.kind >= 30000 && event.kind < 40000) { - return true; - } - - return false; -} - /** * Get the deduplication key for an event * For replaceable events: kind:pubkey diff --git a/src/routes/repos/[npub]/[repo]/+page.svelte.backup b/src/routes/repos/[npub]/[repo]/+page.svelte.backup deleted file mode 100644 index bff49ab..0000000 --- a/src/routes/repos/[npub]/[repo]/+page.svelte.backup +++ /dev/null @@ -1,6619 +0,0 @@ - - - - {safeTitle || 'Repository'} - - - - - - - - {#if hasImage && safeImage} - - {/if} - {#if hasBanner && safeBanner} - - - {/if} - - - - - - {#if hasBanner && safeBanner} - - {:else if hasImage && safeImage} - - {/if} - - -
- - {#if repoBanner && typeof repoBanner === 'string' && repoBanner.trim()} -
- { - if (typeof window !== 'undefined') { - console.error('[Repo Images] Failed to load banner:', repoBanner); - const target = e.target as HTMLImageElement; - if (target) target.style.display = 'none'; - } - }} /> -
- {/if} - - {#if repoOwnerPubkeyDerived} - { if (typeof showRepoMenu !== 'undefined') showRepoMenu = !showRepoMenu; }} - showMenu={showRepoMenu || false} - userPubkey={userPubkey || null} - isBookmarked={isBookmarked || false} - loadingBookmark={loadingBookmark || false} - onToggleBookmark={safeToggleBookmark} - onFork={safeForkRepository} - forking={forking || false} - onCloneToServer={safeCloneRepository} - cloning={cloning || false} - checkingCloneStatus={checkingCloneStatus || false} - onCreateIssue={() => { if (typeof showCreateIssueDialog !== 'undefined') showCreateIssueDialog = true; }} - onCreatePR={() => { if (typeof showCreatePRDialog !== 'undefined') showCreatePRDialog = true; }} - onCreatePatch={() => { if (typeof showCreatePatchDialog !== 'undefined') showCreatePatchDialog = true; }} - onCreateBranch={async () => { - if (!userPubkey || !isMaintainer || needsClone) return; - try { - const settings = await settingsStore.getSettings(); - defaultBranchName = settings.defaultBranch || 'master'; - } catch { - defaultBranchName = 'master'; - } - // Preset the default branch name in the input field - newBranchName = defaultBranchName; - newBranchFrom = null; // Reset from branch selection - showCreateBranchDialog = true; - }} - onSettings={() => goto(`/signup?npub=${npub}&repo=${repo}`)} - onGenerateVerification={repoOwnerPubkeyDerived && userPubkeyHex === repoOwnerPubkeyDerived && verificationStatus?.verified !== true ? generateAnnouncementFileForRepo : undefined} - onDeleteAnnouncement={repoOwnerPubkeyDerived && userPubkeyHex === repoOwnerPubkeyDerived ? deleteAnnouncement : undefined} - deletingAnnouncement={deletingAnnouncement} - hasUnlimitedAccess={hasUnlimitedAccess($userStore.userLevel)} - needsClone={needsClone} - allMaintainers={allMaintainers} - onCopyEventId={copyEventId} - /> - {/if} - - - - {#if repoWebsite || (repoCloneUrls && repoCloneUrls.length > 0) || repoLanguage || (repoTopics && repoTopics.length > 0) || forkInfo?.isFork} - - {/if} - -
- {#if isRepoCloned === false && (canUseApiFallback || apiFallbackAvailable === null)} -
- -
- {/if} - {#if error} -
-
- Error: {error} -
- {#if error.includes('not cloned locally') && hasUnlimitedAccess($userStore.userLevel)} -
- -
- {/if} -
- {/if} - - - - {#if isRepoCloned === false && !canUseApiFallback && tabs.length === 0} -
-
-

Repository Not Cloned

-

This repository has not been cloned to the server yet, and read-only access via external clone URLs is not available.

- {#if hasUnlimitedAccess($userStore.userLevel)} -

Use the "Clone to Server" option in the repository menu to clone this repository.

- {:else} -

Contact a server administrator with unlimited access to clone this repository.

- {/if} -
-
- {:else} -
- - {#if activeTab === 'files' && canViewRepo} - { - currentPath = path; - loadFiles(path); - }} - onNavigateBack={handleBack} - onContentChange={(content) => { - editedContent = content; - hasChanges = content !== fileContent; - }} - {isMaintainer} - readmeContent={readmeContent || null} - readmePath={readmePath || null} - {readmeHtml} - {showFilePreview} - {fileHtml} - {highlightedFileContent} - {isImageFile} - {imageUrl} - {wordWrap} - {supportsPreview} - onSave={() => { - if (!userPubkey || !isMaintainer || needsClone) return; - showCommitDialog = true; - }} - onTogglePreview={() => { - showFilePreview = !showFilePreview; - if (!showFilePreview && fileContent && currentFile) { - const ext = currentFile.split('.').pop() || ''; - applySyntaxHighlighting(fileContent, ext).catch(err => console.error('Error applying syntax highlighting:', err)); - } - }} - onCopyFileContent={copyFileContent} - onDownloadFile={downloadFile} - {copyingFile} - {saving} - {needsClone} - {cloneTooltip} - {branches} - {currentBranch} - {defaultBranch} - onBranchChange={(branch) => { - currentBranch = branch; - handleBranchChangeDirect(branch); - }} - {userPubkey} - /> - {/if} - - - {#if activeTab === 'history' && canViewRepo} - { - selectedCommit = hash; - viewDiff(hash); - }} - onVerify={async (hash) => { - verifyingCommits.add(hash); - try { - // Trigger verification logic - find the commit and verify - const commit = commits.find(c => (c.hash || (c as any).sha) === hash); - if (commit) { - await verifyCommit(hash); - } - } finally { - verifyingCommits.delete(hash); - } - }} - {verifyingCommits} - {showDiff} - {diffData} - /> - {/if} - - - selectedTag = tagName} - onTabChange={(tab) => activeTab = tab as typeof activeTab} - onToggleMobilePanel={() => showLeftPanelOnMobile = !showLeftPanelOnMobile} - onCreateTag={() => showCreateTagDialog = true} - onCreateRelease={(tagName, tagHash) => { - newReleaseTagName = tagName; - newReleaseTagHash = tagHash; - showCreateReleaseDialog = true; - }} - onLoadTags={loadTags} - /> - - - {#if activeTab === 'code-search' && canViewRepo} - - {/if} - - - {#if activeTab === 'issues'} - { - selectedIssue = id; - loadIssueReplies(id); - }} - onStatusUpdate={async (id, status) => { - // Find issue and update status - const issue = issues.find(i => i.id === id); - if (issue) { - await updateIssueStatus(id, issue.author, status as 'open' | 'closed' | 'resolved' | 'draft'); - await loadIssues(); - } - }} - {issueReplies} - loadingReplies={loadingIssueReplies} - /> - {/if} - - - {#if activeTab === 'prs'} - { - selectedPR = id; - }} - onStatusUpdate={async (id, status) => { - // Find PR and update status - similar to updateIssueStatus - const pr = prs.find(p => p.id === id); - if (pr && userPubkeyHex) { - // Check if user is maintainer or PR author - const isAuthor = userPubkeyHex === pr.author; - if (!isMaintainer && !isAuthor) { - alert('Only repository maintainers or PR authors can update PR status'); - return; - } - - try { - const response = await fetch(`/api/repos/${npub}/${repo}/prs`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - prId: id, - prAuthor: pr.author, - status - }) - }); - - if (!response.ok) { - const data = await response.json(); - throw new Error(data.error || 'Failed to update PR status'); - } - - await loadPRs(); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to update PR status'; - console.error('Error updating PR status:', err); - } - } - }} - /> - {/if} - - - {#if activeTab === 'patches'} - { - selectedPatch = id; - }} - onApply={async (id) => { - applying[id] = true; - try { - const patch = patches.find(p => p.id === id); - if (!patch) { - throw new Error('Patch not found'); - } - - if (!userPubkey || !isMaintainer || needsClone) { - alert('Only maintainers can apply patches'); - return; - } - - if (!confirm('Apply this patch to the repository? This will create a commit with the patch changes.')) { - return; - } - - const authorEmail = await fetchUserEmail(); - const authorName = await fetchUserName(); - - const response = await fetch(`/api/repos/${npub}/${repo}/patches/${id}/apply`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...buildApiHeaders() - }, - body: JSON.stringify({ - commitMessage: `Apply patch ${id.slice(0, 8)}: ${patch.subject}`, - authorName, - authorEmail, - branch: currentBranch || defaultBranch || 'main' - }) - }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || 'Failed to apply patch'); - } - - await loadPatches(); - alert('Patch applied successfully!'); - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to apply patch'; - console.error('Error applying patch:', err); - } finally { - applying[id] = false; - } - }} - {applying} - /> - {/if} - - - {#if activeTab === 'discussions'} - - {/if} - - - {#if state.ui.activeTab === 'docs'} - - {/if} - - - - - - - - - {#if state.ui.activeTab === 'code-search' && canViewRepo} -
-
- -
-
-
- e.key === 'Enter' && performCodeSearch()} - class="code-search-input" - /> - - -
-
- {#if state.loading.codeSearch} -
-

Searching...

-
- {:else if codeSearchResults.length > 0} -
-

Found {codeSearchResults.length} result{codeSearchResults.length !== 1 ? 's' : ''}

- {#each codeSearchResults as result} -
-
- {result.file} - Line {result.line} - {#if codeSearchScope === 'all' && 'repo' in result} - {result.repo || npub}/{result.repo || repo} - {/if} -
-
{result.content}
-
- {/each} -
- {:else if codeSearchQuery.trim() && !loadingCodeSearch} -
- {#if error} -

Error: {error}

- {:else} -

No results found

- {/if} -
- {/if} -
- {/if} - - - - - - - - - -
- {/if} -
- - - {#if state.openDialog === 'createFile' && state.user.pubkey && state.maintainers.isMaintainer} - - {/if} - - - {#if state.openDialog === 'createBranch' && state.user.pubkey && state.maintainers.isMaintainer} - - {/if} - - - {#if state.openDialog === 'createTag' && state.user.pubkey && state.maintainers.isMaintainer} - - {/if} - - - {#if state.openDialog === 'createRelease' && state.user.pubkey && (state.maintainers.isMaintainer || state.user.pubkeyHex === repoOwnerPubkeyDerived) && state.clone.isCloned} - - {/if} - - - {#if showCreateIssueDialog && userPubkey} - - {/if} - - - {#if state.openDialog === 'createThread' && state.user.pubkey} - - {/if} - - - {#if showReplyDialog && userPubkey && (replyingToThread || replyingToComment)} - - {/if} - - - {#if state.openDialog === 'createPR' && state.user.pubkey} - - {/if} - - - {#if state.openDialog === 'createPatch' && state.user.pubkey} - - {/if} - - - {#if state.openDialog === 'patchHighlight'} - - {/if} - - - {#if state.openDialog === 'patchComment'} - - {/if} - - - {#if state.openDialog === 'commit' && state.user.pubkey && state.maintainers.isMaintainer} - - {/if} - - - {#if showVerificationDialog && verificationFileContent} - - {/if} - - - {#if showCloneUrlVerificationDialog} - - {/if} -
- - diff --git a/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts b/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts index dfd4c9d..68fc847 100644 --- a/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts +++ b/src/routes/repos/[npub]/[repo]/utils/discussion-utils.ts @@ -5,6 +5,7 @@ import type { NostrEvent } from '$lib/types/nostr.js'; import { KIND } from '$lib/types/nostr.js'; +import { getReferencedEventFromDiscussion as getReferencedEventFromDiscussionUtil } from '$lib/utils/nostr-links.js'; /** * Format discussion timestamp @@ -34,41 +35,13 @@ export function getDiscussionEvent(eventId: string, events: Map ): NostrEvent | undefined { - // Check e-tag - const eTag = event.tags.find(t => t[0] === 'e' && t[1])?.[1]; - if (eTag) { - const referenced = events.get(eTag); - if (referenced) return referenced; - } - - // Check a-tag - const aTag = event.tags.find(t => t[0] === 'a' && t[1])?.[1]; - if (aTag) { - const parts = aTag.split(':'); - if (parts.length === 3) { - const kind = parseInt(parts[0]); - const pubkey = parts[1]; - const dTag = parts[2]; - return Array.from(events.values()).find(e => - e.kind === kind && - e.pubkey === pubkey && - e.tags.find(t => t[0] === 'd' && t[1] === dTag) - ); - } - } - - // Check q-tag - const qTag = event.tags.find(t => t[0] === 'q' && t[1])?.[1]; - if (qTag) { - return events.get(qTag); - } - - return undefined; + return getReferencedEventFromDiscussionUtil(event, events); } /**