From 244cbe36ff6246b5fb6890f1f5eae4922a9963a3 Mon Sep 17 00:00:00 2001 From: Dario Ghunney Ware Date: Wed, 18 Jun 2025 15:13:27 +0100 Subject: [PATCH] changed response type, updated tests --- .../software/common/util/EmlToPdf.java | 4 +- .../api/misc/AttachmentsController.java | 7 +- .../SPDF/service/PDFAttachmentService.java | 78 +++++++------ .../PDFAttachmentServiceInterface.java | 2 +- .../api/misc/AttachmentsControllerTest.java | 14 ++- .../service/PDFAttachmentServiceTest.java | 105 +++++++++++++++++- 6 files changed, 165 insertions(+), 45 deletions(-) diff --git a/common/src/main/java/stirling/software/common/util/EmlToPdf.java b/common/src/main/java/stirling/software/common/util/EmlToPdf.java index 2cd984ef6..c11b4db4c 100644 --- a/common/src/main/java/stirling/software/common/util/EmlToPdf.java +++ b/common/src/main/java/stirling/software/common/util/EmlToPdf.java @@ -1242,15 +1242,13 @@ public class EmlToPdf { document, new ByteArrayInputStream(attachment.getData())); embeddedFile.setSize(attachment.getData().length); embeddedFile.setCreationDate(new GregorianCalendar()); - if (attachment.getContentType() != null) { - embeddedFile.setSubtype(attachment.getContentType()); - } // Create file specification PDComplexFileSpecification fileSpec = new PDComplexFileSpecification(); fileSpec.setFile(uniqueFilename); fileSpec.setEmbeddedFile(embeddedFile); if (attachment.getContentType() != null) { + embeddedFile.setSubtype(attachment.getContentType()); fileSpec.setFileDescription("Email attachment: " + uniqueFilename); } diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentsController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentsController.java index 5e67a5294..9f5bacb1b 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentsController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentsController.java @@ -60,10 +60,11 @@ public class AttachmentsController { // Add attachments catalog.setNames(documentNames); - pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + byte[] output = + pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); - return WebResponseUtils.pdfDocToWebResponse( - document, + return WebResponseUtils.bytesToWebResponse( + output, Filenames.toSimpleFileName(pdfFile.getOriginalFilename()) .replaceFirst("[.][^.]+$", "") + "_with_attachments.pdf"); diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/service/PDFAttachmentService.java b/stirling-pdf/src/main/java/stirling/software/SPDF/service/PDFAttachmentService.java index 78e9639e5..d7db7c696 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/service/PDFAttachmentService.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/service/PDFAttachmentService.java @@ -1,5 +1,6 @@ package stirling.software.SPDF.service; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.GregorianCalendar; import java.util.HashMap; @@ -13,6 +14,7 @@ import org.apache.pdfbox.pdmodel.PageMode; import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification; import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile; import org.apache.pdfbox.pdmodel.encryption.AccessPermission; +import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -25,7 +27,7 @@ import stirling.software.common.util.PDFAttachmentUtils; public class PDFAttachmentService implements PDFAttachmentServiceInterface { @Override - public void addAttachment( + public byte[] addAttachment( PDDocument document, PDEmbeddedFilesNameTreeNode embeddedFilesTree, List attachments) @@ -34,6 +36,7 @@ public class PDFAttachmentService implements PDFAttachmentServiceInterface { try { existingNames = embeddedFilesTree.getNames(); + if (existingNames == null) { log.debug("No existing embedded files found, creating new names map."); existingNames = new HashMap<>(); @@ -45,69 +48,78 @@ public class PDFAttachmentService implements PDFAttachmentServiceInterface { throw e; } + grantAccessPermissions(document); final Map existingEmbeddedFiles = existingNames; + attachments.forEach( attachment -> { - // Create attachments specification - PDComplexFileSpecification fileSpecification = new PDComplexFileSpecification(); - fileSpecification.setFile(attachment.getOriginalFilename()); - fileSpecification.setFileUnicode(attachment.getOriginalFilename()); - fileSpecification.setFileDescription( - "Embedded attachment: " + attachment.getOriginalFilename()); + String filename = attachment.getOriginalFilename(); try { - // Create embedded attachment PDEmbeddedFile embeddedFile = new PDEmbeddedFile(document, attachment.getInputStream()); embeddedFile.setSize((int) attachment.getSize()); embeddedFile.setCreationDate(new GregorianCalendar()); embeddedFile.setModDate(new GregorianCalendar()); - - // Set MIME type if available String contentType = attachment.getContentType(); if (StringUtils.isNotBlank(contentType)) { embeddedFile.setSubtype(contentType); } - // Associate embedded attachment with file specification + // Create attachments specification and associate embedded attachment with + // file + PDComplexFileSpecification fileSpecification = + new PDComplexFileSpecification(); + fileSpecification.setFile(filename); + fileSpecification.setFileUnicode(filename); + fileSpecification.setFileDescription("Embedded attachment: " + filename); embeddedFile.setFile(fileSpecification); fileSpecification.setEmbeddedFile(embeddedFile); fileSpecification.setEmbeddedFileUnicode(embeddedFile); // Add to the existing files map - existingEmbeddedFiles.put( - attachment.getOriginalFilename(), fileSpecification); + existingEmbeddedFiles.put(filename, fileSpecification); - log.info( - "Added attachment: {} ({} bytes)", - attachment.getOriginalFilename(), - attachment.getSize()); + log.info("Added attachment: {} ({} bytes)", filename, attachment.getSize()); } catch (IOException e) { - log.warn( - "Failed to create embedded file for attachment: {}", - attachment.getOriginalFilename(), - e); + log.warn("Failed to create embedded file for attachment: {}", filename, e); } }); embeddedFilesTree.setNames(existingNames); - - grantAccessPermissions(document); PDFAttachmentUtils.setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + document.save(output); + + return output.toByteArray(); } private void grantAccessPermissions(PDDocument document) { - AccessPermission currentPermissions = document.getCurrentAccessPermission(); + try { + AccessPermission currentPermissions = document.getCurrentAccessPermission(); - currentPermissions.setCanAssembleDocument(true); - currentPermissions.setCanFillInForm(currentPermissions.canFillInForm()); - currentPermissions.setCanModify(true); - currentPermissions.setCanPrint(true); - currentPermissions.setCanPrintFaithful(true); + currentPermissions.setCanAssembleDocument(true); + currentPermissions.setCanFillInForm(currentPermissions.canFillInForm()); + currentPermissions.setCanModify(true); + currentPermissions.setCanPrint(true); + currentPermissions.setCanPrintFaithful(true); - // Ensure these permissions are enabled for embedded file access - currentPermissions.setCanExtractContent(true); - currentPermissions.setCanExtractForAccessibility(true); - currentPermissions.setCanModifyAnnotations(true); + // Ensure these permissions are enabled for embedded file access + currentPermissions.setCanExtractContent(true); + currentPermissions.setCanExtractForAccessibility(true); + currentPermissions.setCanModifyAnnotations(true); + + var protectionPolicy = new StandardProtectionPolicy(null, null, currentPermissions); + + if (!document.isAllSecurityToBeRemoved()) { + document.setAllSecurityToBeRemoved(true); + } + + document.protect(protectionPolicy); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + document.save(output); + } catch (IOException e) { + throw new RuntimeException(e); + } } } diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/service/PDFAttachmentServiceInterface.java b/stirling-pdf/src/main/java/stirling/software/SPDF/service/PDFAttachmentServiceInterface.java index 90ae33013..13ed19e6c 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/service/PDFAttachmentServiceInterface.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/service/PDFAttachmentServiceInterface.java @@ -9,7 +9,7 @@ import org.springframework.web.multipart.MultipartFile; public interface PDFAttachmentServiceInterface { - void addAttachment( + byte[] addAttachment( PDDocument document, PDEmbeddedFilesNameTreeNode efTree, List attachments) diff --git a/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentsControllerTest.java b/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentsControllerTest.java index 00f0339e9..89eadaecd 100644 --- a/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentsControllerTest.java +++ b/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentsControllerTest.java @@ -61,16 +61,19 @@ class AttachmentsControllerTest { @Test void addAttachments_WithExistingNames() throws IOException { List attachments = List.of(attachment1, attachment2); + byte[] expectedOutput = "modified PDF content".getBytes(); when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument); when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog); when(mockCatalog.getNames()).thenReturn(mockNameDict); when(mockNameDict.getEmbeddedFiles()).thenReturn(mockEmbeddedFilesTree); + when(pdfAttachmentService.addAttachment(mockDocument, mockEmbeddedFilesTree, attachments)).thenReturn(expectedOutput); ResponseEntity response = attachmentsController.addAttachments(pdfFile, attachments); assertNotNull(response); assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); verify(pdfDocumentFactory).load(pdfFile, false); verify(mockCatalog).setNames(mockNameDict); verify(pdfAttachmentService).addAttachment(mockDocument, mockEmbeddedFilesTree, attachments); @@ -79,14 +82,17 @@ class AttachmentsControllerTest { @Test void addAttachments_WithoutExistingNames() throws IOException { List attachments = List.of(attachment1); + byte[] expectedOutput = "modified PDF content".getBytes(); try (PDDocument realDocument = new PDDocument()) { when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(realDocument); + when(pdfAttachmentService.addAttachment(eq(realDocument), any(PDEmbeddedFilesNameTreeNode.class), eq(attachments))).thenReturn(expectedOutput); ResponseEntity response = attachmentsController.addAttachments(pdfFile, attachments); assertNotNull(response); assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); verify(pdfDocumentFactory).load(pdfFile, false); verify(pdfAttachmentService).addAttachment(eq(realDocument), any(PDEmbeddedFilesNameTreeNode.class), eq(attachments)); } @@ -113,7 +119,7 @@ class AttachmentsControllerTest { when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog); when(mockCatalog.getNames()).thenReturn(mockNameDict); when(mockNameDict.getEmbeddedFiles()).thenReturn(mockEmbeddedFilesTree); - doThrow(ioException).when(pdfAttachmentService).addAttachment(mockDocument, mockEmbeddedFilesTree, attachments); + when(pdfAttachmentService.addAttachment(mockDocument, mockEmbeddedFilesTree, attachments)).thenThrow(ioException); assertThrows(IOException.class, () -> attachmentsController.addAttachments(pdfFile, attachments)); verify(pdfAttachmentService).addAttachment(mockDocument, mockEmbeddedFilesTree, attachments); @@ -162,16 +168,19 @@ class AttachmentsControllerTest { @Test void addAttachments_EmptyAttachmentsList() throws IOException { List emptyAttachments = List.of(); + byte[] expectedOutput = "PDF content without new attachments".getBytes(); when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument); when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog); when(mockCatalog.getNames()).thenReturn(mockNameDict); when(mockNameDict.getEmbeddedFiles()).thenReturn(mockEmbeddedFilesTree); + when(pdfAttachmentService.addAttachment(mockDocument, mockEmbeddedFilesTree, emptyAttachments)).thenReturn(expectedOutput); ResponseEntity response = attachmentsController.addAttachments(pdfFile, emptyAttachments); assertNotNull(response); assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); verify(pdfAttachmentService).addAttachment(mockDocument, mockEmbeddedFilesTree, emptyAttachments); } @@ -179,16 +188,19 @@ class AttachmentsControllerTest { void addAttachments_NullFilename() throws IOException { MockMultipartFile attachmentWithNullName = new MockMultipartFile("attachment", null, "text/plain", "content".getBytes()); List attachments = List.of(attachmentWithNullName); + byte[] expectedOutput = "PDF with null filename attachment".getBytes(); when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument); when(mockDocument.getDocumentCatalog()).thenReturn(mockCatalog); when(mockCatalog.getNames()).thenReturn(mockNameDict); when(mockNameDict.getEmbeddedFiles()).thenReturn(mockEmbeddedFilesTree); + when(pdfAttachmentService.addAttachment(mockDocument, mockEmbeddedFilesTree, attachments)).thenReturn(expectedOutput); ResponseEntity response = attachmentsController.addAttachments(pdfFile, attachments); assertNotNull(response); assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); verify(pdfAttachmentService).addAttachment(mockDocument, mockEmbeddedFilesTree, attachments); } diff --git a/stirling-pdf/src/test/java/stirling/software/SPDF/service/PDFAttachmentServiceTest.java b/stirling-pdf/src/test/java/stirling/software/SPDF/service/PDFAttachmentServiceTest.java index cd2b0eb51..af40f444c 100644 --- a/stirling-pdf/src/test/java/stirling/software/SPDF/service/PDFAttachmentServiceTest.java +++ b/stirling-pdf/src/test/java/stirling/software/SPDF/service/PDFAttachmentServiceTest.java @@ -7,10 +7,14 @@ import java.util.List; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode; import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification; +import org.apache.pdfbox.pdmodel.encryption.AccessPermission; +import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.web.multipart.MultipartFile; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -39,8 +43,10 @@ class PDFAttachmentServiceTest { when(attachments.get(0).getSize()).thenReturn(12L); when(attachments.get(0).getContentType()).thenReturn("text/plain"); - pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + byte[] result = pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + assertNotNull(result); + assertTrue(result.length > 0); verify(embeddedFilesTree).setNames(anyMap()); } } @@ -58,8 +64,10 @@ class PDFAttachmentServiceTest { when(attachments.get(0).getSize()).thenReturn(15L); when(attachments.get(0).getContentType()).thenReturn("application/pdf"); - pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + byte[] result = pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + assertNotNull(result); + assertTrue(result.length > 0); verify(embeddedFilesTree).setNames(anyMap()); } } @@ -78,8 +86,10 @@ class PDFAttachmentServiceTest { when(attachments.get(0).getSize()).thenReturn(25L); when(attachments.get(0).getContentType()).thenReturn(""); - pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + byte[] result = pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + assertNotNull(result); + assertTrue(result.length > 0); verify(embeddedFilesTree).setNames(anyMap()); } } @@ -111,8 +121,95 @@ class PDFAttachmentServiceTest { when(attachments.get(0).getInputStream()).thenThrow(ioException); when(attachments.get(0).getSize()).thenReturn(10L); - pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + byte[] result = pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + assertNotNull(result); + assertTrue(result.length > 0); + verify(embeddedFilesTree).setNames(anyMap()); + } + } + + @Test + void addAttachmentToPDF_WithProtectedDocument() throws IOException { + try (var document = new PDDocument()) { + // Create a document with restricted permissions (this simulates an encrypted/protected document) + AccessPermission ap = new AccessPermission(); + ap.setCanExtractContent(false); // Restrict content extraction initially + var spp = new StandardProtectionPolicy("owner", "user", ap); + document.protect(spp); + + var embeddedFilesTree = mock(PDEmbeddedFilesNameTreeNode.class); + var attachments = List.of(mock(MultipartFile.class)); + var existingNames = new HashMap(); + + when(embeddedFilesTree.getNames()).thenReturn(existingNames); + when(attachments.get(0).getOriginalFilename()).thenReturn("test.txt"); + when(attachments.get(0).getInputStream()).thenReturn( + new ByteArrayInputStream("Test content".getBytes())); + when(attachments.get(0).getSize()).thenReturn(12L); + when(attachments.get(0).getContentType()).thenReturn("text/plain"); + + byte[] result = pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + + assertNotNull(result); + assertTrue(result.length > 0); + verify(embeddedFilesTree).setNames(anyMap()); + } + } + + @Test + void addAttachmentToPDF_WithRestrictedPermissions() throws IOException { + try (var document = new PDDocument()) { + // Create a document with very restricted permissions that should block permission changes + AccessPermission ap = new AccessPermission(); + ap.setCanModify(false); + ap.setCanAssembleDocument(false); + ap.setCanExtractContent(false); + var spp = new StandardProtectionPolicy("owner", "user", ap); + document.protect(spp); + + var embeddedFilesTree = mock(PDEmbeddedFilesNameTreeNode.class); + var attachments = List.of(mock(MultipartFile.class)); + var existingNames = new HashMap(); + + when(embeddedFilesTree.getNames()).thenReturn(existingNames); + when(attachments.get(0).getOriginalFilename()).thenReturn("test.txt"); + when(attachments.get(0).getInputStream()).thenReturn( + new ByteArrayInputStream("Test content".getBytes())); + when(attachments.get(0).getSize()).thenReturn(12L); + when(attachments.get(0).getContentType()).thenReturn("text/plain"); + + byte[] result = pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + + assertNotNull(result); + assertTrue(result.length > 0); + verify(embeddedFilesTree).setNames(anyMap()); + } + } + + @Test + void addAttachmentToPDF_WithNonEncryptedDocument() throws IOException { + try (var document = new PDDocument()) { + var embeddedFilesTree = mock(PDEmbeddedFilesNameTreeNode.class); + var attachments = List.of(mock(MultipartFile.class)); + var existingNames = new HashMap(); + + when(embeddedFilesTree.getNames()).thenReturn(existingNames); + when(attachments.get(0).getOriginalFilename()).thenReturn("test.txt"); + when(attachments.get(0).getInputStream()).thenReturn( + new ByteArrayInputStream("Test content".getBytes())); + when(attachments.get(0).getSize()).thenReturn(12L); + when(attachments.get(0).getContentType()).thenReturn("text/plain"); + + byte[] result = pdfAttachmentService.addAttachment(document, embeddedFilesTree, attachments); + + assertNotNull(result); + assertTrue(result.length > 0); + // Verify permissions are set correctly for non-encrypted documents + AccessPermission permissions = document.getCurrentAccessPermission(); + assertTrue(permissions.canExtractContent()); + assertTrue(permissions.canExtractForAccessibility()); + assertTrue(permissions.canModifyAnnotations()); verify(embeddedFilesTree).setNames(anyMap()); } }