Add MindSpace canonical URL facade

This commit is contained in:
john
2026-07-02 21:04:49 +08:00
parent e4d6267c96
commit 66c530b1ed
4 changed files with 167 additions and 1 deletions
+1
View File
@@ -93,6 +93,7 @@ validatePublicBackingObject({ publicationId })
规则:
- 新代码不得直接拼 `/MindSpace/<userId>/...`
- 当前分支新增的 `mindspace-canonical-url.mjs` 是 P1 facade 起点;后续旧 helper 迁移到它后,再接入 backing object 校验。
- 旧 URL helper 暂时保留为 compatibility layer。
- public URL 必须能反查到 publication 或 asset。
- URL 返回前应校验 backing object 存在,或明确返回 pending 状态。
+95
View File
@@ -0,0 +1,95 @@
export const MINDSPACE_PUBLIC_ROOT = 'MindSpace';
export const MINDSPACE_PUBLIC_ZONE = 'public';
function urlError(message, code, details = {}) {
return Object.assign(new Error(message), { code, ...details });
}
function requiredSegment(value, field) {
const normalized = String(value ?? '').trim();
if (!normalized) {
throw urlError(`${field} is required`, 'invalid_mindspace_url_segment', { field });
}
if (normalized.includes('/') || normalized.includes('\\') || normalized.includes('\0')) {
throw urlError(`${field} must be a single URL segment`, 'invalid_mindspace_url_segment', {
field,
});
}
return normalized;
}
export function normalizeMindSpacePublicBaseUrl(publicBaseUrl) {
const normalized = String(publicBaseUrl ?? '').trim().replace(/\/+$/, '');
if (!normalized) {
throw urlError('publicBaseUrl is required', 'invalid_mindspace_public_base_url');
}
return normalized;
}
export function normalizeMindSpacePublicRelativePath(relativePath) {
const raw = String(relativePath ?? '').trim();
if (!raw) {
throw urlError('relativePath is required', 'invalid_mindspace_public_relative_path');
}
if (raw.includes('\0') || raw.includes('\\')) {
throw urlError('relativePath must use URL-style forward slashes', 'invalid_mindspace_public_relative_path');
}
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(raw) || raw.startsWith('//')) {
throw urlError('relativePath must not be an absolute URL', 'invalid_mindspace_public_relative_path');
}
const clean = raw.replace(/^\/+/, '');
const parts = clean.split('/').filter(Boolean);
if (parts.length === 0 || parts.includes('..')) {
throw urlError('relativePath must not traverse parent directories', 'invalid_mindspace_public_relative_path');
}
return parts.join('/');
}
export function createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath }) {
const base = normalizeMindSpacePublicBaseUrl(publicBaseUrl);
const owner = requiredSegment(ownerKey, 'ownerKey');
const relative = normalizeMindSpacePublicRelativePath(relativePath);
const encodedRelative = relative.split('/').map(encodeURIComponent).join('/');
return `${base}/${MINDSPACE_PUBLIC_ROOT}/${encodeURIComponent(owner)}/${encodedRelative}`;
}
export function createMindSpacePublicPageUrl({ publicBaseUrl, ownerKey, filename }) {
const clean = normalizeMindSpacePublicRelativePath(filename);
const relative = clean.startsWith(`${MINDSPACE_PUBLIC_ZONE}/`)
? clean
: `${MINDSPACE_PUBLIC_ZONE}/${clean}`;
return createMindSpacePublicUrl({ publicBaseUrl, ownerKey, relativePath: relative });
}
export function parseMindSpacePublicUrl(inputUrl) {
const parsed = new URL(String(inputUrl ?? ''), 'https://mindspace.invalid');
const parts = parsed.pathname.split('/').filter(Boolean).map(decodeURIComponent);
const rootIndex = parts.indexOf(MINDSPACE_PUBLIC_ROOT);
if (rootIndex < 0 || parts.length < rootIndex + 3) {
throw urlError('URL is not a MindSpace public URL', 'not_mindspace_public_url');
}
const ownerKey = parts[rootIndex + 1];
const relativePath = normalizeMindSpacePublicRelativePath(parts.slice(rootIndex + 2).join('/'));
return {
ownerKey,
relativePath,
isPublicZone: relativePath === MINDSPACE_PUBLIC_ZONE || relativePath.startsWith(`${MINDSPACE_PUBLIC_ZONE}/`),
filename: relativePath.split('/').at(-1) ?? '',
};
}
export function canonicalizeMindSpacePublicUrl(inputUrl, { publicBaseUrl }) {
const parsed = parseMindSpacePublicUrl(inputUrl);
return createMindSpacePublicUrl({
publicBaseUrl,
ownerKey: parsed.ownerKey,
relativePath: parsed.relativePath,
});
}
export const mindspaceCanonicalUrlInternals = {
requiredSegment,
urlError,
};
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
canonicalizeMindSpacePublicUrl,
createMindSpacePublicPageUrl,
createMindSpacePublicUrl,
normalizeMindSpacePublicRelativePath,
parseMindSpacePublicUrl,
} from './mindspace-canonical-url.mjs';
test('createMindSpacePublicUrl encodes owner and relative path segments', () => {
assert.equal(
createMindSpacePublicUrl({
publicBaseUrl: 'https://m.tkmind.cn/',
ownerKey: 'user 1',
relativePath: 'public/report 1.html',
}),
'https://m.tkmind.cn/MindSpace/user%201/public/report%201.html',
);
});
test('createMindSpacePublicPageUrl always places html pages under public zone', () => {
assert.equal(
createMindSpacePublicPageUrl({
publicBaseUrl: 'https://m.tkmind.cn',
ownerKey: 'user-1',
filename: 'report.html',
}),
'https://m.tkmind.cn/MindSpace/user-1/public/report.html',
);
assert.equal(
createMindSpacePublicPageUrl({
publicBaseUrl: 'https://m.tkmind.cn',
ownerKey: 'user-1',
filename: 'public/report.html',
}),
'https://m.tkmind.cn/MindSpace/user-1/public/report.html',
);
});
test('normalizeMindSpacePublicRelativePath rejects absolute URLs and traversal', () => {
assert.equal(normalizeMindSpacePublicRelativePath('/public/a.html'), 'public/a.html');
for (const value of ['https://example.com/a.html', '//example.com/a.html', '../a.html', 'public/../a.html', 'a\\b']) {
assert.throws(
() => normalizeMindSpacePublicRelativePath(value),
(error) => error.code === 'invalid_mindspace_public_relative_path',
);
}
});
test('parseMindSpacePublicUrl extracts owner and relative path', () => {
assert.deepEqual(
parseMindSpacePublicUrl('https://m.tkmind.cn/MindSpace/user-1/public/report.html?x=1'),
{
ownerKey: 'user-1',
relativePath: 'public/report.html',
isPublicZone: true,
filename: 'report.html',
},
);
});
test('canonicalizeMindSpacePublicUrl rewrites only the host base', () => {
assert.equal(
canonicalizeMindSpacePublicUrl('https://old.example/MindSpace/user-1/public/report.html', {
publicBaseUrl: 'https://m.tkmind.cn',
}),
'https://m.tkmind.cn/MindSpace/user-1/public/report.html',
);
});
+1 -1
View File
@@ -38,7 +38,7 @@
"build:portal-runtime": "node scripts/build-portal-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",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-schema.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs tool-gateway.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-public-links.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-canonical-url.test.mjs mindspace-conversation-package.test.mjs mindspace-conversation-schema.test.mjs mindspace-storage-adapter.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
"verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs",