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/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..c3fb18303 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,22 @@ export default defineComponent({ populateAuthorizationToken(); stripTokenFromUrl(); - const urlParams = 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); }); - // --- 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 +170,13 @@ export default defineComponent({ useRemoteSaveStateStore().setSaveUrl(url); } + // --- remote server --- // + + const serverStore = useServerStore(); + onMounted(() => { + serverStore.connect(); + }); + // --- layout --- // const { visibleLayout } = storeToRefs(useViewStore()); 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..8c8ac0307 100644 --- a/src/io/import/processors/openUriStream.ts +++ b/src/io/import/processors/openUriStream.ts @@ -3,6 +3,8 @@ 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'; +import { basename } from '@/src/utils/path'; const openUriStream: ImportHandler = async (dataSource, context) => { if (dataSource.type !== 'uri' || !canFetchUrl(dataSource.uri)) { @@ -19,6 +21,16 @@ const openUriStream: ImportHandler = async (dataSource, context) => { await fetcher.connect(); + const filenameFromHeader = extractFilenameFromContentDisposition( + fetcher.contentDisposition + ); + + // Only use Content-Disposition if current name lacks an extension + // (indicating it's likely auto-derived from URL like "download" or "getImage") + const hasExtension = basename(dataSource.name).lastIndexOf('.') > 0; + const finalName = + !hasExtension && filenameFromHeader ? filenameFromHeader : dataSource.name; + // ensure we close the connection on completion context?.onCleanup?.(() => { fetcher.close(); @@ -27,6 +39,7 @@ const openUriStream: ImportHandler = async (dataSource, context) => { return asIntermediateResult([ { ...dataSource, + name: finalName, fetcher, }, ]); 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/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) { diff --git a/src/utils/parseContentDispositionHeader.ts b/src/utils/parseContentDispositionHeader.ts new file mode 100644 index 000000000..2d2c19a7d --- /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: 'inline' | 'attachment' | 'invalid'; + 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/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..a09eb6965 --- /dev/null +++ b/src/utils/urlParams.ts @@ -0,0 +1,89 @@ +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(), window.location.href); + return true; + } catch { + return false; + } +}; + +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)); + } + + const trimmed = value.trim(); + + if (!trimmed) return []; + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + return splitAndClean(trimmed.slice(1, -1)); + } + + if (trimmed.includes(',')) { + return splitAndClean(trimmed); + } + + 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; +}; diff --git a/tests/server/content-disposition-server.ts b/tests/server/content-disposition-server.ts new file mode 100644 index 000000000..5aab7a8c8 --- /dev/null +++ b/tests/server/content-disposition-server.ts @@ -0,0 +1,48 @@ +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); + }); + + 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..1824cc807 --- /dev/null +++ b/tests/specs/content-disposition.e2e.ts @@ -0,0 +1,33 @@ +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); + }); +});