344 lines
9.8 KiB
JavaScript
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,
|
|
};
|