Files
memind/mindspace-publications.mjs
T
john e3063ea806 fix(mindspace): refresh published HTML after workspace sync
Keep online publication snapshots aligned when public/*.html changes, default static page binds to public access with MindSpace delivery URLs, and split browser-safe workspace helpers out of the Vite import chain.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 20:44:04 +08:00

1466 lines
53 KiB
JavaScript

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 {
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 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(
/(?<![/:])public\/((?:images|\.tmp-images)\/[^"'<>@\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 publicBaseUrl = resolvePublicBaseUrl();
const publicUrl = privateToken
? `/s/${privateToken}`
: `${publicBaseUrl}/u/${encodeURIComponent(ownerSlug)}/pages/${result.urlSlug}`;
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],
);
let publicUrl = publication.public_url;
if (publication.access_mode === 'public') {
publicUrl = buildOnlinePublicationPublicUrl(ownerSlug, publication.url_slug);
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,
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
};