312 lines
11 KiB
JavaScript
312 lines
11 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 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
|