Files
memind/server/portal-published-page-delivery.mjs
john e2014e05e6 fix(analytics): stop identifying Public visitors as page creators
Public generated pages were calling umami.identify(owner_id), collapsing
every visitor into the creator session. Anonymous visits now skip identify;
logged-in viewers identify with their own distinct id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 13:05:18 +08:00

402 lines
9.9 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';
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,
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 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.
}
}
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,
});
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);
}
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);
};
}