// lib/locks.js —— markdown [lock:] 成对块锁定核心共享模块(博客/论坛共用) // // 语法(多类型): // [lock:login]...[/lock] 需登录 // [lock:reply]...[/lock] 评论后解锁(含待审核评论) // [lock:password 密码]...[/lock] 密码解锁(bcrypt 成本 10 存储校验) // [lock]...[/lock] 默认 login // // 真锁原则:未解锁内容不进 HTML/API——剥离的块整体替换为 @@LOCK@@ 占位符, // 块内文本与 [image:]/[file:] 附件标签一律不输出;password 块内容必须经 unlock 接口 // bcrypt 校验后才返回。未闭合 [lock: 不匹配正则,按普通文本原样保留(不破坏文档)。 const bcrypt = require('bcryptjs'); const rateLimit = require('express-rate-limit'); const jwt = require('jsonwebtoken'); const { SECRET } = require('../middleware/auth'); const LOCK_RE = /\[lock(?::([^\]]*))?\]([\s\S]*?)\[\/lock\]/gi; const PASSWORD_TAG_RE = /\[lock:password\s*[::\s]\s*([^\]]*)\]/gi; // 解锁接口限流:15 分钟窗口内每 IP 最多 10 次(照 loginLimiter 模式,防密码爆破) const unlockLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, message: { error: '尝试次数过多,请 15 分钟后再试' }, }); // 参数解析:空 → login;login/reply → 对应;'password:<密码>'(冒号分隔,兼容 'password 密码')→ password // 冒号分隔优于空格:密码本身可含空格(如 "my secret") function parseLockParams(param) { const p = String(param || '').trim(); if (!p || p === 'login') return { type: 'login' }; if (p === 'reply') return { type: 'reply' }; const m = p.match(/^password\s*[::\s]\s*(.+)$/i) || p.match(/^password$/i); if (m && m[1] && m[1].trim()) return { type: 'password', plain: m[1].trim() }; if (p.toLowerCase() === 'password') return { type: 'password', plain: '' }; // 无密码参数按 login 兜底在下方 // 未知参数容错按 login 处理(不破坏文档) return { type: 'login' }; } // 提取锁定块:index=解析顺序(0 起);raw=块原文含标签;inner=块内原文。 // 注意:本函数不做 bcrypt 计算(读路径零开销)——hash 由 saveLocks 落库、 // unlock 接口取库内 hash 校验,两块 index 顺序一致(同一正则遍历)。 function parseLocks(content) { const blocks = []; const re = new RegExp(LOCK_RE.source, 'gi'); String(content || '').replace(re, (m, param, inner) => { const { type, plain } = parseLockParams(param); blocks.push({ index: blocks.length, type, plain, raw: m, inner }); return ''; }); return { blocks, stripped: '' }; } // 依据 viewer 视角剥离锁定块,返回 { stripped, unlocked }: // stripped 中保留块原样输出(含 [lock:] 标签),剥离块替换为 @@LOCK@@ 占位符; // unlocked = 本次视角已解锁的块索引数组。 // viewer: { userId, isAdmin, isAuthor, hasCommented } // login 块:任何登录用户可见;reply 块:已评论或 admin/作者; // password 块:仅 admin/作者可见(其余一律剥离,必须经 unlock 接口 bcrypt 校验; // unlockedSet 参数不豁免 password 块,防止前端伪造解锁)。 // maskPasswords:对输出中残留的 [lock:password 明文] 参数打码为 ***(SSR 用, // 覆盖未闭合/异常块按文本输出时明文进 HTML 的路径)。 function stripLocks(content, opts = {}) { const { viewer = {}, unlockedSet = [], maskPasswords = false } = opts; const blocks = parseLocks(content).blocks; const unlocked = []; let matchIdx = -1; const stripped = String(content || '').replace(new RegExp(LOCK_RE.source, 'gi'), (m) => { matchIdx++; const b = blocks[matchIdx]; const extra = Array.isArray(unlockedSet) && unlockedSet.map(Number).includes(b.index); // 判定:admin/作者恒全解锁(password 含明文也仅限作者可见);login 登录即见; // reply 已评论即见;password 非作者一律剥离(unlockedSet 不豁免) const keep = !!( viewer.isAdmin || viewer.isAuthor || (b.type === 'login' && !!viewer.userId) || (b.type === 'reply' && (viewer.hasCommented || extra)) || (b.type === 'password' && extra && (viewer.isAdmin || viewer.isAuthor)) ); if (keep) { unlocked.push(b.index); return m; } return '@@LOCK' + b.index + '@@'; }); let out = stripped; if (maskPasswords) { out = out.replace(PASSWORD_TAG_RE, (m, p) => '[lock:password ' + (p.trim() ? '***' : '') + ']'); } return { stripped: out, unlocked }; } // 保存锁定元数据:parseLocks → JSON.stringify(blocks.map(b => ({type, hash})))(不含 raw/inner)。 // password 块在此处 bcrypt 哈希(成本 10,全站一致);login/reply 块无 hash 字段。 function saveLocks(content) { const { blocks } = parseLocks(content); return JSON.stringify(blocks.map(b => ({ type: b.type, ...(b.type === 'password' ? { hash: bcrypt.hashSync(b.plain, 10) } : {}), }))); } // 解锁校验(blog/forum 共用): // blocks=parseLocks(content).blocks;locksMeta=库内 JSON(取 password hash); // viewer={userId,isAdmin,isAuthor};replyQualified=是否已评论。 // 返回 { ok, status, inner? };失败统一 401(密码错/块不存在不区分,防探测); // reply 未评论返回 403。 function verifyUnlock({ blocks, locksMeta = [], index, password, viewer = {}, replyQualified = false }) { const b = (blocks || []).find(x => x.index === index); if (!b) return { ok: false, status: 401 }; if (viewer.isAdmin || viewer.isAuthor) return { ok: true, status: 200, inner: b.inner }; if (b.type === 'login') { if (!viewer.userId) return { ok: false, status: 401 }; return { ok: true, status: 200, inner: b.inner }; } if (b.type === 'reply') { if (!replyQualified) return { ok: false, status: 403 }; return { ok: true, status: 200, inner: b.inner }; } if (b.type === 'password') { const meta = locksMeta[index] || null; const hash = meta && meta.hash; if (!hash || !password || !bcrypt.compareSync(String(password), hash)) { return { ok: false, status: 401 }; } return { ok: true, status: 200, inner: b.inner }; } return { ok: false, status: 401 }; } // 解锁附件 token:详情接口对已解锁块、unlock 接口对解锁成功块签发, // 前端用它加载块内 [image:]/[file:] 附件(/api/upload/locked 鉴权)。 // 有效期 1h;payload 绑定 refType/refId/blockIndex——只能取该帖该块的附件。 function makeLockToken(refType, refId, blockIndex) { return jwt.sign({ purpose: 'lock-attachment', refType, refId, blockIndex }, SECRET, { expiresIn: '1h' }); } // 校验解锁附件 token:合法且 purpose 匹配返回 payload,否则 null function verifyLockToken(token) { if (!token) return null; try { const payload = jwt.verify(token, SECRET); if (!payload || payload.purpose !== 'lock-attachment') return null; return payload; } catch { return null; } } module.exports = { parseLocks, stripLocks, saveLocks, verifyUnlock, unlockLimiter, makeLockToken, verifyLockToken };