fde6503bdf
Memind CI / Test, build, and release guards (push) Has been cancelled
Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config. Co-authored-by: Cursor <cursoragent@cursor.com>
919 lines
31 KiB
JavaScript
919 lines
31 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import test from 'node:test';
|
|
import { createPublicationService, publicationInternals } from './mindspace-publications.mjs';
|
|
|
|
test('normalizes safe long-page slugs', () => {
|
|
assert.equal(publicationInternals.normalizeSlug(' product-intro-2026 '), 'product-intro-2026');
|
|
assert.throws(
|
|
() => publicationInternals.normalizeSlug('../private'),
|
|
(error) => error.code === 'invalid_publish_input',
|
|
);
|
|
assert.throws(
|
|
() => publicationInternals.normalizeSlug('中文地址'),
|
|
(error) => error.code === 'invalid_publish_input',
|
|
);
|
|
});
|
|
|
|
test('accepts all documented access modes', () => {
|
|
for (const mode of [
|
|
'public',
|
|
'password',
|
|
'private_link',
|
|
'time_limited',
|
|
'login_required',
|
|
'owner_only',
|
|
]) {
|
|
assert.equal(publicationInternals.normalizeAccessMode(mode), mode);
|
|
}
|
|
assert.throws(
|
|
() => publicationInternals.normalizeAccessMode('team_only'),
|
|
(error) => error.code === 'invalid_publish_input',
|
|
);
|
|
});
|
|
|
|
test('getCurrent exposes whether the owner confirmed publication visibility', async () => {
|
|
const service = createPublicationService({
|
|
async query(sql, params) {
|
|
assert.match(sql, /SELECT pr\.\*/);
|
|
assert.deepEqual(params, ['page-1', 'user-1']);
|
|
return [[{
|
|
id: 'pub-1',
|
|
page_id: 'page-1',
|
|
page_version_id: 'version-1',
|
|
url_slug: 'journal',
|
|
public_url: '/u/john/pages/journal',
|
|
access_mode: 'public',
|
|
expires_at: null,
|
|
status: 'online',
|
|
view_count: 2,
|
|
published_at: 1000,
|
|
offline_at: null,
|
|
user_confirmed_at: 2000,
|
|
}]];
|
|
},
|
|
});
|
|
|
|
const publication = await service.getCurrent('user-1', 'page-1');
|
|
assert.equal(publication.userConfirmedAt, 2000);
|
|
});
|
|
|
|
test('getCurrent returns null confirmation for an unconfirmed publication', async () => {
|
|
const service = createPublicationService({
|
|
async query() {
|
|
return [[{
|
|
id: 'pub-1',
|
|
page_id: 'page-1',
|
|
page_version_id: 'version-1',
|
|
url_slug: 'journal',
|
|
public_url: '/u/john/pages/journal',
|
|
access_mode: 'public',
|
|
expires_at: null,
|
|
status: 'online',
|
|
view_count: 0,
|
|
published_at: 1000,
|
|
offline_at: null,
|
|
user_confirmed_at: null,
|
|
}]];
|
|
},
|
|
});
|
|
|
|
const publication = await service.getCurrent('user-1', 'page-1');
|
|
assert.equal(publication.userConfirmedAt, null);
|
|
});
|
|
|
|
test('cleanupExpiredUnconfirmedPublications falls back to owner-only access', async () => {
|
|
let executedSql = '';
|
|
const service = createPublicationService({
|
|
async query(sql, params) {
|
|
executedSql = sql;
|
|
assert.deepEqual(params, [3000, 3000]);
|
|
return [{ affectedRows: 1 }];
|
|
},
|
|
});
|
|
|
|
const result = await service.cleanupExpiredUnconfirmedPublications(3000);
|
|
assert.deepEqual(result, { cleaned: 1 });
|
|
assert.match(executedSql, /SET access_mode = 'owner_only'/);
|
|
assert.doesNotMatch(executedSql, /SET access_mode = 'private'/);
|
|
});
|
|
|
|
test('hashes access passwords with a random salt', () => {
|
|
const first = publicationInternals.hashPassword('Publish-Password-2026');
|
|
const second = publicationInternals.hashPassword('Publish-Password-2026');
|
|
assert.notEqual(first, second);
|
|
assert.equal(publicationInternals.verifyPassword('Publish-Password-2026', first), true);
|
|
assert.equal(publicationInternals.verifyPassword('wrong-password', first), false);
|
|
assert.throws(
|
|
() => publicationInternals.normalizePassword('short', true),
|
|
(error) => error.code === 'invalid_publish_input',
|
|
);
|
|
});
|
|
|
|
test('requires future expiry timestamps for time-limited pages', () => {
|
|
const future = Date.now() + 60_000;
|
|
assert.equal(publicationInternals.normalizeExpiresAt(future, true), future);
|
|
assert.throws(
|
|
() => publicationInternals.normalizeExpiresAt(Date.now() - 1, true),
|
|
(error) => error.code === 'invalid_publish_input',
|
|
);
|
|
});
|
|
|
|
test('classifies devices and strips referrer paths', () => {
|
|
assert.equal(
|
|
publicationInternals.deviceType('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0) Mobile'),
|
|
'mobile',
|
|
);
|
|
assert.equal(publicationInternals.deviceType('Googlebot/2.1'), 'bot');
|
|
assert.equal(publicationInternals.deviceType('Mozilla/5.0 (Macintosh)'), 'desktop');
|
|
assert.equal(
|
|
publicationInternals.referrerHost('https://example.com/private/path?token=secret'),
|
|
'example.com',
|
|
);
|
|
assert.equal(publicationInternals.referrerHost('not a url'), null);
|
|
});
|
|
|
|
test('warns for contact details and external links without silently hiding them', () => {
|
|
const result = publicationInternals.scanContent(
|
|
'联系 13812345678 或 hello@example.com,详情 https://example.com/path',
|
|
);
|
|
assert.equal(result.status, 'warned');
|
|
assert.equal(result.riskLevel, 'medium');
|
|
assert.equal(result.allowed, true);
|
|
assert.deepEqual(
|
|
result.findings.map((finding) => finding.type),
|
|
['phone', 'email', 'external_link'],
|
|
);
|
|
assert.doesNotMatch(result.findings[0].sampleMasked, /13812345678/);
|
|
});
|
|
|
|
test('deduplicates the same sensitive value repeated by summary and content', () => {
|
|
const result = publicationInternals.scanContent(
|
|
'摘要 qa@example.com\n正文 qa@example.com',
|
|
);
|
|
assert.equal(result.findings[0].occurrenceCount, 1);
|
|
});
|
|
|
|
test('blocks identity and bank-card numbers', () => {
|
|
const result = publicationInternals.scanContent(
|
|
'身份证 310101199001011234,银行卡 6222021234567890123',
|
|
);
|
|
assert.equal(result.status, 'blocked');
|
|
assert.equal(result.riskLevel, 'high');
|
|
assert.equal(result.allowed, false);
|
|
assert.ok(result.findings.every((finding) => finding.blocking));
|
|
});
|
|
|
|
test('publicHomepageResponse summarizes public cards and total views', () => {
|
|
const result = publicationInternals.publicHomepageResponse(
|
|
{
|
|
id: 'user-1',
|
|
slug: 'john',
|
|
username: 'john',
|
|
display_name: 'John',
|
|
},
|
|
[
|
|
{
|
|
id: 'pub-1',
|
|
page_id: 'page-1',
|
|
title: '第一篇',
|
|
summary: '摘要一',
|
|
template_id: 'report',
|
|
public_url: '/u/john/pages/one',
|
|
url_slug: 'one',
|
|
view_count: 7,
|
|
published_at: 100,
|
|
},
|
|
{
|
|
id: 'pub-2',
|
|
page_id: 'page-2',
|
|
title: '第二篇',
|
|
summary: '摘要二',
|
|
template_id: 'editorial',
|
|
public_url: '/u/john/pages/two',
|
|
url_slug: 'two',
|
|
view_count: 3,
|
|
published_at: 200,
|
|
},
|
|
],
|
|
);
|
|
assert.equal(result.owner.displayName, 'John');
|
|
assert.equal(result.totalViews, 10);
|
|
assert.equal(result.pageCount, 2);
|
|
assert.equal(result.pages[0].publicUrl, '/u/john/pages/one');
|
|
});
|
|
|
|
test('buildPublicationThumbnailFallback points private resources at the public page thumbnail', () => {
|
|
assert.equal(
|
|
publicationInternals.buildPublicationThumbnailFallback('john', 'page-037d497f'),
|
|
'/u/john/pages/page-037d497f.thumbnail.png',
|
|
);
|
|
assert.equal(
|
|
publicationInternals.buildPublicationThumbnailFallback('木子', 'demo-page'),
|
|
'/u/%E6%9C%A8%E5%AD%90/pages/demo-page.thumbnail.png',
|
|
);
|
|
});
|
|
|
|
test('resolvePublic returns page provenance for package artifact registration', async () => {
|
|
const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-public-resolve-'));
|
|
const storageKey = 'users/user-1/publications/pub-1/index.html';
|
|
await fs.mkdir(path.dirname(path.join(storageRoot, storageKey)), { recursive: true });
|
|
await fs.writeFile(path.join(storageRoot, storageKey), '<!doctype html><title>发布页</title>');
|
|
const service = createPublicationService(
|
|
{
|
|
async query(sql) {
|
|
if (sql.includes('FROM h5_publish_records pr') && sql.includes('COALESCE(u.slug, u.username)')) {
|
|
return [[
|
|
{
|
|
id: 'pub-1',
|
|
user_id: 'user-1',
|
|
owner_id: 'user-1',
|
|
page_id: 'page-1',
|
|
page_version_id: 'version-1',
|
|
title: '发布页',
|
|
source_session_id: 'session-1',
|
|
source_message_id: 'message-1',
|
|
url_slug: 'published',
|
|
public_url: 'https://m.tkmind.cn/u/john/pages/published',
|
|
access_mode: 'public',
|
|
status: 'online',
|
|
view_count: 0,
|
|
published_at: 3000,
|
|
storage_key: storageKey,
|
|
},
|
|
]];
|
|
}
|
|
return [[]];
|
|
},
|
|
},
|
|
{
|
|
storageRoot,
|
|
idFactory: () => 'view-1',
|
|
},
|
|
);
|
|
|
|
const result = await service.resolvePublic('john', 'published', null, null, {});
|
|
|
|
assert.equal(result.ownerId, 'user-1');
|
|
assert.deepEqual(result.pageSource, {
|
|
pageId: 'page-1',
|
|
title: '发布页',
|
|
sourceSessionId: 'session-1',
|
|
sourceMessageId: 'message-1',
|
|
});
|
|
});
|
|
|
|
test('prepareHtmlPublishContent inlines owned private image assets before scanning', async () => {
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-publish-'));
|
|
const storageKey = 'users/user-1/assets/asset-1/current.png';
|
|
await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true });
|
|
await fs.writeFile(path.join(root, storageKey), Buffer.from('png-bytes'));
|
|
|
|
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,
|
|
},
|
|
],
|
|
];
|
|
},
|
|
};
|
|
|
|
try {
|
|
const html = '<!doctype html><img src="/api/mindspace/v1/assets/asset-1/download?inline=1&v=1">';
|
|
const prepared = await publicationInternals.prepareHtmlPublishContent({
|
|
pool,
|
|
userId: 'user-1',
|
|
html,
|
|
ownerSlug: 'john',
|
|
urlSlug: 'korean-daily-look',
|
|
absoluteStoragePath: (key) => path.join(root, key),
|
|
});
|
|
|
|
assert.match(prepared, /src="data:image\/png;base64,/);
|
|
assert.doesNotMatch(prepared, /\/api\/mindspace\/v1\/assets\//);
|
|
const scan = publicationInternals.scanContent(prepared, { format: 'html' });
|
|
assert.equal(
|
|
scan.findings.some((finding) => finding.type === 'private_resource_reference'),
|
|
false,
|
|
);
|
|
} finally {
|
|
await fs.rm(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
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: '<!doctype html><img src="/api/mindspace/v1/assets/asset-1/download?inline=1&v=1">',
|
|
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: '<!doctype html><img src="/api/mindspace/v1/assets/asset-1/download?inline=1&v=1">',
|
|
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: {
|
|
async query() {
|
|
return [[]];
|
|
},
|
|
},
|
|
userId: 'user-1',
|
|
html: '<!doctype html><img src="https://tkmind.ai/sig/rs:fit:1280:1280:0/q:85/plain/local:///users/user-1/images/2026-06-29/hero.jpg@jpg">',
|
|
ownerSlug: 'john',
|
|
urlSlug: 'story',
|
|
absoluteStoragePath: () => {
|
|
throw new Error('should not read private storage');
|
|
},
|
|
});
|
|
|
|
assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/);
|
|
assert.doesNotMatch(prepared, /plain\/local:\/\//);
|
|
});
|
|
|
|
test('registerPublicationArtifactForConversation records public html artifact', async () => {
|
|
const calls = [];
|
|
const registry = {
|
|
async ensurePackage(input) {
|
|
calls.push(['ensurePackage', input]);
|
|
return { id: 'cp_session-1' };
|
|
},
|
|
async recordArtifact(input) {
|
|
calls.push(['recordArtifact', input]);
|
|
return input;
|
|
},
|
|
async writeManifestForSession(input) {
|
|
calls.push(['writeManifestForSession', input]);
|
|
return { storageKey: 'users/user-1/conversations/session-1/manifest.json' };
|
|
},
|
|
};
|
|
|
|
const result = await publicationInternals.registerPublicationArtifactForConversation({
|
|
registry,
|
|
userId: 'user-1',
|
|
page: {
|
|
page_id: 'page-1',
|
|
title: '发布页',
|
|
source_session_id: 'session-1',
|
|
source_message_id: 'message-1',
|
|
},
|
|
publication: {
|
|
id: 'pub-1',
|
|
publicUrl: 'https://m.tkmind.cn/u/john/pages/published',
|
|
publishedAt: 3000,
|
|
},
|
|
bundleAssetId: 'asset-pub',
|
|
storageKey: 'users/user-1/publications/pub-1/index.html',
|
|
htmlBytes: 256,
|
|
now: 3000,
|
|
});
|
|
|
|
assert.equal(result.id, 'ca_pub-1');
|
|
assert.deepEqual(calls[0], [
|
|
'ensurePackage',
|
|
{
|
|
userId: 'user-1',
|
|
sessionId: 'session-1',
|
|
title: '发布页',
|
|
now: 3000,
|
|
},
|
|
]);
|
|
assert.equal(calls[1][1].artifactKind, 'public_html');
|
|
assert.equal(calls[1][1].publicationId, 'pub-1');
|
|
assert.equal(calls[1][1].canonicalUrl, 'https://m.tkmind.cn/u/john/pages/published');
|
|
assert.deepEqual(calls[2], [
|
|
'writeManifestForSession',
|
|
{ userId: 'user-1', sessionId: 'session-1' },
|
|
]);
|
|
});
|
|
|
|
test('collectPublicationCompanionArtifacts finds real download and long-image files', async () => {
|
|
const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'publication-companions-'));
|
|
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
|
|
await fs.writeFile(path.join(publishDir, 'public', 'report.docx'), 'docx');
|
|
await fs.writeFile(path.join(publishDir, 'public', 'report.long.png'), 'png');
|
|
await fs.writeFile(path.join(publishDir, 'public', 'appendix.pdf'), 'pdf');
|
|
|
|
const artifacts = await publicationInternals.collectPublicationCompanionArtifacts({
|
|
publishDir,
|
|
htmlRelativePath: 'public/report.html',
|
|
html: '<a href="appendix.pdf" download>PDF</a>',
|
|
});
|
|
|
|
assert.deepEqual(
|
|
artifacts.map((item) => item.workspacePath).sort(),
|
|
['public/appendix.pdf', 'public/report.docx', 'public/report.long.png'],
|
|
);
|
|
});
|
|
|
|
test('registerPublicationArtifactForConversation records public companions', async () => {
|
|
const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'publication-companions-'));
|
|
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
|
|
await fs.writeFile(path.join(publishDir, 'public', 'report.docx'), 'docx');
|
|
await fs.writeFile(path.join(publishDir, 'public', 'report.long.png'), 'png');
|
|
const calls = [];
|
|
const registry = {
|
|
async ensurePackage(input) {
|
|
calls.push(['ensurePackage', input]);
|
|
return { id: 'cp_session-1' };
|
|
},
|
|
async recordArtifact(input) {
|
|
calls.push(['recordArtifact', input]);
|
|
return input;
|
|
},
|
|
async writeManifestForSession(input) {
|
|
calls.push(['writeManifestForSession', input]);
|
|
return { storageKey: 'users/user-1/conversations/session-1/manifest.json' };
|
|
},
|
|
};
|
|
|
|
await publicationInternals.registerPublicationArtifactForConversation({
|
|
registry,
|
|
userId: 'user-1',
|
|
page: {
|
|
page_id: 'page-1',
|
|
title: '发布页',
|
|
source_session_id: 'session-1',
|
|
source_message_id: 'message-1',
|
|
source_relative_path: 'public/report.html',
|
|
},
|
|
publication: {
|
|
id: 'pub-1',
|
|
publicUrl: 'https://m.tkmind.cn/u/john/pages/published',
|
|
publishedAt: 3000,
|
|
},
|
|
bundleAssetId: 'asset-pub',
|
|
storageKey: 'users/user-1/publications/pub-1/index.html',
|
|
htmlBytes: 256,
|
|
html: '<!doctype html><title>发布页</title>',
|
|
publishDir,
|
|
now: 3000,
|
|
});
|
|
|
|
const artifactKinds = calls
|
|
.filter(([name]) => name === 'recordArtifact')
|
|
.map(([, input]) => input.artifactKind)
|
|
.sort();
|
|
assert.deepEqual(artifactKinds, ['docx', 'long_image', 'public_html']);
|
|
const docx = calls.find(([, input]) => input?.artifactKind === 'docx')?.[1];
|
|
assert.equal(docx.displayName, 'report.docx');
|
|
assert.match(docx.canonicalUrl, /\/MindSpace\/user-1\/public\/report\.docx$/);
|
|
});
|
|
|
|
test('publish registers public html and companion artifacts for chat-sourced pages', async () => {
|
|
const storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-publish-flow-'));
|
|
const h5Root = await fs.mkdtemp(path.join(os.tmpdir(), 'mindspace-h5-root-'));
|
|
const pageStorageKey = 'users/user-1/pages/page-1/versions/v1.html';
|
|
const pageHtml = '<!doctype html><title>Report</title><main>发布内容</main>';
|
|
await fs.mkdir(path.dirname(path.join(storageRoot, pageStorageKey)), { recursive: true });
|
|
await fs.writeFile(path.join(storageRoot, pageStorageKey), pageHtml);
|
|
const publishDir = path.join(h5Root, 'MindSpace', 'user-1');
|
|
await fs.mkdir(path.join(publishDir, 'public'), { recursive: true });
|
|
await fs.writeFile(path.join(publishDir, 'public', 'report.docx'), 'docx');
|
|
await fs.writeFile(path.join(publishDir, 'public', 'report.long.png'), 'png');
|
|
const registryCalls = [];
|
|
let idCounter = 0;
|
|
const pageRow = {
|
|
page_id: 'page-1',
|
|
title: 'Report',
|
|
summary: '',
|
|
page_type: 'html',
|
|
template_id: 'static-html',
|
|
current_version_id: 'version-1',
|
|
source_session_id: 'session-1',
|
|
source_message_id: 'message-1',
|
|
user_id: 'user-1',
|
|
space_id: 'space-1',
|
|
page_version_id: 'version-1',
|
|
version_no: 1,
|
|
bundle_asset_id: null,
|
|
storage_key: pageStorageKey,
|
|
source_relative_path: 'public/report.html',
|
|
};
|
|
const pool = {
|
|
async query(sql, params = []) {
|
|
if (sql.includes('FROM h5_page_records p') && sql.includes('JOIN h5_page_versions')) {
|
|
assert.deepEqual(params, ['page-1', 'user-1', 'version-1']);
|
|
return [[pageRow]];
|
|
}
|
|
if (sql.includes('FROM h5_publish_records pr') && sql.includes('pr.page_id <>')) {
|
|
return [[]];
|
|
}
|
|
if (sql.includes('FROM h5_users')) {
|
|
return [[{ public_slug: 'john' }]];
|
|
}
|
|
if (sql.includes('FROM h5_assets') || sql.includes('FROM h5_asset_versions')) {
|
|
return [[]];
|
|
}
|
|
return [[]];
|
|
},
|
|
async getConnection() {
|
|
return {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
release() {},
|
|
async query(sql) {
|
|
if (sql.includes('FROM h5_user_spaces') && sql.includes('FOR UPDATE')) {
|
|
return [[{ quota_bytes: 1_000_000, used_bytes: 0, reserved_bytes: 0 }]];
|
|
}
|
|
if (sql.includes('COUNT(DISTINCT page_id)')) {
|
|
return [[{ public_page_used: 0, page_already_online: 0 }]];
|
|
}
|
|
if (sql.includes('FROM h5_space_categories')) {
|
|
return [[{ id: 'cat-public' }]];
|
|
}
|
|
if (sql.includes('SELECT id, page_version_id FROM h5_publish_records')) {
|
|
return [[]];
|
|
}
|
|
return [[]];
|
|
},
|
|
};
|
|
},
|
|
};
|
|
const service = createPublicationService(pool, {
|
|
storageRoot,
|
|
h5Root,
|
|
idFactory: () => `id-${++idCounter}`,
|
|
conversationPackageRegistry: {
|
|
async ensurePackage(input) {
|
|
registryCalls.push(['ensurePackage', input]);
|
|
return { id: 'cp-session-1' };
|
|
},
|
|
async recordArtifact(input) {
|
|
registryCalls.push(['recordArtifact', input]);
|
|
return input;
|
|
},
|
|
async writeManifestForSession(input) {
|
|
registryCalls.push(['writeManifestForSession', input]);
|
|
return input;
|
|
},
|
|
},
|
|
});
|
|
|
|
try {
|
|
const publication = await service.publish('user-1', 'page-1', {
|
|
pageVersionId: 'version-1',
|
|
accessMode: 'public',
|
|
urlSlug: 'report',
|
|
acknowledgedFindingIds: [],
|
|
});
|
|
|
|
assert.equal(publication.id, 'id-4');
|
|
const artifactKinds = registryCalls
|
|
.filter(([name]) => name === 'recordArtifact')
|
|
.map(([, input]) => input.artifactKind)
|
|
.sort();
|
|
assert.deepEqual(artifactKinds, ['docx', 'long_image', 'public_html']);
|
|
const companion = registryCalls.find(([, input]) => input?.artifactKind === 'docx')?.[1];
|
|
assert.equal(companion.messageId, 'message-1');
|
|
assert.equal(companion.publicationId, 'id-4');
|
|
assert.equal(registryCalls.at(-1)[0], 'writeManifestForSession');
|
|
} finally {
|
|
await fs.rm(storageRoot, { recursive: true, force: true });
|
|
await fs.rm(h5Root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('registerPublicationArtifactForConversation skips pages without source session', async () => {
|
|
assert.equal(
|
|
await publicationInternals.registerPublicationArtifactForConversation({
|
|
registry: { ensurePackage: async () => assert.fail('should not be called') },
|
|
userId: 'user-1',
|
|
page: { page_id: 'page-1', title: '发布页' },
|
|
publication: { id: 'pub-1' },
|
|
}),
|
|
null,
|
|
);
|
|
});
|
|
|
|
test('prepareHtmlPublishContent rewrites imgproxy urls in srcset and css url contexts', async () => {
|
|
const prepared = await publicationInternals.prepareHtmlPublishContent({
|
|
pool: {
|
|
async query() {
|
|
return [[]];
|
|
},
|
|
},
|
|
userId: 'user-1',
|
|
html: [
|
|
'<!doctype html>',
|
|
"<img srcset='https://img.tkmind.cn/sig/rs:fit:256:256:0/q:70/plain/local:///users/user-1/images/2026-06-29/thumb.jpg@jpg 1x, https://tkmind.ai/sig/rs:fit:1280:1280:0/q:85/plain/local:///users/user-1/images/2026-06-29/hero.jpg@jpg 2x'>",
|
|
'<section style="background-image:url(http://localhost:20081/unsafe/rs:fit:1280:1280:0/q:85/plain/local:///users/user-1/images/2026-06-29/bg.jpg@jpg)"></section>',
|
|
].join(''),
|
|
ownerSlug: 'john',
|
|
urlSlug: 'story',
|
|
absoluteStoragePath: () => {
|
|
throw new Error('should not read private storage');
|
|
},
|
|
});
|
|
|
|
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: '<!doctype html><img src="http://127.0.0.1:5173/MindSpace/user-1/public/images/2026-07-05/demo.jpg" alt="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 =
|
|
'<img src="http://127.0.0.1:5173/MindSpace/user-1/images/2026-07-05/demo.jpg" alt="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: {
|
|
async query() {
|
|
return [[]];
|
|
},
|
|
},
|
|
userId: 'user-1',
|
|
html: [
|
|
'<!doctype html>',
|
|
`<meta name="mindspace-cover" content='{"cover":"public/images/2026-06-29/hero.jpg"}'>`,
|
|
`<section style="background-image:url('public/images/2026-06-29/bg.jpg')"></section>`,
|
|
`<img src="public/.tmp-images/card.png">`,
|
|
].join(''),
|
|
ownerSlug: 'john',
|
|
urlSlug: 'shared-story',
|
|
htmlRelativePath: 'public/shared/shared-story.html',
|
|
absoluteStoragePath: () => {
|
|
throw new Error('should not read private storage');
|
|
},
|
|
});
|
|
|
|
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('prepareHtmlPublishContent rewrites known chart.js cdn scripts to platform asset', async () => {
|
|
const prepared = await publicationInternals.prepareHtmlPublishContent({
|
|
pool: {
|
|
async query() {
|
|
return [[]];
|
|
},
|
|
},
|
|
userId: 'user-1',
|
|
html: '<!doctype html><body><script src="https://cdn.jsdelivr.net/npm/chart.js/dist/chart.umd.min.js"></script></body></html>',
|
|
ownerSlug: 'john',
|
|
urlSlug: 'survey-admin',
|
|
htmlRelativePath: 'public/survey-admin.html',
|
|
absoluteStoragePath: () => {
|
|
throw new Error('should not read private storage');
|
|
},
|
|
});
|
|
|
|
assert.match(prepared, /src="\/assets\/chart\.umd\.min\.js"/);
|
|
assert.doesNotMatch(prepared, /cdn\.jsdelivr\.net/);
|
|
});
|
|
|
|
test('rewritePublicationCanonicalAssetUrls rewrites workspace tmp image paths for /u pages routes', () => {
|
|
const html = [
|
|
'<img src=".tmp-images/one.jpg">',
|
|
'<img src="../.tmp-images/two.jpg">',
|
|
`<section style="background-image:url('.tmp-images/three.jpg')"></section>`,
|
|
].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;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('updatePublicationAccess notifies search engines when public access is confirmed', async () => {
|
|
const notifyCalls = [];
|
|
const publication = {
|
|
id: 'pub-1',
|
|
user_id: 'user-1',
|
|
page_id: 'page-1',
|
|
page_version_id: 'version-1',
|
|
status: 'online',
|
|
access_mode: 'private',
|
|
url_slug: 'landing',
|
|
expires_at: null,
|
|
password_hash: null,
|
|
user_confirmed_at: null,
|
|
published_at: 1000,
|
|
offline_at: null,
|
|
view_count: 0,
|
|
};
|
|
const pool = {
|
|
async getConnection() {
|
|
return {
|
|
async beginTransaction() {},
|
|
async commit() {},
|
|
async rollback() {},
|
|
async query(sql) {
|
|
if (sql.includes('FOR UPDATE')) {
|
|
return [[publication]];
|
|
}
|
|
return [{ affectedRows: 1 }];
|
|
},
|
|
release() {},
|
|
};
|
|
},
|
|
};
|
|
const service = createPublicationService(pool, {
|
|
notifyPublicationSearchEnginesFn: async (_pool, response) => {
|
|
notifyCalls.push(response);
|
|
},
|
|
});
|
|
|
|
await service.updatePublicationAccess('user-1', 'pub-1', {
|
|
accessMode: 'public',
|
|
});
|
|
|
|
assert.equal(notifyCalls.length, 1);
|
|
assert.equal(notifyCalls[0].accessMode, 'public');
|
|
assert.equal(notifyCalls[0].urlSlug, 'landing');
|
|
});
|