import fs from 'node:fs'; import fsPromises from 'node:fs/promises'; import path from 'node:path'; import { htmlReferencesPrivateAssets, materializePrivateAssetsInWorkspaceHtml, resolveClosestHtmlRelativePath, } from './mindspace-chat-save.mjs'; import { scanWorkspaceFilesForProhibitedBrowserStorage, } from './mindspace-browser-storage-policy.mjs'; import { getPageDeliveryContract } from './mindspace-delivery-contract.mjs'; import { renderLongImageBuffer } from './mindspace-long-image.mjs'; import { resolveMindSpacePublicRequest } from './mindspace-public-route.mjs'; import { listRecentlyModifiedPublicHtmlRelativePaths, } from './mindspace-run-public-html-scope.mjs'; import { normalizeWorkspaceRelativePath, } from './mindspace-pages.mjs'; import { evaluatePageDataHtmlContent, } from './mindspace-page-data-finish-guard.mjs'; import { detectPageDataDatasetUsageFromHtml, } from './page-data-html-detect.mjs'; import { policyAllowsAction, } from './page-access-policy.mjs'; import { readPageAccessPolicy, } from './page-data-policy-store.mjs'; import { buildMindSpacePublicRoutePath, resolveMindSpaceUserPublishDir, } from './mindspace-runtime-config.mjs'; import { ensureThumbnailPng } from './mindspace-thumbnail-png.mjs'; import { ensureWorkspaceHtmlThumbnail } from './mindspace-workspace-thumbnails.mjs'; import { PUBLIC_ZONE_DIR } from './user-publish.mjs'; function normalizeRelativePathFromRoot(root, absolutePath) { const relativePath = path .relative(root, absolutePath) .replace(/\\/g, '/'); if ( !relativePath || relativePath === '..' || relativePath.startsWith('../') || path.posix.isAbsolute(relativePath) ) { return null; } return relativePath; } function decodeRequestSegments(requestPath) { const segments = []; for (const rawSegment of String(requestPath ?? '').split('/')) { if (!rawSegment) continue; let segment; try { segment = decodeURIComponent(rawSegment); } catch { return null; } if (!segment || segment === '.' || segment === '..' || segment.includes('/') || segment.includes('\\')) { return null; } segments.push(segment); } return segments; } async function statFile(statFn, targetPath) { try { const stat = await statFn(targetPath); return stat?.isFile?.() ? stat : null; } catch { return null; } } export function createMindSpaceWorkspacePublicationDeliveryService({ pool, h5Root, storageRoot, logger = console, resolvePublicRequestFn = resolveMindSpacePublicRequest, resolveUserPublishDirFn = resolveMindSpaceUserPublishDir, resolveClosestHtmlFn = resolveClosestHtmlRelativePath, ensureWorkspaceThumbnailFn = ensureWorkspaceHtmlThumbnail, getDeliveryContractFn = getPageDeliveryContract, materializePrivateAssetsFn = materializePrivateAssetsInWorkspaceHtml, ensureThumbnailFn = ensureThumbnailPng, renderLongImageBufferFn = renderLongImageBuffer, listRecentlyModifiedPublicHtmlRelativePathsFn = listRecentlyModifiedPublicHtmlRelativePaths, evaluatePageDataHtmlContentFn = evaluatePageDataHtmlContent, detectPageDataDatasetUsageFromHtmlFn = detectPageDataDatasetUsageFromHtml, policyAllowsActionFn = policyAllowsAction, readPageAccessPolicyFn = readPageAccessPolicy, scanWorkspaceFilesForProhibitedBrowserStorageFn = scanWorkspaceFilesForProhibitedBrowserStorage, readFileFn = fsPromises.readFile, statFn = fsPromises.stat, existsSyncFn = fs.existsSync, } = {}) { if (!pool || typeof pool.query !== 'function') { throw new Error( 'createMindSpaceWorkspacePublicationDeliveryService requires a database pool', ); } if (!h5Root || !storageRoot) { throw new Error( 'createMindSpaceWorkspacePublicationDeliveryService requires h5Root and storageRoot', ); } async function resolveUsernameToUserId(username) { const [rows] = await pool.query( `SELECT id FROM h5_users WHERE LOWER(username) = ? OR LOWER(COALESCE(slug, username)) = ? LIMIT 1`, [username, username], ); return rows?.[0]?.id ? String(rows[0].id) : null; } async function prepareFileDelivery({ filePath, ownerId, publishDir, }) { const relativePath = normalizeRelativePathFromRoot( publishDir, filePath, ); if (!relativePath) { return { action: 'forbidden', reason: 'outside_publish_root', }; } let readablePath = filePath; if ( /\.thumbnail\.png$/i.test(readablePath) && !(await statFile(statFn, readablePath)) ) { const svgSibling = readablePath.replace( /\.png$/i, '.svg', ); if (existsSyncFn(svgSibling)) { readablePath = ensureThumbnailFn(svgSibling) ?? readablePath; } } const fileStat = await statFile( statFn, readablePath, ); if (!fileStat) { return { action: 'not_found', reason: 'missing_file', ownerId, }; } const htmlDelivery = /\.html$/i.test(relativePath); if (htmlDelivery) { const contract = await getDeliveryContractFn({ pool, userId: ownerId, relativePath, }).catch(() => null); if ( contract && contract.status !== 'ready' ) { return { action: 'not_ready', ownerId, relativePath, }; } let html = await readFileFn( readablePath, 'utf8', ); if ( relativePath.startsWith( `${PUBLIC_ZONE_DIR}/`, ) && htmlReferencesPrivateAssets(html) ) { const materialized = await materializePrivateAssetsFn({ pool, storageRoot, h5Root, userId: ownerId, html, htmlRelativePath: relativePath, writeBack: true, }).catch(() => ({ html, changed: false, })); if (materialized.changed) { html = materialized.html; } } const thumbnailRelativePath = relativePath.replace( /\.html$/i, '.thumbnail.svg', ); const thumbnailAbsolutePath = path.resolve( publishDir, thumbnailRelativePath, ); return { action: 'deliver', kind: 'html', ownerId, relativePath, servedName: path.posix.basename( relativePath, ), canonicalPath: buildMindSpacePublicRoutePath( ownerId, relativePath.split('/'), ), thumbnailName: existsSyncFn( thumbnailAbsolutePath, ) ? path.posix .basename(thumbnailRelativePath) .replace(/\.svg$/i, '.png') : null, html, }; } const body = await readFileFn(readablePath); return { action: 'deliver', kind: 'binary', ownerId, relativePath, servedName: path.posix.basename( relativePath, ), canonicalPath: buildMindSpacePublicRoutePath( ownerId, relativePath.split('/'), ), bodyBase64: body.toString('base64'), cacheControl: /\.thumbnail\.png$/i.test( relativePath, ) ? 'public, max-age=300' : null, }; } return { async listRecentlyModifiedPublicHtml({ userId, sinceMs, } = {}) { const ownerId = String( userId ?? '', ).trim().toLowerCase(); if (!ownerId) { return { relativePaths: [] }; } const publishDir = resolveUserPublishDirFn(h5Root, { id: ownerId, }); return { relativePaths: listRecentlyModifiedPublicHtmlRelativePathsFn( publishDir, { sinceMs }, ), }; }, async validateRunDeliverables({ userId, deliverables, } = {}) { const ownerId = String( userId ?? '', ).trim().toLowerCase(); if (!ownerId) { return { errors: [] }; } const publishDir = resolveUserPublishDirFn(h5Root, { id: ownerId, }); const resolvedPublishDir = path.resolve(publishDir); const pageDataErrors = []; const relativePaths = []; for (const page of deliverables?.pages ?? []) { const relativePath = normalizeWorkspaceRelativePath( page.workspaceRelativePath, ); if ( !relativePath?.startsWith( `${PUBLIC_ZONE_DIR}/`, ) ) { continue; } relativePaths.push(relativePath); const filePath = path.resolve( publishDir, relativePath, ); if ( !filePath.startsWith( `${resolvedPublishDir}${path.sep}`, ) || !(await statFile(statFn, filePath)) ) { continue; } const html = await readFileFn( filePath, 'utf8', ); const evaluation = evaluatePageDataHtmlContentFn( html, { relativePath }, ); if (!evaluation.usesPageDataApi) { continue; } for (const issue of evaluation.issues) { pageDataErrors.push({ code: issue, message: `${relativePath} Page Data HTML 不可交付:${issue}`, }); } const policy = page.pageId ? readPageAccessPolicyFn( publishDir, page.pageId, ) : null; for (const [dataset, actions] of detectPageDataDatasetUsageFromHtmlFn( html, )) { for (const action of [ 'read', 'insert', ]) { if ( actions?.[action] && !policyAllowsActionFn( policy, dataset, action, ) ) { pageDataErrors.push({ code: 'page_data_policy_action_missing', message: `${relativePath} 的 ${dataset}.${action} 未获最终 policy 授权或 dataset 已关闭`, }); } } } } const violations = scanWorkspaceFilesForProhibitedBrowserStorageFn( { publishDir, relativePaths, }, ); return { errors: [ ...pageDataErrors, ...violations.map( (violation) => ({ code: 'browser_storage_forbidden', message: `${violation.relativePath} 使用 ${violation.apis.join(', ')}`, }), ), ], }; }, async resolveWorkspaceRequest({ requestPath, } = {}) { const result = await resolvePublicRequestFn({ h5Root, requestPath, resolveUsernameToUserId, resolveClosestHtmlRelativePath: resolveClosestHtmlFn, ensureThumbnail: async ( publishDir, relativePath, ) => { await ensureWorkspaceThumbnailFn( publishDir, relativePath, ).catch(() => {}); }, logger, }); if (result.action !== 'serve') { return result; } const ownerId = String( result.ownerKey ?? '', ).trim().toLowerCase(); const publishDir = resolveUserPublishDirFn(h5Root, { id: ownerId, }); return prepareFileDelivery({ filePath: result.filePath, ownerId, publishDir, }); }, async readOwnerPublicAsset({ ownerSlug, requestPath, } = {}) { const slug = String( ownerSlug ?? '', ).trim().toLowerCase(); const segments = decodeRequestSegments(requestPath); if (!slug || !segments?.length) { return { action: 'not_found', reason: slug ? 'missing_file' : 'missing_owner', }; } const ownerId = await resolveUsernameToUserId(slug); if (!ownerId) { return { action: 'not_found', reason: 'missing_owner', }; } const publishDir = resolveUserPublishDirFn(h5Root, { id: ownerId, }); const filePath = path.resolve( publishDir, PUBLIC_ZONE_DIR, ...segments, ); const publicRoot = path.resolve( publishDir, PUBLIC_ZONE_DIR, ); if ( filePath !== publicRoot && !filePath.startsWith( `${publicRoot}${path.sep}`, ) ) { return { action: 'forbidden', reason: 'outside_public_root', }; } const fileStat = await statFile( statFn, filePath, ); if (!fileStat) { return { action: 'not_found', reason: 'missing_file', ownerId, }; } const body = await readFileFn(filePath); const relativePath = path.posix.join( PUBLIC_ZONE_DIR, ...segments, ); return { action: 'deliver', kind: 'binary', ownerId, relativePath, servedName: path.posix.basename( relativePath, ), canonicalPath: buildMindSpacePublicRoutePath( ownerId, relativePath.split('/'), ), bodyBase64: body.toString('base64'), }; }, async renderLongImage({ pageUrl } = {}) { const url = String(pageUrl ?? '').trim(); if (!/^https?:\/\//i.test(url)) { throw Object.assign( new Error('缺少可渲染的页面 URL'), { code: 'invalid_page_url' }, ); } const image = await renderLongImageBufferFn({ url }); return { bodyBase64: image.toString('base64'), servedName: 'mindspace-public-page.long.png', }; }, }; } export const mindspaceWorkspacePublicationDeliveryInternals = Object.freeze({ decodeRequestSegments, normalizeRelativePathFromRoot, });