diff --git a/mindspace-chat-save.mjs b/mindspace-chat-save.mjs index 396bf06..efb9ab4 100644 --- a/mindspace-chat-save.mjs +++ b/mindspace-chat-save.mjs @@ -50,6 +50,7 @@ export async function materializePrivateAssetsInWorkspaceHtml({ htmlRelativePath, writeBack = false, }) { + PRIVATE_ASSET_URL_PATTERN.lastIndex = 0; const matches = [...String(html).matchAll(PRIVATE_ASSET_URL_PATTERN)]; if (matches.length === 0) { return { html, count: 0, changed: false }; @@ -106,6 +107,55 @@ export async function materializePrivateAssetsInWorkspaceHtml({ 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 }) { const collectIds = (text) => [ ...new Set([...String(text).matchAll(PRIVATE_ASSET_URL_PATTERN)].map((match) => match[1])), diff --git a/mindspace-chat-save.test.mjs b/mindspace-chat-save.test.mjs index 38c89bc..d35c292 100644 --- a/mindspace-chat-save.test.mjs +++ b/mindspace-chat-save.test.mjs @@ -14,6 +14,7 @@ import { findPublishHtml, injectHtmlBaseHref, materializePrivateAssetsInWorkspaceHtml, + materializePrivateAssetsInPublicHtmlFiles, repairMissingHtmlAssetReferences, resolveClosestHtmlRelativePath, resolveChatSaveAnalysis, @@ -226,6 +227,44 @@ test('materializePrivateAssetsInWorkspaceHtml copies images to public/.tmp-image 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 = ``; + 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 () => { const validId = '22222222-2222-4222-8222-222222222222'; const invalidId = '33333333-3333-4333-8333-333333333333'; diff --git a/mindspace-public-finish-sync.mjs b/mindspace-public-finish-sync.mjs index d9792d8..3d5c295 100644 --- a/mindspace-public-finish-sync.mjs +++ b/mindspace-public-finish-sync.mjs @@ -1,7 +1,7 @@ import fs from 'node:fs'; 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 { scheduleWorkspaceHtmlThumbnailSidecars } from './mindspace-workspace-thumbnails.mjs'; @@ -480,6 +480,9 @@ export async function syncPublicHtmlAfterFinish({ syncWorkspaceAssets, registerPublicHtmlArtifacts, sessionId = null, + pool = null, + storageRoot = null, + h5Root = null, } = {}) { if (!hasRecentOwnPublicHtmlReference(messages, currentUser, { publishDir })) { return { materialized: [], skipped: [], synced: false }; @@ -496,6 +499,17 @@ export async function syncPublicHtmlAfterFinish({ }); const publicHtmlRelativePaths = publicHtmlArtifactRefs.map((ref) => ref.relativePath); 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; if (typeof syncWorkspaceAssets === 'function' && currentUser?.id) { await syncWorkspaceAssets(currentUser.id, { @@ -512,5 +526,13 @@ export async function syncPublicHtmlAfterFinish({ artifactRefs: publicHtmlArtifactRefs, }); } - return { materialized, skipped, synced, docxSync, publicHtmlRelativePaths, publicHtmlArtifactRefs }; + return { + materialized, + skipped, + synced, + docxSync, + publicHtmlRelativePaths, + publicHtmlArtifactRefs, + assetMaterialization, + }; } diff --git a/mindspace-public-finish-sync.test.mjs b/mindspace-public-finish-sync.test.mjs index 0ab490d..90751c5 100644 --- a/mindspace-public-finish-sync.test.mjs +++ b/mindspace-public-finish-sync.test.mjs @@ -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'), + ``, + ); + 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', () => { const publishDir = fs.mkdtempSync(path.join(os.tmpdir(), 'public-finish-sync-')); try { diff --git a/server.mjs b/server.mjs index aa055ad..6afb19e 100644 --- a/server.mjs +++ b/server.mjs @@ -116,6 +116,7 @@ import { injectHtmlBaseHref, resolveClosestHtmlRelativePath, createAssetDataUriResolver, + htmlReferencesPrivateAssets, materializePrivateAssetsInWorkspaceHtml, repairMissingHtmlAssetReferences, resolveChatSaveAnalysis, @@ -4846,6 +4847,9 @@ api.get('/sessions/:sessionId/events', async (req, res, next) => { currentUser: req.currentUser, publishDir, sessionId: sid, + pool: authPool, + storageRoot: resolveMindSpaceRuntimeConfig(__dirname, process.env).storageRoot, + h5Root: __dirname, syncWorkspaceAssets: WORKSPACE_MAINTENANCE_ENABLED && mindSpaceAssets ? (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) { if (!filePath.toLowerCase().endsWith('.html')) { res.sendFile(filePath, (err) => { @@ -5743,6 +5771,7 @@ async function sendPublishFile(req, res, filePath) { res.status(404).json({ message: '文件不存在' }); return; } + html = await ensurePublicHtmlPrivateAssetsMaterialized(filePath, html); html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET); const embed = isPlazaEmbedRequest(req.query); if (embed) {