server.js 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348
  1. const express = require('express');
  2. const path = require('path');
  3. const fs = require('fs').promises;
  4. const cors = require('cors');
  5. const app = express();
  6. const PORT = 3000;
  7. // index.md 缓存
  8. let indexCache = null;
  9. let indexCacheTime = 0;
  10. const CACHE_DURATION = 5000; // 5秒缓存
  11. // 路径安全验证函数 - 防止路径遍历攻击
  12. function sanitizePath(input) {
  13. if (!input || typeof input !== 'string') {
  14. return '';
  15. }
  16. // 安全清理策略:
  17. // 1. 移除路径遍历序列
  18. // 2. 移除开头的斜杠(防止绝对路径)
  19. return input
  20. .replace(/\.\./g, '') // 移除 ..
  21. .replace(/^[\/\\]+/, ''); // 移除开头的 / 和 \
  22. }
  23. // 验证路径是否在允许的目录内
  24. function validatePath(fullPath, baseDir) {
  25. const resolvedPath = path.resolve(fullPath);
  26. const resolvedBaseDir = path.resolve(baseDir);
  27. return resolvedPath.startsWith(resolvedBaseDir);
  28. }
  29. // 中间件
  30. app.use(cors());
  31. app.use(express.json({ limit: '10mb' })); // 增加请求体大小限制到10MB
  32. app.use(express.urlencoded({ limit: '10mb', extended: true }));
  33. app.use(express.static('public'));
  34. // API: 获取图片资源
  35. app.get('/api/image/:category/*', async (req, res) => {
  36. try {
  37. // 对分类名进行处理(移除危险字符但保留中文)
  38. const category = req.params.category.replace(/\.\./g, '');
  39. const imagePath = req.params[0]; // 获取剩余路径部分,如 'assets/test.png'
  40. if (!category || !imagePath) {
  41. return res.status(400).json({ error: '无效的请求路径' });
  42. }
  43. const docsDir = path.join(__dirname, 'docs');
  44. const fullPath = path.join(docsDir, category, imagePath);
  45. // 验证路径安全性
  46. if (!validatePath(fullPath, docsDir)) {
  47. return res.status(403).json({ error: '拒绝访问' });
  48. }
  49. // 检查文件是否存在
  50. try {
  51. await fs.access(fullPath);
  52. } catch (err) {
  53. return res.status(404).json({ error: '图片不存在' });
  54. }
  55. // 发送图片文件
  56. res.sendFile(fullPath);
  57. } catch (error) {
  58. console.error('Error serving image:', error);
  59. res.status(500).json({ error: '无法加载图片' });
  60. }
  61. });
  62. // 获取 index.md 结构(带缓存)
  63. async function getIndexStructure() {
  64. const now = Date.now();
  65. if (indexCache && (now - indexCacheTime < CACHE_DURATION)) {
  66. return indexCache;
  67. }
  68. const indexPath = path.join(__dirname, 'index.md');
  69. const content = await fs.readFile(indexPath, 'utf-8');
  70. indexCache = parseIndexMd(content);
  71. indexCacheTime = now;
  72. return indexCache;
  73. }
  74. // API: 解析 index.md 结构
  75. app.get('/api/structure', async (req, res) => {
  76. try {
  77. const structure = await getIndexStructure();
  78. res.json(structure);
  79. } catch (error) {
  80. res.status(500).json({ error: '无法解析文档结构', details: error.message });
  81. }
  82. });
  83. // API: 获取指定分类的文档列表
  84. app.get('/api/category/:category', async (req, res) => {
  85. try {
  86. const category = sanitizePath(req.params.category);
  87. if (!category) {
  88. return res.status(400).json({ error: '无效的分类名称' });
  89. }
  90. const structure = await getIndexStructure();
  91. const categoryData = structure.find(cat => cat.name === category);
  92. if (!categoryData) {
  93. return res.status(404).json({ error: '分类不存在' });
  94. }
  95. res.json(categoryData);
  96. } catch (error) {
  97. res.status(500).json({ error: '无法获取分类信息', details: error.message });
  98. }
  99. });
  100. // API: 获取指定文档的内容
  101. // 修改为支持通配符路径参数,以正确处理子目录
  102. app.get('/api/doc/:category/*', async (req, res) => {
  103. try {
  104. const category = sanitizePath(req.params.category);
  105. const docName = req.params[0]; // 获取剩余的路径部分,支持子目录
  106. if (!category || !docName) {
  107. return res.status(400).json({ error: '无效的分类或文档名称' });
  108. }
  109. const docsDir = path.join(__dirname, 'docs');
  110. // 直接使用 docName,它可能包含子目录路径
  111. const docPath = path.join(docsDir, category, `${docName}.md`);
  112. // 验证路径是否在 docs 目录内
  113. if (!validatePath(docPath, docsDir)) {
  114. return res.status(403).json({ error: '拒绝访问:无效的路径' });
  115. }
  116. const content = await fs.readFile(docPath, 'utf-8');
  117. res.json({ content, category, docName });
  118. } catch (error) {
  119. res.status(500).json({ error: '无法读取文档', details: error.message });
  120. }
  121. });
  122. // API: 保存文档内容
  123. // 修改为支持通配符路径参数,以正确处理子目录
  124. app.put('/api/doc/:category/*', async (req, res) => {
  125. try {
  126. const category = sanitizePath(req.params.category);
  127. const docName = req.params[0]; // 获取剩余的路径部分,支持子目录
  128. const { content } = req.body;
  129. // 验证输入
  130. if (!category || !docName) {
  131. return res.status(400).json({ error: '无效的分类或文档名称' });
  132. }
  133. if (!content || typeof content !== 'string') {
  134. return res.status(400).json({ error: '无效的文档内容' });
  135. }
  136. const docsDir = path.join(__dirname, 'docs');
  137. // 直接使用 docName,它可能包含子目录路径
  138. const docPath = path.join(docsDir, category, `${docName}.md`);
  139. // 验证路径是否在 docs 目录内
  140. if (!validatePath(docPath, docsDir)) {
  141. return res.status(403).json({ error: '拒绝访问:无效的路径' });
  142. }
  143. // 检查文件是否存在
  144. try {
  145. await fs.access(docPath);
  146. } catch (error) {
  147. return res.status(404).json({ error: '文档不存在' });
  148. }
  149. // 保存文件
  150. await fs.writeFile(docPath, content, 'utf-8');
  151. res.json({
  152. success: true,
  153. message: '文档保存成功',
  154. category,
  155. docName
  156. });
  157. } catch (error) {
  158. console.error('Save document error:', error);
  159. res.status(500).json({ error: '无法保存文档', details: error.message });
  160. }
  161. });
  162. // API: 搜索文档
  163. app.get('/api/search/:category', async (req, res) => {
  164. try {
  165. const category = sanitizePath(req.params.category);
  166. // currentDoc 可能包含子目录路径,不要使用 sanitizePath
  167. const currentDoc = req.query.currentDoc || '';
  168. const { q } = req.query;
  169. if (!category) {
  170. return res.status(400).json({ error: '无效的分类名称' });
  171. }
  172. if (!q || q.trim().length === 0) {
  173. return res.json({ currentDoc: [], otherDocs: [] });
  174. }
  175. const query = q.toLowerCase();
  176. const docsDir = path.join(__dirname, 'docs');
  177. const categoryPath = path.join(docsDir, category);
  178. // 验证路径是否在 docs 目录内
  179. if (!validatePath(categoryPath, docsDir)) {
  180. return res.status(403).json({ error: '拒绝访问:无效的路径' });
  181. }
  182. // 从 index.md 获取当前分类下应该搜索的文档列表
  183. const structure = await getIndexStructure();
  184. const categoryData = structure.find(cat => cat.name === category);
  185. if (!categoryData) {
  186. return res.status(404).json({ error: '分类不存在' });
  187. }
  188. // 只搜索 index.md 中列出的文档
  189. const mdFiles = categoryData.docs.map(doc => {
  190. // doc.name 可能包含子目录路径,如 "测试/11"
  191. return doc.name + '.md';
  192. });
  193. const currentDocResults = [];
  194. const otherDocsResults = [];
  195. // 搜索每个文档
  196. for (const file of mdFiles) {
  197. const docName = file.replace('.md', '').replace(/\\/g, '/'); // 统一使用正斜杠
  198. const filePath = path.join(categoryPath, file.replace(/\//g, path.sep)); // 处理路径分隔符
  199. // 检查文件是否存在(以防 index.md 中列出的文件实际不存在)
  200. try {
  201. await fs.access(filePath);
  202. } catch (err) {
  203. console.warn(`文档不存在,跳过搜索: ${filePath}`);
  204. continue;
  205. }
  206. const content = await fs.readFile(filePath, 'utf-8');
  207. // 搜索匹配的行
  208. const lines = content.split('\n');
  209. const matches = [];
  210. lines.forEach((line, index) => {
  211. const lowerLine = line.toLowerCase();
  212. if (lowerLine.includes(query)) {
  213. // 获取上下文(前后各50个字符)
  214. const startIndex = Math.max(0, lowerLine.indexOf(query) - 50);
  215. const endIndex = Math.min(line.length, lowerLine.indexOf(query) + query.length + 50);
  216. let snippet = line.substring(startIndex, endIndex);
  217. // 如果不是从头开始,添加省略号
  218. if (startIndex > 0) snippet = '...' + snippet;
  219. if (endIndex < line.length) snippet = snippet + '...';
  220. matches.push({
  221. line: index + 1,
  222. snippet: snippet,
  223. fullLine: line
  224. });
  225. }
  226. });
  227. if (matches.length > 0) {
  228. const result = {
  229. docName,
  230. matchCount: matches.length,
  231. matches: matches.slice(0, 5) // 最多返回5个匹配
  232. };
  233. // 区分当前文档和其他文档
  234. // 处理 URL 解码后的 currentDoc(支持斜杠路径)
  235. const decodedCurrentDoc = decodeURIComponent(currentDoc).replace(/\\/g, '/');
  236. if (docName === decodedCurrentDoc) {
  237. currentDocResults.push(result);
  238. } else {
  239. otherDocsResults.push(result);
  240. }
  241. }
  242. }
  243. res.json({
  244. query: q,
  245. currentDoc: currentDocResults,
  246. otherDocs: otherDocsResults
  247. });
  248. } catch (error) {
  249. res.status(500).json({ error: '搜索失败', details: error.message });
  250. }
  251. });
  252. // 解析 index.md 的函数
  253. function parseIndexMd(content) {
  254. const lines = content.split('\n');
  255. const structure = [];
  256. let currentCategory = null;
  257. for (const line of lines) {
  258. const trimmedLine = line.trim();
  259. // 匹配分类 [category]
  260. const categoryMatch = trimmedLine.match(/^\[(.+?)\]$/);
  261. if (categoryMatch) {
  262. currentCategory = {
  263. name: categoryMatch[1],
  264. docs: []
  265. };
  266. structure.push(currentCategory);
  267. continue;
  268. }
  269. // 匹配文档项 1: testa.md 或 3.1: testc1.md
  270. const docMatch = trimmedLine.match(/^([\d.]+):\s*(.+?)\.md$/);
  271. if (docMatch && currentCategory) {
  272. const [, number, docName] = docMatch;
  273. const level = (number.match(/\./g) || []).length;
  274. currentCategory.docs.push({
  275. number,
  276. name: docName,
  277. level,
  278. fullName: `${docName}.md`
  279. });
  280. }
  281. }
  282. return structure;
  283. }
  284. // 启动服务器
  285. app.listen(PORT, () => {
  286. console.log(`服务器运行在 http://localhost:${PORT}`);
  287. });