server.js 13 KB

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