feat: 附件真锁(锁定块内附件改走 /api/upload/locked 鉴权接口,JWT 解锁 token)+ 更换软件授权协议(定制版 LICENSE)
This commit is contained in:
@@ -5,17 +5,28 @@ import { escapeHtml } from '../lib/utils.js';
|
||||
import { getToken } from '../api/client.js';
|
||||
import LockBlock from './LockBlock.jsx';
|
||||
|
||||
/** [image:文件名] → /uploads/ 图片 */
|
||||
function imageTag(filename) {
|
||||
if (!filename) return '';
|
||||
return `<img src="/uploads/${encodeURIComponent(filename)}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
|
||||
/**
|
||||
* 锁块附件鉴权地址:后端 /api/upload/locked 按 (ref_type, ref_id, block, file) + token 校验,
|
||||
* 只允许取「该帖该块」已解锁的附件。缺 refType/refId/token 返回 null → 调用方回退原直链。
|
||||
*/
|
||||
function lockedAttachmentUrl(refType, refId, blockIndex, filename, token) {
|
||||
if (!refType || refId == null || !token) return null;
|
||||
return `/api/upload/locked?ref_type=${encodeURIComponent(refType)}&ref_id=${refId}&block=${encodeURIComponent(blockIndex)}&file=${encodeURIComponent(filename)}&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
/** [file:文件名] → 带 token 的下载链接 */
|
||||
function fileTag(filename) {
|
||||
/** [image:文件名] → 图片标签;lockedUrl 存在则 src 走鉴权接口,否则 /uploads/ 直链 */
|
||||
function imageTag(filename, lockedUrl) {
|
||||
if (!filename) return '';
|
||||
const src = lockedUrl || `/uploads/${encodeURIComponent(filename)}`;
|
||||
return `<img src="${src}" alt="" loading="lazy" style="max-width:100%;border-radius:8px;margin:8px 0">`;
|
||||
}
|
||||
|
||||
/** [file:文件名] → 下载链接;lockedUrl 存在则 href 走鉴权接口(保留 target=_blank 与样式),否则原下载逻辑 */
|
||||
function fileTag(filename, lockedUrl) {
|
||||
if (!filename) return '';
|
||||
const token = getToken() || '';
|
||||
return `<a href="/api/upload/download/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}" target="_blank" class="file-link" style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--md-ref-surface-container);border-radius:8px;margin:4px 0;text-decoration:none;color:var(--md-ref-primary);font-size:14px">
|
||||
const href = lockedUrl || `/api/upload/download/${encodeURIComponent(filename)}?token=${encodeURIComponent(token)}`;
|
||||
return `<a href="${href}" target="_blank" class="file-link" style="display:inline-flex;align-items:center;gap:6px;padding:6px 12px;background:var(--md-ref-surface-container);border-radius:8px;margin:4px 0;text-decoration:none;color:var(--md-ref-primary);font-size:14px">
|
||||
<span class="material-icons" style="font-size:18px">attachment</span> ${escapeHtml(filename)}</a>`;
|
||||
}
|
||||
|
||||
@@ -40,22 +51,42 @@ function extractLockType(block) {
|
||||
*/
|
||||
const INLINE_LOCK_RE = /(\[lock(?::[^\]]*)?\]([\s\S]*?)\[\/lock\])/g;
|
||||
|
||||
/** 统计文本段内内联 [lock:] 成对标签数量(全文锁块索引游标推进用) */
|
||||
function countInlineLocks(text) {
|
||||
const m = String(text).match(INLINE_LOCK_RE);
|
||||
return m ? m.length : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染一段文本:文本段走 renderContent;内联 [lock:] 段渲染「已解锁内容」容器。
|
||||
* 已解锁块(admin/作者/解锁后)后端在 content 里原样保留 [lock:...]...[/lock] 标签,
|
||||
* 已解锁块(admin/作者/登录或评论后可见)后端在 content 里原样保留 [lock:...]...[/lock] 标签,
|
||||
* 这里按标签切成锁块容器(同款背景+边框 + 🔓 已解锁头部),内容递归 MarkdownRenderer 渲染。
|
||||
* rest.inlineStartIdx:本段起始内联块在全文中的索引(buildNodes 用锁块游标给出),
|
||||
* 用于查 locks 数组拿该块的附件 token(块内 [image:]/[file:] 走鉴权接口)。
|
||||
*/
|
||||
function renderSegments(text, useMarkdown, rest) {
|
||||
const { locks, refType, refId, blockCtx: parentCtx } = rest;
|
||||
const parts = String(text).split(INLINE_LOCK_RE);
|
||||
const nodes = [];
|
||||
let textIdx = 0;
|
||||
let lockIdx = 0;
|
||||
let tagIdx = rest.inlineStartIdx || 0; // 内联块在全文中的索引游标
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const p = parts[i];
|
||||
if (i % 3 === 1) {
|
||||
// 整块(含标签),紧随其后 i+1 是对应块内内容
|
||||
const inner = parts[i + 1] || '';
|
||||
const type = extractLockType(p);
|
||||
const idx = tagIdx++;
|
||||
// 内联块附件上下文:已处锁块内(parentCtx)时沿用父块 token(嵌套块文件同属父块);
|
||||
// 顶层则按本段索引查 locks 数组拿该块的 token(无 token 保持直链向后兼容)
|
||||
let childCtx = null;
|
||||
if (parentCtx) {
|
||||
childCtx = parentCtx;
|
||||
} else {
|
||||
const meta = (Array.isArray(locks) && locks.find((l) => l.index === idx)) || {};
|
||||
if (meta.token) childCtx = { index: idx, token: meta.token };
|
||||
}
|
||||
nodes.push(
|
||||
<div key={'reveal' + lockIdx++} className="lock-reveal">
|
||||
<div className="lock-placeholder lock-revealed">
|
||||
@@ -68,7 +99,14 @@ function renderSegments(text, useMarkdown, rest) {
|
||||
<MarkdownRenderer
|
||||
content={inner}
|
||||
useMarkdown={useMarkdown}
|
||||
{...rest}
|
||||
locks={locks}
|
||||
unlocked={rest.unlocked}
|
||||
lockContent={rest.lockContent}
|
||||
onUnlock={rest.onUnlock}
|
||||
onGoComment={rest.onGoComment}
|
||||
refType={refType}
|
||||
refId={refId}
|
||||
blockCtx={childCtx}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -76,7 +114,9 @@ function renderSegments(text, useMarkdown, rest) {
|
||||
);
|
||||
i += 1; // 跳过内容段(已作为 inner 渲染)
|
||||
} else if (p) {
|
||||
nodes.push(<RawBlock key={'text' + textIdx++} html={renderContent(p, useMarkdown)} />);
|
||||
// 文本段:仅当外层处于已解锁锁块内(parentCtx)时附件才改走鉴权接口
|
||||
const attach = parentCtx ? { refType, refId, blockIndex: parentCtx.index, token: parentCtx.token || '' } : null;
|
||||
nodes.push(<RawBlock key={'text' + textIdx++} html={renderContent(p, useMarkdown, attach)} />);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
@@ -85,15 +125,21 @@ function renderSegments(text, useMarkdown, rest) {
|
||||
/**
|
||||
* 内容渲染:useMarkdown=false 走纯文本(转义 + 标签替换 + <br>);
|
||||
* useMarkdown=true 先抽取 [image:]/[file:] 标签,marked 渲染后再还原(照搬 render.js)。
|
||||
* 本函数只处理单段文本,lock 分段在组件层完成。
|
||||
* attach(可选)= { refType, refId, blockIndex, token }:处于已解锁锁块内时的附件鉴权上下文;
|
||||
* 缺省则 [image:]/[file:] 照旧 /uploads/ 直链与下载逻辑。本函数只处理单段文本,lock 分段在组件层完成。
|
||||
*/
|
||||
export function renderContent(content, useMarkdown) {
|
||||
export function renderContent(content, useMarkdown, attach) {
|
||||
if (content == null) return '';
|
||||
|
||||
// 锁块附件地址生成器:无 attach 上下文或无 token 时返回 null → 走原直链/下载逻辑
|
||||
const lockedUrl = (filename) => (attach
|
||||
? lockedAttachmentUrl(attach.refType, attach.refId, attach.blockIndex, filename, attach.token)
|
||||
: null);
|
||||
|
||||
if (!useMarkdown) {
|
||||
let html = escapeHtml(content);
|
||||
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f));
|
||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f));
|
||||
html = html.replace(/\[image:([^\]]+)\]/g, (m, f) => imageTag(f, lockedUrl(f)));
|
||||
html = html.replace(/\[file:([^\]]+)\]/g, (m, f) => fileTag(f, lockedUrl(f)));
|
||||
html = html.replace(/\n/g, '<br>');
|
||||
return DOMPurify.sanitize(html);
|
||||
}
|
||||
@@ -106,8 +152,8 @@ export function renderContent(content, useMarkdown) {
|
||||
|
||||
html = marked.parse(html, { breaks: true, gfm: true });
|
||||
|
||||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)]));
|
||||
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)]));
|
||||
html = html.replace(/\x00IMG(\d+)\x00/g, (m, i) => imageTag(images[parseInt(i)], lockedUrl(images[parseInt(i)])));
|
||||
html = html.replace(/\x00FILE(\d+)\x00/g, (m, i) => fileTag(files[parseInt(i)], lockedUrl(files[parseInt(i)])));
|
||||
html = DOMPurify.sanitize(html);
|
||||
|
||||
// 给 h2 注入稳定锚点 id(在 HTML 字符串内生成,重渲一致,供目录锚点定位;
|
||||
@@ -125,10 +171,11 @@ function RawBlock({ html }) {
|
||||
/**
|
||||
* 构建渲染节点(两层分段):
|
||||
* 1. @@LOCK<n>@@ 占位分段(详情页 locks 非空)——lock 段:已解锁+有内容 → 递归渲染(.lock-reveal 淡入),
|
||||
* 否则 LockBlock 锁定卡片;文本段 → 再按内联 [lock:] 切段(已解锁容器 / 文本)
|
||||
* 否则 LockBlock 锁定卡片;文本段 → 再按内联 [lock:] 切段(已解锁容器 / 文本)。
|
||||
* 文本段内联块索引用全文游标(nextIdx)衔接,保证与后端 parseLocks 的 index 一致。
|
||||
* 2. locks 为空(含 previewMode 编辑预览):整段按内联 [lock:] 切段,作者视角同样显示已解锁容器
|
||||
*/
|
||||
function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode }) {
|
||||
function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }) {
|
||||
if (content == null) return null;
|
||||
const str = String(content);
|
||||
const hasLocks = Array.isArray(locks) && locks.length > 0;
|
||||
@@ -137,24 +184,33 @@ function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlo
|
||||
// 服务端已把未解锁块替换为 @@LOCK<n>@@;split 交替产出 [文本, 索引, 文本, 索引…]
|
||||
const parts = str.split(/@@LOCK(\d+)@@/);
|
||||
const nodes = [];
|
||||
let nextIdx = 0; // 全文锁块索引游标:内联块与 @@LOCK 占位按文档顺序递增
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const p = parts[i];
|
||||
if (i % 2 === 1) {
|
||||
const n = parseInt(p, 10);
|
||||
nextIdx = n + 1; // 占位符自带索引,同步游标
|
||||
const meta = locks.find((l) => l.index === n) || {};
|
||||
const type = meta.type || 'password';
|
||||
const revealed = unlocked && unlocked.has(n) && lockContent && lockContent.get(n);
|
||||
if (revealed) {
|
||||
const lockEntry = unlocked && unlocked.has(n) && lockContent && lockContent.get(n);
|
||||
if (lockEntry) {
|
||||
// lockContent 兼容纯字符串或 { content, token };token 优先取 locks 数组项(后端按块签发)
|
||||
const raw = typeof lockEntry === 'object' ? lockEntry.content : lockEntry;
|
||||
const entryToken = typeof lockEntry === 'object' && lockEntry.token ? lockEntry.token : '';
|
||||
const token = (meta.token || entryToken) || '';
|
||||
nodes.push(
|
||||
<div key={'lock' + n} className="lock-reveal">
|
||||
<MarkdownRenderer
|
||||
content={revealed}
|
||||
content={raw}
|
||||
useMarkdown={useMarkdown}
|
||||
locks={locks}
|
||||
unlocked={unlocked}
|
||||
lockContent={lockContent}
|
||||
onUnlock={onUnlock}
|
||||
onGoComment={onGoComment}
|
||||
refType={refType}
|
||||
refId={refId}
|
||||
blockCtx={{ index: n, token }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -163,27 +219,37 @@ function buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlo
|
||||
}
|
||||
} else if (p) {
|
||||
// 文本段:可能仍含内联 [lock:](已解锁块)→ 切段渲染已解锁容器
|
||||
nodes.push(...renderSegments(p, useMarkdown, { locks, unlocked, lockContent, onUnlock, onGoComment }));
|
||||
nodes.push(...renderSegments(p, useMarkdown, {
|
||||
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
|
||||
inlineStartIdx: nextIdx,
|
||||
}));
|
||||
nextIdx += countInlineLocks(p);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// locks 为空:整段按内联 [lock:] 切段(previewMode 作者预览同样显示「已解锁内容」容器)
|
||||
return renderSegments(str, useMarkdown, { locks, unlocked, lockContent, onUnlock, onGoComment });
|
||||
return renderSegments(str, useMarkdown, {
|
||||
locks, unlocked, lockContent, onUnlock, onGoComment, refType, refId, blockCtx,
|
||||
inlineStartIdx: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown 渲染器(前台统一入口):
|
||||
* props:
|
||||
* content / useMarkdown — 同原有
|
||||
* locks — [{index, type}] 详情接口返回的锁定块元信息
|
||||
* locks — [{index, type}](新后端带 {index, type, token})详情接口返回的锁定块元信息
|
||||
* unlocked — Set<number> 已解锁索引(login/reply 由服务端判定后内容直接给原文;
|
||||
* password 类解锁后内容存 lockContent)
|
||||
* lockContent — Map<number, string> password 块解锁拿到的 markdown(仅内存态)
|
||||
* lockContent — Map<number, string | {content, token}> password 块解锁拿到的 markdown(仅内存态)
|
||||
* onUnlock — async (index, password?) → 解锁;失败 throw
|
||||
* onGoComment — reply 块「去评论」滚动回调
|
||||
* previewMode — 编辑器作者预览:锁定标签直接展开
|
||||
* refType — 'blog' | 'forum'(可选):提供后锁块内 [image:]/[file:] 附件改走鉴权接口
|
||||
* refId — 详情 id(可选,配合 refType)
|
||||
* blockCtx — {index, token}(可选):当前渲染上下文处于已解锁锁块内,附件用该块的 token 鉴权
|
||||
*/
|
||||
export default function MarkdownRenderer({
|
||||
content = '',
|
||||
@@ -194,10 +260,13 @@ export default function MarkdownRenderer({
|
||||
onUnlock,
|
||||
onGoComment,
|
||||
previewMode = false,
|
||||
refType,
|
||||
refId,
|
||||
blockCtx,
|
||||
}) {
|
||||
const nodes = useMemo(
|
||||
() => buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode }),
|
||||
[content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode]
|
||||
() => buildNodes({ content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx }),
|
||||
[content, useMarkdown, locks, unlocked, lockContent, onUnlock, onGoComment, previewMode, refType, refId, blockCtx]
|
||||
);
|
||||
return <div className="md-body">{nodes}</div>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user