From fb482a1052c48480c2347d7dc4d42eae70a40d61 Mon Sep 17 00:00:00 2001 From: nobswebdev Date: Sun, 1 Nov 2026 15:32:25 +0100 Subject: [PATCH] Sniff image MIME for ZK viewer blobs so save-as gets a real extension. --- frontend/package-lock.json | 140 ++++++++++++++++++ frontend/package.json | 1 + frontend/src/stores/zkView.ts | 5 +- .../utils/file/detectImageMimeType.spec.ts | 30 ++++ .../src/utils/file/detectImageMimeType.ts | 7 + 5 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 frontend/src/utils/file/detectImageMimeType.spec.ts create mode 100644 frontend/src/utils/file/detectImageMimeType.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index dbf223f..bb92c41 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "dayjs": "^1.11.23", "escape-html": "^1.0.3", "hash-wasm": "^4.12.0", + "image-type": "^6.0.0", "pinia": "^4.0.3", "pretty-bytes": "^7.2.0", "tailwindcss": "^4.3.3", @@ -86,6 +87,16 @@ "node": ">=6.9.0" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.3.tgz", + "integrity": "sha512-49CycTz1aAZmvFcwkeFyhud1HUmRIB5Tabf7ALV+VA4Iu85MTfz2cUwzPagMFAdVBvrWdkCxG67I30tUwwpKsw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", @@ -1701,6 +1712,30 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@tokenizer/inflate": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.2.7.tgz", + "integrity": "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fflate": "^0.8.2", + "token-types": "^6.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2509,6 +2544,30 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/file-type": { + "version": "20.5.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-20.5.0.tgz", + "integrity": "sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.2.6", + "strtok3": "^10.2.0", + "token-types": "^6.0.0", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, "node_modules/follow-redirects": { "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", @@ -2688,6 +2747,41 @@ "node": ">= 6" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/image-type": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/image-type/-/image-type-6.0.0.tgz", + "integrity": "sha512-efpcYd/E9A7a+oanft11ceIbO9Aw0iszfJ7Qfh4QLWl2Ulsth9nnllV/L1TmzKwlQ2O5FuT08vy5zxLnGxZe8w==", + "license": "MIT", + "dependencies": { + "file-type": "^20.5.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3388,6 +3482,22 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/strtok3": { + "version": "10.6.3", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.6.3.tgz", + "integrity": "sha512-MD8mENgyXMr2mDaDfzP3RWYDSyYQxIIou7HhCiCJiBekrsoDtUG0id3vYnjDfY9ndOEbazFXe13Qw6u/1AB8ew==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -3467,6 +3577,24 @@ "node": ">=14.0.0" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -3488,6 +3616,18 @@ "dev": true, "license": "MIT" }, + "node_modules/uint8array-extras": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.6.0.tgz", + "integrity": "sha512-8iAasVS4wUx0gPLjH8Xtz2PeDzTSiy8QiLGu2DT3X5GU+egFEQhpnbtkehbAwjvDcxIERmCYcysiODFmE3Ud0Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 389e394..51e06bc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "dayjs": "^1.11.23", "escape-html": "^1.0.3", "hash-wasm": "^4.12.0", + "image-type": "^6.0.0", "pinia": "^4.0.3", "pretty-bytes": "^7.2.0", "tailwindcss": "^4.3.3", diff --git a/frontend/src/stores/zkView.ts b/frontend/src/stores/zkView.ts index 9c83997..048df6e 100644 --- a/frontend/src/stores/zkView.ts +++ b/frontend/src/stores/zkView.ts @@ -3,6 +3,7 @@ import { ref } from 'vue'; import { api } from '@/plugins/axios'; import type { GetZkUploadCiphertextResult } from '@/types/upload/GetZkUploadCiphertextResult'; import { base64ToBytes } from '@/utils/encoding/base64ToBytes'; +import { detectImageMimeType } from '@/utils/file/detectImageMimeType'; import { toArrayBuffer } from '@/utils/toArrayBuffer'; import { decryptPasswordProtectedZkUpload } from '@/utils/upload/decryptPasswordProtectedZkUpload'; import { decryptZkUpload } from '@/utils/upload/decryptZkUpload'; @@ -67,7 +68,9 @@ export const useZkViewStore = defineStore('zkView', () => { throw new Error('Could not build image blob'); } - imageObjectUrl.value = URL.createObjectURL(new Blob([buffer])); + const mimeType = await detectImageMimeType(plaintext); + + imageObjectUrl.value = URL.createObjectURL(new Blob([buffer], { type: mimeType })); }; return { diff --git a/frontend/src/utils/file/detectImageMimeType.spec.ts b/frontend/src/utils/file/detectImageMimeType.spec.ts new file mode 100644 index 0000000..0272180 --- /dev/null +++ b/frontend/src/utils/file/detectImageMimeType.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; +import { detectImageMimeType } from '@/utils/file/detectImageMimeType'; + +const padToMinDetectLength = (header: number[]): Uint8Array => { + const out = new Uint8Array(4100); + + out.set(header); + + return out; +}; + +describe('detectImageMimeType', () => { + it('detects PNG', async () => { + const png = padToMinDetectLength([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + + await expect(detectImageMimeType(png)).resolves.toBe('image/png'); + }); + + it('detects JPEG', async () => { + const jpeg = padToMinDetectLength([0xff, 0xd8, 0xff, 0xe0]); + + await expect(detectImageMimeType(jpeg)).resolves.toBe('image/jpeg'); + }); + + it('falls back for unknown bytes', async () => { + await expect(detectImageMimeType(new Uint8Array(4100))).resolves.toBe( + 'application/octet-stream' + ); + }); +}); diff --git a/frontend/src/utils/file/detectImageMimeType.ts b/frontend/src/utils/file/detectImageMimeType.ts new file mode 100644 index 0000000..5f9844b --- /dev/null +++ b/frontend/src/utils/file/detectImageMimeType.ts @@ -0,0 +1,7 @@ +import imageType from 'image-type'; + +export const detectImageMimeType = async (data: Uint8Array): Promise => { + const result = await imageType(data); + + return result?.mime ?? 'application/octet-stream'; +};