mindspace: close authority boundaries
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user