diff --git a/.editorconfig b/.editorconfig index ca0238887..d45455a7a 100644 --- a/.editorconfig +++ b/.editorconfig @@ -1,6 +1,7 @@ root = true [*] +charset = utf-8 indent_style = space indent_size = 4 end_of_line = lf @@ -26,6 +27,26 @@ trim_trailing_whitespace = false [*.js] indent_size = 2 +[*.css] +# CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards. +indent_size = 2 + [*.yaml] +# YAML files use an indent size of 2 spaces to maintain consistency with common YAML formatting practices. +indent_size = 2 insert_final_newline = false trim_trailing_whitespace = false + +[*.yml] +# YML files follow the same conventions as YAML files, using an indent size of 2 spaces. +indent_size = 2 +insert_final_newline = false +trim_trailing_whitespace = false + +[*.json] +# JSON files use an indent size of 2 spaces, which is the standard for JSON formatting. +indent_size = 2 + +[*.jsonc] +# JSONC (JSON with comments) files also follow the standard JSON formatting with an indent size of 2 spaces. +indent_size = 2 diff --git a/.github/config/repo_devs.json b/.github/config/repo_devs.json new file mode 100644 index 000000000..6f8b9f90c --- /dev/null +++ b/.github/config/repo_devs.json @@ -0,0 +1,12 @@ +{ + "repo_devs": [ + "Frooodle", + "sf298", + "Ludy87", + "LaserKaspar", + "sbplat", + "reecebrowne", + "DarioGii", + "ConnorYoh" + ] +} diff --git a/.github/config/system-prompt.txt b/.github/config/system-prompt.txt new file mode 100644 index 000000000..f3842878f --- /dev/null +++ b/.github/config/system-prompt.txt @@ -0,0 +1,13 @@ +You are a professional software engineer specializing in reviewing pull request titles. + +Your job is to analyze a git diff and an existing PR title, then evaluate and improve the PR title. + +You must: +- Always return valid JSON +- Only return the JSON response (no Markdown, no formatting) +- Use one of these conventional commit types at the beginning of the title: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test +- Use lowercase only, no emojis, no trailing period +- Ensure the title is between 5 and 72 printable ASCII characters +- Never let spelling or grammar errors affect the rating +- If the PR title is rated 6 or higher and only contains spelling or grammar mistakes, correct it - do not rephrase it +- If the PR title is rated below 6, generate a new, better title based on the diff diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 2fb9365e4..f8e66fab4 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -49,6 +49,7 @@ labels: - 'proprietary/src/main/resources/static/.*' - 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/.*' - 'stirling-pdf/src/main/java/stirling/software/SPDF/UI/.*' + - 'proprietary/src/main/java/stirling/software/proprietary/security/controller/web/.*' - label: 'Java' files: @@ -80,6 +81,7 @@ labels: - 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/MetricsController.java' - 'stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/.*' - 'stirling-pdf/src/main/java/stirling/software/SPDF/model/api/.*' + - 'proprietary/src/main/java/stirling/software/proprietary/security/controller/api/.*' - 'scripts/png_to_webp.py' - 'split_photos.py' - '.github/workflows/swagger.yml' @@ -97,7 +99,7 @@ labels: - 'Dockerfile' - 'Dockerfile.fat' - 'Dockerfile.ultra-lite' - - 'exampleYmlFiles/*.yml' + - 'exampleYmlFiles/.*.yml' - 'scripts/download-security-jar.sh' - 'scripts/init.sh' - 'scripts/init-without-ocr.sh' @@ -122,6 +124,7 @@ labels: - 'stirling-pdf/src/test/.*' - 'testing/.*' - '.github/workflows/scorecards.yml' + - 'exampleYmlFiles/test_cicd.yml' - label: 'Github' files: diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml new file mode 100644 index 000000000..0447a9b62 --- /dev/null +++ b/.github/workflows/ai_pr_title_review.yml @@ -0,0 +1,228 @@ +name: AI - PR Title Review + +on: + pull_request: + types: [opened, edited] + branches: [main] + +permissions: # required for secure-repo hardening + contents: read + +jobs: + ai-title-review: + permissions: + contents: read + pull-requests: write + models: read + + runs-on: ubuntu-latest + + steps: + - name: Harden Runner + uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + with: + egress-policy: audit + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + + - name: Configure Git to suppress detached HEAD warning + run: git config --global advice.detachedHead false + + - name: Setup GitHub App Bot + if: github.actor != 'dependabot[bot]' + id: setup-bot + uses: ./.github/actions/setup-bot + continue-on-error: true + with: + app-id: ${{ secrets.GH_APP_ID }} + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} + + - name: Check if actor is repo developer + id: actor + run: | + if [[ "${{ github.actor }}" == *"[bot]" ]]; then + echo "PR opened by a bot – skipping AI title review." + echo "is_repo_dev=false" >> $GITHUB_OUTPUT + exit 0 + fi + if [ ! -f .github/config/repo_devs.json ]; then + echo "Error: .github/config/repo_devs.json not found" >&2 + exit 1 + fi + # Validate JSON and extract repo_devs + REPO_DEVS=$(jq -r '.repo_devs[]' .github/config/repo_devs.json 2>/dev/null || { echo "Error: Invalid JSON in repo_devs.json" >&2; exit 1; }) + # Convert developer list into Bash array + mapfile -t DEVS_ARRAY <<< "$REPO_DEVS" + if [[ " ${DEVS_ARRAY[*]} " == *" ${{ github.actor }} "* ]]; then + echo "is_repo_dev=true" >> $GITHUB_OUTPUT + else + echo "is_repo_dev=false" >> $GITHUB_OUTPUT + fi + + - name: Get PR diff + if: steps.actor.outputs.is_repo_dev == 'true' + id: get_diff + run: | + git fetch origin ${{ github.base_ref }} + git diff origin/${{ github.base_ref }}...HEAD | head -n 10000 | grep -vP '[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x{202E}\x{200B}]' > pr.diff + echo "diff<> $GITHUB_OUTPUT + cat pr.diff >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Check and sanitize PR title + if: steps.actor.outputs.is_repo_dev == 'true' + id: sanitize_pr_title + env: + PR_TITLE_RAW: ${{ github.event.pull_request.title }} + run: | + # Sanitize PR title: max 72 characters, only printable characters + PR_TITLE=$(echo "$PR_TITLE_RAW" | tr -d '\n\r' | head -c 72 | sed 's/[^[:print:]]//g') + if [[ ${#PR_TITLE} -lt 5 ]]; then + echo "PR title is too short. Must be at least 5 characters." >&2 + fi + echo "pr_title=$PR_TITLE" >> $GITHUB_OUTPUT + + - name: AI PR Title Analysis + if: steps.actor.outputs.is_repo_dev == 'true' + id: ai-title-analysis + uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0 + with: + model: openai/gpt-4o + system-prompt-file: ".github/config/system-prompt.txt" + prompt: | + Based on the following input data: + + { + "diff": "${{ steps.get_diff.outputs.diff }}", + "pr_title": "${{ steps.sanitize_pr_title.outputs.pr_title }}" + } + + Respond ONLY with valid JSON in the format: + { + "improved_rating": <0-10>, + "improved_ai_title_rating": <0-10>, + "improved_title": "" + } + + - name: Validate and set SCRIPT_OUTPUT + if: steps.actor.outputs.is_repo_dev == 'true' + run: | + cat < ai_response.json + ${{ steps.ai-title-analysis.outputs.response }} + EOF + + # Validate JSON structure + jq -e ' + (keys | sort) == ["improved_ai_title_rating", "improved_rating", "improved_title"] and + (.improved_rating | type == "number" and . >= 0 and . <= 10) and + (.improved_ai_title_rating | type == "number" and . >= 0 and . <= 10) and + (.improved_title | type == "string") + ' ai_response.json + if [ $? -ne 0 ]; then + echo "Invalid AI response format" >&2 + cat ai_response.json >&2 + exit 1 + fi + # Parse JSON fields + IMPROVED_RATING=$(jq -r '.improved_rating' ai_response.json) + IMPROVED_TITLE=$(jq -r '.improved_title' ai_response.json) + # Limit comment length to 1000 characters + COMMENT=$(cat < /tmp/ai-title-comment.md + # Log input and output to the GitHub Step Summary + echo "### šŸ¤– AI PR Title Analysis" >> $GITHUB_STEP_SUMMARY + echo "### Input PR Title" >> $GITHUB_STEP_SUMMARY + echo '```bash' >> $GITHUB_STEP_SUMMARY + echo "${{ steps.sanitize_pr_title.outputs.pr_title }}" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + echo '### AI Response (raw JSON)' >> $GITHUB_STEP_SUMMARY + echo '```json' >> $GITHUB_STEP_SUMMARY + cat ai_response.json >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + + - name: Post comment on PR if needed + if: steps.actor.outputs.is_repo_dev == 'true' + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + continue-on-error: true + with: + github-token: ${{ steps.setup-bot.outputs.token }} + script: | + const fs = require('fs'); + const body = fs.readFileSync('/tmp/ai-title-comment.md', 'utf8'); + const { GITHUB_REPOSITORY } = process.env; + const [owner, repo] = GITHUB_REPOSITORY.split('/'); + const issue_number = context.issue.number; + + const ratingMatch = body.match(/\*\*PR-Title Rating\*\*: (\d+)\/10/); + const rating = ratingMatch ? parseInt(ratingMatch[1], 10) : null; + + const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]"; + const comments = await github.rest.issues.listComments({ owner, repo, issue_number }); + + const existing = comments.data.find(c => + c.user?.login === expectedActor && + c.body.includes("## šŸ¤– AI PR Title Suggestion") + ); + + if (rating === null) { + console.log("No rating found in AI response – skipping."); + return; + } + + if (rating <= 5) { + if (existing) { + await github.rest.issues.updateComment({ + owner, repo, + comment_id: existing.id, + body + }); + console.log("Updated existing suggestion comment."); + } else { + await github.rest.issues.createComment({ + owner, repo, issue_number, + body + }); + console.log("Created new suggestion comment."); + } + } else { + const praise = `## šŸ¤– AI PR Title Suggestion\n\nGreat job! The current PR title is clear and well-structured.\n\nāœ… No suggestions needed.\n\n---\n*Generated by GitHub Models AI*`; + + if (existing) { + await github.rest.issues.updateComment({ + owner, repo, + comment_id: existing.id, + body: praise + }); + console.log("Replaced suggestion with praise."); + } else { + console.log("Rating > 5 and no existing comment – skipping comment."); + } + } + + - name: is not repo dev + if: steps.actor.outputs.is_repo_dev != 'true' + run: | + exit 0 # Skip the AI title review for non-repo developers + + - name: Clean up + if: always() + run: | + rm -f pr.diff ai_response.json /tmp/ai-title-comment.md + echo "Cleaned up temporary files." + continue-on-error: true # Ensure cleanup runs even if previous steps fail diff --git a/.github/workflows/check_properties.yml b/.github/workflows/check_properties.yml index c1032d00c..7c2c075b3 100644 --- a/.github/workflows/check_properties.yml +++ b/.github/workflows/check_properties.yml @@ -269,3 +269,12 @@ jobs: run: | echo "Failing the job because errors were detected." exit 1 + + - name: Cleanup temporary files + if: always() + run: | + echo "Cleaning up temporary files..." + rm -rf pr-branch + rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt + echo "Cleanup complete." + continue-on-error: true # Ensure cleanup runs even if previous steps fail \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b4b3841e6..5418deaea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.11 + rev: v0.12.0 hooks: - id: ruff args: @@ -16,13 +16,13 @@ repos: hooks: - id: codespell args: - - --ignore-words-list= + - --ignore-words-list=thirdParty,tabEl,tabEls - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 files: \.(html|css|js|py|md)$ exclude: (.vscode|.devcontainer|stirling-pdf/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js) - repo: https://github.com/gitleaks/gitleaks - rev: v8.26.0 + rev: v8.27.2 hooks: - id: gitleaks - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 432bd0248..128af83ba 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -15,6 +15,7 @@ "ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code "GitHub.copilot", // GitHub Copilot AI pair programmer for Visual Studio Code "GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code - "charliermarsh.ruff" // Ruff code formatter for Python to follow the Ruff Style Guide + "charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide + "yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index a2f0da613..3f272e18a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,6 +6,27 @@ "[java]": { "editor.defaultFormatter": "josevseb.google-java-format-for-vs-code" }, + "[jsonc]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "[json]": { + "editor.defaultFormatter": "vscode.json-language-features" + }, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter" + }, + "[gradle-kotlin-dsl]": { + "editor.defaultFormatter": "vscjava.vscode-gradle" + }, + "[markdown]": { + "editor.defaultFormatter": "yzhang.markdown-all-in-one" + }, + "[gradle-build]": { + "editor.defaultFormatter": "vscjava.vscode-gradle" + }, + "[gradle]": { + "editor.defaultFormatter": "vscjava.vscode-gradle" + }, "java.compile.nullAnalysis.mode": "automatic", "java.configuration.updateBuildConfiguration": "interactive", "java.format.enabled": true, @@ -86,9 +107,21 @@ "spring.initializr.defaultLanguage": "Java", "spring.initializr.defaultGroupId": "stirling.software.SPDF", "spring.initializr.defaultArtifactId": "SPDF", + "java.jdt.ls.lombokSupport.enabled": true, + "html.format.wrapLineLength": 127, + "html.format.enable": true, + "html.format.indentInnerHtml": true, + "html.format.unformatted": "script,style,textarea", + "html.format.contentUnformatted": "pre,code", + "html.format.extraLiners": "head,body,/html", + "html.format.wrapAttributes": "force", + "html.format.wrapAttributesIndentSize": 2, + "html.format.indentHandlebars": true, + "html.format.preserveNewLines": true, + "html.format.maxPreserveNewLines": 2, "java.project.sourcePaths": [ "stirling-pdf/src/main/java", "common/src/main/java", "proprietary/src/main/java" - ], + ] } diff --git a/common/build.gradle b/common/build.gradle index cdfc11b8f..6dfd222bf 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -16,6 +16,7 @@ spotless { } dependencies { api 'org.springframework.boot:spring-boot-starter-web' + api 'org.springframework.boot:spring-boot-starter-aop' api 'org.springframework.boot:spring-boot-starter-thymeleaf' api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20240325.1' api 'com.fathzer:javaluator:3.0.6' @@ -28,5 +29,5 @@ dependencies { api 'org.snakeyaml:snakeyaml-engine:2.9' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9" api 'jakarta.mail:jakarta.mail-api:2.1.3' - api 'org.springframework.boot:spring-boot-starter-aop' + runtimeOnly 'org.eclipse.angus:angus-mail:2.0.3' } diff --git a/common/src/main/java/stirling/software/common/util/AttachmentUtils.java b/common/src/main/java/stirling/software/common/util/AttachmentUtils.java new file mode 100644 index 000000000..32830a9f0 --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/AttachmentUtils.java @@ -0,0 +1,50 @@ +package stirling.software.common.util; + +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSName; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PageMode; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class AttachmentUtils { + + /** + * Sets the PDF catalog viewer preferences to display attachments in the viewer. + * + * @param document The PDDocument to modify. + * @param pageMode The PageMode to set for the PDF viewer. PageMode + * values: UseNone, UseOutlines, UseThumbs, + * FullScreen, UseOC, UseAttachments. + */ + public static void setCatalogViewerPreferences(PDDocument document, PageMode pageMode) { + try { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + if (catalog != null) { + COSDictionary catalogDict = catalog.getCOSObject(); + + catalog.setPageMode(pageMode); + catalogDict.setName(COSName.PAGE_MODE, pageMode.stringValue()); + + COSDictionary viewerPrefs = + (COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES); + if (viewerPrefs == null) { + viewerPrefs = new COSDictionary(); + catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs); + } + + viewerPrefs.setName( + COSName.getPDFName("NonFullScreenPageMode"), pageMode.stringValue()); + + viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true); + + log.info( + "Set PDF PageMode to UseAttachments to automatically show attachments pane"); + } + } catch (Exception e) { + log.error("Failed to set catalog viewer preferences for attachments", e); + } + } +} 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 11ac09114..05e9cec5c 100644 --- a/common/src/main/java/stirling/software/common/util/EmlToPdf.java +++ b/common/src/main/java/stirling/software/common/util/EmlToPdf.java @@ -1,5 +1,7 @@ package stirling.software.common.util; +import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences; + import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -20,13 +22,11 @@ import java.util.Properties; import java.util.regex.Matcher; import java.util.regex.Pattern; -import org.apache.pdfbox.cos.COSDictionary; -import org.apache.pdfbox.cos.COSName; import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary; import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode; import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PageMode; import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification; import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile; @@ -42,10 +42,13 @@ import lombok.experimental.UtilityClass; import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.api.converters.EmlToPdfRequest; +import stirling.software.common.model.api.converters.HTMLToPdfRequest; +import stirling.software.common.service.CustomPDFDocumentFactory; @Slf4j @UtilityClass public class EmlToPdf { + private static final class StyleConstants { // Font and layout constants static final int DEFAULT_FONT_SIZE = 12; @@ -197,8 +200,7 @@ public class EmlToPdf { TempFileManager tempFileManager) throws IOException, InterruptedException { - stirling.software.common.model.api.converters.HTMLToPdfRequest htmlRequest = - createHtmlRequest(request); + HTMLToPdfRequest htmlRequest = createHtmlRequest(request); try { return FileToPdf.convertHtmlToPdf( @@ -884,33 +886,33 @@ public class EmlToPdf { Class messageClass = message.getClass(); // Extract headers via reflection - java.lang.reflect.Method getSubject = messageClass.getMethod("getSubject"); + Method getSubject = messageClass.getMethod("getSubject"); String subject = (String) getSubject.invoke(message); content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject"); - java.lang.reflect.Method getFrom = messageClass.getMethod("getFrom"); + Method getFrom = messageClass.getMethod("getFrom"); Object[] fromAddresses = (Object[]) getFrom.invoke(message); content.setFrom( fromAddresses != null && fromAddresses.length > 0 ? safeMimeDecode(fromAddresses[0].toString()) : ""); - java.lang.reflect.Method getAllRecipients = messageClass.getMethod("getAllRecipients"); + Method getAllRecipients = messageClass.getMethod("getAllRecipients"); Object[] recipients = (Object[]) getAllRecipients.invoke(message); content.setTo( recipients != null && recipients.length > 0 ? safeMimeDecode(recipients[0].toString()) : ""); - java.lang.reflect.Method getSentDate = messageClass.getMethod("getSentDate"); + Method getSentDate = messageClass.getMethod("getSentDate"); content.setDate((Date) getSentDate.invoke(message)); // Extract content - java.lang.reflect.Method getContent = messageClass.getMethod("getContent"); + Method getContent = messageClass.getMethod("getContent"); Object messageContent = getContent.invoke(message); if (messageContent instanceof String stringContent) { - java.lang.reflect.Method getContentType = messageClass.getMethod("getContentType"); + Method getContentType = messageClass.getMethod("getContentType"); String contentType = (String) getContentType.invoke(message); if (contentType != null && contentType.toLowerCase().contains("text/html")) { content.setHtmlBody(stringContent); @@ -949,11 +951,10 @@ public class EmlToPdf { } Class multipartClass = multipart.getClass(); - java.lang.reflect.Method getCount = multipartClass.getMethod("getCount"); + Method getCount = multipartClass.getMethod("getCount"); int count = (Integer) getCount.invoke(multipart); - java.lang.reflect.Method getBodyPart = - multipartClass.getMethod("getBodyPart", int.class); + Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class); for (int i = 0; i < count; i++) { Object part = getBodyPart.invoke(multipart, i); @@ -974,12 +975,12 @@ public class EmlToPdf { } Class partClass = part.getClass(); - java.lang.reflect.Method isMimeType = partClass.getMethod("isMimeType", String.class); - java.lang.reflect.Method getContent = partClass.getMethod("getContent"); - java.lang.reflect.Method getDisposition = partClass.getMethod("getDisposition"); - java.lang.reflect.Method getFileName = partClass.getMethod("getFileName"); - java.lang.reflect.Method getContentType = partClass.getMethod("getContentType"); - java.lang.reflect.Method getHeader = partClass.getMethod("getHeader", String.class); + Method isMimeType = partClass.getMethod("isMimeType", String.class); + Method getContent = partClass.getMethod("getContent"); + Method getDisposition = partClass.getMethod("getDisposition"); + Method getFileName = partClass.getMethod("getFileName"); + Method getContentType = partClass.getMethod("getContentType"); + Method getHeader = partClass.getMethod("getHeader", String.class); Object disposition = getDisposition.invoke(part); String filename = (String) getFileName.invoke(part); @@ -1186,7 +1187,7 @@ public class EmlToPdf { private static byte[] attachFilesToPdf( byte[] pdfBytes, List attachments, - stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory) + CustomPDFDocumentFactory pdfDocumentFactory) throws IOException { try (PDDocument document = pdfDocumentFactory.load(pdfBytes); ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { @@ -1244,15 +1245,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); } @@ -1274,7 +1273,7 @@ public class EmlToPdf { efTree.setNames(efMap); // Set catalog viewer preferences to automatically show attachments pane - setCatalogViewerPreferences(document); + setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS); } // Add attachment annotations to the first page for each embedded file @@ -1428,41 +1427,7 @@ public class EmlToPdf { } } - private static void setCatalogViewerPreferences(PDDocument document) { - try { - PDDocumentCatalog catalog = document.getDocumentCatalog(); - if (catalog != null) { - // Get the catalog's COS dictionary to work with low-level PDF objects - COSDictionary catalogDict = catalog.getCOSObject(); - - // Set PageMode to UseAttachments - this is the standard PDF specification approach - // PageMode values: UseNone, UseOutlines, UseThumbs, FullScreen, UseOC, - // UseAttachments - catalogDict.setName(COSName.PAGE_MODE, "UseAttachments"); - - // Also set viewer preferences for better attachment viewing experience - COSDictionary viewerPrefs = - (COSDictionary) catalogDict.getDictionaryObject(COSName.VIEWER_PREFERENCES); - if (viewerPrefs == null) { - viewerPrefs = new COSDictionary(); - catalogDict.setItem(COSName.VIEWER_PREFERENCES, viewerPrefs); - } - - // Set NonFullScreenPageMode to UseAttachments as fallback for viewers that support - // it - viewerPrefs.setName(COSName.getPDFName("NonFullScreenPageMode"), "UseAttachments"); - - // Additional viewer preferences that may help with attachment display - viewerPrefs.setBoolean(COSName.getPDFName("DisplayDocTitle"), true); - - log.info( - "Set PDF PageMode to UseAttachments to automatically show attachments pane"); - } - } catch (Exception e) { - // Log warning but don't fail the entire operation for viewer preferences - log.warn("Failed to set catalog viewer preferences for attachments", e); - } - } + // MIME header decoding functionality for RFC 2047 encoded headers - moved to constants private static String decodeMimeHeader(String encodedText) { if (encodedText == null || encodedText.trim().isEmpty()) { diff --git a/common/src/main/java/stirling/software/common/util/WebResponseUtils.java b/common/src/main/java/stirling/software/common/util/WebResponseUtils.java index 62a0e3246..745f5d5ec 100644 --- a/common/src/main/java/stirling/software/common/util/WebResponseUtils.java +++ b/common/src/main/java/stirling/software/common/util/WebResponseUtils.java @@ -16,12 +16,12 @@ import io.github.pixee.security.Filenames; public class WebResponseUtils { - public static ResponseEntity boasToWebResponse( + public static ResponseEntity baosToWebResponse( ByteArrayOutputStream baos, String docName) throws IOException { return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName); } - public static ResponseEntity boasToWebResponse( + public static ResponseEntity baosToWebResponse( ByteArrayOutputStream baos, String docName, MediaType mediaType) throws IOException { return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), docName, mediaType); } @@ -44,8 +44,7 @@ public class WebResponseUtils { headers.setContentType(mediaType); headers.setContentLength(bytes.length); String encodedDocName = - URLEncoder.encode(docName, StandardCharsets.UTF_8.toString()) - .replaceAll("\\+", "%20"); + URLEncoder.encode(docName, StandardCharsets.UTF_8).replaceAll("\\+", "%20"); headers.setContentDispositionFormData("attachment", encodedDocName); return new ResponseEntity<>(bytes, headers, HttpStatus.OK); } @@ -61,9 +60,8 @@ public class WebResponseUtils { // Open Byte Array and save document to it ByteArrayOutputStream baos = new ByteArrayOutputStream(); document.save(baos); - // Close the document document.close(); - return boasToWebResponse(baos, docName); + return baosToWebResponse(baos, docName); } } diff --git a/common/src/test/java/stirling/software/common/util/WebResponseUtilsTest.java b/common/src/test/java/stirling/software/common/util/WebResponseUtilsTest.java index f5ce5a6b1..70286fbf7 100644 --- a/common/src/test/java/stirling/software/common/util/WebResponseUtilsTest.java +++ b/common/src/test/java/stirling/software/common/util/WebResponseUtilsTest.java @@ -25,7 +25,7 @@ public class WebResponseUtilsTest { String docName = "sample.pdf"; ResponseEntity responseEntity = - WebResponseUtils.boasToWebResponse(baos, docName); + WebResponseUtils.baosToWebResponse(baos, docName); assertNotNull(responseEntity); assertEquals(HttpStatus.OK, responseEntity.getStatusCode()); diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/stirling-pdf/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index f4b27f33d..361eeace3 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -174,6 +174,7 @@ public class EndpointConfiguration { addEndpointToGroup("Other", "get-info-on-pdf"); addEndpointToGroup("Other", "show-javascript"); addEndpointToGroup("Other", "remove-image-pdf"); + addEndpointToGroup("Other", "add-attachments"); // CLI addEndpointToGroup("CLI", "compress-pdf"); @@ -252,6 +253,7 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "pdf-to-text"); addEndpointToGroup("Java", "remove-image-pdf"); addEndpointToGroup("Java", "pdf-to-markdown"); + addEndpointToGroup("Java", "add-attachments"); // Javascript addEndpointToGroup("Javascript", "pdf-organizer"); diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/MergeController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/MergeController.java index 5e37314a6..ddd988ef9 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/MergeController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/MergeController.java @@ -225,7 +225,7 @@ public class MergeController { String mergedFileName = files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "") + "_merged_unsigned.pdf"; - return WebResponseUtils.boasToWebResponse( + return WebResponseUtils.baosToWebResponse( baos, mergedFileName); // Return the modified PDF } catch (Exception ex) { diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java new file mode 100644 index 000000000..b36065612 --- /dev/null +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java @@ -0,0 +1,57 @@ +package stirling.software.SPDF.controller.api.misc; + +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import io.github.pixee.security.Filenames; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.SPDF.model.api.misc.AddAttachmentRequest; +import stirling.software.SPDF.service.AttachmentServiceInterface; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.WebResponseUtils; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/misc") +@Tag(name = "Misc", description = "Miscellaneous APIs") +public class AttachmentController { + + private final CustomPDFDocumentFactory pdfDocumentFactory; + + private final AttachmentServiceInterface pdfAttachmentService; + + @PostMapping(consumes = "multipart/form-data", value = "/add-attachments") + @Operation( + summary = "Add attachments to PDF", + description = + "This endpoint adds attachments to a PDF. Input:PDF, Output:PDF Type:MISO") + public ResponseEntity addAttachments(@ModelAttribute AddAttachmentRequest request) + throws IOException { + MultipartFile fileInput = request.getFileInput(); + List attachments = request.getAttachments(); + + PDDocument document = + pdfAttachmentService.addAttachment( + pdfDocumentFactory.load(fileInput, false), attachments); + + return WebResponseUtils.pdfDocToWebResponse( + document, + Filenames.toSimpleFileName(fileInput.getOriginalFilename()) + .replaceFirst("[.][^.]+$", "") + + "_with_attachments.pdf"); + } +} diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java index a7314fc7e..7d5086b4c 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/BlankPageController.java @@ -144,7 +144,7 @@ public class BlankPageController { zos.close(); log.info("Returning ZIP file: {}", filename + "_processed.zip"); - return WebResponseUtils.boasToWebResponse( + return WebResponseUtils.baosToWebResponse( baos, filename + "_processed.zip", MediaType.APPLICATION_OCTET_STREAM); } catch (IOException e) { diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java index 4ec844485..cb06b9f4d 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/misc/ExtractImagesController.java @@ -148,7 +148,7 @@ public class ExtractImagesController { // Create ByteArrayResource from byte array byte[] zipContents = baos.toByteArray(); - return WebResponseUtils.boasToWebResponse( + return WebResponseUtils.baosToWebResponse( baos, filename + "_extracted-images.zip", MediaType.APPLICATION_OCTET_STREAM); } diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java index d573301d0..d6b4fa0da 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineController.java @@ -118,7 +118,7 @@ public class PipelineController { } zipOut.close(); log.info("Returning zipped file response..."); - return WebResponseUtils.boasToWebResponse( + return WebResponseUtils.baosToWebResponse( baos, "output.zip", MediaType.APPLICATION_OCTET_STREAM); } catch (Exception e) { log.error("Error handling data: ", e); diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java index 3260eb31f..612c666c4 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/security/CertSignController.java @@ -205,7 +205,7 @@ public class CertSignController { location, reason, showLogo); - return WebResponseUtils.boasToWebResponse( + return WebResponseUtils.baosToWebResponse( baos, Filenames.toSimpleFileName(pdf.getOriginalFilename()).replaceFirst("[.][^.]+$", "") + "_signed.pdf"); diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/OtherWebController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/OtherWebController.java index 25333d495..aac9cb6a0 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/OtherWebController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/web/OtherWebController.java @@ -191,4 +191,11 @@ public class OtherWebController { model.addAttribute("currentPage", "auto-rename"); return "misc/auto-rename"; } + + @GetMapping("/add-attachments") + @Hidden + public String attachmentsForm(Model model) { + model.addAttribute("currentPage", "add-attachments"); + return "misc/add-attachments"; + } } diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/misc/AddAttachmentRequest.java b/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/misc/AddAttachmentRequest.java new file mode 100644 index 000000000..cf85451f4 --- /dev/null +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/model/api/misc/AddAttachmentRequest.java @@ -0,0 +1,23 @@ +package stirling.software.SPDF.model.api.misc; + +import java.util.List; + +import org.springframework.web.multipart.MultipartFile; + +import io.swagger.v3.oas.annotations.media.Schema; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +import stirling.software.common.model.api.PDFFile; + +@Data +@EqualsAndHashCode(callSuper = true) +public class AddAttachmentRequest extends PDFFile { + + @Schema( + description = "The image file to be overlaid onto the PDF.", + requiredMode = Schema.RequiredMode.REQUIRED, + format = "binary") + private List attachments; +} diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/service/AttachmentService.java b/stirling-pdf/src/main/java/stirling/software/SPDF/service/AttachmentService.java new file mode 100644 index 000000000..4aa6dfe41 --- /dev/null +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/service/AttachmentService.java @@ -0,0 +1,105 @@ +package stirling.software.SPDF.service; + +import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences; + +import java.io.IOException; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.commons.lang3.StringUtils; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary; +import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode; +import org.apache.pdfbox.pdmodel.PageMode; +import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification; +import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +public class AttachmentService implements AttachmentServiceInterface { + + @Override + public PDDocument addAttachment(PDDocument document, List attachments) + throws IOException { + PDEmbeddedFilesNameTreeNode embeddedFilesTree = getEmbeddedFilesTree(document); + Map existingNames; + + try { + Map names = embeddedFilesTree.getNames(); + + if (names == null) { + log.debug("No existing embedded files found, creating new names map."); + existingNames = new HashMap<>(); + } else { + existingNames = new HashMap<>(names); + log.debug("Embedded files: {}", existingNames.keySet()); + } + } catch (IOException e) { + log.error("Could not retrieve existing embedded files", e); + throw e; + } + + attachments.forEach( + attachment -> { + String filename = attachment.getOriginalFilename(); + + try { + PDEmbeddedFile embeddedFile = + new PDEmbeddedFile(document, attachment.getInputStream()); + embeddedFile.setSize((int) attachment.getSize()); + embeddedFile.setCreationDate(new GregorianCalendar()); + embeddedFile.setModDate(new GregorianCalendar()); + String contentType = attachment.getContentType(); + if (StringUtils.isNotBlank(contentType)) { + embeddedFile.setSubtype(contentType); + } + + // Create attachments specification and associate embedded attachment with + // file + PDComplexFileSpecification fileSpecification = + new PDComplexFileSpecification(); + fileSpecification.setFile(filename); + fileSpecification.setFileUnicode(filename); + fileSpecification.setFileDescription("Embedded attachment: " + filename); + fileSpecification.setEmbeddedFile(embeddedFile); + fileSpecification.setEmbeddedFileUnicode(embeddedFile); + + existingNames.put(filename, fileSpecification); + + log.info("Added attachment: {} ({} bytes)", filename, attachment.getSize()); + } catch (IOException e) { + log.warn("Failed to create embedded file for attachment: {}", filename, e); + } + }); + + embeddedFilesTree.setNames(existingNames); + setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS); + + return document; + } + + private PDEmbeddedFilesNameTreeNode getEmbeddedFilesTree(PDDocument document) { + PDDocumentCatalog catalog = document.getDocumentCatalog(); + PDDocumentNameDictionary documentNames = catalog.getNames(); + + if (documentNames == null) { + documentNames = new PDDocumentNameDictionary(catalog); + } + + catalog.setNames(documentNames); + PDEmbeddedFilesNameTreeNode embeddedFilesTree = documentNames.getEmbeddedFiles(); + + if (embeddedFilesTree == null) { + embeddedFilesTree = new PDEmbeddedFilesNameTreeNode(); + documentNames.setEmbeddedFiles(embeddedFilesTree); + } + return embeddedFilesTree; + } +} diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/service/AttachmentServiceInterface.java b/stirling-pdf/src/main/java/stirling/software/SPDF/service/AttachmentServiceInterface.java new file mode 100644 index 000000000..c684a429d --- /dev/null +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/service/AttachmentServiceInterface.java @@ -0,0 +1,13 @@ +package stirling.software.SPDF.service; + +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.springframework.web.multipart.MultipartFile; + +public interface AttachmentServiceInterface { + + PDDocument addAttachment(PDDocument document, List attachments) + throws IOException; +} diff --git a/stirling-pdf/src/main/resources/messages_en_GB.properties b/stirling-pdf/src/main/resources/messages_en_GB.properties index 22cbfaf17..d28657b91 100644 --- a/stirling-pdf/src/main/resources/messages_en_GB.properties +++ b/stirling-pdf/src/main/resources/messages_en_GB.properties @@ -525,6 +525,10 @@ home.addImage.title=Add image home.addImage.desc=Adds a image onto a set location on the PDF addImage.tags=img,jpg,picture,photo +home.attachments.title=Add Attachments +home.attachments.desc=Add or remove embedded files (attachments) to/from a PDF +attachments.tags=embed,attach,file,attachment,attachments + home.watermark.title=Add Watermark home.watermark.desc=Add a custom watermark to your PDF document. watermark.tags=Text,repeating,label,own,copyright,trademark,img,jpg,picture,photo @@ -1205,6 +1209,12 @@ addImage.everyPage=Every Page? addImage.upload=Add image addImage.submit=Add image +#attachments +attachments.title=Add Attachments +attachments.header=Add attachments +attachments.description=Allows you to add attachments to the PDF +attachments.descriptionPlaceholder=Enter a description for the attachments... +attachments.addButton=Add Attachments #merge merge.title=Merge @@ -1594,6 +1604,7 @@ fileChooser.dragAndDropPDF=Drag & Drop PDF file fileChooser.dragAndDropImage=Drag & Drop Image file fileChooser.hoveredDragAndDrop=Drag & Drop file(s) here fileChooser.extractPDF=Extracting... +fileChooser.addAttachments=drag & drop attachments here #release notes releases.footer=Releases diff --git a/stirling-pdf/src/main/resources/static/js/file-icon-factory.js b/stirling-pdf/src/main/resources/static/js/file-icon-factory.js index 81328f423..645ae323e 100644 --- a/stirling-pdf/src/main/resources/static/js/file-icon-factory.js +++ b/stirling-pdf/src/main/resources/static/js/file-icon-factory.js @@ -6,6 +6,9 @@ class FileIconFactory { return this.createPDFIcon(); case "csv": return this.createCSVIcon(); + case "xls": + case "xlsx": + return this.createXLSXIcon(); case "jpe": case "jpg": case "jpeg": @@ -44,8 +47,29 @@ class FileIconFactory { return ``; } + static createCSVIcon() { + return ` + + + + `; + } + + static createXLSXIcon() { + return ` + + + + + `; + } + static createUnknownFileIcon() { - return ``; + return ` + + + + `; } } diff --git a/stirling-pdf/src/main/resources/static/js/fileInput.js b/stirling-pdf/src/main/resources/static/js/fileInput.js index e874ea1c2..f06371986 100644 --- a/stirling-pdf/src/main/resources/static/js/fileInput.js +++ b/stirling-pdf/src/main/resources/static/js/fileInput.js @@ -45,6 +45,8 @@ function setupFileInput(chooser) { inputContainer.querySelector('#dragAndDrop').innerHTML = window.fileInput.dragAndDropPDF; } else if (inputContainer.id === 'image-upload-input-container') { inputContainer.querySelector('#dragAndDrop').innerHTML = window.fileInput.dragAndDropImage; + } else if (inputContainer.id === 'attachments-input-container') { + inputContainer.querySelector('#dragAndDrop').innerHTML = window.fileInput.addAttachments; } let allFiles = []; let overlay; diff --git a/stirling-pdf/src/main/resources/templates/fragments/common.html b/stirling-pdf/src/main/resources/templates/fragments/common.html index 02d919b2b..38e71f04f 100644 --- a/stirling-pdf/src/main/resources/templates/fragments/common.html +++ b/stirling-pdf/src/main/resources/templates/fragments/common.html @@ -268,6 +268,7 @@ window.fileInput = { dragAndDropPDF: '[[#{fileChooser.dragAndDropPDF}]]', dragAndDropImage: '[[#{fileChooser.dragAndDropImage}]]', + addAttachments: '[[#{fileChooser.addAttachments}]]', extractPDF: '[[#{fileChooser.extractPDF}]]', loading: '[[#{loading}]]' }; diff --git a/stirling-pdf/src/main/resources/templates/fragments/navElements.html b/stirling-pdf/src/main/resources/templates/fragments/navElements.html index cd7fae74b..38dfa0ac6 100644 --- a/stirling-pdf/src/main/resources/templates/fragments/navElements.html +++ b/stirling-pdf/src/main/resources/templates/fragments/navElements.html @@ -236,7 +236,10 @@
-
+ +
+
diff --git a/stirling-pdf/src/main/resources/templates/home-legacy.html b/stirling-pdf/src/main/resources/templates/home-legacy.html index d60ac220e..9531a359b 100644 --- a/stirling-pdf/src/main/resources/templates/home-legacy.html +++ b/stirling-pdf/src/main/resources/templates/home-legacy.html @@ -290,6 +290,9 @@
+
+
diff --git a/stirling-pdf/src/main/resources/templates/misc/add-attachments.html b/stirling-pdf/src/main/resources/templates/misc/add-attachments.html new file mode 100644 index 000000000..60bb16c96 --- /dev/null +++ b/stirling-pdf/src/main/resources/templates/misc/add-attachments.html @@ -0,0 +1,42 @@ + + + + + + + + +
+
+ +

+
+
+
+
+ attachment + +
+ +
+ +
+
+ + +
+
+ + + +
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerTest.java b/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerTest.java new file mode 100644 index 000000000..9047bffb5 --- /dev/null +++ b/stirling-pdf/src/test/java/stirling/software/SPDF/controller/api/misc/AttachmentControllerTest.java @@ -0,0 +1,133 @@ +package stirling.software.SPDF.controller.api.misc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import org.mockito.MockedStatic; + +import java.io.IOException; +import java.util.List; + +import org.apache.pdfbox.pdmodel.PDDocument; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import stirling.software.SPDF.model.api.misc.AddAttachmentRequest; +import stirling.software.SPDF.service.AttachmentServiceInterface; +import stirling.software.common.service.CustomPDFDocumentFactory; +import stirling.software.common.util.WebResponseUtils; + +@ExtendWith(MockitoExtension.class) +class AttachmentControllerTest { + + @Mock + private CustomPDFDocumentFactory pdfDocumentFactory; + + @Mock + private AttachmentServiceInterface pdfAttachmentService; + + @InjectMocks + private AttachmentController attachmentController; + + private MockMultipartFile pdfFile; + private MockMultipartFile attachment1; + private MockMultipartFile attachment2; + private AddAttachmentRequest request; + private PDDocument mockDocument; + private PDDocument modifiedMockDocument; + + @BeforeEach + void setUp() { + pdfFile = new MockMultipartFile("fileInput", "test.pdf", "application/pdf", "PDF content".getBytes()); + attachment1 = new MockMultipartFile("attachment1", "file1.txt", "text/plain", "File 1 content".getBytes()); + attachment2 = new MockMultipartFile("attachment2", "file2.jpg", "image/jpeg", "Image content".getBytes()); + request = new AddAttachmentRequest(); + mockDocument = mock(PDDocument.class); + modifiedMockDocument = mock(PDDocument.class); + } + + @Test + void addAttachments_Success() throws IOException { + List attachments = List.of(attachment1, attachment2); + request.setAttachments(attachments); + request.setFileInput(pdfFile); + ResponseEntity expectedResponse = ResponseEntity.ok("modified PDF content".getBytes()); + + when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument); + when(pdfAttachmentService.addAttachment(mockDocument, attachments)).thenReturn(modifiedMockDocument); + + try (MockedStatic mockedWebResponseUtils = mockStatic(WebResponseUtils.class)) { + mockedWebResponseUtils.when(() -> WebResponseUtils.pdfDocToWebResponse(eq(modifiedMockDocument), eq("test_with_attachments.pdf"))) + .thenReturn(expectedResponse); + + ResponseEntity response = attachmentController.addAttachments(request); + + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + verify(pdfDocumentFactory).load(pdfFile, false); + verify(pdfAttachmentService).addAttachment(mockDocument, attachments); + } + } + + @Test + void addAttachments_SingleAttachment() throws IOException { + List attachments = List.of(attachment1); + request.setAttachments(attachments); + request.setFileInput(pdfFile); + ResponseEntity expectedResponse = ResponseEntity.ok("modified PDF content".getBytes()); + + when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument); + when(pdfAttachmentService.addAttachment(mockDocument, attachments)).thenReturn(modifiedMockDocument); + + try (MockedStatic mockedWebResponseUtils = mockStatic(WebResponseUtils.class)) { + mockedWebResponseUtils.when(() -> WebResponseUtils.pdfDocToWebResponse(eq(modifiedMockDocument), eq("test_with_attachments.pdf"))) + .thenReturn(expectedResponse); + + ResponseEntity response = attachmentController.addAttachments(request); + + assertNotNull(response); + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + verify(pdfDocumentFactory).load(pdfFile, false); + verify(pdfAttachmentService).addAttachment(mockDocument, attachments); + } + } + + @Test + void addAttachments_IOExceptionFromPDFLoad() throws IOException { + List attachments = List.of(attachment1); + request.setAttachments(attachments); + request.setFileInput(pdfFile); + IOException ioException = new IOException("Failed to load PDF"); + + when(pdfDocumentFactory.load(pdfFile, false)).thenThrow(ioException); + + assertThrows(IOException.class, () -> attachmentController.addAttachments(request)); + verify(pdfDocumentFactory).load(pdfFile, false); + verifyNoInteractions(pdfAttachmentService); + } + + @Test + void addAttachments_IOExceptionFromAttachmentService() throws IOException { + List attachments = List.of(attachment1); + request.setAttachments(attachments); + request.setFileInput(pdfFile); + IOException ioException = new IOException("Failed to add attachment"); + + when(pdfDocumentFactory.load(pdfFile, false)).thenReturn(mockDocument); + when(pdfAttachmentService.addAttachment(mockDocument, attachments)).thenThrow(ioException); + + assertThrows(IOException.class, () -> attachmentController.addAttachments(request)); + verify(pdfAttachmentService).addAttachment(mockDocument, attachments); + } +} diff --git a/stirling-pdf/src/test/java/stirling/software/SPDF/service/AttachmentServiceTest.java b/stirling-pdf/src/test/java/stirling/software/SPDF/service/AttachmentServiceTest.java new file mode 100644 index 000000000..837530533 --- /dev/null +++ b/stirling-pdf/src/test/java/stirling/software/SPDF/service/AttachmentServiceTest.java @@ -0,0 +1,105 @@ +package stirling.software.SPDF.service; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.List; +import org.apache.pdfbox.pdmodel.PDDocument; +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.*; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AttachmentServiceTest { + + private AttachmentService attachmentService; + + @BeforeEach + void setUp() { + attachmentService = new AttachmentService(); + } + + @Test + void addAttachmentToPDF() throws IOException { + try (var document = new PDDocument()) { + document.setDocumentId(100L); + var attachments = List.of(mock(MultipartFile.class)); + + 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"); + + PDDocument result = attachmentService.addAttachment(document, attachments); + + assertNotNull(result); + assertEquals(document.getDocumentId(), result.getDocumentId()); + assertNotNull(result.getDocumentCatalog().getNames()); + } + } + + @Test + void addAttachmentToPDF_MultipleAttachments() throws IOException { + try (var document = new PDDocument()) { + document.setDocumentId(100L); + var attachment1 = mock(MultipartFile.class); + var attachment2 = mock(MultipartFile.class); + var attachments = List.of(attachment1, attachment2); + + when(attachment1.getOriginalFilename()).thenReturn("document.pdf"); + when(attachment1.getInputStream()).thenReturn( + new ByteArrayInputStream("PDF content".getBytes())); + when(attachment1.getSize()).thenReturn(15L); + when(attachment1.getContentType()).thenReturn("application/pdf"); + + when(attachment2.getOriginalFilename()).thenReturn("image.jpg"); + when(attachment2.getInputStream()).thenReturn( + new ByteArrayInputStream("Image content".getBytes())); + when(attachment2.getSize()).thenReturn(20L); + when(attachment2.getContentType()).thenReturn("image/jpeg"); + + PDDocument result = attachmentService.addAttachment(document, attachments); + + assertNotNull(result); + assertNotNull(result.getDocumentCatalog().getNames()); + } + } + + @Test + void addAttachmentToPDF_WithBlankContentType() throws IOException { + try (var document = new PDDocument()) { + document.setDocumentId(100L); + var attachments = List.of(mock(MultipartFile.class)); + + when(attachments.get(0).getOriginalFilename()).thenReturn("image.jpg"); + when(attachments.get(0).getInputStream()).thenReturn( + new ByteArrayInputStream("Image content".getBytes())); + when(attachments.get(0).getSize()).thenReturn(25L); + when(attachments.get(0).getContentType()).thenReturn(""); + + PDDocument result = attachmentService.addAttachment(document, attachments); + + assertNotNull(result); + assertNotNull(result.getDocumentCatalog().getNames()); + } + } + + @Test + void addAttachmentToPDF_AttachmentInputStreamThrowsIOException() throws IOException { + try (var document = new PDDocument()) { + var attachments = List.of(mock(MultipartFile.class)); + var ioException = new IOException("Failed to read attachment stream"); + + when(attachments.get(0).getOriginalFilename()).thenReturn("test.txt"); + when(attachments.get(0).getInputStream()).thenThrow(ioException); + when(attachments.get(0).getSize()).thenReturn(10L); + + PDDocument result = attachmentService.addAttachment(document, attachments); + + assertNotNull(result); + assertNotNull(result.getDocumentCatalog().getNames()); + } + } +} diff --git a/testing/allEndpointsRemovedSettings.yml b/testing/allEndpointsRemovedSettings.yml index 3290d6fef..8230b4418 100644 --- a/testing/allEndpointsRemovedSettings.yml +++ b/testing/allEndpointsRemovedSettings.yml @@ -128,7 +128,7 @@ ui: languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled. endpoints: # All the possible endpoints are disabled - toRemove: [crop, merge-pdfs, multi-page-layout, overlay-pdfs, pdf-to-single-page, rearrange-pages, remove-image-pdf, remove-pages, rotate-pdf, scale-pages, split-by-size-or-count, split-pages, split-pdf-by-chapters, split-pdf-by-sections, add-password, add-watermark, auto-redact, cert-sign, get-info-on-pdf, redact, remove-cert-sign, remove-password, sanitize-pdf, validate-signature, file-to-pdf, html-to-pdf, img-to-pdf, markdown-to-pdf, pdf-to-csv, pdf-to-html, pdf-to-img, pdf-to-markdown, pdf-to-pdfa, pdf-to-presentation, pdf-to-text, pdf-to-word, pdf-to-xml, url-to-pdf, add-image, add-page-numbers, add-stamp, auto-rename, auto-split-pdf, compress-pdf, decompress-pdf, extract-image-scans, extract-images, flatten, ocr-pdf, remove-blanks, repair, replace-invert-pdf, show-javascript, update-metadata, filter-contains-image, filter-contains-text, filter-file-size, filter-page-count, filter-page-rotation, filter-page-size] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages']) + toRemove: [crop, merge-pdfs, multi-page-layout, overlay-pdfs, pdf-to-single-page, rearrange-pages, remove-image-pdf, remove-pages, rotate-pdf, scale-pages, split-by-size-or-count, split-pages, split-pdf-by-chapters, split-pdf-by-sections, add-password, add-watermark, auto-redact, cert-sign, get-info-on-pdf, redact, remove-cert-sign, remove-password, sanitize-pdf, validate-signature, file-to-pdf, html-to-pdf, img-to-pdf, markdown-to-pdf, pdf-to-csv, pdf-to-html, pdf-to-img, pdf-to-markdown, pdf-to-pdfa, pdf-to-presentation, pdf-to-text, pdf-to-word, pdf-to-xml, url-to-pdf, add-image, add-page-numbers, add-stamp, auto-rename, auto-split-pdf, compress-pdf, decompress-pdf, extract-image-scans, extract-images, flatten, ocr-pdf, remove-blanks, repair, replace-invert-pdf, show-javascript, update-metadata, filter-contains-image, filter-contains-text, filter-file-size, filter-page-count, filter-page-rotation, filter-page-size, add-attachments] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages']) groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice']) metrics: diff --git a/testing/endpoints.txt b/testing/endpoints.txt index 5468ad6c1..149e3af3a 100644 --- a/testing/endpoints.txt +++ b/testing/endpoints.txt @@ -30,6 +30,7 @@ /api/v1/misc/add-stamp /api/v1/misc/add-page-numbers /api/v1/misc/add-image +/api/v1/misc/add-attachments /api/v1/convert/url/pdf /api/v1/convert/pdf/xml /api/v1/convert/pdf/word diff --git a/testing/webpage_urls.txt b/testing/webpage_urls.txt index 8ccaaf0b1..c6c713dd0 100644 --- a/testing/webpage_urls.txt +++ b/testing/webpage_urls.txt @@ -51,3 +51,4 @@ /swagger-ui/index.html /licenses /releases +/add-attachments