Add MindSpace canonical URL facade
This commit is contained in:
@@ -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 状态。
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user