"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.docsFilesService = exports.FileNotFoundError = exports.PathTraversalError = void 0; const promises_1 = require("fs/promises"); const path_1 = require("path"); const crypto_1 = __importDefault(require("crypto")); const env_1 = require("../../config/env"); const redis_1 = require("../../config/redis"); const logger_1 = require("../../utils/logger"); const metrics_1 = require("../../utils/metrics"); const DOCS_ROOT = (0, path_1.resolve)(env_1.env.MKDOCS_DOCS_PATH); // Redis cache configuration const CACHE_KEY_PREFIX = 'DOCS_CACHE:'; const TREE_CACHE_KEY = `${CACHE_KEY_PREFIX}tree`; const TREE_CACHE_TTL = 30; // 30 seconds — short so external changes show quickly const FILE_CONTENT_CACHE_TTL = 60 * 60; // 1 hour for file content function hashFilePath(path) { return crypto_1.default.createHash('sha256').update(path).digest('hex').substring(0, 16); } /** * Resolve and validate a relative path within MKDOCS_DOCS_PATH. * Throws if the resolved path escapes the docs root. */ function safeResolve(relativePath) { const normalized = (0, path_1.normalize)(relativePath).replace(/^(\.\.(\/|\\|$))+/, ''); const resolved = (0, path_1.resolve)(DOCS_ROOT, normalized); // Use DOCS_ROOT + sep to prevent prefix attacks (e.g., /mkdocs/docs-evil matching /mkdocs/docs) if (resolved !== DOCS_ROOT && !resolved.startsWith(DOCS_ROOT + '/')) { throw new PathTraversalError(); } return resolved; } class PathTraversalError extends Error { constructor() { super('Path traversal not allowed'); this.name = 'PathTraversalError'; } } exports.PathTraversalError = PathTraversalError; class FileNotFoundError extends Error { constructor(filePath) { super(`File not found: ${filePath}`); this.name = 'FileNotFoundError'; } } exports.FileNotFoundError = FileNotFoundError; /** * Recursively list the file tree under MKDOCS_DOCS_PATH. * Cached in Redis with 1-hour TTL for root calls. */ async function listTree(dir = DOCS_ROOT, relBase = '') { // Try cache for root call only if (dir === DOCS_ROOT && !relBase) { try { const cached = await redis_1.redis.get(TREE_CACHE_KEY); if (cached) { metrics_1.cm_docs_cache_hits.inc({ type: 'tree' }); return JSON.parse(cached); } metrics_1.cm_docs_cache_misses.inc({ type: 'tree' }); } catch (err) { logger_1.logger.warn('Failed to get cached docs tree:', err); metrics_1.cm_docs_cache_misses.inc({ type: 'tree' }); } } const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true }); const sorted = entries .filter(e => !e.name.startsWith('.')) .sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); // Parallel I/O instead of sequential for better performance const nodes = await Promise.all(sorted.map(async (entry) => { const relPath = relBase ? `${relBase}/${entry.name}` : entry.name; if (entry.isDirectory()) { const children = await listTree((0, path_1.join)(dir, entry.name), relPath); return { name: entry.name, path: relPath, isDirectory: true, children }; } else { return { name: entry.name, path: relPath, isDirectory: false }; } })); // Cache root result if (dir === DOCS_ROOT && !relBase) { try { await redis_1.redis.setex(TREE_CACHE_KEY, TREE_CACHE_TTL, JSON.stringify(nodes)); } catch (err) { logger_1.logger.warn('Failed to cache docs tree:', err); } } return nodes; } async function readFileContent(relativePath) { const cacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(relativePath)}`; // Try cache first try { const cached = await redis_1.redis.get(cacheKey); if (cached) { metrics_1.cm_docs_cache_hits.inc({ type: 'file' }); return cached; } metrics_1.cm_docs_cache_misses.inc({ type: 'file' }); } catch (err) { logger_1.logger.warn('Failed to get cached file content:', err); metrics_1.cm_docs_cache_misses.inc({ type: 'file' }); } // Read from disk const fullPath = safeResolve(relativePath); try { const content = await (0, promises_1.readFile)(fullPath, 'utf-8'); // Cache the result try { await redis_1.redis.setex(cacheKey, FILE_CONTENT_CACHE_TTL, content); } catch (err) { logger_1.logger.warn('Failed to cache file content:', err); } return content; } catch (err) { if (err.code === 'ENOENT') { throw new FileNotFoundError(relativePath); } throw err; } } async function writeFileContent(relativePath, content) { const fullPath = safeResolve(relativePath); await (0, promises_1.mkdir)((0, path_1.dirname)(fullPath), { recursive: true }); await (0, promises_1.writeFile)(fullPath, content, 'utf-8'); // Invalidate file cache (content changed, structure unchanged) const cacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(relativePath)}`; try { await redis_1.redis.del(cacheKey); } catch (err) { logger_1.logger.warn('Failed to invalidate file cache:', err); } } async function createFile(relativePath, content, isDirectory) { const fullPath = safeResolve(relativePath); try { await (0, promises_1.stat)(fullPath); throw new Error(`Already exists: ${relativePath}`); } catch (err) { if (err.code !== 'ENOENT') { if (err.message?.startsWith('Already exists')) throw err; throw err; } } if (isDirectory) { await (0, promises_1.mkdir)(fullPath, { recursive: true }); } else { await (0, promises_1.mkdir)((0, path_1.dirname)(fullPath), { recursive: true }); await (0, promises_1.writeFile)(fullPath, content || '', 'utf-8'); } // Invalidate tree cache (structure changed) try { await redis_1.redis.del(TREE_CACHE_KEY); } catch (err) { logger_1.logger.warn('Failed to invalidate tree cache:', err); } } async function deleteFile(relativePath) { const fullPath = safeResolve(relativePath); try { const info = await (0, promises_1.stat)(fullPath); if (info.isDirectory()) { const entries = await (0, promises_1.readdir)(fullPath); if (entries.length > 0) { throw new Error('Directory is not empty'); } await (0, promises_1.rm)(fullPath, { recursive: false }); } else { await (0, promises_1.rm)(fullPath); } } catch (err) { if (err.code === 'ENOENT') { throw new FileNotFoundError(relativePath); } throw err; } // Invalidate both tree and file cache const fileCacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(relativePath)}`; try { await Promise.all([ redis_1.redis.del(TREE_CACHE_KEY), redis_1.redis.del(fileCacheKey), ]); } catch (err) { logger_1.logger.warn('Failed to invalidate caches on delete:', err); } } async function renameFile(fromPath, toPath) { const fullFrom = safeResolve(fromPath); const fullTo = safeResolve(toPath); try { await (0, promises_1.stat)(fullFrom); } catch { throw new FileNotFoundError(fromPath); } await (0, promises_1.mkdir)((0, path_1.dirname)(fullTo), { recursive: true }); await (0, promises_1.rename)(fullFrom, fullTo); // Invalidate tree and both file paths const fromCacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(fromPath)}`; const toCacheKey = `${CACHE_KEY_PREFIX}file:${hashFilePath(toPath)}`; try { await Promise.all([ redis_1.redis.del(TREE_CACHE_KEY), redis_1.redis.del(fromCacheKey), redis_1.redis.del(toCacheKey), ]); } catch (err) { logger_1.logger.warn('Failed to invalidate caches on rename:', err); } } function isEditableFile(relativePath) { const ext = (0, path_1.extname)(relativePath).toLowerCase(); return ['.md', '.txt', '.yml', '.yaml', '.json', '.css', '.html', '.js'].includes(ext); } const ALLOWED_UPLOAD_EXTENSIONS = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico', '.pdf', '.zip', ]); async function uploadFile(relativePath, sourcePath) { const ext = (0, path_1.extname)(relativePath).toLowerCase(); if (!ALLOWED_UPLOAD_EXTENSIONS.has(ext)) { throw new Error(`File type not allowed: ${ext}`); } const fullPath = safeResolve(relativePath); await (0, promises_1.mkdir)((0, path_1.dirname)(fullPath), { recursive: true }); await (0, promises_1.copyFile)(sourcePath, fullPath); // Invalidate tree cache (structure changed) try { await redis_1.redis.del(TREE_CACHE_KEY); } catch (err) { logger_1.logger.warn('Failed to invalidate tree cache after upload:', err); } } async function invalidateTreeCache() { try { await redis_1.redis.del(TREE_CACHE_KEY); } catch (err) { logger_1.logger.warn('Failed to invalidate tree cache:', err); } } /** * Flatten a FileNode tree into file-only entries, then filter by * case-insensitive query match on name or path. Name matches score * higher so they sort first. */ async function searchFiles(query, limit = 5) { const tree = await listTree(); const q = query.toLowerCase(); const matches = []; function walk(nodes) { for (const node of nodes) { if (node.isDirectory) { if (node.children) walk(node.children); } else { const nameLower = node.name.toLowerCase(); const pathLower = node.path.toLowerCase(); if (nameLower.includes(q)) { matches.push({ name: node.name, path: node.path, score: 2 }); } else if (pathLower.includes(q)) { matches.push({ name: node.name, path: node.path, score: 1 }); } } } } walk(tree); matches.sort((a, b) => b.score - a.score); return matches.slice(0, limit).map(({ name, path }) => ({ name, path })); } exports.docsFilesService = { listTree, readFileContent, writeFileContent, createFile, deleteFile, renameFile, uploadFile, safeResolve, isEditableFile, invalidateTreeCache, searchFiles, }; //# sourceMappingURL=docs-files.service.js.map