Files
memind/server/portal-workspace-publication-delivery.mjs
2026-07-25 09:46:10 +08:00

534 lines
12 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import {
ensureWorkspaceHtmlThumbnail,
} from '../mindspace-workspace-thumbnails.mjs';
import {
injectMindSpaceAnalytics,
resolveAnalyticsOwnerLabel,
resolveAnalyticsOwnerSegment,
resolveAnalyticsPlan,
} from '../mindspace-analytics.mjs';
import {
injectMindSpaceRybbit,
} from '../mindspace-rybbit.mjs';
import {
resolveMindSpacePublicRequest,
} from '../mindspace-public-route.mjs';
import {
buildPublishedHtmlViewContext,
parseMindSpacePublishFilePath,
resolveMindSpacePageDataContext,
} from '../mindspace-public-page-context.mjs';
import {
preparePublicHtmlAssetDelivery,
} from '../mindspace-public-asset-token.mjs';
import {
decorateMindSpacePublishedHtml,
handleMindSpaceLongImageDownload,
} from '../mindspace-public-delivery.mjs';
import {
PUBLIC_ZONE_DIR,
PUBLISH_ROOT_DIR,
} from '../user-publish.mjs';
import {
resolveMindSpaceRuntimeConfig,
resolveMindSpaceUserPublishDir,
} from '../mindspace-runtime-config.mjs';
import {
htmlReferencesPrivateAssets,
materializePrivateAssetsInWorkspaceHtml,
resolveClosestHtmlRelativePath,
} from '../mindspace-chat-save.mjs';
import {
getPageDeliveryContract,
} from '../mindspace-delivery-contract.mjs';
import {
injectPublicFileShareButton,
} from '../mindspace-public-share-widget.mjs';
import {
allowPlazaEmbedFrame,
isPlazaEmbedRequest,
preparePublicationHtmlForEmbed,
} from '../plaza-embed.mjs';
import {
publishedPageCsp,
} from '../mindspace-published-page-csp.mjs';
import {
rewriteKnownCdnScriptSources,
} from '../mindspace-published-script-localize.mjs';
import {
injectOgTags,
injectWechatShareBridge,
} from '../mindspace-og-tags.mjs';
import {
ensureThumbnailPng,
thumbnailPngPathForSvg,
} from '../mindspace-thumbnail-png.mjs';
import {
isLongImageDownloadRequest,
longImagePathForHtml,
renderLongImage,
} from '../mindspace-long-image.mjs';
import { isWechatUserAgent } from '../wechat-oauth.mjs';
export function createPortalWorkspacePublicationDelivery({
h5Root,
internalAgentSecret,
analyticsConfig,
rybbitConfig,
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
resolveRequestOrigin,
removeQueryParam,
processEnv = process.env,
logger = console,
fsApi = fs,
pathApi = path,
resolvePublicRequest =
resolveMindSpacePublicRequest,
resolveUserPublishDir =
resolveMindSpaceUserPublishDir,
resolveRuntimeConfig =
resolveMindSpaceRuntimeConfig,
resolveClosestHtml =
resolveClosestHtmlRelativePath,
ensureWorkspaceThumbnail =
ensureWorkspaceHtmlThumbnail,
resolvePageDataContext =
resolveMindSpacePageDataContext,
getDeliveryContract =
getPageDeliveryContract,
ensureThumbnail = ensureThumbnailPng,
} = {}) {
if (
!h5Root ||
typeof resolveRequestOrigin !== 'function' ||
typeof removeQueryParam !== 'function'
) {
throw new Error(
'createPortalWorkspacePublicationDelivery requires delivery dependencies',
);
}
async function sendLongImageDownloadIfRequested(
req,
res,
filePath,
) {
const origin = resolveRequestOrigin(req);
const currentUrl = origin
? `${origin}${
req.originalUrl || req.url || ''
}`
: null;
return handleMindSpaceLongImageDownload({
query: req.query,
filePath,
pageUrl: currentUrl
? removeQueryParam(
removeQueryParam(
currentUrl,
'download',
),
'export',
)
: null,
res,
isLongImageDownloadRequest,
longImagePathForHtml,
renderLongImage,
});
}
async function ensurePublicHtmlPrivateAssetsMaterialized(
filePath,
html,
) {
const authPool = getAuthPool();
if (
!authPool ||
!htmlReferencesPrivateAssets(html)
) {
return html;
}
const normalized = pathApi.resolve(
String(filePath ?? ''),
);
const ownerMatch = normalized.match(
new RegExp(
`[\\\\/]${PUBLISH_ROOT_DIR}[\\\\/]([0-9a-f-]{36})[\\\\/]`,
'i',
),
);
const userId = ownerMatch?.[1] ?? null;
if (!userId) return html;
const publishDir = resolveUserPublishDir(
h5Root,
{ id: userId },
);
const htmlRelativePath = pathApi
.relative(publishDir, normalized)
.replace(/\\/g, '/');
if (
!htmlRelativePath.startsWith(
`${PUBLIC_ZONE_DIR}/`,
)
) {
return html;
}
const { storageRoot } = resolveRuntimeConfig(
h5Root,
processEnv,
);
const result =
await materializePrivateAssetsInWorkspaceHtml({
pool: authPool,
storageRoot,
h5Root,
userId,
html,
htmlRelativePath,
writeBack: true,
}).catch(() => ({
html,
changed: false,
}));
return result.changed ? result.html : html;
}
async function sendPublishFile(
req,
res,
filePath,
{ isOwner = true } = {},
) {
if (!filePath.toLowerCase().endsWith('.html')) {
res.sendFile(filePath, (error) => {
if (error && !res.headersSent) {
res
.status(404)
.json({ message: '文件不存在' });
}
});
return;
}
if (
await sendLongImageDownloadIfRequested(
req,
res,
filePath,
)
) {
return;
}
let html;
try {
html = fsApi.readFileSync(
filePath,
'utf8',
);
} catch {
res
.status(404)
.json({ message: '文件不存在' });
return;
}
html =
await ensurePublicHtmlPrivateAssetsMaterialized(
filePath,
html,
);
html =
rewriteKnownCdnScriptSources(html).html;
html = preparePublicHtmlAssetDelivery(
html,
internalAgentSecret,
);
const embed = isPlazaEmbedRequest(req.query);
if (embed) {
html =
preparePublicationHtmlForEmbed(html);
allowPlazaEmbedFrame(res);
res.set(
'Content-Security-Policy',
publishedPageCsp(html, { embed }),
);
}
const context =
buildPublishedHtmlViewContext({
origin: resolveRequestOrigin(req),
requestPath:
req.originalUrl || req.url || '',
filePath,
thumbnailPngPathForSvg,
});
const parsedPublishPath =
parseMindSpacePublishFilePath(
filePath,
h5Root,
);
const userAuth = getUserAuth();
const pageOwner =
parsedPublishPath?.userId && userAuth
? await userAuth
.getUserById(
parsedPublishPath.userId,
)
.catch(() => null)
: null;
let pageDataContext = null;
const mindSpacePages = getMindSpacePages();
if (
parsedPublishPath?.userId &&
parsedPublishPath.relativePath
) {
pageDataContext =
await resolvePageDataContext({
pool: getAuthPool(),
pageService: mindSpacePages,
userId: parsedPublishPath.userId,
relativePath:
parsedPublishPath.relativePath,
logger,
});
}
html = injectMindSpaceAnalytics(html, {
ownerId:
parsedPublishPath?.userId ?? '',
ownerSegment:
resolveAnalyticsOwnerSegment(
pageOwner ?? {},
),
ownerLabel: resolveAnalyticsOwnerLabel(
pageOwner ?? {},
),
planType: resolveAnalyticsPlan(pageOwner ?? {}),
generatedAt: pageDataContext?.generatedAt ?? '',
pageId:
pageDataContext?.pageId ?? '',
publicationId:
pageDataContext?.publicationId ??
pageDataContext?.publication_id ??
'',
config: analyticsConfig,
});
html = injectMindSpaceRybbit(html, {
ownerId:
parsedPublishPath?.userId ?? '',
ownerSegment:
resolveAnalyticsOwnerSegment(
pageOwner ?? {},
),
ownerLabel: resolveAnalyticsOwnerLabel(
pageOwner ?? {},
),
pageId:
pageDataContext?.pageId ?? '',
publicationId:
pageDataContext?.publicationId ??
pageDataContext?.publication_id ??
'',
config: rybbitConfig,
});
const decorated =
decorateMindSpacePublishedHtml({
html,
embed,
isOwner,
pageDataContext,
context,
htmlFilePath: filePath,
userAgent:
req.get('user-agent') || '',
preparePublicationHtmlForEmbed,
injectOgTags,
injectWechatShareBridge,
injectPublicFileShareButton,
publishedPageCsp,
isWechatUserAgent,
});
html = decorated.html;
if (decorated.allowEmbedFrame) {
allowPlazaEmbedFrame(res);
}
res.set(
'Content-Security-Policy',
decorated.csp,
);
res.set(
'Content-Type',
'text/html; charset=utf-8',
);
// REGRESSION GUARD: mindspace-public-owner-vs-visitor — this HTML varies by viewer.
res.set(
'Cache-Control',
'private, no-store',
);
res.send(html);
}
async function serveUserPublishFile(
req,
res,
next,
) {
const result = await resolvePublicRequest({
h5Root,
requestPath: req.path,
resolveUsernameToUserId: async (
username,
) => {
const authPool = getAuthPool();
if (!authPool) return null;
const [rows] = await authPool.query(
`SELECT id FROM h5_users WHERE username = ? LIMIT 1`,
[username],
);
return rows[0]?.id
? String(rows[0].id)
: null;
},
resolveClosestHtmlRelativePath:
resolveClosestHtml,
ensureThumbnail: async (
publishDir,
relativePath,
) => {
await ensureWorkspaceThumbnail(
publishDir,
relativePath,
).catch(() => {});
},
logger,
});
if (result.action === 'redirect') {
res.redirect(
result.status ?? 301,
result.location,
);
return;
}
if (result.action === 'forbidden') {
res
.status(403)
.json({ message: '禁止访问' });
return;
}
if (result.action === 'not_found') {
if (
result.reason === 'missing_owner_dir'
) {
res
.status(404)
.json({ message: '用户不存在' });
return;
}
if (
result.reason ===
'missing_directory_index'
) {
res
.status(404)
.json({
message:
'目录中没有 index.html',
});
return;
}
res
.status(404)
.json({ message: '文件不存在' });
return;
}
const resolvedPath = result.filePath;
const authPool = getAuthPool();
if (
authPool &&
result.ownerKey &&
/\.html$/i.test(resolvedPath)
) {
const publishDir =
resolveUserPublishDir(h5Root, {
id: result.ownerKey,
});
const relativePath = pathApi
.relative(publishDir, resolvedPath)
.replace(/\\/g, '/');
const contract =
await getDeliveryContract({
pool: authPool,
userId: result.ownerKey,
relativePath,
}).catch(() => null);
if (
contract &&
contract.status !== 'ready'
) {
return res
.status(409)
.type('text/plain; charset=utf-8')
.send(
'页面已生成,正在完成发布验证,请稍后重试。',
);
}
}
if (
/\.thumbnail\.png$/i.test(
resolvedPath,
)
) {
const svgSibling = resolvedPath.replace(
/\.png$/i,
'.svg',
);
if (fsApi.existsSync(svgSibling)) {
const pngPath =
ensureThumbnail(svgSibling);
if (
pngPath &&
fsApi.existsSync(pngPath)
) {
res.set(
'Cache-Control',
'public, max-age=300',
);
res.sendFile(pngPath);
return;
}
}
}
const userAuth = getUserAuth();
const viewer =
req.userSession && userAuth
? await userAuth
.getMe(req.userToken)
.catch(() => null)
: null;
const isOwner = Boolean(
viewer?.id &&
result.ownerKey &&
String(viewer.id).toLowerCase() ===
String(
result.ownerKey,
).toLowerCase(),
);
await sendPublishFile(
req,
res,
resolvedPath,
{ isOwner },
);
}
return {
sendPublishFile,
serveUserPublishFile,
};
}