From a15310c5a6f5cdc2b0ea311d0a310b1385134093 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 24 Nov 2025 08:01:24 -0500 Subject: [PATCH 1/5] fix(urls): add URL parameter validation and ZIP file magic detection - Add urlParams.ts utility for parsing and validating URL parameters - Validates URLs in the urls and config parameters - Supports comma-separated and bracket notation for multiple URLs - Filters out invalid URLs and logs errors - Add ZIP magic bytes detection to file type identification - Detects ZIP files by PK header signature (0x50, 0x4b, 0x03, 0x04) - Enables loading of ZIP archives from URLs without .zip extension - Fixes loading DICOM files from APIs that return ZIP archives with generic Content-Type headers (e.g., application/octet-stream) - Integrate URL parameter normalization in App.vue - Use normalizeUrlParams() to validate URLs before loading - Ensures only valid URLs are processed by the loading pipeline This fixes issues with loading files from URLs that: 1. Return generic Content-Type headers 2. Have no file extension in the URL 3. Don't expose Content-Disposition via CORS headers --- src/actions/loadUserFiles.ts | 8 +++- src/components/App.vue | 21 +++++---- src/io/magic.ts | 7 ++- src/utils/urlParams.test.ts | 90 ++++++++++++++++++++++++++++++++++++ src/utils/urlParams.ts | 90 ++++++++++++++++++++++++++++++++++++ 5 files changed, 204 insertions(+), 12 deletions(-) create mode 100644 src/utils/urlParams.test.ts create mode 100644 src/utils/urlParams.ts diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index a68dd61b2..0c283475c 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -376,7 +376,13 @@ function urlsToDataSources(urls: string[], names: string[] = []): DataSource[] { }); } -export async function loadUrls(params: UrlParams) { +type LoadUrlsParams = { + urls?: string[]; + names?: string[]; + config?: string[]; +}; + +export async function loadUrls(params: UrlParams | LoadUrlsParams) { if (params.config) { const configUrls = wrapInArray(params.config); const configSources = urlsToDataSources(configUrls); diff --git a/src/components/App.vue b/src/components/App.vue index 206417c51..e2023eadd 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -85,6 +85,7 @@ import { import { defaultImageMetadata } from '@/src/core/progressiveImage'; import VtkRenderWindowParent from '@/src/components/vtk/VtkRenderWindowParent.vue'; import { useSyncWindowing } from '@/src/composables/useSyncWindowing'; +import { normalizeUrlParams } from '@/src/utils/urlParams'; export default defineComponent({ name: 'App', @@ -146,21 +147,16 @@ export default defineComponent({ populateAuthorizationToken(); stripTokenFromUrl(); - const urlParams = vtkURLExtract.extractURLParameters() as UrlParams; + const urlParams = normalizeUrlParams( + vtkURLExtract.extractURLParameters() as UrlParams + ); onMounted(() => { loadUrls(urlParams); }); - // --- remote server --- // - - const serverStore = useServerStore(); - - onMounted(() => { - serverStore.connect(); - }); + // --- remote save state URL --- // - // --- save state --- // if (import.meta.env.VITE_ENABLE_REMOTE_SAVE && urlParams.save) { const url = Array.isArray(urlParams.save) ? urlParams.save[0] @@ -168,6 +164,13 @@ export default defineComponent({ useRemoteSaveStateStore().setSaveUrl(url); } + // --- remote server --- // + + const serverStore = useServerStore(); + onMounted(() => { + serverStore.connect(); + }); + // --- layout --- // const { visibleLayout } = storeToRefs(useViewStore()); diff --git a/src/io/magic.ts b/src/io/magic.ts index 980e7f06b..0e5a91de7 100644 --- a/src/io/magic.ts +++ b/src/io/magic.ts @@ -9,10 +9,13 @@ interface MagicDatabase { } /** - * file magic database - * Used to handle certain cases where files have no extension + * file magic database for when files have no extension */ const FILE_MAGIC_DB: MagicDatabase[] = [ + { + mime: 'application/zip', + header: [0x50, 0x4b, 0x03, 0x04], // PK\x03\x04 + }, { mime: 'application/dicom', skip: 128, diff --git a/src/utils/urlParams.test.ts b/src/utils/urlParams.test.ts new file mode 100644 index 000000000..743068793 --- /dev/null +++ b/src/utils/urlParams.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import { normalizeUrlParams } from './urlParams'; + +describe('normalizeUrlParams', () => { + it('handles single URL as string', () => { + const result = normalizeUrlParams({ + urls: 'https://example.com/file.dcm', + }); + expect(result.urls).toEqual(['https://example.com/file.dcm']); + }); + + it('handles array of URLs', () => { + const result = normalizeUrlParams({ + urls: ['https://example.com/file1.dcm', 'https://example.com/file2.dcm'], + }); + expect(result.urls).toEqual([ + 'https://example.com/file1.dcm', + 'https://example.com/file2.dcm', + ]); + }); + + it('handles bracket notation string', () => { + const result = normalizeUrlParams({ + urls: '[https://example.com/file1.dcm,https://example.com/file2.dcm]', + }); + expect(result.urls).toEqual([ + 'https://example.com/file1.dcm', + 'https://example.com/file2.dcm', + ]); + }); + + it('handles comma-separated URLs', () => { + const result = normalizeUrlParams({ + urls: 'https://example.com/file1.dcm,https://example.com/file2.dcm', + }); + expect(result.urls).toEqual([ + 'https://example.com/file1.dcm', + 'https://example.com/file2.dcm', + ]); + }); + + it('filters out invalid URLs', () => { + const result = normalizeUrlParams({ + urls: ['https://example.com/valid.dcm', 'not-a-url', 'also-invalid'], + }); + expect(result.urls).toEqual(['https://example.com/valid.dcm']); + }); + + it('handles URLs with query parameters', () => { + const result = normalizeUrlParams({ + urls: 'https://api.example.com/getImage?id=123&format=dcm', + }); + expect(result.urls).toEqual([ + 'https://api.example.com/getImage?id=123&format=dcm', + ]); + }); + + it('handles config and names parameters', () => { + const result = normalizeUrlParams({ + config: 'https://example.com/config.json', + names: ['Image 1', 'Image 2'], + }); + expect(result.config).toEqual(['https://example.com/config.json']); + expect(result.names).toEqual(['Image 1', 'Image 2']); + }); + + it('returns empty object for no valid URLs', () => { + const result = normalizeUrlParams({ + urls: 'not-a-url', + }); + expect(result.urls).toBeUndefined(); + }); + + it('handles save parameter', () => { + const result = normalizeUrlParams({ + save: 'https://example.com/save', + }); + expect(result.save).toBe('https://example.com/save'); + }); + + it('preserves save parameter as array', () => { + const result = normalizeUrlParams({ + save: ['https://example.com/save1', 'https://example.com/save2'], + }); + expect(result.save).toEqual([ + 'https://example.com/save1', + 'https://example.com/save2', + ]); + }); +}); diff --git a/src/utils/urlParams.ts b/src/utils/urlParams.ts new file mode 100644 index 000000000..af9bb020f --- /dev/null +++ b/src/utils/urlParams.ts @@ -0,0 +1,90 @@ +import { UrlParams } from '@vueuse/core'; +import { logError } from '@/src/utils/loggers'; + +type ParsedUrlParams = { + urls?: string[]; + names?: string[]; + config?: string[]; + save?: string | string[]; +}; + +const isValidUrl = (str: string) => { + try { + // eslint-disable-next-line no-new + new URL(str.trim()); + return true; + } catch { + return false; + } +}; + +const parseUrlArray = (value: string | string[]): string[] => { + if (Array.isArray(value)) { + return value.flatMap((v) => parseUrlArray(v)); + } + + const trimmed = value.trim(); + + if (!trimmed) return []; + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + const inner = trimmed.slice(1, -1); + return inner + .split(',') + .map((url) => url.trim()) + .filter(Boolean); + } + + if (trimmed.includes(',')) { + return trimmed + .split(',') + .map((url) => url.trim()) + .filter(Boolean); + } + + return [trimmed]; +}; + +export const normalizeUrlParams = (rawParams: UrlParams): ParsedUrlParams => { + const normalized: ParsedUrlParams = {}; + + if (rawParams.urls) { + const urls = parseUrlArray(rawParams.urls); + const validUrls = urls.filter((url) => { + const isValid = isValidUrl(url); + if (!isValid) { + logError(new Error(`Invalid URL in urls parameter: ${url}`)); + } + return isValid; + }); + + if (validUrls.length > 0) { + normalized.urls = validUrls; + } + } + + if (rawParams.names) { + normalized.names = parseUrlArray(rawParams.names); + } + + if (rawParams.config) { + const configs = parseUrlArray(rawParams.config); + const validConfigs = configs.filter((url) => { + const isValid = isValidUrl(url); + if (!isValid) { + logError(new Error(`Invalid URL in config parameter: ${url}`)); + } + return isValid; + }); + + if (validConfigs.length > 0) { + normalized.config = validConfigs; + } + } + + if (rawParams.save) { + normalized.save = rawParams.save; + } + + return normalized; +}; From 0c281460c47e3a5eb1c5072aca0175bb07b7864e Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 24 Nov 2025 09:34:35 -0500 Subject: [PATCH 2/5] feat(urls): add Content-Disposition header support for filename detection Add support for extracting filenames from Content-Disposition headers (RFC 6266) when loading files from URLs. This enables VolView to load files from APIs that don't include file extensions in their URL paths. Priority order for filename detection: 1. Content-Disposition header (new) 2. URL path extension (existing) 3. Magic bytes (existing fallback) Includes comprehensive e2e tests with custom Express test server. --- package-lock.json | 251 +++++++++++++++++++++ package.json | 3 + src/core/streaming/cachedStreamFetcher.ts | 2 + src/io/import/processors/openUriStream.ts | 8 + src/utils/parseContentDispositionHeader.ts | 43 ++++ tests/server/content-disposition-server.ts | 60 +++++ tests/specs/content-disposition.e2e.ts | 41 ++++ 7 files changed, 408 insertions(+) create mode 100644 src/utils/parseContentDispositionHeader.ts create mode 100644 tests/server/content-disposition-server.ts create mode 100644 tests/specs/content-disposition.e2e.ts diff --git a/package-lock.json b/package-lock.json index 8eaa682ff..cb6b14138 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "@sentry/vue": "^7.54.0", "@thi.ng/api": "^8.11.27", "@thi.ng/rasterize": "^1.0.144", + "@types/cors": "^2.8.19", "@types/deep-equal": "^1.0.1", + "@types/express": "^5.0.5", "@types/file-saver": "^2.0.5", "@types/mocha": "^9.1.1", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -40,6 +42,7 @@ "comlink": "^4.4.1", "concurrently": "^8.2.2", "core-js": "3.22.5", + "cors": "^2.8.5", "cross-env": "^7.0.3", "deep-equal": "^2.0.5", "dicomweb-client-typed": "^0.8.6", @@ -7006,6 +7009,17 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, "node_modules/@types/chai": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz", @@ -7021,6 +7035,26 @@ "@types/chai": "*" } }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.1.tgz", @@ -7061,12 +7095,44 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "node_modules/@types/express": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", + "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "node_modules/@types/file-saver": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==", "dev": true }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -7128,6 +7194,13 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "dev": true }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mocha": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", @@ -7156,12 +7229,59 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/semver": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", "dev": true }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", @@ -11124,6 +11244,20 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -31098,6 +31232,16 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "requires": { + "@types/connect": "*", + "@types/node": "*" + } + }, "@types/chai": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz", @@ -31113,6 +31257,24 @@ "@types/chai": "*" } }, + "@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, "@types/deep-equal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.1.tgz", @@ -31153,12 +31315,41 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "dev": true }, + "@types/express": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", + "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "dev": true, + "requires": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^1" + } + }, + "@types/express-serve-static-core": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", + "dev": true, + "requires": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, "@types/file-saver": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/file-saver/-/file-saver-2.0.5.tgz", "integrity": "sha512-zv9kNf3keYegP5oThGLaPk8E081DFDuwfqjtiTzm6PoxChdJ1raSuADf2YGCVIyrSynLrgc8JWv296s7Q7pQSQ==", "dev": true }, + "@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -31217,6 +31408,12 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "dev": true }, + "@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, "@types/mocha": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-9.1.1.tgz", @@ -31244,12 +31441,56 @@ "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==", "dev": true }, + "@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "dev": true + }, + "@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, "@types/semver": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.0.tgz", "integrity": "sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==", "dev": true }, + "@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "requires": { + "@types/node": "*" + } + }, + "@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "requires": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + }, + "dependencies": { + "@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "requires": { + "@types/mime": "^1", + "@types/node": "*" + } + } + } + }, "@types/sinonjs__fake-timers": { "version": "8.1.5", "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz", @@ -34069,6 +34310,16 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "dev": true, + "requires": { + "object-assign": "^4", + "vary": "^1" + } + }, "crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", diff --git a/package.json b/package.json index 4b0b5bf19..b31278d34 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,9 @@ "@sentry/vue": "^7.54.0", "@thi.ng/api": "^8.11.27", "@thi.ng/rasterize": "^1.0.144", + "@types/cors": "^2.8.19", "@types/deep-equal": "^1.0.1", + "@types/express": "^5.0.5", "@types/file-saver": "^2.0.5", "@types/mocha": "^9.1.1", "@typescript-eslint/eslint-plugin": "^5.62.0", @@ -63,6 +65,7 @@ "comlink": "^4.4.1", "concurrently": "^8.2.2", "core-js": "3.22.5", + "cors": "^2.8.5", "cross-env": "^7.0.3", "deep-equal": "^2.0.5", "dicomweb-client-typed": "^0.8.6", diff --git a/src/core/streaming/cachedStreamFetcher.ts b/src/core/streaming/cachedStreamFetcher.ts index 33e0ab668..c496c5b62 100644 --- a/src/core/streaming/cachedStreamFetcher.ts +++ b/src/core/streaming/cachedStreamFetcher.ts @@ -79,6 +79,7 @@ export class CachedStreamFetcher implements Fetcher { null; public contentType: string = ''; + public contentDisposition: string = ''; constructor( private request: RequestInfo | URL, @@ -130,6 +131,7 @@ export class CachedStreamFetcher implements Fetcher { }); this.contentType = response.headers.get('content-type') ?? ''; + this.contentDisposition = response.headers.get('content-disposition') ?? ''; let remainingContentLength: number | null = null; if (response.headers.has('content-length')) { diff --git a/src/io/import/processors/openUriStream.ts b/src/io/import/processors/openUriStream.ts index f7be050e6..2dc2b1157 100644 --- a/src/io/import/processors/openUriStream.ts +++ b/src/io/import/processors/openUriStream.ts @@ -3,6 +3,7 @@ import { CachedStreamFetcher } from '@/src/core/streaming/cachedStreamFetcher'; import { getRequestPool } from '@/src/core/streaming/requestPool'; import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; import { canFetchUrl } from '@/src/utils/fetch'; +import { extractFilenameFromContentDisposition } from '@/src/utils/parseContentDispositionHeader'; const openUriStream: ImportHandler = async (dataSource, context) => { if (dataSource.type !== 'uri' || !canFetchUrl(dataSource.uri)) { @@ -19,6 +20,12 @@ const openUriStream: ImportHandler = async (dataSource, context) => { await fetcher.connect(); + const filenameFromHeader = extractFilenameFromContentDisposition( + fetcher.contentDisposition + ); + + const finalName = filenameFromHeader || dataSource.name; + // ensure we close the connection on completion context?.onCleanup?.(() => { fetcher.close(); @@ -27,6 +34,7 @@ const openUriStream: ImportHandler = async (dataSource, context) => { return asIntermediateResult([ { ...dataSource, + name: finalName, fetcher, }, ]); diff --git a/src/utils/parseContentDispositionHeader.ts b/src/utils/parseContentDispositionHeader.ts new file mode 100644 index 000000000..044851e85 --- /dev/null +++ b/src/utils/parseContentDispositionHeader.ts @@ -0,0 +1,43 @@ +const CONTENT_DISPOSITION_FILENAME_REGEXP = + /filename\s*=\s*(?:"([^"]*)"|([^;\s]*))/i; + +export type ContentDisposition = + | { type: 'invalid'; filename: null } + | { type: 'inline'; filename: string | null } + | { type: 'attachment'; filename: string | null }; + +export function parseContentDispositionHeader( + headerValue: string | null +): ContentDisposition { + if (headerValue == null || headerValue.length === 0) { + return { type: 'invalid', filename: null }; + } + + const trimmed = headerValue.trim().toLowerCase(); + + let disposition: 'inline' | 'attachment' | 'invalid' = 'invalid'; + if (trimmed.startsWith('inline')) { + disposition = 'inline'; + } else if (trimmed.startsWith('attachment')) { + disposition = 'attachment'; + } + + if (disposition === 'invalid') { + return { type: 'invalid', filename: null }; + } + + const match = CONTENT_DISPOSITION_FILENAME_REGEXP.exec(headerValue); + const filename = match ? match[1] || match[2] || null : null; + + return { + type: disposition, + filename: filename && filename.length > 0 ? filename : null, + }; +} + +export function extractFilenameFromContentDisposition( + headerValue: string | null +): string | null { + const parsed = parseContentDispositionHeader(headerValue); + return parsed.filename; +} diff --git a/tests/server/content-disposition-server.ts b/tests/server/content-disposition-server.ts new file mode 100644 index 000000000..d7dad46a9 --- /dev/null +++ b/tests/server/content-disposition-server.ts @@ -0,0 +1,60 @@ +import express from 'express'; +import cors from 'cors'; +import { readFileSync } from 'fs'; +import { join } from 'path'; + +const PORT = 4568; +const TEMP_DIR = '.tmp'; + +export function createContentDispositionServer() { + const app = express(); + + app.use( + cors({ + origin: '*', + exposedHeaders: ['Content-Disposition'], + }) + ); + + app.get('/scan', (req, res) => { + const filePath = join(TEMP_DIR, 'CT_Electrodes.nii.gz'); + const fileBuffer = readFileSync(filePath); + + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="CT_Electrodes.nii.gz"' + ); + res.send(fileBuffer); + }); + + app.get('/scan.dcm', (req, res) => { + const filePath = join(TEMP_DIR, 'CT_Electrodes.nii.gz'); + const fileBuffer = readFileSync(filePath); + + res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="CT_Electrodes.nii.gz"' + ); + res.send(fileBuffer); + }); + + return app; +} + +export function startServer() { + const app = createContentDispositionServer(); + return app.listen(PORT, () => { + console.log(`Content-Disposition test server running on port ${PORT}`); + }); +} + +export function stopServer(server: ReturnType) { + return new Promise((resolve) => { + server.close(() => { + console.log('Content-Disposition test server stopped'); + resolve(); + }); + }); +} diff --git a/tests/specs/content-disposition.e2e.ts b/tests/specs/content-disposition.e2e.ts new file mode 100644 index 000000000..51fabbb99 --- /dev/null +++ b/tests/specs/content-disposition.e2e.ts @@ -0,0 +1,41 @@ +import { volViewPage } from '../pageobjects/volview.page'; +import { downloadFile } from './utils'; +import { startServer, stopServer } from '../server/content-disposition-server'; + +const CT_ELECTRODES = { + url: 'https://raw.githubusercontent.com/neurolabusc/niivue-images/main/CT_Electrodes.nii.gz', + name: 'CT_Electrodes.nii.gz', +}; + +const SERVER_PORT = 4568; + +describe('Content-Disposition header handling', () => { + let server: ReturnType; + + before(async () => { + await downloadFile(CT_ELECTRODES.url, CT_ELECTRODES.name); + server = startServer(); + }); + + after(async () => { + if (server) { + await stopServer(server); + } + }); + + it('should use filename from Content-Disposition when URL has no extension', async () => { + await volViewPage.open(`?urls=http://localhost:${SERVER_PORT}/scan`); + await volViewPage.waitForViews(); + + const notificationCount = await volViewPage.getNotificationsCount(); + expect(notificationCount).toBe(0); + }); + + it('should prefer Content-Disposition over URL extension', async () => { + await volViewPage.open(`?urls=http://localhost:${SERVER_PORT}/scan.dcm`); + await volViewPage.waitForViews(); + + const notificationCount = await volViewPage.getNotificationsCount(); + expect(notificationCount).toBe(0); + }); +}); From 2701d3aeb0065958ab1ae6c36f91907521244617 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 24 Nov 2025 09:53:36 -0500 Subject: [PATCH 3/5] fix(zip): filter LICENSE files from archive extraction --- src/io/zip.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/io/zip.ts b/src/io/zip.ts index c25e5c975..e4a05afb2 100644 --- a/src/io/zip.ts +++ b/src/io/zip.ts @@ -7,8 +7,8 @@ export async function extractFilesFromZip(zipFile: File): Promise { const promises: Promise[] = []; const paths: string[] = []; zip.forEach((relPath, file) => { - if (!file.dir) { - const fileName = basename(file.name); + const fileName = basename(file.name); + if (!file.dir && fileName.toLowerCase() !== 'license') { const path = dirname(file.name); const fileEntry = zip.file(file.name); if (fileEntry) { From 52bc306d57c759adf9a50f12450108174e995dd1 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 24 Nov 2025 10:44:46 -0500 Subject: [PATCH 4/5] fix(urls): preserve explicit names over Content-Disposition Only use Content-Disposition when name has no extension (indicating URL-derived name like 'download' or 'getImage'). Names with extensions are preserved, avoiding conflicts with explicit user-provided names. - Removed second test case (wrong extension override not supported) - Removed unused server endpoint - Priority: Names with extension > Content-Disposition > Names without extension Fix urls accept relative URLs in config parameter validationfix --- src/io/import/processors/openUriStream.ts | 6 +++++- src/utils/urlParams.ts | 2 +- tests/server/content-disposition-server.ts | 12 ------------ tests/specs/content-disposition.e2e.ts | 8 -------- 4 files changed, 6 insertions(+), 22 deletions(-) diff --git a/src/io/import/processors/openUriStream.ts b/src/io/import/processors/openUriStream.ts index 2dc2b1157..80f99467c 100644 --- a/src/io/import/processors/openUriStream.ts +++ b/src/io/import/processors/openUriStream.ts @@ -24,7 +24,11 @@ const openUriStream: ImportHandler = async (dataSource, context) => { fetcher.contentDisposition ); - const finalName = filenameFromHeader || dataSource.name; + // Only use Content-Disposition if current name lacks an extension + // (indicating it's likely auto-derived from URL like "download" or "getImage") + const hasExtension = dataSource.name.includes('.'); + const finalName = + !hasExtension && filenameFromHeader ? filenameFromHeader : dataSource.name; // ensure we close the connection on completion context?.onCleanup?.(() => { diff --git a/src/utils/urlParams.ts b/src/utils/urlParams.ts index af9bb020f..9e3aa58f9 100644 --- a/src/utils/urlParams.ts +++ b/src/utils/urlParams.ts @@ -11,7 +11,7 @@ type ParsedUrlParams = { const isValidUrl = (str: string) => { try { // eslint-disable-next-line no-new - new URL(str.trim()); + new URL(str.trim(), window.location.href); return true; } catch { return false; diff --git a/tests/server/content-disposition-server.ts b/tests/server/content-disposition-server.ts index d7dad46a9..5aab7a8c8 100644 --- a/tests/server/content-disposition-server.ts +++ b/tests/server/content-disposition-server.ts @@ -28,18 +28,6 @@ export function createContentDispositionServer() { res.send(fileBuffer); }); - app.get('/scan.dcm', (req, res) => { - const filePath = join(TEMP_DIR, 'CT_Electrodes.nii.gz'); - const fileBuffer = readFileSync(filePath); - - res.setHeader('Content-Type', 'application/octet-stream'); - res.setHeader( - 'Content-Disposition', - 'attachment; filename="CT_Electrodes.nii.gz"' - ); - res.send(fileBuffer); - }); - return app; } diff --git a/tests/specs/content-disposition.e2e.ts b/tests/specs/content-disposition.e2e.ts index 51fabbb99..1824cc807 100644 --- a/tests/specs/content-disposition.e2e.ts +++ b/tests/specs/content-disposition.e2e.ts @@ -30,12 +30,4 @@ describe('Content-Disposition header handling', () => { const notificationCount = await volViewPage.getNotificationsCount(); expect(notificationCount).toBe(0); }); - - it('should prefer Content-Disposition over URL extension', async () => { - await volViewPage.open(`?urls=http://localhost:${SERVER_PORT}/scan.dcm`); - await volViewPage.waitForViews(); - - const notificationCount = await volViewPage.getNotificationsCount(); - expect(notificationCount).toBe(0); - }); }); From f29076ea32e9240540abfd90327a34aa51d7ec58 Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Mon, 24 Nov 2025 12:27:47 -0500 Subject: [PATCH 5/5] refactor: improve URL handling and simplify types - Add error handling for URL parameter parsing in App.vue - Improve extension detection in openUriStream.ts - Use basename() and lastIndexOf('.') for robust extension check - Prevents false positives from hidden files and URL domains --- src/components/App.vue | 12 +++++++++--- src/io/import/processors/openUriStream.ts | 3 ++- src/utils/parseContentDispositionHeader.ts | 8 ++++---- src/utils/urlParams.ts | 17 ++++++++--------- 4 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/components/App.vue b/src/components/App.vue index e2023eadd..c3fb18303 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -147,9 +147,15 @@ export default defineComponent({ populateAuthorizationToken(); stripTokenFromUrl(); - const urlParams = normalizeUrlParams( - vtkURLExtract.extractURLParameters() as UrlParams - ); + let urlParams: ReturnType; + try { + urlParams = normalizeUrlParams( + vtkURLExtract.extractURLParameters() as UrlParams + ); + } catch (error) { + console.error('Failed to parse URL parameters:', error); + urlParams = {}; + } onMounted(() => { loadUrls(urlParams); diff --git a/src/io/import/processors/openUriStream.ts b/src/io/import/processors/openUriStream.ts index 80f99467c..8c8ac0307 100644 --- a/src/io/import/processors/openUriStream.ts +++ b/src/io/import/processors/openUriStream.ts @@ -4,6 +4,7 @@ import { getRequestPool } from '@/src/core/streaming/requestPool'; import { ImportHandler, asIntermediateResult } from '@/src/io/import/common'; import { canFetchUrl } from '@/src/utils/fetch'; import { extractFilenameFromContentDisposition } from '@/src/utils/parseContentDispositionHeader'; +import { basename } from '@/src/utils/path'; const openUriStream: ImportHandler = async (dataSource, context) => { if (dataSource.type !== 'uri' || !canFetchUrl(dataSource.uri)) { @@ -26,7 +27,7 @@ const openUriStream: ImportHandler = async (dataSource, context) => { // Only use Content-Disposition if current name lacks an extension // (indicating it's likely auto-derived from URL like "download" or "getImage") - const hasExtension = dataSource.name.includes('.'); + const hasExtension = basename(dataSource.name).lastIndexOf('.') > 0; const finalName = !hasExtension && filenameFromHeader ? filenameFromHeader : dataSource.name; diff --git a/src/utils/parseContentDispositionHeader.ts b/src/utils/parseContentDispositionHeader.ts index 044851e85..2d2c19a7d 100644 --- a/src/utils/parseContentDispositionHeader.ts +++ b/src/utils/parseContentDispositionHeader.ts @@ -1,10 +1,10 @@ const CONTENT_DISPOSITION_FILENAME_REGEXP = /filename\s*=\s*(?:"([^"]*)"|([^;\s]*))/i; -export type ContentDisposition = - | { type: 'invalid'; filename: null } - | { type: 'inline'; filename: string | null } - | { type: 'attachment'; filename: string | null }; +export type ContentDisposition = { + type: 'inline' | 'attachment' | 'invalid'; + filename: string | null; +}; export function parseContentDispositionHeader( headerValue: string | null diff --git a/src/utils/urlParams.ts b/src/utils/urlParams.ts index 9e3aa58f9..a09eb6965 100644 --- a/src/utils/urlParams.ts +++ b/src/utils/urlParams.ts @@ -18,6 +18,12 @@ const isValidUrl = (str: string) => { } }; +const splitAndClean = (str: string) => + str + .split(',') + .map((url) => url.trim()) + .filter(Boolean); + const parseUrlArray = (value: string | string[]): string[] => { if (Array.isArray(value)) { return value.flatMap((v) => parseUrlArray(v)); @@ -28,18 +34,11 @@ const parseUrlArray = (value: string | string[]): string[] => { if (!trimmed) return []; if (trimmed.startsWith('[') && trimmed.endsWith(']')) { - const inner = trimmed.slice(1, -1); - return inner - .split(',') - .map((url) => url.trim()) - .filter(Boolean); + return splitAndClean(trimmed.slice(1, -1)); } if (trimmed.includes(',')) { - return trimmed - .split(',') - .map((url) => url.trim()) - .filter(Boolean); + return splitAndClean(trimmed); } return [trimmed];