import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs'; import { scanContent } from './mindspace-content-scan.mjs'; import { pageInternals } from './mindspace-pages.mjs'; const SCANNER_VERSION = 'mindspace-content-v1'; 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; } 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), })), }; } export function createPublicationService(pool, options = {}) { const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace')); const idFactory = options.idFactory ?? (() => crypto.randomUUID()); const publicPageLimit = Number(options.publicPageLimit ?? 5); const absoluteStoragePath = (storageKey) => { const resolved = path.resolve(storageRoot, storageKey); if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) { throw new Error('存储路径越界'); } return resolved; }; 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.user_id, p.space_id, pv.id AS page_version_id, pv.version_no, pv.bundle_asset_id, av.storage_key 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(absoluteStoragePath(row.storage_key), 'utf8'), }; }; 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; let publishContent = page.content; if (page.page_type === 'html') { publishContent = (await localizeGoogleFontsCss(page.content)).html; } const scan = scanContent(`${page.title}\n${page.summary ?? ''}\n${publishContent}`, { format: page.page_type === 'html' ? 'html' : 'text', }); 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, ...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); let publishContent = page.content; if (page.page_type === 'html') { publishContent = (await localizeGoogleFontsCss(page.content)).html; } 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], ); 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 [users] = await conn.query( `SELECT COALESCE(slug, username) AS public_slug FROM h5_users WHERE id = ? LIMIT 1`, [userId], ); const ownerSlug = users[0]?.public_slug; const publicUrl = privateToken ? `/s/${privateToken}` : `/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, 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, 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], ); await conn.commit(); return { ...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, }; } 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 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, ], ); return { html: await fs.readFile(absoluteStoragePath(row.storage_key), 'utf8'), publication: publicationResponse({ ...row, view_count: Number(row.view_count) + 1 }), }; }; const resolvePublic = async (ownerSlug, urlSlug, viewerId, password, requestMeta) => { const [rows] = await pool.query( `SELECT pr.*, u.id AS owner_id, av.storage_key FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_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, av.storage_key FROM h5_publish_records pr JOIN h5_users u ON u.id = pr.user_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); }; return { check, publish, getCurrent, getPublicHomepage, getStats, offline, resolvePublic, resolvePrivateLink, }; } export const publicationInternals = { normalizeSlug, normalizeAccessMode, normalizePassword, normalizeExpiresAt, hashPassword, verifyPassword, deviceType, referrerHost, publicHomepageResponse, scanContent, // re-exported from mindspace-content-scan.mjs };