534 lines
12 KiB
JavaScript
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,
|
|
};
|
|
}
|