1240 lines
44 KiB
JavaScript
1240 lines
44 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 { replacePrivateResourceReferences, scanContent } from './mindspace-content-scan.mjs';
|
|
import { pageInternals } from './mindspace-pages.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 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,
|
|
);
|
|
}
|
|
|
|
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}`),
|
|
);
|
|
}
|
|
|
|
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) {
|
|
replacements.set(assetId, imgproxySigner.buildUrl(imgproxySigner.baseUrl, asset.storage_key, '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 = replaceImgproxyStandardImageReferences(
|
|
publishContent,
|
|
resolvePublicBaseUrl(),
|
|
);
|
|
publishContent = await localizePrivateImageReferences({
|
|
pool,
|
|
userId,
|
|
html: publishContent,
|
|
absoluteStoragePath,
|
|
imgproxySigner,
|
|
});
|
|
const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
|
|
const resolvedHtmlRelativePath =
|
|
htmlRelativePath
|
|
?? inferWorkspaceHtmlRelativePath({
|
|
pageTitle,
|
|
htmlContent: publishContent,
|
|
publishDir,
|
|
});
|
|
publishContent = rewriteWorkspacePublicAssetReferences(publishContent, resolvedHtmlRelativePath);
|
|
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.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 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 ? { buildUrl: (path, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path, 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 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 = 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 [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_publish_records SET status = 'offline', offline_at = ?, updated_at = ?
|
|
WHERE user_id = ? AND page_id = ? AND status = 'online'`,
|
|
[now, now, 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 updatePublicationStatus = async (userId, publicationId, { accessMode, 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 = normalizeAccessMode(accessMode);
|
|
const normalizedExpiresAt = normalizeExpiresAt(expiresAt, normalizedMode === 'time_limited');
|
|
const now = Date.now();
|
|
|
|
await conn.query(
|
|
`UPDATE h5_publish_records
|
|
SET access_mode = ?, expires_at = ?, user_confirmed_at = ?, updated_at = ?
|
|
WHERE id = ? AND user_id = ?`,
|
|
[normalizedMode, normalizedExpiresAt, now, now, publicationId, userId],
|
|
);
|
|
await conn.commit();
|
|
|
|
const updated = {
|
|
...publication,
|
|
access_mode: normalizedMode,
|
|
expires_at: normalizedExpiresAt,
|
|
user_confirmed_at: now,
|
|
updated_at: now,
|
|
};
|
|
return publicationResponse(updated);
|
|
} catch (error) {
|
|
await conn.rollback();
|
|
throw error;
|
|
} finally {
|
|
conn.release();
|
|
}
|
|
};
|
|
|
|
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 fs.readFile(await resolveReadableStoragePath(row.storage_key), 'utf8');
|
|
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,
|
|
},
|
|
};
|
|
};
|
|
|
|
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, av.storage_key
|
|
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, av.storage_key
|
|
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,
|
|
getPublicHomepage,
|
|
getStats,
|
|
offline,
|
|
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,
|
|
workspacePublicAssetRelativeUrl,
|
|
rewriteWorkspacePublicAssetReferences,
|
|
localizePrivateImageReferences,
|
|
collectPublicationCompanionArtifacts,
|
|
prepareHtmlPublishContent,
|
|
registerPublicationArtifactForConversation,
|
|
scanContent, // re-exported from mindspace-content-scan.mjs
|
|
};
|