fix(mindspace): 公开页上传图片物化到 static 路径供转发访问
Finish 同步与首次访问时将私有资产 API 图片复制到 public/.tmp-images, 避免其他用户转发链接后因无登录态无法加载页面内图片。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -50,6 +50,7 @@ export async function materializePrivateAssetsInWorkspaceHtml({
|
|||||||
htmlRelativePath,
|
htmlRelativePath,
|
||||||
writeBack = false,
|
writeBack = false,
|
||||||
}) {
|
}) {
|
||||||
|
PRIVATE_ASSET_URL_PATTERN.lastIndex = 0;
|
||||||
const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN)];
|
const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN)];
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
return { html, count: 0, changed: false };
|
return { html, count: 0, changed: false };
|
||||||
@@ -106,6 +107,55 @@ export async function materializePrivateAssetsInWorkspaceHtml({
|
|||||||
return { html: result, count, changed: true };
|
return { html: result, count, changed: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function htmlReferencesPrivateAssets(html) {
|
||||||
|
PRIVATE_ASSET_URL_PATTERN.lastIndex = 0;
|
||||||
|
const found = PRIVATE_ASSET_URL_PATTERN.test(String(html ?? ''));
|
||||||
|
PRIVATE_ASSET_URL_PATTERN.lastIndex = 0;
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function materializePrivateAssetsInPublicHtmlFiles({
|
||||||
|
pool,
|
||||||
|
storageRoot,
|
||||||
|
h5Root,
|
||||||
|
userId,
|
||||||
|
publishDir,
|
||||||
|
relativePaths = [],
|
||||||
|
}) {
|
||||||
|
const materialized = [];
|
||||||
|
const skipped = [];
|
||||||
|
const uniquePaths = [...new Set(relativePaths)].filter(Boolean);
|
||||||
|
for (const htmlRelativePath of uniquePaths) {
|
||||||
|
const htmlPath = path.join(publishDir, htmlRelativePath);
|
||||||
|
let html;
|
||||||
|
try {
|
||||||
|
html = await fs.readFile(htmlPath, 'utf8');
|
||||||
|
} catch {
|
||||||
|
skipped.push(htmlRelativePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!htmlReferencesPrivateAssets(html)) {
|
||||||
|
skipped.push(htmlRelativePath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const result = await materializePrivateAssetsInWorkspaceHtml({
|
||||||
|
pool,
|
||||||
|
storageRoot,
|
||||||
|
h5Root,
|
||||||
|
userId,
|
||||||
|
html,
|
||||||
|
htmlRelativePath,
|
||||||
|
writeBack: true,
|
||||||
|
});
|
||||||
|
if (result.changed) {
|
||||||
|
materialized.push({ relativePath: htmlRelativePath, count: result.count });
|
||||||
|
} else {
|
||||||
|
skipped.push(htmlRelativePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { materialized, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
export async function repairMissingHtmlAssetReferences({ pool, userId, sourceContent, html }) {
|
export async function repairMissingHtmlAssetReferences({ pool, userId, sourceContent, html }) {
|
||||||
const collectIds = (text) => [
|
const collectIds = (text) => [
|
||||||
...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN)].map((match) => match[1])),
|
...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN)].map((match) => match[1])),
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
findPublishHtml,
|
findPublishHtml,
|
||||||
injectHtmlBaseHref,
|
injectHtmlBaseHref,
|
||||||
materializePrivateAssetsInWorkspaceHtml,
|
materializePrivateAssetsInWorkspaceHtml,
|
||||||
|
materializePrivateAssetsInPublicHtmlFiles,
|
||||||
repairMissingHtmlAssetReferences,
|
repairMissingHtmlAssetReferences,
|
||||||
resolveClosestHtmlRelativePath,
|
resolveClosestHtmlRelativePath,
|
||||||
resolveChatSaveAnalysis,
|
resolveChatSaveAnalysis,
|
||||||
@@ -226,6 +227,44 @@ test('materializePrivateAssetsInWorkspaceHtml copies images to public/.tmp-image
|
|||||||
await fs.stat(path.join(publishDir, '.tmp-images', `${assetId}.jpg`));
|
await fs.stat(path.join(publishDir, '.tmp-images', `${assetId}.jpg`));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('materializePrivateAssetsInPublicHtmlFiles rewrites referenced public html files', async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-chat-save-materialize-batch-'));
|
||||||
|
const storageRoot = path.join(root, 'storage');
|
||||||
|
const assetId = '44444444-4444-4444-8444-444444444444';
|
||||||
|
const userId = USER_ID;
|
||||||
|
const storageKey = `users/${userId}/assets/${assetId}/versions/v1`;
|
||||||
|
const sourcePath = path.join(storageRoot, storageKey);
|
||||||
|
await fs.mkdir(path.dirname(sourcePath), { recursive: true });
|
||||||
|
await fs.writeFile(sourcePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||||
|
|
||||||
|
const publishDir = path.join(root, 'MindSpace', userId);
|
||||||
|
const htmlRelativePath = 'public/share.html';
|
||||||
|
const htmlPath = path.join(publishDir, htmlRelativePath);
|
||||||
|
await fs.mkdir(path.dirname(htmlPath), { recursive: true });
|
||||||
|
const html = `<!doctype html><img src="/api/mindspace/v1/assets/${assetId}/download?inline=1">`;
|
||||||
|
await fs.writeFile(htmlPath, html, 'utf8');
|
||||||
|
|
||||||
|
const pool = {
|
||||||
|
query: async () => [
|
||||||
|
[{ id: assetId, mime_type: 'image/jpeg', original_filename: 'hero.jpg', storage_key: storageKey }],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await materializePrivateAssetsInPublicHtmlFiles({
|
||||||
|
pool,
|
||||||
|
storageRoot,
|
||||||
|
h5Root: root,
|
||||||
|
userId,
|
||||||
|
publishDir,
|
||||||
|
relativePaths: [htmlRelativePath],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.materialized.length, 1);
|
||||||
|
assert.equal(result.materialized[0].relativePath, htmlRelativePath);
|
||||||
|
const saved = await fs.readFile(htmlPath, 'utf8');
|
||||||
|
assert.match(saved, /\.tmp-images\/44444444-4444-4444-8444-444444444444\.jpg/);
|
||||||
|
});
|
||||||
|
|
||||||
test('repairMissingHtmlAssetReferences swaps invalid html asset ids from chat message', async () => {
|
test('repairMissingHtmlAssetReferences swaps invalid html asset ids from chat message', async () => {
|
||||||
const validId = '22222222-2222-4222-8222-222222222222';
|
const validId = '22222222-2222-4222-8222-222222222222';
|
||||||
const invalidId = '33333333-3333-4333-8333-333333333333';
|
const invalidId = '33333333-3333-4333-8333-333333333333';
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { extractStaticPageLinks } from './mindspace-chat-save.mjs';
|
import { extractStaticPageLinks, materializePrivateAssetsInPublicHtmlFiles } from './mindspace-chat-save.mjs';
|
||||||
import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs';
|
import { DOWNLOADABLE_FILE_PATTERN } from './mindspace-html-download-links.mjs';
|
||||||
import { scheduleWorkspaceHtmlThumbnailSidecars } from './mindspace-workspace-thumbnails.mjs';
|
import { scheduleWorkspaceHtmlThumbnailSidecars } from './mindspace-workspace-thumbnails.mjs';
|
||||||
|
|
||||||
@@ -480,6 +480,9 @@ export async function syncPublicHtmlAfterFinish({
|
|||||||
syncWorkspaceAssets,
|
syncWorkspaceAssets,
|
||||||
registerPublicHtmlArtifacts,
|
registerPublicHtmlArtifacts,
|
||||||
sessionId = null,
|
sessionId = null,
|
||||||
|
pool = null,
|
||||||
|
storageRoot = null,
|
||||||
|
h5Root = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
|
if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) {
|
||||||
return { materialized: [], skipped: [], synced: false };
|
return { materialized: [], skipped: [], synced: false };
|
||||||
@@ -496,6 +499,17 @@ export async function syncPublicHtmlAfterFinish({
|
|||||||
});
|
});
|
||||||
const publicHtmlRelativePaths = publicHtmlArtifactRefs.map((ref) => ref.relativePath);
|
const publicHtmlRelativePaths = publicHtmlArtifactRefs.map((ref) => ref.relativePath);
|
||||||
const sourceMessageId = publicHtmlArtifactRefs.find((ref) => ref.messageId)?.messageId ?? null;
|
const sourceMessageId = publicHtmlArtifactRefs.find((ref) => ref.messageId)?.messageId ?? null;
|
||||||
|
let assetMaterialization = { materialized: [], skipped: [] };
|
||||||
|
if (pool && storageRoot && h5Root && currentUser?.id && publicHtmlRelativePaths.length > 0) {
|
||||||
|
assetMaterialization = await materializePrivateAssetsInPublicHtmlFiles({
|
||||||
|
pool,
|
||||||
|
storageRoot,
|
||||||
|
h5Root,
|
||||||
|
userId: currentUser.id,
|
||||||
|
publishDir,
|
||||||
|
relativePaths: publicHtmlRelativePaths,
|
||||||
|
}).catch(() => ({ materialized: [], skipped: publicHtmlRelativePaths }));
|
||||||
|
}
|
||||||
let synced = false;
|
let synced = false;
|
||||||
if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) {
|
if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) {
|
||||||
await syncWorkspaceAssets(currentUser.id, {
|
await syncWorkspaceAssets(currentUser.id, {
|
||||||
@@ -512,5 +526,13 @@ export async function syncPublicHtmlAfterFinish({
|
|||||||
artifactRefs: publicHtmlArtifactRefs,
|
artifactRefs: publicHtmlArtifactRefs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { materialized, skipped, synced, docxSync, publicHtmlRelativePaths, publicHtmlArtifactRefs };
|
return {
|
||||||
|
materialized,
|
||||||
|
skipped,
|
||||||
|
synced,
|
||||||
|
docxSync,
|
||||||
|
publicHtmlRelativePaths,
|
||||||
|
publicHtmlArtifactRefs,
|
||||||
|
assetMaterialization,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,6 +249,58 @@ test('syncPublicHtmlAfterFinish registers existing public html artifacts for the
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('syncPublicHtmlAfterFinish materializes private image assets into public html files', async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-assets-'));
|
||||||
|
const publishDir = path.join(root, 'MindSpace', CURRENT_USER.id);
|
||||||
|
const storageRoot = path.join(root, 'storage');
|
||||||
|
const assetId = '55555555-5555-4555-8555-555555555555';
|
||||||
|
const storageKey = `users/${CURRENT_USER.id}/assets/${assetId}/versions/v1`;
|
||||||
|
fs.mkdirSync(path.dirname(path.join(storageRoot, storageKey)), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(storageRoot, storageKey), Buffer.from([0xff, 0xd8, 0xff, 0xd9]));
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.join(publishDir, 'public'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(publishDir, 'public/tea.html'),
|
||||||
|
`<!doctype html><img src="/api/mindspace/v1/assets/${assetId}/download?inline=1">`,
|
||||||
|
);
|
||||||
|
const messages = [
|
||||||
|
{
|
||||||
|
id: 'msg-tea-1',
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'text',
|
||||||
|
text: `[茶馆](https://m.tkmind.cn/MindSpace/${CURRENT_USER.id}/public/tea.html)`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const pool = {
|
||||||
|
query: async () => [
|
||||||
|
[{ id: assetId, mime_type: 'image/jpeg', original_filename: 'tea.jpg', storage_key: storageKey }],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await syncPublicHtmlAfterFinish({
|
||||||
|
messages,
|
||||||
|
currentUser: CURRENT_USER,
|
||||||
|
publishDir,
|
||||||
|
sessionId: 'session-tea',
|
||||||
|
pool,
|
||||||
|
storageRoot,
|
||||||
|
h5Root: root,
|
||||||
|
syncWorkspaceAssets: async () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.assetMaterialization.materialized.length, 1);
|
||||||
|
const saved = fs.readFileSync(path.join(publishDir, 'public/tea.html'), 'utf8');
|
||||||
|
assert.match(saved, /\.tmp-images\/55555555-5555-4555-8555-555555555555\.jpg/);
|
||||||
|
assert.ok(fs.existsSync(path.join(publishDir, 'public/.tmp-images', `${assetId}.jpg`)));
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('collectOwnPublicHtmlArtifactRefs includes source message ids when available', () => {
|
test('collectOwnPublicHtmlArtifactRefs includes source message ids when available', () => {
|
||||||
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-'));
|
||||||
try {
|
try {
|
||||||
|
|||||||
+29
@@ -116,6 +116,7 @@ import {
|
|||||||
injectHtmlBaseHref,
|
injectHtmlBaseHref,
|
||||||
resolveClosestHtmlRelativePath,
|
resolveClosestHtmlRelativePath,
|
||||||
createAssetDataUriResolver,
|
createAssetDataUriResolver,
|
||||||
|
htmlReferencesPrivateAssets,
|
||||||
materializePrivateAssetsInWorkspaceHtml,
|
materializePrivateAssetsInWorkspaceHtml,
|
||||||
repairMissingHtmlAssetReferences,
|
repairMissingHtmlAssetReferences,
|
||||||
resolveChatSaveAnalysis,
|
resolveChatSaveAnalysis,
|
||||||
@@ -4846,6 +4847,9 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => {
|
|||||||
currentUser: req.currentUser,
|
currentUser: req.currentUser,
|
||||||
publishDir,
|
publishDir,
|
||||||
sessionId: sid,
|
sessionId: sid,
|
||||||
|
pool: authPool,
|
||||||
|
storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot,
|
||||||
|
h5Root: __dirname,
|
||||||
syncWorkspaceAssets:
|
syncWorkspaceAssets:
|
||||||
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
|
WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets
|
||||||
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
|
? (userId, options) => mindSpaceAssets.syncWorkspaceAssets(userId, options)
|
||||||
@@ -5728,6 +5732,30 @@ async function sendLongImageDownloadIfRequested(req, res, filePath) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensurePublicHtmlPrivateAssetsMaterialized(filePath, html) {
|
||||||
|
if (!authPool || !htmlReferencesPrivateAssets(html)) return html;
|
||||||
|
const normalized = path.resolve(String(filePath ?? ''));
|
||||||
|
const ownerMatch = normalized.match(
|
||||||
|
new RegExp(`[\\\\/]${PUBLISH_ROOT_DIR}[\\\\/]([0-9a-f-]{36})[\\\\/]`, 'i'),
|
||||||
|
);
|
||||||
|
const userId = ownerMatch?.[1] ?? null;
|
||||||
|
if (!userId) return html;
|
||||||
|
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
|
||||||
|
const htmlRelativePath = path.relative(publishDir, normalized).replace(/\\/g, '/');
|
||||||
|
if (!htmlRelativePath.startsWith(`${PUBLIC_ZONE_DIR}/`)) return html;
|
||||||
|
const { storageRoot } = resolveMindSpaceRuntimeConfig(__dirname, process.env);
|
||||||
|
const result = await materializePrivateAssetsInWorkspaceHtml({
|
||||||
|
pool: authPool,
|
||||||
|
storageRoot,
|
||||||
|
h5Root: __dirname,
|
||||||
|
userId,
|
||||||
|
html,
|
||||||
|
htmlRelativePath,
|
||||||
|
writeBack: true,
|
||||||
|
}).catch(() => ({ html, changed: false }));
|
||||||
|
return result.changed ? result.html : html;
|
||||||
|
}
|
||||||
|
|
||||||
async function sendPublishFile(req, res, filePath) {
|
async function sendPublishFile(req, res, filePath) {
|
||||||
if (!filePath.toLowerCase().endsWith('.html')) {
|
if (!filePath.toLowerCase().endsWith('.html')) {
|
||||||
res.sendFile(filePath, (err) => {
|
res.sendFile(filePath, (err) => {
|
||||||
@@ -5743,6 +5771,7 @@ async function sendPublishFile(req, res, filePath) {
|
|||||||
res.status(404).json({ message: '文件不存在' });
|
res.status(404).json({ message: '文件不存在' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
html = await ensurePublicHtmlPrivateAssetsMaterialized(filePath, html);
|
||||||
html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET);
|
html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET);
|
||||||
const embed = isPlazaEmbedRequest(req.query);
|
const embed = isPlazaEmbedRequest(req.query);
|
||||||
if (embed) {
|
if (embed) {
|
||||||
|
|||||||
Reference in New Issue
Block a user