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, }; }