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'; // 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); } // 中间件 // 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 (req.session && req.session.isAuthenticated) { return next(); } // 如果是 API 请求,返回 401 if (req.path.startsWith('/api')) { return res.status(401).json({ error: '未登录,请先登录' }); } // 如果是页面请求,重定向到登录页 res.redirect('/login.html'); } // 根路径重定向处理 app.get('/', (req, res) => { console.log('访问首页 - Session状态:', req.session ? req.session.isAuthenticated : 'no session'); if (req.session && req.session.isAuthenticated) { const indexPath = path.join(__dirname, 'public', 'index.html'); console.log('发送首页文件:', indexPath); res.sendFile(indexPath, (err) => { if (err) { console.error('发送首页文件失败:', err); res.status(500).json({ error: '无法加载首页', details: err.message }); } }); } else { console.log('未登录,重定向到登录页'); res.redirect('/login.html'); } }); // reader.html 需要登录 app.get('/reader.html', (req, res) => { if (req.session && req.session.isAuthenticated) { res.sendFile(path.join(__dirname, 'public', 'reader.html')); } else { res.redirect('/login.html'); } }); // 静态文件服务(CSS、JS、图片等) app.use('/css', express.static(path.join(__dirname, 'public', 'css'))); app.use('/js', express.static(path.join(__dirname, 'public', 'js'))); // login.html 不需要登录 app.get('/login.html', (req, res) => { // 如果已登录,重定向到首页 if (req.session && req.session.isAuthenticated) { res.redirect('/'); } else { res.sendFile(path.join(__dirname, 'public', 'login.html')); } }); // API: 登录 app.post('/api/login', (req, res) => { const { password } = req.body; console.log('登录尝试 - IP:', req.ip, 'Session ID:', req.sessionID); if (!password || typeof password !== 'string') { console.log('登录失败: 密码格式错误'); return res.status(400).json({ error: '请输入密码' }); } if (password === PASSWORD) { req.session.isAuthenticated = true; console.log('登录成功 - Session ID:', req.sessionID, 'Session数据:', req.session); // 保存 session 后再响应 req.session.save((err) => { if (err) { console.error('Session保存失败:', err); return res.status(500).json({ error: '登录失败,请重试' }); } console.log('Session保存成功'); res.json({ success: true, message: '登录成功' }); }); } else { console.log('登录失败: 密码错误'); res.status(401).json({ error: '密码错误' }); } }); // API: 登出 app.post('/api/logout', (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ error: '登出失败' }); } res.json({ success: true, message: '登出成功' }); }); }); // API: 检查登录状态 app.get('/api/check-auth', (req, res) => { if (req.session && req.session.isAuthenticated) { res.json({ authenticated: true }); } else { res.json({ authenticated: false }); } }); // API: 获取图片资源(需要登录) app.get('/api/image/:category/*', requireAuth, async (req, res) => { try { // 对分类名进行处理(移除危险字符但保留中文) const category = req.params.category.replace(/\.\./g, ''); const imagePath = req.params[0]; // 获取剩余路径部分,如 'assets/test.png' if (!category || !imagePath) { return res.status(400).json({ error: '无效的请求路径' }); } const docsDir = path.join(__dirname, 'docs'); const fullPath = path.join(docsDir, category, imagePath); // 验证路径安全性 if (!validatePath(fullPath, docsDir)) { return res.status(403).json({ error: '拒绝访问' }); } // 检查文件是否存在 try { await fs.access(fullPath); } catch (err) { return res.status(404).json({ error: '图片不存在' }); } // 发送图片文件 res.sendFile(fullPath); } catch (error) { console.error('Error serving image:', error); res.status(500).json({ error: '无法加载图片' }); } }); // 获取 index.md 结构(带缓存) async function getIndexStructure() { const now = Date.now(); if (indexCache && (now - indexCacheTime < CACHE_DURATION)) { return indexCache; } const indexPath = path.join(__dirname, 'index.md'); const content = await fs.readFile(indexPath, '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) { res.status(500).json({ error: '无法解析文档结构', details: error.message }); } }); // API: 获取指定分类的文档列表(需要登录) app.get('/api/category/:category', requireAuth, async (req, res) => { try { const category = sanitizePath(req.params.category); if (!category) { return res.status(400).json({ error: '无效的分类名称' }); } const structure = await getIndexStructure(); const categoryData = structure.find(cat => cat.name === category); if (!categoryData) { return res.status(404).json({ error: '分类不存在' }); } res.json(categoryData); } catch (error) { res.status(500).json({ error: '无法获取分类信息', details: 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 res.status(400).json({ error: '无效的分类或文档名称' }); } const docsDir = path.join(__dirname, 'docs'); // 直接使用 docName,它可能包含子目录路径 const docPath = path.join(docsDir, category, `${docName}.md`); // 验证路径是否在 docs 目录内 if (!validatePath(docPath, docsDir)) { return res.status(403).json({ error: '拒绝访问:无效的路径' }); } const content = await fs.readFile(docPath, 'utf-8'); res.json({ content, category, docName }); } catch (error) { res.status(500).json({ error: '无法读取文档', details: 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 res.status(400).json({ error: '无效的分类或文档名称' }); } if (!content || typeof content !== 'string') { return res.status(400).json({ error: '无效的文档内容' }); } const docsDir = path.join(__dirname, 'docs'); // 直接使用 docName,它可能包含子目录路径 const docPath = path.join(docsDir, category, `${docName}.md`); // 验证路径是否在 docs 目录内 if (!validatePath(docPath, docsDir)) { return res.status(403).json({ error: '拒绝访问:无效的路径' }); } // 检查文件是否存在 try { await fs.access(docPath); } catch (error) { return res.status(404).json({ error: '文档不存在' }); } // 保存文件 await fs.writeFile(docPath, content, 'utf-8'); res.json({ success: true, message: '文档保存成功', category, docName }); } catch (error) { console.error('Save document error:', error); res.status(500).json({ error: '无法保存文档', details: error.message }); } }); // API: 搜索文档(需要登录) app.get('/api/search/:category', requireAuth, async (req, res) => { try { const category = sanitizePath(req.params.category); // currentDoc 可能包含子目录路径,不要使用 sanitizePath const currentDoc = req.query.currentDoc || ''; const { q } = req.query; if (!category) { return res.status(400).json({ error: '无效的分类名称' }); } if (!q || q.trim().length === 0) { return res.json({ currentDoc: [], otherDocs: [] }); } const query = q.toLowerCase(); const docsDir = path.join(__dirname, 'docs'); const categoryPath = path.join(docsDir, category); // 验证路径是否在 docs 目录内 if (!validatePath(categoryPath, docsDir)) { return res.status(403).json({ error: '拒绝访问:无效的路径' }); } // 从 index.md 获取当前分类下应该搜索的文档列表 const structure = await getIndexStructure(); const categoryData = structure.find(cat => cat.name === category); if (!categoryData) { return res.status(404).json({ error: '分类不存在' }); } // 只搜索 index.md 中列出的文档 const mdFiles = categoryData.docs.map(doc => { // doc.name 可能包含子目录路径,如 "测试/11" return doc.name + '.md'; }); const currentDocResults = []; const otherDocsResults = []; // 搜索每个文档 for (const file of mdFiles) { const docName = file.replace('.md', '').replace(/\\/g, '/'); // 统一使用正斜杠 const filePath = path.join(categoryPath, file.replace(/\//g, path.sep)); // 处理路径分隔符 // 检查文件是否存在(以防 index.md 中列出的文件实际不存在) try { await fs.access(filePath); } catch (err) { console.warn(`文档不存在,跳过搜索: ${filePath}`); continue; } const content = await fs.readFile(filePath, 'utf-8'); // 搜索匹配的行 const lines = content.split('\n'); const matches = []; lines.forEach((line, index) => { const lowerLine = line.toLowerCase(); if (lowerLine.includes(query)) { // 获取上下文(前后各50个字符) 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: snippet, fullLine: line }); } }); if (matches.length > 0) { const result = { docName, matchCount: matches.length, matches: matches.slice(0, 5) // 最多返回5个匹配 }; // 区分当前文档和其他文档 // 处理 URL 解码后的 currentDoc(支持斜杠路径) const decodedCurrentDoc = decodeURIComponent(currentDoc).replace(/\\/g, '/'); if (docName === decodedCurrentDoc) { currentDocResults.push(result); } else { otherDocsResults.push(result); } } } res.json({ query: q, currentDoc: currentDocResults, otherDocs: otherDocsResults }); } catch (error) { res.status(500).json({ error: '搜索失败', details: 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) => { console.log('404 未找到:', req.method, req.path); res.status(404).json({ error: '页面不存在', path: req.path }); }); // 启动服务器 app.listen(PORT, () => { console.log(`服务器运行在 http://localhost:${PORT}`); console.log(`密码: ${PASSWORD}`); console.log(`Session 有效期: 7天`); });