import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { extractImageAssetKey } from './chat-image-turn-scope.mjs'; const WECHAT_MP_PUBLIC_PATH_RE = /\/public\/(wechat-mp\/[^?#\s"'<>]+)/i; const MINDSPACE_PUBLIC_PATH_RE = /\/MindSpace\/[0-9a-f-]{36}\/public\/(wechat-mp\/[^?#\s"'<>]+|images\/[^?#\s"'<>]+)/i; const HTML_IMG_SRC_RE = /\bsrc\s*=\s*["']([^"']+)["']/gi; const HTML_COVER_JSON_RE = /name=["']mindspace-cover["']\s+content=['"]([^'"]+)['"]/i; function formatUtcDateStamp(date = new Date()) { return date.toISOString().slice(0, 10); } export function extractPublicZoneRelativePath(rawUrl, userId = '') { const value = String(rawUrl ?? '').trim(); if (!value) return null; const mindspaceMatch = value.match(MINDSPACE_PUBLIC_PATH_RE); if (mindspaceMatch?.[1]) { return `public/${mindspaceMatch[1]}`; } const wechatMatch = value.match(WECHAT_MP_PUBLIC_PATH_RE); if (wechatMatch?.[1]) { return `public/${wechatMatch[1]}`; } if (value.startsWith('public/wechat-mp/') || value.startsWith('public/images/')) { return value; } if (value.startsWith('wechat-mp/') || value.startsWith('images/')) { return `public/${value}`; } try { const parsed = new URL( value, value.startsWith('/') ? 'http://local' : undefined, ); const pathname = parsed.pathname; const publicIndex = pathname.indexOf('/public/'); if (publicIndex >= 0) { const tail = pathname.slice(publicIndex + '/public/'.length); if (tail.startsWith('wechat-mp/') || tail.startsWith('images/')) { return `public/${tail}`; } } } catch { return null; } void userId; return null; } export function materializeWechatPublicImageForEmbed({ publishDir, rawUrl, buffer = null, mimeType = 'image/jpeg', now = new Date(), } = {}) { const publicRelativePath = extractPublicZoneRelativePath(rawUrl); if (!publicRelativePath || !String(publishDir ?? '').trim()) { return null; } const sourceAbs = path.join(publishDir, publicRelativePath); let sourceBuffer = buffer; if (!sourceBuffer) { try { if (!fs.existsSync(sourceAbs) || !fs.statSync(sourceAbs).isFile()) { return null; } sourceBuffer = fs.readFileSync(sourceAbs); if (!mimeType) mimeType = 'image/jpeg'; } catch { return null; } } if (!Buffer.isBuffer(sourceBuffer) || sourceBuffer.length === 0) { return null; } if (publicRelativePath.startsWith('public/images/')) { const embedPath = publicRelativePath.slice('public/'.length); return { publicRelativePath, relativeEmbedPath: embedPath, embedUrl: embedPath, assetKeys: collectEmbedAssetKeys([embedPath, publicRelativePath, rawUrl]), materialized: false, }; } if (!publicRelativePath.startsWith('public/wechat-mp/')) { return null; } const dateDir = formatUtcDateStamp(now); const hash = crypto.createHash('md5').update(sourceBuffer).digest('hex').slice(0, 8); const basename = path.posix.basename(publicRelativePath); const destPublicRelativePath = `public/images/${dateDir}/${hash}-${basename}`; const destAbs = path.join(publishDir, destPublicRelativePath); try { fs.mkdirSync(path.dirname(destAbs), { recursive: true }); if (!fs.existsSync(destAbs)) { fs.writeFileSync(destAbs, sourceBuffer); } } catch { return null; } const embedPath = destPublicRelativePath.slice('public/'.length); return { publicRelativePath: destPublicRelativePath, relativeEmbedPath: embedPath, embedUrl: embedPath, assetKeys: collectEmbedAssetKeys([embedPath, destPublicRelativePath, rawUrl]), materialized: true, mimeType, }; } export function collectEmbedAssetKeys(values = []) { const keys = new Set(); for (const value of values) { const key = extractImageAssetKey(value); if (key) keys.add(key); } return keys; } export function buildAllowedPageImageEmbedKeys({ publishDir, imageUrls = [], userId = '', } = {}) { const allowed = new Set(); for (const rawUrl of imageUrls) { const materialized = materializeWechatPublicImageForEmbed({ publishDir, rawUrl, userId, }); if (materialized?.assetKeys) { for (const key of materialized.assetKeys) allowed.add(key); } else { for (const key of collectEmbedAssetKeys([rawUrl])) allowed.add(key); } } return allowed; } export function extractHtmlImageSourceKeys(html) { const keys = new Set(); const value = String(html ?? ''); if (!value) return keys; for (const match of value.matchAll(HTML_IMG_SRC_RE)) { for (const key of collectEmbedAssetKeys([match[1]])) keys.add(key); } const coverMatch = value.match(HTML_COVER_JSON_RE); if (coverMatch?.[1]) { try { const parsed = JSON.parse( coverMatch[1] .replace(/"/g, '"') .replace(/'/g, "'"), ); for (const key of collectEmbedAssetKeys([parsed?.cover, parsed?.image])) { if (key) keys.add(key); } } catch { for (const key of collectEmbedAssetKeys([coverMatch[1]])) keys.add(key); } } return keys; } export function verifyHtmlImageSourcesAllowed(html, allowedKeys) { const allowed = allowedKeys instanceof Set ? allowedKeys : new Set(allowedKeys); if (allowed.size === 0) { return { ok: true, reason: null, offendingKeys: [] }; } const found = extractHtmlImageSourceKeys(html); if (found.size === 0) { return { ok: false, reason: 'missing_required_images', offendingKeys: [], }; } const offendingKeys = [...found].filter((key) => !allowed.has(key)); if (offendingKeys.length > 0) { return { ok: false, reason: 'stale_image_source', offendingKeys, }; } return { ok: true, reason: null, offendingKeys: [] }; } export const chatImageMaterializeInternals = { formatUtcDateStamp, HTML_IMG_SRC_RE, };