import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs'; import { rewriteKnownCdnScriptSources } from './mindspace-published-script-localize.mjs'; import { replacePrivateResourceReferences, scanContent } from './mindspace-content-scan.mjs'; import { pageInternals, normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; import { readWorkspacePublishHtml } from './mindspace-workspace-path.mjs'; import { loadMindSpaceConfig } from './mindspace-config.mjs'; import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs'; import { isPublicWorkspaceHtmlRelativePath, resolvePageWorkspaceRelativePath, resolveWorkspaceMindSpacePublicUrl, } from './mindspace-runtime-config.mjs'; import { isRelativeDownloadReference, loadWorkspaceDownloadLinkIndex, resolveWorkspaceRelativeFilePath, rewriteRelativeDownloadLinks, inferWorkspaceHtmlRelativePath, } from './mindspace-html-download-links.mjs'; import { createImgproxySigner } from './imgproxy-signer.mjs'; const SCANNER_VERSION = 'mindspace-content-v1'; const PRIVATE_ASSET_DOWNLOAD_URL_PATTERN = /(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi; const URL_ATTR_PATTERN = /(\b(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi; const ACCESS_MODES = new Set([ 'public', 'password', 'private_link', 'time_limited', 'login_required', 'owner_only', ]); const SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; function publicationError(message, code, details) { return Object.assign(new Error(message), { code, details }); } function normalizeSlug(value) { const slug = String(value ?? '').normalize('NFKC').trim().toLowerCase(); if (!SLUG_PATTERN.test(slug)) { throw publicationError('页面地址只能包含小写字母、数字和连字符', 'invalid_publish_input'); } return slug; } async function findAvailableSlug(pool, userId, preferredSlug, excludePageId) { const normalized = normalizeSlug(preferredSlug); const candidates = [normalized]; for (let index = 2; index <= 20; index += 1) { candidates.push(`${normalized}-${index}`); } candidates.push(`${normalized}-${excludePageId.replace(/-/g, '').slice(0, 8)}`); for (const candidate of candidates) { if (!SLUG_PATTERN.test(candidate)) continue; const [rows] = await pool.query( `SELECT id FROM h5_publish_records WHERE user_id = ? AND url_slug = ? AND status = 'online' AND page_id <> ? LIMIT 1`, [userId, candidate, excludePageId], ); if (!rows[0]) return candidate; } return `${normalized}-${crypto.randomBytes(3).toString('hex')}`; } function normalizeAccessMode(value) { const mode = String(value ?? 'public'); if (!ACCESS_MODES.has(mode)) { throw publicationError('不支持的访问方式', 'invalid_publish_input'); } return mode; } function normalizePassword(value, required) { const password = String(value ?? ''); if (!required && !password) return null; if (password.length < 8 || password.length > 128) { throw publicationError('访问密码长度必须为 8 到 128 个字符', 'invalid_publish_input'); } return password; } function normalizeExpiresAt(value, required) { if (!required && (value == null || value === '')) return null; const expiresAt = Number(value); if (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now()) { throw publicationError('过期时间必须晚于当前时间', 'invalid_publish_input'); } return expiresAt; } function hashPassword(password) { const salt = crypto.randomBytes(16); const hash = crypto.scryptSync(password, salt, 32); return `scrypt$${salt.toString('hex')}$${hash.toString('hex')}`; } function verifyPassword(password, encoded) { const [algorithm, saltHex, hashHex] = String(encoded ?? '').split('$'); if (algorithm !== 'scrypt' || !saltHex || !hashHex) return false; const expected = Buffer.from(hashHex, 'hex'); const actual = crypto.scryptSync(String(password ?? ''), Buffer.from(saltHex, 'hex'), expected.length); return expected.length === actual.length && crypto.timingSafeEqual(expected, actual); } function deviceType(userAgent) { const value = String(userAgent ?? ''); if (/bot|crawler|spider|slurp/i.test(value)) return 'bot'; if (/ipad|tablet|kindle|silk/i.test(value)) return 'tablet'; if (/mobile|iphone|android/i.test(value)) return 'mobile'; if (value) return 'desktop'; return 'unknown'; } function referrerHost(referrer) { if (!referrer) return null; try { return new URL(referrer).hostname.slice(0, 255) || null; } catch { return null; } } function publicationResponse(row) { if (!row) return null; return { id: row.id, pageId: row.page_id, pageVersionId: row.page_version_id, urlSlug: row.url_slug, publicUrl: row.public_url, accessMode: row.access_mode, expiresAt: row.expires_at == null ? null : Number(row.expires_at), status: row.status, viewCount: Number(row.view_count ?? 0), publishedAt: Number(row.published_at), offlineAt: row.offline_at == null ? null : Number(row.offline_at), }; } function pageRecordStateFromAccessMode(accessMode) { const isPublic = accessMode === 'public'; return { status: isPublic ? 'published' : 'protected', visibility: isPublic ? 'public' : 'private', }; } async function syncPageRecordFromAccessMode(executor, userId, pageId, accessMode, now = Date.now()) { const { status, visibility } = pageRecordStateFromAccessMode(accessMode); await executor.query( `UPDATE h5_page_records SET status = ?, visibility = ?, updated_at = ? WHERE id = ? AND user_id = ? AND status <> 'deleted'`, [status, visibility, now, pageId, userId], ); } function buildOnlinePublicationPublicUrl(ownerSlug, urlSlug) { const publicBaseUrl = resolvePublicBaseUrl(); return `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${urlSlug}`; } function resolvePublicationDeliveryPublicUrl({ h5Root, userId, ownerSlug, urlSlug, accessMode, workspaceRelativePath, privateToken = null, } = {}) { if (privateToken) return `/s/${privateToken}`; const mindSpaceUrl = resolveWorkspaceMindSpacePublicUrl({ h5Root, userId, relativePath: workspaceRelativePath, }); if (mindSpaceUrl) return mindSpaceUrl; return buildOnlinePublicationPublicUrl(ownerSlug, urlSlug); } function publicHomepageResponse(owner, pages) { const totalViews = pages.reduce( (sum, page) => sum + Number(page.viewCount ?? page.view_count ?? 0), 0, ); return { owner: { id: owner.id, slug: owner.slug, username: owner.username, displayName: owner.display_name || owner.username, }, totalViews, pageCount: pages.length, pages: pages.map((page) => ({ id: page.id, pageId: page.page_id, title: page.title, summary: page.summary ?? '', templateId: page.template_id, publicUrl: page.public_url, urlSlug: page.url_slug, viewCount: Number(page.view_count ?? 0), publishedAt: Number(page.published_at), })), }; } function buildPublicationThumbnailFallback(ownerSlug, urlSlug) { return `/u/${encodeURIComponent(ownerSlug)}/pages/${encodeURIComponent(urlSlug)}.thumbnail.png`; } function companionKindForWorkspacePath(workspacePath) { const normalized = String(workspacePath ?? '').toLowerCase(); if (normalized.endsWith('.long.png')) return 'long_image'; if (normalized.endsWith('.docx') || normalized.endsWith('.doc')) return 'docx'; if (normalized.endsWith('.pdf')) return 'pdf'; return 'generated_file'; } function companionMimeForWorkspacePath(workspacePath) { const normalized = String(workspacePath ?? '').toLowerCase(); if (normalized.endsWith('.long.png') || normalized.endsWith('.png')) return 'image/png'; if (normalized.endsWith('.docx')) { return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; } if (normalized.endsWith('.doc')) return 'application/msword'; if (normalized.endsWith('.pdf')) return 'application/pdf'; if (normalized.endsWith('.csv')) return 'text/csv'; if (normalized.endsWith('.md')) return 'text/markdown'; if (normalized.endsWith('.txt')) return 'text/plain'; return 'application/octet-stream'; } async function statWorkspaceFile(publishDir, workspacePath) { if (!publishDir || !workspacePath) return null; const normalized = String(workspacePath).replace(/^\/+/, '').replace(/\\/g, '/'); const root = path.resolve(publishDir); const absolutePath = path.resolve(root, ...normalized.split('/')); if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) return null; try { const stat = await fs.stat(absolutePath); if (!stat.isFile()) return null; return { workspacePath: normalized, sizeBytes: stat.size }; } catch (error) { if (error?.code !== 'ENOENT') throw error; return null; } } async function collectPublicationCompanionArtifacts({ publishDir, html = '', htmlRelativePath = null, }) { if (!publishDir || !htmlRelativePath) return []; const candidates = new Set(); const normalizedHtmlPath = String(htmlRelativePath).replace(/^\/+/, '').replace(/\\/g, '/'); if (normalizedHtmlPath.toLowerCase().endsWith('.html')) { candidates.add(normalizedHtmlPath.replace(/\.html$/i, '.docx')); candidates.add(normalizedHtmlPath.replace(/\.html$/i, '.long.png')); } let match; while ((match = URL_ATTR_PATTERN.exec(String(html ?? ''))) !== null) { const ref = match[2]; if (isRelativeDownloadReference(ref)) { candidates.add(resolveWorkspaceRelativeFilePath(normalizedHtmlPath, ref)); } } const artifacts = []; for (const candidate of candidates) { const stat = await statWorkspaceFile(publishDir, candidate); if (stat) artifacts.push(stat); } return artifacts; } const PUBLIC_IMAGE_STORAGE_KEY_PATTERN = /^users\/([^/]+)\/images\/(\d{4}-\d{2}-\d{2}\/[^?#"'<>@\s]+)$/i; function storageKeyToPublicStandardImageUrl(storageKey, publicBaseUrl) { const match = String(storageKey ?? '').match(PUBLIC_IMAGE_STORAGE_KEY_PATTERN); if (!match) return null; return buildPublicUrl(publicBaseUrl, match[1], `public/images/${match[2]}`); } function replaceImgproxyStandardImageReferences(html, publicBaseUrl) { const source = String(html ?? ''); if (!source) return source; return source.replace( /(?:https?:\/\/[^/"'<>?\s,)]+)?\/[^"'<>?\s,)]*plain\/local:\/\/\/(users\/[^"'<>?\s,)]*?\/images\/\d{4}-\d{2}-\d{2}\/[^"'<>?\s,@)]+)(?:@[a-z0-9]+)?(?:\?[^"'<>),\s]*)?/gi, (value, storageKey) => storageKeyToPublicStandardImageUrl(storageKey, publicBaseUrl) ?? value, ); } /** Fix legacy publication HTML that dropped the public/ segment from MindSpace image URLs. */ export function rewriteBrokenMindSpacePublicImageUrls(html) { const source = String(html ?? ''); if (!source.includes('/images/')) return source; return source.replace( /((?:https?:\/\/[^/"'<>?\s,)]+)?\/MindSpace\/[^/"'<>?\s,)]+)\/images\/(\d{4}-\d{2}-\d{2}\/)/gi, '$1/public/images/$2', ); } function workspacePublicAssetRelativeUrl(htmlRelativePath, assetRelativePath) { const htmlPath = String(htmlRelativePath ?? '').replace(/^\/+/, '') || 'public/index.html'; const assetPath = String(assetRelativePath ?? '').replace(/^\/+/, ''); const htmlDir = path.posix.dirname(htmlPath); const relativePath = path.posix.relative(htmlDir === '.' ? '' : htmlDir, assetPath).replace(/\\/g, '/'); return relativePath || path.posix.basename(assetPath); } export function rewriteWorkspacePublicAssetReferences(html, htmlRelativePath) { const source = String(html ?? ''); if (!source) return source; return source.replace( /(?@\s)]+(?:\?[^"'<>)\s]*)?)/gi, (value, assetRelativePath) => workspacePublicAssetRelativeUrl(htmlRelativePath, `public/${assetRelativePath}`), ); } const PUBLICATION_WORKSPACE_ASSET_PATTERN = /(["'(\s])(?:\.\.\/)*(?:public\/)?((?:\.tmp-images|images\/\d{4}-\d{2}-\d{2})\/[^"'<>)\s]+)/gi; /** Rewrite workspace-relative image paths to canonical /u/{slug}/public/... URLs for /u/.../pages/ routes. */ export function rewritePublicationCanonicalAssetUrls(html, ownerSlug) { const slug = String(ownerSlug ?? '').trim(); if (!slug) return String(html ?? ''); const source = String(html ?? ''); if (!source.includes('.tmp-images/') && !source.includes('images/')) return source; const base = `/u/${encodeURIComponent(slug)}/public/`; return source.replace(PUBLICATION_WORKSPACE_ASSET_PATTERN, (_value, prefix, assetPath) => { return `${prefix}${base}${assetPath}`; }); } async function localizePrivateImageReferences({ pool, userId, html, absoluteStoragePath, imgproxySigner = null, }) { const source = String(html ?? ''); const matches = [...source.matchAll(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN)]; if (matches.length === 0) return source; const assetIds = [...new Set(matches.map((match) => match[1]).filter(Boolean))]; if (assetIds.length === 0) return source; const [assets] = await pool.query( `SELECT a.id, a.mime_type, v.storage_key FROM h5_assets a JOIN h5_asset_versions v ON v.id = a.current_version_id WHERE a.user_id = ? AND a.id IN (?) AND a.mime_type LIKE 'image/%' AND a.status <> 'deleted'`, [userId, assetIds], ); const byId = new Map(assets.map((asset) => [asset.id, asset])); const replacements = new Map(); for (const assetId of assetIds) { const asset = byId.get(assetId); if (!asset) continue; if (imgproxySigner) { const storageKey = String(asset.storage_key ?? '').trim(); if (!storageKey) continue; replacements.set(assetId, imgproxySigner.buildUrl(storageKey, 'display')); } else { const mimeType = String(asset.mime_type || 'application/octet-stream'); const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key)); replacements.set(assetId, `data:${mimeType};base64,${buffer.toString('base64')}`); } } if (replacements.size === 0) return source; return source.replace(PRIVATE_ASSET_DOWNLOAD_URL_PATTERN, (value, assetId) => { return replacements.get(assetId) ?? value; }); } async function prepareHtmlPublishContent({ pool, userId, html, ownerSlug, urlSlug, htmlRelativePath = null, pageTitle = null, absoluteStoragePath, imgproxySigner = null, h5Root = null, }) { let publishContent = (await localizeGoogleFontsCss(html)).html; publishContent = rewriteKnownCdnScriptSources(publishContent).html; publishContent = replaceImgproxyStandardImageReferences( publishContent, resolvePublicBaseUrl(), ); publishContent = await localizePrivateImageReferences({ pool, userId, html: publishContent, absoluteStoragePath, imgproxySigner, }); // imgproxy URLs embed /users/... storage keys; convert them to public standard images // before replacePrivateResourceReferences, which would otherwise treat them as private paths. publishContent = replaceImgproxyStandardImageReferences( publishContent, resolvePublicBaseUrl(), ); const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null; const resolvedHtmlRelativePath = htmlRelativePath ?? inferWorkspaceHtmlRelativePath({ pageTitle, htmlContent: publishContent, publishDir, }); publishContent = rewriteWorkspacePublicAssetReferences(publishContent, resolvedHtmlRelativePath); publishContent = rewritePublicationCanonicalAssetUrls(publishContent, ownerSlug); const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, userId); publishContent = rewriteRelativeDownloadLinks(publishContent, { htmlRelativePath: resolvedHtmlRelativePath, publishDir, linkIndex, publishKey: userId, publicBaseUrl: resolvePublicBaseUrl(), preferAssetDownload: false, }).html; return replacePrivateResourceReferences( publishContent, buildPublicationThumbnailFallback(ownerSlug, urlSlug), ); } export function createPublicationService(pool, options = {}) { const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace')); const h5Root = options.h5Root ?? null; const idFactory = options.idFactory ?? (() => crypto.randomUUID()); const publicPageLimitFallback = Number(options.publicPageLimit ?? 5); const conversationPackageRegistry = options.conversationPackageRegistry ?? null; let imgproxySigner = null; if (process.env.IMGPROXY_BASE_URL && process.env.IMGPROXY_SIGNING_KEY && process.env.IMGPROXY_SIGNING_SALT) { try { imgproxySigner = createImgproxySigner( process.env.IMGPROXY_SIGNING_KEY, process.env.IMGPROXY_SIGNING_SALT, ); imgproxySigner.baseUrl = process.env.IMGPROXY_BASE_URL; console.log('[Publication] imgproxy signer initialized'); } catch (err) { console.warn('[Publication] imgproxy signer init failed:', err instanceof Error ? err.message : err); } } const resolvePublicPageLimit = async () => { try { const config = await loadMindSpaceConfig(pool); return Number(config.publicPageLimit ?? publicPageLimitFallback); } catch { return publicPageLimitFallback; } }; const absoluteStoragePath = (storageKey) => { const resolved = path.resolve(storageRoot, storageKey); if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) { throw new Error('存储路径越界'); } return resolved; }; const resolveStoragePathCandidates = (storageKey) => { const normalized = String(storageKey ?? '').replace(/\\/g, '/'); const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized; const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/); if (!match) return [normalized]; const [, userId, scope, entityId, versionTag] = match; return [ normalized, `users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`, `users/${userId}/${scope}/${entityId}/versions/${versionTag}`, ].filter((candidate, index, list) => list.indexOf(candidate) === index); }; const resolveReadableStoragePath = async (storageKey) => { let lastError = null; for (const candidate of resolveStoragePathCandidates(storageKey)) { const absolutePath = absoluteStoragePath(candidate); try { await fs.stat(absolutePath); return absolutePath; } catch (error) { if (error?.code === 'ENOENT') { lastError = error; continue; } throw error; } } throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' }); }; const loadVersion = async (userId, pageId, pageVersionId) => { const [rows] = await pool.query( `SELECT p.id AS page_id, p.title, p.summary, p.page_type, p.template_id, p.current_version_id, p.workspace_relative_path, p.source_session_id, p.source_message_id, p.user_id, p.space_id, pv.id AS page_version_id, pv.version_no, pv.bundle_asset_id, av.storage_key, JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path FROM h5_page_records p JOIN h5_page_versions pv ON pv.page_id = p.id JOIN h5_asset_versions av ON av.asset_id = pv.content_asset_id AND av.version_no = 1 WHERE p.id = ? AND p.user_id = ? AND pv.id = ? AND p.status <> 'deleted' LIMIT 1`, [pageId, userId, pageVersionId], ); const row = rows[0]; if (!row) throw publicationError('页面版本不存在', 'page_not_found'); return { ...row, version_no: Number(row.version_no), content: await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8'), }; }; const loadOwnerSlug = async (userId) => { const [rows] = await pool.query( `SELECT COALESCE(slug, username) AS public_slug FROM h5_users WHERE id = ? LIMIT 1`, [userId], ); const ownerSlug = String(rows[0]?.public_slug ?? '').trim(); if (!ownerSlug) throw publicationError('用户公开地址不存在', 'publication_owner_not_found'); return ownerSlug; }; const preparePublishContent = async (page, ownerSlug, urlSlug) => { let publishContent = page.content; if (page.page_type !== 'html') return publishContent; const workspaceRelativePath = normalizeWorkspaceRelativePath( page.workspace_relative_path ?? page.source_relative_path, ); if (h5Root && workspaceRelativePath) { const workspaceHtml = await readWorkspacePublishHtml( h5Root, page.user_id, workspaceRelativePath, ); if (workspaceHtml && workspaceHtml.length > String(publishContent ?? '').length) { publishContent = workspaceHtml; } } const publishDir = h5Root ? resolvePublishDir(h5Root, { id: page.user_id }) : null; return prepareHtmlPublishContent({ pool, userId: page.user_id, html: publishContent, ownerSlug, urlSlug, pageTitle: page.title, htmlRelativePath: inferWorkspaceHtmlRelativePath({ snapshotRelativePath: page.source_relative_path, pageTitle: page.title, htmlContent: publishContent, publishDir, }), absoluteStoragePath, h5Root, imgproxySigner: imgproxySigner ? { baseUrl: imgproxySigner.baseUrl, buildUrl: (storagePath, preset = 'display') => imgproxySigner.buildUrl(imgproxySigner.baseUrl, storagePath, preset), } : null, }); }; const persistScan = async (conn, userId, pageVersionId, scan, now) => { const scanId = idFactory(); await conn.query( `INSERT INTO h5_security_scans (id, user_id, target_type, target_id, scanner_version, status, risk_level, findings_count, summary_json, started_at, completed_at) VALUES (?, ?, 'page_version', ?, ?, ?, ?, ?, ?, ?, ?)`, [ scanId, userId, pageVersionId, SCANNER_VERSION, scan.status, scan.riskLevel, scan.findings.length, JSON.stringify({ types: scan.findings.map((finding) => finding.type) }), now, now, ], ); for (const finding of scan.findings) { await conn.query( `INSERT INTO h5_security_findings (id, scan_id, finding_type, risk_level, occurrence_count, sample_masked, blocking, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ idFactory(), scanId, finding.type, finding.riskLevel, finding.occurrenceCount, finding.sampleMasked, finding.blocking ? 1 : 0, now, ], ); } return scanId; }; const check = async (userId, pageId, input) => { const slug = normalizeSlug(input.urlSlug); const accessMode = normalizeAccessMode(input.accessMode); normalizePassword(input.password, accessMode === 'password'); const expiresAt = normalizeExpiresAt( input.expiresAt, accessMode === 'time_limited', ); const [onlineRows] = await pool.query( `SELECT id, url_slug, public_url FROM h5_publish_records WHERE user_id = ? AND page_id = ? AND status = 'online' LIMIT 1`, [userId, pageId], ); const currentOnline = onlineRows[0] ?? null; if (currentOnline) { throw publicationError('页面已发布,请先下线后再重新发布', 'page_already_online', { publicationId: currentOnline.id, publicUrl: currentOnline.public_url, }); } const page = await loadVersion(userId, pageId, input.pageVersionId); if (page.page_version_id !== page.current_version_id) { throw publicationError('只能发布页面的当前版本', 'invalid_state_transition'); } const [conflicts] = await pool.query( `SELECT pr.id, pr.page_id, p.title FROM h5_publish_records pr JOIN h5_page_records p ON p.id = pr.page_id WHERE pr.user_id = ? AND pr.url_slug = ? AND pr.status = 'online' AND pr.page_id <> ? LIMIT 1`, [userId, slug, pageId], ); const conflict = conflicts[0] ?? null; const ownerSlug = await loadOwnerSlug(userId); const publishContent = await preparePublishContent(page, ownerSlug, slug); const scan = scanContent(`${page.title}\n${page.summary ?? ''}\n${publishContent}`, { format: page.page_type === 'html' ? 'html' : 'text', allowHtmlActiveContent: page.page_type === 'html', }); const suggestedUrlSlug = conflict && accessMode !== 'private_link' ? await findAvailableSlug(pool, userId, slug, pageId) : null; return { pageVersionId: page.page_version_id, urlSlug: slug, accessMode, expiresAt, slugAvailable: accessMode === 'private_link' || !conflict, slugConflict: conflict ? { pageId: conflict.page_id, title: conflict.title } : null, suggestedUrlSlug, ...scan, allowed: (accessMode === 'private_link' || !conflict) && scan.allowed, }; }; const publish = async (userId, pageId, input) => { const result = await check(userId, pageId, input); if (!result.slugAvailable) throw publicationError('页面地址已被使用', 'slug_conflict'); if (!result.allowed) { throw publicationError('页面包含不可公开的高风险信息', 'security_risk_blocked', { findings: result.findings, }); } const acknowledged = input.autoAcknowledgeFindings ? new Set(result.findings.map((finding) => finding.id)) : new Set(input.acknowledgedFindingIds ?? []); const missingAcknowledgements = result.findings.filter( (finding) => !finding.blocking && !acknowledged.has(finding.id), ); if (missingAcknowledgements.length) { throw publicationError('请先确认发布检查中的风险提示', 'security_ack_required', { findings: missingAcknowledgements, }); } const page = await loadVersion(userId, pageId, result.pageVersionId); const ownerSlug = await loadOwnerSlug(userId); const publishContent = await preparePublishContent(page, ownerSlug, result.urlSlug); const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent }); const htmlBytes = Buffer.byteLength(html); const conn = await pool.getConnection(); let writtenPath; try { await conn.beginTransaction(); const [spaces] = await conn.query( `SELECT quota_bytes, used_bytes, reserved_bytes FROM h5_user_spaces WHERE id = ? AND user_id = ? FOR UPDATE`, [page.space_id, userId], ); const available = Number(spaces[0]?.quota_bytes ?? 0) - Number(spaces[0]?.used_bytes ?? 0) - Number(spaces[0]?.reserved_bytes ?? 0); if (available < htmlBytes) { throw publicationError('剩余空间不足', 'quota_exceeded', { requiredBytes: htmlBytes, availableBytes: Math.max(0, available), }); } const [publicationUsage] = await conn.query( `SELECT COUNT(DISTINCT page_id) AS public_page_used, MAX(CASE WHEN page_id = ? THEN 1 ELSE 0 END) AS page_already_online FROM h5_publish_records WHERE user_id = ? AND status = 'online'`, [pageId, userId], ); const publicPageLimit = await resolvePublicPageLimit(); if ( !Number(publicationUsage[0]?.page_already_online) && Number(publicationUsage[0]?.public_page_used) >= publicPageLimit ) { throw publicationError('公开页面数量已达到套餐上限', 'public_page_limit_exceeded', { limit: publicPageLimit, }); } const [onlineLock] = await conn.query( `SELECT id, public_url FROM h5_publish_records WHERE user_id = ? AND page_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`, [userId, pageId], ); if (onlineLock[0]) { throw publicationError('页面已发布,请先下线后再重新发布', 'page_already_online', { publicationId: onlineLock[0].id, publicUrl: onlineLock[0].public_url, }); } const [categories] = await conn.query( `SELECT id FROM h5_space_categories WHERE user_id = ? AND space_id = ? AND category_code = 'public' LIMIT 1`, [userId, page.space_id], ); if (!categories[0]) throw publicationError('公开分类不存在', 'category_not_found'); const now = Date.now(); const scanId = await persistScan(conn, userId, page.page_version_id, result, now); const bundleAssetId = idFactory(); const assetVersionId = idFactory(); const publishId = idFactory(); const privateToken = result.accessMode === 'private_link' ? crypto.randomBytes(24).toString('base64url') : null; const tokenHash = privateToken ? crypto.createHash('sha256').update(privateToken).digest('hex') : null; const passwordHash = result.accessMode === 'password' ? hashPassword(input.password) : null; const storageKey = path.posix.join( 'users', userId, 'publications', publishId, 'index.html', ); writtenPath = absoluteStoragePath(storageKey); await fs.mkdir(path.dirname(writtenPath), { recursive: true }); await fs.writeFile(writtenPath, html, { flag: 'wx' }); const checksum = crypto.createHash('sha256').update(html).digest('hex'); const workspaceRelativePath = resolvePageWorkspaceRelativePath(page); const publicUrl = resolvePublicationDeliveryPublicUrl({ h5Root, userId, ownerSlug, urlSlug: result.urlSlug, accessMode: result.accessMode, workspaceRelativePath, privateToken, }); await conn.query( `INSERT INTO h5_assets (id, user_id, space_id, category_id, asset_type, mime_type, original_filename, display_name, current_version_id, size_bytes, checksum, risk_level, visibility, status, source_type, created_at, updated_at) VALUES (?, ?, ?, ?, 'html', 'text/html', 'index.html', ?, ?, ?, ?, ?, 'public_candidate', 'ready', 'generated', ?, ?)`, [ bundleAssetId, userId, page.space_id, categories[0].id, `${page.title} · 发布快照`, assetVersionId, htmlBytes, checksum, result.riskLevel, now, now, ], ); await conn.query( `INSERT INTO h5_asset_versions (id, asset_id, version_no, storage_key, size_bytes, checksum, mime_type, created_by, change_note, scan_status, created_at) VALUES (?, ?, 1, ?, ?, ?, 'text/html', ?, '发布页面快照', ?, ?)`, [ assetVersionId, bundleAssetId, storageKey, htmlBytes, checksum, userId, result.status === 'passed' ? 'passed' : 'warned', now, ], ); const [previousRows] = await conn.query( `SELECT id, page_version_id FROM h5_publish_records WHERE user_id = ? AND page_id = ? ORDER BY published_at DESC LIMIT 1 FOR UPDATE`, [userId, pageId], ); await conn.query( `UPDATE h5_page_versions SET immutable = 1, bundle_asset_id = ?, security_scan_id = ? WHERE id = ? AND page_id = ?`, [bundleAssetId, scanId, page.page_version_id, pageId], ); await conn.query( `INSERT INTO h5_publish_records (id, user_id, page_id, page_version_id, publish_type, url_slug, public_url, access_mode, password_hash, token_hash, token_prefix, expires_at, user_confirmed_at, published_at, status, view_count, security_scan_id, created_at, updated_at) VALUES (?, ?, ?, ?, 'page', ?, ?, ?, ?, ?, ?, ?, ?, ?, 'online', 0, ?, ?, ?)`, [ publishId, userId, pageId, page.page_version_id, result.urlSlug, publicUrl, result.accessMode, passwordHash, tokenHash, privateToken?.slice(0, 8) ?? null, result.expiresAt, null, now, scanId, now, now, ], ); await conn.query( `INSERT INTO h5_publication_events (id, publish_id, event_type, actor_id, old_page_version_id, new_page_version_id, access_mode, detail_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ idFactory(), publishId, previousRows.length ? 'republished' : 'published', userId, previousRows[0]?.page_version_id ?? null, page.page_version_id, result.accessMode, JSON.stringify({ findings: result.findings.map((finding) => finding.id) }), now, ], ); await conn.query( `UPDATE h5_page_records SET current_publish_id = ?, status = ?, visibility = ?, updated_at = ? WHERE id = ? AND user_id = ?`, [ publishId, result.accessMode === 'public' ? 'published' : 'protected', result.accessMode === 'public' ? 'public' : 'private', now, pageId, userId, ], ); await conn.query( `UPDATE h5_user_spaces SET used_bytes = used_bytes + ?, updated_at = ? WHERE id = ? AND user_id = ?`, [htmlBytes, now, page.space_id, userId], ); const assetUrlPattern = /\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)/gi; const assetIds = new Set(); let match; while ((match = assetUrlPattern.exec(html)) !== null) { assetIds.add(match[1]); } if (assetIds.size > 0) { const refValues = Array.from(assetIds).map((assetId) => [publishId, assetId, now]); await conn.query( `INSERT IGNORE INTO h5_publication_asset_refs (publication_id, asset_id, created_at) VALUES ` + refValues.map(() => '(?, ?, ?)').join(','), refValues.flat(), ); } await conn.commit(); const publication = { ...publicationResponse({ id: publishId, page_id: pageId, page_version_id: page.page_version_id, url_slug: result.urlSlug, public_url: publicUrl, access_mode: result.accessMode, status: 'online', view_count: 0, published_at: now, offline_at: null, expires_at: result.expiresAt, }), findings: result.findings, }; await registerPublicationArtifactForConversation({ registry: conversationPackageRegistry, userId, page, publication, bundleAssetId, storageKey, htmlBytes, html: page.content, publishDir: h5Root ? resolvePublishDir(h5Root, { id: userId }) : null, now, }); return publication; } catch (error) { await conn.rollback(); if (writtenPath) await fs.rm(writtenPath, { force: true }).catch(() => {}); throw error; } finally { conn.release(); } }; const getCurrent = async (userId, pageId) => { const [rows] = await pool.query( `SELECT pr.* FROM h5_publish_records pr JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id WHERE pr.page_id = ? AND pr.user_id = ? AND pr.status = 'online' ORDER BY pr.published_at DESC LIMIT 1`, [pageId, userId], ); return publicationResponse(rows[0]); }; const refreshOnlinePublicationHtml = async (userId, pageId) => { const [pubRows] = await pool.query( `SELECT pr.id, pr.url_slug, pr.public_url, pr.page_version_id, pr.access_mode, pr.status, pr.view_count, pr.published_at, pr.offline_at, pr.expires_at, pv.bundle_asset_id, av.id AS asset_version_id, av.storage_key FROM h5_publish_records pr JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id JOIN h5_assets a ON a.id = pv.bundle_asset_id JOIN h5_asset_versions av ON av.asset_id = a.id AND av.version_no = 1 WHERE pr.page_id = ? AND pr.user_id = ? AND pr.status = 'online' ORDER BY pr.published_at DESC LIMIT 1`, [pageId, userId], ); const publication = pubRows[0]; if (!publication) return null; const [pageRows] = await pool.query( `SELECT current_version_id FROM h5_page_records WHERE id = ? AND user_id = ? AND status <> 'deleted' LIMIT 1`, [pageId, userId], ); const currentVersionId = pageRows[0]?.current_version_id; if (!currentVersionId) { throw publicationError('页面不存在', 'page_not_found'); } const page = await loadVersion(userId, pageId, currentVersionId); if (page.page_type !== 'html') { return publicationResponse(publication); } const ownerSlug = await loadOwnerSlug(userId); const publishContent = await preparePublishContent(page, ownerSlug, publication.url_slug); const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent }); const htmlBytes = Buffer.byteLength(html); const checksum = crypto.createHash('sha256').update(html).digest('hex'); const writtenPath = absoluteStoragePath(publication.storage_key); await fs.mkdir(path.dirname(writtenPath), { recursive: true }); await fs.writeFile(writtenPath, html); const now = Date.now(); await pool.query( `UPDATE h5_assets SET size_bytes = ?, checksum = ?, updated_at = ? WHERE id = ? AND user_id = ?`, [htmlBytes, checksum, now, publication.bundle_asset_id, userId], ); await pool.query( `UPDATE h5_asset_versions SET size_bytes = ?, checksum = ? WHERE id = ?`, [htmlBytes, checksum, publication.asset_version_id], ); const workspaceRelativePath = resolvePageWorkspaceRelativePath(page); const publicUrl = resolvePublicationDeliveryPublicUrl({ h5Root, userId, ownerSlug, urlSlug: publication.url_slug, accessMode: publication.access_mode, workspaceRelativePath, }); if (publicUrl !== publication.public_url) { await pool.query( `UPDATE h5_publish_records SET public_url = ?, updated_at = ? WHERE id = ? AND user_id = ?`, [publicUrl, now, publication.id, userId], ); } await syncPageRecordFromAccessMode(pool, userId, pageId, publication.access_mode, now); return publicationResponse({ ...publication, public_url: publicUrl, updated_at: now, }); }; const updatePublicationAccess = async (userId, publicationId, { accessMode, password, expiresAt } = {}) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( `SELECT * FROM h5_publish_records WHERE id = ? AND user_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`, [publicationId, userId], ); const publication = rows[0]; if (!publication) throw publicationError('发布记录不存在', 'publication_not_found'); const normalizedMode = accessMode != null ? normalizeAccessMode(accessMode) : publication.access_mode; const normalizedExpiresAt = expiresAt !== undefined ? normalizeExpiresAt(expiresAt, normalizedMode === 'time_limited') : publication.expires_at; let passwordHash = null; if (normalizedMode === 'password') { const explicit = String(password ?? '').trim(); if (explicit) { passwordHash = hashPassword(normalizePassword(explicit, true)); } else if (publication.password_hash) { passwordHash = publication.password_hash; } else { throw publicationError('访问密码长度必须为 8 到 128 个字符', 'invalid_publish_input'); } } const now = Date.now(); await conn.query( `UPDATE h5_publish_records SET access_mode = ?, expires_at = ?, password_hash = ?, user_confirmed_at = ?, updated_at = ? WHERE id = ? AND user_id = ?`, [normalizedMode, normalizedExpiresAt, passwordHash, now, now, publicationId, userId], ); await syncPageRecordFromAccessMode( conn, userId, publication.page_id, normalizedMode, now, ); await conn.commit(); const updated = { ...publication, access_mode: normalizedMode, expires_at: normalizedExpiresAt, password_hash: passwordHash, user_confirmed_at: now, updated_at: now, }; return publicationResponse(updated); } catch (error) { await conn.rollback(); throw error; } finally { conn.release(); } }; const updatePublicationStatus = async (userId, publicationId, { accessMode, expiresAt }) => { return updatePublicationAccess(userId, publicationId, { accessMode, expiresAt }); }; const offline = async (userId, publicationId) => { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows] = await conn.query( `SELECT * FROM h5_publish_records WHERE id = ? AND user_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`, [publicationId, userId], ); const publication = rows[0]; if (!publication) throw publicationError('发布记录不存在', 'publication_not_found'); const now = Date.now(); await conn.query( `UPDATE h5_publish_records SET status = 'offline', offline_at = ?, updated_at = ? WHERE id = ?`, [now, now, publicationId], ); await conn.query( `UPDATE h5_page_records SET current_publish_id = NULL, status = 'offline', visibility = 'private', updated_at = ? WHERE id = ? AND user_id = ? AND current_publish_id = ?`, [now, publication.page_id, userId, publicationId], ); await conn.query( `INSERT INTO h5_publication_events (id, publish_id, event_type, actor_id, old_page_version_id, new_page_version_id, access_mode, detail_json, created_at) VALUES (?, ?, 'offlined', ?, ?, NULL, ?, '{}', ?)`, [ idFactory(), publicationId, userId, publication.page_version_id, publication.access_mode, now, ], ); await conn.query( `UPDATE plaza_posts SET status = 'hidden', updated_at = ? WHERE publication_id = ? AND status != 'hidden'`, [now, publicationId], ); await conn.commit(); return { id: publicationId, status: 'offline', offlineAt: now }; } catch (error) { await conn.rollback(); throw error; } finally { conn.release(); } }; const resolveRow = async (row, viewerId, password, requestMeta = {}) => { if (!row) throw publicationError('公开页面不存在', 'publication_not_found'); if (row.expires_at && Number(row.expires_at) <= Date.now()) { await pool.query( `UPDATE h5_publish_records SET status = 'expired', updated_at = ? WHERE id = ?`, [Date.now(), row.id], ); throw publicationError('公开页面已过期', 'publication_not_found'); } if (row.access_mode === 'owner_only' && row.owner_id !== viewerId) { throw publicationError('公开页面不存在', 'publication_not_found'); } if (row.access_mode === 'login_required' && !viewerId) { throw publicationError('登录后才能访问此页面', 'publication_login_required'); } if (row.access_mode === 'password' && !verifyPassword(password, row.password_hash)) { throw publicationError('需要访问密码', 'publication_password_required'); } const now = Date.now(); await pool.query( `UPDATE h5_publish_records SET view_count = view_count + 1, updated_at = ? WHERE id = ?`, [now, row.id], ); await pool.query( `INSERT INTO h5_publication_views (id, publish_id, viewer_user_id, referrer_host, device_type, viewed_at) VALUES (?, ?, ?, ?, ?, ?)`, [ idFactory(), row.id, viewerId ?? null, referrerHost(requestMeta.referrer), deviceType(requestMeta.userAgent), now, ], ); const html = await readPublicationHtmlWithWorkspaceFallback(row); return { html: await refreshPublishedHtmlDownloadLinks(row, html), publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }), ownerId: row.owner_id, workspaceRelativePath: resolvePageWorkspaceRelativePath(row), pageSource: { pageId: row.page_id, title: row.title, sourceSessionId: row.source_session_id ?? null, sourceMessageId: row.source_message_id ?? null, }, }; }; // REGRESSION GUARD: mindspace-page-sync-thumbnail — publication delivery must fall back to // workspace HTML when publication storage snapshot is missing (isolated env / migration). async function readPublicationHtmlWithWorkspaceFallback(row) { try { return await fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8'); } catch (error) { const storageMissing = error?.code === 'ENOENT' || error?.code === 'storage_not_found'; if (!storageMissing || !h5Root) throw error; const workspaceRelativePath = normalizeWorkspaceRelativePath( row.workspace_relative_path ?? row.source_relative_path, ); if (!workspaceRelativePath) throw error; const workspaceHtml = await readWorkspacePublishHtml(h5Root, row.owner_id, workspaceRelativePath); if (!workspaceHtml) throw error; return workspaceHtml; } } const refreshPublishedHtmlDownloadLinks = async (row, html) => { if (!h5Root || !row?.owner_id) return html; const source = String(html ?? ''); if (!source.trim()) return html; const publishDir = resolvePublishDir(h5Root, { id: row.owner_id }); const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, row.owner_id); return rewriteRelativeDownloadLinks(html, { htmlRelativePath: inferWorkspaceHtmlRelativePath({ pageTitle: row.title, htmlContent: html, publishDir, }), publishDir, linkIndex, publishKey: row.owner_id, publicBaseUrl: resolvePublicBaseUrl(), preferAssetDownload: false, }).html; }; const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => { const [rows] = await pool.query( `SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id, p.workspace_relative_path, av.storage_key, JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_id JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1 JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1 WHERE COALESCE(u.slug, u.username) = ? AND pr.url_slug = ? AND pr.status = 'online' AND pr.access_mode <> 'private_link' ORDER BY pr.published_at DESC LIMIT 1`, [ownerSlug, urlSlug], ); return resolveRow(rows[0], viewerId, password, requestMeta); }; const resolvePrivateLink = async (token, viewerId, requestMeta) => { const tokenHash = crypto.createHash('sha256').update(String(token)).digest('hex'); const [rows] = await pool.query( `SELECT pr.*, u.id AS owner_id, p.title, p.source_session_id, p.source_message_id, p.workspace_relative_path, av.storage_key, JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) AS source_relative_path FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_id JOIN h5_page_records p ON p.id = pr.page_id JOIN h5_page_versions pv ON pv.id = pr.page_version_id AND pv.immutable = 1 JOIN h5_asset_versions av ON av.asset_id = pv.bundle_asset_id AND av.version_no = 1 WHERE pr.token_hash = ? AND pr.status = 'online' AND pr.access_mode = 'private_link' LIMIT 1`, [tokenHash], ); return resolveRow(rows[0], viewerId, null, requestMeta); }; const getStats = async (userId, publicationId) => { const [publications] = await pool.query( `SELECT id, view_count, published_at, status FROM h5_publish_records WHERE id = ? AND user_id = ? LIMIT 1`, [publicationId, userId], ); const publication = publications[0]; if (!publication) throw publicationError('发布记录不存在', 'publication_not_found'); const since = Date.now() - 30 * 24 * 60 * 60 * 1000; const [daily] = await pool.query( `SELECT DATE_FORMAT(FROM_UNIXTIME(viewed_at / 1000), '%Y-%m-%d') AS day, COUNT(*) AS views FROM h5_publication_views WHERE publish_id = ? AND viewed_at >= ? GROUP BY day ORDER BY day`, [publicationId, since], ); const [devices] = await pool.query( `SELECT device_type, COUNT(*) AS views FROM h5_publication_views WHERE publish_id = ? GROUP BY device_type ORDER BY views DESC`, [publicationId], ); const [sources] = await pool.query( `SELECT COALESCE(referrer_host, 'direct') AS source, COUNT(*) AS views FROM h5_publication_views WHERE publish_id = ? GROUP BY source ORDER BY views DESC LIMIT 10`, [publicationId], ); return { publicationId, status: publication.status, totalViews: Number(publication.view_count), publishedAt: Number(publication.published_at), daily: daily.map((row) => ({ day: row.day, views: Number(row.views) })), devices: devices.map((row) => ({ deviceType: row.device_type, views: Number(row.views), })), sources: sources.map((row) => ({ source: row.source, views: Number(row.views) })), }; }; const getPublicHomepage = async (ownerSlug) => { const [owners] = await pool.query( `SELECT id, username, COALESCE(slug, username) AS slug, display_name FROM h5_users WHERE COALESCE(slug, username) = ? LIMIT 1`, [ownerSlug], ); const owner = owners[0]; if (!owner) throw publicationError('公开主页不存在', 'publication_not_found'); const [rows] = await pool.query( `SELECT pr.id, pr.page_id, pr.url_slug, pr.public_url, pr.view_count, pr.published_at, p.title, p.summary, p.template_id FROM h5_publish_records pr JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id WHERE pr.user_id = ? AND pr.status = 'online' AND pr.access_mode = 'public' ORDER BY pr.published_at DESC LIMIT 48`, [owner.id], ); return publicHomepageResponse(owner, rows); }; const cleanupExpiredUnconfirmedPublications = async (now = Date.now()) => { const [result] = await pool.query( `UPDATE h5_publish_records SET access_mode = 'private', expires_at = NULL, updated_at = ? WHERE access_mode = 'public' AND expires_at IS NOT NULL AND expires_at <= ? AND user_confirmed_at IS NULL AND status = 'online'`, [now, now], ); return { cleaned: result.affectedRows }; }; return { check, publish, getCurrent, refreshOnlinePublicationHtml, getPublicHomepage, getStats, offline, updatePublicationAccess, updatePublicationStatus, resolvePublic, resolvePrivateLink, cleanupExpiredUnconfirmedPublications, }; } async function registerPublicationArtifactForConversation({ registry, userId, page, publication, bundleAssetId, storageKey, htmlBytes, html, publishDir, now, }) { const sessionId = String(page?.source_session_id ?? '').trim(); if (!registry || !sessionId || !publication?.id) return null; try { const packageRecord = await registry.ensurePackage({ userId, sessionId, title: page.title, now, }); const artifact = await registry.recordArtifact({ id: `ca_${publication.id}`, packageId: packageRecord.id, artifactKind: 'public_html', role: 'assistant', assetId: bundleAssetId, pageId: page.page_id, publicationId: publication.id, messageId: page.source_message_id ?? null, displayName: `${page.title}.html`, mimeType: 'text/html', sizeBytes: htmlBytes, storageKey, canonicalUrl: publication.publicUrl, sortOrder: Number.isFinite(Number(publication.publishedAt)) ? Number(publication.publishedAt) : 0, now, }); const htmlRelativePath = page.source_relative_path ?? null; const companionArtifacts = await collectPublicationCompanionArtifacts({ publishDir, html, htmlRelativePath, }); await Promise.all( companionArtifacts.map((companion, index) => registry.recordArtifact({ id: `ca_${publication.id}_${crypto .createHash('sha256') .update(companion.workspacePath) .digest('hex') .slice(0, 12)}`, packageId: packageRecord.id, artifactKind: companionKindForWorkspacePath(companion.workspacePath), role: 'assistant', pageId: page.page_id, publicationId: publication.id, messageId: page.source_message_id ?? null, displayName: path.posix.basename(companion.workspacePath), mimeType: companionMimeForWorkspacePath(companion.workspacePath), sizeBytes: companion.sizeBytes, canonicalUrl: buildPublicUrl(resolvePublicBaseUrl(), userId, companion.workspacePath), sortOrder: (Number.isFinite(Number(publication.publishedAt)) ? Number(publication.publishedAt) : 0) + index + 1, now, }), ), ); await registry.writeManifestForSession({ userId, sessionId }); return artifact; } catch (error) { console.warn( '[MindSpace] conversation package publication artifact registration failed:', error instanceof Error ? error.message : error, ); return null; } } export const publicationInternals = { normalizeSlug, normalizeAccessMode, normalizePassword, normalizeExpiresAt, hashPassword, verifyPassword, deviceType, referrerHost, publicHomepageResponse, buildPublicationThumbnailFallback, replaceImgproxyStandardImageReferences, rewriteBrokenMindSpacePublicImageUrls, workspacePublicAssetRelativeUrl, rewriteWorkspacePublicAssetReferences, rewritePublicationCanonicalAssetUrls, localizePrivateImageReferences, collectPublicationCompanionArtifacts, prepareHtmlPublishContent, registerPublicationArtifactForConversation, scanContent, // re-exported from mindspace-content-scan.mjs };