55100f8945
Memind CI / Test, build, and release guards (push) Failing after 2s
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>
587 lines
15 KiB
JavaScript
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,
|
|
});
|