Files
rainblogweb/lib/locks.js
T

153 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 分钟后再试' },
});
// 参数解析:空 → loginlogin/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).blockslocksMeta=库内 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 鉴权)。
// 有效期 1hpayload 绑定 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 };