153 lines
7.1 KiB
JavaScript
153 lines
7.1 KiB
JavaScript
// lib/locks.js —— markdown [lock:] 成对块锁定核心共享模块(博客/论坛共用)
|
||
//
|
||
// 语法(多类型):
|
||
// [lock:login]...[/lock] 需登录
|
||
// [lock:reply]...[/lock] 评论后解锁(含待审核评论)
|
||
// [lock:password 密码]...[/lock] 密码解锁(bcrypt 成本 10 存储校验)
|
||
// [lock]...[/lock] 默认 login
|
||
//
|
||
// 真锁原则:未解锁内容不进 HTML/API——剥离的块整体替换为 @@LOCK<index>@@ 占位符,
|
||
// 块内文本与 [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<index>@@ 占位符;
|
||
// 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 };
|