const express = require('express'); const path = require('path'); const fs = require('fs').promises; const cors = require('cors'); const session = require('express-session'); const app = express(); const PORT = 3000; // 硬编码的密码 const PASSWORD = 'cjy@0526'; // 目录常量 const DIRS = { public: path.join(__dirname, 'public'), docs: path.join(__dirname, 'docs'), index: path.join(__dirname, 'index.md') }; // index.md 缓存 let indexCache = null; let indexCacheTime = 0; const CACHE_DURATION = 5000; // 5秒缓存 // 路径安全验证函数 - 防止路径遍历攻击 function sanitizePath(input) { if (!input || typeof input !== 'string') { return ''; } // 安全清理策略: // 1. 移除路径遍历序列 // 2. 移除开头的斜杠(防止绝对路径) return input .replace(/\.\./g, '') // 移除 .. .replace(/^[\/\\]+/, ''); // 移除开头的 / 和 \ } // 验证路径是否在允许的目录内 function validatePath(fullPath, baseDir) { const resolvedPath = path.resolve(fullPath); const resolvedBaseDir = path.resolve(baseDir); return resolvedPath.startsWith(resolvedBaseDir); } // 检查是否已认证 function isAuthenticated(req) { return req.session && req.session.isAuthenticated === true; } // 统一的错误响应助手 const ErrorResponse = { badRequest: (res, message, details = null) => { const response = { error: message }; if (details) response.details = details; return res.status(400).json(response); }, unauthorized: (res, message = '未授权') => { return res.status(401).json({ error: message }); }, forbidden: (res, message = '拒绝访问') => { return res.status(403).json({ error: message }); }, notFound: (res, message, details = null) => { const response = { error: message }; if (details) response.details = details; return res.status(404).json(response); }, serverError: (res, message, details = null) => { const response = { error: message }; if (details) response.details = details; return res.status(500).json(response); } }; // 中间件 // CORS 配置 - 允许前端携带凭证(cookies) app.use(cors({ origin: true, // 允许所有来源(生产环境建议指定具体域名) credentials: true // 允许携带凭证 })); app.use(express.json({ limit: '10mb' })); // 增加请求体大小限制到10MB app.use(express.urlencoded({ limit: '10mb', extended: true })); // Session 中间件 app.use(session({ secret: 'cjydocs-secret-key-2024', // 会话密钥 resave: false, saveUninitialized: false, cookie: { maxAge: 7 * 24 * 60 * 60 * 1000, // 7天 httpOnly: true, secure: false, // 如果使用 HTTPS,设置为 true sameSite: 'lax', // 允许跨站点请求携带 cookie path: '/' // Cookie 路径 }, name: 'cjydocs.sid', // Session cookie 名称 rolling: true // 每次请求时重置过期时间 })); // 登录验证中间件 function requireAuth(req, res, next) { if (isAuthenticated(req)) { return next(); } // 如果是 API 请求,返回 401 if (req.path.startsWith('/api')) { return ErrorResponse.unauthorized(res, '未登录,请先登录'); } // 如果是页面请求,重定向到登录页 res.redirect('/login.html'); } // 根路径重定向处理 app.get('/', (req, res) => { if (isAuthenticated(req)) { res.sendFile(path.join(DIRS.public, 'index.html'), (err) => { if (err && !res.headersSent) { console.error('发送首页文件失败:', err); ErrorResponse.serverError(res, '无法加载首页', err.message); } }); } else { res.redirect('/login.html'); } }); // reader.html 需要登录 app.get('/reader.html', (req, res) => { if (isAuthenticated(req)) { res.sendFile(path.join(DIRS.public, 'reader.html')); } else { res.redirect('/login.html'); } }); // 静态文件服务(CSS、JS、图片等) app.use('/css', express.static(path.join(DIRS.public, 'css'))); app.use('/js', express.static(path.join(DIRS.public, 'js'))); // login.html 不需要登录 app.get('/login.html', (req, res) => { // 如果已登录,重定向到首页 if (isAuthenticated(req)) { res.redirect('/'); } else { res.sendFile(path.join(DIRS.public, 'login.html')); } }); // API: 登录 app.post('/api/login', (req, res) => { const { password } = req.body; if (!password || typeof password !== 'string') { return ErrorResponse.badRequest(res, '请输入密码'); } if (password === PASSWORD) { req.session.isAuthenticated = true; // 保存 session 后再响应 req.session.save((err) => { if (err) { console.error('Session保存失败:', err); return ErrorResponse.serverError(res, '登录失败,请重试'); } res.json({ success: true, message: '登录成功' }); }); } else { return ErrorResponse.unauthorized(res, '密码错误'); } }); // API: 登出 app.post('/api/logout', (req, res) => { req.session.destroy((err) => { if (err) { return ErrorResponse.serverError(res, '登出失败'); } res.json({ success: true, message: '登出成功' }); }); }); // API: 检查登录状态 app.get('/api/check-auth', (req, res) => { res.json({ authenticated: isAuthenticated(req) }); }); // API: 获取图片资源(需要登录) app.get('/api/image/:category/*', requireAuth, async (req, res) => { try { const category = sanitizePath(req.params.category); const imagePath = req.params[0]; // 获取剩余路径部分,如 'assets/test.png' if (!category || !imagePath) { return ErrorResponse.badRequest(res, '无效的请求路径'); } const fullPath = path.join(DIRS.docs, category, imagePath); // 验证路径安全性 if (!validatePath(fullPath, DIRS.docs)) { return ErrorResponse.forbidden(res, '拒绝访问'); } // 检查文件是否存在 try { await fs.access(fullPath); } catch (err) { return ErrorResponse.notFound(res, '图片不存在'); } // 发送图片文件 res.sendFile(fullPath); } catch (error) { console.error('Error serving image:', error); return ErrorResponse.serverError(res, '无法加载图片'); } }); // 获取 index.md 结构(带缓存) async function getIndexStructure() { const now = Date.now(); if (indexCache && (now - indexCacheTime < CACHE_DURATION)) { return indexCache; } const content = await fs.readFile(DIRS.index, 'utf-8'); indexCache = parseIndexMd(content); indexCacheTime = now; return indexCache; } // API: 解析 index.md 结构(需要登录) app.get('/api/structure', requireAuth, async (req, res) => { try { const structure = await getIndexStructure(); res.json(structure); } catch (error) { return ErrorResponse.serverError(res, '无法解析文档结构', error.message); } }); // API: 获取指定分类的文档列表(需要登录) app.get('/api/category/:category', requireAuth, async (req, res) => { try { const category = sanitizePath(req.params.category); if (!category) { return ErrorResponse.badRequest(res, '无效的分类名称'); } const structure = await getIndexStructure(); const categoryData = structure.find(cat => cat.name === category); if (!categoryData) { return ErrorResponse.notFound(res, '分类不存在'); } res.json(categoryData); } catch (error) { return ErrorResponse.serverError(res, '无法获取分类信息', error.message); } }); // API: 获取指定文档的内容(需要登录) // 修改为支持通配符路径参数,以正确处理子目录 app.get('/api/doc/:category/*', requireAuth, async (req, res) => { try { const category = sanitizePath(req.params.category); const docName = req.params[0]; // 获取剩余的路径部分,支持子目录 if (!category || !docName) { return ErrorResponse.badRequest(res, '无效的分类或文档名称'); } // 直接使用 docName,它可能包含子目录路径 const docPath = path.join(DIRS.docs, category, `${docName}.md`); // 验证路径是否在 docs 目录内 if (!validatePath(docPath, DIRS.docs)) { return ErrorResponse.forbidden(res, '拒绝访问:无效的路径'); } const content = await fs.readFile(docPath, 'utf-8'); res.json({ content, category, docName }); } catch (error) { return ErrorResponse.serverError(res, '无法读取文档', error.message); } }); // API: 保存文档内容(需要登录) // 修改为支持通配符路径参数,以正确处理子目录 app.put('/api/doc/:category/*', requireAuth, async (req, res) => { try { const category = sanitizePath(req.params.category); const docName = req.params[0]; // 获取剩余的路径部分,支持子目录 const { content } = req.body; // 验证输入 if (!category || !docName) { return ErrorResponse.badRequest(res, '无效的分类或文档名称'); } if (!content || typeof content !== 'string') { return ErrorResponse.badRequest(res, '无效的文档内容'); } // 直接使用 docName,它可能包含子目录路径 const docPath = path.join(DIRS.docs, category, `${docName}.md`); // 验证路径是否在 docs 目录内 if (!validatePath(docPath, DIRS.docs)) { return ErrorResponse.forbidden(res, '拒绝访问:无效的路径'); } // 检查文件是否存在 try { await fs.access(docPath); } catch (error) { return ErrorResponse.notFound(res, '文档不存在'); } // 保存文件 await fs.writeFile(docPath, content, 'utf-8'); res.json({ success: true, message: '文档保存成功', category, docName }); } catch (error) { console.error('Save document error:', error); return ErrorResponse.serverError(res, '无法保存文档', error.message); } }); // API: 搜索文档(需要登录) app.get('/api/search/:category', requireAuth, async (req, res) => { try { const category = sanitizePath(req.params.category); const currentDoc = req.query.currentDoc || ''; const { q } = req.query; if (!category) { return ErrorResponse.badRequest(res, '无效的分类名称'); } if (!q || q.trim().length === 0) { return res.json({ currentDoc: [], otherDocs: [] }); } const query = q.toLowerCase(); const categoryPath = path.join(DIRS.docs, category); // 验证路径是否在 docs 目录内 if (!validatePath(categoryPath, DIRS.docs)) { return ErrorResponse.forbidden(res, '拒绝访问:无效的路径'); } // 从 index.md 获取当前分类下应该搜索的文档列表 const structure = await getIndexStructure(); const categoryData = structure.find(cat => cat.name === category); if (!categoryData) { return ErrorResponse.notFound(res, '分类不存在'); } // 并行读取所有文档 const MAX_MATCHES_PER_DOC = 5; const decodedCurrentDoc = decodeURIComponent(currentDoc).replace(/\\/g, '/'); const fileReadPromises = categoryData.docs.map(async (doc) => { const docName = doc.name; const filePath = path.join(categoryPath, `${docName}.md`); try { const content = await fs.readFile(filePath, 'utf-8'); return { docName, content, exists: true }; } catch (err) { console.warn(`文档不存在,跳过搜索: ${filePath}`); return { docName, exists: false }; } }); const fileContents = await Promise.all(fileReadPromises); const currentDocResults = []; const otherDocsResults = []; // 搜索每个文档 for (const { docName, content, exists } of fileContents) { if (!exists) continue; const lines = content.split('\n'); const matches = []; // 搜索匹配的行,达到上限后提前退出 for (let index = 0; index < lines.length && matches.length < MAX_MATCHES_PER_DOC; index++) { const line = lines[index]; const lowerLine = line.toLowerCase(); if (lowerLine.includes(query)) { const startIndex = Math.max(0, lowerLine.indexOf(query) - 50); const endIndex = Math.min(line.length, lowerLine.indexOf(query) + query.length + 50); let snippet = line.substring(startIndex, endIndex); if (startIndex > 0) snippet = '...' + snippet; if (endIndex < line.length) snippet = snippet + '...'; matches.push({ line: index + 1, snippet, fullLine: line }); } } if (matches.length > 0) { const result = { docName, matchCount: matches.length, matches }; // 区分当前文档和其他文档 if (docName === decodedCurrentDoc) { currentDocResults.push(result); } else { otherDocsResults.push(result); } } } res.json({ query: q, currentDoc: currentDocResults, otherDocs: otherDocsResults }); } catch (error) { return ErrorResponse.serverError(res, '搜索失败', error.message); } }); // 解析 index.md 的函数 function parseIndexMd(content) { const lines = content.split('\n'); const structure = []; let currentCategory = null; for (const line of lines) { const trimmedLine = line.trim(); // 匹配分类 [category] const categoryMatch = trimmedLine.match(/^\[(.+?)\]$/); if (categoryMatch) { currentCategory = { name: categoryMatch[1], docs: [] }; structure.push(currentCategory); continue; } // 匹配文档项 1: testa.md 或 3.1: testc1.md const docMatch = trimmedLine.match(/^([\d.]+):\s*(.+?)\.md$/); if (docMatch && currentCategory) { const [, number, docName] = docMatch; const level = (number.match(/\./g) || []).length; currentCategory.docs.push({ number, name: docName, level, fullName: `${docName}.md` }); } } return structure; } // 全局错误处理中间件 app.use((err, req, res, next) => { console.error('全局错误捕获:', err); res.status(err.status || 500).json({ error: '服务器内部错误', message: err.message, path: req.path }); }); // 404 处理 app.use((req, res) => { res.status(404).json({ error: '页面不存在', path: req.path }); }); // 启动服务器 app.listen(PORT, () => { console.log(`服务器运行在 http://localhost:${PORT}`); });