Files
memind/server/portal-published-page-delivery.mjs
john fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-10 08:06:12 +08:00

454 lines
12 KiB
JavaScript

import {
injectPublishedPageDataContext,
resolveMindSpacePageDataContext,
} from '../mindspace-public-page-context.mjs';
import {
collectInlineScriptHashes,
} from '../mindspace-public-delivery.mjs';
import {
buildViewerAnalyticsIdentity,
injectMindSpaceAnalytics,
resolveAnalyticsOwnerLabel,
resolveAnalyticsOwnerSegment,
resolveAnalyticsPlan,
} from '../mindspace-analytics.mjs';
import {
injectMindSpaceRybbit,
} from '../mindspace-rybbit.mjs';
import {
rewriteBrokenMindSpacePublicImageUrls,
rewritePublicationCanonicalAssetUrls,
} from '../mindspace-publications.mjs';
import {
allowPlazaEmbedFrame,
preparePublicationHtmlForEmbed,
stripPublicationHtmlCspMeta,
} from '../plaza-embed.mjs';
import {
publishedPageCsp,
} from '../mindspace-published-page-csp.mjs';
import {
injectOgTags,
injectWechatShareBridge,
} from '../mindspace-og-tags.mjs';
import {
isLongImageDownloadRequest,
renderLongImageBuffer,
} from '../mindspace-long-image.mjs';
import { isWechatUserAgent } from '../wechat-oauth.mjs';
import {
appendQueryParam,
detectPublishedPageTitle,
extractOgImageUrl,
extractShareMetaFromPageHtml,
publishedPageShellHtml,
removeQueryParam,
resolveRequestOrigin,
} from './portal-publication-shell.mjs';
import {
decorateMindSpaceSeoGeoHtml,
applySeoGeoResponseHeaders,
} from '../mindspace-seo-geo-delivery.mjs';
import {
loadMindSpaceConfigCached,
} from '../mindspace-config.mjs';
async function decoratePublicationHtmlAnalytics(
html,
{
result,
analyticsConfig,
rybbitConfig,
viewer = null,
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
resolvePageDataContext =
resolveMindSpacePageDataContext,
logger = console,
} = {},
) {
const source = String(html ?? '');
const isFullHtml =
/^\s*<!doctype html/i.test(source) ||
/^\s*<html[\s>]/i.test(source);
if (!isFullHtml) return source;
const userAuth = getUserAuth?.();
const pageOwner =
result?.ownerId && userAuth
? await userAuth
.getUserById(result.ownerId)
.catch(() => null)
: null;
let pageDataContext = null;
const mindSpacePages = getMindSpacePages?.();
if (
result?.ownerId &&
result?.workspaceRelativePath &&
mindSpacePages
) {
pageDataContext = await resolvePageDataContext({
pool: getAuthPool?.(),
pageService: mindSpacePages,
userId: result.ownerId,
relativePath: result.workspaceRelativePath,
logger,
}).catch(() => null);
}
const ownerSegment = resolveAnalyticsOwnerSegment(
pageOwner ?? {},
);
const ownerLabel = resolveAnalyticsOwnerLabel(
pageOwner ?? {},
);
const pageId =
pageDataContext?.pageId ??
result?.pageSource?.pageId ??
'';
const publicationId =
pageDataContext?.publicationId ??
pageDataContext?.publication_id ??
result?.publication?.id ??
'';
let decorated = injectMindSpaceAnalytics(source, {
ownerId: result?.ownerId ?? '',
ownerSegment,
ownerLabel,
planType: resolveAnalyticsPlan(pageOwner ?? {}),
generatedAt: pageDataContext?.generatedAt ?? '',
pageId,
publicationId,
viewerIdentity: buildViewerAnalyticsIdentity(viewer, analyticsConfig),
config: analyticsConfig,
});
decorated = injectMindSpaceRybbit(decorated, {
ownerId: result?.ownerId ?? '',
ownerSegment,
ownerLabel,
pageId,
publicationId,
channel: 'publication',
config: rybbitConfig,
});
return decorated;
}
export function createPortalPublishedPageDelivery({
registerLongImageArtifact,
renderLongImage =
renderLongImageBuffer,
analyticsConfig = { enabled: false },
rybbitConfig = { enabled: false },
getAuthPool = () => null,
getUserAuth = () => null,
getMindSpacePages = () => null,
getMindSpaceConfig = loadMindSpaceConfigCached,
logger = console,
} = {}) {
if (
typeof registerLongImageArtifact !==
'function'
) {
throw new Error(
'createPortalPublishedPageDelivery requires artifact registration',
);
}
return async function sendPublishedPage(
req,
res,
result,
{
embed = false,
raw = false,
ownerSlug = null,
} = {},
) {
let html =
rewriteBrokenMindSpacePublicImageUrls(
result.html,
);
if (ownerSlug) {
html =
rewritePublicationCanonicalAssetUrls(
html,
ownerSlug,
);
}
const origin = resolveRequestOrigin(req);
const originalPath =
req.originalUrl || req.url || '';
const sharePath = removeQueryParam(
removeQueryParam(
originalPath,
'download',
),
'export',
);
const pageUrl = originalPath
? new URL(
sharePath,
origin || 'http://localhost',
)
.toString()
.split('#')[0]
: '';
const pageDirUrl = pageUrl
? pageUrl.slice(
0,
pageUrl.lastIndexOf('/') + 1,
)
: '';
const mindSpaceConfig = getAuthPool()
? await getMindSpaceConfig(getAuthPool()).catch(() => ({ seoGeo: { enabled: false } }))
: { seoGeo: { enabled: false } };
const seoGeoConfig = mindSpaceConfig?.seoGeo ?? null;
const publicationSnapshot = result?.publication ?? null;
const applySeoGeo = (htmlInput, { innerHtml = null, meta = {} } = {}) =>
decorateMindSpaceSeoGeoHtml(htmlInput, {
seoGeoConfig,
publication: publicationSnapshot,
embed,
context: {
origin,
pageUrl,
pageDirUrl,
meta,
},
innerHtml,
});
const wechatShare =
!embed &&
isWechatUserAgent(
req.get('user-agent') || '',
);
if (embed) {
html =
preparePublicationHtmlForEmbed(html);
allowPlazaEmbedFrame(res);
} else if (raw) {
html =
stripPublicationHtmlCspMeta(html);
}
html = injectPublishedPageDataContext(
html,
{
pageSource: result.pageSource,
publication: result.publication,
},
);
html = await decoratePublicationHtmlAnalytics(html, {
result,
analyticsConfig,
rybbitConfig,
viewer: req.currentUser ?? null,
getAuthPool,
getUserAuth,
getMindSpacePages,
logger,
});
if (!embed) {
try {
html = injectOgTags(html, {
origin,
pageUrl,
pageDirUrl,
});
if (wechatShare) {
html = injectWechatShareBridge(
html,
{ pageUrl },
);
}
} catch {
// Never block delivery on share metadata.
}
}
let robotsHeader = null;
if (!embed) {
try {
const shareMeta = extractShareMetaFromPageHtml(html);
const seoGeoResult = applySeoGeo(html, { meta: shareMeta });
html = seoGeoResult.html;
robotsHeader = seoGeoResult.robotsHeader;
} catch {
// Never block delivery on SEO/GEO metadata.
}
}
const isFullHtml =
/^\s*<!doctype html/i.test(html) ||
/^\s*<html[\s>]/i.test(html);
if (
!embed &&
!raw &&
isFullHtml &&
isLongImageDownloadRequest(req.query)
) {
try {
const rawUrl = new URL(
appendQueryParam(
sharePath || originalPath,
'view',
'raw',
),
origin || 'http://localhost',
);
const image =
await renderLongImage({
url: rawUrl.toString(),
});
const longImageUrl = new URL(
appendQueryParam(
sharePath || originalPath,
'download',
'long-image',
),
origin || 'http://localhost',
).toString();
await registerLongImageArtifact({
result,
image,
canonicalUrl: longImageUrl,
});
res.set('Content-Type', 'image/png');
res.set(
'Content-Disposition',
'attachment; filename="mindspace-public-page.long.png"',
);
res.set(
'Cache-Control',
'no-store',
);
return res.send(image);
} catch (error) {
return res
.status(500)
.type(
'text/plain; charset=utf-8',
)
.send(
`长图生成失败:${
error?.message || '未知错误'
}`,
);
}
}
const canWrapWithShell =
!embed &&
!raw &&
isFullHtml &&
result.publication?.accessMode !==
'password';
if (canWrapWithShell) {
const title =
detectPublishedPageTitle(html);
const rawUrl = appendQueryParam(
sharePath || originalPath,
'view',
'raw',
);
const longImageUrl = appendQueryParam(
sharePath || originalPath,
'download',
'long-image',
);
let shellHtml = publishedPageShellHtml({
iframeUrl: rawUrl,
shareUrl: pageUrl,
title,
longImageUrl,
});
try {
shellHtml = injectOgTags(shellHtml, {
origin,
pageUrl,
pageDirUrl,
fallbackImageUrl:
extractOgImageUrl(html),
meta:
extractShareMetaFromPageHtml(
html,
),
});
if (wechatShare) {
shellHtml =
injectWechatShareBridge(
shellHtml,
{ pageUrl },
);
}
} catch {
// Keep the share shell usable.
}
shellHtml = await decoratePublicationHtmlAnalytics(shellHtml, {
result,
analyticsConfig,
rybbitConfig,
viewer: req.currentUser ?? null,
getAuthPool,
getUserAuth,
getMindSpacePages,
logger,
});
if (!embed) {
try {
const shareMeta = extractShareMetaFromPageHtml(html);
const shellSeoGeo = applySeoGeo(shellHtml, {
innerHtml: html,
meta: shareMeta,
});
shellHtml = shellSeoGeo.html;
robotsHeader = shellSeoGeo.robotsHeader ?? robotsHeader;
} catch {
// Keep the share shell usable.
}
}
applySeoGeoResponseHeaders(res, robotsHeader);
res.set(
'Content-Type',
'text/html; charset=utf-8',
);
res.set(
'Content-Security-Policy',
wechatShare
? "default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' 'self' https://cdn.jsdelivr.net https://res.wx.qq.com; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
: "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self' https://cdn.jsdelivr.net; script-src 'unsafe-inline' 'self' https://cdn.jsdelivr.net; frame-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'",
);
res.set(
'Cache-Control',
result.publication.accessMode ===
'public'
? 'public, max-age=60'
: 'private, no-store',
);
return res.send(shellHtml);
}
applySeoGeoResponseHeaders(res, robotsHeader);
res.set(
'Content-Type',
'text/html; charset=utf-8',
);
res.set(
'Content-Security-Policy',
publishedPageCsp(html, {
embed,
raw,
wechatShare,
scriptHashes:
collectInlineScriptHashes(html),
}),
);
res.set(
'Cache-Control',
result.publication.accessMode ===
'public'
? 'public, max-age=60'
: 'private, no-store',
);
return res.send(html);
};
}