server.js 14 KB

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