diff --git a/src/lib/formatters.js b/src/lib/formatters.js index b577c054..79ccac5a 100644 --- a/src/lib/formatters.js +++ b/src/lib/formatters.js @@ -14,6 +14,7 @@ import UploadingImage from '../widgets/uploading-image.jsx' import { BROKEN_IMAGE_SIZE, CLICKABLE_URL_SCHEMES, IMAGE_THUMBNAIL_DIM, NO_DIMENSIONS_VIDEO, REM_SIZE, VIDEO_THUMBNAIL_WIDTH } from '../config.js'; import { base64ToBlob, blobToBase64, fitImageSize, imageScaled } from './blob-helpers.js'; +import { normalizeFileReference } from './im-api.js'; import { idToColorClass, secondsToTime, shortenFileName } from './strformat.js'; import { cancelablePromise, sanitizeUrl, sanitizeUrlForMime } from './utils.js'; @@ -67,7 +68,7 @@ export function fullFormatter(style, data, values, key, stack) { case 'AU': // Show audio player. if (attr.src) { - attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'audio')); + attr.src = this.authorizeURL(normalizeFileReference(sanitizeUrlForMime(attr.src, 'audio'))); attr.duration = data.duration > 0 ? (data.duration | 0) : undefined; attr.preview = data.preview; attr.loading = 'lazy'; @@ -213,7 +214,7 @@ function handleImageData(el, data, attr) { minHeight: dim.dstHeight + 'px' }; if (!Drafty.isProcessing(data)) { - attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image')); + attr.src = this.authorizeURL(normalizeFileReference(sanitizeUrlForMime(attr.src, 'image'))); attr.alt = data.name; if (attr.src) { if (Math.max(data.width || 0, data.height || 0) > IMAGE_THUMBNAIL_DIM) { @@ -256,7 +257,7 @@ function handleVideoData(el, data, attr) { minHeight: dim.dstHeight + 'px' }; if (!Drafty.isProcessing(data)) { - attr.src = this.authorizeURL(sanitizeUrlForMime(attr.src, 'image')); + attr.src = this.authorizeURL(normalizeFileReference(sanitizeUrlForMime(attr.src, 'image'))); attr.alt = data.name; if (data.ref || data.val) { attr.onClick = (e) => this.onHandleClick(e, 'video'); @@ -495,7 +496,7 @@ function quoteImageOrVideo(data, isVideo) { } promise = Promise.resolve(blob); } else if (ref) { - promise = fetch(this.authorizeURL(sanitizeUrlForMime(ref, 'image'))) + promise = fetch(this.authorizeURL(normalizeFileReference(sanitizeUrlForMime(ref, 'image')))) .then(evt => { if (evt.ok) { return evt.blob(); diff --git a/src/lib/im-api.js b/src/lib/im-api.js index 150d277a..2ebd99a6 100644 --- a/src/lib/im-api.js +++ b/src/lib/im-api.js @@ -117,6 +117,34 @@ function fileBase() { return ((tenant && tenant.endpoints && tenant.endpoints.file) || DEFAULT_FILE_BASE).replace(/\/$/, ''); } +// Historical clients persisted absolute API-host file references in Drafty +// messages. Resolve those references through the tenant's configured file +// endpoint so the web client keeps the authenticated request on its same-origin +// proxy and does not forward custom auth headers into an OSS redirect preflight. +export function normalizeFileReferenceAgainst(ref, apiEndpoint, fileEndpoint, pageOrigin) { + if (!ref || typeof ref !== 'string') { + return ref; + } + try { + const source = new URL(ref, pageOrigin); + const api = new URL(apiEndpoint, pageOrigin); + if (source.origin !== api.origin || !source.pathname.startsWith('/v0/file/')) { + return ref; + } + const target = new URL(fileEndpoint, pageOrigin); + target.pathname = target.pathname.replace(/\/$/, '') + source.pathname.slice('/v0/file'.length); + target.search = source.search; + target.hash = source.hash; + return target.origin === pageOrigin ? target.pathname + target.search + target.hash : target.toString(); + } catch (e) { + return ref; + } +} + +export function normalizeFileReference(ref) { + return normalizeFileReferenceAgainst(ref, apiBase(), fileBase(), window.location.origin); +} + // tinode-web.jsx always sets this on login/reconnect (sessionStorage, tab-lifetime), independent of // whether the user opted into "keep logged in" (which only controls the separate localStorage token // used for surviving a browser restart). REST calls need a token for the current session regardless. diff --git a/src/lib/im-api.test.js b/src/lib/im-api.test.js new file mode 100644 index 00000000..44595ecf --- /dev/null +++ b/src/lib/im-api.test.js @@ -0,0 +1,38 @@ +import { normalizeFileReferenceAgainst } from './im-api'; + +const PAGE_ORIGIN = 'https://web.118im.com'; +const API_ENDPOINT = 'https://api.118im.com/v1/im'; +const FILE_ENDPOINT = 'https://web.118im.com/v0/file'; + +test('normalizes historical API-host file references to the same-origin file endpoint', () => { + expect(normalizeFileReferenceAgainst( + 'https://api.118im.com/v0/file/s/example.jpg?size=large#preview', + API_ENDPOINT, + FILE_ENDPOINT, + PAGE_ORIGIN + )).toBe('/v0/file/s/example.jpg?size=large#preview'); +}); + +test('leaves current and unrelated file references unchanged', () => { + expect(normalizeFileReferenceAgainst( + '/v0/file/s/current.jpg', + API_ENDPOINT, + FILE_ENDPOINT, + PAGE_ORIGIN + )).toBe('/v0/file/s/current.jpg'); + expect(normalizeFileReferenceAgainst( + 'https://cdn.example.com/image.jpg', + API_ENDPOINT, + FILE_ENDPOINT, + PAGE_ORIGIN + )).toBe('https://cdn.example.com/image.jpg'); +}); + +test('uses an external configured file endpoint when same-origin proxy is unavailable', () => { + expect(normalizeFileReferenceAgainst( + 'https://api.118im.com/v0/file/s/example.jpg', + API_ENDPOINT, + 'https://files.example.com/v0/file', + PAGE_ORIGIN + )).toBe('https://files.example.com/v0/file/s/example.jpg'); +});