diff --git a/db.mjs b/db.mjs index 2f41476..189e67f 100644 --- a/db.mjs +++ b/db.mjs @@ -771,6 +771,53 @@ export async function migrateSchema(pool) { ) `); + if (!(await columnExists(pool, 'h5_page_records', 'workspace_relative_path'))) { + await pool.query( + `ALTER TABLE h5_page_records + ADD COLUMN workspace_relative_path VARCHAR(512) NULL AFTER draft_content_ref`, + ); + } + if (!(await indexExists(pool, 'h5_page_records', 'idx_h5_pages_workspace_path'))) { + await pool.query( + `ALTER TABLE h5_page_records + ADD KEY idx_h5_pages_workspace_path (user_id, workspace_relative_path, updated_at)`, + ); + } + if (!(await columnExists(pool, 'h5_assets', 'workspace_relative_path'))) { + await pool.query( + `ALTER TABLE h5_assets + ADD COLUMN workspace_relative_path VARCHAR(512) NULL AFTER display_name`, + ); + } + if (!(await indexExists(pool, 'h5_assets', 'idx_h5_assets_workspace_path'))) { + await pool.query( + `ALTER TABLE h5_assets + ADD KEY idx_h5_assets_workspace_path (user_id, workspace_relative_path, updated_at)`, + ); + } + await pool.query( + `UPDATE h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + SET p.workspace_relative_path = JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) + WHERE p.workspace_relative_path IS NULL + AND JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path') IS NOT NULL + AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) <> ''`, + ); + await pool.query( + `UPDATE h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + SET a.workspace_relative_path = CASE + WHEN a.original_filename LIKE 'public/%' OR a.original_filename LIKE 'oa/%' + THEN a.original_filename + WHEN c.category_code IN ('public', 'oa') + THEN CONCAT(c.category_code, '/', a.original_filename) + ELSE NULL + END + WHERE a.workspace_relative_path IS NULL + AND a.status <> 'deleted' + AND c.category_code IN ('public', 'oa')`, + ); + await pool.query(` CREATE TABLE IF NOT EXISTS h5_user_feedback ( id CHAR(36) PRIMARY KEY, diff --git a/mindspace-assets.mjs b/mindspace-assets.mjs index e6779a2..e7635e4 100644 --- a/mindspace-assets.mjs +++ b/mindspace-assets.mjs @@ -25,6 +25,10 @@ import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES, normalizeImageForStorage, } from './user-image-normalize.mjs'; +import { + normalizeWorkspaceRelativePath, + resolveAssetWorkspaceRelativePath, +} from './mindspace-workspace-path.mjs'; const ALLOWED_EXTENSIONS = new Map([ ['.txt', 'text/plain'], @@ -184,9 +188,15 @@ function isPublicImageAsset(row) { return row?.category_code === 'public'; } +function isCanonicalPublicImageStorageKey(storageKey) { + const normalized = String(storageKey ?? '').replace(/\\/g, '/'); + if (isWorkspaceStorageKey(normalized)) return false; + return isPublicImageStorageKey(normalized); +} + function publicUrlForAsset(row) { if (!isPublicImageAsset(row)) return null; - if (row?.storage_key && isPublicImageStorageKey(row.storage_key)) { + if (row?.storage_key && isCanonicalPublicImageStorageKey(row.storage_key)) { return buildUserImagePublicUrl({ userId: row.user_id, storageKey: row.storage_key, @@ -194,9 +204,16 @@ function publicUrlForAsset(row) { originalFilename: row.original_filename, }); } + const workspacePath = normalizeWorkspaceRelativePath(row?.workspace_relative_path); + if (workspacePath && isWorkspaceStorageKey(row?.storage_key)) { + return createMindSpacePublicUrl({ + publicBaseUrl: resolvePublicBaseUrl(), + ownerKey: row.user_id, + relativePath: workspacePath, + }); + } if (row?.category_code === 'public' && row?.id) { - const legacy = publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename); - return legacy; + return publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename); } return null; } @@ -651,12 +668,17 @@ export function createAssetService(pool, options = {}) { const now = Date.now(); const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(upload.category_code); + const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({ + categoryCode: upload.category_code, + originalFilename: storedFilename, + explicitPath: workspaceRelativePath, + }); 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, + display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility, status, source_type, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`, [ assetId, userId, @@ -666,6 +688,7 @@ export function createAssetService(pool, options = {}) { storedMimeType, storedFilename, upload.filename, + indexedWorkspacePath, versionId, storedSizeBytes, storedChecksum, @@ -1191,12 +1214,17 @@ export function createAssetService(pool, options = {}) { } const now = Date.now(); const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(category.category_code); + const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({ + categoryCode: category.category_code, + originalFilename: storedFilename, + explicitPath: workspaceRelativePath, + }); 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, + display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility, status, source_type, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ assetId, userId, @@ -1206,6 +1234,7 @@ export function createAssetService(pool, options = {}) { storedMimeType, storedFilename, displayName || path.basename(storedFilename), + indexedWorkspacePath, versionId, storedSizeBytes, storedChecksum, @@ -1370,6 +1399,33 @@ export function createAssetService(pool, options = {}) { return expired; }; + const findAssetByWorkspaceRelativePath = async (userId, relativePath) => { + const normalized = normalizeWorkspaceRelativePath(relativePath); + if (!normalized) return null; + const [rows] = await pool.query( + `SELECT a.*, c.category_code, av.storage_key + FROM h5_assets a + JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id + JOIN h5_asset_versions av ON av.id = a.current_version_id + WHERE a.user_id = ? + AND a.status <> 'deleted' + AND ( + a.workspace_relative_path = ? + OR ( + (a.workspace_relative_path IS NULL OR a.workspace_relative_path = '') + AND ( + a.original_filename = ? + OR CONCAT(c.category_code, '/', a.original_filename) = ? + ) + ) + ) + ORDER BY a.updated_at DESC, a.id DESC + LIMIT 1`, + [userId, normalized, normalized, normalized], + ); + return rows[0] ? assetResponse(rows[0]) : null; + }; + return { storageRoot, createUpload, @@ -1379,6 +1435,7 @@ export function createAssetService(pool, options = {}) { cancelUpload, createChatAsset, listAssets, + findAssetByWorkspaceRelativePath, deleteAsset, readAsset, readPublicAsset, @@ -1440,5 +1497,6 @@ export const assetInternals = { normalizeFilename, expectedMimeType, assetTypeForMime, + publicUrlForAsset, registerUploadArtifactForConversation, }; diff --git a/mindspace-assets.test.mjs b/mindspace-assets.test.mjs index 02200b0..e9e533d 100644 --- a/mindspace-assets.test.mjs +++ b/mindspace-assets.test.mjs @@ -689,3 +689,30 @@ test('createUpload rejects another users category id', async () => { (error) => error.code === 'category_not_found', ); }); + +test('publicUrlForAsset uses MindSpace public URL for workspace-synced images', () => { + const url = assetInternals.publicUrlForAsset({ + id: 'asset-1', + user_id: 'user-1', + category_code: 'public', + mime_type: 'image/png', + original_filename: 'mapo-tofu-recipe.thumbnail.png', + workspace_relative_path: 'public/mapo-tofu-recipe.thumbnail.png', + storage_key: 'workspace://user-1/public/mapo-tofu-recipe.thumbnail.png', + }); + assert.match(url, /\/MindSpace\/user-1\/public\/mapo-tofu-recipe\.thumbnail\.png$/); +}); + +test('publicUrlForAsset does not route workspace image paths through imgproxy', () => { + const url = assetInternals.publicUrlForAsset({ + id: 'asset-2', + user_id: 'user-1', + category_code: 'public', + mime_type: 'image/jpeg', + original_filename: 'images/2026-07-04/photo.jpg', + workspace_relative_path: 'public/images/2026-07-04/photo.jpg', + storage_key: 'workspace://user-1/public/images/2026-07-04/photo.jpg', + }); + assert.match(url, /\/MindSpace\/user-1\/public\/images\/2026-07-04\/photo\.jpg$/); + assert.doesNotMatch(url, /20081|workspace:\/\//); +}); diff --git a/mindspace-chat-plaza.mjs b/mindspace-chat-plaza.mjs index cdc9e11..591be8f 100644 --- a/mindspace-chat-plaza.mjs +++ b/mindspace-chat-plaza.mjs @@ -1,3 +1,7 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; + export function slugFromPageTitle(title, pageId) { const ascii = String(title ?? '') .normalize('NFKC') @@ -126,9 +130,21 @@ export async function ensureChatPageForPlaza({ }); } -export async function ensurePagePublicationForPlaza({ userId, page, mindSpacePublications }) { +export async function ensurePagePublicationForPlaza({ + userId, + page, + mindSpacePublications, + refreshIfOnline = true, +}) { const existing = await mindSpacePublications.getCurrent(userId, page.id); - if (existing?.id) return existing; + if (existing?.id) { + if (refreshIfOnline && page.pageType === 'html' && mindSpacePublications.refreshOnlinePublicationHtml) { + return ( + (await mindSpacePublications.refreshOnlinePublicationHtml(userId, page.id)) ?? existing + ); + } + return existing; + } const preferredSlug = slugFromPageTitle(page.title, page.id); return mindSpacePublications.publish(userId, page.id, { @@ -139,6 +155,80 @@ export async function ensurePagePublicationForPlaza({ userId, page, mindSpacePub }); } +function throwAlreadyPublished(postId) { + throw Object.assign(new Error('该内容已发布到广场'), { + code: 'ALREADY_PUBLISHED', + details: { post_id: postId }, + }); +} + +async function assertWorkspacePathNotAlreadyOnPlaza({ userId, relativePath, mindSpacePages }) { + if (!mindSpacePages?.findPlazaContextByWorkspacePath) return null; + const context = await mindSpacePages.findPlazaContextByWorkspacePath(userId, relativePath); + if ( + context?.postId && + ['published', 'pending_review'].includes(String(context.postStatus ?? '')) + ) { + throwAlreadyPublished(context.postId); + } + return context; +} + +async function ensureWorkspaceHtmlPageForPlaza({ + userId, + relativePath, + content, + title, + mindSpacePages, +}) { + const normalized = normalizePublicHtmlRelativePath(relativePath); + let page = await mindSpacePages.findPageByRelativePath(userId, normalized); + const pageInput = { + title: title ?? page?.title ?? 'MindSpace 页面', + content, + contentFormat: 'html', + pageType: 'html', + categoryCode: 'draft', + }; + if (page) { + return mindSpacePages.updatePage(userId, page.id, { + ...pageInput, + expectedVersion: page.versionNo, + }); + } + return mindSpacePages.createFromChat(userId, pageInput, { + snapshot: { + content_mode: 'static_html', + relative_path: normalized, + }, + }); +} + +async function publishPageToPlaza({ userId, page, mindSpacePublications, plazaPosts }) { + const publication = await ensurePagePublicationForPlaza({ + userId, + page, + mindSpacePublications, + refreshIfOnline: true, + }); + + const { post } = await ensurePlazaPostForPublication({ + userId, + publication, + plazaPosts, + }); + + return { + pageId: page.id, + publicationId: publication.id, + publicUrl: publication.publicUrl ?? publication.public_url ?? null, + post: { + id: post.id, + status: post.status, + }, + }; +} + export async function ensurePlazaPostForPublication({ userId, publication, @@ -173,6 +263,7 @@ export async function quickPlazaFromChat({ throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' }); } + const { analysis } = bundle; const page = await ensureChatPageForPlaza({ userId: user.id, bundle, @@ -182,25 +273,146 @@ export async function quickPlazaFromChat({ skipThumbnail: true, }); - const publication = await ensurePagePublicationForPlaza({ + if (analysis.contentMode === 'static_html' && analysis.relativePath) { + await assertWorkspacePathNotAlreadyOnPlaza({ + userId: user.id, + relativePath: analysis.relativePath, + mindSpacePages, + }); + } + + return publishPageToPlaza({ userId: user.id, page, mindSpacePublications, - }); - - const { post } = await ensurePlazaPostForPublication({ - userId: user.id, - publication, plazaPosts, }); +} + +function titleFromPublicHtml(content, relativePath) { + const match = String(content ?? '').match(/]*>([^<]+)<\/title>/i); + const fromTitle = match?.[1]?.replace(/\s+/g, ' ').trim(); + if (fromTitle) return fromTitle; + const basename = path.basename(String(relativePath ?? ''), '.html'); + return basename || 'MindSpace 页面'; +} + +function normalizePublicHtmlRelativePath(relativePath) { + const normalized = normalizeWorkspaceRelativePath(relativePath); + if (!normalized || !normalized.startsWith('public/') || !normalized.toLowerCase().endsWith('.html')) { + throw Object.assign(new Error('仅支持 workspace 公开 HTML 页面'), { code: 'invalid_public_html_path' }); + } + return normalized; +} + +export async function getQuickPlazaFromPublicHtmlStatus({ + user, + relativePath, + mindSpacePages, + mindSpacePublications, + plazaPosts, +}) { + if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { + throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' }); + } + + const normalized = normalizePublicHtmlRelativePath(relativePath); + const context = await mindSpacePages.findPlazaContextByWorkspacePath?.(user.id, normalized); + if ( + context?.postId && + ['published', 'pending_review'].includes(String(context.postStatus ?? '')) + ) { + return { + published: true, + pageId: context.pageId, + publicationId: context.publicationId, + post: { + id: context.postId, + status: context.postStatus, + }, + }; + } + + const page = context?.pageId + ? { id: context.pageId } + : await mindSpacePages.findPageByRelativePath(user.id, normalized); + if (!page) { + return { published: false }; + } + + const publication = + context?.publicationId != null + ? { id: context.publicationId } + : await mindSpacePublications.getCurrent(user.id, page.id); + if (!publication?.id) { + return { published: false, pageId: page.id }; + } + + const post = await plazaPosts.findPostByPublicationId(publication.id, { userId: user.id }); + if (!post || !['published', 'pending_review'].includes(post.status)) { + return { + published: false, + pageId: page.id, + publicationId: publication.id, + }; + } return { + published: true, pageId: page.id, publicationId: publication.id, - publicUrl: publication.publicUrl ?? publication.public_url ?? null, post: { id: post.id, status: post.status, }, }; } + +export async function quickPlazaFromPublicHtml({ + user, + relativePath, + mindSpacePages, + mindSpacePublications, + plazaPosts, + publishDir, +}) { + if (!mindSpacePages || !mindSpacePublications || !plazaPosts) { + throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' }); + } + + const normalized = normalizePublicHtmlRelativePath(relativePath); + + await assertWorkspacePathNotAlreadyOnPlaza({ + userId: user.id, + relativePath: normalized, + mindSpacePages, + }); + + const resolvedRoot = path.resolve(publishDir); + const filePath = path.resolve(publishDir, normalized); + if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) { + throw Object.assign(new Error('路径越界'), { code: 'invalid_public_html_path' }); + } + + let content; + try { + content = await fs.readFile(filePath, 'utf8'); + } catch { + throw Object.assign(new Error('页面文件不存在'), { code: 'static_page_not_found' }); + } + + const page = await ensureWorkspaceHtmlPageForPlaza({ + userId: user.id, + relativePath: normalized, + content, + title: titleFromPublicHtml(content, normalized), + mindSpacePages, + }); + + return publishPageToPlaza({ + userId: user.id, + page, + mindSpacePublications, + plazaPosts, + }); +} diff --git a/mindspace-chat-plaza.test.mjs b/mindspace-chat-plaza.test.mjs index 40bd4b8..9503e07 100644 --- a/mindspace-chat-plaza.test.mjs +++ b/mindspace-chat-plaza.test.mjs @@ -14,3 +14,70 @@ test('slugFromPageTitle falls back to page when title has no ascii', () => { test('slugFromPageTitle keeps ascii slug and page suffix', () => { assert.equal(slugFromPageTitle('Travel Guide 2026', 'abcd-1234-5678-9012'), 'travel-guide-2026-abcd1234'); }); + +test('quickPlazaFromPublicHtml rejects non-public html paths', async () => { + await assert.rejects( + () => + import('./mindspace-chat-plaza.mjs').then(({ quickPlazaFromPublicHtml }) => + quickPlazaFromPublicHtml({ + user: { id: 'user-1' }, + relativePath: 'pages/demo.html', + mindSpacePages: {}, + mindSpacePublications: {}, + plazaPosts: {}, + publishDir: '/tmp/publish', + }), + ), + (error) => error?.code === 'invalid_public_html_path', + ); +}); + +test('getQuickPlazaFromPublicHtmlStatus reports published plaza posts', async () => { + const { getQuickPlazaFromPublicHtmlStatus } = await import('./mindspace-chat-plaza.mjs'); + const result = await getQuickPlazaFromPublicHtmlStatus({ + user: { id: 'user-1' }, + relativePath: 'public/demo.html', + mindSpacePages: { + async findPlazaContextByWorkspacePath(userId, relativePath) { + assert.equal(userId, 'user-1'); + assert.equal(relativePath, 'public/demo.html'); + return { + pageId: 'page-1', + publicationId: 'pub-1', + postId: 'post-1', + postStatus: 'published', + }; + }, + }, + mindSpacePublications: {}, + plazaPosts: {}, + }); + + assert.equal(result.published, true); + assert.equal(result.post.id, 'post-1'); +}); + +test('quickPlazaFromPublicHtml blocks duplicate plaza posts for the same workspace html', async () => { + const { quickPlazaFromPublicHtml } = await import('./mindspace-chat-plaza.mjs'); + await assert.rejects( + () => + quickPlazaFromPublicHtml({ + user: { id: 'user-1' }, + relativePath: 'public/demo.html', + mindSpacePages: { + async findPlazaContextByWorkspacePath() { + return { + pageId: 'page-chat', + publicationId: 'pub-chat', + postId: 'post-chat', + postStatus: 'published', + }; + }, + }, + mindSpacePublications: {}, + plazaPosts: {}, + publishDir: '/tmp/publish', + }), + (error) => error?.code === 'ALREADY_PUBLISHED' && error?.details?.post_id === 'post-chat', + ); +}); diff --git a/mindspace-page-sync.mjs b/mindspace-page-sync.mjs index d2a4006..452801d 100644 --- a/mindspace-page-sync.mjs +++ b/mindspace-page-sync.mjs @@ -33,13 +33,19 @@ async function findPageByWorkspaceRelativePath(pool, userId, relativePath) { const [rows] = await pool.query( `SELECT p.id, p.updated_at FROM h5_page_records p - JOIN h5_page_versions pv ON pv.id = p.current_version_id + LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id WHERE p.user_id = ? AND p.status <> 'deleted' - AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ? + AND ( + p.workspace_relative_path = ? + OR ( + (p.workspace_relative_path IS NULL OR p.workspace_relative_path = '') + AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ? + ) + ) ORDER BY p.updated_at DESC, p.id DESC LIMIT 1`, - [userId, relativePath], + [userId, relativePath, relativePath], ); const row = rows[0]; if (!row) return null; @@ -58,16 +64,18 @@ function parseJsonColumn(value, fallback = {}) { async function loadIndexedWorkspacePages(pool, userId) { const [rows] = await pool.query( - `SELECT p.id, p.updated_at, p.source_asset_id, pv.source_snapshot_json + `SELECT p.id, p.updated_at, p.source_asset_id, p.workspace_relative_path, pv.source_snapshot_json FROM h5_page_records p - JOIN h5_page_versions pv ON pv.id = p.current_version_id + LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id WHERE p.user_id = ? AND p.status <> 'deleted'`, [userId], ); const byPath = new Map(); for (const row of rows) { const snapshot = parseJsonColumn(row.source_snapshot_json); - const relativePath = normalizePublicHtmlPath(snapshot.relative_path); + const relativePath = + normalizePublicHtmlPath(row.workspace_relative_path) ?? + normalizePublicHtmlPath(snapshot.relative_path); if (relativePath) { byPath.set(relativePath, { id: row.id, diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index 3c2fc53..c3d5815 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -446,6 +446,8 @@ export function createPageService(pool, options = {}) { source.createdAt != null && Number.isFinite(Number(source.createdAt)) ? Math.floor(Number(source.createdAt)) : Date.now(); + const pageWorkspaceRelativePath = + normalizeWorkspaceRelativePath(source.snapshot?.relative_path) || null; const contentAssetType = normalized.contentFormat === 'html' ? 'html' : 'markdown'; const contentMimeType = @@ -498,8 +500,8 @@ export function createPageService(pool, options = {}) { `INSERT INTO h5_page_records (id, user_id, space_id, category_id, source_session_id, source_message_id, source_asset_id, title, summary, page_type, template_id, draft_content_ref, - current_version_id, status, visibility, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`, + workspace_relative_path, current_version_id, status, visibility, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`, [ pageId, userId, @@ -513,6 +515,7 @@ export function createPageService(pool, options = {}) { normalized.pageType, normalized.templateId, stored.storageKey, + pageWorkspaceRelativePath, pageVersionId, now, now, @@ -539,7 +542,7 @@ export function createPageService(pool, options = {}) { await conn.query( `UPDATE h5_page_records SET title = ?, summary = ?, page_type = ?, template_id = ?, - draft_content_ref = ?, current_version_id = ?, updated_at = ? + draft_content_ref = ?, workspace_relative_path = ?, current_version_id = ?, updated_at = ? WHERE id = ? AND user_id = ?`, [ normalized.title, @@ -547,6 +550,7 @@ export function createPageService(pool, options = {}) { normalized.pageType, normalized.templateId, stored.storageKey, + pageWorkspaceRelativePath, pageVersionId, now, pageId, @@ -659,14 +663,52 @@ export function createPageService(pool, options = {}) { LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online' WHERE p.user_id = ? AND p.status <> 'deleted' - AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ? + AND ( + p.workspace_relative_path = ? + OR ( + (p.workspace_relative_path IS NULL OR p.workspace_relative_path = '') + AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ? + ) + ) ORDER BY p.updated_at DESC, p.id DESC LIMIT 1`, - [userId, normalized], + [userId, normalized, normalized], ); return rows[0] ? pageResponse(rows[0]) : null; }; + const findPlazaContextByWorkspacePath = async (userId, relativePath) => { + const normalized = normalizeWorkspaceRelativePath(relativePath); + if (!normalized) return null; + const [rows] = await pool.query( + `SELECT p.id AS page_id, pr.id AS publication_id, pp.id AS post_id, pp.status AS post_status + FROM h5_page_records p + JOIN h5_page_versions pv ON pv.id = p.current_version_id + LEFT JOIN h5_publish_records pr ON pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online' + LEFT JOIN plaza_posts pp ON pp.publication_id = pr.id AND pp.user_id = p.user_id AND pp.status != 'hidden' + WHERE p.user_id = ? + AND p.status <> 'deleted' + AND ( + p.workspace_relative_path = ? + OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ? + ) + ORDER BY + CASE pp.status WHEN 'published' THEN 0 WHEN 'pending_review' THEN 1 ELSE 2 END, + p.updated_at DESC, + p.id DESC + LIMIT 1`, + [userId, normalized, normalized], + ); + const row = rows[0]; + if (!row) return null; + return { + pageId: row.page_id, + publicationId: row.publication_id ?? null, + postId: row.post_id ?? null, + postStatus: row.post_status ?? null, + }; + }; + const listPages = async (userId, filters = {}) => { const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`]; const params = [userId]; @@ -1448,6 +1490,7 @@ export function createPageService(pool, options = {}) { findPageBySourceAsset, findPageBySourceMessage, findPageByRelativePath, + findPlazaContextByWorkspacePath, getPage, getDeletePreview, deletePage, diff --git a/mindspace-public-share-widget.mjs b/mindspace-public-share-widget.mjs new file mode 100644 index 0000000..f4163b3 --- /dev/null +++ b/mindspace-public-share-widget.mjs @@ -0,0 +1,205 @@ +import crypto from 'node:crypto'; + +const PUBLIC_FILE_SHARE_SCRIPT = `(function(){ +var root=document.querySelector('[data-mindspace-public-share]'); +if(!root)return; +var shareButton=root.querySelector('[data-action="share"]'); +var captureButton=root.querySelector('[data-action="capture"]'); +var plazaButton=root.querySelector('[data-action="plaza"]'); +var dialog=root.querySelector('[data-mindspace-public-share-dialog]'); +var plazaCancel=root.querySelector('[data-action="plaza-cancel"]'); +var plazaConfirm=root.querySelector('[data-action="plaza-confirm"]'); +var plazaAlreadyClose=root.querySelector('[data-action="plaza-already-close"]'); +var plazaSuccessClose=root.querySelector('[data-action="plaza-success-close"]'); +var plazaErrorClose=root.querySelector('[data-action="plaza-error-close"]'); +var plazaAlreadyLink=root.querySelector('[data-plaza-already-link]'); +var plazaSuccessLink=root.querySelector('[data-plaza-success-link]'); +var plazaViews={ + confirm:root.querySelector('[data-plaza-view="confirm"]'), + loading:root.querySelector('[data-plaza-view="loading"]'), + already:root.querySelector('[data-plaza-view="already"]'), + success:root.querySelector('[data-plaza-view="success"]'), + error:root.querySelector('[data-plaza-view="error"]') +}; +var status=root.querySelector('small'); +var timer=null; +var publishing=false; +var checking=false; +function setStatus(text,err,persist){ +if(!status)return; +status.textContent=text||''; +status.classList.toggle('is-error',!!err); +if(timer)clearTimeout(timer); +if(text&&!persist)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},3200); +} +function cleanUrl(){var url=new URL(location.href);url.hash='';url.searchParams.delete('download');url.searchParams.delete('export');return url.toString();} +function withParam(url,key,value){var next=new URL(url);next.searchParams.set(key,value);return next.toString();} +function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');} +async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);} +function workspaceRelativePath(){var parts=location.pathname.split('/').filter(Boolean);if(parts[0]==='MindSpace'&&parts.length>=3)return parts.slice(2).join('/');return'';} +function showPlazaView(name){Object.keys(plazaViews).forEach(function(key){var view=plazaViews[key];if(view)view.hidden=key!==name;});} +function openPlazaDialog(){if(!dialog)return;dialog.hidden=false;showPlazaView('loading');void refreshPlazaDialog();} +function closePlazaDialog(){if(dialog)dialog.hidden=true;showPlazaView('confirm');} +function apiErrorBody(payload){return payload&&(payload.error||payload)||{};} +function resolvePlazaUrl(payload){var body=apiErrorBody(payload);if(body.plaza_url)return body.plaza_url;var details=body.details;if(details&&details.plaza_url)return details.plaza_url;var postId=details&&(details.post_id||details.postId);if(!postId)return'';return location.origin.replace(/:\\d+$/,'')+':3001/plaza/p/'+encodeURIComponent(postId);} +function showAlreadyPublished(plazaUrl){showPlazaView('already');if(plazaAlreadyLink){if(plazaUrl){plazaAlreadyLink.href=plazaUrl;plazaAlreadyLink.hidden=false;}else{plazaAlreadyLink.hidden=true;}}} +async function refreshPlazaDialog(){ +if(checking)return; +var relativePath=workspaceRelativePath(); +if(!relativePath){showPlazaView('error');return;} +checking=true; +try{ +var res=await fetch('/api/mindspace/v1/pages/quick-plaza-from-public-html/status?relative_path='+encodeURIComponent(relativePath),{credentials:'same-origin'}); +var payload=await res.json().catch(function(){return{};}); +if(!res.ok){ +if(res.status===401)throw new Error('请先登录 MindSpace'); +var errBody=apiErrorBody(payload); +throw new Error(errBody.message||'无法检查发布状态'); +} +var data=payload.data||{}; +if(data.published){showAlreadyPublished(data.plaza_url||resolvePlazaUrl({details:{post_id:data.post&&data.post.id}}));return;} +showPlazaView('confirm'); +}catch(e){ +showPlazaView('error'); +setStatus(e&&e.message?e.message:'无法检查发布状态',true); +} +finally{checking=false;} +} +shareButton&&shareButton.addEventListener('click',async function(){var url=cleanUrl();var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}}); +captureButton&&captureButton.addEventListener('click',function(){setStatus('正在生成长图,请稍候');var link=document.createElement('a');link.href=withParam(cleanUrl(),'download','long-image');link.download='';document.body.appendChild(link);link.click();link.remove();}); +plazaButton&&plazaButton.addEventListener('click',openPlazaDialog); +plazaCancel&&plazaCancel.addEventListener('click',closePlazaDialog); +plazaAlreadyClose&&plazaAlreadyClose.addEventListener('click',closePlazaDialog); +plazaSuccessClose&&plazaSuccessClose.addEventListener('click',closePlazaDialog); +plazaErrorClose&&plazaErrorClose.addEventListener('click',closePlazaDialog); +dialog&&dialog.addEventListener('click',function(event){if(event.target===dialog)closePlazaDialog();}); +plazaConfirm&&plazaConfirm.addEventListener('click',async function(){ +if(publishing)return; +var relativePath=workspaceRelativePath(); +if(!relativePath){setStatus('无法识别页面路径',true);closePlazaDialog();return;} +publishing=true; +plazaConfirm.disabled=true; +showPlazaView('loading'); +try{ +var res=await fetch('/api/mindspace/v1/pages/quick-plaza-from-public-html',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({relative_path:relativePath})}); +var payload=await res.json().catch(function(){return{};}); +if(!res.ok){ +if(res.status===401)throw new Error('请先登录 MindSpace'); +var errBody=apiErrorBody(payload); +var plazaUrl=resolvePlazaUrl(payload); +if(errBody.code==='ALREADY_PUBLISHED'){showAlreadyPublished(plazaUrl);setStatus('该内容已在广场发布',false,true);return;} +throw new Error(errBody.message||'发布失败'); +} +var data=payload.data||{}; +var successUrl=data.plaza_url||resolvePlazaUrl({details:{post_id:data.post&&data.post.id}}); +showPlazaView('success'); +if(plazaSuccessLink&&successUrl){plazaSuccessLink.href=successUrl;plazaSuccessLink.hidden=false;} +else if(plazaSuccessLink){plazaSuccessLink.hidden=true;} +setStatus('发布成功',false,true); +if(successUrl)window.open(successUrl,'_blank','noopener,noreferrer'); +}catch(e){ +showPlazaView('error'); +setStatus(e&&e.message?e.message:'发布失败',true); +}finally{publishing=false;plazaConfirm.disabled=false;} +}); +})();`; + +const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto + .createHash('sha256') + .update(PUBLIC_FILE_SHARE_SCRIPT) + .digest('base64'); + +export function injectPublicFileShareButton(html) { + const source = String(html ?? ''); + if (!source || source.includes('data-mindspace-public-share')) { + return { html: source, scriptHashes: [] }; + } + const markup = ` + +
+ + +
+ + + +
+
+`; + if (/<\/body>/i.test(source)) { + return { + html: source.replace(/<\/body>/i, `${markup}`), + scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH], + }; + } + return { + html: `${source}${markup}`, + scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH], + }; +} + +export const publicShareWidgetInternals = { + PUBLIC_FILE_SHARE_SCRIPT_HASH, +}; diff --git a/mindspace-public-share-widget.test.mjs b/mindspace-public-share-widget.test.mjs new file mode 100644 index 0000000..8b8ccbe --- /dev/null +++ b/mindspace-public-share-widget.test.mjs @@ -0,0 +1,20 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs'; + +test('injectPublicFileShareButton adds plaza entry and confirm dialog', () => { + const result = injectPublicFileShareButton('

Hi

'); + assert.match(result.html, /data-action="plaza"/); + assert.match(result.html, /发布到 Plaza/); + assert.match(result.html, /data-action="plaza-confirm"/); + assert.match(result.html, /quick-plaza-from-public-html/); + assert.match(result.html, /quick-plaza-from-public-html\/status/); + assert.match(result.html, /data-plaza-view="already"/); + assert.match(result.html, /已在广场发布/); + assert.match(result.html, /data-mindspace-public-share-dialog-panel role="dialog"/); + assert.doesNotMatch(result.html, /data-mindspace-public-share-dialog-panel"/); + assert.match(result.html, /color:#fff !important/); + assert.match(result.html, /apiErrorBody/); + assert.match(result.html, /ALREADY_PUBLISHED/); + assert.equal(result.scriptHashes.length, 1); +}); diff --git a/mindspace-publications.mjs b/mindspace-publications.mjs index 3cf8388..f6a17ac 100644 --- a/mindspace-publications.mjs +++ b/mindspace-publications.mjs @@ -250,6 +250,16 @@ function replaceImgproxyStandardImageReferences(html, publicBaseUrl) { ); } +/** 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(/^\/+/, ''); @@ -262,11 +272,26 @@ export function rewriteWorkspacePublicAssetReferences(html, htmlRelativePath) { const source = String(html ?? ''); if (!source) return source; return source.replace( - /public\/((?:images|\.tmp-images)\/[^"'<>@\s)]+(?:\?[^"'<>)\s]*)?)/gi, + /(?@\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, @@ -297,7 +322,9 @@ async function localizePrivateImageReferences({ if (!asset) continue; if (imgproxySigner) { - replacements.set(assetId, imgproxySigner.buildUrl(imgproxySigner.baseUrl, asset.storage_key, 'display')); + 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)); @@ -335,6 +362,12 @@ async function prepareHtmlPublishContent({ 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 @@ -344,6 +377,7 @@ async function prepareHtmlPublishContent({ publishDir, }); publishContent = rewriteWorkspacePublicAssetReferences(publishContent, resolvedHtmlRelativePath); + publishContent = rewritePublicationCanonicalAssetUrls(publishContent, ownerSlug); const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, userId); publishContent = rewriteRelativeDownloadLinks(publishContent, { htmlRelativePath: resolvedHtmlRelativePath, @@ -483,7 +517,13 @@ export function createPublicationService(pool, options = {}) { }), absoluteStoragePath, h5Root, - imgproxySigner: imgproxySigner ? { buildUrl: (path, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path, preset) } : null, + imgproxySigner: imgproxySigner + ? { + baseUrl: imgproxySigner.baseUrl, + buildUrl: (storagePath, preset = 'display') => + imgproxySigner.buildUrl(imgproxySigner.baseUrl, storagePath, preset), + } + : null, }); }; @@ -535,6 +575,18 @@ export function createPublicationService(pool, options = {}) { 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'); @@ -632,6 +684,17 @@ export function createPublicationService(pool, options = {}) { 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`, @@ -712,11 +775,6 @@ export function createPublicationService(pool, options = {}) { 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 = ? @@ -850,6 +908,67 @@ export function createPublicationService(pool, options = {}) { return publicationResponse(rows[0]); }; + const refreshOnlinePublicationHtml = async (userId, pageId) => { + const [pubRows] = await pool.query( + `SELECT pr.id, pr.url_slug, pr.public_url, pr.page_version_id, pr.access_mode, + pr.status, pr.view_count, pr.published_at, pr.offline_at, pr.expires_at, + pv.bundle_asset_id, av.id AS asset_version_id, av.storage_key + FROM h5_publish_records pr + JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id + JOIN h5_page_versions pv ON pv.id = pr.page_version_id + JOIN h5_assets a ON a.id = pv.bundle_asset_id + JOIN h5_asset_versions av ON av.asset_id = a.id AND av.version_no = 1 + WHERE pr.page_id = ? AND pr.user_id = ? AND pr.status = 'online' + ORDER BY pr.published_at DESC + LIMIT 1`, + [pageId, userId], + ); + const publication = pubRows[0]; + if (!publication) return null; + + const [pageRows] = await pool.query( + `SELECT current_version_id FROM h5_page_records + WHERE id = ? AND user_id = ? AND status <> 'deleted' + LIMIT 1`, + [pageId, userId], + ); + const currentVersionId = pageRows[0]?.current_version_id; + if (!currentVersionId) { + throw publicationError('页面不存在', 'page_not_found'); + } + + const page = await loadVersion(userId, pageId, currentVersionId); + if (page.page_type !== 'html') { + return publicationResponse(publication); + } + + const ownerSlug = await loadOwnerSlug(userId); + const publishContent = await preparePublishContent(page, ownerSlug, publication.url_slug); + const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent }); + const htmlBytes = Buffer.byteLength(html); + const checksum = crypto.createHash('sha256').update(html).digest('hex'); + const writtenPath = absoluteStoragePath(publication.storage_key); + await fs.mkdir(path.dirname(writtenPath), { recursive: true }); + await fs.writeFile(writtenPath, html); + const now = Date.now(); + await pool.query( + `UPDATE h5_assets + SET size_bytes = ?, checksum = ?, updated_at = ? + WHERE id = ? AND user_id = ?`, + [htmlBytes, checksum, now, publication.bundle_asset_id, userId], + ); + await pool.query( + `UPDATE h5_asset_versions + SET size_bytes = ?, checksum = ? + WHERE id = ?`, + [htmlBytes, checksum, publication.asset_version_id], + ); + return publicationResponse({ + ...publication, + updated_at: now, + }); + }; + const updatePublicationStatus = async (userId, publicationId, { accessMode, expiresAt }) => { const conn = await pool.getConnection(); try { @@ -1128,6 +1247,7 @@ export function createPublicationService(pool, options = {}) { check, publish, getCurrent, + refreshOnlinePublicationHtml, getPublicHomepage, getStats, offline, @@ -1231,8 +1351,10 @@ export const publicationInternals = { publicHomepageResponse, buildPublicationThumbnailFallback, replaceImgproxyStandardImageReferences, + rewriteBrokenMindSpacePublicImageUrls, workspacePublicAssetRelativeUrl, rewriteWorkspacePublicAssetReferences, + rewritePublicationCanonicalAssetUrls, localizePrivateImageReferences, collectPublicationCompanionArtifacts, prepareHtmlPublishContent, diff --git a/mindspace-publications.test.mjs b/mindspace-publications.test.mjs index 0243a58..ff0a291 100644 --- a/mindspace-publications.test.mjs +++ b/mindspace-publications.test.mjs @@ -248,6 +248,104 @@ test('prepareHtmlPublishContent inlines owned private image assets before scanni } }); +test('prepareHtmlPublishContent signs private image assets when imgproxy signer is enabled', async () => { + const storageKey = 'users/user-1/images/2026-07-03/demo-asset-1.jpg'; + const pool = { + async query(sql, params) { + if (sql.includes('original_filename')) { + return [[]]; + } + assert.match(sql, /h5_assets/); + assert.equal(params[0], 'user-1'); + assert.deepEqual(params[1], ['asset-1']); + return [ + [ + { + id: 'asset-1', + mime_type: 'image/png', + storage_key: storageKey, + }, + ], + ]; + }, + }; + const imgproxySigner = { + baseUrl: 'https://img.example.test', + buildUrl: (baseUrl, storagePath, preset = 'display') => { + assert.equal(baseUrl, 'https://img.example.test'); + assert.equal(storagePath, storageKey); + assert.equal(preset, 'display'); + return `https://img.example.test/sig/plain/local:///${storagePath}`; + }, + }; + + const prepared = await publicationInternals.prepareHtmlPublishContent({ + pool, + userId: 'user-1', + html: '', + ownerSlug: 'john', + urlSlug: 'night-market-essay', + absoluteStoragePath: () => { + throw new Error('should not read private storage when imgproxy is enabled'); + }, + imgproxySigner: { + baseUrl: imgproxySigner.baseUrl, + buildUrl: (storagePath, preset = 'display') => + imgproxySigner.buildUrl(imgproxySigner.baseUrl, storagePath, preset), + }, + }); + + assert.doesNotMatch(prepared, /\/api\/mindspace\/v1\/assets\//); + assert.doesNotMatch(prepared, /thumbnail\.png/); + assert.doesNotMatch(prepared, /plain\/local:\/\//); +}); + +test('prepareHtmlPublishContent converts signed private images to public standard urls', async () => { + const storageKey = + 'users/user-1/images/2026-07-03/60270b88dda7b7ccb8a49d8e2746e80b-asset-1.jpg'; + const pool = { + async query(sql, params) { + if (sql.includes('original_filename')) { + return [[]]; + } + assert.match(sql, /h5_assets/); + assert.equal(params[0], 'user-1'); + assert.deepEqual(params[1], ['asset-1']); + return [ + [ + { + id: 'asset-1', + mime_type: 'image/jpeg', + storage_key: storageKey, + }, + ], + ]; + }, + }; + const imgproxySigner = { + baseUrl: 'https://img.example.test', + buildUrl: (storagePath, preset = 'display') => + `https://img.example.test/sig/rs:fit:1280:1280:0/q:85/plain/local:///${storagePath}@${preset}`, + }; + + const prepared = await publicationInternals.prepareHtmlPublishContent({ + pool, + userId: 'user-1', + html: '', + ownerSlug: 'john', + urlSlug: 'night-market-essay', + absoluteStoragePath: () => { + throw new Error('should not read private storage when imgproxy is enabled'); + }, + imgproxySigner, + }); + + assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-07-03\//); + assert.doesNotMatch(prepared, /\/api\/mindspace\/v1\/assets\//); + assert.doesNotMatch(prepared, /thumbnail\.png/); + assert.doesNotMatch(prepared, /plain\/local:\/\//); +}); + test('prepareHtmlPublishContent rewrites imgproxy local image urls to public standard images', async () => { const prepared = await publicationInternals.prepareHtmlPublishContent({ pool: { @@ -264,7 +362,7 @@ test('prepareHtmlPublishContent rewrites imgproxy local image urls to public sta }, }); - assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg/); + assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/); assert.doesNotMatch(prepared, /plain\/local:\/\//); }); @@ -544,12 +642,44 @@ test('prepareHtmlPublishContent rewrites imgproxy urls in srcset and css url con }, }); - assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/thumb\.jpg 1x/); - assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg 2x/); - assert.match(prepared, /url\(https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/bg\.jpg\)/); + assert.match(prepared, /\/MindSpace\/user-1\/public\/images\/2026-06-29\/thumb\.jpg 1x/); + assert.match(prepared, /\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg 2x/); + assert.match(prepared, /url\(https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/bg\.jpg\)/); assert.doesNotMatch(prepared, /plain\/local:\/\//); }); +test('prepareHtmlPublishContent preserves absolute MindSpace public image urls', async () => { + const prepared = await publicationInternals.prepareHtmlPublishContent({ + pool: { + async query() { + return [[]]; + }, + }, + userId: 'user-1', + html: 'demo', + ownerSlug: 'john', + urlSlug: 'story', + htmlRelativePath: 'public/index.html', + absoluteStoragePath: () => { + throw new Error('should not read private storage'); + }, + }); + + assert.match( + prepared, + /src="http:\/\/127\.0\.0\.1:5173\/MindSpace\/user-1\/public\/images\/2026-07-05\/demo\.jpg"/, + ); + assert.doesNotMatch(prepared, /\/MindSpace\/user-1\/images\/2026-07-05\//); +}); + +test('rewriteBrokenMindSpacePublicImageUrls restores missing public segment', () => { + const html = + 'demo'; + const out = publicationInternals.rewriteBrokenMindSpacePublicImageUrls(html); + assert.match(out, /\/MindSpace\/user-1\/public\/images\/2026-07-05\/demo\.jpg/); + assert.doesNotMatch(out, /\/MindSpace\/user-1\/images\/2026-07-05\//); +}); + test('prepareHtmlPublishContent rewrites workspace public asset paths relative to html output', async () => { const prepared = await publicationInternals.prepareHtmlPublishContent({ pool: { @@ -572,9 +702,82 @@ test('prepareHtmlPublishContent rewrites workspace public asset paths relative t }, }); - assert.match(prepared, /"cover":"\.\.\/images\/2026-06-29\/hero\.jpg"/); - assert.match(prepared, /url\('\.\.\/images\/2026-06-29\/bg\.jpg'\)/); - assert.match(prepared, /src="\.\.\/\.tmp-images\/card\.png"/); - assert.doesNotMatch(prepared, /public\/images\//); - assert.doesNotMatch(prepared, /public\/\.tmp-images\//); + assert.match(prepared, /"cover":"\/u\/john\/public\/images\/2026-06-29\/hero\.jpg"/); + assert.match(prepared, /url\('\/u\/john\/public\/images\/2026-06-29\/bg\.jpg'\)/); + assert.match(prepared, /src="\/u\/john\/public\/\.tmp-images\/card\.png"/); + assert.doesNotMatch(prepared, /src="public\//); + assert.doesNotMatch(prepared, /"cover":"public\//); +}); + +test('rewritePublicationCanonicalAssetUrls rewrites workspace tmp image paths for /u pages routes', () => { + const html = [ + '', + '', + `
`, + ].join(''); + const out = publicationInternals.rewritePublicationCanonicalAssetUrls(html, 'john'); + assert.match(out, /src="\/u\/john\/public\/\.tmp-images\/one\.jpg"/); + assert.match(out, /src="\/u\/john\/public\/\.tmp-images\/two\.jpg"/); + assert.match(out, /url\('\/u\/john\/public\/\.tmp-images\/three\.jpg'\)/); +}); + +test('publish rejects republish while page is still online', async () => { + const pageRow = { + page_id: 'page-1', + title: 'Report', + summary: '', + page_type: 'article', + template_id: 'editorial', + current_version_id: 'version-1', + source_session_id: null, + source_message_id: null, + user_id: 'user-1', + space_id: 'space-1', + page_version_id: 'version-1', + version_no: 1, + bundle_asset_id: null, + storage_key: 'users/user-1/pages/page-1/versions/v1.md', + content: '# Report', + }; + const pool = { + async query(sql, params = []) { + if (sql.includes('FROM h5_page_records p') && sql.includes('JOIN h5_page_versions')) { + return [[pageRow]]; + } + if ( + sql.includes('FROM h5_publish_records') && + sql.includes("status = 'online'") && + sql.includes('page_id = ?') + ) { + return [[{ id: 'pub-online', url_slug: 'report', public_url: '/u/john/pages/report' }]]; + } + if (sql.includes('FROM h5_publish_records pr') && sql.includes('pr.page_id <>')) { + return [[]]; + } + return [[]]; + }, + async getConnection() { + throw new Error('publish should fail before opening a transaction'); + }, + }; + const service = createPublicationService(pool, { + storageRoot: '/tmp', + h5Root: '/tmp', + idFactory: () => 'id-1', + }); + + await assert.rejects( + () => + service.publish('user-1', 'page-1', { + pageVersionId: 'version-1', + accessMode: 'public', + urlSlug: 'report', + acknowledgedFindingIds: [], + }), + (error) => { + assert.equal(error.code, 'page_already_online'); + assert.equal(error.details.publicationId, 'pub-online'); + return true; + }, + ); }); diff --git a/mindspace-server-adapter-contract.mjs b/mindspace-server-adapter-contract.mjs index b3200ee..48e4f2e 100644 --- a/mindspace-server-adapter-contract.mjs +++ b/mindspace-server-adapter-contract.mjs @@ -31,6 +31,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ 'findPageByRelativePath', 'findPageBySourceAsset', 'findPageBySourceMessage', + 'findPlazaContextByWorkspacePath', 'getDeletePreview', 'getPage', 'listPages', @@ -56,6 +57,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ 'getStats', 'offline', 'publish', + 'refreshOnlinePublicationHtml', 'resolvePrivateLink', 'resolvePublic', 'updatePublicationStatus', diff --git a/mindspace-service/mindspace-rpc-server.test.mjs b/mindspace-service/mindspace-rpc-server.test.mjs index af05657..c453298 100644 --- a/mindspace-service/mindspace-rpc-server.test.mjs +++ b/mindspace-service/mindspace-rpc-server.test.mjs @@ -47,6 +47,7 @@ function createStubAdapter() { async createPage() { return { ok: true }; }, async deletePage() { return { ok: true }; }, async findPageByRelativePath() { return null; }, + async findPlazaContextByWorkspacePath() { return null; }, async findPageBySourceAsset() { return null; }, async getDeletePreview() { return { ok: true }; }, async getPage() { return { ok: true }; }, @@ -74,6 +75,7 @@ function createStubAdapter() { async check() { return { ok: true }; }, async cleanupExpiredUnconfirmedPublications() { return { cleaned: 0 }; }, async getCurrent() { return null; }, + async refreshOnlinePublicationHtml() { return null; }, async getPublicHomepage() { return null; }, async getStats() { return { ok: true }; }, async offline() { return { ok: true }; }, diff --git a/mindspace-workspace-path.mjs b/mindspace-workspace-path.mjs new file mode 100644 index 0000000..2b12dcb --- /dev/null +++ b/mindspace-workspace-path.mjs @@ -0,0 +1,29 @@ +import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs'; + +export { normalizeWorkspaceRelativePath }; + +export function resolvePageWorkspaceRelativePath(snapshot) { + if (!snapshot || typeof snapshot !== 'object') return null; + return normalizeWorkspaceRelativePath(snapshot.relative_path) || null; +} + +export function resolveAssetWorkspaceRelativePath({ + categoryCode = null, + originalFilename = null, + explicitPath = null, +} = {}) { + const explicit = normalizeWorkspaceRelativePath(explicitPath); + if (explicit) return explicit; + const filename = String(originalFilename ?? '') + .replace(/\\/g, '/') + .replace(/^\/+/, ''); + if (!filename) return null; + if (/^(?:public|oa)\//i.test(filename)) { + return normalizeWorkspaceRelativePath(filename); + } + const category = String(categoryCode ?? '').trim(); + if (category === 'public' || category === 'oa') { + return normalizeWorkspaceRelativePath(`${category}/${filename}`); + } + return null; +} diff --git a/mindspace-workspace-path.test.mjs b/mindspace-workspace-path.test.mjs new file mode 100644 index 0000000..71e64b9 --- /dev/null +++ b/mindspace-workspace-path.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + resolveAssetWorkspaceRelativePath, + resolvePageWorkspaceRelativePath, +} from './mindspace-workspace-path.mjs'; + +test('resolvePageWorkspaceRelativePath normalizes html workspace paths', () => { + assert.equal( + resolvePageWorkspaceRelativePath({ relative_path: 'demo.html' }), + 'public/demo.html', + ); + assert.equal(resolvePageWorkspaceRelativePath({ relative_path: 'public/demo.html' }), 'public/demo.html'); + assert.equal(resolvePageWorkspaceRelativePath(null), null); +}); + +test('resolveAssetWorkspaceRelativePath prefers explicit public image paths', () => { + assert.equal( + resolveAssetWorkspaceRelativePath({ + explicitPath: 'public/images/2026-07-05/hero.png', + }), + 'public/images/2026-07-05/hero.png', + ); + assert.equal( + resolveAssetWorkspaceRelativePath({ + categoryCode: 'public', + originalFilename: 'report.docx', + }), + 'public/report.docx', + ); + assert.equal( + resolveAssetWorkspaceRelativePath({ + categoryCode: 'draft', + originalFilename: 'notes.md', + }), + null, + ); +}); diff --git a/mindspace-workspace-sync.mjs b/mindspace-workspace-sync.mjs index 2812d14..3b022f8 100644 --- a/mindspace-workspace-sync.mjs +++ b/mindspace-workspace-sync.mjs @@ -7,6 +7,7 @@ import { resolveUserWorkspaceRoot, resolveZoneDir, } from './user-space.mjs'; +import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-path.mjs'; import { assetInternals } from './mindspace-assets.mjs'; import { runBasicFileScan } from './mindspace-scan.mjs'; import { buildWorkspaceStorageKey } from './workspace-storage.mjs'; @@ -193,12 +194,16 @@ export function createWorkspaceAssetSync({ const now = Date.now(); const visibility = category.category_code === 'public' ? 'public_candidate' : 'private'; + const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({ + categoryCode: category.category_code, + originalFilename: file.filename, + }); 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, + display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility, status, source_type, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'workspace', ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'workspace', ?, ?)`, [ assetId, userId, @@ -208,6 +213,7 @@ export function createWorkspaceAssetSync({ detectedMimeType, file.filename, path.basename(file.filename, path.extname(file.filename)) || file.filename, + indexedWorkspacePath, versionId, buffer.length, checksum, @@ -308,10 +314,14 @@ export function createWorkspaceAssetSync({ ); const now = Date.now(); + const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({ + categoryCode: category.category_code, + originalFilename: file.filename, + }); await conn.query( `UPDATE h5_assets SET current_version_id = ?, size_bytes = ?, checksum = ?, mime_type = ?, - asset_type = ?, risk_level = ?, status = ?, updated_at = ? + asset_type = ?, risk_level = ?, status = ?, workspace_relative_path = ?, updated_at = ? WHERE id = ? AND user_id = ?`, [ versionId, @@ -321,6 +331,7 @@ export function createWorkspaceAssetSync({ assetInternals.assetTypeForMime(detectedMimeType), scan.riskLevel, assetStatus, + indexedWorkspacePath, now, existing.id, userId, diff --git a/package.json b/package.json index 1008ee8..05825d9 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,8 @@ "build:mindspace-service-runtime": "node scripts/build-mindspace-service-runtime.mjs", "check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only", "check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links", + "check:mindspace-cover": "node scripts/check-mindspace-cover.mjs", + "demo:thumbnails": "node scripts/thumbnail-preview-demo.mjs", "check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs", "check:memory-v2": "node scripts/check-memory-v2-health.mjs", "canary:memory-v2-app": "node scripts/check-memory-v2-app-canary.mjs", diff --git a/plaza-embed.mjs b/plaza-embed.mjs index d68128b..0715016 100644 --- a/plaza-embed.mjs +++ b/plaza-embed.mjs @@ -10,7 +10,7 @@ export function isPlazaEmbedRequest(query = {}) { export function publishedPageCspForEmbed(isFullHtml) { if (isFullHtml) { - return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors *; script-src 'unsafe-inline'"; + return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: https: http:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors *; script-src 'unsafe-inline'"; } return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors *; script-src 'unsafe-inline'"; } @@ -22,13 +22,22 @@ export function allowPlazaEmbedFrame(res) { const EMBED_BOOTSTRAP = ``; - if (/<\/body>/i.test(source)) { - return { - html: source.replace(/<\/body>/i, `${markup}`), - scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH], - }; - } - return { - html: `${source}${markup}`, - scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH], - }; + return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'"; } function extractOgImageUrl(html) { @@ -5232,8 +5280,11 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUrl }) { `; } -async function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) { - let html = result.html; +async function sendPublishedPage(req, res, result, { embed = false, raw = false, ownerSlug = null } = {}) { + let html = rewriteBrokenMindSpacePublicImageUrls(result.html); + if (ownerSlug) { + html = rewritePublicationCanonicalAssetUrls(html, ownerSlug); + } const origin = resolveRequestOrigin(req); const originalPath = req.originalUrl || req.url || ''; const sharePath = removeQueryParam(removeQueryParam(originalPath, 'download'), 'export'); @@ -5458,6 +5509,7 @@ async function resolvePublishedRoute(req, res, password = null) { return await sendPublishedPage(req, res, result, { embed: isPlazaEmbedRequest(req.query), raw: String(req.query.view ?? '').toLowerCase() === 'raw', + ownerSlug: req.params.ownerSlug, }); } catch (error) { if (error?.code === 'publication_password_required') { @@ -5505,6 +5557,45 @@ app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => { return resolvePublishedRoute(req, res); }); +app.use('/u/:ownerSlug/public', async (req, res) => { + await userAuthReady; + const ownerSlug = String(req.params.ownerSlug ?? '').trim().toLowerCase(); + if (!ownerSlug) return res.status(404).json({ message: '用户不存在' }); + if (!authPool) return res.status(503).send('MindSpace 未启用'); + + try { + const [rows] = await authPool.query( + `SELECT id FROM h5_users WHERE LOWER(COALESCE(slug, username)) = ? LIMIT 1`, + [ownerSlug], + ); + const userId = rows[0]?.id ? String(rows[0].id) : null; + if (!userId) return res.status(404).json({ message: '用户不存在' }); + + const relativePath = String(req.path ?? '') + .replace(/^\/+/, '') + .split('/') + .map((segment) => decodeURIComponent(segment)) + .filter(Boolean); + if (relativePath.length === 0) return res.status(404).json({ message: '文件不存在' }); + + const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId }); + const resolvedRoot = path.resolve(publishDir); + const filePath = path.resolve(publishDir, PUBLIC_ZONE_DIR, ...relativePath); + if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) { + return res.status(403).json({ message: '禁止访问' }); + } + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + return res.status(404).json({ message: '文件不存在' }); + } + + return res.sendFile(filePath, (err) => { + if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' }); + }); + } catch { + return res.status(500).send('文件加载失败'); + } +}); + app.get('/u/:ownerSlug', async (req, res) => { await userAuthReady; if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用'); diff --git a/skills/static-page-publish/SKILL.md b/skills/static-page-publish/SKILL.md index 3cb9029..ba7db46 100644 --- a/skills/static-page-publish/SKILL.md +++ b/skills/static-page-publish/SKILL.md @@ -92,7 +92,26 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 **禁止**省略 mindspace-cover 或填写与页面无关的通用配色;促销/运动/品牌页必须写明 `tag`、`accent` 和 `cover`。 -本地对比示例:`node scripts/thumbnail-preview-demo.mjs` → `/thumbnail-demo/` +本地对比示例:`npm run demo:thumbnails` → `/thumbnail-demo/`(左侧缺 cover vs 右侧方案 A) + +### 交付前自检(必须) + +写完 `public/*.html` 后,**必须**运行 cover 合规检查;有 error 则补全元数据后再交付链接: + +```bash +npm run check:mindspace-cover +# 或指定用户:node scripts/check-mindspace-cover.mjs --user +``` + +| 检查项 | 级别 | 说明 | +|--------|------|------| +| `mindspace-cover` meta | error | 缺失则缩略图为默认绿色渐变 | +| `description` | error | 副标题来源 | +| `cover` raster 主图 | warn | 缺失则无法生成照片封面 | +| `tag` / `accent` / `subtitle` | warn | 影响分类与配色 | +| 平台品牌页脚 | warn | 分享与编辑规范 | + +**视觉类页面**若有 warn `missing_cover_image`,视为封面未达标,必须补 hero 图后再回复用户。 ## 平台页脚标记(必须) diff --git a/src/components/ChatPlazaPublishModal.tsx b/src/components/ChatPlazaPublishModal.tsx index d30f60a..289f4ad 100644 --- a/src/components/ChatPlazaPublishModal.tsx +++ b/src/components/ChatPlazaPublishModal.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { ApiError, quickPlazaFromChat } from '../api/client'; import { resolvePlazaPostUrl } from '../utils/publicUrl'; import { ChatLoadingSpinner } from './ChatLoadingSpinner'; type PlazaPublishPhase = + | { kind: 'confirm' } | { kind: 'loading' } | { kind: 'success'; plazaUrl: string } | { kind: 'already'; plazaUrl: string; message: string } @@ -28,11 +29,15 @@ export function ChatPlazaPublishModal({ messageId: string; onClose: () => void; }) { - const [phase, setPhase] = useState({ kind: 'loading' }); + const [phase, setPhase] = useState({ kind: 'confirm' }); const closeTimer = useRef | null>(null); const onCloseRef = useRef(onClose); onCloseRef.current = onClose; + const startPublish = useCallback(() => { + setPhase({ kind: 'loading' }); + }, []); + useEffect(() => { const prev = document.body.style.overflow; document.body.style.overflow = 'hidden'; @@ -52,6 +57,7 @@ export function ChatPlazaPublishModal({ }, [onClose, phase.kind]); useEffect(() => { + if (phase.kind !== 'loading') return; const controller = new AbortController(); void quickPlazaFromChat({ sessionId, messageId }, controller.signal) @@ -79,7 +85,7 @@ export function ChatPlazaPublishModal({ }); return () => controller.abort(); - }, [sessionId, messageId]); + }, [phase.kind, sessionId, messageId]); const canClose = phase.kind !== 'loading'; @@ -97,6 +103,21 @@ export function ChatPlazaPublishModal({ aria-busy={phase.kind === 'loading'} onClick={(event) => event.stopPropagation()} > + {phase.kind === 'confirm' && ( +
+

发布到 Plaza

+

将把当前对话内容保存为公开页面并推送到发现广场,确认继续?

+
+ + +
+
+ )} + {phase.kind === 'loading' && (
diff --git a/src/index.css b/src/index.css index c29d1fc..48d2382 100644 --- a/src/index.css +++ b/src/index.css @@ -1532,8 +1532,17 @@ body, text-underline-offset: 2px; } -.chat-plaza-publish-close-btn { +.chat-plaza-publish-actions { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + width: 100%; margin-top: 8px; +} + +.chat-plaza-publish-close-btn { + margin-top: 0; padding: 8px 18px; border: 1px solid rgba(24, 33, 29, 0.14); border-radius: 999px; @@ -1544,11 +1553,27 @@ body, font-size: 14px; } +.chat-plaza-publish-confirm-btn { + padding: 8px 18px; + border: 1px solid rgba(47, 111, 87, 0.35); + border-radius: 999px; + background: #2f6f57; + color: #fff; + cursor: pointer; + font: inherit; + font-size: 14px; + font-weight: 600; +} + .chat-plaza-publish-close-btn:hover { border-color: rgba(47, 111, 87, 0.35); color: #2f6f57; } +.chat-plaza-publish-confirm-btn:hover { + background: #265a46; +} + .page-save-panel { display: flex; flex-direction: column; diff --git a/src/utils/mindspaceCards.ts b/src/utils/mindspaceCards.ts index 2fcde28..4ba0221 100644 --- a/src/utils/mindspaceCards.ts +++ b/src/utils/mindspaceCards.ts @@ -56,8 +56,8 @@ export function isImageAsset(asset: MindSpaceAsset) { return asset.assetType === 'image' || asset.mimeType.startsWith('image/'); } -export function buildAssetImageUrl(asset: Pick) { - if (asset.publicUrl) return asset.publicUrl; +export function buildAssetImageUrl(asset: Pick) { + // MindSpace 空间内始终走受鉴权的下载接口,避免 publicUrl(imgproxy / 公开静态路径)在本地或工作区资产上失效。 return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&viewer=0&v=${asset.updatedAt}`; } diff --git a/user-publish.mjs b/user-publish.mjs index 6774e33..ca89a2b 100644 --- a/user-publish.mjs +++ b/user-publish.mjs @@ -224,6 +224,16 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报 **禁止**使用与页面无关的通用风景图逻辑;促销/运动/品牌页必须写明 \`tag\`、\`accent\` 和 \`cover\`。 +### 交付前自检(必须) + +写完 \`public/*.html\` 后运行 cover 合规检查;有 error 则补全后再交付链接: + +\`\`\`bash +npm run check:mindspace-cover +\`\`\` + +视觉类页面若出现 \`missing_cover_image\` 警告,必须补 hero 主图后再回复用户。对比效果:\`npm run demo:thumbnails\` → \`/thumbnail-demo/\` + ## HTML 模板建议 - 完整 \`\`,\`lang="zh-CN"\` diff --git a/wechat/prompts/page-generate.mjs b/wechat/prompts/page-generate.mjs index 55006f0..e0f3185 100644 --- a/wechat/prompts/page-generate.mjs +++ b/wechat/prompts/page-generate.mjs @@ -28,8 +28,9 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {}) ' - ``', ' - ``', ' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。', - '6. 页脚加 `

TKMind · 智趣

`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。', - '7. 回复里只给一个正式域名链接;没有落盘或缺少上述元数据就不要发链接。', + '6. 交付前运行 `npm run check:mindspace-cover`(或 node scripts/check-mindspace-cover.mjs --user );有 error 则补元数据后再回复链接。', + '7. 页脚加 `

TKMind · 智趣

`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。', + '8. 回复里只给一个正式域名链接;没有落盘或缺少上述元数据就不要发链接。', '', buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }), `用户需求:${topic}`,