changemaker.lite/api/dist/modules/docs/docs-files.service.js

248 lines
8.5 KiB
JavaScript

"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 FILE_CACHE_TTL = 60 * 60; // 1 hour
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);
if (!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, FILE_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_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);
}
exports.docsFilesService = {
listTree,
readFileContent,
writeFileContent,
createFile,
deleteFile,
renameFile,
safeResolve,
isEditableFile,
};
//# sourceMappingURL=docs-files.service.js.map