import { NextRequest, NextResponse } from 'next/server' import { readdir, readFile, stat, lstat, realpath, writeFile, mkdir, unlink } from 'fs/promises' import { existsSync, mkdirSync } from 'fs' import { join, dirname, sep } from 'path' import { config } from '@/lib/config' import { resolveWithin } from '@/lib/paths' import { requireRole } from '@/lib/auth' import { readLimiter, mutationLimiter } from '@/lib/rate-limit' import { logger } from '@/lib/logger' const MEMORY_PATH = config.memoryDir const MEMORY_ALLOWED_PREFIXES = (config.memoryAllowedPrefixes || []).map((p) => p.replace(/\\/g, '/')) // Ensure memory directory exists on startup if (MEMORY_PATH && !existsSync(MEMORY_PATH)) { try { mkdirSync(MEMORY_PATH, { recursive: true }) } catch { /* ignore */ } } interface MemoryFile { path: string name: string type: 'file' | 'directory' size?: number modified?: number children?: MemoryFile[] } function normalizeRelativePath(value: string): string { return String(value || '').replace(/\\/g, '/').replace(/^\/+/, '') } function isPathAllowed(relativePath: string): boolean { if (!MEMORY_ALLOWED_PREFIXES.length) return true const normalized = normalizeRelativePath(relativePath) return MEMORY_ALLOWED_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix)) } function isWithinBase(base: string, candidate: string): boolean { if (candidate === base) return true return candidate.startsWith(base + sep) } async function resolveSafeMemoryPath(baseDir: string, relativePath: string): Promise { const baseReal = await realpath(baseDir) const fullPath = resolveWithin(baseDir, relativePath) // For non-existent paths, validate containment using the parent directory realpath. // This also blocks symlinked parent segments that escape the base. let parentReal: string try { parentReal = await realpath(dirname(fullPath)) } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'ENOENT') { throw new Error('Parent directory not found') } throw err } if (!isWithinBase(baseReal, parentReal)) { throw new Error('Path escapes base directory (symlink)') } // If the file exists, ensure it also resolves within base and is not a symlink. try { const st = await lstat(fullPath) if (st.isSymbolicLink()) { throw new Error('Symbolic links are not allowed') } const fileReal = await realpath(fullPath) if (!isWithinBase(baseReal, fileReal)) { throw new Error('Path escapes base directory (symlink)') } } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code !== 'ENOENT') { throw err } } return fullPath } async function buildFileTree(dirPath: string, relativePath: string = ''): Promise { try { const items = await readdir(dirPath, { withFileTypes: true }) const files: MemoryFile[] = [] for (const item of items) { if (item.isSymbolicLink()) { continue } const itemPath = join(dirPath, item.name) const itemRelativePath = join(relativePath, item.name) try { const stats = await stat(itemPath) if (item.isDirectory()) { const children = await buildFileTree(itemPath, itemRelativePath) files.push({ path: itemRelativePath, name: item.name, type: 'directory', modified: stats.mtime.getTime(), children }) } else if (item.isFile()) { files.push({ path: itemRelativePath, name: item.name, type: 'file', size: stats.size, modified: stats.mtime.getTime() }) } } catch (error) { logger.error({ err: error, path: itemPath }, 'Error reading file') } } return files.sort((a, b) => { // Directories first, then files, alphabetical within each type if (a.type !== b.type) { return a.type === 'directory' ? -1 : 1 } return a.name.localeCompare(b.name) }) } catch (error) { logger.error({ err: error, path: dirPath }, 'Error reading directory') return [] } } export async function GET(request: NextRequest) { const auth = requireRole(request, 'viewer') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const rateCheck = readLimiter(request) if (rateCheck) return rateCheck try { const { searchParams } = new URL(request.url) const path = searchParams.get('path') const action = searchParams.get('action') if (action === 'tree') { // Return the file tree if (!MEMORY_PATH) { return NextResponse.json({ tree: [] }) } if (MEMORY_ALLOWED_PREFIXES.length) { const tree: MemoryFile[] = [] for (const prefix of MEMORY_ALLOWED_PREFIXES) { const folder = prefix.replace(/\/$/, '') const fullPath = join(MEMORY_PATH, folder) if (!existsSync(fullPath)) continue try { const stats = await stat(fullPath) if (!stats.isDirectory()) continue tree.push({ path: folder, name: folder, type: 'directory', modified: stats.mtime.getTime(), children: await buildFileTree(fullPath, folder), }) } catch { // Skip unreadable roots } } return NextResponse.json({ tree }) } const tree = await buildFileTree(MEMORY_PATH) return NextResponse.json({ tree }) } if (action === 'content' && path) { // Return file content if (!isPathAllowed(path)) { return NextResponse.json({ error: 'Path not allowed' }, { status: 403 }) } if (!MEMORY_PATH) { return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 }) } const fullPath = await resolveSafeMemoryPath(MEMORY_PATH, path) try { const content = await readFile(fullPath, 'utf-8') const stats = await stat(fullPath) return NextResponse.json({ content, size: stats.size, modified: stats.mtime.getTime(), path }) } catch (error) { return NextResponse.json({ error: 'File not found' }, { status: 404 }) } } if (action === 'search') { const query = searchParams.get('query') if (!query) { return NextResponse.json({ error: 'Query required' }, { status: 400 }) } if (!MEMORY_PATH) { return NextResponse.json({ query, results: [] }) } // Simple file search - in production you'd want a more sophisticated search const results: Array<{path: string, name: string, matches: number}> = [] const searchInFile = async (filePath: string, relativePath: string) => { try { const st = await stat(filePath) // Avoid large-file scanning and memory blowups. if (st.size > 1_000_000) { return } const content = await readFile(filePath, 'utf-8') const haystack = content.toLowerCase() const needle = query.toLowerCase() if (!needle) return let matches = 0 let idx = haystack.indexOf(needle) while (idx !== -1) { matches += 1 idx = haystack.indexOf(needle, idx + needle.length) } if (matches > 0) { results.push({ path: relativePath, name: relativePath.split('/').pop() || '', matches }) } } catch (error) { // Skip files that can't be read } } const searchDirectory = async (dirPath: string, relativePath: string = '') => { try { const items = await readdir(dirPath, { withFileTypes: true }) for (const item of items) { if (item.isSymbolicLink()) { continue } const itemPath = join(dirPath, item.name) const itemRelativePath = join(relativePath, item.name) if (item.isDirectory()) { await searchDirectory(itemPath, itemRelativePath) } else if (item.isFile() && (item.name.endsWith('.md') || item.name.endsWith('.txt'))) { await searchInFile(itemPath, itemRelativePath) } } } catch (error) { logger.error({ err: error, path: dirPath }, 'Error searching directory') } } if (MEMORY_ALLOWED_PREFIXES.length) { for (const prefix of MEMORY_ALLOWED_PREFIXES) { const folder = prefix.replace(/\/$/, '') const fullPath = join(MEMORY_PATH, folder) if (!existsSync(fullPath)) continue await searchDirectory(fullPath, folder) } } else { await searchDirectory(MEMORY_PATH) } return NextResponse.json({ query, results: results.sort((a, b) => b.matches - a.matches) }) } return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) } catch (error) { logger.error({ err: error }, 'Memory API error') return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } export async function POST(request: NextRequest) { const auth = requireRole(request, 'operator') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const rateCheck = mutationLimiter(request) if (rateCheck) return rateCheck try { const body = await request.json() const { action, path, content } = body if (!path) { return NextResponse.json({ error: 'Path is required' }, { status: 400 }) } if (!isPathAllowed(path)) { return NextResponse.json({ error: 'Path not allowed' }, { status: 403 }) } if (!MEMORY_PATH) { return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 }) } const fullPath = await resolveSafeMemoryPath(MEMORY_PATH, path) if (action === 'save') { // Save file content if (content === undefined) { return NextResponse.json({ error: 'Content is required for save action' }, { status: 400 }) } await writeFile(fullPath, content, 'utf-8') return NextResponse.json({ success: true, message: 'File saved successfully' }) } if (action === 'create') { // Create new file const dirPath = dirname(fullPath) // Ensure directory exists try { await mkdir(dirPath, { recursive: true }) } catch (error) { // Directory might already exist } // Check if file already exists try { await stat(fullPath) return NextResponse.json({ error: 'File already exists' }, { status: 409 }) } catch (error) { // File doesn't exist, which is what we want } await writeFile(fullPath, content || '', 'utf-8') return NextResponse.json({ success: true, message: 'File created successfully' }) } return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) } catch (error) { logger.error({ err: error }, 'Memory POST API error') return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } } export async function DELETE(request: NextRequest) { const auth = requireRole(request, 'admin') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const rateCheck = mutationLimiter(request) if (rateCheck) return rateCheck try { const body = await request.json() const { action, path } = body if (!path) { return NextResponse.json({ error: 'Path is required' }, { status: 400 }) } if (!isPathAllowed(path)) { return NextResponse.json({ error: 'Path not allowed' }, { status: 403 }) } if (!MEMORY_PATH) { return NextResponse.json({ error: 'Memory directory not configured' }, { status: 500 }) } const fullPath = await resolveSafeMemoryPath(MEMORY_PATH, path) if (action === 'delete') { // Check if file exists try { await stat(fullPath) } catch (error) { return NextResponse.json({ error: 'File not found' }, { status: 404 }) } await unlink(fullPath) return NextResponse.json({ success: true, message: 'File deleted successfully' }) } return NextResponse.json({ error: 'Invalid action' }, { status: 400 }) } catch (error) { logger.error({ err: error }, 'Memory DELETE API error') return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) } }