Files
memind/mindspace-workspace-publication-delivery-service.mjs
T
john 55100f8945
Memind CI / Test, build, and release guards (push) Failing after 2s
fix(mindspace): ensure thumbnail sidecars for WeChat link card og:image
Generate SVG/PNG sidecars at HTML delivery and after share-preview repair so
forwarded service-account links get a raster og:image when only meta exists.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 13:47:31 +08:00

587 lines
15 KiB
JavaScript

import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import {
htmlReferencesPrivateAssets,
materializePrivateAssetsInWorkspaceHtml,
resolveClosestHtmlRelativePath,
} from './mindspace-chat-save.mjs';
import {
scanWorkspaceFilesForProhibitedBrowserStorage,
} from './mindspace-browser-storage-policy.mjs';
import { getPageDeliveryContract } from './mindspace-delivery-contract.mjs';
import { renderLongImageBuffer } from './mindspace-long-image.mjs';
import { resolveMindSpacePublicRequest } from './mindspace-public-route.mjs';
import {
listRecentlyModifiedPublicHtmlRelativePaths,
} from './mindspace-run-public-html-scope.mjs';
import {
normalizeWorkspaceRelativePath,
} from './mindspace-pages.mjs';
import {
evaluatePageDataHtmlContent,
} from './mindspace-page-data-finish-guard.mjs';
import {
detectPageDataDatasetUsageFromHtml,
} from './page-data-html-detect.mjs';
import {
policyAllowsAction,
} from './page-access-policy.mjs';
import {
readPageAccessPolicy,
} from './page-data-policy-store.mjs';
import {
buildMindSpacePublicRoutePath,
resolveMindSpaceUserPublishDir,
} from './mindspace-runtime-config.mjs';
import { ensureThumbnailPng } from './mindspace-thumbnail-png.mjs';
import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.mjs';
import { PUBLIC_ZONE_DIR } from './user-publish.mjs';
function normalizeRelativePathFromRoot(root, absolutePath) {
const relativePath = path
.relative(root, absolutePath)
.replace(/\\/g, '/');
if (
!relativePath ||
relativePath === '..' ||
relativePath.startsWith('../') ||
path.posix.isAbsolute(relativePath)
) {
return null;
}
return relativePath;
}
function decodeRequestSegments(requestPath) {
const segments = [];
for (const rawSegment of String(requestPath ?? '').split('/')) {
if (!rawSegment) continue;
let segment;
try {
segment = decodeURIComponent(rawSegment);
} catch {
return null;
}
if (!segment || segment === '.' || segment === '..' || segment.includes('/') || segment.includes('\\')) {
return null;
}
segments.push(segment);
}
return segments;
}
async function statFile(statFn, targetPath) {
try {
const stat = await statFn(targetPath);
return stat?.isFile?.() ? stat : null;
} catch {
return null;
}
}
export function createMindSpaceWorkspacePublicationDeliveryService({
pool,
h5Root,
storageRoot,
logger = console,
resolvePublicRequestFn = resolveMindSpacePublicRequest,
resolveUserPublishDirFn = resolveMindSpaceUserPublishDir,
resolveClosestHtmlFn = resolveClosestHtmlRelativePath,
ensureWorkspaceThumbnailFn = ensureWorkspaceHtmlThumbnail,
getDeliveryContractFn = getPageDeliveryContract,
materializePrivateAssetsFn = materializePrivateAssetsInWorkspaceHtml,
ensureThumbnailFn = ensureThumbnailPng,
renderLongImageBufferFn = renderLongImageBuffer,
listRecentlyModifiedPublicHtmlRelativePathsFn =
listRecentlyModifiedPublicHtmlRelativePaths,
evaluatePageDataHtmlContentFn =
evaluatePageDataHtmlContent,
detectPageDataDatasetUsageFromHtmlFn =
detectPageDataDatasetUsageFromHtml,
policyAllowsActionFn = policyAllowsAction,
readPageAccessPolicyFn =
readPageAccessPolicy,
scanWorkspaceFilesForProhibitedBrowserStorageFn =
scanWorkspaceFilesForProhibitedBrowserStorage,
readFileFn = fsPromises.readFile,
statFn = fsPromises.stat,
existsSyncFn = fs.existsSync,
} = {}) {
if (!pool || typeof pool.query !== 'function') {
throw new Error(
'createMindSpaceWorkspacePublicationDeliveryService requires a database pool',
);
}
if (!h5Root || !storageRoot) {
throw new Error(
'createMindSpaceWorkspacePublicationDeliveryService requires h5Root and storageRoot',
);
}
async function resolveUsernameToUserId(username) {
const [rows] = await pool.query(
`SELECT id
FROM h5_users
WHERE LOWER(username) = ?
OR LOWER(COALESCE(slug, username)) = ?
LIMIT 1`,
[username, username],
);
return rows?.[0]?.id ? String(rows[0].id) : null;
}
async function prepareFileDelivery({
filePath,
ownerId,
publishDir,
}) {
const relativePath =
normalizeRelativePathFromRoot(
publishDir,
filePath,
);
if (!relativePath) {
return {
action: 'forbidden',
reason: 'outside_publish_root',
};
}
let readablePath = filePath;
if (
/\.thumbnail\.png$/i.test(readablePath) &&
!(await statFile(statFn, readablePath))
) {
const svgSibling = readablePath.replace(
/\.png$/i,
'.svg',
);
if (existsSyncFn(svgSibling)) {
readablePath =
ensureThumbnailFn(svgSibling) ??
readablePath;
}
}
const fileStat = await statFile(
statFn,
readablePath,
);
if (!fileStat) {
return {
action: 'not_found',
reason: 'missing_file',
ownerId,
};
}
const htmlDelivery =
/\.html$/i.test(relativePath);
if (htmlDelivery) {
const contract =
await getDeliveryContractFn({
pool,
userId: ownerId,
relativePath,
}).catch(() => null);
if (
contract &&
contract.status !== 'ready'
) {
return {
action: 'not_ready',
ownerId,
relativePath,
};
}
let html = await readFileFn(
readablePath,
'utf8',
);
if (
relativePath.startsWith(
`${PUBLIC_ZONE_DIR}/`,
) &&
htmlReferencesPrivateAssets(html)
) {
const materialized =
await materializePrivateAssetsFn({
pool,
storageRoot,
h5Root,
userId: ownerId,
html,
htmlRelativePath: relativePath,
writeBack: true,
}).catch(() => ({
html,
changed: false,
}));
if (materialized.changed) {
html = materialized.html;
}
}
const thumbnailRelativePath =
relativePath.replace(
/\.html$/i,
'.thumbnail.svg',
);
const thumbnailAbsolutePath =
path.resolve(
publishDir,
thumbnailRelativePath,
);
// WeChat link cards and forwards read og:image at serve time. Pages may
// already have share-preview meta without a raster sidecar (e.g. service
// account auto-repair); ensure the SVG exists before building fallbackImageUrl.
if (!existsSyncFn(thumbnailAbsolutePath)) {
try {
await ensureWorkspaceThumbnailFn(
publishDir,
relativePath,
html,
);
} catch (error) {
logger?.warn?.(
`[MindSpace] thumbnail sidecar ensure failed for ${relativePath}: ${
error?.message || error
}`,
);
}
}
return {
action: 'deliver',
kind: 'html',
ownerId,
relativePath,
servedName: path.posix.basename(
relativePath,
),
canonicalPath:
buildMindSpacePublicRoutePath(
ownerId,
relativePath.split('/'),
),
thumbnailName: existsSyncFn(
thumbnailAbsolutePath,
)
? path.posix
.basename(thumbnailRelativePath)
.replace(/\.svg$/i, '.png')
: null,
html,
};
}
const body = await readFileFn(readablePath);
return {
action: 'deliver',
kind: 'binary',
ownerId,
relativePath,
servedName: path.posix.basename(
relativePath,
),
canonicalPath:
buildMindSpacePublicRoutePath(
ownerId,
relativePath.split('/'),
),
bodyBase64: body.toString('base64'),
cacheControl: /\.thumbnail\.png$/i.test(
relativePath,
)
? 'public, max-age=300'
: null,
};
}
return {
async listRecentlyModifiedPublicHtml({
userId,
sinceMs,
} = {}) {
const ownerId = String(
userId ?? '',
).trim().toLowerCase();
if (!ownerId) {
return { relativePaths: [] };
}
const publishDir =
resolveUserPublishDirFn(h5Root, {
id: ownerId,
});
return {
relativePaths:
listRecentlyModifiedPublicHtmlRelativePathsFn(
publishDir,
{ sinceMs },
),
};
},
async validateRunDeliverables({
userId,
deliverables,
} = {}) {
const ownerId = String(
userId ?? '',
).trim().toLowerCase();
if (!ownerId) {
return { errors: [] };
}
const publishDir =
resolveUserPublishDirFn(h5Root, {
id: ownerId,
});
const resolvedPublishDir =
path.resolve(publishDir);
const pageDataErrors = [];
const relativePaths = [];
for (const page of
deliverables?.pages ?? []) {
const relativePath =
normalizeWorkspaceRelativePath(
page.workspaceRelativePath,
);
if (
!relativePath?.startsWith(
`${PUBLIC_ZONE_DIR}/`,
)
) {
continue;
}
relativePaths.push(relativePath);
const filePath = path.resolve(
publishDir,
relativePath,
);
if (
!filePath.startsWith(
`${resolvedPublishDir}${path.sep}`,
) ||
!(await statFile(statFn, filePath))
) {
continue;
}
const html = await readFileFn(
filePath,
'utf8',
);
const evaluation =
evaluatePageDataHtmlContentFn(
html,
{ relativePath },
);
if (!evaluation.usesPageDataApi) {
continue;
}
for (const issue of
evaluation.issues) {
pageDataErrors.push({
code: issue,
message: `${relativePath} Page Data HTML 不可交付:${issue}`,
});
}
const policy = page.pageId
? readPageAccessPolicyFn(
publishDir,
page.pageId,
)
: null;
for (const [dataset, actions] of
detectPageDataDatasetUsageFromHtmlFn(
html,
)) {
for (const action of [
'read',
'insert',
]) {
if (
actions?.[action] &&
!policyAllowsActionFn(
policy,
dataset,
action,
)
) {
pageDataErrors.push({
code:
'page_data_policy_action_missing',
message: `${relativePath}${dataset}.${action} 未获最终 policy 授权或 dataset 已关闭`,
});
}
}
}
}
const violations =
scanWorkspaceFilesForProhibitedBrowserStorageFn(
{
publishDir,
relativePaths,
},
);
return {
errors: [
...pageDataErrors,
...violations.map(
(violation) => ({
code:
'browser_storage_forbidden',
message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`,
}),
),
],
};
},
async resolveWorkspaceRequest({
requestPath,
} = {}) {
const result =
await resolvePublicRequestFn({
h5Root,
requestPath,
resolveUsernameToUserId,
resolveClosestHtmlRelativePath:
resolveClosestHtmlFn,
ensureThumbnail: async (
publishDir,
relativePath,
) => {
await ensureWorkspaceThumbnailFn(
publishDir,
relativePath,
).catch(() => {});
},
logger,
});
if (result.action !== 'serve') {
return result;
}
const ownerId = String(
result.ownerKey ?? '',
).trim().toLowerCase();
const publishDir =
resolveUserPublishDirFn(h5Root, {
id: ownerId,
});
return prepareFileDelivery({
filePath: result.filePath,
ownerId,
publishDir,
});
},
async readOwnerPublicAsset({
ownerSlug,
requestPath,
} = {}) {
const slug = String(
ownerSlug ?? '',
).trim().toLowerCase();
const segments =
decodeRequestSegments(requestPath);
if (!slug || !segments?.length) {
return {
action: 'not_found',
reason: slug
? 'missing_file'
: 'missing_owner',
};
}
const ownerId =
await resolveUsernameToUserId(slug);
if (!ownerId) {
return {
action: 'not_found',
reason: 'missing_owner',
};
}
const publishDir =
resolveUserPublishDirFn(h5Root, {
id: ownerId,
});
const filePath = path.resolve(
publishDir,
PUBLIC_ZONE_DIR,
...segments,
);
const publicRoot = path.resolve(
publishDir,
PUBLIC_ZONE_DIR,
);
if (
filePath !== publicRoot &&
!filePath.startsWith(
`${publicRoot}${path.sep}`,
)
) {
return {
action: 'forbidden',
reason: 'outside_public_root',
};
}
const fileStat = await statFile(
statFn,
filePath,
);
if (!fileStat) {
return {
action: 'not_found',
reason: 'missing_file',
ownerId,
};
}
const body = await readFileFn(filePath);
const relativePath = path.posix.join(
PUBLIC_ZONE_DIR,
...segments,
);
return {
action: 'deliver',
kind: 'binary',
ownerId,
relativePath,
servedName: path.posix.basename(
relativePath,
),
canonicalPath:
buildMindSpacePublicRoutePath(
ownerId,
relativePath.split('/'),
),
bodyBase64: body.toString('base64'),
};
},
async renderLongImage({ pageUrl } = {}) {
const url = String(pageUrl ?? '').trim();
if (!/^https?:\/\//i.test(url)) {
throw Object.assign(
new Error('缺少可渲染的页面 URL'),
{ code: 'invalid_page_url' },
);
}
const image =
await renderLongImageBufferFn({ url });
return {
bodyBase64: image.toString('base64'),
servedName:
'mindspace-public-page.long.png',
};
},
};
}
export const mindspaceWorkspacePublicationDeliveryInternals =
Object.freeze({
decodeRequestSegments,
normalizeRelativePathFromRoot,
});