From c3c487a8fb93b6eafbbc1ee807d51fe91d8b3e36 Mon Sep 17 00:00:00 2001 From: john Date: Fri, 3 Jul 2026 23:02:47 +0800 Subject: [PATCH] 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 --- mindspace-public-asset-token.mjs | 38 +++++++++++++++++++-------- mindspace-public-asset-token.test.mjs | 36 +++++++++++++++++++++++++ server.mjs | 4 +-- 3 files changed, 65 insertions(+), 13 deletions(-) diff --git a/mindspace-public-asset-token.mjs b/mindspace-public-asset-token.mjs index 1d0b2a5..0a00bd6 100644 --- a/mindspace-public-asset-token.mjs +++ b/mindspace-public-asset-token.mjs @@ -26,15 +26,31 @@ export function verifyPublicAssetToken(assetId, token, secret) { ); } -export function appendPublicAssetTokens(html, secret) { - const source = String(html ?? ''); - if (!source || !source.includes('/api/mindspace/v1/assets/')) return source; - return source.replace( - /((?:https?:\/\/[^"'<>\\\s]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download)(\?[^"'<>\\\s)]*)?/gi, - (value, base, assetId, query = '') => { - if (/(?:\?|&|&)public_token=/.test(query)) return value; - const separator = query ? (query.includes('&') ? '&' : '&') : '?'; - return `${base}${query}${separator}public_token=${publicAssetToken(assetId, secret)}`; - }, - ); +const PUBLIC_ASSET_DOWNLOAD_PATTERN = + /((?:https?:\/\/[^"'<>\\\s]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download)(\?[^"'<>\\\s)]*)?/gi; + +export function stripPublicHtmlBaseTag(html) { + return String(html ?? '').replace(/]*>/gi, ''); +} + +export function normalizePublicAssetReferences(html) { + 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); } diff --git a/mindspace-public-asset-token.test.mjs b/mindspace-public-asset-token.test.mjs index 49cec9f..578bfe8 100644 --- a/mindspace-public-asset-token.test.mjs +++ b/mindspace-public-asset-token.test.mjs @@ -2,7 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { appendPublicAssetTokens, + normalizePublicAssetReferences, + preparePublicHtmlAssetDelivery, publicAssetToken, + stripPublicHtmlBaseTag, verifyPublicAssetToken, } from './mindspace-public-asset-token.mjs'; @@ -36,3 +39,36 @@ test('appendPublicAssetTokens preserves existing public tokens', () => { const html = ''; assert.equal(appendPublicAssetTokens(html, SECRET), html); }); + +test('normalizePublicAssetReferences rewrites absolute asset urls to same-origin paths', () => { + const html = [ + '', + '', + '', + ].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 = 'demo'; + assert.equal(stripPublicHtmlBaseTag(html), 'demo'); +}); + +test('preparePublicHtmlAssetDelivery normalizes asset urls before signing', () => { + const html = ''; + const prepared = preparePublicHtmlAssetDelivery(html, SECRET); + + assert.doesNotMatch(prepared, /