diff --git a/app/core/src/main/resources/messages_en_GB.properties b/app/core/src/main/resources/messages_en_GB.properties index 064b5a488..69f06bff5 100644 --- a/app/core/src/main/resources/messages_en_GB.properties +++ b/app/core/src/main/resources/messages_en_GB.properties @@ -1403,6 +1403,8 @@ multiTool.dragDropMessage=Page(s) Selected multiTool.undo=Undo (CTRL + Z) multiTool.redo=Redo (CTRL + Y) +multiTool.svgNotSupported=SVG files are not supported in Multi Tool and were ignored. + #decrypt decrypt.passwordPrompt=This file is password-protected. Please enter the password: decrypt.cancelled=Operation cancelled for PDF: {0} diff --git a/app/core/src/main/resources/static/js/fileInput.js b/app/core/src/main/resources/static/js/fileInput.js index 71b6a1ebf..07ec80882 100644 --- a/app/core/src/main/resources/static/js/fileInput.js +++ b/app/core/src/main/resources/static/js/fileInput.js @@ -29,6 +29,54 @@ const mimeTypes = { "pdf": "application/pdf", }; +const isMultiToolPage = () => window.location.pathname?.includes('multi-tool'); + +const isSvgFile = (file) => { + if (!file) return false; + const type = (file.type || '').toLowerCase(); + if (type === 'image/svg+xml') { + return true; + } + const name = (file.name || '').toLowerCase(); + return name.endsWith('.svg'); +}; + +function filterSvgFiles(files) { + if (!Array.isArray(files) || !isMultiToolPage()) { + return { allowed: files ?? [], rejected: [] }; + } + + const allowed = []; + const rejected = []; + + files.forEach((file) => { + if (isSvgFile(file)) { + rejected.push(file); + } else { + allowed.push(file); + } + }); + + return { allowed, rejected }; +} + +function showSvgWarning(rejectedFiles = []) { + if (!rejectedFiles.length) return; + + const message = window.multiTool?.svgNotSupported || + 'SVG files are not supported in Multi Tool and were ignored.'; + const rejectedNames = rejectedFiles + .map((file) => file?.name) + .filter(Boolean) + .join(', '); + + if (rejectedNames) { + alert(`${message}\n${rejectedNames}`); + } else { + alert(message); + } +} + function setupFileInput(chooser) { const elementId = chooser.getAttribute('data-bs-element-id'); const filesSelected = chooser.getAttribute('data-bs-files-selected'); @@ -198,6 +246,24 @@ function setupFileInput(chooser) { await checkZipFile(); + const { allowed: nonSvgFiles, rejected: rejectedSvgFiles } = filterSvgFiles(allFiles); + if (rejectedSvgFiles.length > 0) { + showSvgWarning(rejectedSvgFiles); + allFiles = nonSvgFiles; + + const updatedTransfer = toDataTransfer(allFiles); + element.files = updatedTransfer.files; + if (allFiles.length === 0) { + element.value = ''; + } + + if (allFiles.length === 0) { + inputContainer.querySelector('#fileInputText').innerHTML = originalText; + showOrHideSelectedFilesContainer(allFiles); + return; + } + } + const uploadLimit = window.stirlingPDF?.uploadLimit ?? 0; if (uploadLimit > 0) { const oversizedFiles = allFiles.filter(f => f.size > uploadLimit); diff --git a/app/core/src/main/resources/static/js/multitool/ImageHighlighter.js b/app/core/src/main/resources/static/js/multitool/ImageHighlighter.js index cf5d161a3..4b914ea9d 100644 --- a/app/core/src/main/resources/static/js/multitool/ImageHighlighter.js +++ b/app/core/src/main/resources/static/js/multitool/ImageHighlighter.js @@ -42,25 +42,6 @@ class ImageHighlighter { img.addEventListener("click", this.imageHighlightCallback); return div; } - - async addImageFile(file, nextSiblingElement) { - const div = document.createElement("div"); - div.classList.add("page-container"); - - var img = document.createElement("img"); - img.classList.add("page-image"); - img.src = URL.createObjectURL(file); - div.appendChild(img); - - this.pdfAdapters.forEach((adapter) => { - adapter.adapt?.(div); - }); - if (nextSiblingElement) { - this.pagesContainer.insertBefore(div, nextSiblingElement); - } else { - this.pagesContainer.appendChild(div); - } - } } export default ImageHighlighter; diff --git a/app/core/src/main/resources/static/js/multitool/PdfContainer.js b/app/core/src/main/resources/static/js/multitool/PdfContainer.js index ce4706793..f71a5da70 100644 --- a/app/core/src/main/resources/static/js/multitool/PdfContainer.js +++ b/app/core/src/main/resources/static/js/multitool/PdfContainer.js @@ -8,6 +8,49 @@ import { AddFilesCommand } from './commands/add-page.js'; import { DecryptFile } from '../DecryptFiles.js'; import { CommandSequence } from './commands/commands-sequence.js'; +const isSvgFile = (file) => { + if (!file) return false; + const type = (file.type || '').toLowerCase(); + if (type === 'image/svg+xml') { + return true; + } + const name = (file.name || '').toLowerCase(); + return name.endsWith('.svg'); +}; + +const partitionSvgFiles = (files = []) => { + const allowed = []; + const rejected = []; + + files.forEach((file) => { + if (isSvgFile(file)) { + rejected.push(file); + } else { + allowed.push(file); + } + }); + + return { allowed, rejected }; +}; + +const notifySvgUnsupported = (files = []) => { + if (!files.length) return; + if (!window.location.pathname?.includes('multi-tool')) return; + + const message = window.multiTool?.svgNotSupported || + 'SVG files are not supported in Multi Tool and were ignored.'; + const names = files + .map((file) => file?.name) + .filter(Boolean) + .join(', '); + + if (names) { + alert(`${message}\n${names}`); + } else { + alert(message); + } +}; + class PdfContainer { fileName; pagesContainer; @@ -180,10 +223,18 @@ class PdfContainer { input.onchange = async (e) => { const files = e.target.files; if (files.length > 0) { - pages = await this.addFilesFromFiles(files, nextSiblingElement, pages); - this.updateFilename(files[0].name); + const { + pages: updatedPages, + acceptedFileCount, + } = await this.addFilesFromFiles(files, nextSiblingElement, pages); - if(window.selectPage){ + pages = updatedPages; + + if (acceptedFileCount > 0) { + this.updateFilename(); + } + + if (window.selectPage && acceptedFileCount > 0) { this.showButton(document.getElementById('select-pages-container'), true); } } @@ -196,11 +247,17 @@ class PdfContainer { async handleDroppedFiles(files, nextSiblingElement = null) { if (files.length > 0) { - const pages = await this.addFilesFromFiles(files, nextSiblingElement, []); - this.updateFilename(files[0]?.name || 'untitled'); + const { + pages, + acceptedFileCount, + } = await this.addFilesFromFiles(files, nextSiblingElement, []); - if(window.selectPage) { - this.showButton(document.getElementById('select-pages-container'), true); + if (acceptedFileCount > 0) { + this.updateFilename(); + + if (window.selectPage) { + this.showButton(document.getElementById('select-pages-container'), true); + } } return pages; @@ -209,14 +266,24 @@ class PdfContainer { async addFilesFromFiles(files, nextSiblingElement, pages) { this.fileName = files[0].name; - for (var i = 0; i < files.length; i++) { + const fileArray = Array.from(files || []); + const { allowed: allowedFiles, rejected: rejectedSvgFiles } = partitionSvgFiles(fileArray); + + if (allowedFiles.length > 0) { + this.fileName = allowedFiles[0].name || 'untitled'; + } + + let acceptedFileCount = 0; + + for (let i = 0; i < allowedFiles.length; i++) { + const file = allowedFiles[i]; const startTime = Date.now(); let processingTime, errorMessage = null, pageCount = 0; try { - let decryptedFile = files[i]; + let decryptedFile = file; let isEncrypted = false; let requiresPassword = false; await this.decryptFile @@ -245,18 +312,27 @@ class PdfContainer { processingTime = Date.now() - startTime; this.captureFileProcessingEvent(true, decryptedFile, processingTime, null, pageCount); + acceptedFileCount++; } catch (error) { processingTime = Date.now() - startTime; errorMessage = error.message || 'Unknown error'; - this.captureFileProcessingEvent(false, files[i], processingTime, errorMessage, pageCount); + this.captureFileProcessingEvent(false, file, processingTime, errorMessage, pageCount); + + if (isSvgFile(file)) { + rejectedSvgFiles.push(file); + } } } + if (rejectedSvgFiles.length > 0) { + notifySvgUnsupported(rejectedSvgFiles); + } + document.querySelectorAll('.enable-on-file').forEach((element) => { element.disabled = false; }); - return pages; + return { pages, acceptedFileCount }; } captureFileProcessingEvent(success, file, processingTime, errorMessage, pageCount) { @@ -329,12 +405,20 @@ class PdfContainer { } async addImageFile(file, nextSiblingElement, pages) { + // Ensure the provided file is a safe image type to prevent DOM XSS when + // rendering user-supplied content. Reject SVG and non-image files that could + // contain executable scripts. + if (!(file instanceof File) || !file.type.startsWith('image/') || file.type === 'image/svg+xml') { + throw new Error('Invalid image file'); + } const div = document.createElement('div'); div.classList.add('page-container'); - var img = document.createElement('img'); + const img = document.createElement('img'); img.classList.add('page-image'); - img.src = URL.createObjectURL(file); + const objectUrl = URL.createObjectURL(file); + img.src = objectUrl; + img.onload = () => URL.revokeObjectURL(objectUrl); div.appendChild(img); this.pdfAdapters.forEach((adapter) => { @@ -740,9 +824,11 @@ class PdfContainer { pdfDoc.addPage(page); } else { page = pdfDoc.addPage([img.naturalWidth, img.naturalHeight]); - const imageBytes = await fetch(img.src).then((res) => res.arrayBuffer()); + + // NEU: Bildbytes robust ermitteln (Canvas für blob:, fetch für http/https) + const { bytes: imageBytes, forcedType } = await bytesFromImageElement(img); const uint8Array = new Uint8Array(imageBytes); - const imageType = detectImageType(uint8Array); + const imageType = forcedType || detectImageType(uint8Array); let image; switch (imageType) { @@ -967,6 +1053,36 @@ class PdfContainer { } } +async function bytesFromImageElement(img) { + // Handle Blob URLs without using fetch() + if (img.src.startsWith('blob:')) { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth; + canvas.height = img.naturalHeight; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(img, 0, 0); + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png')); + if (!blob) throw new Error('Canvas toBlob() failed'); + const buf = await blob.arrayBuffer(); + return { bytes: buf, forcedType: 'PNG' }; // Canvas always generates PNG + } + + // Fetch http(s)/data:-URLs normally (if necessary) + const res = await fetch(img.src, { cache: 'no-store' }); + if (!res.ok) throw new Error(`HTTP ${res.status} beim Laden von ${img.src}`); + const buf = await res.arrayBuffer(); + + // Use Content-Type as a hint (optional) + let forcedType = null; + const ct = res.headers.get('content-type') || ''; + if (ct.includes('png')) forcedType = 'PNG'; + else if (ct.includes('jpeg') || ct.includes('jpg')) forcedType = 'JPEG'; + else if (ct.includes('tiff')) forcedType = 'TIFF'; + else if (ct.includes('gif')) forcedType = 'GIF'; + + return { bytes: buf, forcedType }; +} + function detectImageType(uint8Array) { // Check for PNG signature if (uint8Array[0] === 137 && uint8Array[1] === 80 && uint8Array[2] === 78 && uint8Array[3] === 71) { diff --git a/app/core/src/main/resources/templates/multi-tool.html b/app/core/src/main/resources/templates/multi-tool.html index 1624cf4b5..d3dd3c37c 100644 --- a/app/core/src/main/resources/templates/multi-tool.html +++ b/app/core/src/main/resources/templates/multi-tool.html @@ -147,6 +147,10 @@ redo: '[[#{multiTool.redo}]]', }; + window.multiTool = Object.assign({}, window.multiTool, { + svgNotSupported: '[[#{multiTool.svgNotSupported}]]', + }); + window.decrypt = { passwordPrompt: '[[#{decrypt.passwordPrompt}]]', cancelled: '[[#{decrypt.cancelled}]]',