import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { createAssetDataUriResolver, materializePrivateAssetsInWorkspaceHtml, repairMissingHtmlAssetReferences, resolvePublishHtmlAbsolutePath, } from './mindspace-chat-save.mjs'; import { inlinePrivateAssetsInHtml } from './mindspace-pages.mjs'; import { rewriteWorkspacePublicAssetReferences } from './mindspace-publications.mjs'; import { buildMindSpacePublicUrlForUser, resolveMindSpaceUserPublishDir, } from './mindspace-runtime-config.mjs'; import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs'; import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath, } from './mindspace-workspace-thumbnails.mjs'; import { normalizeWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs'; function chatSaveError(message, code) { return Object.assign(new Error(message), { code }); } function normalizePublicHtmlRelativePath(value) { const raw = String(value ?? '').trim().replace(/\\/g, '/'); if ( !raw || raw .replace(/^\/+/, '') .split('/') .some((segment) => segment === '.' || segment === '..') ) { throw chatSaveError('无效的页面路径', 'invalid_page_path'); } const relativePath = normalizeWorkspaceRelativePath(raw); if ( !relativePath.startsWith('public/') || path.posix.extname(relativePath).toLowerCase() !== '.html' ) { throw chatSaveError('聊天页面必须位于 public 目录', 'invalid_page_path'); } return relativePath; } export function createMindSpaceChatSaveService({ pool, h5Root, storageRoot, env = process.env, repairMissingHtmlAssetReferencesFn = repairMissingHtmlAssetReferences, materializePrivateAssetsInWorkspaceHtmlFn = materializePrivateAssetsInWorkspaceHtml, resolvePublishHtmlAbsolutePathFn = resolvePublishHtmlAbsolutePath, resolveMindSpaceUserPublishDirFn = resolveMindSpaceUserPublishDir, ensureWorkspaceHtmlThumbnailFn = ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePathFn = workspaceThumbnailRelativePath, createAssetDataUriResolverFn = createAssetDataUriResolver, generateHtmlThumbnailFn = generateHtmlThumbnail, inlinePrivateAssetsInHtmlFn = inlinePrivateAssetsInHtml, rewriteWorkspacePublicAssetReferencesFn = rewriteWorkspacePublicAssetReferences, buildMindSpacePublicUrlForUserFn = buildMindSpacePublicUrlForUser, mkdirFn = fs.mkdir, readFileFn = fs.readFile, writeFileFn = fs.writeFile, randomUUIDFn = crypto.randomUUID, } = {}) { if (!pool || typeof pool.query !== 'function') { throw new Error( 'createMindSpaceChatSaveService requires a database pool', ); } if (!h5Root || !storageRoot) { throw new Error( 'createMindSpaceChatSaveService requires h5Root and storageRoot', ); } return { async readWorkspaceHtml({ userId, relativePath, } = {}) { const ownerId = String(userId ?? '').trim().toLowerCase(); if (!ownerId) { throw chatSaveError( '缺少页面用户', 'invalid_page_input', ); } const normalizedRelativePath = normalizePublicHtmlRelativePath(relativePath); const absolutePath = resolvePublishHtmlAbsolutePathFn( h5Root, ownerId, normalizedRelativePath, ); let html; try { html = await readFileFn(absolutePath, 'utf8'); } catch { throw chatSaveError( '页面文件不存在', 'static_page_not_found', ); } return { html, relativePath: normalizedRelativePath, }; }, async createSharedHtml({ userId, html, sourceFilename = 'page.html', } = {}) { const ownerId = String(userId ?? '').trim().toLowerCase(); if (!ownerId || typeof html !== 'string' || !html.trim()) { throw chatSaveError('缺少分享页面内容', 'invalid_page_input'); } const { html: localizedHtml } = await inlinePrivateAssetsInHtmlFn( pool, storageRoot, ownerId, html, ); const basename = path.posix .basename( String(sourceFilename ?? 'page.html') .replace(/\\/g, '/'), ) .replace(/\.html$/i, '') .replace(/[^a-zA-Z0-9._-]+/g, '-') .replace(/^-+|-+$/g, '') .slice(0, 80) || 'page'; const filename = `${basename}-${randomUUIDFn().slice(0, 8)}.html`; const relativePath = `public/shared/${filename}`; const publishDir = resolveMindSpaceUserPublishDirFn( h5Root, { id: ownerId }, ); const destination = path.resolve( publishDir, relativePath, ); if ( destination !== publishDir && !destination.startsWith( `${publishDir}${path.sep}`, ) ) { throw chatSaveError( '无效的分享页面路径', 'invalid_page_path', ); } const sharedHtml = rewriteWorkspacePublicAssetReferencesFn( localizedHtml, relativePath, ); await mkdirFn(path.dirname(destination), { recursive: true, }); await writeFileFn(destination, sharedHtml, 'utf8'); return { publicUrl: buildMindSpacePublicUrlForUserFn({ h5Root, env, user: ownerId, relativePath, }), filename, relativePath, }; }, async materializeWorkspaceHtml({ userId, sourceContent = '', html, relativePath, } = {}) { const ownerId = String(userId ?? '').trim().toLowerCase(); if (!ownerId || typeof html !== 'string' || !html.trim()) { throw chatSaveError('缺少聊天页面内容', 'invalid_page_input'); } const normalizedRelativePath = normalizePublicHtmlRelativePath(relativePath); const repaired = await repairMissingHtmlAssetReferencesFn({ pool, userId: ownerId, sourceContent, html, }).catch(() => ({ html, changed: false })); const repairedHtml = typeof repaired?.html === 'string' ? repaired.html : html; const materialized = await materializePrivateAssetsInWorkspaceHtmlFn({ pool, storageRoot, h5Root, userId: ownerId, html: repairedHtml, htmlRelativePath: normalizedRelativePath, writeBack: false, }).catch(() => ({ html: repairedHtml, count: 0, changed: false, })); const materializedHtml = typeof materialized?.html === 'string' ? materialized.html : repairedHtml; const changed = materializedHtml !== html; if (changed) { const absolutePath = resolvePublishHtmlAbsolutePathFn( h5Root, ownerId, normalizedRelativePath, ); await writeFileFn(absolutePath, materializedHtml, 'utf8'); } return { html: materializedHtml, relativePath: normalizedRelativePath, changed, repairedAssetReferences: repaired?.changed === true, materializedAssetCount: Number(materialized?.count ?? 0), }; }, async ensurePreviewThumbnail({ userId, relativePath, html, title = '', subtitle = '', force = false, } = {}) { const ownerId = String(userId ?? '').trim().toLowerCase(); if (!ownerId || typeof html !== 'string' || !html.trim()) { throw chatSaveError('缺少聊天页面内容', 'invalid_page_input'); } const normalizedRelativePath = normalizePublicHtmlRelativePath(relativePath); const publishDir = resolveMindSpaceUserPublishDirFn(h5Root, { id: ownerId, }); await ensureWorkspaceHtmlThumbnailFn( publishDir, normalizedRelativePath, html, { title: String(title ?? '').trim(), subtitle: String(subtitle ?? '').trim(), force: Boolean(force), }, ); return { relativePath: normalizedRelativePath, ready: true, }; }, async renderPreviewThumbnailSvg({ userId, relativePath, html, title = '', subtitle = '', force = false, } = {}) { const ownerId = String(userId ?? '').trim().toLowerCase(); if (!ownerId || typeof html !== 'string' || !html.trim()) { throw chatSaveError('缺少聊天页面内容', 'invalid_page_input'); } const normalizedRelativePath = normalizePublicHtmlRelativePath(relativePath); const publishDir = resolveMindSpaceUserPublishDirFn(h5Root, { id: ownerId, }); const thumbRel = workspaceThumbnailRelativePathFn( normalizedRelativePath, ); const htmlPath = path.join(publishDir, normalizedRelativePath); await ensureWorkspaceHtmlThumbnailFn( publishDir, normalizedRelativePath, html, { title: String(title ?? '').trim(), subtitle: String(subtitle ?? '').trim(), force: Boolean(force), }, ).catch(() => {}); const resolveAssetDataUri = createAssetDataUriResolverFn( pool, storageRoot, ownerId, ); const svg = await generateHtmlThumbnailFn( publishDir, thumbRel, html, { title: String(title ?? '').trim(), subtitle: String(subtitle ?? '').trim(), contentBaseDir: path.dirname(htmlPath), resolveAssetDataUri, force: Boolean(force), }, ); return { svg, contentType: 'image/svg+xml; charset=utf-8', relativePath: normalizedRelativePath, }; }, }; } export const mindspaceChatSaveServiceInternals = { normalizePublicHtmlRelativePath, };