fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config. Co-authored-by: Cursor <cursoragent@cursor.com>
1522 lines
55 KiB
JavaScript
1522 lines
55 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 { notifyPublicationSearchEngines } from './mindspace-seo-notify.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';
|
|
import { assertPageWriteQuota } from './mindspace-space-quota.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),
|
|
userConfirmedAt: row.user_confirmed_at == null ? null : Number(row.user_confirmed_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(
|
|
/(?<![/:])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;
|
|
const notifyPublicationSearchEnginesFn =
|
|
options.notifyPublicationSearchEnginesFn ?? notifyPublicationSearchEngines;
|
|
|
|
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],
|
|
);
|
|
try {
|
|
assertPageWriteQuota(spaces[0], htmlBytes);
|
|
} catch (error) {
|
|
throw publicationError(error.message, error.code ?? 'quota_exceeded', error.details);
|
|
}
|
|
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,
|
|
pr.user_confirmed_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,
|
|
};
|
|
const response = publicationResponse(updated);
|
|
if (normalizedMode === 'public') {
|
|
void notifyPublicationSearchEnginesFn(pool, response).catch((error) => {
|
|
console.error(
|
|
'[Publication SEO] Baidu push failed:',
|
|
error instanceof Error ? error.message : error,
|
|
);
|
|
});
|
|
}
|
|
return response;
|
|
} 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);
|
|
const workspaceRelativePath =
|
|
resolvePageWorkspaceRelativePath(row);
|
|
return {
|
|
html: await refreshPublishedHtmlDownloadLinks(row, html),
|
|
publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }),
|
|
ownerId: row.owner_id,
|
|
workspaceRelativePath,
|
|
workspacePublicUrl:
|
|
resolveWorkspaceMindSpacePublicUrl({
|
|
h5Root,
|
|
userId: row.owner_id,
|
|
relativePath: workspaceRelativePath,
|
|
}),
|
|
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 = 'owner_only', 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
|
|
};
|