Files
memind/mindspace-chat-save-service.mjs
2026-07-27 15:34:35 +08:00

344 lines
9.8 KiB
JavaScript

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,
};