diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5ab9f82c9..dcc0ca600 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -49,7 +49,7 @@ "java.configuration.updateBuildConfiguration": "interactive", "java.format.enabled": true, "java.format.settings.profile": "GoogleStyle", - "java.format.settings.google.version": "1.26.0", + "java.format.settings.google.version": "1.28.0", "java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting", "java.saveActions.cleanup": true, "java.cleanup.actions": [ @@ -79,9 +79,17 @@ ".venv*/", ".vscode/", "bin/", + "app/core/bin/", + "app/common/bin/", + "app/proprietary/bin/", "build/", + "app/core/build/", + "app/common/build/", + "app/proprietary/build/", "configs/", + "app/core/configs/", "customFiles/", + "app/core/customFiles/", "docs/", "exampleYmlFiles", "gradle/", @@ -93,6 +101,9 @@ ".git-blame-ignore-revs", ".gitattributes", ".gitignore", + "app/core/.gitignore", + "app/common/.gitignore", + "app/proprietary/.gitignore", ".pre-commit-config.yaml" ], "java.signatureHelp.enabled": true, diff --git a/.editorconfig b/.editorconfig index 9faac4bf7..5b76408cc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -31,18 +31,12 @@ indent_size = 2 # CSS files typically use an indent size of 2 spaces for better readability and alignment with community standards. indent_size = 2 -[*.yaml] +[*.{yml,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 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8d4e98e5a..f89c7154d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,21 @@ -# All PRs to V1 must be approved by Frooodle -* @Frooodle @reecebrowne @Ludy87 @DarioGii @ConnorYoh @EthanHealy01 +# All PRs must be approved by Frooodle or Ludy87 +* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh + +# Backend +/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh + +#V1 frontend +/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 +/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 + +#V2 frontend +/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle + +#V2 docker +/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87 +/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 +/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 + + +#GHA (All users) +/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 diff --git a/.github/config/.files.yaml b/.github/config/.files.yaml index 2f4f242cb..a5d8410f3 100644 --- a/.github/config/.files.yaml +++ b/.github/config/.files.yaml @@ -26,4 +26,6 @@ project: &project - gradlew - gradlew.bat - launch4jConfig.xml - - settings.gradle \ No newline at end of file + - settings.gradle + - frontend/** + - docker/** diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index 44a234e3b..3719c0ad8 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -78,6 +78,7 @@ labels: - 'app/core/src/main/resources/banner.txt' - 'app/core/src/main/resources/static/python/png_to_webp.py' - 'app/core/src/main/resources/static/python/split_photos.py' + - 'app/core/src/main/resources/static/pipeline/defaultWebUIConfigs/**' - 'application.properties' - label: 'Security' diff --git a/.github/labels.yml b/.github/labels.yml index 9b35ccb1a..a79fb8be5 100644 --- a/.github/labels.yml +++ b/.github/labels.yml @@ -42,6 +42,7 @@ - name: "Front End" color: "BBD2F1" description: "Issues or pull requests related to front-end development" + from_name: "frontend" - name: "github-actions" description: "Pull requests that update GitHub Actions code" color: "999999" @@ -77,6 +78,7 @@ - name: "Translation" color: "9FABF9" from_name: "translation" + description: "Issues or pull requests related to translation" - name: "upstream" color: "DEDEDE" - name: "v2" @@ -178,3 +180,6 @@ - name: "pr-deployed" color: "00FF00" description: "Pull request has been deployed to a test environment" +- name: "codex" + color: "ededed" + description: "chatgpt AI generated code" diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index d08c4ad04..a5530fced 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -42,7 +42,7 @@ jobs: enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }} steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -153,7 +153,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -197,7 +197,7 @@ jobs: uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 - name: Login to Docker Hub - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index 855e804b2..29aea4389 100644 --- a/.github/workflows/PR-Demo-cleanup.yml +++ b/.github/workflows/PR-Demo-cleanup.yml @@ -21,7 +21,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index b9fd7c277..8a2e8b8ef 100644 --- a/.github/workflows/ai_pr_title_review.yml +++ b/.github/workflows/ai_pr_title_review.yml @@ -19,7 +19,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -87,7 +87,7 @@ jobs: - 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 + uses: actions/ai-inference@0cbed4a10641c75090de5968e66d70eb4660f751 # v1.2.7 with: model: openai/gpt-4o system-prompt-file: ".github/config/system-prompt.txt" diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index bf290de76..bd998d197 100644 --- a/.github/workflows/auto-labelerV2.yml +++ b/.github/workflows/auto-labelerV2.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b2a9abe17..453c4f445 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,18 @@ on: branches: ["main", "V2", "V2-gha"] workflow_dispatch: +# cancel in-progress jobs if a new job is triggered +# This is useful to avoid running multiple builds for the same branch if a new commit is pushed +# or a pull request is updated. +# It helps to save resources and time by ensuring that only the latest commit is built and tested +# This is particularly useful for long-running jobs that may take a while to complete. +# The `group` is set to a combination of the workflow name, event name, and branch name. +# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of +# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -19,7 +31,9 @@ jobs: project: ${{ steps.changes.outputs.project }} openapi: ${{ steps.changes.outputs.openapi }} steps: - - uses: actions/checkout@v2 + + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: Check for file changes uses: dorny/paths-filter@v3.0.2 id: changes @@ -38,7 +52,7 @@ jobs: spring-security: [true, false] steps: - name: Harden Runner - uses: step-security/harden-runner@v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit - name: Checkout repository @@ -104,6 +118,7 @@ jobs: - uses: gradle/actions/setup-gradle@v4.4.1 - name: Generate OpenAPI documentation run: ./gradlew :stirling-pdf:generateOpenApiDocs + - name: Upload OpenAPI Documentation uses: actions/upload-artifact@v4.6.2 with: @@ -144,7 +159,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit - name: Checkout repository @@ -185,7 +200,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -223,3 +238,69 @@ jobs: chmod +x ./testing/test.sh chmod +x ./testing/test_disabledEndpoints.sh ./testing/test.sh + + test-build-docker-images: + if: github.event_name == 'pull_request' && needs.files-changed.outputs.project == 'true' + needs: [files-changed, build, check-generateOpenApiDocs, check-licence] + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"] + steps: + - name: Harden Runner + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 + with: + egress-policy: audit + + - name: Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Set up JDK 17 + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + java-version: "17" + distribution: "temurin" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + with: + gradle-version: 8.14 + + - name: Build application + run: ./gradlew clean build + env: + DISABLE_ADDITIONAL_FEATURES: true + STIRLING_PDF_DESKTOP_UI: false + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + + - name: Build ${{ matrix.docker-rev }} + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + builder: ${{ steps.buildx.outputs.name }} + context: . + file: ./docker/backend/${{ matrix.docker-rev }} + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + platforms: linux/amd64,linux/arm64/v8 + provenance: true + sbom: true + + - name: Upload Reports + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: reports-docker-${{ matrix.docker-rev }} + path: | + build/reports/tests/ + build/test-results/ + build/reports/problems/ + retention-days: 3 + if-no-files-found: warn diff --git a/.github/workflows/check_properties.yml b/.github/workflows/check_properties.yml index da000201a..32a970ef1 100644 --- a/.github/workflows/check_properties.yml +++ b/.github/workflows/check_properties.yml @@ -6,6 +6,18 @@ on: paths: - "app/core/src/main/resources/messages_*.properties" +# cancel in-progress jobs if a new job is triggered +# This is useful to avoid running multiple builds for the same branch if a new commit is pushed +# or a pull request is updated. +# It helps to save resources and time by ensuring that only the latest commit is built and tested +# This is particularly useful for long-running jobs that may take a while to complete. +# The `group` is set to a combination of the workflow name, event name, and branch name. +# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of +# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }} + cancel-in-progress: true + permissions: contents: read # Allow read access to repository content @@ -18,7 +30,7 @@ jobs: pull-requests: write # Allow writing to pull requests steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -115,7 +127,7 @@ jobs: // Filter for relevant files based on the PR changes const changedFiles = files - .filter(file => + .filter(file => file.status !== "removed" && /^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename) ) @@ -277,4 +289,4 @@ jobs: 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 + continue-on-error: true # Ensure cleanup runs even if previous steps fail diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 154b6bdae..30c96a1b0 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml index 23c15816f..49486c4d5 100644 --- a/.github/workflows/licenses-update.yml +++ b/.github/workflows/licenses-update.yml @@ -7,6 +7,18 @@ on: paths: - "build.gradle" +# cancel in-progress jobs if a new job is triggered +# This is useful to avoid running multiple builds for the same branch if a new commit is pushed +# or a pull request is updated. +# It helps to save resources and time by ensuring that only the latest commit is built and tested +# This is particularly useful for long-running jobs that may take a while to complete. +# The `group` is set to a combination of the workflow name, event name, and branch name. +# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of +# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -19,7 +31,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 15349a66d..1388ef0fb 100644 --- a/.github/workflows/manage-label.yml +++ b/.github/workflows/manage-label.yml @@ -15,7 +15,7 @@ jobs: issues: write steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index 3cac33e1f..b55c7d402 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -21,7 +21,7 @@ jobs: versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }} steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -60,7 +60,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -110,7 +110,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -148,7 +148,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -238,7 +238,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -252,7 +252,7 @@ jobs: - name: Install Cosign if: matrix.os == 'windows-latest' - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 + uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 - name: Generate key pair if: matrix.os == 'windows-latest' @@ -301,7 +301,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index ba80e9bcd..6560e9226 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -2,8 +2,9 @@ name: Pre-commit on: workflow_dispatch: - schedule: - - cron: "0 0 * * 1" + push: + branches: + - main permissions: contents: read @@ -16,7 +17,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -46,6 +47,15 @@ jobs: - run: pre-commit run --all-files -c .pre-commit-config.yaml continue-on-error: true + - name: Set up JDK + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + java-version: 17 + distribution: "temurin" + + - name: Build with Gradle + run: ./gradlew clean build + - name: git add run: | git add . diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 432925f1a..2a04ba33e 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -7,6 +7,18 @@ on: - master - main +# cancel in-progress jobs if a new job is triggered +# This is useful to avoid running multiple builds for the same branch if a new commit is pushed +# or a pull request is updated. +# It helps to save resources and time by ensuring that only the latest commit is built and tested +# This is particularly useful for long-running jobs that may take a while to complete. +# The `group` is set to a combination of the workflow name, event name, and branch name. +# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of +# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -18,7 +30,7 @@ jobs: id-token: write steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -42,7 +54,7 @@ jobs: - name: Install cosign if: github.ref == 'refs/heads/master' - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 + uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 with: cosign-release: "v2.4.1" @@ -55,13 +67,13 @@ jobs: run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT - name: Login to Docker Hub - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} - name: Login to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -76,7 +88,7 @@ jobs: - name: Generate tags id: meta - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 if: github.ref != 'refs/heads/main' with: images: | @@ -122,7 +134,7 @@ jobs: - name: Generate tags ultra-lite id: meta2 - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 if: github.ref != 'refs/heads/main' with: images: | @@ -153,7 +165,7 @@ jobs: - name: Generate tags fat id: meta3 - uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0 with: images: | ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index 701bb678e..ba970e885 100644 --- a/.github/workflows/releaseArtifacts.yml +++ b/.github/workflows/releaseArtifacts.yml @@ -23,7 +23,7 @@ jobs: version: ${{ steps.versionNumber.outputs.versionNumber }} steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -83,7 +83,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -95,7 +95,7 @@ jobs: run: ls -R - name: Install Cosign - uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 + uses: sigstore/cosign-installer@d58896d6a1865668819e1d91763c7751a165e159 # v3.9.2 - name: Generate key pair run: cosign generate-key-pair @@ -161,7 +161,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 948a5a37b..47fae4f83 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -74,6 +74,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 + uses: github/codeql-action/upload-sarif@51f77329afa6477de8c49fc9c7046c15b9a4e79d # v3.29.5 with: sarif_file: results.sarif diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index f708a5b8d..71f01438c 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -9,6 +9,18 @@ on: - main workflow_dispatch: +# cancel in-progress jobs if a new job is triggered +# This is useful to avoid running multiple builds for the same branch if a new commit is pushed +# or a pull request is updated. +# It helps to save resources and time by ensuring that only the latest commit is built and tested +# This is particularly useful for long-running jobs that may take a while to complete. +# The `group` is set to a combination of the workflow name, event name, and branch name. +# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of +# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }} + cancel-in-progress: true + permissions: pull-requests: read actions: read @@ -18,7 +30,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 237040f0a..88b150e29 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 463736b65..85a7f10f1 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -6,6 +6,18 @@ on: branches: - master +# cancel in-progress jobs if a new job is triggered +# This is useful to avoid running multiple builds for the same branch if a new commit is pushed +# or a pull request is updated. +# It helps to save resources and time by ensuring that only the latest commit is built and tested +# This is particularly useful for long-running jobs that may take a while to complete. +# The `group` is set to a combination of the workflow name, event name, and branch name. +# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of +# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -14,7 +26,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/sync_files.yml b/.github/workflows/sync_files.yml index 620209dbb..a76cd4acf 100644 --- a/.github/workflows/sync_files.yml +++ b/.github/workflows/sync_files.yml @@ -12,6 +12,18 @@ on: - "app/core/src/main/resources/static/3rdPartyLicenses.json" - "scripts/ignore_translation.toml" +# cancel in-progress jobs if a new job is triggered +# This is useful to avoid running multiple builds for the same branch if a new commit is pushed +# or a pull request is updated. +# It helps to save resources and time by ensuring that only the latest commit is built and tested +# This is particularly useful for long-running jobs that may take a while to complete. +# The `group` is set to a combination of the workflow name, event name, and branch name. +# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of +# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -20,7 +32,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index 85c93a244..b5759ed54 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -4,6 +4,18 @@ on: push: branches: ["master", "UITest", "testdriver"] +# cancel in-progress jobs if a new job is triggered +# This is useful to avoid running multiple builds for the same branch if a new commit is pushed +# or a pull request is updated. +# It helps to save resources and time by ensuring that only the latest commit is built and tested +# This is particularly useful for long-running jobs that may take a while to complete. +# The `group` is set to a combination of the workflow name, event name, and branch name. +# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of +# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened. +concurrency: + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }} + cancel-in-progress: true + permissions: contents: read @@ -12,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -45,7 +57,7 @@ jobs: echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT - name: Login to Docker Hub - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_API }} @@ -110,7 +122,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit @@ -144,7 +156,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 + uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0 with: egress-policy: audit diff --git a/.gitignore b/.gitignore index de9f6f24a..37df23f58 100644 --- a/.gitignore +++ b/.gitignore @@ -125,10 +125,10 @@ SwaggerDoc.json *.tar.gz *.rar *.db -/build -/app/core/build -/app/common/build -/app/proprietary/build +build +app/core/build +app/common/build +app/proprietary/build common/build proprietary/build stirling-pdf/build diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ec2d837e2..a97b3a2a8 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.12.0 + rev: v0.12.7 hooks: - id: ruff args: @@ -22,7 +22,7 @@ repos: files: \.(html|css|js|py|md)$ exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js) - repo: https://github.com/gitleaks/gitleaks - rev: v8.27.2 + rev: v8.28.0 hooks: - id: gitleaks - repo: https://github.com/pre-commit/pre-commit-hooks @@ -43,4 +43,4 @@ repos: # - stylelint-config-standard@38.0.0 # - "@stylistic/stylelint-plugin@3.1.3" # files: \.(css)$ - # args: [--fix] \ No newline at end of file + # args: [--fix] diff --git a/.vscode/settings.json b/.vscode/settings.json index abc54d43e..5b8f77bbc 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,7 @@ "editor.wordSegmenterLocales": "", "editor.guides.bracketPairs": "active", "editor.guides.bracketPairsHorizontal": "active", + "editor.defaultFormatter": "EditorConfig.EditorConfig", "cSpell.enabled": false, "[feature]": { "editor.defaultFormatter": "alexkrechik.cucumberautocomplete" @@ -40,7 +41,7 @@ "java.configuration.updateBuildConfiguration": "interactive", "java.format.enabled": true, "java.format.settings.profile": "GoogleStyle", - "java.format.settings.google.version": "1.27.0", + "java.format.settings.google.version": "1.28.0", "java.format.settings.google.extra": "--aosp --skip-sorting-imports --skip-javadoc-formatting", // (DE) Aktiviert Kommentare im Java-Format. // (EN) Enables comments in Java formatting. diff --git a/README.md b/README.md index 836762158..0533376b8 100644 --- a/README.md +++ b/README.md @@ -116,47 +116,47 @@ Stirling-PDF currently supports 40 languages! | Language | Progress | | -------------------------------------------- | -------------------------------------- | -| Arabic (العربية) (ar_AR) | ![63%](https://geps.dev/progress/63) | -| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![63%](https://geps.dev/progress/63) | -| Basque (Euskara) (eu_ES) | ![37%](https://geps.dev/progress/37) | -| Bulgarian (Български) (bg_BG) | ![70%](https://geps.dev/progress/70) | -| Catalan (Català) (ca_CA) | ![69%](https://geps.dev/progress/69) | -| Croatian (Hrvatski) (hr_HR) | ![62%](https://geps.dev/progress/62) | -| Czech (Česky) (cs_CZ) | ![71%](https://geps.dev/progress/71) | -| Danish (Dansk) (da_DK) | ![63%](https://geps.dev/progress/63) | -| Dutch (Nederlands) (nl_NL) | ![61%](https://geps.dev/progress/61) | +| Arabic (العربية) (ar_AR) | ![61%](https://geps.dev/progress/61) | +| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![62%](https://geps.dev/progress/62) | +| Basque (Euskara) (eu_ES) | ![36%](https://geps.dev/progress/36) | +| Bulgarian (Български) (bg_BG) | ![68%](https://geps.dev/progress/68) | +| Catalan (Català) (ca_CA) | ![68%](https://geps.dev/progress/68) | +| Croatian (Hrvatski) (hr_HR) | ![60%](https://geps.dev/progress/60) | +| Czech (Česky) (cs_CZ) | ![70%](https://geps.dev/progress/70) | +| Danish (Dansk) (da_DK) | ![61%](https://geps.dev/progress/61) | +| Dutch (Nederlands) (nl_NL) | ![60%](https://geps.dev/progress/60) | | English (English) (en_GB) | ![100%](https://geps.dev/progress/100) | | English (US) (en_US) | ![100%](https://geps.dev/progress/100) | -| French (Français) (fr_FR) | ![91%](https://geps.dev/progress/91) | -| German (Deutsch) (de_DE) | ![100%](https://geps.dev/progress/100) | -| Greek (Ελληνικά) (el_GR) | ![69%](https://geps.dev/progress/69) | -| Hindi (हिंदी) (hi_IN) | ![68%](https://geps.dev/progress/68) | +| French (Français) (fr_FR) | ![89%](https://geps.dev/progress/89) | +| German (Deutsch) (de_DE) | ![98%](https://geps.dev/progress/98) | +| Greek (Ελληνικά) (el_GR) | ![67%](https://geps.dev/progress/67) | +| Hindi (हिंदी) (hi_IN) | ![67%](https://geps.dev/progress/67) | | Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) | -| Indonesian (Bahasa Indonesia) (id_ID) | ![63%](https://geps.dev/progress/63) | -| Irish (Gaeilge) (ga_IE) | ![70%](https://geps.dev/progress/70) | +| Indonesian (Bahasa Indonesia) (id_ID) | ![62%](https://geps.dev/progress/62) | +| Irish (Gaeilge) (ga_IE) | ![68%](https://geps.dev/progress/68) | | Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) | -| Japanese (日本語) (ja_JP) | ![95%](https://geps.dev/progress/95) | -| Korean (한국어) (ko_KR) | ![69%](https://geps.dev/progress/69) | -| Norwegian (Norsk) (no_NB) | ![67%](https://geps.dev/progress/67) | -| Persian (فارسی) (fa_IR) | ![66%](https://geps.dev/progress/66) | -| Polish (Polski) (pl_PL) | ![73%](https://geps.dev/progress/73) | -| Portuguese (Português) (pt_PT) | ![70%](https://geps.dev/progress/70) | -| Portuguese Brazilian (Português) (pt_BR) | ![77%](https://geps.dev/progress/77) | -| Romanian (Română) (ro_RO) | ![59%](https://geps.dev/progress/59) | -| Russian (Русский) (ru_RU) | ![90%](https://geps.dev/progress/90) | -| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![97%](https://geps.dev/progress/97) | -| Simplified Chinese (简体中文) (zh_CN) | ![95%](https://geps.dev/progress/95) | -| Slovakian (Slovensky) (sk_SK) | ![53%](https://geps.dev/progress/53) | -| Slovenian (Slovenščina) (sl_SI) | ![73%](https://geps.dev/progress/73) | -| Spanish (Español) (es_ES) | ![75%](https://geps.dev/progress/75) | -| Swedish (Svenska) (sv_SE) | ![67%](https://geps.dev/progress/67) | -| Thai (ไทย) (th_TH) | ![60%](https://geps.dev/progress/60) | -| Tibetan (བོད་ཡིག་) (bo_CN) | ![66%](https://geps.dev/progress/66) | -| Traditional Chinese (繁體中文) (zh_TW) | ![77%](https://geps.dev/progress/77) | -| Turkish (Türkçe) (tr_TR) | ![82%](https://geps.dev/progress/82) | -| Ukrainian (Українська) (uk_UA) | ![72%](https://geps.dev/progress/72) | -| Vietnamese (Tiếng Việt) (vi_VN) | ![58%](https://geps.dev/progress/58) | -| Malayalam (മലയാളം) (ml_IN) | ![75%](https://geps.dev/progress/75) | +| Japanese (日本語) (ja_JP) | ![93%](https://geps.dev/progress/93) | +| Korean (한국어) (ko_KR) | ![67%](https://geps.dev/progress/67) | +| Norwegian (Norsk) (no_NB) | ![66%](https://geps.dev/progress/66) | +| Persian (فارسی) (fa_IR) | ![64%](https://geps.dev/progress/64) | +| Polish (Polski) (pl_PL) | ![72%](https://geps.dev/progress/72) | +| Portuguese (Português) (pt_PT) | ![69%](https://geps.dev/progress/69) | +| Portuguese Brazilian (Português) (pt_BR) | ![76%](https://geps.dev/progress/76) | +| Romanian (Română) (ro_RO) | ![57%](https://geps.dev/progress/57) | +| Russian (Русский) (ru_RU) | ![88%](https://geps.dev/progress/88) | +| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![95%](https://geps.dev/progress/95) | +| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) | +| Slovakian (Slovensky) (sk_SK) | ![51%](https://geps.dev/progress/51) | +| Slovenian (Slovenščina) (sl_SI) | ![71%](https://geps.dev/progress/71) | +| Spanish (Español) (es_ES) | ![74%](https://geps.dev/progress/74) | +| Swedish (Svenska) (sv_SE) | ![65%](https://geps.dev/progress/65) | +| Thai (ไทย) (th_TH) | ![59%](https://geps.dev/progress/59) | +| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) | +| Traditional Chinese (繁體中文) (zh_TW) | ![97%](https://geps.dev/progress/97) | +| Turkish (Türkçe) (tr_TR) | ![80%](https://geps.dev/progress/80) | +| Ukrainian (Українська) (uk_UA) | ![71%](https://geps.dev/progress/71) | +| Vietnamese (Tiếng Việt) (vi_VN) | ![57%](https://geps.dev/progress/57) | +| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) | ## Stirling PDF Enterprise diff --git a/app/common/build.gradle b/app/common/build.gradle index 3c46a59af..39dab8ded 100644 --- a/app/common/build.gradle +++ b/app/common/build.gradle @@ -4,7 +4,7 @@ bootRun { } spotless { java { - target sourceSets.main.allJava + target 'src/**/java/**/*.java' googleJavaFormat(googleJavaFormatVersion).aosp().reorderImports(false) importOrder("java", "javax", "org", "com", "net", "io", "jakarta", "lombok", "me", "stirling") @@ -13,6 +13,18 @@ spotless { leadingTabsToSpaces() endWithNewline() } + yaml { + target '**/*.yml', '**/*.yaml' + trimTrailingWhitespace() + leadingTabsToSpaces() + endWithNewline() + } + format 'gradle', { + target '**/gradle/*.gradle', '**/*.gradle' + trimTrailingWhitespace() + leadingTabsToSpaces() + endWithNewline() + } } dependencies { api 'org.springframework.boot:spring-boot-starter-web' @@ -29,5 +41,5 @@ dependencies { api 'org.snakeyaml:snakeyaml-engine:2.10' api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9" api 'jakarta.mail:jakarta.mail-api:2.1.3' - runtimeOnly 'org.eclipse.angus:angus-mail:2.0.3' + runtimeOnly 'org.eclipse.angus:angus-mail:2.0.4' } diff --git a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java index 08618329d..247a012ad 100644 --- a/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java +++ b/app/common/src/main/java/stirling/software/common/configuration/InstallationPathConfig.java @@ -15,6 +15,7 @@ public class InstallationPathConfig { private static final String CUSTOM_FILES_PATH; private static final String CLIENT_WEBUI_PATH; private static final String SCRIPTS_PATH; + private static final String PIPELINE_PATH; // Config paths private static final String SETTINGS_PATH; @@ -33,6 +34,7 @@ public class InstallationPathConfig { CONFIG_PATH = BASE_PATH + "configs" + File.separator; CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator; CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator; + PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator; // Initialize config paths SETTINGS_PATH = CONFIG_PATH + "settings.yml"; @@ -95,6 +97,10 @@ public class InstallationPathConfig { return SCRIPTS_PATH; } + public static String getPipelinePath() { + return PIPELINE_PATH; + } + public static String getSettingsPath() { return SETTINGS_PATH; } diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index e4edf2baa..ee893c575 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -25,6 +25,9 @@ import org.springframework.core.io.Resource; import org.springframework.core.io.support.EncodedResource; import org.springframework.stereotype.Component; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.Data; import lombok.Getter; import lombok.Setter; @@ -58,7 +61,10 @@ public class ApplicationProperties { private Mail mail = new Mail(); private Premium premium = new Premium(); + + @JsonIgnore // Deprecated - completely hidden from JSON serialization private EnterpriseEdition enterpriseEdition = new EnterpriseEdition(); + private AutoPipeline autoPipeline = new AutoPipeline(); private ProcessExecutor processExecutor = new ProcessExecutor(); @@ -168,17 +174,30 @@ public class ApplicationProperties { private Boolean autoCreateUser = false; private Boolean blockRegistration = false; private String registrationId = "stirling"; - @ToString.Exclude private String idpMetadataUri; + + @ToString.Exclude + @JsonProperty("idpMetadataUri") + private String idpMetadataUri; + private String idpSingleLogoutUrl; private String idpSingleLoginUrl; private String idpIssuer; - private String idpCert; - @ToString.Exclude private String privateKey; - @ToString.Exclude private String spCert; + @JsonProperty("idpCert") + private String idpCert; + + @ToString.Exclude + @JsonProperty("privateKey") + private String privateKey; + + @ToString.Exclude + @JsonProperty("spCert") + private String spCert; + + @JsonIgnore public InputStream getIdpMetadataUri() throws IOException { if (idpMetadataUri.startsWith("classpath:")) { - return new ClassPathResource(idpMetadataUri.substring("classpath".length())) + return new ClassPathResource(idpMetadataUri.substring("classpath:".length())) .getInputStream(); } try { @@ -192,6 +211,7 @@ public class ApplicationProperties { } } + @JsonIgnore public Resource getSpCert() { if (spCert == null) return null; if (spCert.startsWith("classpath:")) { @@ -201,6 +221,7 @@ public class ApplicationProperties { } } + @JsonIgnore public Resource getIdpCert() { if (idpCert == null) return null; if (idpCert.startsWith("classpath:")) { @@ -210,7 +231,9 @@ public class ApplicationProperties { } } + @JsonIgnore public Resource getPrivateKey() { + if (privateKey == null) return null; if (privateKey.startsWith("classpath:")) { return new ClassPathResource(privateKey.substring("classpath:".length())); } else { @@ -289,7 +312,9 @@ public class ApplicationProperties { private Boolean enableAnalytics; private Datasource datasource; private Boolean disableSanitize; + private int maxDPI; private Boolean enableUrlToPDF; + private Html html = new Html(); private CustomPaths customPaths = new CustomPaths(); private String fileUploadLimit; private TempFileManagement tempFileManagement = new TempFileManagement(); @@ -320,8 +345,12 @@ public class ApplicationProperties { @Data public static class TempFileManagement { + @JsonProperty("baseTmpDir") private String baseTmpDir = ""; + + @JsonProperty("libreofficeDir") private String libreofficeDir = ""; + private String systemTempDir = ""; private String prefix = "stirling-pdf-"; private long maxAgeHours = 24; @@ -329,12 +358,14 @@ public class ApplicationProperties { private boolean startupCleanup = true; private boolean cleanupSystemTemp = false; + @JsonIgnore public String getBaseTmpDir() { return baseTmpDir != null && !baseTmpDir.isEmpty() ? baseTmpDir : java.lang.System.getProperty("java.io.tmpdir") + "/stirling-pdf"; } + @JsonIgnore public String getLibreofficeDir() { return libreofficeDir != null && !libreofficeDir.isEmpty() ? libreofficeDir @@ -342,6 +373,25 @@ public class ApplicationProperties { } } + @Data + public static class Html { + private UrlSecurity urlSecurity = new UrlSecurity(); + + @Data + public static class UrlSecurity { + private boolean enabled = true; + private String level = "MEDIUM"; // MAX, MEDIUM, OFF + private List allowedDomains = new ArrayList<>(); + private List blockedDomains = new ArrayList<>(); + private List internalTlds = + Arrays.asList(".local", ".internal", ".corp", ".home"); + private boolean blockPrivateNetworks = true; + private boolean blockLocalhost = true; + private boolean blockLinkLocal = true; + private boolean blockCloudMetadata = true; + } + } + @Data public static class Datasource { private boolean enableCustomDatabase; @@ -591,12 +641,24 @@ public class ApplicationProperties { @Data public static class TimeoutMinutes { + @JsonProperty("libreOfficetimeoutMinutes") private long libreOfficeTimeoutMinutes; + + @JsonProperty("pdfToHtmltimeoutMinutes") private long pdfToHtmlTimeoutMinutes; + + @JsonProperty("pythonOpenCvtimeoutMinutes") private long pythonOpenCvTimeoutMinutes; + + @JsonProperty("weasyPrinttimeoutMinutes") private long weasyPrintTimeoutMinutes; + + @JsonProperty("installApptimeoutMinutes") private long installAppTimeoutMinutes; + + @JsonProperty("calibretimeoutMinutes") private long calibreTimeoutMinutes; + private long tesseractTimeoutMinutes; private long qpdfTimeoutMinutes; private long ghostscriptTimeoutMinutes; diff --git a/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java new file mode 100644 index 000000000..97c2da12e --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/service/SsrfProtectionService.java @@ -0,0 +1,208 @@ +package stirling.software.common.service; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.regex.Pattern; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import stirling.software.common.model.ApplicationProperties; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SsrfProtectionService { + + private final ApplicationProperties applicationProperties; + + private static final Pattern DATA_URL_PATTERN = + Pattern.compile("^data:.*", Pattern.CASE_INSENSITIVE); + private static final Pattern FRAGMENT_PATTERN = Pattern.compile("^#.*"); + + public enum SsrfProtectionLevel { + OFF, // No SSRF protection - allows all URLs + MEDIUM, // Block internal networks but allow external URLs + MAX // Block all external URLs - only data: and fragments + } + + public boolean isUrlAllowed(String url) { + ApplicationProperties.Html.UrlSecurity config = + applicationProperties.getSystem().getHtml().getUrlSecurity(); + + if (!config.isEnabled()) { + return true; + } + + if (url == null || url.trim().isEmpty()) { + return false; + } + + String trimmedUrl = url.trim(); + + // Always allow data URLs and fragments + if (DATA_URL_PATTERN.matcher(trimmedUrl).matches() + || FRAGMENT_PATTERN.matcher(trimmedUrl).matches()) { + return true; + } + + SsrfProtectionLevel level = parseProtectionLevel(config.getLevel()); + + switch (level) { + case OFF: + return true; + case MAX: + return isMaxSecurityAllowed(trimmedUrl, config); + case MEDIUM: + return isMediumSecurityAllowed(trimmedUrl, config); + default: + return false; + } + } + + private SsrfProtectionLevel parseProtectionLevel(String level) { + try { + return SsrfProtectionLevel.valueOf(level.toUpperCase()); + } catch (IllegalArgumentException e) { + log.warn("Invalid SSRF protection level '{}', defaulting to MEDIUM", level); + return SsrfProtectionLevel.MEDIUM; + } + } + + private boolean isMaxSecurityAllowed( + String url, ApplicationProperties.Html.UrlSecurity config) { + // MAX security: only allow explicitly whitelisted domains + try { + URI uri = new URI(url); + String host = uri.getHost(); + + if (host == null) { + return false; + } + + return config.getAllowedDomains().contains(host.toLowerCase()); + + } catch (Exception e) { + log.debug("Failed to parse URL for MAX security check: {}", url, e); + return false; + } + } + + private boolean isMediumSecurityAllowed( + String url, ApplicationProperties.Html.UrlSecurity config) { + try { + URI uri = new URI(url); + String host = uri.getHost(); + + if (host == null) { + return false; + } + + String hostLower = host.toLowerCase(); + + // Check explicit blocked domains + if (config.getBlockedDomains().contains(hostLower)) { + log.debug("URL blocked by explicit domain blocklist: {}", url); + return false; + } + + // Check internal TLD patterns + for (String tld : config.getInternalTlds()) { + if (hostLower.endsWith(tld.toLowerCase())) { + log.debug("URL blocked by internal TLD pattern '{}': {}", tld, url); + return false; + } + } + + // If allowedDomains is specified, only allow those + if (!config.getAllowedDomains().isEmpty()) { + boolean isAllowed = + config.getAllowedDomains().stream() + .anyMatch( + domain -> + hostLower.equals(domain.toLowerCase()) + || hostLower.endsWith( + "." + domain.toLowerCase())); + + if (!isAllowed) { + log.debug("URL not in allowed domains list: {}", url); + return false; + } + } + + // Resolve hostname to IP address for network-based checks + try { + InetAddress address = InetAddress.getByName(host); + + if (config.isBlockPrivateNetworks() && isPrivateAddress(address)) { + log.debug("URL blocked - private network address: {}", url); + return false; + } + + if (config.isBlockLocalhost() && address.isLoopbackAddress()) { + log.debug("URL blocked - localhost address: {}", url); + return false; + } + + if (config.isBlockLinkLocal() && address.isLinkLocalAddress()) { + log.debug("URL blocked - link-local address: {}", url); + return false; + } + + if (config.isBlockCloudMetadata() + && isCloudMetadataAddress(address.getHostAddress())) { + log.debug("URL blocked - cloud metadata endpoint: {}", url); + return false; + } + + } catch (UnknownHostException e) { + log.debug("Failed to resolve hostname for SSRF check: {}", host, e); + return false; + } + + return true; + + } catch (Exception e) { + log.debug("Failed to parse URL for MEDIUM security check: {}", url, e); + return false; + } + } + + private boolean isPrivateAddress(InetAddress address) { + return address.isSiteLocalAddress() + || address.isAnyLocalAddress() + || isPrivateIPv4Range(address.getHostAddress()); + } + + private boolean isPrivateIPv4Range(String ip) { + return ip.startsWith("10.") + || ip.startsWith("192.168.") + || (ip.startsWith("172.") && isInRange172(ip)) + || ip.startsWith("127.") + || "0.0.0.0".equals(ip); + } + + private boolean isInRange172(String ip) { + String[] parts = ip.split("\\."); + if (parts.length >= 2) { + try { + int secondOctet = Integer.parseInt(parts[1]); + return secondOctet >= 16 && secondOctet <= 31; + } catch (NumberFormatException e) { + return false; + } + } + return false; + } + + private boolean isCloudMetadataAddress(String ip) { + // Cloud metadata endpoints for AWS, GCP, Azure, Oracle Cloud, and IBM Cloud + return ip.startsWith("169.254.169.254") // AWS/GCP/Azure + || ip.startsWith("fd00:ec2::254") // AWS IPv6 + || ip.startsWith("169.254.169.253") // Oracle Cloud + || ip.startsWith("169.254.169.250"); // IBM Cloud + } +} diff --git a/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java b/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java index e5fe0436a..05d9b73a6 100644 --- a/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java +++ b/app/common/src/main/java/stirling/software/common/util/CustomHtmlSanitizer.java @@ -1,21 +1,71 @@ package stirling.software.common.util; +import org.owasp.html.AttributePolicy; import org.owasp.html.HtmlPolicyBuilder; import org.owasp.html.PolicyFactory; import org.owasp.html.Sanitizers; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import stirling.software.common.model.ApplicationProperties; +import stirling.software.common.service.SsrfProtectionService; + +@Component public class CustomHtmlSanitizer { - private static final PolicyFactory POLICY = + + private final SsrfProtectionService ssrfProtectionService; + private final ApplicationProperties applicationProperties; + + @Autowired + public CustomHtmlSanitizer( + SsrfProtectionService ssrfProtectionService, + ApplicationProperties applicationProperties) { + this.ssrfProtectionService = ssrfProtectionService; + this.applicationProperties = applicationProperties; + } + + private final AttributePolicy SSRF_SAFE_URL_POLICY = + new AttributePolicy() { + @Override + public String apply(String elementName, String attributeName, String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + + String trimmedValue = value.trim(); + + // Use the SSRF protection service to validate the URL + if (ssrfProtectionService != null + && !ssrfProtectionService.isUrlAllowed(trimmedValue)) { + return null; + } + + return trimmedValue; + } + }; + + private final PolicyFactory SSRF_SAFE_IMAGES_POLICY = + new HtmlPolicyBuilder() + .allowElements("img") + .allowAttributes("alt", "width", "height", "title") + .onElements("img") + .allowAttributes("src") + .matching(SSRF_SAFE_URL_POLICY) + .onElements("img") + .toFactory(); + + private final PolicyFactory POLICY = Sanitizers.FORMATTING .and(Sanitizers.BLOCKS) .and(Sanitizers.STYLES) .and(Sanitizers.LINKS) .and(Sanitizers.TABLES) - .and(Sanitizers.IMAGES) + .and(SSRF_SAFE_IMAGES_POLICY) .and(new HtmlPolicyBuilder().disallowElements("noscript").toFactory()); - public static String sanitize(String html) { - String htmlAfter = POLICY.sanitize(html); - return htmlAfter; + public String sanitize(String html) { + boolean disableSanitize = + Boolean.TRUE.equals(applicationProperties.getSystem().getDisableSanitize()); + return disableSanitize ? html : POLICY.sanitize(html); } } diff --git a/app/common/src/main/java/stirling/software/common/util/EmlParser.java b/app/common/src/main/java/stirling/software/common/util/EmlParser.java new file mode 100644 index 000000000..0815b1c56 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/EmlParser.java @@ -0,0 +1,652 @@ +package stirling.software.common.util; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Properties; +import java.util.regex.Pattern; + +import lombok.Data; +import lombok.experimental.UtilityClass; + +import stirling.software.common.model.api.converters.EmlToPdfRequest; + +@UtilityClass +public class EmlParser { + + private static volatile Boolean jakartaMailAvailable = null; + private static volatile Method mimeUtilityDecodeTextMethod = null; + private static volatile boolean mimeUtilityChecked = false; + + private static final Pattern MIME_ENCODED_PATTERN = + Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?="); + + private static final String DISPOSITION_ATTACHMENT = "attachment"; + private static final String TEXT_PLAIN = "text/plain"; + private static final String TEXT_HTML = "text/html"; + private static final String MULTIPART_PREFIX = "multipart/"; + + private static final String HEADER_CONTENT_TYPE = "content-type:"; + private static final String HEADER_CONTENT_DISPOSITION = "content-disposition:"; + private static final String HEADER_CONTENT_TRANSFER_ENCODING = "content-transfer-encoding:"; + private static final String HEADER_CONTENT_ID = "Content-ID"; + private static final String HEADER_SUBJECT = "Subject:"; + private static final String HEADER_FROM = "From:"; + private static final String HEADER_TO = "To:"; + private static final String HEADER_CC = "Cc:"; + private static final String HEADER_BCC = "Bcc:"; + private static final String HEADER_DATE = "Date:"; + + private static synchronized boolean isJakartaMailAvailable() { + if (jakartaMailAvailable == null) { + try { + Class.forName("jakarta.mail.internet.MimeMessage"); + Class.forName("jakarta.mail.Session"); + Class.forName("jakarta.mail.internet.MimeUtility"); + Class.forName("jakarta.mail.internet.MimePart"); + Class.forName("jakarta.mail.internet.MimeMultipart"); + Class.forName("jakarta.mail.Multipart"); + Class.forName("jakarta.mail.Part"); + jakartaMailAvailable = true; + } catch (ClassNotFoundException e) { + jakartaMailAvailable = false; + } + } + return jakartaMailAvailable; + } + + public static EmailContent extractEmailContent( + byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) + throws IOException { + EmlProcessingUtils.validateEmlInput(emlBytes); + + if (isJakartaMailAvailable()) { + return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer); + } else { + return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer); + } + } + + private static EmailContent extractEmailContentBasic( + byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) { + String emlContent = new String(emlBytes, StandardCharsets.UTF_8); + EmailContent content = new EmailContent(); + + content.setSubject(extractBasicHeader(emlContent, HEADER_SUBJECT)); + content.setFrom(extractBasicHeader(emlContent, HEADER_FROM)); + content.setTo(extractBasicHeader(emlContent, HEADER_TO)); + content.setCc(extractBasicHeader(emlContent, HEADER_CC)); + content.setBcc(extractBasicHeader(emlContent, HEADER_BCC)); + + String dateStr = extractBasicHeader(emlContent, HEADER_DATE); + if (!dateStr.isEmpty()) { + content.setDateString(dateStr); + } + + String htmlBody = extractHtmlBody(emlContent); + if (htmlBody != null) { + content.setHtmlBody(htmlBody); + } else { + String textBody = extractTextBody(emlContent); + content.setTextBody(textBody != null ? textBody : "Email content could not be parsed"); + } + + content.getAttachments().addAll(extractAttachmentsBasic(emlContent)); + + return content; + } + + private static EmailContent extractEmailContentAdvanced( + byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) { + try { + Class sessionClass = Class.forName("jakarta.mail.Session"); + Class mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage"); + + Method getDefaultInstance = + sessionClass.getMethod("getDefaultInstance", Properties.class); + Object session = getDefaultInstance.invoke(null, new Properties()); + + Class[] constructorArgs = new Class[] {sessionClass, InputStream.class}; + Constructor mimeMessageConstructor = + mimeMessageClass.getConstructor(constructorArgs); + Object message = + mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes)); + + return extractFromMimeMessage(message, request, customHtmlSanitizer); + + } catch (ReflectiveOperationException e) { + return extractEmailContentBasic(emlBytes, request, customHtmlSanitizer); + } + } + + private static EmailContent extractFromMimeMessage( + Object message, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) { + EmailContent content = new EmailContent(); + + try { + Class messageClass = message.getClass(); + + Method getSubject = messageClass.getMethod("getSubject"); + String subject = (String) getSubject.invoke(message); + content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject"); + + Method getFrom = messageClass.getMethod("getFrom"); + Object[] fromAddresses = (Object[]) getFrom.invoke(message); + content.setFrom(buildAddressString(fromAddresses)); + + extractRecipients(message, messageClass, content); + + Method getSentDate = messageClass.getMethod("getSentDate"); + content.setDate((Date) getSentDate.invoke(message)); + + Method getContent = messageClass.getMethod("getContent"); + Object messageContent = getContent.invoke(message); + + processMessageContent(message, messageContent, content, request, customHtmlSanitizer); + + } catch (ReflectiveOperationException | RuntimeException e) { + content.setSubject("Email Conversion"); + content.setFrom("Unknown"); + content.setTo("Unknown"); + content.setCc(""); + content.setBcc(""); + content.setTextBody("Email content could not be parsed with advanced processing"); + } + + return content; + } + + private static void extractRecipients( + Object message, Class messageClass, EmailContent content) { + try { + Method getRecipients = + messageClass.getMethod( + "getRecipients", Class.forName("jakarta.mail.Message$RecipientType")); + Class recipientTypeClass = Class.forName("jakarta.mail.Message$RecipientType"); + + Object toType = recipientTypeClass.getField("TO").get(null); + Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType); + content.setTo(buildAddressString(toRecipients)); + + Object ccType = recipientTypeClass.getField("CC").get(null); + Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType); + content.setCc(buildAddressString(ccRecipients)); + + Object bccType = recipientTypeClass.getField("BCC").get(null); + Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType); + content.setBcc(buildAddressString(bccRecipients)); + + } catch (ReflectiveOperationException e) { + try { + Method getAllRecipients = messageClass.getMethod("getAllRecipients"); + Object[] recipients = (Object[]) getAllRecipients.invoke(message); + content.setTo(buildAddressString(recipients)); + content.setCc(""); + content.setBcc(""); + } catch (ReflectiveOperationException ex) { + content.setTo(""); + content.setCc(""); + content.setBcc(""); + } + } + } + + private static String buildAddressString(Object[] addresses) { + if (addresses == null || addresses.length == 0) { + return ""; + } + + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < addresses.length; i++) { + if (i > 0) builder.append(", "); + builder.append(safeMimeDecode(addresses[i].toString())); + } + return builder.toString(); + } + + private static void processMessageContent( + Object message, + Object messageContent, + EmailContent content, + EmlToPdfRequest request, + CustomHtmlSanitizer customHtmlSanitizer) { + try { + if (messageContent instanceof String stringContent) { + Method getContentType = message.getClass().getMethod("getContentType"); + String contentType = (String) getContentType.invoke(message); + + if (contentType != null && contentType.toLowerCase().contains(TEXT_HTML)) { + content.setHtmlBody(stringContent); + } else { + content.setTextBody(stringContent); + } + } else { + Class multipartClass = Class.forName("jakarta.mail.Multipart"); + if (multipartClass.isInstance(messageContent)) { + processMultipart(messageContent, content, request, customHtmlSanitizer, 0); + } + } + } catch (ReflectiveOperationException | ClassCastException e) { + content.setTextBody("Email content could not be parsed with advanced processing"); + } + } + + private static void processMultipart( + Object multipart, + EmailContent content, + EmlToPdfRequest request, + CustomHtmlSanitizer customHtmlSanitizer, + int depth) { + + final int MAX_MULTIPART_DEPTH = 10; + if (depth > MAX_MULTIPART_DEPTH) { + content.setHtmlBody("
Maximum multipart depth exceeded
"); + return; + } + + try { + Class multipartClass = multipart.getClass(); + Method getCount = multipartClass.getMethod("getCount"); + int count = (Integer) getCount.invoke(multipart); + + Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class); + + for (int i = 0; i < count; i++) { + Object part = getBodyPart.invoke(multipart, i); + processPart(part, content, request, customHtmlSanitizer, depth + 1); + } + + } catch (ReflectiveOperationException | ClassCastException e) { + content.setHtmlBody("
Error processing multipart content
"); + } + } + + private static void processPart( + Object part, + EmailContent content, + EmlToPdfRequest request, + CustomHtmlSanitizer customHtmlSanitizer, + int depth) { + try { + Class partClass = part.getClass(); + + 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); + String contentType = (String) getContentType.invoke(part); + + String normalizedDisposition = + disposition != null ? ((String) disposition).toLowerCase() : null; + + if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) { + Object partContent = getContent.invoke(part); + if (partContent instanceof String stringContent) { + content.setTextBody(stringContent); + } + } else if ((Boolean) isMimeType.invoke(part, TEXT_HTML) + && normalizedDisposition == null) { + Object partContent = getContent.invoke(part); + if (partContent instanceof String stringContent) { + String htmlBody = + customHtmlSanitizer != null + ? customHtmlSanitizer.sanitize(stringContent) + : stringContent; + content.setHtmlBody(htmlBody); + } + } else if ((normalizedDisposition != null + && normalizedDisposition.contains(DISPOSITION_ATTACHMENT)) + || (filename != null && !filename.trim().isEmpty())) { + + processAttachment( + part, content, request, getHeader, getContent, filename, contentType); + } else if ((Boolean) isMimeType.invoke(part, "multipart/*")) { + Object multipartContent = getContent.invoke(part); + if (multipartContent != null) { + Class multipartClass = Class.forName("jakarta.mail.Multipart"); + if (multipartClass.isInstance(multipartContent)) { + processMultipart( + multipartContent, content, request, customHtmlSanitizer, depth + 1); + } + } + } + + } catch (ReflectiveOperationException | RuntimeException e) { + // Continue processing other parts if one fails + } + } + + private static void processAttachment( + Object part, + EmailContent content, + EmlToPdfRequest request, + Method getHeader, + Method getContent, + String filename, + String contentType) { + + content.setAttachmentCount(content.getAttachmentCount() + 1); + + if (filename != null && !filename.trim().isEmpty()) { + EmailAttachment attachment = new EmailAttachment(); + attachment.setFilename(safeMimeDecode(filename)); + attachment.setContentType(contentType); + + try { + String[] contentIdHeaders = (String[]) getHeader.invoke(part, HEADER_CONTENT_ID); + if (contentIdHeaders != null) { + for (String contentIdHeader : contentIdHeaders) { + if (contentIdHeader != null && !contentIdHeader.trim().isEmpty()) { + attachment.setEmbedded(true); + String contentId = contentIdHeader.trim().replaceAll("[<>]", ""); + attachment.setContentId(contentId); + break; + } + } + } + } catch (ReflectiveOperationException e) { + } + + if ((request != null && request.isIncludeAttachments()) || attachment.isEmbedded()) { + extractAttachmentData(part, attachment, getContent, request); + } + + content.getAttachments().add(attachment); + } + } + + private static void extractAttachmentData( + Object part, EmailAttachment attachment, Method getContent, EmlToPdfRequest request) { + try { + Object attachmentContent = getContent.invoke(part); + byte[] attachmentData = null; + + if (attachmentContent instanceof InputStream inputStream) { + try (InputStream stream = inputStream) { + attachmentData = stream.readAllBytes(); + } catch (IOException e) { + if (attachment.isEmbedded()) { + attachmentData = new byte[0]; + } else { + throw new RuntimeException(e); + } + } + } else if (attachmentContent instanceof byte[] byteArray) { + attachmentData = byteArray; + } else if (attachmentContent instanceof String stringContent) { + attachmentData = stringContent.getBytes(StandardCharsets.UTF_8); + } + + if (attachmentData != null) { + long maxSizeMB = request != null ? request.getMaxAttachmentSizeMB() : 10L; + long maxSizeBytes = maxSizeMB * 1024 * 1024; + + if (attachmentData.length <= maxSizeBytes || attachment.isEmbedded()) { + attachment.setData(attachmentData); + attachment.setSizeBytes(attachmentData.length); + } else { + attachment.setSizeBytes(attachmentData.length); + } + } + } catch (ReflectiveOperationException | RuntimeException e) { + // Continue without attachment data + } + } + + private static String extractBasicHeader(String emlContent, String headerName) { + try { + String[] lines = emlContent.split("\r?\n"); + for (int i = 0; i < lines.length; i++) { + String line = lines[i]; + if (line.toLowerCase().startsWith(headerName.toLowerCase())) { + StringBuilder value = + new StringBuilder(line.substring(headerName.length()).trim()); + for (int j = i + 1; j < lines.length; j++) { + if (lines[j].startsWith(" ") || lines[j].startsWith("\t")) { + value.append(" ").append(lines[j].trim()); + } else { + break; + } + } + return safeMimeDecode(value.toString()); + } + if (line.trim().isEmpty()) break; + } + } catch (RuntimeException e) { + // Ignore errors in header extraction + } + return ""; + } + + private static String extractHtmlBody(String emlContent) { + try { + String lowerContent = emlContent.toLowerCase(); + int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML); + if (htmlStart == -1) return null; + + int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart); + if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart); + if (bodyStart == -1) return null; + + bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2; + int bodyEnd = findPartEnd(emlContent, bodyStart); + + return emlContent.substring(bodyStart, bodyEnd).trim(); + } catch (Exception e) { + return null; + } + } + + private static String extractTextBody(String emlContent) { + try { + String lowerContent = emlContent.toLowerCase(); + int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN); + if (textStart == -1) { + int bodyStart = emlContent.indexOf("\r\n\r\n"); + if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n"); + if (bodyStart != -1) { + bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2; + int bodyEnd = findPartEnd(emlContent, bodyStart); + return emlContent.substring(bodyStart, bodyEnd).trim(); + } + return null; + } + + int bodyStart = emlContent.indexOf("\r\n\r\n", textStart); + if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart); + if (bodyStart == -1) return null; + + bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2; + int bodyEnd = findPartEnd(emlContent, bodyStart); + + return emlContent.substring(bodyStart, bodyEnd).trim(); + } catch (RuntimeException e) { + return null; + } + } + + private static int findPartEnd(String content, int start) { + String[] lines = content.substring(start).split("\r?\n"); + StringBuilder result = new StringBuilder(); + + for (String line : lines) { + if (line.startsWith("--") && line.length() > 10) break; + result.append(line).append("\n"); + } + + return start + result.length(); + } + + private static List extractAttachmentsBasic(String emlContent) { + List attachments = new ArrayList<>(); + try { + String[] lines = emlContent.split("\r?\n"); + boolean inHeaders = true; + String currentContentType = ""; + String currentDisposition = ""; + String currentFilename = ""; + String currentEncoding = ""; + + for (String line : lines) { + String lowerLine = line.toLowerCase().trim(); + + if (line.trim().isEmpty()) { + inHeaders = false; + if (isAttachment(currentDisposition, currentFilename, currentContentType)) { + EmailAttachment attachment = new EmailAttachment(); + attachment.setFilename(currentFilename); + attachment.setContentType(currentContentType); + attachment.setTransferEncoding(currentEncoding); + attachments.add(attachment); + } + currentContentType = ""; + currentDisposition = ""; + currentFilename = ""; + currentEncoding = ""; + inHeaders = true; + continue; + } + + if (!inHeaders) continue; + + if (lowerLine.startsWith(HEADER_CONTENT_TYPE)) { + currentContentType = line.substring(HEADER_CONTENT_TYPE.length()).trim(); + } else if (lowerLine.startsWith(HEADER_CONTENT_DISPOSITION)) { + currentDisposition = line.substring(HEADER_CONTENT_DISPOSITION.length()).trim(); + currentFilename = extractFilenameFromDisposition(currentDisposition); + } else if (lowerLine.startsWith(HEADER_CONTENT_TRANSFER_ENCODING)) { + currentEncoding = + line.substring(HEADER_CONTENT_TRANSFER_ENCODING.length()).trim(); + } + } + } catch (RuntimeException e) { + // Continue with empty list + } + return attachments; + } + + private static boolean isAttachment(String disposition, String filename, String contentType) { + return (disposition.toLowerCase().contains(DISPOSITION_ATTACHMENT) && !filename.isEmpty()) + || (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/")) + || (contentType.toLowerCase().contains("application/") && !filename.isEmpty()); + } + + private static String extractFilenameFromDisposition(String disposition) { + if (disposition == null || !disposition.contains("filename=")) { + return ""; + } + + // Handle filename*= (RFC 2231 encoded filename) + if (disposition.toLowerCase().contains("filename*=")) { + int filenameStarStart = disposition.toLowerCase().indexOf("filename*=") + 10; + int filenameStarEnd = disposition.indexOf(";", filenameStarStart); + if (filenameStarEnd == -1) filenameStarEnd = disposition.length(); + String extendedFilename = + disposition.substring(filenameStarStart, filenameStarEnd).trim(); + extendedFilename = extendedFilename.replaceAll("^\"|\"$", ""); + + if (extendedFilename.contains("'")) { + String[] parts = extendedFilename.split("'", 3); + if (parts.length == 3) { + return EmlProcessingUtils.decodeUrlEncoded(parts[2]); + } + } + } + + // Handle regular filename= + int filenameStart = disposition.toLowerCase().indexOf("filename=") + 9; + int filenameEnd = disposition.indexOf(";", filenameStart); + if (filenameEnd == -1) filenameEnd = disposition.length(); + String filename = disposition.substring(filenameStart, filenameEnd).trim(); + filename = filename.replaceAll("^\"|\"$", ""); + return safeMimeDecode(filename); + } + + public static String safeMimeDecode(String headerValue) { + if (headerValue == null || headerValue.trim().isEmpty()) { + return ""; + } + + if (!mimeUtilityChecked) { + synchronized (EmlParser.class) { + if (!mimeUtilityChecked) { + initializeMimeUtilityDecoding(); + } + } + } + + if (mimeUtilityDecodeTextMethod != null) { + try { + return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim()); + } catch (ReflectiveOperationException | RuntimeException e) { + // Fall through to custom implementation + } + } + + return EmlProcessingUtils.decodeMimeHeader(headerValue.trim()); + } + + private static void initializeMimeUtilityDecoding() { + try { + Class mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility"); + mimeUtilityDecodeTextMethod = mimeUtilityClass.getMethod("decodeText", String.class); + } catch (ClassNotFoundException | NoSuchMethodException e) { + mimeUtilityDecodeTextMethod = null; + } + mimeUtilityChecked = true; + } + + @Data + public static class EmailContent { + private String subject; + private String from; + private String to; + private String cc; + private String bcc; + private Date date; + private String dateString; // For basic parsing fallback + private String htmlBody; + private String textBody; + private int attachmentCount; + private List attachments = new ArrayList<>(); + + public void setHtmlBody(String htmlBody) { + this.htmlBody = htmlBody != null ? htmlBody.replaceAll("\r", "") : null; + } + + public void setTextBody(String textBody) { + this.textBody = textBody != null ? textBody.replaceAll("\r", "") : null; + } + } + + @Data + public static class EmailAttachment { + private String filename; + private String contentType; + private byte[] data; + private boolean embedded; + private String embeddedFilename; + private long sizeBytes; + private String contentId; + private String disposition; + private String transferEncoding; + + public void setData(byte[] data) { + this.data = data; + if (data != null) { + this.sizeBytes = data.length; + } + } + } +} diff --git a/app/common/src/main/java/stirling/software/common/util/EmlProcessingUtils.java b/app/common/src/main/java/stirling/software/common/util/EmlProcessingUtils.java new file mode 100644 index 000000000..9acc30c16 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/EmlProcessingUtils.java @@ -0,0 +1,601 @@ +package stirling.software.common.util; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import lombok.experimental.UtilityClass; + +import stirling.software.common.model.api.converters.EmlToPdfRequest; +import stirling.software.common.model.api.converters.HTMLToPdfRequest; + +@UtilityClass +public class EmlProcessingUtils { + + // Style constants + private static final int DEFAULT_FONT_SIZE = 12; + private static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif"; + private static final float DEFAULT_LINE_HEIGHT = 1.4f; + private static final String DEFAULT_ZOOM = "1.0"; + private static final String DEFAULT_TEXT_COLOR = "#202124"; + private static final String DEFAULT_BACKGROUND_COLOR = "#ffffff"; + private static final String DEFAULT_BORDER_COLOR = "#e8eaed"; + private static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9"; + private static final String ATTACHMENT_BORDER_COLOR = "#eeeeee"; + + private static final int EML_CHECK_LENGTH = 8192; + private static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2; + + // MIME type detection + private static final Map EXTENSION_TO_MIME_TYPE = + Map.of( + ".png", "image/png", + ".jpg", "image/jpeg", + ".jpeg", "image/jpeg", + ".gif", "image/gif", + ".bmp", "image/bmp", + ".webp", "image/webp", + ".svg", "image/svg+xml", + ".ico", "image/x-icon", + ".tiff", "image/tiff", + ".tif", "image/tiff"); + + public static void validateEmlInput(byte[] emlBytes) { + if (emlBytes == null || emlBytes.length == 0) { + throw new IllegalArgumentException("EML file is empty or null"); + } + + if (isInvalidEmlFormat(emlBytes)) { + throw new IllegalArgumentException("Invalid EML file format"); + } + } + + private static boolean isInvalidEmlFormat(byte[] emlBytes) { + try { + int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH); + String content; + + try { + content = new String(emlBytes, 0, checkLength, StandardCharsets.UTF_8); + if (content.contains("\uFFFD")) { + content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1); + } + } catch (Exception e) { + content = new String(emlBytes, 0, checkLength, StandardCharsets.ISO_8859_1); + } + + String lowerContent = content.toLowerCase(Locale.ROOT); + + boolean hasFrom = + lowerContent.contains("from:") || lowerContent.contains("return-path:"); + boolean hasSubject = lowerContent.contains("subject:"); + boolean hasMessageId = lowerContent.contains("message-id:"); + boolean hasDate = lowerContent.contains("date:"); + boolean hasTo = + lowerContent.contains("to:") + || lowerContent.contains("cc:") + || lowerContent.contains("bcc:"); + boolean hasMimeStructure = + lowerContent.contains("multipart/") + || lowerContent.contains("text/plain") + || lowerContent.contains("text/html") + || lowerContent.contains("boundary="); + + int headerCount = 0; + if (hasFrom) headerCount++; + if (hasSubject) headerCount++; + if (hasMessageId) headerCount++; + if (hasDate) headerCount++; + if (hasTo) headerCount++; + + return headerCount < MIN_HEADER_COUNT_FOR_VALID_EML && !hasMimeStructure; + + } catch (RuntimeException e) { + return false; + } + } + + public static String generateEnhancedEmailHtml( + EmlParser.EmailContent content, + EmlToPdfRequest request, + CustomHtmlSanitizer customHtmlSanitizer) { + StringBuilder html = new StringBuilder(); + + html.append( + String.format( + """ + + + %s + + + """); + + html.append( + String.format( + """ + \n"); + return html.toString(); + } + + public static String processEmailHtmlBody( + String htmlBody, + EmlParser.EmailContent emailContent, + CustomHtmlSanitizer customHtmlSanitizer) { + if (htmlBody == null) return ""; + + String processed = + customHtmlSanitizer != null ? customHtmlSanitizer.sanitize(htmlBody) : htmlBody; + + processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*fixed[^;]*;?", ""); + processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*absolute[^;]*;?", ""); + + if (emailContent != null && !emailContent.getAttachments().isEmpty()) { + processed = PdfAttachmentHandler.processInlineImages(processed, emailContent); + } + + return processed; + } + + public static String convertTextToHtml( + String textBody, CustomHtmlSanitizer customHtmlSanitizer) { + if (textBody == null) return ""; + + String html = + customHtmlSanitizer != null + ? customHtmlSanitizer.sanitize(textBody) + : escapeHtml(textBody); + + html = html.replace("\r\n", "\n").replace("\r", "\n"); + html = html.replace("\n", "
\n"); + + html = + html.replaceAll( + "(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)", + "$1"); + + html = + html.replaceAll( + "([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63})", + "$1"); + + return html; + } + + private static void appendEnhancedStyles(StringBuilder html) { + String css = + String.format( + """ + body { + font-family: %s; + font-size: %dpx; + line-height: %s; + color: %s; + margin: 0; + padding: 16px; + background-color: %s; + } + + .email-container { + width: 100%%; + max-width: 100%%; + margin: 0 auto; + } + + .email-header { + padding-bottom: 10px; + border-bottom: 1px solid %s; + margin-bottom: 10px; + } + + .email-header h1 { + margin: 0 0 10px 0; + font-size: %dpx; + font-weight: bold; + } + + .email-meta div { + margin-bottom: 2px; + font-size: %dpx; + } + + .email-body { + word-wrap: break-word; + } + + .attachment-section { + margin-top: 15px; + padding: 10px; + background-color: %s; + border: 1px solid %s; + border-radius: 3px; + } + + .attachment-section h3 { + margin: 0 0 8px 0; + font-size: %dpx; + } + + .attachment-item { + padding: 5px 0; + } + + .attachment-icon { + margin-right: 5px; + } + + .attachment-details, .attachment-type { + font-size: %dpx; + color: #555555; + } + + .attachment-inclusion-note, .attachment-info-note { + margin-top: 8px; + padding: 6px; + font-size: %dpx; + border-radius: 3px; + } + + .attachment-inclusion-note { + background-color: #e6ffed; + border: 1px solid #d4f7dc; + color: #006420; + } + + .attachment-info-note { + background-color: #fff9e6; + border: 1px solid #fff0c2; + color: #664d00; + } + + .attachment-link-container { + display: flex; + align-items: center; + padding: 8px; + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + margin: 4px 0; + } + + .attachment-link-container:hover { + background-color: #e9ecef; + } + + .attachment-note { + font-size: %dpx; + color: #6c757d; + font-style: italic; + margin-left: 8px; + } + + .no-content { + padding: 20px; + text-align: center; + color: #666; + font-style: italic; + } + + .text-body { + white-space: pre-wrap; + } + + img { + max-width: 100%%; + height: auto; + display: block; + } + """, + DEFAULT_FONT_FAMILY, + DEFAULT_FONT_SIZE, + DEFAULT_LINE_HEIGHT, + DEFAULT_TEXT_COLOR, + DEFAULT_BACKGROUND_COLOR, + DEFAULT_BORDER_COLOR, + DEFAULT_FONT_SIZE + 4, + DEFAULT_FONT_SIZE - 1, + ATTACHMENT_BACKGROUND_COLOR, + ATTACHMENT_BORDER_COLOR, + DEFAULT_FONT_SIZE + 1, + DEFAULT_FONT_SIZE - 2, + DEFAULT_FONT_SIZE - 2, + DEFAULT_FONT_SIZE - 3); + + html.append(css); + } + + private static void appendAttachmentsSection( + StringBuilder html, + EmlParser.EmailContent content, + EmlToPdfRequest request, + CustomHtmlSanitizer customHtmlSanitizer) { + html.append("
\n"); + int displayedAttachmentCount = + content.getAttachmentCount() > 0 + ? content.getAttachmentCount() + : content.getAttachments().size(); + html.append("

Attachments (").append(displayedAttachmentCount).append(")

\n"); + + if (!content.getAttachments().isEmpty()) { + for (int i = 0; i < content.getAttachments().size(); i++) { + EmlParser.EmailAttachment attachment = content.getAttachments().get(i); + + String embeddedFilename = + attachment.getFilename() != null + ? attachment.getFilename() + : ("attachment_" + i); + attachment.setEmbeddedFilename(embeddedFilename); + + String sizeStr = GeneralUtils.formatBytes(attachment.getSizeBytes()); + String contentType = + attachment.getContentType() != null + && !attachment.getContentType().isEmpty() + ? ", " + escapeHtml(attachment.getContentType()) + : ""; + + String attachmentId = "attachment_" + i; + html.append( + String.format( + """ +
+ @ + %s + (%s%s) +
+ """, + attachmentId, + escapeHtml(embeddedFilename), + escapeHtml(EmlParser.safeMimeDecode(attachment.getFilename())), + sizeStr, + contentType)); + } + } + + if (request != null && request.isIncludeAttachments()) { + html.append( + """ +
+

Attachments are embedded in the file.

+
+ """); + } else { + html.append( + """ +
+

Attachment information displayed - files not included in PDF.

+
+ """); + } + html.append("
\n"); + } + + public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) { + HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest(); + + if (request != null) { + htmlRequest.setFileInput(request.getFileInput()); + } + + htmlRequest.setZoom(Float.parseFloat(DEFAULT_ZOOM)); + return htmlRequest; + } + + public static String detectMimeType(String filename, String existingMimeType) { + if (existingMimeType != null && !existingMimeType.isEmpty()) { + return existingMimeType; + } + + if (filename != null) { + String lowerFilename = filename.toLowerCase(); + for (Map.Entry entry : EXTENSION_TO_MIME_TYPE.entrySet()) { + if (lowerFilename.endsWith(entry.getKey())) { + return entry.getValue(); + } + } + } + + return "image/png"; + } + + public static String decodeUrlEncoded(String encoded) { + try { + return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8); + } catch (Exception e) { + return encoded; // Return original if decoding fails + } + } + + public static String decodeMimeHeader(String encodedText) { + if (encodedText == null || encodedText.trim().isEmpty()) { + return encodedText; + } + + try { + StringBuilder result = new StringBuilder(); + Pattern concatenatedPattern = + Pattern.compile( + "(=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)(\\s*=\\?[^?]+\\?[BbQq]\\?[^?]*\\?=)+"); + Matcher concatenatedMatcher = concatenatedPattern.matcher(encodedText); + String processedText = + concatenatedMatcher.replaceAll( + match -> match.group().replaceAll("\\s+(?==\\?)", "")); + + Pattern mimePattern = Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?="); + Matcher matcher = mimePattern.matcher(processedText); + int lastEnd = 0; + + while (matcher.find()) { + result.append(processedText, lastEnd, matcher.start()); + + String charset = matcher.group(1); + String encoding = matcher.group(2).toUpperCase(); + String encodedValue = matcher.group(3); + + try { + String decodedValue = + switch (encoding) { + case "B" -> { + String cleanBase64 = encodedValue.replaceAll("\\s", ""); + byte[] decodedBytes = Base64.getDecoder().decode(cleanBase64); + Charset targetCharset; + try { + targetCharset = Charset.forName(charset); + } catch (Exception e) { + targetCharset = StandardCharsets.UTF_8; + } + yield new String(decodedBytes, targetCharset); + } + case "Q" -> decodeQuotedPrintable(encodedValue, charset); + default -> matcher.group(0); // Return original if unknown encoding + }; + result.append(decodedValue); + } catch (RuntimeException e) { + result.append(matcher.group(0)); // Keep original on decode error + } + + lastEnd = matcher.end(); + } + + result.append(processedText.substring(lastEnd)); + return result.toString(); + } catch (Exception e) { + return encodedText; // Return original on any parsing error + } + } + + private static String decodeQuotedPrintable(String encodedText, String charset) { + StringBuilder result = new StringBuilder(); + for (int i = 0; i < encodedText.length(); i++) { + char c = encodedText.charAt(i); + switch (c) { + case '=' -> { + if (i + 2 < encodedText.length()) { + String hex = encodedText.substring(i + 1, i + 3); + try { + int value = Integer.parseInt(hex, 16); + result.append((char) value); + i += 2; + } catch (NumberFormatException e) { + result.append(c); + } + } else if (i + 1 == encodedText.length() + || (i + 2 == encodedText.length() + && encodedText.charAt(i + 1) == '\n')) { + if (i + 1 < encodedText.length() && encodedText.charAt(i + 1) == '\n') { + i++; // Skip the newline too + } + } else { + result.append(c); + } + } + case '_' -> result.append(' '); // Space encoding in Q encoding + default -> result.append(c); + } + } + + byte[] bytes = result.toString().getBytes(StandardCharsets.ISO_8859_1); + try { + Charset targetCharset = Charset.forName(charset); + return new String(bytes, targetCharset); + } catch (Exception e) { + try { + return new String(bytes, StandardCharsets.UTF_8); + } catch (Exception fallbackException) { + return new String(bytes, StandardCharsets.ISO_8859_1); + } + } + } + + public static String escapeHtml(String text) { + if (text == null) return ""; + return text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'"); + } + + public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) { + if (customHtmlSanitizer != null) { + return customHtmlSanitizer.sanitize(text); + } else { + return escapeHtml(text); + } + } + + public static String simplifyHtmlContent(String htmlContent) { + String simplified = htmlContent.replaceAll("(?i)]*>.*?", ""); + simplified = simplified.replaceAll("(?i)]*>.*?", ""); + return simplified; + } +} diff --git a/app/common/src/main/java/stirling/software/common/util/EmlToPdf.java b/app/common/src/main/java/stirling/software/common/util/EmlToPdf.java index 05e9cec5c..85005af40 100644 --- a/app/common/src/main/java/stirling/software/common/util/EmlToPdf.java +++ b/app/common/src/main/java/stirling/software/common/util/EmlToPdf.java @@ -1,131 +1,23 @@ 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; -import java.io.InputStream; -import java.lang.reflect.Constructor; -import java.lang.reflect.Method; -import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Date; -import java.util.GregorianCalendar; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Properties; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import org.apache.pdfbox.pdmodel.PDDocument; -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; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; -import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import lombok.Data; -import lombok.Getter; 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; - static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif"; - static final float DEFAULT_LINE_HEIGHT = 1.4f; - static final String DEFAULT_ZOOM = "1.0"; - - // Color constants - aligned with application theme - static final String DEFAULT_TEXT_COLOR = "#202124"; - static final String DEFAULT_BACKGROUND_COLOR = "#ffffff"; - static final String DEFAULT_BORDER_COLOR = "#e8eaed"; - static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9"; - static final String ATTACHMENT_BORDER_COLOR = "#eeeeee"; - - // Size constants for PDF annotations - static final float ATTACHMENT_ICON_WIDTH = 12f; - static final float ATTACHMENT_ICON_HEIGHT = 14f; - static final float ANNOTATION_X_OFFSET = 2f; - static final float ANNOTATION_Y_OFFSET = 10f; - - // Content validation constants - static final int EML_CHECK_LENGTH = 8192; - static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2; - - private StyleConstants() {} - } - - private static final class MimeConstants { - static final Pattern MIME_ENCODED_PATTERN = - Pattern.compile("=\\?([^?]+)\\?([BbQq])\\?([^?]*)\\?="); - static final String ATTACHMENT_MARKER = "@"; - - private MimeConstants() {} - } - - private static final class FileSizeConstants { - static final long BYTES_IN_KB = 1024L; - static final long BYTES_IN_MB = BYTES_IN_KB * 1024L; - static final long BYTES_IN_GB = BYTES_IN_MB * 1024L; - - private FileSizeConstants() {} - } - - // Cached Jakarta Mail availability check - private static Boolean jakartaMailAvailable = null; - - private static boolean isJakartaMailAvailable() { - if (jakartaMailAvailable == null) { - try { - // Check for core Jakarta Mail classes - Class.forName("jakarta.mail.internet.MimeMessage"); - Class.forName("jakarta.mail.Session"); - Class.forName("jakarta.mail.internet.MimeUtility"); - Class.forName("jakarta.mail.internet.MimePart"); - Class.forName("jakarta.mail.internet.MimeMultipart"); - Class.forName("jakarta.mail.Multipart"); - Class.forName("jakarta.mail.Part"); - - jakartaMailAvailable = true; - log.debug("Jakarta Mail libraries are available"); - } catch (ClassNotFoundException e) { - jakartaMailAvailable = false; - log.debug("Jakarta Mail libraries are not available, using basic parsing"); - } - } - return jakartaMailAvailable; - } - public static String convertEmlToHtml(byte[] emlBytes, EmlToPdfRequest request) throws IOException { - validateEmlInput(emlBytes); + EmlProcessingUtils.validateEmlInput(emlBytes); - if (isJakartaMailAvailable()) { - return convertEmlToHtmlAdvanced(emlBytes, request); - } else { - return convertEmlToHtmlBasic(emlBytes, request); - } + EmlParser.EmailContent emailContent = + EmlParser.extractEmailContent(emlBytes, request, null); + return EmlProcessingUtils.generateEnhancedEmailHtml(emailContent, request, null); } public static byte[] convertEmlToPdf( @@ -133,59 +25,46 @@ public class EmlToPdf { EmlToPdfRequest request, byte[] emlBytes, String fileName, - boolean disableSanitize, - stirling.software.common.service.CustomPDFDocumentFactory pdfDocumentFactory, - TempFileManager tempFileManager) + CustomPDFDocumentFactory pdfDocumentFactory, + TempFileManager tempFileManager, + CustomHtmlSanitizer customHtmlSanitizer) throws IOException, InterruptedException { - validateEmlInput(emlBytes); + EmlProcessingUtils.validateEmlInput(emlBytes); try { - // Generate HTML representation - EmailContent emailContent = null; - String htmlContent; + EmlParser.EmailContent emailContent = + EmlParser.extractEmailContent(emlBytes, request, customHtmlSanitizer); - if (isJakartaMailAvailable()) { - emailContent = extractEmailContentAdvanced(emlBytes, request); - htmlContent = generateEnhancedEmailHtml(emailContent, request); - } else { - htmlContent = convertEmlToHtmlBasic(emlBytes, request); - } + String htmlContent = + EmlProcessingUtils.generateEnhancedEmailHtml( + emailContent, request, customHtmlSanitizer); - // Convert HTML to PDF byte[] pdfBytes = convertHtmlToPdf( - weasyprintPath, request, htmlContent, disableSanitize, tempFileManager); + weasyprintPath, + request, + htmlContent, + tempFileManager, + customHtmlSanitizer); - // Attach files if available and requested if (shouldAttachFiles(emailContent, request)) { pdfBytes = - attachFilesToPdf( + PdfAttachmentHandler.attachFilesToPdf( pdfBytes, emailContent.getAttachments(), pdfDocumentFactory); } return pdfBytes; } catch (IOException | InterruptedException e) { - log.error("Failed to convert EML to PDF for file: {}", fileName, e); throw e; } catch (Exception e) { - log.error("Unexpected error during EML to PDF conversion for file: {}", fileName, e); - throw new IOException("Conversion failed: " + e.getMessage(), e); + throw new IOException("Error converting EML to PDF", e); } } - private static void validateEmlInput(byte[] emlBytes) { - if (emlBytes == null || emlBytes.length == 0) { - throw new IllegalArgumentException("EML file is empty or null"); - } - - if (isInvalidEmlFormat(emlBytes)) { - throw new IllegalArgumentException("Invalid EML file format"); - } - } - - private static boolean shouldAttachFiles(EmailContent emailContent, EmlToPdfRequest request) { + private static boolean shouldAttachFiles( + EmlParser.EmailContent emailContent, EmlToPdfRequest request) { return emailContent != null && request != null && request.isIncludeAttachments() @@ -196,11 +75,11 @@ public class EmlToPdf { String weasyprintPath, EmlToPdfRequest request, String htmlContent, - boolean disableSanitize, - TempFileManager tempFileManager) + TempFileManager tempFileManager, + CustomHtmlSanitizer customHtmlSanitizer) throws IOException, InterruptedException { - HTMLToPdfRequest htmlRequest = createHtmlRequest(request); + var htmlRequest = EmlProcessingUtils.createHtmlRequest(request); try { return FileToPdf.convertHtmlToPdf( @@ -208,1513 +87,17 @@ public class EmlToPdf { htmlRequest, htmlContent.getBytes(StandardCharsets.UTF_8), "email.html", - disableSanitize, - tempFileManager); + tempFileManager, + customHtmlSanitizer); } catch (IOException | InterruptedException e) { - log.warn("Initial HTML to PDF conversion failed, trying with simplified HTML"); - String simplifiedHtml = simplifyHtmlContent(htmlContent); + String simplifiedHtml = EmlProcessingUtils.simplifyHtmlContent(htmlContent); return FileToPdf.convertHtmlToPdf( weasyprintPath, htmlRequest, simplifiedHtml.getBytes(StandardCharsets.UTF_8), "email.html", - disableSanitize, - tempFileManager); - } - } - - private static String simplifyHtmlContent(String htmlContent) { - String simplified = htmlContent.replaceAll("(?i)]*>.*?", ""); - simplified = simplified.replaceAll("(?i)]*>.*?", ""); - return simplified; - } - - private static String generateUniqueAttachmentId(String filename) { - return "attachment_" + filename.hashCode() + "_" + System.nanoTime(); - } - - private static String convertEmlToHtmlBasic(byte[] emlBytes, EmlToPdfRequest request) { - if (emlBytes == null || emlBytes.length == 0) { - throw new IllegalArgumentException("EML file is empty or null"); - } - - String emlContent = new String(emlBytes, StandardCharsets.UTF_8); - - // Basic email parsing - String subject = extractBasicHeader(emlContent, "Subject:"); - String from = extractBasicHeader(emlContent, "From:"); - String to = extractBasicHeader(emlContent, "To:"); - String cc = extractBasicHeader(emlContent, "Cc:"); - String bcc = extractBasicHeader(emlContent, "Bcc:"); - String date = extractBasicHeader(emlContent, "Date:"); - - // Try to extract HTML content - String htmlBody = extractHtmlBody(emlContent); - if (htmlBody == null) { - String textBody = extractTextBody(emlContent); - htmlBody = - convertTextToHtml( - textBody != null ? textBody : "Email content could not be parsed"); - } - - // Generate HTML with custom styling based on request - StringBuilder html = new StringBuilder(); - html.append("\n"); - html.append("\n"); - html.append("").append(escapeHtml(subject)).append("\n"); - html.append("\n"); - html.append("\n"); - - html.append("
\n"); - html.append("
\n"); - html.append("

").append(escapeHtml(subject)).append("

\n"); - html.append("
\n"); - html.append("
From: ").append(escapeHtml(from)).append("
\n"); - html.append("
To: ").append(escapeHtml(to)).append("
\n"); - - // Include CC and BCC if present and requested - if (request != null && request.isIncludeAllRecipients()) { - if (!cc.trim().isEmpty()) { - html.append("
CC: ").append(escapeHtml(cc)).append("
\n"); - } - if (!bcc.trim().isEmpty()) { - html.append("
BCC: ") - .append(escapeHtml(bcc)) - .append("
\n"); - } - } - - if (!date.trim().isEmpty()) { - html.append("
Date: ").append(escapeHtml(date)).append("
\n"); - } - html.append("
\n"); - - html.append("
\n"); - html.append(processEmailHtmlBody(htmlBody)); - html.append("
\n"); - - // Add attachment information - always check for and display attachments - String attachmentInfo = extractAttachmentInfo(emlContent); - if (!attachmentInfo.isEmpty()) { - html.append("
\n"); - html.append("

Attachments

\n"); - html.append(attachmentInfo); - - // Add a status message about attachment inclusion - if (request != null && request.isIncludeAttachments()) { - html.append("
\n"); - html.append( - "

Note: Attachments are saved as external files and linked in this PDF. Click the links to open files externally.

\n"); - html.append("
\n"); - } else { - html.append("
\n"); - html.append( - "

Attachment information displayed - files not included in PDF. Enable 'Include attachments' to embed files.

\n"); - html.append("
\n"); - } - - html.append("
\n"); - } - - // Show advanced features status if requested - assert request != null; - if (request.getFileInput().isEmpty()) { - html.append("
\n"); - html.append( - "

Note: Some advanced features require Jakarta Mail dependencies.

\n"); - html.append("
\n"); - } - - html.append("
\n"); - html.append(""); - - return html.toString(); - } - - private static EmailContent extractEmailContentAdvanced( - byte[] emlBytes, EmlToPdfRequest request) { - try { - // Use Jakarta Mail for processing - Class sessionClass = Class.forName("jakarta.mail.Session"); - Class mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage"); - - Method getDefaultInstance = - sessionClass.getMethod("getDefaultInstance", Properties.class); - Object session = getDefaultInstance.invoke(null, new Properties()); - - // Cast the session object to the proper type for the constructor - Class[] constructorArgs = new Class[] {sessionClass, InputStream.class}; - Constructor mimeMessageConstructor = - mimeMessageClass.getConstructor(constructorArgs); - Object message = - mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes)); - - return extractEmailContentAdvanced(message, request); - - } catch (ReflectiveOperationException e) { - // Create basic EmailContent from basic processing - EmailContent content = new EmailContent(); - content.setHtmlBody(convertEmlToHtmlBasic(emlBytes, request)); - return content; - } - } - - private static String convertEmlToHtmlAdvanced(byte[] emlBytes, EmlToPdfRequest request) { - EmailContent content = extractEmailContentAdvanced(emlBytes, request); - return generateEnhancedEmailHtml(content, request); - } - - private static String extractAttachmentInfo(String emlContent) { - StringBuilder attachmentInfo = new StringBuilder(); - try { - String[] lines = emlContent.split("\r?\n"); - boolean inHeaders = true; - String currentContentType = ""; - String currentDisposition = ""; - String currentFilename = ""; - String currentEncoding = ""; - boolean inMultipart = false; - String boundary = ""; - - // First pass: find boundary for multipart messages - for (String line : lines) { - String lowerLine = line.toLowerCase().trim(); - if (lowerLine.startsWith("content-type:") && lowerLine.contains("multipart")) { - if (lowerLine.contains("boundary=")) { - int boundaryStart = lowerLine.indexOf("boundary=") + 9; - String boundaryPart = line.substring(boundaryStart).trim(); - if (boundaryPart.startsWith("\"")) { - boundary = boundaryPart.substring(1, boundaryPart.indexOf("\"", 1)); - } else { - int spaceIndex = boundaryPart.indexOf(" "); - boundary = - spaceIndex > 0 - ? boundaryPart.substring(0, spaceIndex) - : boundaryPart; - } - inMultipart = true; - break; - } - } - if (line.trim().isEmpty()) break; - } - - // Second pass: extract attachment information - for (String line : lines) { - String lowerLine = line.toLowerCase().trim(); - - // Check for boundary markers in multipart messages - if (inMultipart && line.trim().startsWith("--" + boundary)) { - // Reset for new part - currentContentType = ""; - currentDisposition = ""; - currentFilename = ""; - currentEncoding = ""; - inHeaders = true; - continue; - } - - if (inHeaders && line.trim().isEmpty()) { - inHeaders = false; - - // Process accumulated attachment info - if (isAttachment(currentDisposition, currentFilename, currentContentType)) { - addAttachmentToInfo( - attachmentInfo, - currentFilename, - currentContentType, - currentEncoding); - - // Reset for next attachment - currentContentType = ""; - currentDisposition = ""; - currentFilename = ""; - currentEncoding = ""; - } - continue; - } - - if (!inHeaders) continue; // Skip body content - - // Parse headers - if (lowerLine.startsWith("content-type:")) { - currentContentType = line.substring(13).trim(); - } else if (lowerLine.startsWith("content-disposition:")) { - currentDisposition = line.substring(20).trim(); - // Extract filename if present - currentFilename = extractFilenameFromDisposition(currentDisposition); - } else if (lowerLine.startsWith("content-transfer-encoding:")) { - currentEncoding = line.substring(26).trim(); - } else if (line.startsWith(" ") || line.startsWith("\t")) { - // Continuation of previous header - if (currentDisposition.contains("filename=")) { - currentDisposition += " " + line.trim(); - currentFilename = extractFilenameFromDisposition(currentDisposition); - } else if (!currentContentType.isEmpty()) { - currentContentType += " " + line.trim(); - } - } - } - - if (isAttachment(currentDisposition, currentFilename, currentContentType)) { - addAttachmentToInfo( - attachmentInfo, currentFilename, currentContentType, currentEncoding); - } - - } catch (RuntimeException e) { - log.warn("Error extracting attachment info: {}", e.getMessage()); - } - return attachmentInfo.toString(); - } - - private static boolean isAttachment(String disposition, String filename, String contentType) { - return (disposition.toLowerCase().contains("attachment") && !filename.isEmpty()) - || (!filename.isEmpty() && !contentType.toLowerCase().startsWith("text/")) - || (contentType.toLowerCase().contains("application/") && !filename.isEmpty()); - } - - private static String extractFilenameFromDisposition(String disposition) { - if (disposition.contains("filename=")) { - int filenameStart = disposition.toLowerCase().indexOf("filename=") + 9; - int filenameEnd = disposition.indexOf(";", filenameStart); - if (filenameEnd == -1) filenameEnd = disposition.length(); - String filename = disposition.substring(filenameStart, filenameEnd).trim(); - filename = filename.replaceAll("^\"|\"$", ""); - // Apply MIME decoding to handle encoded filenames - return safeMimeDecode(filename); - } - return ""; - } - - private static void addAttachmentToInfo( - StringBuilder attachmentInfo, String filename, String contentType, String encoding) { - // Create attachment info with paperclip emoji before filename - attachmentInfo - .append("
") - .append("") - .append(MimeConstants.ATTACHMENT_MARKER) - .append(" ") - .append("") - .append(escapeHtml(filename)) - .append(""); - - // Add content type and encoding info - if (!contentType.isEmpty() || !encoding.isEmpty()) { - attachmentInfo.append(" ("); - if (!contentType.isEmpty()) { - attachmentInfo.append(escapeHtml(contentType)); - } - if (!encoding.isEmpty()) { - if (!contentType.isEmpty()) attachmentInfo.append(", "); - attachmentInfo.append("encoding: ").append(escapeHtml(encoding)); - } - attachmentInfo.append(")"); - } - attachmentInfo.append("
\n"); - } - - private static boolean isInvalidEmlFormat(byte[] emlBytes) { - try { - int checkLength = Math.min(emlBytes.length, StyleConstants.EML_CHECK_LENGTH); - String content = new String(emlBytes, 0, checkLength, StandardCharsets.UTF_8); - String lowerContent = content.toLowerCase(); - - boolean hasFrom = - lowerContent.contains("from:") || lowerContent.contains("return-path:"); - boolean hasSubject = lowerContent.contains("subject:"); - boolean hasMessageId = lowerContent.contains("message-id:"); - boolean hasDate = lowerContent.contains("date:"); - boolean hasTo = - lowerContent.contains("to:") - || lowerContent.contains("cc:") - || lowerContent.contains("bcc:"); - boolean hasMimeStructure = - lowerContent.contains("multipart/") - || lowerContent.contains("text/plain") - || lowerContent.contains("text/html") - || lowerContent.contains("boundary="); - - int headerCount = 0; - if (hasFrom) headerCount++; - if (hasSubject) headerCount++; - if (hasMessageId) headerCount++; - if (hasDate) headerCount++; - if (hasTo) headerCount++; - - return headerCount < StyleConstants.MIN_HEADER_COUNT_FOR_VALID_EML && !hasMimeStructure; - - } catch (RuntimeException e) { - return false; - } - } - - private static String extractBasicHeader(String emlContent, String headerName) { - try { - String[] lines = emlContent.split("\r?\n"); - for (int i = 0; i < lines.length; i++) { - String line = lines[i]; - if (line.toLowerCase().startsWith(headerName.toLowerCase())) { - StringBuilder value = - new StringBuilder(line.substring(headerName.length()).trim()); - // Handle multi-line headers - for (int j = i + 1; j < lines.length; j++) { - if (lines[j].startsWith(" ") || lines[j].startsWith("\t")) { - value.append(" ").append(lines[j].trim()); - } else { - break; - } - } - // Apply MIME header decoding - return safeMimeDecode(value.toString()); - } - if (line.trim().isEmpty()) break; - } - } catch (RuntimeException e) { - log.warn("Error extracting header '{}': {}", headerName, e.getMessage()); - } - return ""; - } - - private static String extractHtmlBody(String emlContent) { - try { - String lowerContent = emlContent.toLowerCase(); - int htmlStart = lowerContent.indexOf("content-type: text/html"); - if (htmlStart == -1) return null; - - return getString(emlContent, htmlStart); - - } catch (Exception e) { - return null; - } - } - - @Nullable - private static String getString(String emlContent, int htmlStart) { - int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart); - if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart); - if (bodyStart == -1) return null; - - bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2; - int bodyEnd = findPartEnd(emlContent, bodyStart); - - return emlContent.substring(bodyStart, bodyEnd).trim(); - } - - private static String extractTextBody(String emlContent) { - try { - String lowerContent = emlContent.toLowerCase(); - int textStart = lowerContent.indexOf("content-type: text/plain"); - if (textStart == -1) { - int bodyStart = emlContent.indexOf("\r\n\r\n"); - if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n"); - if (bodyStart != -1) { - bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2; - int bodyEnd = findPartEnd(emlContent, bodyStart); - return emlContent.substring(bodyStart, bodyEnd).trim(); - } - return null; - } - - return getString(emlContent, textStart); - - } catch (RuntimeException e) { - return null; - } - } - - private static int findPartEnd(String content, int start) { - String[] lines = content.substring(start).split("\r?\n"); - StringBuilder result = new StringBuilder(); - - for (String line : lines) { - if (line.startsWith("--") && line.length() > 10) break; - result.append(line).append("\n"); - } - - return start + result.length(); - } - - private static String convertTextToHtml(String textBody) { - if (textBody == null) return ""; - - String html = escapeHtml(textBody); - html = html.replace("\r\n", "\n").replace("\r", "\n"); - html = html.replace("\n", "
\n"); - - html = - html.replaceAll( - "(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)", - "$1"); - - html = - html.replaceAll( - "([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,63})", - "$1"); - - return html; - } - - private static String processEmailHtmlBody(String htmlBody) { - return processEmailHtmlBody(htmlBody, null); - } - - private static String processEmailHtmlBody(String htmlBody, EmailContent emailContent) { - if (htmlBody == null) return ""; - - String processed = htmlBody; - - // Remove problematic CSS - processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*fixed[^;]*;?", ""); - processed = processed.replaceAll("(?i)\\s*position\\s*:\\s*absolute[^;]*;?", ""); - - // Process inline images (cid: references) if we have email content with attachments - if (emailContent != null && !emailContent.getAttachments().isEmpty()) { - processed = processInlineImages(processed, emailContent); - } - - return processed; - } - - private static String processInlineImages(String htmlContent, EmailContent emailContent) { - if (htmlContent == null || emailContent == null) return htmlContent; - - // Create a map of Content-ID to attachment data - Map contentIdMap = new HashMap<>(); - for (EmailAttachment attachment : emailContent.getAttachments()) { - if (attachment.isEmbedded() - && attachment.getContentId() != null - && attachment.getData() != null) { - contentIdMap.put(attachment.getContentId(), attachment); - } - } - - if (contentIdMap.isEmpty()) return htmlContent; - - // Pattern to match cid: references in img src attributes - Pattern cidPattern = - Pattern.compile( - "(?i)]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>", - Pattern.CASE_INSENSITIVE); - Matcher matcher = cidPattern.matcher(htmlContent); - - StringBuffer result = new StringBuffer(); - while (matcher.find()) { - String contentId = matcher.group(1); - EmailAttachment attachment = contentIdMap.get(contentId); - - if (attachment != null && attachment.getData() != null) { - // Convert to data URI - String mimeType = attachment.getContentType(); - if (mimeType == null || mimeType.isEmpty()) { - // Try to determine MIME type from filename - String filename = attachment.getFilename(); - if (filename != null) { - if (filename.toLowerCase().endsWith(".png")) { - mimeType = "image/png"; - } else if (filename.toLowerCase().endsWith(".jpg") - || filename.toLowerCase().endsWith(".jpeg")) { - mimeType = "image/jpeg"; - } else if (filename.toLowerCase().endsWith(".gif")) { - mimeType = "image/gif"; - } else if (filename.toLowerCase().endsWith(".bmp")) { - mimeType = "image/bmp"; - } else { - mimeType = "image/png"; // fallback - } - } else { - mimeType = "image/png"; // fallback - } - } - - String base64Data = Base64.getEncoder().encodeToString(attachment.getData()); - String dataUri = "data:" + mimeType + ";base64," + base64Data; - - // Replace the cid: reference with the data URI - String replacement = - matcher.group(0).replaceFirst("cid:" + Pattern.quote(contentId), dataUri); - matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); - } else { - // Keep original if attachment not found - matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group(0))); - } - } - matcher.appendTail(result); - - return result.toString(); - } - - private static void appendEnhancedStyles(StringBuilder html) { - int fontSize = StyleConstants.DEFAULT_FONT_SIZE; - String textColor = StyleConstants.DEFAULT_TEXT_COLOR; - String backgroundColor = StyleConstants.DEFAULT_BACKGROUND_COLOR; - String borderColor = StyleConstants.DEFAULT_BORDER_COLOR; - - html.append("body {\n"); - html.append(" font-family: ").append(StyleConstants.DEFAULT_FONT_FAMILY).append(";\n"); - html.append(" font-size: ").append(fontSize).append("px;\n"); - html.append(" line-height: ").append(StyleConstants.DEFAULT_LINE_HEIGHT).append(";\n"); - html.append(" color: ").append(textColor).append(";\n"); - html.append(" margin: 0;\n"); - html.append(" padding: 16px;\n"); - html.append(" background-color: ").append(backgroundColor).append(";\n"); - html.append("}\n\n"); - - html.append(".email-container {\n"); - html.append(" width: 100%;\n"); - html.append(" max-width: 100%;\n"); - html.append(" margin: 0 auto;\n"); - html.append("}\n\n"); - - html.append(".email-header {\n"); - html.append(" padding-bottom: 10px;\n"); - html.append(" border-bottom: 1px solid ").append(borderColor).append(";\n"); - html.append(" margin-bottom: 10px;\n"); - html.append("}\n\n"); - html.append(".email-header h1 {\n"); - html.append(" margin: 0 0 10px 0;\n"); - html.append(" font-size: ").append(fontSize + 4).append("px;\n"); - html.append(" font-weight: bold;\n"); - html.append("}\n\n"); - html.append(".email-meta div {\n"); - html.append(" margin-bottom: 2px;\n"); - html.append(" font-size: ").append(fontSize - 1).append("px;\n"); - html.append("}\n\n"); - - html.append(".email-body {\n"); - html.append(" word-wrap: break-word;\n"); - html.append("}\n\n"); - - html.append(".attachment-section {\n"); - html.append(" margin-top: 15px;\n"); - html.append(" padding: 10px;\n"); - html.append(" background-color: ") - .append(StyleConstants.ATTACHMENT_BACKGROUND_COLOR) - .append(";\n"); - html.append(" border: 1px solid ") - .append(StyleConstants.ATTACHMENT_BORDER_COLOR) - .append(";\n"); - html.append(" border-radius: 3px;\n"); - html.append("}\n\n"); - html.append(".attachment-section h3 {\n"); - html.append(" margin: 0 0 8px 0;\n"); - html.append(" font-size: ").append(fontSize + 1).append("px;\n"); - html.append("}\n\n"); - html.append(".attachment-item {\n"); - html.append(" padding: 5px 0;\n"); - html.append("}\n\n"); - html.append(".attachment-icon {\n"); - html.append(" margin-right: 5px;\n"); - html.append("}\n\n"); - html.append(".attachment-details, .attachment-type {\n"); - html.append(" font-size: ").append(fontSize - 2).append("px;\n"); - html.append(" color: #555555;\n"); - html.append("}\n\n"); - html.append(".attachment-inclusion-note, .attachment-info-note {\n"); - html.append(" margin-top: 8px;\n"); - html.append(" padding: 6px;\n"); - html.append(" font-size: ").append(fontSize - 2).append("px;\n"); - html.append(" border-radius: 3px;\n"); - html.append("}\n\n"); - html.append(".attachment-inclusion-note {\n"); - html.append(" background-color: #e6ffed;\n"); - html.append(" border: 1px solid #d4f7dc;\n"); - html.append(" color: #006420;\n"); - html.append("}\n\n"); - html.append(".attachment-info-note {\n"); - html.append(" background-color: #fff9e6;\n"); - html.append(" border: 1px solid #fff0c2;\n"); - html.append(" color: #664d00;\n"); - html.append("}\n\n"); - html.append(".attachment-link-container {\n"); - html.append(" display: flex;\n"); - html.append(" align-items: center;\n"); - html.append(" padding: 8px;\n"); - html.append(" background-color: #f8f9fa;\n"); - html.append(" border: 1px solid #dee2e6;\n"); - html.append(" border-radius: 4px;\n"); - html.append(" margin: 4px 0;\n"); - html.append("}\n\n"); - html.append(".attachment-link-container:hover {\n"); - html.append(" background-color: #e9ecef;\n"); - html.append("}\n\n"); - html.append(".attachment-note {\n"); - html.append(" font-size: ").append(fontSize - 3).append("px;\n"); - html.append(" color: #6c757d;\n"); - html.append(" font-style: italic;\n"); - html.append(" margin-left: 8px;\n"); - html.append("}\n\n"); - - // Basic image styling: ensure images are responsive but not overly constrained. - html.append("img {\n"); - html.append(" max-width: 100%;\n"); // Make images responsive to container width - html.append(" height: auto;\n"); // Maintain aspect ratio - html.append(" display: block;\n"); // Avoid extra space below images - html.append("}\n\n"); - } - - private static String escapeHtml(String text) { - if (text == null) return ""; - return text.replace("&", "&") - .replace("<", "<") - .replace(">", ">") - .replace("\"", """) - .replace("'", "'"); - } - - private static stirling.software.common.model.api.converters.HTMLToPdfRequest createHtmlRequest( - EmlToPdfRequest request) { - stirling.software.common.model.api.converters.HTMLToPdfRequest htmlRequest = - new stirling.software.common.model.api.converters.HTMLToPdfRequest(); - - if (request != null) { - htmlRequest.setFileInput(request.getFileInput()); - } - - // Set default zoom level - htmlRequest.setZoom(Float.parseFloat(StyleConstants.DEFAULT_ZOOM)); - - return htmlRequest; - } - - private static EmailContent extractEmailContentAdvanced( - Object message, EmlToPdfRequest request) { - EmailContent content = new EmailContent(); - - try { - Class messageClass = message.getClass(); - - // Extract headers via reflection - Method getSubject = messageClass.getMethod("getSubject"); - String subject = (String) getSubject.invoke(message); - content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject"); - - Method getFrom = messageClass.getMethod("getFrom"); - Object[] fromAddresses = (Object[]) getFrom.invoke(message); - content.setFrom( - fromAddresses != null && fromAddresses.length > 0 - ? safeMimeDecode(fromAddresses[0].toString()) - : ""); - - Method getAllRecipients = messageClass.getMethod("getAllRecipients"); - Object[] recipients = (Object[]) getAllRecipients.invoke(message); - content.setTo( - recipients != null && recipients.length > 0 - ? safeMimeDecode(recipients[0].toString()) - : ""); - - Method getSentDate = messageClass.getMethod("getSentDate"); - content.setDate((Date) getSentDate.invoke(message)); - - // Extract content - Method getContent = messageClass.getMethod("getContent"); - Object messageContent = getContent.invoke(message); - - if (messageContent instanceof String stringContent) { - Method getContentType = messageClass.getMethod("getContentType"); - String contentType = (String) getContentType.invoke(message); - if (contentType != null && contentType.toLowerCase().contains("text/html")) { - content.setHtmlBody(stringContent); - } else { - content.setTextBody(stringContent); - } - } else { - // Handle multipart content - try { - Class multipartClass = Class.forName("jakarta.mail.Multipart"); - if (multipartClass.isInstance(messageContent)) { - processMultipartAdvanced(messageContent, content, request); - } - } catch (Exception e) { - log.warn("Error processing content: {}", e.getMessage()); - } - } - - } catch (Exception e) { - content.setSubject("Email Conversion"); - content.setFrom("Unknown"); - content.setTo("Unknown"); - content.setTextBody("Email content could not be parsed with advanced processing"); - } - - return content; - } - - private static void processMultipartAdvanced( - Object multipart, EmailContent content, EmlToPdfRequest request) { - try { - // Enhanced multipart type checking - if (!isValidJakartaMailMultipart(multipart)) { - log.warn("Invalid Jakarta Mail multipart type: {}", multipart.getClass().getName()); - return; - } - - Class multipartClass = multipart.getClass(); - Method getCount = multipartClass.getMethod("getCount"); - int count = (Integer) getCount.invoke(multipart); - - Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class); - - for (int i = 0; i < count; i++) { - Object part = getBodyPart.invoke(multipart, i); - processPartAdvanced(part, content, request); - } - - } catch (Exception e) { - content.setTextBody("Email content could not be parsed with advanced processing"); - } - } - - private static void processPartAdvanced( - Object part, EmailContent content, EmlToPdfRequest request) { - try { - if (!isValidJakartaMailPart(part)) { - log.warn("Invalid Jakarta Mail part type: {}", part.getClass().getName()); - return; - } - - Class partClass = part.getClass(); - 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); - String contentType = (String) getContentType.invoke(part); - - if ((Boolean) isMimeType.invoke(part, "text/plain") && disposition == null) { - content.setTextBody((String) getContent.invoke(part)); - } else if ((Boolean) isMimeType.invoke(part, "text/html") && disposition == null) { - content.setHtmlBody((String) getContent.invoke(part)); - } else if ("attachment".equalsIgnoreCase((String) disposition) - || (filename != null && !filename.trim().isEmpty())) { - - content.setAttachmentCount(content.getAttachmentCount() + 1); - - // Always extract basic attachment metadata for display - if (filename != null && !filename.trim().isEmpty()) { - // Create attachment with metadata only - EmailAttachment attachment = new EmailAttachment(); - // Apply MIME decoding to filename to handle encoded attachment names - attachment.setFilename(safeMimeDecode(filename)); - attachment.setContentType(contentType); - - // Check if it's an embedded image - String[] contentIdHeaders = (String[]) getHeader.invoke(part, "Content-ID"); - if (contentIdHeaders != null && contentIdHeaders.length > 0) { - attachment.setEmbedded(true); - // Store the Content-ID, removing angle brackets if present - String contentId = contentIdHeaders[0]; - if (contentId.startsWith("<") && contentId.endsWith(">")) { - contentId = contentId.substring(1, contentId.length() - 1); - } - attachment.setContentId(contentId); - } - - // Extract attachment data if attachments should be included OR if it's an - // embedded image (needed for inline display) - if ((request != null && request.isIncludeAttachments()) - || attachment.isEmbedded()) { - try { - Object attachmentContent = getContent.invoke(part); - byte[] attachmentData = null; - - if (attachmentContent instanceof java.io.InputStream inputStream) { - try { - attachmentData = inputStream.readAllBytes(); - } catch (IOException e) { - log.warn( - "Failed to read InputStream attachment: {}", - e.getMessage()); - } - } else if (attachmentContent instanceof byte[] byteArray) { - attachmentData = byteArray; - } else if (attachmentContent instanceof String stringContent) { - attachmentData = stringContent.getBytes(StandardCharsets.UTF_8); - } - - if (attachmentData != null) { - // Check size limit (use default 10MB if request is null) - long maxSizeMB = - request != null ? request.getMaxAttachmentSizeMB() : 10L; - long maxSizeBytes = maxSizeMB * 1024 * 1024; - - if (attachmentData.length <= maxSizeBytes) { - attachment.setData(attachmentData); - attachment.setSizeBytes(attachmentData.length); - } else { - // For embedded images, always include data regardless of size - // to ensure inline display works - if (attachment.isEmbedded()) { - attachment.setData(attachmentData); - attachment.setSizeBytes(attachmentData.length); - } else { - // Still show attachment info even if too large - attachment.setSizeBytes(attachmentData.length); - } - } - } - } catch (Exception e) { - log.warn("Error extracting attachment data: {}", e.getMessage()); - } - } - - // Add attachment to the list for display (with or without data) - content.getAttachments().add(attachment); - } - } else if ((Boolean) isMimeType.invoke(part, "multipart/*")) { - // Handle nested multipart content - try { - Object multipartContent = getContent.invoke(part); - Class multipartClass = Class.forName("jakarta.mail.Multipart"); - if (multipartClass.isInstance(multipartContent)) { - processMultipartAdvanced(multipartContent, content, request); - } - } catch (Exception e) { - log.warn("Error processing multipart content: {}", e.getMessage()); - } - } - - } catch (Exception e) { - log.warn("Error processing multipart part: {}", e.getMessage()); - } - } - - private static String generateEnhancedEmailHtml(EmailContent content, EmlToPdfRequest request) { - StringBuilder html = new StringBuilder(); - - html.append("\n"); - html.append("\n"); - html.append("").append(escapeHtml(content.getSubject())).append("\n"); - html.append("\n"); - html.append("\n"); - - html.append("
\n"); - html.append("
\n"); - html.append("

").append(escapeHtml(content.getSubject())).append("

\n"); - html.append("
\n"); - html.append("
From: ") - .append(escapeHtml(content.getFrom())) - .append("
\n"); - html.append("
To: ") - .append(escapeHtml(content.getTo())) - .append("
\n"); - - if (content.getDate() != null) { - html.append("
Date: ") - .append(formatEmailDate(content.getDate())) - .append("
\n"); - } - html.append("
\n"); - - html.append("
\n"); - if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) { - html.append(processEmailHtmlBody(content.getHtmlBody(), content)); - } else if (content.getTextBody() != null && !content.getTextBody().trim().isEmpty()) { - html.append("
"); - html.append(convertTextToHtml(content.getTextBody())); - html.append("
"); - } else { - html.append("
"); - html.append("

No content available

"); - html.append("
"); - } - html.append("
\n"); - - if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) { - html.append("
\n"); - int displayedAttachmentCount = - content.getAttachmentCount() > 0 - ? content.getAttachmentCount() - : content.getAttachments().size(); - html.append("

Attachments (").append(displayedAttachmentCount).append(")

\n"); - - if (!content.getAttachments().isEmpty()) { - for (EmailAttachment attachment : content.getAttachments()) { - // Create attachment info with paperclip emoji before filename - String uniqueId = generateUniqueAttachmentId(attachment.getFilename()); - attachment.setEmbeddedFilename( - attachment.getEmbeddedFilename() != null - ? attachment.getEmbeddedFilename() - : attachment.getFilename()); - - html.append("
") - .append("") - .append(MimeConstants.ATTACHMENT_MARKER) - .append(" ") - .append("") - .append(escapeHtml(safeMimeDecode(attachment.getFilename()))) - .append(""); - - String sizeStr = formatFileSize(attachment.getSizeBytes()); - html.append(" (").append(sizeStr); - if (attachment.getContentType() != null - && !attachment.getContentType().isEmpty()) { - html.append(", ").append(escapeHtml(attachment.getContentType())); - } - html.append(")
\n"); - } - } - - if (request.isIncludeAttachments()) { - html.append("
\n"); - html.append("

Attachments are embedded in the file.

\n"); - html.append("
\n"); - } else { - html.append("
\n"); - html.append( - "

Attachment information displayed - files not included in PDF.

\n"); - html.append("
\n"); - } - - html.append("
\n"); - } - - html.append("
\n"); - html.append(""); - - return html.toString(); - } - - private static byte[] attachFilesToPdf( - byte[] pdfBytes, - List attachments, - CustomPDFDocumentFactory pdfDocumentFactory) - throws IOException { - try (PDDocument document = pdfDocumentFactory.load(pdfBytes); - ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { - - if (attachments == null || attachments.isEmpty()) { - document.save(outputStream); - return outputStream.toByteArray(); - } - - List embeddedFiles = new ArrayList<>(); - - // Set up the embedded files name tree once - if (document.getDocumentCatalog().getNames() == null) { - document.getDocumentCatalog() - .setNames(new PDDocumentNameDictionary(document.getDocumentCatalog())); - } - - PDDocumentNameDictionary names = document.getDocumentCatalog().getNames(); - if (names.getEmbeddedFiles() == null) { - names.setEmbeddedFiles(new PDEmbeddedFilesNameTreeNode()); - } - - PDEmbeddedFilesNameTreeNode efTree = names.getEmbeddedFiles(); - Map efMap = efTree.getNames(); - if (efMap == null) { - efMap = new HashMap<>(); - } - - // Embed each attachment directly into the PDF - for (EmailAttachment attachment : attachments) { - if (attachment.getData() == null || attachment.getData().length == 0) { - continue; - } - - try { - // Generate unique filename - String filename = attachment.getFilename(); - if (filename == null || filename.trim().isEmpty()) { - filename = "attachment_" + System.currentTimeMillis(); - if (attachment.getContentType() != null - && attachment.getContentType().contains("/")) { - String[] parts = attachment.getContentType().split("/"); - if (parts.length > 1) { - filename += "." + parts[1]; - } - } - } - - // Ensure unique filename - String uniqueFilename = getUniqueFilename(filename, embeddedFiles, efMap); - - // Create embedded file - PDEmbeddedFile embeddedFile = - new PDEmbeddedFile( - document, new ByteArrayInputStream(attachment.getData())); - embeddedFile.setSize(attachment.getData().length); - embeddedFile.setCreationDate(new GregorianCalendar()); - - // 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); - } - - // Add to the map (but don't set it yet) - efMap.put(uniqueFilename, fileSpec); - embeddedFiles.add(uniqueFilename); - - // Store the filename for annotation creation - attachment.setEmbeddedFilename(uniqueFilename); - - } catch (Exception e) { - // Log error but continue with other attachments - log.warn("Failed to embed attachment: {}", attachment.getFilename(), e); - } - } - - // Set the complete map once at the end - if (!efMap.isEmpty()) { - efTree.setNames(efMap); - - // Set catalog viewer preferences to automatically show attachments pane - setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS); - } - - // Add attachment annotations to the first page for each embedded file - if (!embeddedFiles.isEmpty()) { - addAttachmentAnnotationsToDocument(document, attachments); - } - - document.save(outputStream); - return outputStream.toByteArray(); - } - } - - private static String getUniqueFilename( - String filename, - List embeddedFiles, - Map efMap) { - String uniqueFilename = filename; - int counter = 1; - while (embeddedFiles.contains(uniqueFilename) || efMap.containsKey(uniqueFilename)) { - String extension = ""; - String baseName = filename; - int lastDot = filename.lastIndexOf('.'); - if (lastDot > 0) { - extension = filename.substring(lastDot); - baseName = filename.substring(0, lastDot); - } - uniqueFilename = baseName + "_" + counter + extension; - counter++; - } - return uniqueFilename; - } - - private static void addAttachmentAnnotationsToDocument( - PDDocument document, List attachments) throws IOException { - if (document.getNumberOfPages() == 0 || attachments == null || attachments.isEmpty()) { - return; - } - - // 1. Find the screen position of all attachment markers - AttachmentMarkerPositionFinder finder = new AttachmentMarkerPositionFinder(); - finder.setSortByPosition(true); // Process pages in order - finder.getText(document); - List markerPositions = finder.getPositions(); - - // 2. Warn if the number of markers and attachments don't match - if (markerPositions.size() != attachments.size()) { - log.warn( - "Found {} attachment markers, but there are {} attachments. Annotation count may be incorrect.", - markerPositions.size(), - attachments.size()); - } - - // 3. Create an invisible annotation over each found marker - int annotationsToAdd = Math.min(markerPositions.size(), attachments.size()); - for (int i = 0; i < annotationsToAdd; i++) { - MarkerPosition position = markerPositions.get(i); - EmailAttachment attachment = attachments.get(i); - - if (attachment.getEmbeddedFilename() != null) { - PDPage page = document.getPage(position.getPageIndex()); - addAttachmentAnnotationToPage( - document, page, attachment, position.getX(), position.getY()); - } - } - } - - private static void addAttachmentAnnotationToPage( - PDDocument document, PDPage page, EmailAttachment attachment, float x, float y) - throws IOException { - - PDAnnotationFileAttachment fileAnnotation = new PDAnnotationFileAttachment(); - - PDRectangle rect = getPdRectangle(page, x, y); - fileAnnotation.setRectangle(rect); - - // Remove visual appearance while keeping clickable functionality - try { - PDAppearanceDictionary appearance = new PDAppearanceDictionary(); - PDAppearanceStream normalAppearance = new PDAppearanceStream(document); - normalAppearance.setBBox(new PDRectangle(0, 0, 0, 0)); // Zero-size bounding box - - appearance.setNormalAppearance(normalAppearance); - fileAnnotation.setAppearance(appearance); - } catch (Exception e) { - // If appearance manipulation fails, just set it to null - fileAnnotation.setAppearance(null); - } - - // Set invisibility flags but keep it functional - fileAnnotation.setInvisible(true); - fileAnnotation.setHidden(false); // Must be false to remain clickable - fileAnnotation.setNoView(false); // Must be false to remain clickable - fileAnnotation.setPrinted(false); - - PDEmbeddedFilesNameTreeNode efTree = - document.getDocumentCatalog().getNames().getEmbeddedFiles(); - if (efTree != null) { - Map efMap = efTree.getNames(); - if (efMap != null) { - PDComplexFileSpecification fileSpec = efMap.get(attachment.getEmbeddedFilename()); - if (fileSpec != null) { - fileAnnotation.setFile(fileSpec); - } - } - } - - fileAnnotation.setContents("Click to open: " + attachment.getFilename()); - fileAnnotation.setAnnotationName("EmbeddedFile_" + attachment.getEmbeddedFilename()); - - page.getAnnotations().add(fileAnnotation); - - log.info( - "Added attachment annotation for '{}' on page {}", - attachment.getFilename(), - document.getPages().indexOf(page) + 1); - } - - private static @NotNull PDRectangle getPdRectangle(PDPage page, float x, float y) { - PDRectangle mediaBox = page.getMediaBox(); - float pdfY = mediaBox.getHeight() - y; - - float iconWidth = - StyleConstants.ATTACHMENT_ICON_WIDTH; // Keep original size for clickability - float iconHeight = - StyleConstants.ATTACHMENT_ICON_HEIGHT; // Keep original size for clickability - - // Keep the full-size rectangle so it remains clickable - return new PDRectangle( - x + StyleConstants.ANNOTATION_X_OFFSET, - pdfY - iconHeight + StyleConstants.ANNOTATION_Y_OFFSET, - iconWidth, - iconHeight); - } - - private static String formatEmailDate(Date date) { - if (date == null) return ""; - java.text.SimpleDateFormat formatter = - new java.text.SimpleDateFormat("EEE, MMM d, yyyy 'at' h:mm a", Locale.ENGLISH); - return formatter.format(date); - } - - private static String formatFileSize(long bytes) { - if (bytes < FileSizeConstants.BYTES_IN_KB) { - return bytes + " B"; - } else if (bytes < FileSizeConstants.BYTES_IN_MB) { - return String.format("%.1f KB", bytes / (double) FileSizeConstants.BYTES_IN_KB); - } else if (bytes < FileSizeConstants.BYTES_IN_GB) { - return String.format("%.1f MB", bytes / (double) FileSizeConstants.BYTES_IN_MB); - } else { - return String.format("%.1f GB", bytes / (double) FileSizeConstants.BYTES_IN_GB); - } - } - - // MIME header decoding functionality for RFC 2047 encoded headers - moved to constants - - private static String decodeMimeHeader(String encodedText) { - if (encodedText == null || encodedText.trim().isEmpty()) { - return encodedText; - } - - try { - StringBuilder result = new StringBuilder(); - Matcher matcher = MimeConstants.MIME_ENCODED_PATTERN.matcher(encodedText); - int lastEnd = 0; - - while (matcher.find()) { - // Add any text before the encoded part - result.append(encodedText, lastEnd, matcher.start()); - - String charset = matcher.group(1); - String encoding = matcher.group(2).toUpperCase(); - String encodedValue = matcher.group(3); - - try { - String decodedValue; - if ("B".equals(encoding)) { - // Base64 decoding - byte[] decodedBytes = Base64.getDecoder().decode(encodedValue); - decodedValue = new String(decodedBytes, Charset.forName(charset)); - } else if ("Q".equals(encoding)) { - // Quoted-printable decoding - decodedValue = decodeQuotedPrintable(encodedValue, charset); - } else { - // Unknown encoding, keep original - decodedValue = matcher.group(0); - } - result.append(decodedValue); - } catch (Exception e) { - log.warn("Failed to decode MIME header part: {}", matcher.group(0), e); - // If decoding fails, keep the original encoded text - result.append(matcher.group(0)); - } - - lastEnd = matcher.end(); - } - - // Add any remaining text after the last encoded part - result.append(encodedText.substring(lastEnd)); - - return result.toString(); - } catch (Exception e) { - log.warn("Error decoding MIME header: {}", encodedText, e); - return encodedText; // Return original if decoding fails - } - } - - private static String decodeQuotedPrintable(String encodedText, String charset) { - StringBuilder result = new StringBuilder(); - for (int i = 0; i < encodedText.length(); i++) { - char c = encodedText.charAt(i); - switch (c) { - case '=' -> { - if (i + 2 < encodedText.length()) { - String hex = encodedText.substring(i + 1, i + 3); - try { - int value = Integer.parseInt(hex, 16); - result.append((char) value); - i += 2; // Skip the hex digits - } catch (NumberFormatException e) { - // If hex parsing fails, keep the original character - result.append(c); - } - } else { - result.append(c); - } - } - case '_' -> // In RFC 2047, underscore represents space - result.append(' '); - default -> result.append(c); - } - } - - // Convert bytes to proper charset - byte[] bytes = result.toString().getBytes(StandardCharsets.ISO_8859_1); - return new String(bytes, Charset.forName(charset)); - } - - private static String safeMimeDecode(String headerValue) { - if (headerValue == null) { - return ""; - } - - try { - if (isJakartaMailAvailable()) { - // Use Jakarta Mail's MimeUtility for proper MIME decoding - Class mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility"); - Method decodeText = mimeUtilityClass.getMethod("decodeText", String.class); - return (String) decodeText.invoke(null, headerValue.trim()); - } else { - // Fallback to basic MIME decoding - return decodeMimeHeader(headerValue.trim()); - } - } catch (Exception e) { - log.warn("Failed to decode MIME header, using original: {}", headerValue, e); - return headerValue; - } - } - - private static boolean isValidJakartaMailPart(Object part) { - if (part == null) return false; - - try { - // Check if the object implements jakarta.mail.Part interface - Class partInterface = Class.forName("jakarta.mail.Part"); - if (!partInterface.isInstance(part)) { - return false; - } - - // Additional check for MimePart - try { - Class mimePartInterface = Class.forName("jakarta.mail.internet.MimePart"); - return mimePartInterface.isInstance(part); - } catch (ClassNotFoundException e) { - // MimePart not available, but Part is sufficient - return true; - } - } catch (ClassNotFoundException e) { - log.debug("Jakarta Mail Part interface not available for validation"); - return false; - } - } - - private static boolean isValidJakartaMailMultipart(Object multipart) { - if (multipart == null) return false; - - try { - // Check if the object implements jakarta.mail.Multipart interface - Class multipartInterface = Class.forName("jakarta.mail.Multipart"); - if (!multipartInterface.isInstance(multipart)) { - return false; - } - - // Additional check for MimeMultipart - try { - Class mimeMultipartClass = Class.forName("jakarta.mail.internet.MimeMultipart"); - if (mimeMultipartClass.isInstance(multipart)) { - log.debug("Found MimeMultipart instance for enhanced processing"); - return true; - } - } catch (ClassNotFoundException e) { - log.debug("MimeMultipart not available, using base Multipart interface"); - } - - return true; - } catch (ClassNotFoundException e) { - log.debug("Jakarta Mail Multipart interface not available for validation"); - return false; - } - } - - @Data - public static class EmailContent { - private String subject; - private String from; - private String to; - private Date date; - private String htmlBody; - private String textBody; - private int attachmentCount; - private List attachments = new ArrayList<>(); - - public void setHtmlBody(String htmlBody) { - this.htmlBody = htmlBody != null ? htmlBody.replaceAll("\r", "") : null; - } - - public void setTextBody(String textBody) { - this.textBody = textBody != null ? textBody.replaceAll("\r", "") : null; - } - } - - @Data - public static class EmailAttachment { - private String filename; - private String contentType; - private byte[] data; - private boolean embedded; - private String embeddedFilename; - private long sizeBytes; - - // New fields for advanced processing - private String contentId; - private String disposition; - private String transferEncoding; - - // Custom setter to maintain size calculation logic - public void setData(byte[] data) { - this.data = data; - if (data != null) { - this.sizeBytes = data.length; - } - } - } - - @Data - public static class MarkerPosition { - private int pageIndex; - private float x; - private float y; - private String character; - - public MarkerPosition(int pageIndex, float x, float y, String character) { - this.pageIndex = pageIndex; - this.x = x; - this.y = y; - this.character = character; - } - } - - public static class AttachmentMarkerPositionFinder - extends org.apache.pdfbox.text.PDFTextStripper { - @Getter private final List positions = new ArrayList<>(); - private int currentPageIndex; - protected boolean sortByPosition; - private boolean isInAttachmentSection; - private boolean attachmentSectionFound; - - public AttachmentMarkerPositionFinder() { - super(); - this.currentPageIndex = 0; - this.sortByPosition = false; - this.isInAttachmentSection = false; - this.attachmentSectionFound = false; - } - - @Override - protected void startPage(org.apache.pdfbox.pdmodel.PDPage page) throws IOException { - super.startPage(page); - } - - @Override - protected void endPage(org.apache.pdfbox.pdmodel.PDPage page) throws IOException { - currentPageIndex++; - super.endPage(page); - } - - @Override - protected void writeString( - String string, List textPositions) - throws IOException { - // Check if we are entering or exiting the attachment section - String lowerString = string.toLowerCase(); - - // Look for attachment section start marker - if (lowerString.contains("attachments (")) { - isInAttachmentSection = true; - attachmentSectionFound = true; - } - - // Look for attachment section end markers (common patterns that indicate end of - // attachments) - if (isInAttachmentSection - && (lowerString.contains("") - || lowerString.contains("") - || (attachmentSectionFound - && lowerString.trim().isEmpty() - && string.length() > 50))) { - isInAttachmentSection = false; - } - - // Only look for markers if we are in the attachment section - if (isInAttachmentSection) { - String attachmentMarker = MimeConstants.ATTACHMENT_MARKER; - for (int i = 0; (i = string.indexOf(attachmentMarker, i)) != -1; i++) { - if (i < textPositions.size()) { - org.apache.pdfbox.text.TextPosition textPosition = textPositions.get(i); - MarkerPosition position = - new MarkerPosition( - currentPageIndex, - textPosition.getXDirAdj(), - textPosition.getYDirAdj(), - attachmentMarker); - positions.add(position); - } - } - } - super.writeString(string, textPositions); - } - - @Override - public void setSortByPosition(boolean sortByPosition) { - this.sortByPosition = sortByPosition; + tempFileManager, + customHtmlSanitizer); } } } diff --git a/app/common/src/main/java/stirling/software/common/util/FileToPdf.java b/app/common/src/main/java/stirling/software/common/util/FileToPdf.java index c735e5287..799f91e05 100644 --- a/app/common/src/main/java/stirling/software/common/util/FileToPdf.java +++ b/app/common/src/main/java/stirling/software/common/util/FileToPdf.java @@ -26,8 +26,8 @@ public class FileToPdf { HTMLToPdfRequest request, byte[] fileBytes, String fileName, - boolean disableSanitize, - TempFileManager tempFileManager) + TempFileManager tempFileManager, + CustomHtmlSanitizer customHtmlSanitizer) throws IOException, InterruptedException { try (TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) { @@ -39,14 +39,15 @@ public class FileToPdf { if (fileName.toLowerCase().endsWith(".html")) { String sanitizedHtml = sanitizeHtmlContent( - new String(fileBytes, StandardCharsets.UTF_8), disableSanitize); + new String(fileBytes, StandardCharsets.UTF_8), + customHtmlSanitizer); Files.write( tempInputFile.getPath(), sanitizedHtml.getBytes(StandardCharsets.UTF_8)); } else if (fileName.toLowerCase().endsWith(".zip")) { Files.write(tempInputFile.getPath(), fileBytes); sanitizeHtmlFilesInZip( - tempInputFile.getPath(), disableSanitize, tempFileManager); + tempInputFile.getPath(), tempFileManager, customHtmlSanitizer); } else { throw ExceptionUtils.createHtmlFileRequiredException(); } @@ -78,12 +79,15 @@ public class FileToPdf { } // tempOutputFile auto-closed } - private static String sanitizeHtmlContent(String htmlContent, boolean disableSanitize) { - return (!disableSanitize) ? CustomHtmlSanitizer.sanitize(htmlContent) : htmlContent; + private static String sanitizeHtmlContent( + String htmlContent, CustomHtmlSanitizer customHtmlSanitizer) { + return customHtmlSanitizer.sanitize(htmlContent); } private static void sanitizeHtmlFilesInZip( - Path zipFilePath, boolean disableSanitize, TempFileManager tempFileManager) + Path zipFilePath, + TempFileManager tempFileManager, + CustomHtmlSanitizer customHtmlSanitizer) throws IOException { try (TempDirectory tempUnzippedDir = new TempDirectory(tempFileManager)) { try (ZipInputStream zipIn = @@ -99,7 +103,8 @@ public class FileToPdf { || entry.getName().toLowerCase().endsWith(".htm")) { String content = new String(zipIn.readAllBytes(), StandardCharsets.UTF_8); - String sanitizedContent = sanitizeHtmlContent(content, disableSanitize); + String sanitizedContent = + sanitizeHtmlContent(content, customHtmlSanitizer); Files.write( filePath, sanitizedContent.getBytes(StandardCharsets.UTF_8)); } else { diff --git a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java index a164faec2..9f8d7a7e0 100644 --- a/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/GeneralUtils.java @@ -14,6 +14,7 @@ import java.util.Arrays; import java.util.Enumeration; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.UUID; import org.springframework.core.io.ClassPathResource; @@ -34,8 +35,16 @@ import stirling.software.common.configuration.InstallationPathConfig; @Slf4j public class GeneralUtils { - private static final List DEFAULT_VALID_SCRIPTS = - List.of("png_to_webp.py", "split_photos.py"); + private static final Set DEFAULT_VALID_SCRIPTS = + Set.of("png_to_webp.py", "split_photos.py"); + private static final Set DEFAULT_VALID_PIPELINE = + Set.of( + "OCR images.json", + "Prepare-pdfs-for-email.json", + "split-rotate-auto-rename.json"); + + private static final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs"; + private static final String PYTHON_SCRIPTS_DIR = "python"; public static File convertMultipartFileToFile(MultipartFile multipartFile) throws IOException { String customTempDir = System.getenv("STIRLING_TEMPFILES_DIRECTORY"); @@ -447,7 +456,46 @@ public class GeneralUtils { } /** - * Extracts a file from classpath:/static/python to a temporary directory and returns the path. + * Extracts the default pipeline configurations from the classpath to the installation path. + * Creates directories if needed and copies default JSON files. + * + *

Existing files will be overwritten atomically (when supported). In case of unsupported + * atomic moves, falls back to non-atomic replace. + * + * @throws IOException if an I/O error occurs during file operations + */ + public static void extractPipeline() throws IOException { + Path pipelineDir = + Paths.get(InstallationPathConfig.getPipelinePath(), DEFAULT_WEBUI_CONFIGS_DIR); + Files.createDirectories(pipelineDir); + + for (String name : DEFAULT_VALID_PIPELINE) { + if (!Paths.get(name).getFileName().toString().equals(name)) { + log.error("Invalid pipeline file name: {}", name); + throw new IllegalArgumentException("Invalid pipeline file name: " + name); + } + Path target = pipelineDir.resolve(name); + ClassPathResource res = + new ClassPathResource( + "static/pipeline/" + DEFAULT_WEBUI_CONFIGS_DIR + "/" + name); + if (!res.exists()) { + log.error("Resource not found: {}", res.getPath()); + throw new IOException("Resource not found: " + res.getPath()); + } + copyResourceToFile(res, target); + } + } + + /** + * Extracts the specified Python script from the classpath to the installation path. Validates + * name and copies file atomically when possible, overwriting existing. + * + *

Existing files will be overwritten atomically (when supported). + * + * @param scriptName the name of the script to extract + * @return the path to the extracted script + * @throws IllegalArgumentException if the script name is invalid or not allowed + * @throws IOException if an I/O error occurs */ public static Path extractScript(String scriptName) throws IOException { // Validate input @@ -458,26 +506,71 @@ public class GeneralUtils { throw new IllegalArgumentException( "scriptName must not contain path traversal characters"); } + if (!Paths.get(scriptName).getFileName().toString().equals(scriptName)) { + throw new IllegalArgumentException( + "scriptName must not contain path traversal characters"); + } if (!DEFAULT_VALID_SCRIPTS.contains(scriptName)) { throw new IllegalArgumentException( "scriptName must be either 'png_to_webp.py' or 'split_photos.py'"); } - Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), "python"); + Path scriptsDir = Paths.get(InstallationPathConfig.getScriptsPath(), PYTHON_SCRIPTS_DIR); Files.createDirectories(scriptsDir); - Path scriptFile = scriptsDir.resolve(scriptName); - if (!Files.exists(scriptFile)) { - ClassPathResource resource = new ClassPathResource("static/python/" + scriptName); - try (InputStream in = resource.getInputStream()) { - Files.copy(in, scriptFile, StandardCopyOption.REPLACE_EXISTING); + Path target = scriptsDir.resolve(scriptName); + ClassPathResource res = + new ClassPathResource("static/" + PYTHON_SCRIPTS_DIR + "/" + scriptName); + if (!res.exists()) { + log.error("Resource not found: {}", res.getPath()); + throw new IOException("Resource not found: " + res.getPath()); + } + copyResourceToFile(res, target); + return target; + } + + /** + * Copies a resource from the classpath to a specified target file. + * + * @param resource the ClassPathResource to copy + * @param target the target Path where the resource will be copied + * @throws IOException if an I/O error occurs during the copy operation + */ + private static void copyResourceToFile(ClassPathResource resource, Path target) + throws IOException { + Path dir = target.getParent(); + Path tmp = Files.createTempFile(dir, target.getFileName().toString(), ".tmp"); + try (InputStream in = resource.getInputStream()) { + Files.copy(in, tmp, StandardCopyOption.REPLACE_EXISTING); + try { + Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + log.warn( + "Atomic move not supported, falling back to non-atomic move for {}", + target, + e); + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (FileAlreadyExistsException e) { + log.debug("File already exists at {}, attempting to replace it.", target); + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + } catch (AccessDeniedException e) { + log.error("Access denied while attempting to copy resource to {}", target, e); + throw e; + } catch (FileSystemException e) { + log.error("File system error occurred while copying resource to {}", target, e); + throw e; + } catch (IOException e) { + log.error("Failed to copy resource to {}", target, e); + throw e; + } finally { + try { + Files.deleteIfExists(tmp); } catch (IOException e) { - log.error("Failed to extract Python script", e); - throw e; + log.warn("Failed to delete temporary file {}", tmp, e); } } - return scriptFile; } public static boolean isVersionHigher(String currentVersion, String compareVersion) { diff --git a/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java b/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java index ae6c0b66f..7140b3cc2 100644 --- a/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/ImageProcessingUtils.java @@ -5,8 +5,11 @@ import java.awt.image.*; import java.io.IOException; import java.io.InputStream; import java.nio.ByteBuffer; +import java.util.Iterator; import javax.imageio.ImageIO; +import javax.imageio.ImageReader; +import javax.imageio.stream.ImageInputStream; import org.springframework.web.multipart.MultipartFile; @@ -115,7 +118,36 @@ public class ImageProcessingUtils { public static BufferedImage loadImageWithExifOrientation(MultipartFile file) throws IOException { - BufferedImage image = ImageIO.read(file.getInputStream()); + BufferedImage image = null; + String filename = file.getOriginalFilename(); + + if (filename != null && filename.toLowerCase().endsWith(".psd")) { + // For PSD files, try explicit ImageReader + Iterator readers = ImageIO.getImageReadersByFormatName("PSD"); + if (readers.hasNext()) { + ImageReader reader = readers.next(); + try (ImageInputStream iis = ImageIO.createImageInputStream(file.getInputStream())) { + reader.setInput(iis); + image = reader.read(0); + } finally { + reader.dispose(); + } + } + if (image == null) { + throw new IOException( + "Unable to read image from file: " + + filename + + ". Supported PSD formats: RGB/CMYK/Gray 8-32 bit, RLE/ZIP compression"); + } + } else { + // For non-PSD files, use standard ImageIO + image = ImageIO.read(file.getInputStream()); + } + + if (image == null) { + throw new IOException("Unable to read image from file: " + filename); + } + double orientation = extractImageOrientation(file.getInputStream()); return applyOrientation(image, orientation); } diff --git a/app/common/src/main/java/stirling/software/common/util/PdfAttachmentHandler.java b/app/common/src/main/java/stirling/software/common/util/PdfAttachmentHandler.java new file mode 100644 index 000000000..2478aad94 --- /dev/null +++ b/app/common/src/main/java/stirling/software/common/util/PdfAttachmentHandler.java @@ -0,0 +1,680 @@ +package stirling.software.common.util; + +import static stirling.software.common.util.AttachmentUtils.setCatalogViewerPreferences; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Date; +import java.util.GregorianCalendar; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +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; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; +import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; +import org.apache.pdfbox.text.PDFTextStripper; +import org.apache.pdfbox.text.TextPosition; +import org.jetbrains.annotations.NotNull; +import org.springframework.web.multipart.MultipartFile; + +import lombok.Data; +import lombok.Getter; +import lombok.experimental.UtilityClass; + +import stirling.software.common.service.CustomPDFDocumentFactory; + +@UtilityClass +public class PdfAttachmentHandler { + // Note: This class is designed for EML attachments, not general PDF attachments. + + private static final String ATTACHMENT_MARKER = "@"; + private static final float ATTACHMENT_ICON_WIDTH = 12f; + private static final float ATTACHMENT_ICON_HEIGHT = 14f; + private static final float ANNOTATION_X_OFFSET = 2f; + private static final float ANNOTATION_Y_OFFSET = 10f; + + public static byte[] attachFilesToPdf( + byte[] pdfBytes, + List attachments, + CustomPDFDocumentFactory pdfDocumentFactory) + throws IOException { + + if (attachments == null || attachments.isEmpty()) { + return pdfBytes; + } + + try (PDDocument document = pdfDocumentFactory.load(pdfBytes); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + + List multipartAttachments = new ArrayList<>(attachments.size()); + for (int i = 0; i < attachments.size(); i++) { + EmlParser.EmailAttachment attachment = attachments.get(i); + if (attachment.getData() != null && attachment.getData().length > 0) { + String embeddedFilename = + attachment.getFilename() != null + ? attachment.getFilename() + : ("attachment_" + i); + attachment.setEmbeddedFilename(embeddedFilename); + multipartAttachments.add(createMultipartFile(attachment)); + } + } + + if (!multipartAttachments.isEmpty()) { + Map indexToFilenameMap = + addAttachmentsToDocumentWithMapping( + document, multipartAttachments, attachments); + setCatalogViewerPreferences(document, PageMode.USE_ATTACHMENTS); + addAttachmentAnnotationsToDocumentWithMapping( + document, attachments, indexToFilenameMap); + } + + document.save(outputStream); + return outputStream.toByteArray(); + } catch (RuntimeException e) { + throw new IOException( + "Invalid PDF structure or processing error: " + e.getMessage(), e); + } catch (Exception e) { + throw new IOException("Error attaching files to PDF: " + e.getMessage(), e); + } + } + + private static MultipartFile createMultipartFile(EmlParser.EmailAttachment attachment) { + return new MultipartFile() { + @Override + public @NotNull String getName() { + return "attachment"; + } + + @Override + public String getOriginalFilename() { + return attachment.getFilename() != null + ? attachment.getFilename() + : "attachment_" + System.currentTimeMillis(); + } + + @Override + public String getContentType() { + return attachment.getContentType() != null + ? attachment.getContentType() + : "application/octet-stream"; + } + + @Override + public boolean isEmpty() { + return attachment.getData() == null || attachment.getData().length == 0; + } + + @Override + public long getSize() { + return attachment.getData() != null ? attachment.getData().length : 0; + } + + @Override + public byte @NotNull [] getBytes() { + return attachment.getData() != null ? attachment.getData() : new byte[0]; + } + + @Override + public @NotNull InputStream getInputStream() { + byte[] data = attachment.getData(); + return new ByteArrayInputStream(data != null ? data : new byte[0]); + } + + @Override + public void transferTo(@NotNull File dest) throws IOException, IllegalStateException { + try (FileOutputStream fos = new FileOutputStream(dest)) { + byte[] data = attachment.getData(); + if (data != null) { + fos.write(data); + } + } + } + }; + } + + private static String ensureUniqueFilename(String filename, Set existingNames) { + if (!existingNames.contains(filename)) { + return filename; + } + + String baseName; + String extension = ""; + int lastDot = filename.lastIndexOf('.'); + if (lastDot > 0) { + baseName = filename.substring(0, lastDot); + extension = filename.substring(lastDot); + } else { + baseName = filename; + } + + int counter = 1; + String uniqueName; + do { + uniqueName = baseName + "_" + counter + extension; + counter++; + } while (existingNames.contains(uniqueName)); + + return uniqueName; + } + + private static @NotNull PDRectangle calculateAnnotationRectangle( + PDPage page, float x, float y) { + PDRectangle cropBox = page.getCropBox(); + + // ISO 32000-1:2008 Section 8.3: PDF coordinate system transforms + int rotation = page.getRotation(); + float pdfX = x; + float pdfY = cropBox.getHeight() - y; + + switch (rotation) { + case 90 -> { + float temp = pdfX; + pdfX = pdfY; + pdfY = cropBox.getWidth() - temp; + } + case 180 -> { + pdfX = cropBox.getWidth() - pdfX; + pdfY = y; + } + case 270 -> { + float temp = pdfX; + pdfX = cropBox.getHeight() - pdfY; + pdfY = temp; + } + default -> {} + } + + float iconHeight = ATTACHMENT_ICON_HEIGHT; + float paddingX = 2.0f; + float paddingY = 2.0f; + + PDRectangle rect = + new PDRectangle( + pdfX + ANNOTATION_X_OFFSET + paddingX, + pdfY - iconHeight + ANNOTATION_Y_OFFSET + paddingY, + ATTACHMENT_ICON_WIDTH, + iconHeight); + + PDRectangle mediaBox = page.getMediaBox(); + if (rect.getLowerLeftX() < mediaBox.getLowerLeftX() + || rect.getLowerLeftY() < mediaBox.getLowerLeftY() + || rect.getUpperRightX() > mediaBox.getUpperRightX() + || rect.getUpperRightY() > mediaBox.getUpperRightY()) { + + float adjustedX = + Math.max( + mediaBox.getLowerLeftX(), + Math.min( + rect.getLowerLeftX(), + mediaBox.getUpperRightX() - rect.getWidth())); + float adjustedY = + Math.max( + mediaBox.getLowerLeftY(), + Math.min( + rect.getLowerLeftY(), + mediaBox.getUpperRightY() - rect.getHeight())); + rect = new PDRectangle(adjustedX, adjustedY, rect.getWidth(), rect.getHeight()); + } + + return rect; + } + + public static String processInlineImages( + String htmlContent, EmlParser.EmailContent emailContent) { + if (htmlContent == null || emailContent == null) return htmlContent; + + Map contentIdMap = new HashMap<>(); + for (EmlParser.EmailAttachment attachment : emailContent.getAttachments()) { + if (attachment.isEmbedded() + && attachment.getContentId() != null + && attachment.getData() != null) { + contentIdMap.put(attachment.getContentId(), attachment); + } + } + + if (contentIdMap.isEmpty()) return htmlContent; + + Pattern cidPattern = + Pattern.compile( + "(?i)]*\\ssrc\\s*=\\s*['\"]cid:([^'\"]+)['\"][^>]*>", + Pattern.CASE_INSENSITIVE); + Matcher matcher = cidPattern.matcher(htmlContent); + + StringBuilder result = new StringBuilder(); + while (matcher.find()) { + String contentId = matcher.group(1); + EmlParser.EmailAttachment attachment = contentIdMap.get(contentId); + + if (attachment != null && attachment.getData() != null) { + String mimeType = + EmlProcessingUtils.detectMimeType( + attachment.getFilename(), attachment.getContentType()); + + String base64Data = Base64.getEncoder().encodeToString(attachment.getData()); + String dataUri = "data:" + mimeType + ";base64," + base64Data; + + String replacement = + matcher.group(0).replaceFirst("cid:" + Pattern.quote(contentId), dataUri); + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } else { + matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group(0))); + } + } + matcher.appendTail(result); + + return result.toString(); + } + + public static String formatEmailDate(Date date) { + if (date == null) return ""; + + SimpleDateFormat formatter = + new SimpleDateFormat("EEE, MMM d, yyyy 'at' h:mm a z", Locale.ENGLISH); + formatter.setTimeZone(TimeZone.getTimeZone("UTC")); + return formatter.format(date); + } + + @Data + public static class MarkerPosition { + private int pageIndex; + private float x; + private float y; + private String character; + private String filename; + + public MarkerPosition(int pageIndex, float x, float y, String character, String filename) { + this.pageIndex = pageIndex; + this.x = x; + this.y = y; + this.character = character; + this.filename = filename; + } + } + + public static class AttachmentMarkerPositionFinder extends PDFTextStripper { + @Getter private final List positions = new ArrayList<>(); + private int currentPageIndex; + protected boolean sortByPosition; + private boolean isInAttachmentSection; + private boolean attachmentSectionFound; + private final StringBuilder currentText = new StringBuilder(); + + private static final Pattern ATTACHMENT_SECTION_PATTERN = + Pattern.compile("attachments\\s*\\(\\d+\\)", Pattern.CASE_INSENSITIVE); + + private static final Pattern FILENAME_PATTERN = + Pattern.compile("@\\s*([^\\s\\(]+(?:\\.[a-zA-Z0-9]+)?)"); + + public AttachmentMarkerPositionFinder() { + super(); + this.currentPageIndex = 0; + this.sortByPosition = false; // Disable sorting to preserve document order + this.isInAttachmentSection = false; + this.attachmentSectionFound = false; + } + + @Override + public String getText(PDDocument document) throws IOException { + super.getText(document); + + if (sortByPosition) { + positions.sort( + (a, b) -> { + int pageCompare = Integer.compare(a.getPageIndex(), b.getPageIndex()); + if (pageCompare != 0) return pageCompare; + return Float.compare( + b.getY(), a.getY()); // Descending Y per PDF coordinate system + }); + } + + return ""; // Return empty string as we only need positions + } + + @Override + protected void startPage(PDPage page) throws IOException { + super.startPage(page); + } + + @Override + protected void endPage(PDPage page) throws IOException { + currentPageIndex++; + super.endPage(page); + } + + @Override + protected void writeString(String string, List textPositions) + throws IOException { + String lowerString = string.toLowerCase(); + + if (ATTACHMENT_SECTION_PATTERN.matcher(lowerString).find()) { + isInAttachmentSection = true; + attachmentSectionFound = true; + } + + if (isInAttachmentSection + && (lowerString.contains("") + || lowerString.contains("") + || (attachmentSectionFound + && lowerString.trim().isEmpty() + && string.length() > 50))) { + isInAttachmentSection = false; + } + + if (isInAttachmentSection) { + currentText.append(string); + + for (int i = 0; (i = string.indexOf(ATTACHMENT_MARKER, i)) != -1; i++) { + if (i < textPositions.size()) { + TextPosition textPosition = textPositions.get(i); + + String filename = extractFilenameAfterMarker(string, i); + + MarkerPosition position = + new MarkerPosition( + currentPageIndex, + textPosition.getXDirAdj(), + textPosition.getYDirAdj(), + ATTACHMENT_MARKER, + filename); + positions.add(position); + } + } + } + super.writeString(string, textPositions); + } + + @Override + public void setSortByPosition(boolean sortByPosition) { + this.sortByPosition = sortByPosition; + } + + private String extractFilenameAfterMarker(String text, int markerIndex) { + String afterMarker = text.substring(markerIndex + 1); + + Matcher matcher = FILENAME_PATTERN.matcher("@" + afterMarker); + if (matcher.find()) { + return matcher.group(1); + } + + String[] parts = afterMarker.split("[\\s\\(\\)]+"); + for (String part : parts) { + part = part.trim(); + if (part.length() > 3 && part.contains(".")) { + return part; + } + } + + return null; + } + } + + private static Map addAttachmentsToDocumentWithMapping( + PDDocument document, + List attachments, + List originalAttachments) + throws IOException { + + PDDocumentCatalog catalog = document.getDocumentCatalog(); + + if (catalog == null) { + throw new IOException("PDF document catalog is not accessible"); + } + + 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); + } + + Map existingNames = embeddedFilesTree.getNames(); + if (existingNames == null) { + existingNames = new HashMap<>(); + } + + Map indexToFilenameMap = new HashMap<>(); + + for (int i = 0; i < attachments.size(); i++) { + MultipartFile attachment = attachments.get(i); + String filename = attachment.getOriginalFilename(); + if (filename == null || filename.trim().isEmpty()) { + filename = "attachment_" + i; + } + + String normalizedFilename = + isAscii(filename) + ? filename + : java.text.Normalizer.normalize( + filename, java.text.Normalizer.Form.NFC); + String uniqueFilename = + ensureUniqueFilename(normalizedFilename, existingNames.keySet()); + + indexToFilenameMap.put(i, uniqueFilename); + + PDEmbeddedFile embeddedFile = new PDEmbeddedFile(document, attachment.getInputStream()); + embeddedFile.setSize((int) attachment.getSize()); + + GregorianCalendar currentTime = new GregorianCalendar(); + embeddedFile.setCreationDate(currentTime); + embeddedFile.setModDate(currentTime); + + String contentType = attachment.getContentType(); + if (contentType != null && !contentType.trim().isEmpty()) { + embeddedFile.setSubtype(contentType); + } + + PDComplexFileSpecification fileSpecification = new PDComplexFileSpecification(); + fileSpecification.setFile(uniqueFilename); + fileSpecification.setFileUnicode(uniqueFilename); + fileSpecification.setEmbeddedFile(embeddedFile); + fileSpecification.setEmbeddedFileUnicode(embeddedFile); + + existingNames.put(uniqueFilename, fileSpecification); + } + + embeddedFilesTree.setNames(existingNames); + documentNames.setEmbeddedFiles(embeddedFilesTree); + catalog.setNames(documentNames); + + return indexToFilenameMap; + } + + private static void addAttachmentAnnotationsToDocumentWithMapping( + PDDocument document, + List attachments, + Map indexToFilenameMap) + throws IOException { + + if (document.getNumberOfPages() == 0 || attachments == null || attachments.isEmpty()) { + return; + } + + AttachmentMarkerPositionFinder finder = new AttachmentMarkerPositionFinder(); + finder.setSortByPosition(false); // Keep document order to maintain pairing + finder.getText(document); + List markerPositions = finder.getPositions(); + + int annotationsToAdd = Math.min(markerPositions.size(), attachments.size()); + + for (int i = 0; i < annotationsToAdd; i++) { + MarkerPosition position = markerPositions.get(i); + + String filenameNearMarker = position.getFilename(); + + EmlParser.EmailAttachment matchingAttachment = + findAttachmentByFilename(attachments, filenameNearMarker); + + if (matchingAttachment != null) { + String embeddedFilename = + findEmbeddedFilenameForAttachment(matchingAttachment, indexToFilenameMap); + + if (embeddedFilename != null) { + PDPage page = document.getPage(position.getPageIndex()); + addAttachmentAnnotationToPageWithMapping( + document, + page, + matchingAttachment, + embeddedFilename, + position.getX(), + position.getY(), + i); + } else { + // No embedded filename found for attachment + } + } else { + // No matching attachment found for filename near marker + } + } + } + + private static EmlParser.EmailAttachment findAttachmentByFilename( + List attachments, String targetFilename) { + if (targetFilename == null || targetFilename.trim().isEmpty()) { + return null; + } + + String normalizedTarget = normalizeFilename(targetFilename); + + // First try exact match + for (EmlParser.EmailAttachment attachment : attachments) { + if (attachment.getFilename() != null) { + String normalizedAttachment = normalizeFilename(attachment.getFilename()); + if (normalizedAttachment.equals(normalizedTarget)) { + return attachment; + } + } + } + + // Then try contains match + for (EmlParser.EmailAttachment attachment : attachments) { + if (attachment.getFilename() != null) { + String normalizedAttachment = normalizeFilename(attachment.getFilename()); + if (normalizedAttachment.contains(normalizedTarget) + || normalizedTarget.contains(normalizedAttachment)) { + return attachment; + } + } + } + + return null; + } + + private static String findEmbeddedFilenameForAttachment( + EmlParser.EmailAttachment attachment, Map indexToFilenameMap) { + + String attachmentFilename = attachment.getFilename(); + if (attachmentFilename == null) { + return null; + } + + for (Map.Entry entry : indexToFilenameMap.entrySet()) { + String embeddedFilename = entry.getValue(); + if (embeddedFilename != null + && (embeddedFilename.equals(attachmentFilename) + || embeddedFilename.contains(attachmentFilename) + || attachmentFilename.contains(embeddedFilename))) { + return embeddedFilename; + } + } + + return null; + } + + private static String normalizeFilename(String filename) { + if (filename == null) return ""; + return filename.toLowerCase() + .trim() + .replaceAll("\\s+", " ") + .replaceAll("[^a-zA-Z0-9._-]", ""); + } + + private static void addAttachmentAnnotationToPageWithMapping( + PDDocument document, + PDPage page, + EmlParser.EmailAttachment attachment, + String embeddedFilename, + float x, + float y, + int attachmentIndex) + throws IOException { + + PDAnnotationFileAttachment fileAnnotation = new PDAnnotationFileAttachment(); + + PDRectangle rect = calculateAnnotationRectangle(page, x, y); + fileAnnotation.setRectangle(rect); + + fileAnnotation.setPrinted(false); + fileAnnotation.setHidden(false); + fileAnnotation.setNoView(false); + fileAnnotation.setNoZoom(true); + fileAnnotation.setNoRotate(true); + + try { + PDAppearanceDictionary appearance = new PDAppearanceDictionary(); + PDAppearanceStream normalAppearance = new PDAppearanceStream(document); + normalAppearance.setBBox(new PDRectangle(0, 0, rect.getWidth(), rect.getHeight())); + appearance.setNormalAppearance(normalAppearance); + fileAnnotation.setAppearance(appearance); + } catch (RuntimeException e) { + fileAnnotation.setAppearance(null); + } + + PDEmbeddedFilesNameTreeNode efTree = + document.getDocumentCatalog().getNames().getEmbeddedFiles(); + if (efTree != null) { + Map efMap = efTree.getNames(); + if (efMap != null) { + PDComplexFileSpecification fileSpec = efMap.get(embeddedFilename); + if (fileSpec != null) { + fileAnnotation.setFile(fileSpec); + } else { + // Could not find embedded file + } + } + } + + fileAnnotation.setContents( + "Attachment " + (attachmentIndex + 1) + ": " + attachment.getFilename()); + fileAnnotation.setAnnotationName( + "EmbeddedFile_" + attachmentIndex + "_" + embeddedFilename); + + page.getAnnotations().add(fileAnnotation); + } + + private static boolean isAscii(String str) { + if (str == null) return true; + for (int i = 0; i < str.length(); i++) { + if (str.charAt(i) > 127) { + return false; + } + } + return true; + } +} diff --git a/app/common/src/main/java/stirling/software/common/util/PdfUtils.java b/app/common/src/main/java/stirling/software/common/util/PdfUtils.java index ec269e47d..6f4305bd3 100644 --- a/app/common/src/main/java/stirling/software/common/util/PdfUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/PdfUtils.java @@ -35,6 +35,7 @@ import io.github.pixee.security.Filenames; import lombok.extern.slf4j.Slf4j; +import stirling.software.common.model.ApplicationProperties; import stirling.software.common.service.CustomPDFDocumentFactory; @Slf4j @@ -145,13 +146,18 @@ public class PdfUtils { throws IOException, Exception { // Validate and limit DPI to prevent excessive memory usage - final int MAX_SAFE_DPI = 500; // Maximum safe DPI to prevent memory issues - if (DPI > MAX_SAFE_DPI) { + int maxSafeDpi = 500; // Default maximum safe DPI + ApplicationProperties properties = + ApplicationContextProvider.getBean(ApplicationProperties.class); + if (properties != null && properties.getSystem() != null) { + maxSafeDpi = properties.getSystem().getMaxDPI(); + } + if (DPI > maxSafeDpi) { throw ExceptionUtils.createIllegalArgumentException( "error.dpiExceedsLimit", "DPI value {0} exceeds maximum safe limit of {1}. High DPI values can cause memory issues and crashes. Please use a lower DPI value.", DPI, - MAX_SAFE_DPI); + maxSafeDpi); } try (PDDocument document = pdfDocumentFactory.load(inputStream)) { diff --git a/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java b/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java index 6d72855fb..2c4546ac0 100644 --- a/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java +++ b/app/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java @@ -7,24 +7,19 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import org.junit.jupiter.api.BeforeEach; - -import java.util.Arrays; import java.util.function.Supplier; import org.aspectj.lang.ProceedingJoinPoint; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; import org.mockito.Captor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.ResponseEntity; @@ -45,62 +40,44 @@ class AutoJobPostMappingIntegrationTest { private AutoJobAspect autoJobAspect; - @Mock - private JobExecutorService jobExecutorService; + @Mock private JobExecutorService jobExecutorService; - @Mock - private HttpServletRequest request; + @Mock private HttpServletRequest request; - @Mock - private FileOrUploadService fileOrUploadService; + @Mock private FileOrUploadService fileOrUploadService; - @Mock - private FileStorage fileStorage; + @Mock private FileStorage fileStorage; + @Mock private ResourceMonitor resourceMonitor; - @Mock - private ResourceMonitor resourceMonitor; - - @Mock - private JobQueue jobQueue; + @Mock private JobQueue jobQueue; @BeforeEach void setUp() { - autoJobAspect = new AutoJobAspect( - jobExecutorService, - request, - fileOrUploadService, - fileStorage - ); + autoJobAspect = + new AutoJobAspect(jobExecutorService, request, fileOrUploadService, fileStorage); } - @Mock - private ProceedingJoinPoint joinPoint; + @Mock private ProceedingJoinPoint joinPoint; - @Mock - private AutoJobPostMapping autoJobPostMapping; + @Mock private AutoJobPostMapping autoJobPostMapping; - @Captor - private ArgumentCaptor> workCaptor; + @Captor private ArgumentCaptor> workCaptor; - @Captor - private ArgumentCaptor asyncCaptor; + @Captor private ArgumentCaptor asyncCaptor; - @Captor - private ArgumentCaptor timeoutCaptor; + @Captor private ArgumentCaptor timeoutCaptor; - @Captor - private ArgumentCaptor queueableCaptor; + @Captor private ArgumentCaptor queueableCaptor; - @Captor - private ArgumentCaptor resourceWeightCaptor; + @Captor private ArgumentCaptor resourceWeightCaptor; @Test void shouldExecuteWithCustomParameters() throws Throwable { // Given PDFFile pdfFile = new PDFFile(); pdfFile.setFileId("test-file-id"); - Object[] args = new Object[] { pdfFile }; + Object[] args = new Object[] {pdfFile}; when(joinPoint.getArgs()).thenReturn(args); when(request.getParameter("async")).thenReturn("true"); @@ -113,9 +90,8 @@ class AutoJobPostMappingIntegrationTest { MultipartFile mockFile = mock(MultipartFile.class); when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile); - when(jobExecutorService.runJobGeneric( - anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt())) + anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt())) .thenReturn(ResponseEntity.ok("success")); // When @@ -124,12 +100,13 @@ class AutoJobPostMappingIntegrationTest { // Then assertEquals(ResponseEntity.ok("success"), result); - verify(jobExecutorService).runJobGeneric( - asyncCaptor.capture(), - workCaptor.capture(), - timeoutCaptor.capture(), - queueableCaptor.capture(), - resourceWeightCaptor.capture()); + verify(jobExecutorService) + .runJobGeneric( + asyncCaptor.capture(), + workCaptor.capture(), + timeoutCaptor.capture(), + queueableCaptor.capture(), + resourceWeightCaptor.capture()); assertTrue(asyncCaptor.getValue(), "Async should be true"); assertEquals(60000L, timeoutCaptor.getValue(), "Timeout should be 60000ms"); @@ -158,11 +135,12 @@ class AutoJobPostMappingIntegrationTest { // Mock jobExecutorService to execute the work immediately when(jobExecutorService.runJobGeneric( - anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt())) - .thenAnswer(invocation -> { - Supplier work = invocation.getArgument(1); - return work.get(); - }); + anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt())) + .thenAnswer( + invocation -> { + Supplier work = invocation.getArgument(1); + return work.get(); + }); // When Object result = autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping); @@ -179,7 +157,7 @@ class AutoJobPostMappingIntegrationTest { // Given PDFFile pdfFile = new PDFFile(); pdfFile.setFileInput(mock(MultipartFile.class)); - Object[] args = new Object[] { pdfFile }; + Object[] args = new Object[] {pdfFile}; when(joinPoint.getArgs()).thenReturn(args); when(request.getParameter("async")).thenReturn("true"); @@ -190,14 +168,16 @@ class AutoJobPostMappingIntegrationTest { // Mock job executor to return a successful response when(jobExecutorService.runJobGeneric( - anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt())) + anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt())) .thenReturn(ResponseEntity.ok("success")); // When autoJobAspect.wrapWithJobExecution(joinPoint, autoJobPostMapping); // Then - assertEquals("stored-file-id", pdfFile.getFileId(), + assertEquals( + "stored-file-id", + pdfFile.getFileId(), "FileId should be set to the stored file id"); assertNotNull(pdfFile.getFileInput(), "FileInput should be replaced with persistent file"); diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesDynamicYamlPropertySourceTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesDynamicYamlPropertySourceTest.java new file mode 100644 index 000000000..71d3997be --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesDynamicYamlPropertySourceTest.java @@ -0,0 +1,59 @@ +package stirling.software.common.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.StandardEnvironment; + +import stirling.software.common.configuration.InstallationPathConfig; + +class ApplicationPropertiesDynamicYamlPropertySourceTest { + + @Test + void loads_yaml_into_environment() throws Exception { + // YAML-Config in Temp-Datei schreiben + String yaml = + "" + + "ui:\n" + + " appName: \"My App\"\n" + + "system:\n" + + " enableAnalytics: true\n"; + Path tmp = Files.createTempFile("spdf-settings-", ".yml"); + Files.writeString(tmp, yaml); + + // Pfad per statischem Mock liefern + try (MockedStatic mocked = + Mockito.mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(tmp.toString()); + + ConfigurableEnvironment env = new StandardEnvironment(); + ApplicationProperties props = new ApplicationProperties(); + + props.dynamicYamlPropertySource(env); // fügt PropertySource an erster Stelle ein + + assertEquals("My App", env.getProperty("ui.appName")); + assertEquals("true", env.getProperty("system.enableAnalytics")); + } + } + + @Test + void throws_when_settings_file_missing() throws Exception { + String missing = "/path/does/not/exist/spdf.yml"; + try (MockedStatic mocked = + Mockito.mockStatic(InstallationPathConfig.class)) { + mocked.when(InstallationPathConfig::getSettingsPath).thenReturn(missing); + + ConfigurableEnvironment env = new StandardEnvironment(); + ApplicationProperties props = new ApplicationProperties(); + + assertThrows(IOException.class, () -> props.dynamicYamlPropertySource(env)); + } + } +} diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java new file mode 100644 index 000000000..da83fd462 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesLogicTest.java @@ -0,0 +1,248 @@ +package stirling.software.common.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import stirling.software.common.model.ApplicationProperties.Driver; +import stirling.software.common.model.ApplicationProperties.Premium; +import stirling.software.common.model.ApplicationProperties.Security; +import stirling.software.common.model.exception.UnsupportedProviderException; + +class ApplicationPropertiesLogicTest { + + @Test + void system_isAnalyticsEnabled_null_false_true() { + ApplicationProperties.System sys = new ApplicationProperties.System(); + + sys.setEnableAnalytics(null); + assertFalse(sys.isAnalyticsEnabled()); + + sys.setEnableAnalytics(Boolean.FALSE); + assertFalse(sys.isAnalyticsEnabled()); + + sys.setEnableAnalytics(Boolean.TRUE); + assertTrue(sys.isAnalyticsEnabled()); + } + + @Test + void tempFileManagement_defaults_and_overrides() { + ApplicationProperties.TempFileManagement tfm = + new ApplicationProperties.TempFileManagement(); + + String expectedBase = + java.lang.System.getProperty("java.io.tmpdir").replaceAll("/+$", "") + + "/stirling-pdf"; + assertEquals(expectedBase, tfm.getBaseTmpDir()); + + String expectedLibre = expectedBase + "/libreoffice"; + assertEquals(expectedLibre, tfm.getLibreofficeDir()); + + tfm.setBaseTmpDir("/custom/base"); + assertEquals("/custom/base", tfm.getBaseTmpDir()); + + tfm.setLibreofficeDir("/opt/libre"); + assertEquals("/opt/libre", tfm.getLibreofficeDir()); + } + + @Test + void oauth2_scope_parsing_and_validity() { + Security.OAUTH2 oauth2 = new Security.OAUTH2(); + oauth2.setIssuer("https://issuer"); + oauth2.setClientId("client"); + oauth2.setClientSecret("secret"); + oauth2.setUseAsUsername("email"); + oauth2.setScopes("openid, profile ,email"); + assertTrue(oauth2.isSettingsValid()); + } + + @Test + void security_login_method_flags() { + Security sec = new Security(); + + sec.getOauth2().setEnabled(true); + sec.getSaml2().setEnabled(true); + + assertTrue(sec.isUserPass()); + assertTrue(sec.isOauth2Active()); + assertTrue(sec.isSaml2Active()); + + sec.setLoginMethod(Security.LoginMethods.NORMAL.toString()); + assertTrue(sec.isUserPass()); + assertFalse(sec.isOauth2Active()); + assertFalse(sec.isSaml2Active()); + } + + @Test + void security_isAltLogin_reflects_oauth2_or_saml2() { + Security sec = new Security(); + + assertFalse(sec.isAltLogin()); + + sec.getOauth2().setEnabled(true); + sec.getSaml2().setEnabled(false); + assertTrue(sec.isAltLogin()); + + sec.getOauth2().setEnabled(false); + sec.getSaml2().setEnabled(true); + assertTrue(sec.isAltLogin()); + + sec.getOauth2().setEnabled(true); + sec.getSaml2().setEnabled(true); + assertTrue(sec.isAltLogin()); + } + + @Test + void oauth2_client_provider_mapping_and_unsupported() throws UnsupportedProviderException { + Security.OAUTH2.Client client = new Security.OAUTH2.Client(); + + assertNotNull(client.get("google")); + assertNotNull(client.get("github")); + assertNotNull(client.get("keycloak")); + + UnsupportedProviderException ex = + assertThrows(UnsupportedProviderException.class, () -> client.get("unknown")); + assertTrue(ex.getMessage().toLowerCase().contains("not supported")); + } + + @Test + void premium_google_drive_getters_return_empty_string_on_null_or_blank() { + Premium.ProFeatures.GoogleDrive gd = new Premium.ProFeatures.GoogleDrive(); + + assertEquals("", gd.getClientId()); + assertEquals("", gd.getApiKey()); + assertEquals("", gd.getAppId()); + + gd.setClientId(" id "); + gd.setApiKey(" key "); + gd.setAppId(" app "); + assertEquals(" id ", gd.getClientId()); + assertEquals(" key ", gd.getApiKey()); + assertEquals(" app ", gd.getAppId()); + } + + @Test + void ui_getters_return_null_for_blank() { + ApplicationProperties.Ui ui = new ApplicationProperties.Ui(); + ui.setAppName(" "); + ui.setHomeDescription(""); + ui.setAppNameNavbar(null); + + assertNull(ui.getAppName()); + assertNull(ui.getHomeDescription()); + assertNull(ui.getAppNameNavbar()); + + ui.setAppName("Stirling-PDF"); + ui.setHomeDescription("Home"); + ui.setAppNameNavbar("Nav"); + assertEquals("Stirling-PDF", ui.getAppName()); + assertEquals("Home", ui.getHomeDescription()); + assertEquals("Nav", ui.getAppNameNavbar()); + } + + @Test + void driver_toString_contains_driver_name() { + assertTrue(Driver.H2.toString().contains("h2")); + assertTrue(Driver.POSTGRESQL.toString().contains("postgresql")); + } + + @Test + void session_limits_and_timeouts_have_reasonable_defaults() { + ApplicationProperties.ProcessExecutor pe = new ApplicationProperties.ProcessExecutor(); + + ApplicationProperties.ProcessExecutor.SessionLimit s = pe.getSessionLimit(); + assertEquals(2, s.getQpdfSessionLimit()); + assertEquals(1, s.getTesseractSessionLimit()); + assertEquals(1, s.getLibreOfficeSessionLimit()); + assertEquals(1, s.getPdfToHtmlSessionLimit()); + assertEquals(8, s.getPythonOpenCvSessionLimit()); + assertEquals(16, s.getWeasyPrintSessionLimit()); + assertEquals(1, s.getInstallAppSessionLimit()); + assertEquals(1, s.getCalibreSessionLimit()); + assertEquals(8, s.getGhostscriptSessionLimit()); + assertEquals(2, s.getOcrMyPdfSessionLimit()); + + ApplicationProperties.ProcessExecutor.TimeoutMinutes t = pe.getTimeoutMinutes(); + assertEquals(30, t.getTesseractTimeoutMinutes()); + assertEquals(30, t.getQpdfTimeoutMinutes()); + assertEquals(30, t.getLibreOfficeTimeoutMinutes()); + assertEquals(20, t.getPdfToHtmlTimeoutMinutes()); + assertEquals(30, t.getPythonOpenCvTimeoutMinutes()); + assertEquals(30, t.getWeasyPrintTimeoutMinutes()); + assertEquals(60, t.getInstallAppTimeoutMinutes()); + assertEquals(30, t.getCalibreTimeoutMinutes()); + assertEquals(30, t.getGhostscriptTimeoutMinutes()); + assertEquals(30, t.getOcrMyPdfTimeoutMinutes()); + } + + @Deprecated + @Test + void enterprise_metadata_defaults() { + ApplicationProperties.EnterpriseEdition ee = new ApplicationProperties.EnterpriseEdition(); + ApplicationProperties.EnterpriseEdition.CustomMetadata eMeta = ee.getCustomMetadata(); + eMeta.setCreator(" "); + eMeta.setProducer(null); + assertEquals("Stirling-PDF", eMeta.getCreator()); + assertEquals("Stirling-PDF", eMeta.getProducer()); + } + + @Test + void premium_metadata_defaults() { + Premium.ProFeatures pf = new Premium.ProFeatures(); + Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata(); + pMeta.setCreator(""); + pMeta.setProducer(""); + assertEquals("Stirling-PDF", pMeta.getCreator()); + assertEquals("Stirling-PDF", pMeta.getProducer()); + } + + @Test + void premium_metadata_awesome() { + Premium.ProFeatures pf = new Premium.ProFeatures(); + Premium.ProFeatures.CustomMetadata pMeta = pf.getCustomMetadata(); + pMeta.setCreator("Awesome PDF Tool"); + pMeta.setProducer("Awesome PDF Tool"); + assertEquals("Awesome PDF Tool", pMeta.getCreator()); + assertEquals("Awesome PDF Tool", pMeta.getProducer()); + } + + @Test + void string_isValid_handles_null_empty_blank_and_trimmed() { + ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2(); + + assertFalse(oauth2.isValid((String) null, "issuer")); + assertFalse(oauth2.isValid("", "issuer")); + assertFalse(oauth2.isValid(" ", "issuer")); + + assertTrue(oauth2.isValid("x", "issuer")); + assertTrue(oauth2.isValid(" x ", "issuer")); // trimmt intern + } + + @Test + void collection_isValid_handles_null_and_empty() { + ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2(); + + Collection nullColl = null; + Collection empty = List.of(); + + assertFalse(oauth2.isValid(nullColl, "scopes")); + assertFalse(oauth2.isValid(empty, "scopes")); + } + + @Test + void collection_isValid_true_when_non_empty_even_if_element_is_blank() { + ApplicationProperties.Security.OAUTH2 oauth2 = new ApplicationProperties.Security.OAUTH2(); + + // Aktuelles Verhalten: prüft NUR !isEmpty(), nicht Inhalt + Collection oneBlank = new ArrayList<>(); + oneBlank.add(" "); + + assertTrue( + oauth2.isValid(oneBlank, "scopes"), + "Dokumentiert aktuelles Verhalten: nicht-leere Liste gilt als gültig, auch wenn Element leer/blank ist"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesSaml2HttpTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesSaml2HttpTest.java new file mode 100644 index 000000000..3fa8299ca --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesSaml2HttpTest.java @@ -0,0 +1,80 @@ +package stirling.software.common.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +class ApplicationPropertiesSaml2HttpTest { + + @Test + void idpMetadataUri_http_is_resolved_via_mockwebserver() throws Exception { + try (MockWebServer server = new MockWebServer()) { + server.enqueue( + new MockResponse() + .setResponseCode(200) + .addHeader("Content-Type", "application/xml") + .setBody("")); + server.start(); + + String url = server.url("/meta").toString(); + + var s = new ApplicationProperties.Security.SAML2(); + s.setIdpMetadataUri(url); + + try (InputStream in = s.getIdpMetadataUri()) { + String body = new String(in.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(body.contains("EntityDescriptor")); + } + } + } + + @Test + void idpMetadataUri_invalidUri_triggers_catch_and_throwsIOException() { + // Ungültige URI -> new URI(...) wirft URISyntaxException -> catch -> IOException + var s = new ApplicationProperties.Security.SAML2(); + s.setIdpMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##) + + assertThrows(IOException.class, s::getIdpMetadataUri); + } + + @Test + void spCert_else_branch_returns_FileSystemResource_for_filesystem_path() throws Exception { + var s = new ApplicationProperties.Security.SAML2(); + + // temporäre Datei simuliert "Filesystem"-Pfad (-> else-Zweig) + Path tmp = Files.createTempFile("spdf-spcert-", ".crt"); + Files.writeString(tmp, "CERT"); + + s.setSpCert(tmp.toString()); + Resource r = s.getSpCert(); + + assertNotNull(r); + assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path"); + assertTrue(r.exists(), "Temp file should exist"); + } + + @Test + void idpCert_else_branch_returns_FileSystemResource_even_if_missing() { + var s = new ApplicationProperties.Security.SAML2(); + + // bewusst nicht existierender Pfad -> else-Zweig wird trotzdem genommen + String missing = "/this/path/does/not/exist/idp.crt"; + s.setIdpCert(missing); + Resource r = s.getIdpCert(); + + assertNotNull(r); + assertTrue(r instanceof FileSystemResource, "Expected FileSystemResource for FS path"); + assertFalse(r.exists(), "Resource should not exist for missing file"); + } +} diff --git a/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesSaml2ResourceTest.java b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesSaml2ResourceTest.java new file mode 100644 index 000000000..efc266561 --- /dev/null +++ b/app/common/src/test/java/stirling/software/common/model/ApplicationPropertiesSaml2ResourceTest.java @@ -0,0 +1,55 @@ +package stirling.software.common.model; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.Resource; + +class ApplicationPropertiesSaml2ResourceTest { + + @Test + void idpMetadataUri_classpath_is_resolved() throws Exception { + var s = new ApplicationProperties.Security.SAML2(); + s.setIdpMetadataUri("classpath:saml/dummy.txt"); + + try (InputStream in = s.getIdpMetadataUri()) { + assertNotNull(in, "Classpath InputStream should not be null"); + String txt = new String(in.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(txt.contains("ok")); + } + } + + @Test + void spCert_idpCert_privateKey_null_classpath_and_filesystem() throws Exception { + var s = new ApplicationProperties.Security.SAML2(); + + s.setSpCert(null); + s.setIdpCert(null); + s.setPrivateKey(null); + assertNull(s.getSpCert()); + assertNull(s.getIdpCert()); + assertNull(s.getPrivateKey()); + + s.setSpCert("classpath:saml/dummy.txt"); + s.setIdpCert("classpath:saml/dummy.txt"); + s.setPrivateKey("classpath:saml/dummy.txt"); + Resource sp = s.getSpCert(); + Resource idp = s.getIdpCert(); + Resource pk = s.getPrivateKey(); + assertTrue(sp.exists()); + assertTrue(idp.exists()); + assertTrue(pk.exists()); + + Path tmp = Files.createTempFile("spdf-key-", ".pem"); + Files.writeString(tmp, "KEY"); + s.setPrivateKey(tmp.toString()); + Resource pkFs = s.getPrivateKey(); + assertNotNull(pkFs); + assertTrue(pkFs.exists()); + } +} diff --git a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java index 81ab5857e..76f3e353e 100644 --- a/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java +++ b/app/common/src/test/java/stirling/software/common/service/FileStorageTest.java @@ -1,10 +1,9 @@ package stirling.software.common.service; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; import static org.mockito.AdditionalAnswers.*; +import static org.mockito.Mockito.*; -import java.io.ByteArrayInputStream; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -21,14 +20,11 @@ import org.springframework.web.multipart.MultipartFile; class FileStorageTest { - @TempDir - Path tempDir; + @TempDir Path tempDir; - @Mock - private FileOrUploadService fileOrUploadService; + @Mock private FileOrUploadService fileOrUploadService; - @InjectMocks - private FileStorage fileStorage; + @InjectMocks private FileStorage fileStorage; private MultipartFile mockFile; @@ -50,11 +46,14 @@ class FileStorageTest { when(mockFile.getBytes()).thenReturn(fileContent); // Set up mock to handle transferTo by writing the file - doAnswer(invocation -> { - java.io.File file = invocation.getArgument(0); - Files.write(file.toPath(), fileContent); - return null; - }).when(mockFile).transferTo(any(java.io.File.class)); + doAnswer( + invocation -> { + java.io.File file = invocation.getArgument(0); + Files.write(file.toPath(), fileContent); + return null; + }) + .when(mockFile) + .transferTo(any(java.io.File.class)); // Act String fileId = fileStorage.storeFile(mockFile); @@ -90,7 +89,7 @@ class FileStorageTest { MultipartFile expectedFile = mock(MultipartFile.class); when(fileOrUploadService.toMockMultipartFile(eq(fileId), eq(fileContent))) - .thenReturn(expectedFile); + .thenReturn(expectedFile); // Act MultipartFile result = fileStorage.retrieveFile(fileId); diff --git a/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java index 370db503b..630ac80bf 100644 --- a/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java +++ b/app/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java @@ -4,14 +4,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doAnswer; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -30,11 +25,9 @@ import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.test.util.ReflectionTestUtils; import jakarta.servlet.http.HttpServletRequest; -import stirling.software.common.model.job.JobProgress; import stirling.software.common.model.job.JobResponse; @ExtendWith(MockitoExtension.class) @@ -42,36 +35,31 @@ class JobExecutorServiceTest { private JobExecutorService jobExecutorService; - @Mock - private TaskManager taskManager; + @Mock private TaskManager taskManager; - @Mock - private FileStorage fileStorage; + @Mock private FileStorage fileStorage; - @Mock - private HttpServletRequest request; + @Mock private HttpServletRequest request; - @Mock - private ResourceMonitor resourceMonitor; + @Mock private ResourceMonitor resourceMonitor; - @Mock - private JobQueue jobQueue; + @Mock private JobQueue jobQueue; - @Captor - private ArgumentCaptor jobIdCaptor; + @Captor private ArgumentCaptor jobIdCaptor; @BeforeEach void setUp() { // Initialize the service manually with all its dependencies - jobExecutorService = new JobExecutorService( - taskManager, - fileStorage, - request, - resourceMonitor, - jobQueue, - 30000L, // asyncRequestTimeoutMs - "30m" // sessionTimeout - ); + jobExecutorService = + new JobExecutorService( + taskManager, + fileStorage, + request, + resourceMonitor, + jobQueue, + 30000L, // asyncRequestTimeoutMs + "30m" // sessionTimeout + ); } @Test @@ -109,13 +97,13 @@ class JobExecutorServiceTest { verify(taskManager).createTask(jobIdCaptor.capture()); } - @Test void shouldHandleSyncJobError() { // Given - Supplier work = () -> { - throw new RuntimeException("Test error"); - }; + Supplier work = + () -> { + throw new RuntimeException("Test error"); + }; // When ResponseEntity response = jobExecutorService.runJobGeneric(false, work); @@ -141,8 +129,7 @@ class JobExecutorServiceTest { when(jobQueue.queueJob(anyString(), eq(80), any(), anyLong())).thenReturn(future); // When - ResponseEntity response = jobExecutorService.runJobGeneric( - true, work, 5000, true, 80); + ResponseEntity response = jobExecutorService.runJobGeneric(true, work, 5000, true, 80); // Then assertEquals(HttpStatus.OK, response.getStatusCode()); @@ -160,8 +147,9 @@ class JobExecutorServiceTest { long customTimeout = 60000L; // Use reflection to access the private executeWithTimeout method - java.lang.reflect.Method executeMethod = JobExecutorService.class - .getDeclaredMethod("executeWithTimeout", Supplier.class, long.class); + java.lang.reflect.Method executeMethod = + JobExecutorService.class.getDeclaredMethod( + "executeWithTimeout", Supplier.class, long.class); executeMethod.setAccessible(true); // Create a spy on the JobExecutorService to verify method calls @@ -177,19 +165,21 @@ class JobExecutorServiceTest { @Test void shouldHandleTimeout() throws Exception { // Given - Supplier work = () -> { - try { - Thread.sleep(100); // Simulate long-running job - return "test-result"; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException(e); - } - }; + Supplier work = + () -> { + try { + Thread.sleep(100); // Simulate long-running job + return "test-result"; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + }; // Use reflection to access the private executeWithTimeout method - java.lang.reflect.Method executeMethod = JobExecutorService.class - .getDeclaredMethod("executeWithTimeout", Supplier.class, long.class); + java.lang.reflect.Method executeMethod = + JobExecutorService.class.getDeclaredMethod( + "executeWithTimeout", Supplier.class, long.class); executeMethod.setAccessible(true); // When/Then diff --git a/app/common/src/test/java/stirling/software/common/service/JobQueueTest.java b/app/common/src/test/java/stirling/software/common/service/JobQueueTest.java index 64c836faf..ca0698436 100644 --- a/app/common/src/test/java/stirling/software/common/service/JobQueueTest.java +++ b/app/common/src/test/java/stirling/software/common/service/JobQueueTest.java @@ -1,10 +1,8 @@ package stirling.software.common.service; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Map; @@ -17,7 +15,6 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import stirling.software.common.model.job.JobProgress; import stirling.software.common.service.ResourceMonitor.ResourceStatus; @ExtendWith(MockitoExtension.class) @@ -25,16 +22,17 @@ class JobQueueTest { private JobQueue jobQueue; - @Mock - private ResourceMonitor resourceMonitor; + @Mock private ResourceMonitor resourceMonitor; - - private final AtomicReference statusRef = new AtomicReference<>(ResourceStatus.OK); + private final AtomicReference statusRef = + new AtomicReference<>(ResourceStatus.OK); @BeforeEach void setUp() { // Mark stubbing as lenient to avoid UnnecessaryStubbingException - lenient().when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(10); + lenient() + .when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())) + .thenReturn(10); lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef); // Initialize JobQueue with mocked ResourceMonitor @@ -50,7 +48,6 @@ class JobQueueTest { jobQueue.queueJob(jobId, resourceWeight, work, timeoutMs); - assertTrue(jobQueue.isJobQueued(jobId)); assertEquals(1, jobQueue.getTotalQueuedJobs()); } diff --git a/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java b/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java index 9667f4999..25a098764 100644 --- a/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java +++ b/app/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java @@ -1,14 +1,10 @@ package stirling.software.common.service; -import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; import java.lang.management.MemoryMXBean; -import java.lang.management.MemoryUsage; import java.lang.management.OperatingSystemMXBean; import java.time.Instant; import java.util.concurrent.atomic.AtomicReference; @@ -30,20 +26,19 @@ import stirling.software.common.service.ResourceMonitor.ResourceStatus; @ExtendWith(MockitoExtension.class) class ResourceMonitorTest { - @InjectMocks - private ResourceMonitor resourceMonitor; + @InjectMocks private ResourceMonitor resourceMonitor; - @Mock - private OperatingSystemMXBean osMXBean; + @Mock private OperatingSystemMXBean osMXBean; - @Mock - private MemoryMXBean memoryMXBean; + @Mock private MemoryMXBean memoryMXBean; @Spy - private AtomicReference currentStatus = new AtomicReference<>(ResourceStatus.OK); + private AtomicReference currentStatus = + new AtomicReference<>(ResourceStatus.OK); @Spy - private AtomicReference latestMetrics = new AtomicReference<>(new ResourceMetrics()); + private AtomicReference latestMetrics = + new AtomicReference<>(new ResourceMetrics()); @BeforeEach void setUp() { @@ -92,23 +87,26 @@ class ResourceMonitorTest { assertEquals(3, capacity, "With CRITICAL status, capacity should be reduced to 30%"); // Test minimum capacity enforcement - assertEquals(minCapacity, resourceMonitor.calculateDynamicQueueCapacity(1, minCapacity), + assertEquals( + minCapacity, + resourceMonitor.calculateDynamicQueueCapacity(1, minCapacity), "Should never go below minimum capacity"); } @ParameterizedTest @CsvSource({ - "10, OK, false", // Light job, OK status + "10, OK, false", // Light job, OK status "10, WARNING, false", // Light job, WARNING status "10, CRITICAL, true", // Light job, CRITICAL status - "30, OK, false", // Medium job, OK status - "30, WARNING, true", // Medium job, WARNING status + "30, OK, false", // Medium job, OK status + "30, WARNING, true", // Medium job, WARNING status "30, CRITICAL, true", // Medium job, CRITICAL status - "80, OK, true", // Heavy job, OK status - "80, WARNING, true", // Heavy job, WARNING status - "80, CRITICAL, true" // Heavy job, CRITICAL status + "80, OK, true", // Heavy job, OK status + "80, WARNING, true", // Heavy job, WARNING status + "80, CRITICAL, true" // Heavy job, CRITICAL status }) - void shouldQueueJobBasedOnWeightAndStatus(int weight, ResourceStatus status, boolean shouldQueue) { + void shouldQueueJobBasedOnWeightAndStatus( + int weight, ResourceStatus status, boolean shouldQueue) { // Given currentStatus.set(status); @@ -116,8 +114,11 @@ class ResourceMonitorTest { boolean result = resourceMonitor.shouldQueueJob(weight); // Then - assertEquals(shouldQueue, result, - String.format("For weight %d and status %s, shouldQueue should be %s", + assertEquals( + shouldQueue, + result, + String.format( + "For weight %d and status %s, shouldQueue should be %s", weight, status, shouldQueue)); } @@ -131,7 +132,9 @@ class ResourceMonitorTest { ResourceMetrics freshMetrics = new ResourceMetrics(0.5, 0.5, 1024, 2048, 4096, now); // When/Then - assertTrue(staleMetrics.isStale(5000), "Metrics from 6 seconds ago should be stale with 5s threshold"); + assertTrue( + staleMetrics.isStale(5000), + "Metrics from 6 seconds ago should be stale with 5s threshold"); assertFalse(freshMetrics.isStale(5000), "Fresh metrics should not be stale"); } } diff --git a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java index 5fd2dcc87..5b8027b62 100644 --- a/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TaskManagerTest.java @@ -6,7 +6,6 @@ import static org.mockito.Mockito.*; import java.time.LocalDateTime; import java.util.Map; import java.util.UUID; -import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -22,11 +21,9 @@ import stirling.software.common.model.job.ResultFile; class TaskManagerTest { - @Mock - private FileStorage fileStorage; + @Mock private FileStorage fileStorage; - @InjectMocks - private TaskManager taskManager; + @InjectMocks private TaskManager taskManager; private AutoCloseable closeable; @@ -234,18 +231,20 @@ class TaskManagerTest { ReflectionTestUtils.setField(oldJob, "complete", true); // Create a ResultFile and set it using the new approach - ResultFile resultFile = ResultFile.builder() - .fileId("file-id") - .fileName("test.pdf") - .contentType("application/pdf") - .fileSize(1024L) - .build(); + ResultFile resultFile = + ResultFile.builder() + .fileId("file-id") + .fileName("test.pdf") + .contentType("application/pdf") + .fileSize(1024L) + .build(); ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile)); when(fileStorage.deleteFile("file-id")).thenReturn(true); // Obtain access to the private jobResults map - Map jobResultsMap = (Map) ReflectionTestUtils.getField(taskManager, "jobResults"); + Map jobResultsMap = + (Map) ReflectionTestUtils.getField(taskManager, "jobResults"); // 3. Create an active job String activeJobId = "active-job"; diff --git a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java index 34c471227..0418a746e 100644 --- a/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java +++ b/app/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java @@ -12,7 +12,6 @@ import java.nio.file.Path; import java.nio.file.attribute.FileTime; import java.util.HashSet; import java.util.Set; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.stream.Stream; @@ -30,31 +29,22 @@ import stirling.software.common.model.ApplicationProperties; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileRegistry; -/** - * Tests for the TempFileCleanupService, focusing on its pattern-matching and cleanup logic. - */ +/** Tests for the TempFileCleanupService, focusing on its pattern-matching and cleanup logic. */ public class TempFileCleanupServiceTest { - @TempDir - Path tempDir; + @TempDir Path tempDir; - @Mock - private TempFileRegistry registry; + @Mock private TempFileRegistry registry; - @Mock - private TempFileManager tempFileManager; + @Mock private TempFileManager tempFileManager; - @Mock - private ApplicationProperties applicationProperties; + @Mock private ApplicationProperties applicationProperties; - @Mock - private ApplicationProperties.System system; + @Mock private ApplicationProperties.System system; - @Mock - private ApplicationProperties.TempFileManagement tempFileManagement; + @Mock private ApplicationProperties.TempFileManagement tempFileManagement; - @InjectMocks - private TempFileCleanupService cleanupService; + @InjectMocks private TempFileCleanupService cleanupService; private Path systemTempDir; private Path customTempDir; @@ -124,7 +114,8 @@ public class TempFileCleanupServiceTest { // Files that should be preserved Path jettyFile1 = Files.createFile(systemTempDir.resolve("jetty-123.tmp")); - Path jettyFile2 = Files.createFile(systemTempDir.resolve("something-with-jetty-inside.tmp")); + Path jettyFile2 = + Files.createFile(systemTempDir.resolve("something-with-jetty-inside.tmp")); Path regularFile = Files.createFile(systemTempDir.resolve("important.txt")); // Create a nested directory with temp files @@ -143,19 +134,29 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { // Mock Files.list for each directory we'll process - mockedFiles.when(() -> Files.list(eq(systemTempDir))) - .thenReturn(Stream.of( - ourTempFile1, ourTempFile2, oldTempFile, sysTempFile1, - jettyFile1, jettyFile2, regularFile, emptyFile, nestedDir)); + mockedFiles + .when(() -> Files.list(eq(systemTempDir))) + .thenReturn( + Stream.of( + ourTempFile1, + ourTempFile2, + oldTempFile, + sysTempFile1, + jettyFile1, + jettyFile2, + regularFile, + emptyFile, + nestedDir)); - mockedFiles.when(() -> Files.list(eq(customTempDir))) + mockedFiles + .when(() -> Files.list(eq(customTempDir))) .thenReturn(Stream.of(ourTempFile3, ourTempFile4, sysTempFile2, sysTempFile3)); - mockedFiles.when(() -> Files.list(eq(libreOfficeTempDir))) + mockedFiles + .when(() -> Files.list(eq(libreOfficeTempDir))) .thenReturn(Stream.of(ourTempFile5)); - mockedFiles.when(() -> Files.list(eq(nestedDir))) - .thenReturn(Stream.of(nestedTempFile)); + mockedFiles.when(() -> Files.list(eq(nestedDir))).thenReturn(Stream.of(nestedTempFile)); // Configure Files.isDirectory for each path mockedFiles.when(() -> Files.isDirectory(eq(nestedDir))).thenReturn(true); @@ -165,48 +166,59 @@ public class TempFileCleanupServiceTest { mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true); // Configure Files.getLastModifiedTime to return different times based on file names - mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class))) - .thenAnswer(invocation -> { - Path path = invocation.getArgument(0); - String fileName = path.getFileName().toString(); + mockedFiles + .when(() -> Files.getLastModifiedTime(any(Path.class))) + .thenAnswer( + invocation -> { + Path path = invocation.getArgument(0); + String fileName = path.getFileName().toString(); - // For files with "old" in the name, return a timestamp older than maxAgeMillis - if (fileName.contains("old")) { - return FileTime.fromMillis(System.currentTimeMillis() - 5000000); - } - // For empty.tmp file, return a timestamp older than 5 minutes (for empty file test) - else if (fileName.equals("empty.tmp")) { - return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000); - } - // For all other files, return a recent timestamp - else { - return FileTime.fromMillis(System.currentTimeMillis() - 60000); // 1 minute ago - } - }); + // For files with "old" in the name, return a timestamp older than + // maxAgeMillis + if (fileName.contains("old")) { + return FileTime.fromMillis( + System.currentTimeMillis() - 5000000); + } + // For empty.tmp file, return a timestamp older than 5 minutes (for + // empty file test) + else if (fileName.equals("empty.tmp")) { + return FileTime.fromMillis( + System.currentTimeMillis() - 6 * 60 * 1000); + } + // For all other files, return a recent timestamp + else { + return FileTime.fromMillis( + System.currentTimeMillis() - 60000); // 1 minute ago + } + }); // Configure Files.size to return different sizes based on file names - mockedFiles.when(() -> Files.size(any(Path.class))) - .thenAnswer(invocation -> { - Path path = invocation.getArgument(0); - String fileName = path.getFileName().toString(); + mockedFiles + .when(() -> Files.size(any(Path.class))) + .thenAnswer( + invocation -> { + Path path = invocation.getArgument(0); + String fileName = path.getFileName().toString(); - // Return 0 bytes for the empty file - if (fileName.equals("empty.tmp")) { - return 0L; - } - // Return normal size for all other files - else { - return 1024L; // 1 KB - } - }); + // Return 0 bytes for the empty file + if (fileName.equals("empty.tmp")) { + return 0L; + } + // Return normal size for all other files + else { + return 1024L; // 1 KB + } + }); // For deleteIfExists, track which files would be deleted - mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) - .thenAnswer(invocation -> { - Path path = invocation.getArgument(0); - deletedFiles.add(path); - return true; - }); + mockedFiles + .when(() -> Files.deleteIfExists(any(Path.class))) + .thenAnswer( + invocation -> { + Path path = invocation.getArgument(0); + deletedFiles.add(path); + return true; + }); // Act - set containerMode to false for this test invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000); @@ -218,20 +230,33 @@ public class TempFileCleanupServiceTest { assertTrue(deletedFiles.contains(emptyFile), "Empty file should be deleted"); // Regular temp files should not be deleted because they're too new - assertFalse(deletedFiles.contains(ourTempFile1), "Recent temp file should be preserved"); - assertFalse(deletedFiles.contains(ourTempFile2), "Recent temp file should be preserved"); - assertFalse(deletedFiles.contains(ourTempFile3), "Recent temp file should be preserved"); - assertFalse(deletedFiles.contains(ourTempFile4), "Recent temp file should be preserved"); - assertFalse(deletedFiles.contains(ourTempFile5), "Recent temp file should be preserved"); + assertFalse( + deletedFiles.contains(ourTempFile1), "Recent temp file should be preserved"); + assertFalse( + deletedFiles.contains(ourTempFile2), "Recent temp file should be preserved"); + assertFalse( + deletedFiles.contains(ourTempFile3), "Recent temp file should be preserved"); + assertFalse( + deletedFiles.contains(ourTempFile4), "Recent temp file should be preserved"); + assertFalse( + deletedFiles.contains(ourTempFile5), "Recent temp file should be preserved"); // System temp files should not be deleted in non-container mode - assertFalse(deletedFiles.contains(sysTempFile1), "System temp file should be preserved in non-container mode"); - assertFalse(deletedFiles.contains(sysTempFile2), "System temp file should be preserved in non-container mode"); - assertFalse(deletedFiles.contains(sysTempFile3), "System temp file should be preserved in non-container mode"); + assertFalse( + deletedFiles.contains(sysTempFile1), + "System temp file should be preserved in non-container mode"); + assertFalse( + deletedFiles.contains(sysTempFile2), + "System temp file should be preserved in non-container mode"); + assertFalse( + deletedFiles.contains(sysTempFile3), + "System temp file should be preserved in non-container mode"); // Jetty files and regular files should never be deleted assertFalse(deletedFiles.contains(jettyFile1), "Jetty file should be preserved"); - assertFalse(deletedFiles.contains(jettyFile2), "File with jetty in name should be preserved"); + assertFalse( + deletedFiles.contains(jettyFile2), + "File with jetty in name should be preserved"); assertFalse(deletedFiles.contains(regularFile), "Regular file should be preserved"); } } @@ -252,7 +277,8 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { // Mock Files.list for systemTempDir - mockedFiles.when(() -> Files.list(eq(systemTempDir))) + mockedFiles + .when(() -> Files.list(eq(systemTempDir))) .thenReturn(Stream.of(ourTempFile, sysTempFile, regularFile)); // Configure Files.isDirectory @@ -262,28 +288,37 @@ public class TempFileCleanupServiceTest { mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true); // Configure Files.getLastModifiedTime to return recent timestamps - mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class))) - .thenReturn(FileTime.fromMillis(System.currentTimeMillis() - 60000)); // 1 minute ago + mockedFiles + .when(() -> Files.getLastModifiedTime(any(Path.class))) + .thenReturn( + FileTime.fromMillis( + System.currentTimeMillis() - 60000)); // 1 minute ago // Configure Files.size to return normal size - mockedFiles.when(() -> Files.size(any(Path.class))) - .thenReturn(1024L); // 1 KB + mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L); // 1 KB // For deleteIfExists, track which files would be deleted - mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) - .thenAnswer(invocation -> { - Path path = invocation.getArgument(0); - deletedFiles.add(path); - return true; - }); + mockedFiles + .when(() -> Files.deleteIfExists(any(Path.class))) + .thenAnswer( + invocation -> { + Path path = invocation.getArgument(0); + deletedFiles.add(path); + return true; + }); // Act - set containerMode to true and maxAgeMillis to 0 for container startup cleanup invokeCleanupDirectoryStreaming(systemTempDir, true, 0, 0); - // Assert - In container mode, both our temp files and system temp files should be deleted + // Assert - In container mode, both our temp files and system temp files should be + // deleted // regardless of age (when maxAgeMillis is 0) - assertTrue(deletedFiles.contains(ourTempFile), "Our temp file should be deleted in container mode"); - assertTrue(deletedFiles.contains(sysTempFile), "System temp file should be deleted in container mode"); + assertTrue( + deletedFiles.contains(ourTempFile), + "Our temp file should be deleted in container mode"); + assertTrue( + deletedFiles.contains(sysTempFile), + "System temp file should be deleted in container mode"); assertFalse(deletedFiles.contains(regularFile), "Regular file should be preserved"); } } @@ -303,7 +338,8 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { // Mock Files.list for systemTempDir - mockedFiles.when(() -> Files.list(eq(systemTempDir))) + mockedFiles + .when(() -> Files.list(eq(systemTempDir))) .thenReturn(Stream.of(emptyFile, recentEmptyFile)); // Configure Files.isDirectory @@ -313,39 +349,46 @@ public class TempFileCleanupServiceTest { mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true); // Configure Files.getLastModifiedTime to return different times based on file names - mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class))) - .thenAnswer(invocation -> { - Path path = invocation.getArgument(0); - String fileName = path.getFileName().toString(); + mockedFiles + .when(() -> Files.getLastModifiedTime(any(Path.class))) + .thenAnswer( + invocation -> { + Path path = invocation.getArgument(0); + String fileName = path.getFileName().toString(); - if (fileName.equals("empty.tmp")) { - // More than 5 minutes old - return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000); - } else { - // Less than 5 minutes old - return FileTime.fromMillis(System.currentTimeMillis() - 2 * 60 * 1000); - } - }); + if (fileName.equals("empty.tmp")) { + // More than 5 minutes old + return FileTime.fromMillis( + System.currentTimeMillis() - 6 * 60 * 1000); + } else { + // Less than 5 minutes old + return FileTime.fromMillis( + System.currentTimeMillis() - 2 * 60 * 1000); + } + }); // Configure Files.size to return 0 for empty files - mockedFiles.when(() -> Files.size(any(Path.class))) - .thenReturn(0L); + mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(0L); // For deleteIfExists, track which files would be deleted - mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) - .thenAnswer(invocation -> { - Path path = invocation.getArgument(0); - deletedFiles.add(path); - return true; - }); + mockedFiles + .when(() -> Files.deleteIfExists(any(Path.class))) + .thenAnswer( + invocation -> { + Path path = invocation.getArgument(0); + deletedFiles.add(path); + return true; + }); // Act invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000); // Assert - assertTrue(deletedFiles.contains(emptyFile), + assertTrue( + deletedFiles.contains(emptyFile), "Empty file older than 5 minutes should be deleted"); - assertFalse(deletedFiles.contains(recentEmptyFile), + assertFalse( + deletedFiles.contains(recentEmptyFile), "Empty file newer than 5 minutes should not be deleted"); } } @@ -370,17 +413,13 @@ public class TempFileCleanupServiceTest { // Use MockedStatic to mock Files operations try (MockedStatic mockedFiles = mockStatic(Files.class)) { // Mock Files.list for each directory - mockedFiles.when(() -> Files.list(eq(systemTempDir))) - .thenReturn(Stream.of(dir1)); + mockedFiles.when(() -> Files.list(eq(systemTempDir))).thenReturn(Stream.of(dir1)); - mockedFiles.when(() -> Files.list(eq(dir1))) - .thenReturn(Stream.of(tempFile1, dir2)); + mockedFiles.when(() -> Files.list(eq(dir1))).thenReturn(Stream.of(tempFile1, dir2)); - mockedFiles.when(() -> Files.list(eq(dir2))) - .thenReturn(Stream.of(tempFile2, dir3)); + mockedFiles.when(() -> Files.list(eq(dir2))).thenReturn(Stream.of(tempFile2, dir3)); - mockedFiles.when(() -> Files.list(eq(dir3))) - .thenReturn(Stream.of(tempFile3)); + mockedFiles.when(() -> Files.list(eq(dir3))).thenReturn(Stream.of(tempFile3)); // Configure Files.isDirectory for each path mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true); @@ -394,31 +433,35 @@ public class TempFileCleanupServiceTest { mockedFiles.when(() -> Files.exists(any(Path.class))).thenReturn(true); // Configure Files.getLastModifiedTime to return different times based on file names - mockedFiles.when(() -> Files.getLastModifiedTime(any(Path.class))) - .thenAnswer(invocation -> { - Path path = invocation.getArgument(0); - String fileName = path.getFileName().toString(); + mockedFiles + .when(() -> Files.getLastModifiedTime(any(Path.class))) + .thenAnswer( + invocation -> { + Path path = invocation.getArgument(0); + String fileName = path.getFileName().toString(); - if (fileName.contains("old")) { - // Old file - return FileTime.fromMillis(System.currentTimeMillis() - 5000000); - } else { - // Recent file - return FileTime.fromMillis(System.currentTimeMillis() - 60000); - } - }); + if (fileName.contains("old")) { + // Old file + return FileTime.fromMillis( + System.currentTimeMillis() - 5000000); + } else { + // Recent file + return FileTime.fromMillis(System.currentTimeMillis() - 60000); + } + }); // Configure Files.size to return normal size - mockedFiles.when(() -> Files.size(any(Path.class))) - .thenReturn(1024L); + mockedFiles.when(() -> Files.size(any(Path.class))).thenReturn(1024L); // For deleteIfExists, track which files would be deleted - mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) - .thenAnswer(invocation -> { - Path path = invocation.getArgument(0); - deletedFiles.add(path); - return true; - }); + mockedFiles + .when(() -> Files.deleteIfExists(any(Path.class))) + .thenAnswer( + invocation -> { + Path path = invocation.getArgument(0); + deletedFiles.add(path); + return true; + }); // Act invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000); @@ -430,14 +473,15 @@ public class TempFileCleanupServiceTest { // Assert assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved"); assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved"); - assertTrue(deletedFiles.contains(tempFile3), "Old temp file in nested directory should be deleted"); + assertTrue( + deletedFiles.contains(tempFile3), + "Old temp file in nested directory should be deleted"); } } - /** - * Helper method to invoke the private cleanupDirectoryStreaming method using reflection - */ - private void invokeCleanupDirectoryStreaming(Path directory, boolean containerMode, int depth, long maxAgeMillis) + /** Helper method to invoke the private cleanupDirectoryStreaming method using reflection */ + private void invokeCleanupDirectoryStreaming( + Path directory, boolean containerMode, int depth, long maxAgeMillis) throws IOException { try { // Create a consumer that tracks deleted files @@ -445,13 +489,26 @@ public class TempFileCleanupServiceTest { Consumer deleteCallback = path -> deleteCount.incrementAndGet(); // Get the method with updated signature - var method = TempFileCleanupService.class.getDeclaredMethod( - "cleanupDirectoryStreaming", - Path.class, boolean.class, int.class, long.class, boolean.class, Consumer.class); + var method = + TempFileCleanupService.class.getDeclaredMethod( + "cleanupDirectoryStreaming", + Path.class, + boolean.class, + int.class, + long.class, + boolean.class, + Consumer.class); method.setAccessible(true); // Invoke the method with appropriate parameters - method.invoke(cleanupService, directory, containerMode, depth, maxAgeMillis, false, deleteCallback); + method.invoke( + cleanupService, + directory, + containerMode, + depth, + maxAgeMillis, + false, + deleteCallback); } catch (Exception e) { throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e); } diff --git a/app/common/src/test/java/stirling/software/common/util/CheckProgramInstallTest.java b/app/common/src/test/java/stirling/software/common/util/CheckProgramInstallTest.java index 17723a5e4..ae8132618 100644 --- a/app/common/src/test/java/stirling/software/common/util/CheckProgramInstallTest.java +++ b/app/common/src/test/java/stirling/software/common/util/CheckProgramInstallTest.java @@ -1,14 +1,5 @@ package stirling.software.common.util; -import java.io.IOException; -import java.lang.reflect.Field; -import java.util.Arrays; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.MockedStatic; -import org.mockito.Mockito; -import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; @@ -19,6 +10,18 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Arrays; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.mockito.Mockito; + +import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult; + class CheckProgramInstallTest { private MockedStatic mockProcessExecutor; diff --git a/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java b/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java index 65bffe05e..eaf992f71 100644 --- a/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java +++ b/app/common/src/test/java/stirling/software/common/util/CustomHtmlSanitizerTest.java @@ -3,21 +3,46 @@ package stirling.software.common.util; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import stirling.software.common.service.SsrfProtectionService; + class CustomHtmlSanitizerTest { + private CustomHtmlSanitizer customHtmlSanitizer; + + @BeforeEach + void setUp() { + SsrfProtectionService mockSsrfProtectionService = mock(SsrfProtectionService.class); + stirling.software.common.model.ApplicationProperties mockApplicationProperties = + mock(stirling.software.common.model.ApplicationProperties.class); + stirling.software.common.model.ApplicationProperties.System mockSystem = + mock(stirling.software.common.model.ApplicationProperties.System.class); + + // Allow all URLs by default for basic tests + when(mockSsrfProtectionService.isUrlAllowed(org.mockito.ArgumentMatchers.anyString())) + .thenReturn(true); + when(mockApplicationProperties.getSystem()).thenReturn(mockSystem); + when(mockSystem.getDisableSanitize()).thenReturn(false); // Enable sanitization for tests + + customHtmlSanitizer = + new CustomHtmlSanitizer(mockSsrfProtectionService, mockApplicationProperties); + } + @ParameterizedTest @MethodSource("provideHtmlTestCases") void testSanitizeHtml(String inputHtml, String[] expectedContainedTags) { // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(inputHtml); + String sanitizedHtml = customHtmlSanitizer.sanitize(inputHtml); // Assert for (String tag : expectedContainedTags) { @@ -58,7 +83,7 @@ class CustomHtmlSanitizerTest { "

Styled text

"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithStyles); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithStyles); // Assert // The OWASP HTML Sanitizer might filter some specific styles, so we only check that @@ -75,7 +100,7 @@ class CustomHtmlSanitizerTest { "Example Link"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithLink); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithLink); // Assert // The most important aspect is that the link content is preserved @@ -97,7 +122,7 @@ class CustomHtmlSanitizerTest { String htmlWithJsLink = "Malicious Link"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithJsLink); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithJsLink); // Assert assertFalse(sanitizedHtml.contains("javascript:"), "JavaScript URLs should be removed"); @@ -116,7 +141,7 @@ class CustomHtmlSanitizerTest { + ""; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithTable); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithTable); // Assert assertTrue(sanitizedHtml.contains(""; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithImage); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithImage); // Assert assertTrue(sanitizedHtml.contains(""; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithDataUrlImage); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithDataUrlImage); // Assert assertFalse( @@ -175,7 +200,7 @@ class CustomHtmlSanitizerTest { "Click me"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithJsEvent); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithJsEvent); // Assert assertFalse( @@ -192,7 +217,7 @@ class CustomHtmlSanitizerTest { String htmlWithScript = "

Safe content

"; // Act - String sanitizedHtml = CustomHtmlSanitizer.sanitize(htmlWithScript); + String sanitizedHtml = customHtmlSanitizer.sanitize(htmlWithScript); // Assert assertFalse(sanitizedHtml.contains(" diff --git a/app/core/src/main/resources/templates/edit-table-of-contents.html b/app/core/src/main/resources/templates/edit-table-of-contents.html index 99ab02c1d..21a7204e7 100644 --- a/app/core/src/main/resources/templates/edit-table-of-contents.html +++ b/app/core/src/main/resources/templates/edit-table-of-contents.html @@ -1,75 +1,169 @@ - - - - - - + + + + + - -
-
- -

-
-
-
-
- bookmark_add - + +
+
+ +

+
+
+
+
+ bookmark_add + +
+
+
+
+ +
+ + + +
+ +
+
+

+ +
+ +
+ +
+ +
+ +
+ + +
+ +
+ + + +
+ + +
+ + + +
+
+
+ + + +
+ +

+ +

+
+

+

+

+
+ +
+ +
+ + +
-
-
-
- -
- - - -
- -
-
-

- -
- -
- -
- -
- - - -
- -

- -

-
-

-

-

-
- -
- -
+
- -
- - + - + \ No newline at end of file diff --git a/app/core/src/main/resources/templates/fragments/navbar.html b/app/core/src/main/resources/templates/fragments/navbar.html index e5aea9345..833d4fd91 100644 --- a/app/core/src/main/resources/templates/fragments/navbar.html +++ b/app/core/src/main/resources/templates/fragments/navbar.html @@ -11,9 +11,44 @@ diff --git a/app/core/src/main/resources/templates/home-legacy.html b/app/core/src/main/resources/templates/home-legacy.html index 9531a359b..3c01bcbd6 100644 --- a/app/core/src/main/resources/templates/home-legacy.html +++ b/app/core/src/main/resources/templates/home-legacy.html @@ -413,9 +413,6 @@
- - -
diff --git a/app/core/src/main/resources/templates/security/get-info-on-pdf.html b/app/core/src/main/resources/templates/security/get-info-on-pdf.html index 86e65cd01..0b64bb679 100644 --- a/app/core/src/main/resources/templates/security/get-info-on-pdf.html +++ b/app/core/src/main/resources/templates/security/get-info-on-pdf.html @@ -106,7 +106,6 @@
-