server.js 14 KB

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