diff --git a/tkmind-proxy.mjs b/tkmind-proxy.mjs index 418000d..bc6b961 100644 --- a/tkmind-proxy.mjs +++ b/tkmind-proxy.mjs @@ -453,6 +453,9 @@ const PUBLIC_HTML_LINK_PATTERN = /https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html)/gi; const PUBLIC_HTML_MARKDOWN_LINK_PATTERN = /\[([^\]\n]*)\]\((https?:\/\/[^\s<>"')\]]+\/MindSpace\/([0-9a-f-]{36}|[a-z0-9._-]+)\/([^\s<>"')\]]+\.html))\)/gi; +const MARKDOWN_LINK_PATTERN = /\[([^\]\n]*)\]\((https?:\/\/[^\s<>"')\]]+)\)/g; +const PLAIN_HTML_URL_PATTERN = /https?:\/\/[^\s<>"')\]]+\.html/gi; +const INLINE_PUBLIC_HTML_PATH_PATTERN = /`((?:\.\/)?public\/[^`\s<>"')\]]+\.html)`/gi; const USER_IDENTITY_BLOCK_PATTERN = /^\[用户身份\][\s\S]*?(?:\n{2,}|$)/; const IMAGE_URL_LINES_PATTERN = /\n*\[图片\d+]: [^\n]+/g; const TKMIND_VISION_NOTE_PATTERN = /\n*【TKMind 图片分析结果[\s\S]*$/; @@ -524,6 +527,48 @@ function sanitizeOwnPublicHtmlUrl(publicUrl, owner, rawRelativePath, currentUser }; } +function isCurrentUserPublicOwner(owner, currentUser) { + const normalizedOwner = String(owner ?? '').trim().toLowerCase(); + const normalizedUserId = String(currentUser?.id ?? '').trim().toLowerCase(); + const normalizedUsername = String(currentUser?.username ?? '').trim().toLowerCase(); + return Boolean(normalizedOwner && ( + normalizedOwner === normalizedUserId || + normalizedOwner === normalizedUsername + )); +} + +function sanitizeMissingMindSpacePublicHtmlUrl(url, currentUser) { + let parsed; + try { + parsed = new URL(url); + } catch { + return null; + } + const parts = parsed.pathname + .split('/') + .filter(Boolean) + .map((part) => decodePathSegment(part)); + if (parts[0]?.toLowerCase() === 'mindspace') return null; + const owner = parts[0]; + if (!isCurrentUserPublicOwner(owner, currentUser)) return null; + const rawRelativePath = parts.slice(1).join('/'); + if (!rawRelativePath.toLowerCase().endsWith('.html')) return null; + const result = sanitizeOwnPublicHtmlUrl(url, owner, rawRelativePath, currentUser); + return result.ok ? result.url : result.notice; +} + +function sanitizeOwnPublicHtmlRelativePath(rawRelativePath, currentUser) { + const owner = currentUser?.id || currentUser?.username; + if (!owner) return null; + const normalizedRelativePath = normalizeStaticHtmlRelativePath(rawRelativePath); + if (!normalizedRelativePath.toLowerCase().endsWith('.html')) return null; + if (!publicHtmlExistsForUser(owner, normalizedRelativePath, currentUser)) return null; + return { + label: path.posix.basename(normalizedRelativePath), + url: buildPublicUrl(resolvePublicBaseUrl(), owner, normalizedRelativePath), + }; +} + export function sanitizePublicHtmlLinksInText(text, currentUser) { let next = String(text ?? '').replace( PUBLIC_HTML_MARKDOWN_LINK_PATTERN, @@ -533,10 +578,22 @@ export function sanitizePublicHtmlLinksInText(text, currentUser) { return label ? `[${label}](${result.url})` : result.url; }, ); - return next.replace(PUBLIC_HTML_LINK_PATTERN, (match, owner, rawRelativePath) => { + next = next.replace(PUBLIC_HTML_LINK_PATTERN, (match, owner, rawRelativePath) => { const result = sanitizeOwnPublicHtmlUrl(match, owner, rawRelativePath, currentUser); return result.ok ? result.url : result.notice; }); + next = next.replace(MARKDOWN_LINK_PATTERN, (match, label, url) => { + const sanitizedUrl = sanitizeMissingMindSpacePublicHtmlUrl(url, currentUser); + if (!sanitizedUrl) return match; + return label ? `[${label}](${sanitizedUrl})` : sanitizedUrl; + }); + next = next.replace(PLAIN_HTML_URL_PATTERN, (match) => ( + sanitizeMissingMindSpacePublicHtmlUrl(match, currentUser) ?? match + )); + return next.replace(INLINE_PUBLIC_HTML_PATH_PATTERN, (match, rawRelativePath) => { + const result = sanitizeOwnPublicHtmlRelativePath(rawRelativePath, currentUser); + return result ? `[${result.label}](${result.url})` : match; + }); } export function sanitizeUserVisibleMessageText(text, currentUser) { diff --git a/tkmind-proxy.test.mjs b/tkmind-proxy.test.mjs index 48b2c82..463595f 100644 --- a/tkmind-proxy.test.mjs +++ b/tkmind-proxy.test.mjs @@ -216,6 +216,59 @@ test('sanitizePublicHtmlLinksInText canonicalizes wrong MindSpace public hosts', } }); +test('sanitizePublicHtmlLinksInText canonicalizes public html links missing MindSpace segment', () => { + const owner = `test-user-${Date.now()}-missing-mindspace`; + const previousBase = process.env.H5_PUBLIC_BASE_URL; + process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn'; + const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'kuanting-plan.html'); + try { + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, '宽庭'); + const text = + `[宽庭方案](https://mindspace.tkmind.com/${owner}/public/kuanting-plan.html)`; + const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john' }); + assert.doesNotMatch(next, /mindspace\.tkmind\.com/); + assert.match( + next, + new RegExp(`https://m\\.tkmind\\.cn/MindSpace/${owner}/public/kuanting-plan\\.html`), + ); + assert.doesNotMatch(next, /页面生成未完成/); + } finally { + if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL; + else process.env.H5_PUBLIC_BASE_URL = previousBase; + fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true }); + } +}); + +test('sanitizePublicHtmlLinksInText leaves other users missing-MindSpace links unchanged', () => { + const owner = `test-user-${Date.now()}-other-missing-mindspace`; + const text = `[外部页面](https://mindspace.tkmind.com/${owner}/public/kuanting-plan.html)`; + const next = sanitizePublicHtmlLinksInText(text, { id: `current-${owner}`, username: 'john' }); + assert.equal(next, text); +}); + +test('sanitizePublicHtmlLinksInText links existing inline public html paths', () => { + const owner = `test-user-${Date.now()}-inline-public-path`; + const previousBase = process.env.H5_PUBLIC_BASE_URL; + process.env.H5_PUBLIC_BASE_URL = 'https://m.tkmind.cn'; + const htmlPath = path.join(process.cwd(), 'MindSpace', owner, 'public', 'thailand-travel-guide.html'); + try { + fs.mkdirSync(path.dirname(htmlPath), { recursive: true }); + fs.writeFileSync(htmlPath, 'Thailand'); + const text = '攻略页面已成功创建!文件位于 **`public/thailand-travel-guide.html`**(约 37KB)。'; + const next = sanitizePublicHtmlLinksInText(text, { id: owner, username: 'john2' }); + assert.doesNotMatch(next, /`public\/thailand-travel-guide\.html`/); + assert.match( + next, + new RegExp(`\\[thailand-travel-guide\\.html\\]\\(https://m\\.tkmind\\.cn/MindSpace/${owner}/public/thailand-travel-guide\\.html\\)`), + ); + } finally { + if (previousBase == null) delete process.env.H5_PUBLIC_BASE_URL; + else process.env.H5_PUBLIC_BASE_URL = previousBase; + fs.rmSync(path.join(process.cwd(), 'MindSpace', owner), { recursive: true, force: true }); + } +}); + test('sanitizeSessionConversationPublicHtmlLinks updates text items inside conversation messages', () => { const owner = `test-user-${Date.now()}-conversation`; const publicRoot = path.join(process.cwd(), 'MindSpace', owner, 'public');