760a1760ae
Memind CI / Test, build, and release guards (push) Successful in 6m58s
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>
206 lines
5.8 KiB
JavaScript
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(/"/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,
|
|
};
|