Files
memind/chat-image-materialize.mjs
T
john 760a1760ae
Memind CI / Test, build, and release guards (push) Successful in 6m58s
fix(wechat): enforce current-turn image paths in page delivery
Materialize wechat-mp uploads into dated public/images paths during vision
preprocessing and fail closed when delivered HTML references stale workspace
images instead of the current turn allowlist.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-27 12:54:46 +08:00

206 lines
5.8 KiB
JavaScript

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(/&quot;/g, '"')
.replace(/&#39;/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,
};