fix: serve MindSpace public assets as same-origin relative URLs
Rewrite absolute tkmind asset download links to /api/... at delivery time and strip base tags that violate public page CSP, so images load on any host. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,15 +26,31 @@ export function verifyPublicAssetToken(assetId, token, secret) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function appendPublicAssetTokens(html, secret) {
|
const PUBLIC_ASSET_DOWNLOAD_PATTERN =
|
||||||
const source = String(html ?? '');
|
/((?:https?:\/\/[^"'<>\\\s]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download)(\?[^"'<>\\\s)]*)?/gi;
|
||||||
if (!source || !source.includes('/api/mindspace/v1/assets/')) return source;
|
|
||||||
return source.replace(
|
export function stripPublicHtmlBaseTag(html) {
|
||||||
/((?:https?:\/\/[^"'<>\\\s]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download)(\?[^"'<>\\\s)]*)?/gi,
|
return String(html ?? '').replace(/<base\b[^>]*>/gi, '');
|
||||||
(value, base, assetId, query = '') => {
|
}
|
||||||
if (/(?:\?|&|&)public_token=/.test(query)) return value;
|
|
||||||
const separator = query ? (query.includes('&') ? '&' : '&') : '?';
|
export function normalizePublicAssetReferences(html) {
|
||||||
return `${base}${query}${separator}public_token=${publicAssetToken(assetId, secret)}`;
|
const source = String(html ?? '');
|
||||||
},
|
if (!source.includes('/api/mindspace/v1/assets/')) return source;
|
||||||
);
|
return source.replace(PUBLIC_ASSET_DOWNLOAD_PATTERN, (_value, _base, assetId, query = '') => {
|
||||||
|
return `/api/mindspace/v1/assets/${assetId}/download${query ?? ''}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendPublicAssetTokens(html, secret) {
|
||||||
|
const source = normalizePublicAssetReferences(html);
|
||||||
|
if (!source || !source.includes('/api/mindspace/v1/assets/')) return source;
|
||||||
|
return source.replace(PUBLIC_ASSET_DOWNLOAD_PATTERN, (value, _base, assetId, query = '') => {
|
||||||
|
if (/(?:\?|&|&)public_token=/.test(query)) return value;
|
||||||
|
const separator = query ? (query.includes('&') ? '&' : '&') : '?';
|
||||||
|
return `/api/mindspace/v1/assets/${assetId}/download${query}${separator}public_token=${publicAssetToken(assetId, secret)}`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function preparePublicHtmlAssetDelivery(html, secret) {
|
||||||
|
return appendPublicAssetTokens(stripPublicHtmlBaseTag(html), secret);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import {
|
import {
|
||||||
appendPublicAssetTokens,
|
appendPublicAssetTokens,
|
||||||
|
normalizePublicAssetReferences,
|
||||||
|
preparePublicHtmlAssetDelivery,
|
||||||
publicAssetToken,
|
publicAssetToken,
|
||||||
|
stripPublicHtmlBaseTag,
|
||||||
verifyPublicAssetToken,
|
verifyPublicAssetToken,
|
||||||
} from './mindspace-public-asset-token.mjs';
|
} from './mindspace-public-asset-token.mjs';
|
||||||
|
|
||||||
@@ -36,3 +39,36 @@ test('appendPublicAssetTokens preserves existing public tokens', () => {
|
|||||||
const html = '<img src="/api/mindspace/v1/assets/asset-1/download?inline=1&public_token=keep-me">';
|
const html = '<img src="/api/mindspace/v1/assets/asset-1/download?inline=1&public_token=keep-me">';
|
||||||
assert.equal(appendPublicAssetTokens(html, SECRET), html);
|
assert.equal(appendPublicAssetTokens(html, SECRET), html);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizePublicAssetReferences rewrites absolute asset urls to same-origin paths', () => {
|
||||||
|
const html = [
|
||||||
|
'<img src="https://m.tkmind.cn/api/mindspace/v1/assets/asset-1/download?inline=1">',
|
||||||
|
'<img src="http://127.0.0.1:5173/api/mindspace/v1/assets/asset-2/download?inline=1&v=3">',
|
||||||
|
'<meta property="og:image" content="https://mm.tkmind.cn/api/mindspace/v1/assets/asset-3/download?inline=1">',
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
const normalized = normalizePublicAssetReferences(html);
|
||||||
|
|
||||||
|
assert.match(normalized, /src="\/api\/mindspace\/v1\/assets\/asset-1\/download\?inline=1"/);
|
||||||
|
assert.match(normalized, /src="\/api\/mindspace\/v1\/assets\/asset-2\/download\?inline=1&v=3"/);
|
||||||
|
assert.match(
|
||||||
|
normalized,
|
||||||
|
/content="\/api\/mindspace\/v1\/assets\/asset-3\/download\?inline=1"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stripPublicHtmlBaseTag removes base tags blocked by public page CSP', () => {
|
||||||
|
const html = '<head><base href="/"><title>demo</title></head>';
|
||||||
|
assert.equal(stripPublicHtmlBaseTag(html), '<head><title>demo</title></head>');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preparePublicHtmlAssetDelivery normalizes asset urls before signing', () => {
|
||||||
|
const html = '<head><base href="/"></head><img src="https://m.tkmind.cn/api/mindspace/v1/assets/asset-1/download?inline=1">';
|
||||||
|
const prepared = preparePublicHtmlAssetDelivery(html, SECRET);
|
||||||
|
|
||||||
|
assert.doesNotMatch(prepared, /<base\b/i);
|
||||||
|
assert.match(
|
||||||
|
prepared,
|
||||||
|
/src="\/api\/mindspace\/v1\/assets\/asset-1\/download\?inline=1&public_token=[A-Za-z0-9_-]+"/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
+2
-2
@@ -59,7 +59,7 @@ import {
|
|||||||
resolvePublicRequestOrigin,
|
resolvePublicRequestOrigin,
|
||||||
} from './mindspace-public-page-context.mjs';
|
} from './mindspace-public-page-context.mjs';
|
||||||
import {
|
import {
|
||||||
appendPublicAssetTokens,
|
preparePublicHtmlAssetDelivery,
|
||||||
verifyPublicAssetToken,
|
verifyPublicAssetToken,
|
||||||
} from './mindspace-public-asset-token.mjs';
|
} from './mindspace-public-asset-token.mjs';
|
||||||
import {
|
import {
|
||||||
@@ -5427,7 +5427,7 @@ async function sendPublishFile(req, res, filePath) {
|
|||||||
res.status(404).json({ message: '文件不存在' });
|
res.status(404).json({ message: '文件不存在' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
html = appendPublicAssetTokens(html, INTERNAL_AGENT_SECRET);
|
html = preparePublicHtmlAssetDelivery(html, INTERNAL_AGENT_SECRET);
|
||||||
const embed = isPlazaEmbedRequest(req.query);
|
const embed = isPlazaEmbedRequest(req.query);
|
||||||
if (embed) {
|
if (embed) {
|
||||||
html = preparePublicationHtmlForEmbed(html);
|
html = preparePublicationHtmlForEmbed(html);
|
||||||
|
|||||||
Reference in New Issue
Block a user