server.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498
  1. const express = require('express');
  2. const path = require('path');
  3. const fs = require('fs').promises;
  4. const cors = require('cors');
  5. const session = require('express-session');
  6. const app = express();
  7. const PORT = 3000;
  8. // 硬编码的密码
  9. const PASSWORD = 'cjy@0526';
  10. // index.md 缓存
  11. let indexCache = null;
  12. let indexCacheTime = 0;
  13. const CACHE_DURATION = 5000; // 5秒缓存
  14. // 路径安全验证函数 - 防止路径遍历攻击
  15. function sanitizePath(input) {
  16. if (!input || typeof input !== 'string') {
  17. return '';
  18. }
  19. // 安全清理策略:
  20. // 1. 移除路径遍历序列
  21. // 2. 移除开头的斜杠(防止绝对路径)
  22. return input
  23. .replace(/\.\./g, '') // 移除 ..
  24. .replace(/^[\/\\]+/, ''); // 移除开头的 / 和 \
  25. }
  26. // 验证路径是否在允许的目录内
  27. function validatePath(fullPath, baseDir) {
  28. const resolvedPath = path.resolve(fullPath);
  29. const resolvedBaseDir = path.resolve(baseDir);
  30. return resolvedPath.startsWith(resolvedBaseDir);
  31. }
  32. // 中间件
  33. // CORS 配置 - 允许前端携带凭证(cookies)
  34. app.use(cors({
  35. origin: true, // 允许所有来源(生产环境建议指定具体域名)
  36. credentials: true // 允许携带凭证
  37. }));
  38. app.use(express.json({ limit: '10mb' })); // 增加请求体大小限制到10MB
  39. app.use(express.urlencoded({ limit: '10mb', extended: true }));
  40. // Session 中间件
  41. app.use(session({
  42. secret: 'cjydocs-secret-key-2024', // 会话密钥
  43. resave: false,
  44. saveUninitialized: false,
  45. cookie: {
  46. maxAge: 7 * 24 * 60 * 60 * 1000, // 7天
  47. httpOnly: true,
  48. secure: false, // 如果使用 HTTPS,设置为 true
  49. sameSite: 'lax', // 允许跨站点请求携带 cookie
  50. path: '/' // Cookie 路径
  51. },
  52. name: 'cjydocs.sid', // Session cookie 名称
  53. rolling: true // 每次请求时重置过期时间
  54. }));
  55. // 登录验证中间件
  56. function requireAuth(req, res, next) {
  57. if (req.session && req.session.isAuthenticated) {
  58. return next();
  59. }
  60. // 如果是 API 请求,返回 401
  61. if (req.path.startsWith('/api')) {
  62. return res.status(401).json({ error: '未登录,请先登录' });
  63. }
  64. // 如果是页面请求,重定向到登录页
  65. res.redirect('/login.html');
  66. }
  67. // 根路径重定向处理
  68. app.get('/', (req, res) => {
  69. console.log('访问首页 - Session状态:', req.session ? req.session.isAuthenticated : 'no session');
  70. if (req.session && req.session.isAuthenticated) {
  71. const indexPath = path.join(__dirname, 'public', 'index.html');
  72. console.log('发送首页文件:', indexPath);
  73. res.sendFile(indexPath, (err) => {
  74. if (err) {
  75. console.error('发送首页文件失败:', err);
  76. res.status(500).json({ error: '无法加载首页', details: err.message });
  77. }
  78. });
  79. } else {
  80. console.log('未登录,重定向到登录页');
  81. res.redirect('/login.html');
  82. }
  83. });
  84. // reader.html 需要登录
  85. app.get('/reader.html', (req, res) => {
  86. if (req.session && req.session.isAuthenticated) {
  87. res.sendFile(path.join(__dirname, 'public', 'reader.html'));
  88. } else {
  89. res.redirect('/login.html');
  90. }
  91. });
  92. // 静态文件服务(CSS、JS、图片等)
  93. app.use('/css', express.static(path.join(__dirname, 'public', 'css')));
  94. app.use('/js', express.static(path.join(__dirname, 'public', 'js')));
  95. // login.html 不需要登录
  96. app.get('/login.html', (req, res) => {
  97. // 如果已登录,重定向到首页
  98. if (req.session && req.session.isAuthenticated) {
  99. res.redirect('/');
  100. } else {
  101. res.sendFile(path.join(__dirname, 'public', 'login.html'));
  102. }
  103. });
  104. // API: 登录
  105. app.post('/api/login', (req, res) => {
  106. const { password } = req.body;
  107. console.log('登录尝试 - IP:', req.ip, 'Session ID:', req.sessionID);
  108. if (!password || typeof password !== 'string') {
  109. console.log('登录失败: 密码格式错误');
  110. return res.status(400).json({ error: '请输入密码' });
  111. }
  112. if (password === PASSWORD) {
  113. req.session.isAuthenticated = true;
  114. console.log('登录成功 - Session ID:', req.sessionID, 'Session数据:', req.session);
  115. // 保存 session 后再响应
  116. req.session.save((err) => {
  117. if (err) {
  118. console.error('Session保存失败:', err);
  119. return res.status(500).json({ error: '登录失败,请重试' });
  120. }
  121. console.log('Session保存成功');
  122. res.json({ success: true, message: '登录成功' });
  123. });
  124. } else {
  125. console.log('登录失败: 密码错误');
  126. res.status(401).json({ error: '密码错误' });
  127. }
  128. });
  129. // API: 登出
  130. app.post('/api/logout', (req, res) => {
  131. req.session.destroy((err) => {
  132. if (err) {
  133. return res.status(500).json({ error: '登出失败' });
  134. }
  135. res.json({ success: true, message: '登出成功' });
  136. });
  137. });
  138. // API: 检查登录状态
  139. app.get('/api/check-auth', (req, res) => {
  140. if (req.session && req.session.isAuthenticated) {
  141. res.json({ authenticated: true });
  142. } else {
  143. res.json({ authenticated: false });
  144. }
  145. });
  146. // API: 获取图片资源(需要登录)
  147. app.get('/api/image/:category/*', requireAuth, async (req, res) => {
  148. try {
  149. // 对分类名进行处理(移除危险字符但保留中文)
  150. const category = req.params.category.replace(/\.\./g, '');
  151. const imagePath = req.params[0]; // 获取剩余路径部分,如 'assets/test.png'
  152. if (!category || !imagePath) {
  153. return res.status(400).json({ error: '无效的请求路径' });
  154. }
  155. const docsDir = path.join(__dirname, 'docs');
  156. const fullPath = path.join(docsDir, category, imagePath);
  157. // 验证路径安全性
  158. if (!validatePath(fullPath, docsDir)) {
  159. return res.status(403).json({ error: '拒绝访问' });
  160. }
  161. // 检查文件是否存在
  162. try {
  163. await fs.access(fullPath);
  164. } catch (err) {
  165. return res.status(404).json({ error: '图片不存在' });
  166. }
  167. // 发送图片文件
  168. res.sendFile(fullPath);
  169. } catch (error) {
  170. console.error('Error serving image:', error);
  171. res.status(500).json({ error: '无法加载图片' });
  172. }
  173. });
  174. // 获取 index.md 结构(带缓存)
  175. async function getIndexStructure() {
  176. const now = Date.now();
  177. if (indexCache && (now - indexCacheTime < CACHE_DURATION)) {
  178. return indexCache;
  179. }
  180. const indexPath = path.join(__dirname, 'index.md');
  181. const content = await fs.readFile(indexPath, 'utf-8');
  182. indexCache = parseIndexMd(content);
  183. indexCacheTime = now;
  184. return indexCache;
  185. }
  186. // API: 解析 index.md 结构(需要登录)
  187. app.get('/api/structure', requireAuth, async (req, res) => {
  188. try {
  189. const structure = await getIndexStructure();
  190. res.json(structure);
  191. } catch (error) {
  192. res.status(500).json({ error: '无法解析文档结构', details: error.message });
  193. }
  194. });
  195. // API: 获取指定分类的文档列表(需要登录)
  196. app.get('/api/category/:category', requireAuth, async (req, res) => {
  197. try {
  198. const category = sanitizePath(req.params.category);
  199. if (!category) {
  200. return res.status(400).json({ error: '无效的分类名称' });
  201. }
  202. const structure = await getIndexStructure();
  203. const categoryData = structure.find(cat => cat.name === category);
  204. if (!categoryData) {
  205. return res.status(404).json({ error: '分类不存在' });
  206. }
  207. res.json(categoryData);
  208. } catch (error) {
  209. res.status(500).json({ error: '无法获取分类信息', details: error.message });
  210. }
  211. });
  212. // API: 获取指定文档的内容(需要登录)
  213. // 修改为支持通配符路径参数,以正确处理子目录
  214. app.get('/api/doc/:category/*', requireAuth, async (req, res) => {
  215. try {
  216. const category = sanitizePath(req.params.category);
  217. const docName = req.params[0]; // 获取剩余的路径部分,支持子目录
  218. if (!category || !docName) {
  219. return res.status(400).json({ error: '无效的分类或文档名称' });
  220. }
  221. const docsDir = path.join(__dirname, 'docs');
  222. // 直接使用 docName,它可能包含子目录路径
  223. const docPath = path.join(docsDir, category, `${docName}.md`);
  224. // 验证路径是否在 docs 目录内
  225. if (!validatePath(docPath, docsDir)) {
  226. return res.status(403).json({ error: '拒绝访问:无效的路径' });
  227. }
  228. const content = await fs.readFile(docPath, 'utf-8');
  229. res.json({ content, category, docName });
  230. } catch (error) {
  231. res.status(500).json({ error: '无法读取文档', details: error.message });
  232. }
  233. });
  234. // API: 保存文档内容(需要登录)
  235. // 修改为支持通配符路径参数,以正确处理子目录
  236. app.put('/api/doc/:category/*', requireAuth, async (req, res) => {
  237. try {
  238. const category = sanitizePath(req.params.category);
  239. const docName = req.params[0]; // 获取剩余的路径部分,支持子目录
  240. const { content } = req.body;
  241. // 验证输入
  242. if (!category || !docName) {
  243. return res.status(400).json({ error: '无效的分类或文档名称' });
  244. }
  245. if (!content || typeof content !== 'string') {
  246. return res.status(400).json({ error: '无效的文档内容' });
  247. }
  248. const docsDir = path.join(__dirname, 'docs');
  249. // 直接使用 docName,它可能包含子目录路径
  250. const docPath = path.join(docsDir, category, `${docName}.md`);
  251. // 验证路径是否在 docs 目录内
  252. if (!validatePath(docPath, docsDir)) {
  253. return res.status(403).json({ error: '拒绝访问:无效的路径' });
  254. }
  255. // 检查文件是否存在
  256. try {
  257. await fs.access(docPath);
  258. } catch (error) {
  259. return res.status(404).json({ error: '文档不存在' });
  260. }
  261. // 保存文件
  262. await fs.writeFile(docPath, content, 'utf-8');
  263. res.json({
  264. success: true,
  265. message: '文档保存成功',
  266. category,
  267. docName
  268. });
  269. } catch (error) {
  270. console.error('Save document error:', error);
  271. res.status(500).json({ error: '无法保存文档', details: error.message });
  272. }
  273. });
  274. // API: 搜索文档(需要登录)
  275. app.get('/api/search/:category', requireAuth, async (req, res) => {
  276. try {
  277. const category = sanitizePath(req.params.category);
  278. // currentDoc 可能包含子目录路径,不要使用 sanitizePath
  279. const currentDoc = req.query.currentDoc || '';
  280. const { q } = req.query;
  281. if (!category) {
  282. return res.status(400).json({ error: '无效的分类名称' });
  283. }
  284. if (!q || q.trim().length === 0) {
  285. return res.json({ currentDoc: [], otherDocs: [] });
  286. }
  287. const query = q.toLowerCase();
  288. const docsDir = path.join(__dirname, 'docs');
  289. const categoryPath = path.join(docsDir, category);
  290. // 验证路径是否在 docs 目录内
  291. if (!validatePath(categoryPath, docsDir)) {
  292. return res.status(403).json({ error: '拒绝访问:无效的路径' });
  293. }
  294. // 从 index.md 获取当前分类下应该搜索的文档列表
  295. const structure = await getIndexStructure();
  296. const categoryData = structure.find(cat => cat.name === category);
  297. if (!categoryData) {
  298. return res.status(404).json({ error: '分类不存在' });
  299. }
  300. // 只搜索 index.md 中列出的文档
  301. const mdFiles = categoryData.docs.map(doc => {
  302. // doc.name 可能包含子目录路径,如 "测试/11"
  303. return doc.name + '.md';
  304. });
  305. const currentDocResults = [];
  306. const otherDocsResults = [];
  307. // 搜索每个文档
  308. for (const file of mdFiles) {
  309. const docName = file.replace('.md', '').replace(/\\/g, '/'); // 统一使用正斜杠
  310. const filePath = path.join(categoryPath, file.replace(/\//g, path.sep)); // 处理路径分隔符
  311. // 检查文件是否存在(以防 index.md 中列出的文件实际不存在)
  312. try {
  313. await fs.access(filePath);
  314. } catch (err) {
  315. console.warn(`文档不存在,跳过搜索: ${filePath}`);
  316. continue;
  317. }
  318. const content = await fs.readFile(filePath, 'utf-8');
  319. // 搜索匹配的行
  320. const lines = content.split('\n');
  321. const matches = [];
  322. lines.forEach((line, index) => {
  323. const lowerLine = line.toLowerCase();
  324. if (lowerLine.includes(query)) {
  325. // 获取上下文(前后各50个字符)
  326. const startIndex = Math.max(0, lowerLine.indexOf(query) - 50);
  327. const endIndex = Math.min(line.length, lowerLine.indexOf(query) + query.length + 50);
  328. let snippet = line.substring(startIndex, endIndex);
  329. // 如果不是从头开始,添加省略号
  330. if (startIndex > 0) snippet = '...' + snippet;
  331. if (endIndex < line.length) snippet = snippet + '...';
  332. matches.push({
  333. line: index + 1,
  334. snippet: snippet,
  335. fullLine: line
  336. });
  337. }
  338. });
  339. if (matches.length > 0) {
  340. const result = {
  341. docName,
  342. matchCount: matches.length,
  343. matches: matches.slice(0, 5) // 最多返回5个匹配
  344. };
  345. // 区分当前文档和其他文档
  346. // 处理 URL 解码后的 currentDoc(支持斜杠路径)
  347. const decodedCurrentDoc = decodeURIComponent(currentDoc).replace(/\\/g, '/');
  348. if (docName === decodedCurrentDoc) {
  349. currentDocResults.push(result);
  350. } else {
  351. otherDocsResults.push(result);
  352. }
  353. }
  354. }
  355. res.json({
  356. query: q,
  357. currentDoc: currentDocResults,
  358. otherDocs: otherDocsResults
  359. });
  360. } catch (error) {
  361. res.status(500).json({ error: '搜索失败', details: error.message });
  362. }
  363. });
  364. // 解析 index.md 的函数
  365. function parseIndexMd(content) {
  366. const lines = content.split('\n');
  367. const structure = [];
  368. let currentCategory = null;
  369. for (const line of lines) {
  370. const trimmedLine = line.trim();
  371. // 匹配分类 [category]
  372. const categoryMatch = trimmedLine.match(/^\[(.+?)\]$/);
  373. if (categoryMatch) {
  374. currentCategory = {
  375. name: categoryMatch[1],
  376. docs: []
  377. };
  378. structure.push(currentCategory);
  379. continue;
  380. }
  381. // 匹配文档项 1: testa.md 或 3.1: testc1.md
  382. const docMatch = trimmedLine.match(/^([\d.]+):\s*(.+?)\.md$/);
  383. if (docMatch && currentCategory) {
  384. const [, number, docName] = docMatch;
  385. const level = (number.match(/\./g) || []).length;
  386. currentCategory.docs.push({
  387. number,
  388. name: docName,
  389. level,
  390. fullName: `${docName}.md`
  391. });
  392. }
  393. }
  394. return structure;
  395. }
  396. // 全局错误处理中间件
  397. app.use((err, req, res, next) => {
  398. console.error('全局错误捕获:', err);
  399. res.status(err.status || 500).json({
  400. error: '服务器内部错误',
  401. message: err.message,
  402. path: req.path
  403. });
  404. });
  405. // 404 处理
  406. app.use((req, res) => {
  407. console.log('404 未找到:', req.method, req.path);
  408. res.status(404).json({
  409. error: '页面不存在',
  410. path: req.path
  411. });
  412. });
  413. // 启动服务器
  414. app.listen(PORT, () => {
  415. console.log(`服务器运行在 http://localhost:${PORT}`);
  416. console.log(`密码: ${PASSWORD}`);
  417. console.log(`Session 有效期: 7天`);
  418. });