diff --git a/.github/labeler-config-srvaroa.yml b/.github/labeler-config-srvaroa.yml index f8e66fab4..b2324fbe3 100644 --- a/.github/labeler-config-srvaroa.yml +++ b/.github/labeler-config-srvaroa.yml @@ -115,7 +115,7 @@ labels: - '.editorconfig' - '.pre-commit-config' - '.github/workflows/pre_commit.yml' - - 'HowToAddNewLanguage.md' + - 'devGuide/.*' - label: 'Test' files: diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index da5780860..b909f28e8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,6 @@ # Description of Changes + --- @@ -15,15 +17,15 @@ Closes #(issue_number) ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) -- [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) -- [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md) (if applicable) +- [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) +- [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) -- [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) +- [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) @@ -31,4 +33,4 @@ Closes #(issue_number) ### Testing (if applicable) -- [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#6-testing) for more details. +- [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. diff --git a/.github/scripts/requirements_pre_commit.txt b/.github/scripts/requirements_pre_commit.txt index ea034f5d1..4e2d2c2b6 100644 --- a/.github/scripts/requirements_pre_commit.txt +++ b/.github/scripts/requirements_pre_commit.txt @@ -2,7 +2,7 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' '.github\scripts\requirements_pre_commit.in' +# pip-compile --generate-hashes --output-file='.github\scripts\requirements_pre_commit.txt' --strip-extras '.github\scripts\requirements_pre_commit.in' # cfgv==3.4.0 \ --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \ @@ -12,25 +12,25 @@ distlib==0.3.9 \ --hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \ --hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403 # via virtualenv -filelock==3.16.1 \ - --hash=sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0 \ - --hash=sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435 +filelock==3.18.0 \ + --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \ + --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de # via virtualenv -identify==2.6.5 \ - --hash=sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566 \ - --hash=sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc +identify==2.6.12 \ + --hash=sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2 \ + --hash=sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6 # via pre-commit nodeenv==1.9.1 \ --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \ --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9 # via pre-commit -platformdirs==4.3.6 \ - --hash=sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907 \ - --hash=sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb +platformdirs==4.3.8 \ + --hash=sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc \ + --hash=sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4 # via virtualenv -pre-commit==4.0.1 \ - --hash=sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2 \ - --hash=sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878 +pre-commit==4.2.0 \ + --hash=sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146 \ + --hash=sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd # via -r .github\scripts\requirements_pre_commit.in pyyaml==6.0.2 \ --hash=sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff \ @@ -87,7 +87,7 @@ pyyaml==6.0.2 \ --hash=sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12 \ --hash=sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4 # via pre-commit -virtualenv==20.28.1 \ - --hash=sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb \ - --hash=sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329 +virtualenv==20.31.2 \ + --hash=sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11 \ + --hash=sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af # via pre-commit diff --git a/.github/scripts/requirements_sync_readme.txt b/.github/scripts/requirements_sync_readme.txt index 6b72d4086..e72486ab2 100644 --- a/.github/scripts/requirements_sync_readme.txt +++ b/.github/scripts/requirements_sync_readme.txt @@ -2,9 +2,9 @@ # This file is autogenerated by pip-compile with Python 3.10 # by the following command: # -# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' '.github\scripts\requirements_sync_readme.in' +# pip-compile --generate-hashes --output-file='.github\scripts\requirements_sync_readme.txt' --strip-extras '.github\scripts\requirements_sync_readme.in' # -tomlkit==0.13.2 \ - --hash=sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde \ - --hash=sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79 +tomlkit==0.13.3 \ + --hash=sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1 \ + --hash=sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0 # via -r .github\scripts\requirements_sync_readme.in diff --git a/.github/workflows/PR-Demo-Comment-with-react.yml b/.github/workflows/PR-Demo-Comment-with-react.yml index 874081068..edb696bf0 100644 --- a/.github/workflows/PR-Demo-Comment-with-react.yml +++ b/.github/workflows/PR-Demo-Comment-with-react.yml @@ -30,6 +30,7 @@ jobs: github.event.comment.user.login == 'sbplat' || github.event.comment.user.login == 'reecebrowne' || github.event.comment.user.login == 'DarioGii' || + github.event.comment.user.login == 'EthanHealy01' || github.event.comment.user.login == 'ConnorYoh' ) outputs: @@ -42,7 +43,7 @@ jobs: enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }} steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -149,7 +150,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/PR-Demo-cleanup.yml b/.github/workflows/PR-Demo-cleanup.yml index ae17ee7c8..bec52c2bb 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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/ai_pr_title_review.yml b/.github/workflows/ai_pr_title_review.yml index 0447a9b62..b9fd7c277 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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/auto-labeler.yml b/.github/workflows/auto-labeler.yml index 5828a2556..f1d7d730c 100644 --- a/.github/workflows/auto-labeler.yml +++ b/.github/workflows/auto-labeler.yml @@ -13,7 +13,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/auto-labelerV2.yml b/.github/workflows/auto-labelerV2.yml index dec73ddac..bf290de76 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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a4666956..bf688d534 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,7 +25,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -91,7 +91,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -135,7 +135,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/check_properties.yml b/.github/workflows/check_properties.yml index 7c2c075b3..7e6c43cbd 100644 --- a/.github/workflows/check_properties.yml +++ b/.github/workflows/check_properties.yml @@ -18,7 +18,7 @@ jobs: pull-requests: write # Allow writing to pull requests steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 4f44295f7..154b6bdae 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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/licenses-update.yml b/.github/workflows/licenses-update.yml index 227948288..0cff27a96 100644 --- a/.github/workflows/licenses-update.yml +++ b/.github/workflows/licenses-update.yml @@ -19,7 +19,7 @@ jobs: repository-projects: write # Required for enabling automerge steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/manage-label.yml b/.github/workflows/manage-label.yml index 3f123afbd..15349a66d 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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/multiOSReleases.yml b/.github/workflows/multiOSReleases.yml index e2f33fae0..cdd8c6580 100644 --- a/.github/workflows/multiOSReleases.yml +++ b/.github/workflows/multiOSReleases.yml @@ -21,27 +21,31 @@ jobs: versionMac: ${{ steps.versionNumberMac.outputs.versionNumberMac }} steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - # Get version number + - name: Set up JDK + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: 'temurin' + java-version: '21' + + # ✅ Get version from Gradle - name: Get version number id: versionNumber run: | - VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') + VERSION=$(./gradlew printVersion --quiet | tail -1) echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT + # ✅ Get Mac-specific version from Gradle - name: Get version number mac id: versionNumberMac run: | - VERSION=$(grep "^version =" build.gradle | awk -F'"' '{print $2}') - CURRENT_YEAR=$(date +'%Y') - IFS='.' read -r -a VERSION_PARTS <<< "$VERSION" - MAC_VERSION="$CURRENT_YEAR.${VERSION_PARTS[1]:-0}.${VERSION_PARTS[2]:-0}" - echo "versionNumberMac=$MAC_VERSION" >> $GITHUB_OUTPUT + VERSION_MAC=$(./gradlew printMacVersion --quiet | tail -1) + echo "versionNumberMac=$VERSION_MAC" >> $GITHUB_OUTPUT build-portable: needs: read_versions @@ -56,7 +60,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -82,7 +86,7 @@ jobs: run: | mkdir ./binaries mv ./build/launch4j/Stirling-PDF.exe ./binaries/win-Stirling-PDF-portable-Server${{ matrix.file_suffix }}.exe - mv ./build/libs/Stirling-PDF-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar + mv ./stirling-pdf/build/libs/stirling-pdf-${{ needs.read_versions.outputs.version }}.jar ./binaries/Stirling-PDF${{ matrix.file_suffix }}.jar - name: Upload build artifacts uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 @@ -106,7 +110,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -144,7 +148,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -234,7 +238,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -248,7 +252,7 @@ jobs: - name: Install Cosign if: matrix.os == 'windows-latest' - uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0 + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 - name: Generate key pair if: matrix.os == 'windows-latest' @@ -297,7 +301,7 @@ jobs: contents: write steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/pre_commit.yml b/.github/workflows/pre_commit.yml index 1190c49cd..ebe81c5a8 100644 --- a/.github/workflows/pre_commit.yml +++ b/.github/workflows/pre_commit.yml @@ -16,7 +16,7 @@ jobs: pull-requests: write steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/push-docker.yml b/.github/workflows/push-docker.yml index 39f022586..432925f1a 100644 --- a/.github/workflows/push-docker.yml +++ b/.github/workflows/push-docker.yml @@ -18,7 +18,7 @@ jobs: id-token: write steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -42,7 +42,7 @@ jobs: - name: Install cosign if: github.ref == 'refs/heads/master' - uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0 + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 with: cosign-release: "v2.4.1" @@ -77,6 +77,7 @@ jobs: - name: Generate tags id: meta uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 + if: github.ref != 'refs/heads/main' with: images: | ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf @@ -86,11 +87,11 @@ jobs: tags: | type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }} - type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push main Dockerfile id: build-push-regular uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + if: github.ref != 'refs/heads/main' with: builder: ${{ steps.buildx.outputs.name }} context: . @@ -153,7 +154,6 @@ jobs: - name: Generate tags fat id: meta3 uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0 - if: github.ref != 'refs/heads/main' with: images: | ${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf @@ -163,11 +163,11 @@ jobs: tags: | type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }} type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }} + type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push main Dockerfile fat id: build-push-fat uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - if: github.ref != 'refs/heads/main' with: builder: ${{ steps.buildx.outputs.name }} context: . diff --git a/.github/workflows/releaseArtifacts.yml b/.github/workflows/releaseArtifacts.yml index 76c711734..701bb678e 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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -83,7 +83,7 @@ jobs: file_suffix: "" steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -95,7 +95,7 @@ jobs: run: ls -R - name: Install Cosign - uses: sigstore/cosign-installer@fb28c2b6339dcd94da6e4cbcbc5e888961f6f8c3 # v3.9.0 + uses: sigstore/cosign-installer@398d4b0eeef1380460a10c8013a76f728fb906ac # v3.9.1 - 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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index a79dc0ec2..948a5a37b 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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 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@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 + uses: github/codeql-action/upload-sarif@181d5eefc20863364f96762470ba6f862bdef56b # v3.29.2 with: sarif_file: results.sarif diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 187e823ae..f708a5b8d 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 17d81412a..237040f0a 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@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index 6b9307887..d717d5563 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/.github/workflows/sync_files.yml b/.github/workflows/sync_files.yml index f89f36b2a..cf5ecbc5c 100644 --- a/.github/workflows/sync_files.yml +++ b/.github/workflows/sync_files.yml @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -57,8 +57,8 @@ jobs: - name: Run git add run: | - git add README.md - git diff --staged --quiet || git commit -m ":memo: Sync README.md" || echo "No changes detected" + git add README.md scripts/ignore_translation.toml + git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected" - name: Create Pull Request if: always() diff --git a/.github/workflows/testdriver.yml b/.github/workflows/testdriver.yml index d0244619d..2f818fbd0 100644 --- a/.github/workflows/testdriver.yml +++ b/.github/workflows/testdriver.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -105,7 +105,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit @@ -134,7 +134,7 @@ jobs: steps: - name: Harden Runner - uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1 + uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2 with: egress-policy: audit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9fad6ecbd..d9828c409 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ Please make sure your Pull Request adheres to the following guidelines: ## Translations -If you would like to add or modify a translation, please see [How to add new languages to Stirling-PDF](HowToAddNewLanguage.md). Also, please create a Pull Request so others can use it! +If you would like to add or modify a translation, please see [How to add new languages to Stirling-PDF](devGuide/HowToAddNewLanguage.md). Also, please create a Pull Request so others can use it! ## Docs @@ -37,7 +37,18 @@ First, make sure you've read the section [Pull Requests](#pull-requests). If, at any point in time, you have a question, please feel free to ask in the same issue thread or in our [Discord](https://discord.gg/FJUSXUSYec). -Developers should review our [Developer Guide](DeveloperGuide.md) +## Developer Documentation + +For technical guides, setup instructions, and development resources, please see our [Developer Documentation](devGuide/) which includes: + +- [Developer Guide](devGuide/DeveloperGuide.md) - Main setup and architecture guide +- [Exception Handling Guide](devGuide/EXCEPTION_HANDLING_GUIDE.md) - Error handling patterns and i18n +- [Translation Guide](devGuide/HowToAddNewLanguage.md) - Adding new languages +- And more in the [devGuide folder](devGuide/) + +For configuration and usage guides, see: +- [Database Guide](DATABASE.md) - Database setup and configuration +- [OCR Guide](HowToUseOCR.md) - OCR setup and configuration ## License diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..84dddd1dc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,103 @@ +# Main stage +FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715 + +# Copy necessary files +COPY scripts /scripts +COPY pipeline /pipeline +COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ +COPY stirling-pdf/build/libs/*.jar app.jar + +ARG VERSION_TAG + +LABEL org.opencontainers.image.title="Stirling-PDF" +LABEL org.opencontainers.image.description="A powerful locally hosted web-based PDF manipulation tool supporting 50+ operations including merging, splitting, conversion, OCR, watermarking, and more." +LABEL org.opencontainers.image.source="https://github.com/Stirling-Tools/Stirling-PDF" +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.vendor="Stirling-Tools" +LABEL org.opencontainers.image.url="https://www.stirlingpdf.com" +LABEL org.opencontainers.image.documentation="https://docs.stirlingpdf.com" +LABEL maintainer="Stirling-Tools" +LABEL org.opencontainers.image.authors="Stirling-Tools" +LABEL org.opencontainers.image.version="${VERSION_TAG}" +LABEL org.opencontainers.image.keywords="PDF, manipulation, merge, split, convert, OCR, watermark" + +# Set Environment Variables +ENV DISABLE_ADDITIONAL_FEATURES=true \ + VERSION_TAG=$VERSION_TAG \ + JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \ + JAVA_CUSTOM_OPTS="" \ + HOME=/home/stirlingpdfuser \ + PUID=1000 \ + PGID=1000 \ + UMASK=022 \ + PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \ + UNO_PATH=/usr/lib/libreoffice/program \ + URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \ + PATH=$PATH:/opt/venv/bin \ + STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ + TMPDIR=/tmp/stirling-pdf \ + TEMP=/tmp/stirling-pdf \ + TMP=/tmp/stirling-pdf + + +# JDK for app +RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ + echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ + echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ + apk upgrade --no-cache -a && \ + apk add --no-cache \ + ca-certificates \ + tzdata \ + tini \ + bash \ + curl \ + shadow \ + su-exec \ + openssl \ + openssl-dev \ + openjdk21-jre \ + # Doc conversion + gcompat \ + libc6-compat \ + libreoffice \ + # pdftohtml + poppler-utils \ + # OCR MY PDF (unpaper for descew and other advanced features) + tesseract-ocr-data-eng \ + tesseract-ocr-data-chi_sim \ + tesseract-ocr-data-deu \ + tesseract-ocr-data-fra \ + tesseract-ocr-data-por \ + unpaper \ + # CV + py3-opencv \ + python3 \ + ocrmypdf \ + py3-pip \ + py3-pillow@testing \ + py3-pdf2image@testing \ + # URW Base 35 fonts for better PDF rendering + font-urw-base35 && \ + python3 -m venv /opt/venv && \ + /opt/venv/bin/pip install --upgrade pip setuptools && \ + /opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \ + ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \ + ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \ + ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \ + mv /usr/share/tessdata /usr/share/tessdata-original && \ + mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \ + # Configure URW Base 35 fonts + ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \ + fc-cache -f -v && \ + chmod +x /scripts/* && \ + chmod +x /scripts/init.sh && \ + # User permissions + addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \ + chown stirlingpdfuser:stirlingpdfgroup /app.jar + +EXPOSE 8080/tcp + +# Set user and run command +ENTRYPOINT ["tini", "--", "/scripts/init.sh"] +CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 000000000..78460115f --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,61 @@ +# dockerfile.dev + +# Basisimage: Gradle mit JDK 17 (Debian-basiert) +FROM gradle:8.14-jdk17 + +# Als Root-Benutzer arbeiten, um benötigte Pakete zu installieren +USER root + +# Set GRADLE_HOME und füge Gradle zum PATH hinzu +ENV GRADLE_HOME=/opt/gradle +ENV PATH="$GRADLE_HOME/bin:$PATH" + +# Update und Installation zusätzlicher Pakete (Debian/Ubuntu-basiert) +RUN apt-get update && apt-get install -y \ + sudo \ + libreoffice \ + poppler-utils \ + qpdf \ +# settings.yml | tessdataDir: /usr/share/tesseract-ocr/5/tessdata + tesseract-ocr \ + tesseract-ocr-eng \ + fonts-terminus fonts-dejavu fonts-font-awesome fonts-noto fonts-noto-core fonts-noto-cjk fonts-noto-extra fonts-liberation fonts-linuxlibertine fonts-urw-base35 \ + python3-uno \ + python3-venv \ +# ss -tln + iproute2 \ + && apt-get clean && rm -rf /var/lib/apt/lists/* + +# Setze die Environment Variable für setuptools +ENV SETUPTOOLS_USE_DISTUTILS=local \ + STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ + TMPDIR=/tmp/stirling-pdf \ + TEMP=/tmp/stirling-pdf \ + TMP=/tmp/stirling-pdf + +# Installation der benötigten Python-Pakete +RUN python3 -m venv --system-site-packages /opt/venv \ + && . /opt/venv/bin/activate \ + && pip install --upgrade pip setuptools \ + && pip install --no-cache-dir WeasyPrint pdf2image pillow unoserver opencv-python-headless pre-commit + +# Füge den venv-Pfad zur globalen PATH-Variable hinzu, damit die Tools verfügbar sind +ENV PATH="/opt/venv/bin:$PATH" + +COPY . /workspace + +RUN mkdir -p /tmp/stirling-pdf \ + && fc-cache -f -v \ + && adduser --disabled-password --gecos '' devuser \ + && chown -R devuser:devuser /home/devuser /workspace /tmp/stirling-pdf +RUN echo "devuser ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/devuser \ + && chmod 0440 /etc/sudoers.d/devuser + +# Setze das Arbeitsverzeichnis (wird später per Bind-Mount überschrieben) +WORKDIR /workspace + +RUN chmod +x /workspace/.devcontainer/git-init.sh +RUN sudo chmod +x /workspace/.devcontainer/init-setup.sh + +# Wechsel zum Nicht‑Root Benutzer +USER devuser diff --git a/Dockerfile.fat b/Dockerfile.fat new file mode 100644 index 000000000..4053cd97f --- /dev/null +++ b/Dockerfile.fat @@ -0,0 +1,114 @@ +# Build the application +FROM gradle:8.14-jdk21 AS build + +COPY build.gradle . +COPY settings.gradle . +COPY gradlew . +COPY gradle gradle/ +COPY stirling-pdf/build.gradle stirling-pdf/. +COPY common/build.gradle common/. +COPY proprietary/build.gradle proprietary/. +RUN ./gradlew build -x spotlessApply -x spotlessCheck -x test -x sonarqube || return 0 + +# Set the working directory +WORKDIR /app + +# Copy the entire project to the working directory +COPY . . + +# Build the application with DISABLE_ADDITIONAL_FEATURES=false +RUN DISABLE_ADDITIONAL_FEATURES=false \ + STIRLING_PDF_DESKTOP_UI=false \ + ./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube + +# Main stage +FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715 + +# Copy necessary files +COPY scripts /scripts +COPY pipeline /pipeline +COPY stirling-pdf/src/main/resources/static/fonts/*.ttf /usr/share/fonts/opentype/noto/ +COPY --from=build /app/stirling-pdf/build/libs/*.jar app.jar + +ARG VERSION_TAG + +# Set Environment Variables +ENV DISABLE_ADDITIONAL_FEATURES=true \ + VERSION_TAG=$VERSION_TAG \ + JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \ + JAVA_CUSTOM_OPTS="" \ + HOME=/home/stirlingpdfuser \ + PUID=1000 \ + PGID=1000 \ + UMASK=022 \ + FAT_DOCKER=true \ + INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false \ + PYTHONPATH=/usr/lib/libreoffice/program:/opt/venv/lib/python3.12/site-packages \ + UNO_PATH=/usr/lib/libreoffice/program \ + URE_BOOTSTRAP=file:///usr/lib/libreoffice/program/fundamentalrc \ + PATH=$PATH:/opt/venv/bin \ + STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ + TMPDIR=/tmp/stirling-pdf \ + TEMP=/tmp/stirling-pdf \ + TMP=/tmp/stirling-pdf + + +# JDK for app +RUN echo "@main https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ + echo "@community https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ + echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ + apk upgrade --no-cache -a && \ + apk add --no-cache \ + ca-certificates \ + tzdata \ + tini \ + bash \ + curl \ + shadow \ + su-exec \ + openssl \ + openssl-dev \ + openjdk21-jre \ + # Doc conversion + gcompat \ + libc6-compat \ + libreoffice \ + # pdftohtml + poppler-utils \ + # OCR MY PDF (unpaper for descew and other advanced featues) + tesseract-ocr-data-eng \ + tesseract-ocr-data-chi_sim \ + tesseract-ocr-data-deu \ + tesseract-ocr-data-fra \ + tesseract-ocr-data-por \ + unpaper \ + font-terminus font-dejavu font-noto font-noto-cjk font-awesome font-noto-extra font-liberation font-linux-libertine font-urw-base35 \ + # CV + py3-opencv \ + python3 \ + ocrmypdf \ + py3-pip \ + py3-pillow@testing \ + py3-pdf2image@testing && \ + python3 -m venv /opt/venv && \ + /opt/venv/bin/pip install --upgrade pip setuptools && \ + /opt/venv/bin/pip install --no-cache-dir --upgrade unoserver weasyprint && \ + ln -s /usr/lib/libreoffice/program/uno.py /opt/venv/lib/python3.12/site-packages/ && \ + ln -s /usr/lib/libreoffice/program/unohelper.py /opt/venv/lib/python3.12/site-packages/ && \ + ln -s /usr/lib/libreoffice/program /opt/venv/lib/python3.12/site-packages/LibreOffice && \ + mv /usr/share/tessdata /usr/share/tessdata-original && \ + mkdir -p $HOME /configs /logs /customFiles /pipeline/watchedFolders /pipeline/finishedFolders /tmp/stirling-pdf && \ + # Configure URW Base 35 fonts + ln -s /usr/share/fontconfig/conf.avail/69-urw-*.conf /etc/fonts/conf.d/ && \ + fc-cache -f -v && \ + chmod +x /scripts/* && \ + chmod +x /scripts/init.sh && \ + # User permissions + addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /usr/share/fonts/opentype/noto /configs /customFiles /pipeline /tmp/stirling-pdf && \ + chown stirlingpdfuser:stirlingpdfgroup /app.jar + +EXPOSE 8080/tcp +# Set user and run command +ENTRYPOINT ["tini", "--", "/scripts/init.sh"] +CMD ["sh", "-c", "java -Dfile.encoding=UTF-8 -Djava.io.tmpdir=/tmp/stirling-pdf -jar /app.jar & /opt/venv/bin/unoserver --port 2003 --interface 127.0.0.1"] diff --git a/Dockerfile.ultra-lite b/Dockerfile.ultra-lite new file mode 100644 index 000000000..283d3c983 --- /dev/null +++ b/Dockerfile.ultra-lite @@ -0,0 +1,55 @@ +# use alpine +FROM alpine:3.22.0@sha256:8a1f59ffb675680d47db6337b49d22281a139e9d709335b492be023728e11715 + +ARG VERSION_TAG + +# Set Environment Variables +ENV DISABLE_ADDITIONAL_FEATURES=true \ + HOME=/home/stirlingpdfuser \ + VERSION_TAG=$VERSION_TAG \ + JAVA_BASE_OPTS="-XX:+UnlockExperimentalVMOptions -XX:MaxRAMPercentage=75 -XX:InitiatingHeapOccupancyPercent=20 -XX:+G1PeriodicGCInvokesConcurrent -XX:G1PeriodicGCInterval=10000 -XX:+UseStringDeduplication -XX:G1PeriodicGCSystemLoadThreshold=70" \ + JAVA_CUSTOM_OPTS="" \ + PUID=1000 \ + PGID=1000 \ + UMASK=022 \ + STIRLING_TEMPFILES_DIRECTORY=/tmp/stirling-pdf \ + TMPDIR=/tmp/stirling-pdf \ + TEMP=/tmp/stirling-pdf \ + TMP=/tmp/stirling-pdf + +# Copy necessary files +COPY scripts/download-security-jar.sh /scripts/download-security-jar.sh +COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh +COPY scripts/installFonts.sh /scripts/installFonts.sh +COPY pipeline /pipeline +COPY stirling-pdf/build/libs/*.jar app.jar + +# Set up necessary directories and permissions +RUN echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/main" | tee -a /etc/apk/repositories && \ + echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/community" | tee -a /etc/apk/repositories && \ + echo "@testing https://dl-cdn.alpinelinux.org/alpine/edge/testing" | tee -a /etc/apk/repositories && \ + apk upgrade --no-cache -a && \ + apk add --no-cache \ + ca-certificates \ + tzdata \ + tini \ + bash \ + curl \ + shadow \ + su-exec \ + openjdk21-jre && \ + # User permissions + mkdir -p /configs /logs /customFiles /usr/share/fonts/opentype/noto /tmp/stirling-pdf && \ + chmod +x /scripts/*.sh && \ + addgroup -S stirlingpdfgroup && adduser -S stirlingpdfuser -G stirlingpdfgroup && \ + chown -R stirlingpdfuser:stirlingpdfgroup $HOME /scripts /configs /customFiles /pipeline /tmp/stirling-pdf && \ + chown stirlingpdfuser:stirlingpdfgroup /app.jar + +# Set environment variables +ENV ENDPOINTS_GROUPS_TO_REMOVE=CLI + +EXPOSE 8080/tcp + +# Run the application +ENTRYPOINT ["tini", "--", "/scripts/init-without-ocr.sh"] +CMD ["java", "-Dfile.encoding=UTF-8", "-Djava.io.tmpdir=/tmp/stirling-pdf", "-jar", "/app.jar"] \ No newline at end of file diff --git a/README.md b/README.md index 1c67d5d9e..d0909ba2f 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ All documentation available at [https://docs.stirlingpdf.com/](https://docs.stir - API for integration with external scripts - Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation) - Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation) -- Enterprise features like SSO see [here](https://docs.stirlingpdf.com/Enterprise%20Edition) +- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation) ## PDF Features @@ -116,47 +116,47 @@ Stirling-PDF currently supports 40 languages! | Language | Progress | | -------------------------------------------- | -------------------------------------- | -| Arabic (العربية) (ar_AR) | ![65%](https://geps.dev/progress/65) | -| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![65%](https://geps.dev/progress/65) | -| Basque (Euskara) (eu_ES) | ![38%](https://geps.dev/progress/38) | -| Bulgarian (Български) (bg_BG) | ![72%](https://geps.dev/progress/72) | -| Catalan (Català) (ca_CA) | ![71%](https://geps.dev/progress/71) | -| Croatian (Hrvatski) (hr_HR) | ![64%](https://geps.dev/progress/64) | -| Czech (Česky) (cs_CZ) | ![74%](https://geps.dev/progress/74) | -| Danish (Dansk) (da_DK) | ![65%](https://geps.dev/progress/65) | -| Dutch (Nederlands) (nl_NL) | ![63%](https://geps.dev/progress/63) | +| 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) | | 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) | ![73%](https://geps.dev/progress/73) | -| German (Deutsch) (de_DE) | ![92%](https://geps.dev/progress/92) | -| Greek (Ελληνικά) (el_GR) | ![71%](https://geps.dev/progress/71) | -| Hindi (हिंदी) (hi_IN) | ![71%](https://geps.dev/progress/71) | +| 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) | | Hungarian (Magyar) (hu_HU) | ![99%](https://geps.dev/progress/99) | -| Indonesian (Bahasa Indonesia) (id_ID) | ![65%](https://geps.dev/progress/65) | -| Irish (Gaeilge) (ga_IE) | ![72%](https://geps.dev/progress/72) | +| Indonesian (Bahasa Indonesia) (id_ID) | ![63%](https://geps.dev/progress/63) | +| Irish (Gaeilge) (ga_IE) | ![70%](https://geps.dev/progress/70) | | Italian (Italiano) (it_IT) | ![98%](https://geps.dev/progress/98) | -| Japanese (日本語) (ja_JP) | ![72%](https://geps.dev/progress/72) | -| Korean (한국어) (ko_KR) | ![71%](https://geps.dev/progress/71) | -| Norwegian (Norsk) (no_NB) | ![70%](https://geps.dev/progress/70) | -| Persian (فارسی) (fa_IR) | ![68%](https://geps.dev/progress/68) | -| Polish (Polski) (pl_PL) | ![76%](https://geps.dev/progress/76) | -| Portuguese (Português) (pt_PT) | ![72%](https://geps.dev/progress/72) | -| Portuguese Brazilian (Português) (pt_BR) | ![80%](https://geps.dev/progress/80) | -| Romanian (Română) (ro_RO) | ![61%](https://geps.dev/progress/61) | -| Russian (Русский) (ru_RU) | ![72%](https://geps.dev/progress/72) | -| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![46%](https://geps.dev/progress/46) | -| Simplified Chinese (简体中文) (zh_CN) | ![93%](https://geps.dev/progress/93) | -| Slovakian (Slovensky) (sk_SK) | ![54%](https://geps.dev/progress/54) | -| Slovenian (Slovenščina) (sl_SI) | ![75%](https://geps.dev/progress/75) | -| Spanish (Español) (es_ES) | ![78%](https://geps.dev/progress/78) | -| Swedish (Svenska) (sv_SE) | ![69%](https://geps.dev/progress/69) | -| Thai (ไทย) (th_TH) | ![62%](https://geps.dev/progress/62) | -| Tibetan (བོད་ཡིག་) (bo_CN) | ![68%](https://geps.dev/progress/68) | -| Traditional Chinese (繁體中文) (zh_TW) | ![80%](https://geps.dev/progress/80) | -| Turkish (Türkçe) (tr_TR) | ![85%](https://geps.dev/progress/85) | -| Ukrainian (Українська) (uk_UA) | ![75%](https://geps.dev/progress/75) | -| Vietnamese (Tiếng Việt) (vi_VN) | ![60%](https://geps.dev/progress/60) | -| Malayalam (മലയാളം) (ml_IN) | ![77%](https://geps.dev/progress/77) | +| Japanese (日本語) (ja_JP) | ![70%](https://geps.dev/progress/70) | +| 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) | ![70%](https://geps.dev/progress/70) | +| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![97%](https://geps.dev/progress/97) | +| Simplified Chinese (简体中文) (zh_CN) | ![90%](https://geps.dev/progress/90) | +| 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) | ## Stirling PDF Enterprise @@ -168,7 +168,7 @@ Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro) Join our community: - [Contribution Guidelines](CONTRIBUTING.md) -- [Translation Guide (How to add custom languages)](HowToAddNewLanguage.md) +- [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md) +- [Developer Guide](devGuide/DeveloperGuide.md) - [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues) - [Discord Community](https://discord.gg/HYmhKj45pU) -- [Developer Guide](DeveloperGuide.md) diff --git a/build.gradle b/build.gradle index 76c96b13f..a0d198c3a 100644 --- a/build.gradle +++ b/build.gradle @@ -9,12 +9,14 @@ plugins { id "com.diffplug.spotless" version "7.0.4" id "com.github.jk1.dependency-license-report" version "2.9" //id "nebula.lint" version "19.0.3" - id "org.panteleyev.jpackageplugin" version "1.6.1" + id "org.panteleyev.jpackageplugin" version "1.7.3" id "org.sonarqube" version "6.2.0.5505" } import com.github.jk1.license.render.* import org.gradle.internal.os.OperatingSystem +import org.panteleyev.jpackage.ImageType + import java.nio.file.Files import java.time.Year @@ -26,7 +28,7 @@ ext { bouncycastleVersion = "1.81" springSecuritySamlVersion = "6.5.1" openSamlVersion = "4.3.2" - commonmarkVersion = "0.24.0" + commonmarkVersion = "0.25.0" googleJavaFormatVersion = "1.27.0" tempJrePath = null } @@ -43,39 +45,19 @@ bootJar { enabled = false } -sourceSets { - main { - java { - if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true' - || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES') - && System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) { - exclude 'stirling/software/proprietary/security/**' - } - if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') { - exclude 'stirling/software/SPDF/UI/impl/**' - } +// Configure main class for the root project +springBoot { + mainClass = 'stirling.software.SPDF.SPDFApplication' +} - } - } - - test { - java { - if (System.getenv('DOCKER_ENABLE_SECURITY') == 'false' || System.getenv('DISABLE_ADDITIONAL_FEATURES') == 'true' - || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES') - && System.getProperty('DISABLE_ADDITIONAL_FEATURES') == 'true')) { - exclude 'stirling/software/proprietary/security/**' - } - - if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') { - exclude 'stirling/software/SPDF/UI/impl/**' - } - } - } +repositories { + mavenCentral() + maven { url = 'https://build.shibboleth.net/maven/releases' } } allprojects { group = 'stirling.software' - version = '1.0.0' + version = '1.0.2' configurations.configureEach { exclude group: 'commons-logging', module: 'commons-logging' @@ -83,7 +65,6 @@ allprojects { } } - tasks.register('writeVersion') { def propsFile = file("$projectDir/common/src/main/resources/version.properties") def propsDir = propsFile.parentFile @@ -108,6 +89,10 @@ tasks.register('writeVersion') { } } +tasks.named('createExe') { + dependsOn(":stirling-pdf:bootJar") +} + subprojects { apply plugin: 'java' apply plugin: 'java-library' @@ -120,9 +105,11 @@ subprojects { sourceCompatibility = JavaVersion.VERSION_17 } - bootJar { - enabled = false - } + if (project.name != "stirling-pdf") { + bootJar { + enabled = false + } + } repositories { mavenCentral() @@ -170,7 +157,7 @@ subprojects { test { useJUnitPlatform() } - + tasks.named("processResources") { dependsOn(rootProject.tasks.writeVersion) } @@ -224,6 +211,14 @@ openApi { waitTimeInSeconds = 60 // Increase the wait time to 60 seconds } +// Configure the forked spring boot run task to properly delegate to the stirling-pdf module +tasks.named('forkedSpringBootRun') { + dependsOn ':stirling-pdf:bootRun' + doFirst { + println "Delegating forkedSpringBootRun to :stirling-pdf:bootRun" + } +} + //0.11.5 to 2024.11.5 static def getMacVersion(String version) { def currentYear = Year.now().getValue() @@ -232,14 +227,15 @@ static def getMacVersion(String version) { } jpackage { - input = "build/libs" - destination = "${projectDir}/build/jpackage" + dependsOn(":stirling-pdf:bootJar") + input = layout.projectDirectory.dir("stirling-pdf/build/libs") + destination = layout.projectDirectory.dir("build/jpackage") mainJar = "Stirling-PDF-${project.version}.jar" appName = "Stirling PDF" appVersion = project.version vendor = "Stirling PDF Inc" appDescription = "Stirling PDF - Your Local PDF Editor" - icon = "stirling-pdf/src/main/resources/static/favicon.ico" + icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.ico") verbose = true // mainClass = "org.springframework.boot.loader.launch.JarLauncher" @@ -274,15 +270,15 @@ jpackage { winUpgradeUuid = "2a43ed0c-b8c2-40cf-89e1-751129b87641" // Unique identifier for updates winHelpUrl = "https://github.com/Stirling-Tools/Stirling-PDF" winUpdateUrl = "https://github.com/Stirling-Tools/Stirling-PDF/releases" - type = "exe" + type = ImageType.EXE installDir = "C:/Program Files/Stirling-PDF" } // MacOS-specific configuration mac { appVersion = getMacVersion(project.version.toString()) - icon = "stirling-pdf/src/main/resources/static/favicon.icns" - type = "dmg" + icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.icns") + type = ImageType.DMG macPackageIdentifier = "Stirling PDF" macPackageName = "Stirling PDF" macAppCategory = "public.app-category.productivity" @@ -303,8 +299,8 @@ jpackage { // Linux-specific configuration linux { appVersion = project.version - icon = "stirling-pdf/src/main/resources/static/favicon.png" - type = "deb" // Can also use "rpm" for Red Hat-based systems + icon = layout.projectDirectory.file("stirling-pdf/src/main/resources/static/favicon.png") + type = ImageType.DEB // Can also use "rpm" for Red Hat-based systems // Debian package configuration //linuxPackageName = "stirlingpdf" @@ -340,7 +336,7 @@ jpackage { // Add copyright and license information copyright = "Copyright © 2025 Stirling PDF Inc." - licenseFile = "LICENSE" + licenseFile = layout.projectDirectory.file("LICENSE") } //tasks.wrapper { @@ -375,7 +371,7 @@ tasks.register('jpackageMacX64') { commandLine 'jpackage', '--type', 'dmg', '--name', 'Stirling PDF (x86_64)', - '--input', 'build/libs', + '--input', 'stirling-pdf/build/libs', '--main-jar', "Stirling-PDF-${project.version}.jar", '--main-class', 'org.springframework.boot.loader.launch.JarLauncher', '--runtime-image', file(jrePath + "/zulu-17.jre/Contents/Home"), @@ -475,7 +471,7 @@ launch4j { } else { headerType = "console" } - jarTask = tasks.bootJar + jarTask = project(":stirling-pdf").tasks.bootJar errTitle="Encountered error, do you have Java 21?" downloadUrl="https://download.oracle.com/java/21/latest/jdk-21_windows-x64_bin.exe" @@ -537,6 +533,14 @@ swaggerhubUpload { } dependencies { + implementation project(':stirling-pdf') + implementation project(':common') + if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true' + || (project.hasProperty('DISABLE_ADDITIONAL_FEATURES') + && System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) { + implementation project(':proprietary') + } + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher:1.12.2' } diff --git a/common/src/main/java/stirling/software/common/configuration/AppConfig.java b/common/src/main/java/stirling/software/common/configuration/AppConfig.java index 02614584b..f611f42ca 100644 --- a/common/src/main/java/stirling/software/common/configuration/AppConfig.java +++ b/common/src/main/java/stirling/software/common/configuration/AppConfig.java @@ -10,7 +10,6 @@ import java.util.Properties; import java.util.function.Predicate; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -21,6 +20,7 @@ import org.springframework.core.env.Environment; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.core.io.ResourceLoader; +import org.springframework.util.ClassUtils; import org.thymeleaf.spring6.SpringTemplateEngine; import lombok.Getter; @@ -148,23 +148,10 @@ public class AppConfig { } @Bean(name = "activeSecurity") - public boolean activeSecurity() { - String disableAdditionalFeatures = env.getProperty("DISABLE_ADDITIONAL_FEATURES"); - - if (disableAdditionalFeatures != null) { - // DISABLE_ADDITIONAL_FEATURES=true means security OFF, so return false - // DISABLE_ADDITIONAL_FEATURES=false means security ON, so return true - return !Boolean.parseBoolean(disableAdditionalFeatures); - } - - return env.getProperty("DOCKER_ENABLE_SECURITY", Boolean.class, true); - } - - @Bean(name = "missingActiveSecurity") - @ConditionalOnMissingClass( - "stirling.software.proprietary.security.configuration.SecurityConfiguration") public boolean missingActiveSecurity() { - return true; + return ClassUtils.isPresent( + "stirling.software.proprietary.security.configuration.SecurityConfiguration", + this.getClass().getClassLoader()); } @Bean(name = "directoryFilter") diff --git a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 0017fa34a..e4edf2baa 100644 --- a/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -545,6 +545,8 @@ public class ApplicationProperties { private int calibreSessionLimit; private int qpdfSessionLimit; private int tesseractSessionLimit; + private int ghostscriptSessionLimit; + private int ocrMyPdfSessionLimit; public int getQpdfSessionLimit() { return qpdfSessionLimit > 0 ? qpdfSessionLimit : 2; @@ -577,6 +579,14 @@ public class ApplicationProperties { public int getCalibreSessionLimit() { return calibreSessionLimit > 0 ? calibreSessionLimit : 1; } + + public int getGhostscriptSessionLimit() { + return ghostscriptSessionLimit > 0 ? ghostscriptSessionLimit : 8; + } + + public int getOcrMyPdfSessionLimit() { + return ocrMyPdfSessionLimit > 0 ? ocrMyPdfSessionLimit : 2; + } } @Data @@ -589,6 +599,8 @@ public class ApplicationProperties { private long calibreTimeoutMinutes; private long tesseractTimeoutMinutes; private long qpdfTimeoutMinutes; + private long ghostscriptTimeoutMinutes; + private long ocrMyPdfTimeoutMinutes; public long getTesseractTimeoutMinutes() { return tesseractTimeoutMinutes > 0 ? tesseractTimeoutMinutes : 30; @@ -621,6 +633,14 @@ public class ApplicationProperties { public long getCalibreTimeoutMinutes() { return calibreTimeoutMinutes > 0 ? calibreTimeoutMinutes : 30; } + + public long getGhostscriptTimeoutMinutes() { + return ghostscriptTimeoutMinutes > 0 ? ghostscriptTimeoutMinutes : 30; + } + + public long getOcrMyPdfTimeoutMinutes() { + return ocrMyPdfTimeoutMinutes > 0 ? ocrMyPdfTimeoutMinutes : 30; + } } } } diff --git a/common/src/main/java/stirling/software/common/model/job/JobResult.java b/common/src/main/java/stirling/software/common/model/job/JobResult.java index a621f2db2..1aa66d1a8 100644 --- a/common/src/main/java/stirling/software/common/model/job/JobResult.java +++ b/common/src/main/java/stirling/software/common/model/job/JobResult.java @@ -1,6 +1,7 @@ package stirling.software.common.model.job; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; @@ -26,14 +27,8 @@ public class JobResult { /** Error message if the job failed */ private String error; - /** The file ID of the result file, if applicable */ - private String fileId; - - /** Original file name, if applicable */ - private String originalFileName; - - /** MIME type of the result, if applicable */ - private String contentType; + /** List of result files for jobs that produce files */ + private List resultFiles; /** Time when the job was created */ private LocalDateTime createdAt; @@ -64,21 +59,6 @@ public class JobResult { .build(); } - /** - * Mark this job as complete with a file result - * - * @param fileId The file ID of the result - * @param originalFileName The original file name - * @param contentType The content type of the file - */ - public void completeWithFile(String fileId, String originalFileName, String contentType) { - this.complete = true; - this.fileId = fileId; - this.originalFileName = originalFileName; - this.contentType = contentType; - this.completedAt = LocalDateTime.now(); - } - /** * Mark this job as complete with a general result * @@ -101,6 +81,67 @@ public class JobResult { this.completedAt = LocalDateTime.now(); } + /** + * Mark this job as complete with multiple file results + * + * @param resultFiles The list of result files + */ + public void completeWithFiles(List resultFiles) { + this.complete = true; + this.resultFiles = new ArrayList<>(resultFiles); + this.completedAt = LocalDateTime.now(); + } + + /** + * Mark this job as complete with a single file result (convenience method) + * + * @param fileId The file ID of the result + * @param fileName The file name + * @param contentType The content type of the file + * @param fileSize The size of the file in bytes + */ + public void completeWithSingleFile( + String fileId, String fileName, String contentType, long fileSize) { + ResultFile resultFile = + ResultFile.builder() + .fileId(fileId) + .fileName(fileName) + .contentType(contentType) + .fileSize(fileSize) + .build(); + completeWithFiles(List.of(resultFile)); + } + + /** + * Check if this job has file results + * + * @return true if this job has file results, false otherwise + */ + public boolean hasFiles() { + return resultFiles != null && !resultFiles.isEmpty(); + } + + /** + * Check if this job has multiple file results + * + * @return true if this job has multiple file results, false otherwise + */ + public boolean hasMultipleFiles() { + return resultFiles != null && resultFiles.size() > 1; + } + + /** + * Get all result files + * + * @return List of result files + */ + public List getAllResultFiles() { + if (resultFiles != null && !resultFiles.isEmpty()) { + return Collections.unmodifiableList(resultFiles); + } + return Collections.emptyList(); + } + /** * Add a note to this job * diff --git a/common/src/main/java/stirling/software/common/model/job/ResultFile.java b/common/src/main/java/stirling/software/common/model/job/ResultFile.java new file mode 100644 index 000000000..da51b1d6c --- /dev/null +++ b/common/src/main/java/stirling/software/common/model/job/ResultFile.java @@ -0,0 +1,26 @@ +package stirling.software.common.model.job; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** Represents a single file result from a job execution */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ResultFile { + + /** The file ID for accessing the file */ + private String fileId; + + /** The original file name */ + private String fileName; + + /** MIME type of the file */ + private String contentType; + + /** Size of the file in bytes */ + private long fileSize; +} diff --git a/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java b/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java index 51f52c34d..d106a2729 100644 --- a/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java +++ b/common/src/main/java/stirling/software/common/service/CustomPDFDocumentFactory.java @@ -24,6 +24,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.api.PDFFile; import stirling.software.common.util.ApplicationContextProvider; +import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.TempFileManager; import stirling.software.common.util.TempFileRegistry; @@ -82,7 +83,7 @@ public class CustomPDFDocumentFactory { */ public PDDocument load(File file, boolean readOnly) throws IOException { if (file == null) { - throw new IllegalArgumentException("File cannot be null"); + throw ExceptionUtils.createNullArgumentException("File"); } long fileSize = file.length(); @@ -109,7 +110,7 @@ public class CustomPDFDocumentFactory { */ public PDDocument load(Path path, boolean readOnly) throws IOException { if (path == null) { - throw new IllegalArgumentException("File cannot be null"); + throw ExceptionUtils.createNullArgumentException("File"); } long fileSize = Files.size(path); @@ -130,7 +131,7 @@ public class CustomPDFDocumentFactory { /** Load a PDF from byte array with automatic optimization and read-only option. */ public PDDocument load(byte[] input, boolean readOnly) throws IOException { if (input == null) { - throw new IllegalArgumentException("Input bytes cannot be null"); + throw ExceptionUtils.createNullArgumentException("Input bytes"); } long dataSize = input.length; @@ -151,7 +152,7 @@ public class CustomPDFDocumentFactory { /** Load a PDF from InputStream with automatic optimization and read-only option. */ public PDDocument load(InputStream input, boolean readOnly) throws IOException { if (input == null) { - throw new IllegalArgumentException("InputStream cannot be null"); + throw ExceptionUtils.createNullArgumentException("InputStream"); } // Since we don't know the size upfront, buffer to a temp file @@ -174,7 +175,7 @@ public class CustomPDFDocumentFactory { public PDDocument load(InputStream input, String password, boolean readOnly) throws IOException { if (input == null) { - throw new IllegalArgumentException("InputStream cannot be null"); + throw ExceptionUtils.createNullArgumentException("InputStream"); } // Since we don't know the size upfront, buffer to a temp file @@ -292,9 +293,32 @@ public class CustomPDFDocumentFactory { } else { throw new IllegalArgumentException("Unsupported source type: " + source.getClass()); } + + configureResourceCacheIfNeeded(document, contentSize); + return document; } + /** + * Configure resource cache based on content size and memory constraints. Disables resource + * cache for large files or when memory is low to prevent OOM errors. + */ + private void configureResourceCacheIfNeeded(PDDocument document, long contentSize) { + if (contentSize > LARGE_FILE_THRESHOLD) { + document.setResourceCache(null); + } else { + // Check current memory status for smaller files + long maxMemory = Runtime.getRuntime().maxMemory(); + long usedMemory = + Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory(); + double freeMemoryPercent = (double) (maxMemory - usedMemory) / maxMemory * 100; + + if (freeMemoryPercent < MIN_FREE_MEMORY_PERCENTAGE) { + document.setResourceCache(null); + } + } + } + /** Load a PDF with password protection using adaptive loading strategies */ private PDDocument loadAdaptivelyWithPassword(Object source, long contentSize, String password) throws IOException { @@ -313,6 +337,9 @@ public class CustomPDFDocumentFactory { } else { throw new IllegalArgumentException("Unsupported source type: " + source.getClass()); } + + configureResourceCacheIfNeeded(document, contentSize); + return document; } @@ -354,7 +381,12 @@ public class CustomPDFDocumentFactory { private PDDocument loadFromFile(File file, long size, StreamCacheCreateFunction cache) throws IOException { - return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache); + try { + return Loader.loadPDF(new DeletingRandomAccessFile(file), "", null, null, cache); + } catch (IOException e) { + ExceptionUtils.logException("PDF loading from file", e); + throw ExceptionUtils.handlePdfException(e); + } } private PDDocument loadFromBytes(byte[] bytes, long size, StreamCacheCreateFunction cache) @@ -366,7 +398,13 @@ public class CustomPDFDocumentFactory { Files.write(tempFile, bytes); return loadFromFile(tempFile.toFile(), size, cache); } - return Loader.loadPDF(bytes, "", null, null, cache); + + try { + return Loader.loadPDF(bytes, "", null, null, cache); + } catch (IOException e) { + ExceptionUtils.logException("PDF loading from bytes", e); + throw ExceptionUtils.handlePdfException(e); + } } public PDDocument createNewDocument(MemoryUsageSetting settings) throws IOException { @@ -399,7 +437,7 @@ public class CustomPDFDocumentFactory { try { document.setAllSecurityToBeRemoved(true); } catch (Exception e) { - log.error("Decryption failed", e); + ExceptionUtils.logException("PDF decryption", e); throw new IOException("PDF decryption failed", e); } } diff --git a/common/src/main/java/stirling/software/common/service/FileStorage.java b/common/src/main/java/stirling/software/common/service/FileStorage.java index e200ded8a..320b97865 100644 --- a/common/src/main/java/stirling/software/common/service/FileStorage.java +++ b/common/src/main/java/stirling/software/common/service/FileStorage.java @@ -131,14 +131,46 @@ public class FileStorage { return Files.exists(filePath); } + /** + * Get the size of a file by its ID without loading the content into memory + * + * @param fileId The ID of the file + * @return The size of the file in bytes + * @throws IOException If the file doesn't exist or can't be read + */ + public long getFileSize(String fileId) throws IOException { + Path filePath = getFilePath(fileId); + + if (!Files.exists(filePath)) { + throw new IOException("File not found with ID: " + fileId); + } + + return Files.size(filePath); + } + /** * Get the path for a file ID * * @param fileId The ID of the file * @return The path to the file + * @throws IllegalArgumentException if fileId contains path traversal characters or resolves + * outside base directory */ private Path getFilePath(String fileId) { - return Path.of(tempDirPath).resolve(fileId); + // Validate fileId to prevent path traversal + if (fileId.contains("..") || fileId.contains("/") || fileId.contains("\\")) { + throw new IllegalArgumentException("Invalid file ID"); + } + + Path basePath = Path.of(tempDirPath).normalize().toAbsolutePath(); + Path resolvedPath = basePath.resolve(fileId).normalize(); + + // Ensure resolved path is within the base directory + if (!resolvedPath.startsWith(basePath)) { + throw new IllegalArgumentException("File ID resolves to an invalid path"); + } + + return resolvedPath; } /** diff --git a/common/src/main/java/stirling/software/common/service/TaskManager.java b/common/src/main/java/stirling/software/common/service/TaskManager.java index c2b3ba8a8..219ae4ac4 100644 --- a/common/src/main/java/stirling/software/common/service/TaskManager.java +++ b/common/src/main/java/stirling/software/common/service/TaskManager.java @@ -1,15 +1,25 @@ package stirling.software.common.service; +import io.github.pixee.security.ZipSecurity; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.time.LocalDateTime; import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; import jakarta.annotation.PreDestroy; @@ -17,6 +27,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobStats; +import stirling.software.common.model.job.ResultFile; /** Manages async tasks and their results */ @Service @@ -80,8 +91,53 @@ public class TaskManager { public void setFileResult( String jobId, String fileId, String originalFileName, String contentType) { JobResult jobResult = getOrCreateJobResult(jobId); - jobResult.completeWithFile(fileId, originalFileName, contentType); - log.debug("Set file result for job ID: {} with file ID: {}", jobId, fileId); + + // Check if this is a ZIP file that should be extracted + if (isZipFile(contentType, originalFileName)) { + try { + List extractedFiles = + extractZipToIndividualFiles(fileId, originalFileName); + if (!extractedFiles.isEmpty()) { + jobResult.completeWithFiles(extractedFiles); + log.debug( + "Set multiple file results for job ID: {} with {} files extracted from ZIP", + jobId, + extractedFiles.size()); + return; + } + } catch (Exception e) { + log.warn( + "Failed to extract ZIP file for job {}: {}. Falling back to single file result.", + jobId, + e.getMessage()); + } + } + + // Handle as single file using new ResultFile approach + try { + long fileSize = fileStorage.getFileSize(fileId); + jobResult.completeWithSingleFile(fileId, originalFileName, contentType, fileSize); + log.debug("Set single file result for job ID: {} with file ID: {}", jobId, fileId); + } catch (Exception e) { + log.warn( + "Failed to get file size for job {}: {}. Using size 0.", jobId, e.getMessage()); + jobResult.completeWithSingleFile(fileId, originalFileName, contentType, 0); + } + } + + /** + * Set the result of a task as multiple files + * + * @param jobId The job ID + * @param resultFiles The list of result files + */ + public void setMultipleFileResults(String jobId, List resultFiles) { + JobResult jobResult = getOrCreateJobResult(jobId); + jobResult.completeWithFiles(resultFiles); + log.debug( + "Set multiple file results for job ID: {} with {} files", + jobId, + resultFiles.size()); } /** @@ -104,7 +160,7 @@ public class TaskManager { public void setComplete(String jobId) { JobResult jobResult = getOrCreateJobResult(jobId); if (jobResult.getResult() == null - && jobResult.getFileId() == null + && !jobResult.hasFiles() && jobResult.getError() == null) { // If no result or error has been set, mark it as complete with an empty result jobResult.completeWithResult("Task completed successfully"); @@ -186,7 +242,7 @@ public class TaskManager { failedJobs++; } else { successfulJobs++; - if (result.getFileId() != null) { + if (result.hasFiles()) { fileResultJobs++; } } @@ -250,17 +306,8 @@ public class TaskManager { && result.getCompletedAt() != null && result.getCompletedAt().isBefore(expiryThreshold)) { - // If the job has a file result, delete the file - if (result.getFileId() != null) { - try { - fileStorage.deleteFile(result.getFileId()); - } catch (Exception e) { - log.warn( - "Failed to delete file for job {}: {}", - entry.getKey(), - e.getMessage()); - } - } + // Clean up file results + cleanupJobFiles(result, entry.getKey()); // Remove the job result jobResults.remove(entry.getKey()); @@ -290,4 +337,128 @@ public class TaskManager { cleanupExecutor.shutdownNow(); } } + + /** Check if a file is a ZIP file based on content type and filename */ + private boolean isZipFile(String contentType, String fileName) { + if (contentType != null + && (contentType.equals("application/zip") + || contentType.equals("application/x-zip-compressed"))) { + return true; + } + + if (fileName != null && fileName.toLowerCase().endsWith(".zip")) { + return true; + } + + return false; + } + + /** Extract a ZIP file into individual files and store them */ + private List extractZipToIndividualFiles( + String zipFileId, String originalZipFileName) throws IOException { + List extractedFiles = new ArrayList<>(); + + MultipartFile zipFile = fileStorage.retrieveFile(zipFileId); + + try (ZipInputStream zipIn = + ZipSecurity.createHardenedInputStream(new ByteArrayInputStream(zipFile.getBytes()))) { + ZipEntry entry; + while ((entry = zipIn.getNextEntry()) != null) { + if (!entry.isDirectory()) { + // Use buffered reading for memory safety + ByteArrayOutputStream out = new ByteArrayOutputStream(); + byte[] buffer = new byte[4096]; + int bytesRead; + while ((bytesRead = zipIn.read(buffer)) != -1) { + out.write(buffer, 0, bytesRead); + } + byte[] fileContent = out.toByteArray(); + + String contentType = determineContentType(entry.getName()); + String individualFileId = fileStorage.storeBytes(fileContent, entry.getName()); + + ResultFile resultFile = + ResultFile.builder() + .fileId(individualFileId) + .fileName(entry.getName()) + .contentType(contentType) + .fileSize(fileContent.length) + .build(); + + extractedFiles.add(resultFile); + log.debug( + "Extracted file: {} (size: {} bytes)", + entry.getName(), + fileContent.length); + } + zipIn.closeEntry(); + } + } + + // Clean up the original ZIP file after extraction + try { + fileStorage.deleteFile(zipFileId); + log.debug("Cleaned up original ZIP file: {}", zipFileId); + } catch (Exception e) { + log.warn("Failed to clean up original ZIP file {}: {}", zipFileId, e.getMessage()); + } + + return extractedFiles; + } + + /** Determine content type based on file extension */ + private String determineContentType(String fileName) { + if (fileName == null) { + return MediaType.APPLICATION_OCTET_STREAM_VALUE; + } + + String lowerName = fileName.toLowerCase(); + if (lowerName.endsWith(".pdf")) { + return MediaType.APPLICATION_PDF_VALUE; + } else if (lowerName.endsWith(".txt")) { + return MediaType.TEXT_PLAIN_VALUE; + } else if (lowerName.endsWith(".json")) { + return MediaType.APPLICATION_JSON_VALUE; + } else if (lowerName.endsWith(".xml")) { + return MediaType.APPLICATION_XML_VALUE; + } else if (lowerName.endsWith(".jpg") || lowerName.endsWith(".jpeg")) { + return MediaType.IMAGE_JPEG_VALUE; + } else if (lowerName.endsWith(".png")) { + return MediaType.IMAGE_PNG_VALUE; + } else { + return MediaType.APPLICATION_OCTET_STREAM_VALUE; + } + } + + /** Clean up files associated with a job result */ + private void cleanupJobFiles(JobResult result, String jobId) { + // Clean up all result files + if (result.hasFiles()) { + for (ResultFile resultFile : result.getAllResultFiles()) { + try { + fileStorage.deleteFile(resultFile.getFileId()); + } catch (Exception e) { + log.warn( + "Failed to delete file {} for job {}: {}", + resultFile.getFileId(), + jobId, + e.getMessage()); + } + } + } + } + + /** Find the ResultFile metadata for a given file ID by searching through all job results */ + public ResultFile findResultFileByFileId(String fileId) { + for (JobResult jobResult : jobResults.values()) { + if (jobResult.hasFiles()) { + for (ResultFile resultFile : jobResult.getAllResultFiles()) { + if (fileId.equals(resultFile.getFileId())) { + return resultFile; + } + } + } + } + return null; + } } diff --git a/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java b/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java index 4eba52f29..df85a016b 100644 --- a/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java +++ b/common/src/main/java/stirling/software/common/service/TempFileCleanupService.java @@ -154,11 +154,15 @@ public class TempFileCleanupService { boolean containerMode = isContainerMode(); int unregisteredDeletedCount = cleanupUnregisteredFiles(containerMode, true, maxAgeMillis); - log.info( - "Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories", - registeredDeletedCount, - unregisteredDeletedCount, - directoriesDeletedCount); + if (registeredDeletedCount > 0 + || unregisteredDeletedCount > 0 + || directoriesDeletedCount > 0) { + log.info( + "Scheduled cleanup complete. Deleted {} registered files, {} unregistered files, {} directories", + registeredDeletedCount, + unregisteredDeletedCount, + directoriesDeletedCount); + } } /** @@ -166,7 +170,6 @@ public class TempFileCleanupService { * important in Docker environments where temp files persist between container restarts. */ private void runStartupCleanup() { - log.info("Running startup temporary file cleanup"); boolean containerMode = isContainerMode(); log.info( @@ -178,7 +181,6 @@ public class TempFileCleanupService { long maxAgeMillis = containerMode ? 0 : 24 * 60 * 60 * 1000; // 0 or 24 hours int totalDeletedCount = cleanupUnregisteredFiles(containerMode, false, maxAgeMillis); - log.info( "Startup cleanup complete. Deleted {} temporary files/directories", totalDeletedCount); @@ -225,7 +227,7 @@ public class TempFileCleanupService { tempDir -> { try { String phase = isScheduled ? "scheduled" : "startup"; - log.info( + log.debug( "Scanning directory for {} cleanup: {}", phase, tempDir); diff --git a/common/src/main/java/stirling/software/common/util/ExceptionUtils.java b/common/src/main/java/stirling/software/common/util/ExceptionUtils.java new file mode 100644 index 000000000..74f65e713 --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/ExceptionUtils.java @@ -0,0 +1,327 @@ +package stirling.software.common.util; + +import java.io.IOException; +import java.text.MessageFormat; + +import lombok.extern.slf4j.Slf4j; + +/** + * Utility class for handling exceptions with internationalized error messages. Provides consistent + * error handling and user-friendly messages across the application. + */ +@Slf4j +public class ExceptionUtils { + + /** + * Create an IOException with internationalized message for PDF corruption. + * + * @param cause the original exception + * @return IOException with user-friendly message + */ + public static IOException createPdfCorruptedException(Exception cause) { + return createPdfCorruptedException(null, cause); + } + + /** + * Create an IOException with internationalized message for PDF corruption with context. + * + * @param context additional context (e.g., "during merge", "during image extraction") + * @param cause the original exception + * @return IOException with user-friendly message + */ + public static IOException createPdfCorruptedException(String context, Exception cause) { + String message; + if (context != null && !context.isEmpty()) { + message = + String.format( + "Error %s: PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation.", + context); + } else { + message = + "PDF file appears to be corrupted or damaged. Please try using the 'Repair PDF' feature first to fix the file before proceeding with this operation."; + } + return new IOException(message, cause); + } + + /** + * Create an IOException with internationalized message for multiple corrupted PDFs. + * + * @param cause the original exception + * @return IOException with user-friendly message + */ + public static IOException createMultiplePdfCorruptedException(Exception cause) { + String message = + "One or more PDF files appear to be corrupted or damaged. Please try using the 'Repair PDF' feature on each file first before attempting to merge them."; + return new IOException(message, cause); + } + + /** + * Create an IOException with internationalized message for PDF encryption issues. + * + * @param cause the original exception + * @return IOException with user-friendly message + */ + public static IOException createPdfEncryptionException(Exception cause) { + String message = + "The PDF appears to have corrupted encryption data. This can happen when the PDF was created with incompatible encryption methods. Please try using the 'Repair PDF' feature first, or contact the document creator for a new copy."; + return new IOException(message, cause); + } + + /** + * Create an IOException with internationalized message for PDF password issues. + * + * @param cause the original exception + * @return IOException with user-friendly message + */ + public static IOException createPdfPasswordException(Exception cause) { + String message = + "The PDF Document is passworded and either the password was not provided or was incorrect"; + return new IOException(message, cause); + } + + /** + * Create an IOException with internationalized message for file processing errors. + * + * @param operation the operation being performed (e.g., "merge", "split", "convert") + * @param cause the original exception + * @return IOException with user-friendly message + */ + public static IOException createFileProcessingException(String operation, Exception cause) { + String message = + String.format( + "An error occurred while processing the file during %s operation: %s", + operation, cause.getMessage()); + return new IOException(message, cause); + } + + /** + * Create a generic IOException with internationalized message. + * + * @param messageKey the i18n message key + * @param defaultMessage the default message if i18n is not available + * @param cause the original exception + * @param args optional arguments for the message + * @return IOException with user-friendly message + */ + public static IOException createIOException( + String messageKey, String defaultMessage, Exception cause, Object... args) { + String message = MessageFormat.format(defaultMessage, args); + return new IOException(message, cause); + } + + /** + * Create a generic RuntimeException with internationalized message. + * + * @param messageKey the i18n message key + * @param defaultMessage the default message if i18n is not available + * @param cause the original exception + * @param args optional arguments for the message + * @return RuntimeException with user-friendly message + */ + public static RuntimeException createRuntimeException( + String messageKey, String defaultMessage, Exception cause, Object... args) { + String message = MessageFormat.format(defaultMessage, args); + return new RuntimeException(message, cause); + } + + /** + * Create an IllegalArgumentException with internationalized message. + * + * @param messageKey the i18n message key + * @param defaultMessage the default message if i18n is not available + * @param args optional arguments for the message + * @return IllegalArgumentException with user-friendly message + */ + public static IllegalArgumentException createIllegalArgumentException( + String messageKey, String defaultMessage, Object... args) { + String message = MessageFormat.format(defaultMessage, args); + return new IllegalArgumentException(message); + } + + /** Create file validation exceptions. */ + public static IllegalArgumentException createHtmlFileRequiredException() { + return createIllegalArgumentException( + "error.fileFormatRequired", "File must be in {0} format", "HTML or ZIP"); + } + + public static IllegalArgumentException createPdfFileRequiredException() { + return createIllegalArgumentException( + "error.fileFormatRequired", "File must be in {0} format", "PDF"); + } + + public static IllegalArgumentException createInvalidPageSizeException(String size) { + return createIllegalArgumentException( + "error.invalidFormat", "Invalid {0} format: {1}", "page size", size); + } + + /** Create OCR-related exceptions. */ + public static IOException createOcrLanguageRequiredException() { + return createIOException( + "error.optionsNotSpecified", "{0} options are not specified", null, "OCR language"); + } + + public static IOException createOcrInvalidLanguagesException() { + return createIOException( + "error.invalidFormat", + "Invalid {0} format: {1}", + null, + "OCR languages", + "none of the selected languages are valid"); + } + + public static IOException createOcrToolsUnavailableException() { + return createIOException( + "error.toolNotInstalled", "{0} is not installed", null, "OCR tools"); + } + + /** Create system requirement exceptions. */ + public static IOException createPythonRequiredForWebpException() { + return createIOException( + "error.toolRequired", "{0} is required for {1}", null, "Python", "WebP conversion"); + } + + /** Create file operation exceptions. */ + public static IOException createFileNotFoundException(String fileId) { + return createIOException("error.fileNotFound", "File not found with ID: {0}", null, fileId); + } + + public static RuntimeException createPdfaConversionFailedException() { + return createRuntimeException( + "error.conversionFailed", "{0} conversion failed", null, "PDF/A"); + } + + public static IllegalArgumentException createInvalidComparatorException() { + return createIllegalArgumentException( + "error.invalidFormat", + "Invalid {0} format: {1}", + "comparator", + "only 'greater', 'equal', and 'less' are supported"); + } + + /** Create compression-related exceptions. */ + public static RuntimeException createMd5AlgorithmException(Exception cause) { + return createRuntimeException( + "error.algorithmNotAvailable", "{0} algorithm not available", cause, "MD5"); + } + + public static IllegalArgumentException createCompressionOptionsException() { + return createIllegalArgumentException( + "error.optionsNotSpecified", + "{0} options are not specified", + "compression (expected output size and optimize level)"); + } + + public static IOException createGhostscriptCompressionException() { + return createIOException( + "error.commandFailed", "{0} command failed", null, "Ghostscript compression"); + } + + public static IOException createGhostscriptCompressionException(Exception cause) { + return createIOException( + "error.commandFailed", "{0} command failed", cause, "Ghostscript compression"); + } + + public static IOException createQpdfCompressionException(Exception cause) { + return createIOException("error.commandFailed", "{0} command failed", cause, "QPDF"); + } + + /** + * Check if an exception indicates a corrupted PDF and wrap it with appropriate message. + * + * @param e the exception to check + * @return the original exception if not PDF corruption, or a new IOException with user-friendly + * message + */ + public static IOException handlePdfException(IOException e) { + return handlePdfException(e, null); + } + + /** + * Check if an exception indicates a corrupted PDF and wrap it with appropriate message. + * + * @param e the exception to check + * @param context additional context for the error + * @return the original exception if not PDF corruption, or a new IOException with user-friendly + * message + */ + public static IOException handlePdfException(IOException e, String context) { + if (PdfErrorUtils.isCorruptedPdfError(e)) { + return createPdfCorruptedException(context, e); + } + + if (isEncryptionError(e)) { + return createPdfEncryptionException(e); + } + + if (isPasswordError(e)) { + return createPdfPasswordException(e); + } + + return e; // Return original exception if no specific handling needed + } + + /** + * Check if an exception indicates a PDF encryption/decryption error. + * + * @param e the exception to check + * @return true if it's an encryption error, false otherwise + */ + public static boolean isEncryptionError(IOException e) { + String message = e.getMessage(); + if (message == null) return false; + + return message.contains("BadPaddingException") + || message.contains("Given final block not properly padded") + || message.contains("AES initialization vector not fully read") + || message.contains("Failed to decrypt"); + } + + /** + * Check if an exception indicates a PDF password error. + * + * @param e the exception to check + * @return true if it's a password error, false otherwise + */ + public static boolean isPasswordError(IOException e) { + String message = e.getMessage(); + if (message == null) return false; + + return message.contains("password is incorrect") + || message.contains("Password is not provided") + || message.contains("PDF contains an encryption dictionary"); + } + + /** + * Log an exception with appropriate level based on its type. + * + * @param operation the operation being performed + * @param e the exception that occurred + */ + public static void logException(String operation, Exception e) { + if (PdfErrorUtils.isCorruptedPdfError(e)) { + log.warn("PDF corruption detected during {}: {}", operation, e.getMessage()); + } else if (e instanceof IOException + && (isEncryptionError((IOException) e) || isPasswordError((IOException) e))) { + log.info("PDF security issue during {}: {}", operation, e.getMessage()); + } else { + log.error("Unexpected error during {}", operation, e); + } + } + + /** Create common validation exceptions. */ + public static IllegalArgumentException createInvalidArgumentException(String argumentName) { + return createIllegalArgumentException( + "error.invalidArgument", "Invalid argument: {0}", argumentName); + } + + public static IllegalArgumentException createInvalidArgumentException( + String argumentName, String value) { + return createIllegalArgumentException( + "error.invalidFormat", "Invalid {0} format: {1}", argumentName, value); + } + + public static IllegalArgumentException createNullArgumentException(String argumentName) { + return createIllegalArgumentException( + "error.argumentRequired", "{0} must not be null", argumentName); + } +} diff --git a/common/src/main/java/stirling/software/common/util/FileToPdf.java b/common/src/main/java/stirling/software/common/util/FileToPdf.java index 7b3765084..c735e5287 100644 --- a/common/src/main/java/stirling/software/common/util/FileToPdf.java +++ b/common/src/main/java/stirling/software/common/util/FileToPdf.java @@ -32,21 +32,23 @@ public class FileToPdf { try (TempFile tempOutputFile = new TempFile(tempFileManager, ".pdf")) { try (TempFile tempInputFile = - new TempFile(tempFileManager, fileName.endsWith(".html") ? ".html" : ".zip")) { + new TempFile( + tempFileManager, + fileName.toLowerCase().endsWith(".html") ? ".html" : ".zip")) { - if (fileName.endsWith(".html")) { + if (fileName.toLowerCase().endsWith(".html")) { String sanitizedHtml = sanitizeHtmlContent( new String(fileBytes, StandardCharsets.UTF_8), disableSanitize); Files.write( tempInputFile.getPath(), sanitizedHtml.getBytes(StandardCharsets.UTF_8)); - } else if (fileName.endsWith(".zip")) { + } else if (fileName.toLowerCase().endsWith(".zip")) { Files.write(tempInputFile.getPath(), fileBytes); sanitizeHtmlFilesInZip( tempInputFile.getPath(), disableSanitize, tempFileManager); } else { - throw new IllegalArgumentException("Unsupported file format: " + fileName); + throw ExceptionUtils.createHtmlFileRequiredException(); } List command = new ArrayList<>(); diff --git a/common/src/main/java/stirling/software/common/util/PdfErrorUtils.java b/common/src/main/java/stirling/software/common/util/PdfErrorUtils.java new file mode 100644 index 000000000..aeda114c9 --- /dev/null +++ b/common/src/main/java/stirling/software/common/util/PdfErrorUtils.java @@ -0,0 +1,55 @@ +package stirling.software.common.util; + +import java.io.IOException; + +/** Utility class for detecting and handling PDF-related errors. */ +public class PdfErrorUtils { + + /** + * Checks if an IOException indicates a corrupted PDF file. + * + * @param e the IOException to check + * @return true if the error indicates PDF corruption, false otherwise + */ + public static boolean isCorruptedPdfError(IOException e) { + return isCorruptedPdfError(e.getMessage()); + } + + /** + * Checks if any Exception indicates a corrupted PDF file. + * + * @param e the Exception to check + * @return true if the error indicates PDF corruption, false otherwise + */ + public static boolean isCorruptedPdfError(Exception e) { + return isCorruptedPdfError(e.getMessage()); + } + + /** + * Checks if an error message indicates a corrupted PDF file. + * + * @param message the error message to check + * @return true if the message indicates PDF corruption, false otherwise + */ + private static boolean isCorruptedPdfError(String message) { + if (message == null) return false; + + // Check for common corruption indicators + return message.contains("Missing root object specification") + || message.contains("Header doesn't contain versioninfo") + || message.contains("Expected trailer") + || message.contains("Invalid PDF") + || message.contains("Corrupted") + || message.contains("damaged") + || message.contains("Unknown dir object") + || message.contains("Can't dereference COSObject") + || message.contains("parseCOSString string should start with") + || message.contains("ICCBased colorspace array must have a stream") + || message.contains("1-based index not found") + || message.contains("Invalid dictionary, found:") + || message.contains("AES initialization vector not fully read") + || message.contains("BadPaddingException") + || message.contains("Given final block not properly padded") + || message.contains("End-of-File, expected line"); + } +} diff --git a/common/src/main/java/stirling/software/common/util/PdfUtils.java b/common/src/main/java/stirling/software/common/util/PdfUtils.java index 3986110e5..ec269e47d 100644 --- a/common/src/main/java/stirling/software/common/util/PdfUtils.java +++ b/common/src/main/java/stirling/software/common/util/PdfUtils.java @@ -42,26 +42,34 @@ public class PdfUtils { public static PDRectangle textToPageSize(String size) { switch (size.toUpperCase()) { - case "A0": + case "A0" -> { return PDRectangle.A0; - case "A1": + } + case "A1" -> { return PDRectangle.A1; - case "A2": + } + case "A2" -> { return PDRectangle.A2; - case "A3": + } + case "A3" -> { return PDRectangle.A3; - case "A4": + } + case "A4" -> { return PDRectangle.A4; - case "A5": + } + case "A5" -> { return PDRectangle.A5; - case "A6": + } + case "A6" -> { return PDRectangle.A6; - case "LETTER": + } + case "LETTER" -> { return PDRectangle.LETTER; - case "LEGAL": + } + case "LEGAL" -> { return PDRectangle.LEGAL; - default: - throw new IllegalArgumentException("Invalid standard page size: " + size); + } + default -> throw ExceptionUtils.createInvalidPageSizeException(size); } } @@ -135,6 +143,17 @@ public class PdfUtils { int DPI, String filename) 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) { + 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); + } + try (PDDocument document = pdfDocumentFactory.load(inputStream)) { PDFRenderer pdfRenderer = new PDFRenderer(document); pdfRenderer.setSubsamplingAllowed(true); @@ -158,7 +177,21 @@ public class PdfUtils { writer.prepareWriteSequence(null); for (int i = 0; i < pageCount; ++i) { - BufferedImage image = pdfRenderer.renderImageWithDPI(i, DPI, colorType); + BufferedImage image; + try { + image = pdfRenderer.renderImageWithDPI(i, DPI, colorType); + } catch (IllegalArgumentException e) { + if (e.getMessage() != null + && e.getMessage() + .contains("Maximum size of image exceeded")) { + throw ExceptionUtils.createIllegalArgumentException( + "error.pageTooBigForDpi", + "PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).", + i + 1, + DPI); + } + throw e; + } writer.writeToSequence(new IIOImage(image, null, null), param); } @@ -190,7 +223,20 @@ public class PdfUtils { PdfImageDimensionValue dimension = pageSizes.get(settings); if (dimension == null) { // Render the image to get the dimensions - pdfSizeImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType); + try { + pdfSizeImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType); + } catch (IllegalArgumentException e) { + if (e.getMessage() != null + && e.getMessage() + .contains("Maximum size of image exceeded")) { + throw ExceptionUtils.createIllegalArgumentException( + "error.pageTooBigExceedsArray", + "PDF page {0} is too large to render at {1} DPI. The resulting image would exceed Java's maximum array size. Please try a lower DPI value (recommended: 150 or less).", + i + 1, + DPI); + } + throw e; + } pdfSizeImageIndex = i; dimension = new PdfImageDimensionValue( @@ -218,7 +264,20 @@ public class PdfUtils { if (firstImageAlreadyRendered && i == 0) { pageImage = pdfSizeImage; } else { - pageImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType); + try { + pageImage = pdfRenderer.renderImageWithDPI(i, DPI, colorType); + } catch (IllegalArgumentException e) { + if (e.getMessage() != null + && e.getMessage() + .contains("Maximum size of image exceeded")) { + throw ExceptionUtils.createIllegalArgumentException( + "error.pageTooBigForDpi", + "PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).", + i + 1, + DPI); + } + throw e; + } } // Calculate the x-coordinate to center the image @@ -238,7 +297,20 @@ public class PdfUtils { // Zip the images and return as byte array try (ZipOutputStream zos = new ZipOutputStream(baos)) { for (int i = 0; i < pageCount; ++i) { - BufferedImage image = pdfRenderer.renderImageWithDPI(i, DPI, colorType); + BufferedImage image; + try { + image = pdfRenderer.renderImageWithDPI(i, DPI, colorType); + } catch (IllegalArgumentException e) { + if (e.getMessage() != null + && e.getMessage().contains("Maximum size of image exceeded")) { + throw ExceptionUtils.createIllegalArgumentException( + "error.pageTooBigForDpi", + "PDF page {0} is too large to render at {1} DPI. Please try a lower DPI value (recommended: 150 or less).", + i + 1, + DPI); + } + throw e; + } try (ByteArrayOutputStream baosImage = new ByteArrayOutputStream()) { ImageIO.write(image, imageType, baosImage); @@ -276,7 +348,19 @@ public class PdfUtils { PDFRenderer pdfRenderer = new PDFRenderer(document); pdfRenderer.setSubsamplingAllowed(true); for (int page = 0; page < document.getNumberOfPages(); ++page) { - BufferedImage bim = pdfRenderer.renderImageWithDPI(page, 300, ImageType.RGB); + BufferedImage bim; + try { + bim = pdfRenderer.renderImageWithDPI(page, 300, ImageType.RGB); + } catch (IllegalArgumentException e) { + if (e.getMessage() != null + && e.getMessage().contains("Maximum size of image exceeded")) { + throw ExceptionUtils.createIllegalArgumentException( + "error.pageTooBigFor300Dpi", + "PDF page {0} is too large to render at 300 DPI. The resulting image would exceed Java's maximum array size. Please use a lower DPI value for PDF-to-image conversion.", + page + 1); + } + throw e; + } PDPage originalPage = document.getPage(page); float width = originalPage.getMediaBox().getWidth(); @@ -349,7 +433,7 @@ public class PdfUtils { } ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); doc.save(byteArrayOutputStream); - log.info("PDF successfully saved to byte array"); + log.debug("PDF successfully saved to byte array"); return byteArrayOutputStream.toByteArray(); } } @@ -495,8 +579,7 @@ public class PdfUtils { case "less": return actualPageCount < pageCount; default: - throw new IllegalArgumentException( - "Invalid comparator. Only 'greater', 'equal', and 'less' are supported."); + throw ExceptionUtils.createInvalidArgumentException("comparator", comparator); } } diff --git a/common/src/main/java/stirling/software/common/util/ProcessExecutor.java b/common/src/main/java/stirling/software/common/util/ProcessExecutor.java index 09c5ff675..ee7297153 100644 --- a/common/src/main/java/stirling/software/common/util/ProcessExecutor.java +++ b/common/src/main/java/stirling/software/common/util/ProcessExecutor.java @@ -84,6 +84,16 @@ public class ProcessExecutor { .getProcessExecutor() .getSessionLimit() .getCalibreSessionLimit(); + case GHOSTSCRIPT -> + applicationProperties + .getProcessExecutor() + .getSessionLimit() + .getGhostscriptSessionLimit(); + case OCR_MY_PDF -> + applicationProperties + .getProcessExecutor() + .getSessionLimit() + .getOcrMyPdfSessionLimit(); }; long timeoutMinutes = @@ -128,6 +138,16 @@ public class ProcessExecutor { .getProcessExecutor() .getTimeoutMinutes() .getCalibreTimeoutMinutes(); + case GHOSTSCRIPT -> + applicationProperties + .getProcessExecutor() + .getTimeoutMinutes() + .getGhostscriptTimeoutMinutes(); + case OCR_MY_PDF -> + applicationProperties + .getProcessExecutor() + .getTimeoutMinutes() + .getOcrMyPdfTimeoutMinutes(); }; return new ProcessExecutor(semaphoreLimit, liveUpdates, timeoutMinutes); }); @@ -278,7 +298,9 @@ public class ProcessExecutor { INSTALL_APP, CALIBRE, TESSERACT, - QPDF + QPDF, + GHOSTSCRIPT, + OCR_MY_PDF } public class ProcessExecutorResult { diff --git a/common/src/main/java/stirling/software/common/util/misc/CustomColorReplaceStrategy.java b/common/src/main/java/stirling/software/common/util/misc/CustomColorReplaceStrategy.java index dc1781236..6703a159a 100644 --- a/common/src/main/java/stirling/software/common/util/misc/CustomColorReplaceStrategy.java +++ b/common/src/main/java/stirling/software/common/util/misc/CustomColorReplaceStrategy.java @@ -108,6 +108,12 @@ public class CustomColorReplaceStrategy extends ReplaceAndInvertColorStrategy { } catch (IllegalArgumentException ie) { log.info("text not supported by font "); font = checkSupportedFontForCharacter(unicodeText); + } catch (UnsupportedOperationException ue) { + log.info( + "font does not support encoding operation: {} for text: '{}'", + font.getClass().getSimpleName(), + unicodeText); + font = checkSupportedFontForCharacter(unicodeText); } finally { // if any other font is not supported, then replace default character * if (font == null) { diff --git a/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java b/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java index e25ceddf9..6d72855fb 100644 --- a/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java +++ b/common/src/test/java/stirling/software/common/annotations/AutoJobPostMappingIntegrationTest.java @@ -126,7 +126,7 @@ class AutoJobPostMappingIntegrationTest { verify(jobExecutorService).runJobGeneric( asyncCaptor.capture(), - workCaptor.capture(), + workCaptor.capture(), timeoutCaptor.capture(), queueableCaptor.capture(), resourceWeightCaptor.capture()); @@ -197,7 +197,7 @@ class AutoJobPostMappingIntegrationTest { 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/common/src/test/java/stirling/software/common/service/FileStorageTest.java b/common/src/test/java/stirling/software/common/service/FileStorageTest.java index f1ca1ffdf..81ab5857e 100644 --- a/common/src/test/java/stirling/software/common/service/FileStorageTest.java +++ b/common/src/test/java/stirling/software/common/service/FileStorageTest.java @@ -187,4 +187,4 @@ class FileStorageTest { // Assert assertFalse(result); } -} \ No newline at end of file +} diff --git a/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java b/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java index a4d293b1b..370db503b 100644 --- a/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java +++ b/common/src/test/java/stirling/software/common/service/JobExecutorServiceTest.java @@ -64,8 +64,8 @@ class JobExecutorServiceTest { void setUp() { // Initialize the service manually with all its dependencies jobExecutorService = new JobExecutorService( - taskManager, - fileStorage, + taskManager, + fileStorage, request, resourceMonitor, jobQueue, @@ -199,4 +199,4 @@ class JobExecutorServiceTest { assertTrue(e.getCause() instanceof TimeoutException); } } -} \ No newline at end of file +} diff --git a/common/src/test/java/stirling/software/common/service/JobQueueTest.java b/common/src/test/java/stirling/software/common/service/JobQueueTest.java index 813f5e172..64c836faf 100644 --- a/common/src/test/java/stirling/software/common/service/JobQueueTest.java +++ b/common/src/test/java/stirling/software/common/service/JobQueueTest.java @@ -36,7 +36,7 @@ class JobQueueTest { // Mark stubbing as lenient to avoid UnnecessaryStubbingException lenient().when(resourceMonitor.calculateDynamicQueueCapacity(anyInt(), anyInt())).thenReturn(10); lenient().when(resourceMonitor.getCurrentStatus()).thenReturn(statusRef); - + // Initialize JobQueue with mocked ResourceMonitor jobQueue = new JobQueue(resourceMonitor); } @@ -99,4 +99,4 @@ class JobQueueTest { assertTrue(jobQueue.isJobQueued(jobId)); assertFalse(jobQueue.isJobQueued("nonexistent")); } -} \ No newline at end of file +} diff --git a/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java b/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java index a707b87e6..9667f4999 100644 --- a/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java +++ b/common/src/test/java/stirling/software/common/service/ResourceMonitorTest.java @@ -117,7 +117,7 @@ class ResourceMonitorTest { // Then assertEquals(shouldQueue, result, - String.format("For weight %d and status %s, shouldQueue should be %s", + String.format("For weight %d and status %s, shouldQueue should be %s", weight, status, shouldQueue)); } @@ -134,4 +134,4 @@ class ResourceMonitorTest { 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"); } -} \ No newline at end of file +} diff --git a/common/src/test/java/stirling/software/common/service/TaskManagerTest.java b/common/src/test/java/stirling/software/common/service/TaskManagerTest.java index 85f62aed4..b2cb26dd8 100644 --- a/common/src/test/java/stirling/software/common/service/TaskManagerTest.java +++ b/common/src/test/java/stirling/software/common/service/TaskManagerTest.java @@ -18,6 +18,7 @@ import org.springframework.test.util.ReflectionTestUtils; import stirling.software.common.model.job.JobResult; import stirling.software.common.model.job.JobStats; +import stirling.software.common.model.job.ResultFile; class TaskManagerTest { @@ -73,13 +74,17 @@ class TaskManagerTest { } @Test - void testSetFileResult() { + void testSetFileResult() throws Exception { // Arrange String jobId = UUID.randomUUID().toString(); taskManager.createTask(jobId); String fileId = "file-id"; String originalFileName = "test.pdf"; String contentType = "application/pdf"; + long fileSize = 1024L; + + // Mock the fileStorage.getFileSize() call + when(fileStorage.getFileSize(fileId)).thenReturn(fileSize); // Act taskManager.setFileResult(jobId, fileId, originalFileName, contentType); @@ -88,9 +93,17 @@ class TaskManagerTest { JobResult result = taskManager.getJobResult(jobId); assertNotNull(result); assertTrue(result.isComplete()); - assertEquals(fileId, result.getFileId()); - assertEquals(originalFileName, result.getOriginalFileName()); - assertEquals(contentType, result.getContentType()); + assertTrue(result.hasFiles()); + assertFalse(result.hasMultipleFiles()); + + var resultFiles = result.getAllResultFiles(); + assertEquals(1, resultFiles.size()); + + ResultFile resultFile = resultFiles.get(0); + assertEquals(fileId, resultFile.getFileId()); + assertEquals(originalFileName, resultFile.getFileName()); + assertEquals(contentType, resultFile.getContentType()); + assertEquals(fileSize, resultFile.getFileSize()); assertNotNull(result.getCompletedAt()); } @@ -163,8 +176,11 @@ class TaskManagerTest { } @Test - void testGetJobStats() { + void testGetJobStats() throws Exception { // Arrange + // Mock fileStorage.getFileSize for file operations + when(fileStorage.getFileSize("file-id")).thenReturn(1024L); + // 1. Create active job String activeJobId = "active-job"; taskManager.createTask(activeJobId); @@ -216,9 +232,15 @@ class TaskManagerTest { LocalDateTime oldTime = LocalDateTime.now().minusHours(1); ReflectionTestUtils.setField(oldJob, "completedAt", oldTime); ReflectionTestUtils.setField(oldJob, "complete", true); - ReflectionTestUtils.setField(oldJob, "fileId", "file-id"); - ReflectionTestUtils.setField(oldJob, "originalFileName", "test.pdf"); - ReflectionTestUtils.setField(oldJob, "contentType", "application/pdf"); + + // 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(); + ReflectionTestUtils.setField(oldJob, "resultFiles", java.util.List.of(resultFile)); when(fileStorage.deleteFile("file-id")).thenReturn(true); @@ -252,17 +274,17 @@ class TaskManagerTest { // Verify the executor service is shutdown // This is difficult to test directly, but we can verify it doesn't throw exceptions } - + @Test void testAddNote() { // Arrange String jobId = UUID.randomUUID().toString(); taskManager.createTask(jobId); String note = "Test note"; - + // Act boolean result = taskManager.addNote(jobId, note); - + // Assert assertTrue(result); JobResult jobResult = taskManager.getJobResult(jobId); @@ -271,16 +293,16 @@ class TaskManagerTest { assertEquals(1, jobResult.getNotes().size()); assertEquals(note, jobResult.getNotes().get(0)); } - + @Test void testAddNote_NonExistentJob() { // Arrange String jobId = "non-existent-job"; String note = "Test note"; - + // Act boolean result = taskManager.addNote(jobId, note); - + // Assert assertFalse(result); } diff --git a/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java b/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java index 009c00860..34c471227 100644 --- a/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java +++ b/common/src/test/java/stirling/software/common/service/TempFileCleanupServiceTest.java @@ -82,10 +82,10 @@ public class TempFileCleanupServiceTest { when(tempFileManagement.isStartupCleanup()).thenReturn(false); when(tempFileManagement.isCleanupSystemTemp()).thenReturn(false); when(tempFileManagement.getCleanupIntervalMinutes()).thenReturn(30L); - + // Set machineType using reflection (still needed for this field) ReflectionTestUtils.setField(cleanupService, "machineType", "Standard"); - + when(tempFileManager.getMaxAgeMillis()).thenReturn(3600000L); // 1 hour } @@ -113,30 +113,30 @@ public class TempFileCleanupServiceTest { Path ourTempFile3 = Files.createFile(customTempDir.resolve("stirling-pdf-789.tmp")); Path ourTempFile4 = Files.createFile(customTempDir.resolve("pdf-save-123-456.tmp")); Path ourTempFile5 = Files.createFile(libreOfficeTempDir.resolve("input_file.pdf")); - + // Old temporary files Path oldTempFile = Files.createFile(systemTempDir.resolve("output_old.pdf")); - + // System temp files that should be cleaned in container mode Path sysTempFile1 = Files.createFile(systemTempDir.resolve("lu123abc.tmp")); Path sysTempFile2 = Files.createFile(customTempDir.resolve("ocr_process123")); Path sysTempFile3 = Files.createFile(customTempDir.resolve("tmp_upload.tmp")); - + // 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 regularFile = Files.createFile(systemTempDir.resolve("important.txt")); - + // Create a nested directory with temp files Path nestedDir = Files.createDirectories(systemTempDir.resolve("nested")); Path nestedTempFile = Files.createFile(nestedDir.resolve("output_nested.pdf")); - + // Empty file (special case) Path emptyFile = Files.createFile(systemTempDir.resolve("empty.tmp")); - + // Configure mock registry to say these files aren't registered when(registry.contains(any(File.class))).thenReturn(false); - + // The set of files that will be deleted in our test Set deletedFiles = new HashSet<>(); @@ -145,31 +145,31 @@ public class TempFileCleanupServiceTest { // Mock Files.list for each directory we'll process mockedFiles.when(() -> Files.list(eq(systemTempDir))) .thenReturn(Stream.of( - ourTempFile1, ourTempFile2, oldTempFile, sysTempFile1, + ourTempFile1, ourTempFile2, oldTempFile, sysTempFile1, jettyFile1, jettyFile2, regularFile, emptyFile, nestedDir)); - + mockedFiles.when(() -> Files.list(eq(customTempDir))) .thenReturn(Stream.of(ourTempFile3, ourTempFile4, sysTempFile2, sysTempFile3)); - + mockedFiles.when(() -> Files.list(eq(libreOfficeTempDir))) .thenReturn(Stream.of(ourTempFile5)); - + mockedFiles.when(() -> Files.list(eq(nestedDir))) .thenReturn(Stream.of(nestedTempFile)); - + // Configure Files.isDirectory for each path mockedFiles.when(() -> Files.isDirectory(eq(nestedDir))).thenReturn(true); mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); - + // Configure Files.exists to return true for all paths 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(); - + // For files with "old" in the name, return a timestamp older than maxAgeMillis if (fileName.contains("old")) { return FileTime.fromMillis(System.currentTimeMillis() - 5000000); @@ -183,13 +183,13 @@ public class TempFileCleanupServiceTest { 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(); - + // Return 0 bytes for the empty file if (fileName.equals("empty.tmp")) { return 0L; @@ -199,7 +199,7 @@ public class TempFileCleanupServiceTest { return 1024L; // 1 KB } }); - + // For deleteIfExists, track which files would be deleted mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) .thenAnswer(invocation -> { @@ -207,28 +207,28 @@ public class TempFileCleanupServiceTest { deletedFiles.add(path); return true; }); - + // Act - set containerMode to false for this test invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000); invokeCleanupDirectoryStreaming(customTempDir, false, 0, 3600000); invokeCleanupDirectoryStreaming(libreOfficeTempDir, false, 0, 3600000); - + // Assert - Only old temp files and empty files should be deleted assertTrue(deletedFiles.contains(oldTempFile), "Old temp file should be deleted"); 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"); - + // 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"); - + // 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"); @@ -242,10 +242,10 @@ public class TempFileCleanupServiceTest { Path ourTempFile = Files.createFile(systemTempDir.resolve("output_123.pdf")); Path sysTempFile = Files.createFile(systemTempDir.resolve("lu123abc.tmp")); Path regularFile = Files.createFile(systemTempDir.resolve("important.txt")); - + // Configure mock registry to say these files aren't registered when(registry.contains(any(File.class))).thenReturn(false); - + // The set of files that will be deleted in our test Set deletedFiles = new HashSet<>(); @@ -254,21 +254,21 @@ public class TempFileCleanupServiceTest { // Mock Files.list for systemTempDir mockedFiles.when(() -> Files.list(eq(systemTempDir))) .thenReturn(Stream.of(ourTempFile, sysTempFile, regularFile)); - + // Configure Files.isDirectory mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); - + // Configure Files.exists 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 - + // Configure Files.size to return normal size 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 -> { @@ -276,10 +276,10 @@ public class TempFileCleanupServiceTest { 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 // regardless of age (when maxAgeMillis is 0) assertTrue(deletedFiles.contains(ourTempFile), "Our temp file should be deleted in container mode"); @@ -293,10 +293,10 @@ public class TempFileCleanupServiceTest { // Arrange - Create an empty file Path emptyFile = Files.createFile(systemTempDir.resolve("empty.tmp")); Path recentEmptyFile = Files.createFile(systemTempDir.resolve("recent_empty.tmp")); - + // Configure mock registry to say these files aren't registered when(registry.contains(any(File.class))).thenReturn(false); - + // The set of files that will be deleted in our test Set deletedFiles = new HashSet<>(); @@ -305,19 +305,19 @@ public class TempFileCleanupServiceTest { // Mock Files.list for systemTempDir mockedFiles.when(() -> Files.list(eq(systemTempDir))) .thenReturn(Stream.of(emptyFile, recentEmptyFile)); - + // Configure Files.isDirectory mockedFiles.when(() -> Files.isDirectory(any(Path.class))).thenReturn(false); - + // Configure Files.exists 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(); - + if (fileName.equals("empty.tmp")) { // More than 5 minutes old return FileTime.fromMillis(System.currentTimeMillis() - 6 * 60 * 1000); @@ -326,11 +326,11 @@ public class TempFileCleanupServiceTest { 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); - + // For deleteIfExists, track which files would be deleted mockedFiles.when(() -> Files.deleteIfExists(any(Path.class))) .thenAnswer(invocation -> { @@ -338,14 +338,14 @@ public class TempFileCleanupServiceTest { 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"); } } @@ -356,14 +356,14 @@ public class TempFileCleanupServiceTest { Path dir1 = Files.createDirectories(systemTempDir.resolve("dir1")); Path dir2 = Files.createDirectories(dir1.resolve("dir2")); Path dir3 = Files.createDirectories(dir2.resolve("dir3")); - + Path tempFile1 = Files.createFile(dir1.resolve("output_1.pdf")); Path tempFile2 = Files.createFile(dir2.resolve("output_2.pdf")); Path tempFile3 = Files.createFile(dir3.resolve("output_old_3.pdf")); - + // Configure mock registry to say these files aren't registered when(registry.contains(any(File.class))).thenReturn(false); - + // The set of files that will be deleted in our test Set deletedFiles = new HashSet<>(); @@ -372,16 +372,16 @@ public class TempFileCleanupServiceTest { // Mock Files.list for each directory 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(dir2))) .thenReturn(Stream.of(tempFile2, dir3)); - + mockedFiles.when(() -> Files.list(eq(dir3))) .thenReturn(Stream.of(tempFile3)); - + // Configure Files.isDirectory for each path mockedFiles.when(() -> Files.isDirectory(eq(dir1))).thenReturn(true); mockedFiles.when(() -> Files.isDirectory(eq(dir2))).thenReturn(true); @@ -389,16 +389,16 @@ public class TempFileCleanupServiceTest { mockedFiles.when(() -> Files.isDirectory(eq(tempFile1))).thenReturn(false); mockedFiles.when(() -> Files.isDirectory(eq(tempFile2))).thenReturn(false); mockedFiles.when(() -> Files.isDirectory(eq(tempFile3))).thenReturn(false); - + // Configure Files.exists to return true for all paths 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(); - + if (fileName.contains("old")) { // Old file return FileTime.fromMillis(System.currentTimeMillis() - 5000000); @@ -407,11 +407,11 @@ public class TempFileCleanupServiceTest { return FileTime.fromMillis(System.currentTimeMillis() - 60000); } }); - + // Configure Files.size to return normal size 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 -> { @@ -419,14 +419,14 @@ public class TempFileCleanupServiceTest { deletedFiles.add(path); return true; }); - + // Act invokeCleanupDirectoryStreaming(systemTempDir, false, 0, 3600000); - + // Debug - print what was deleted System.out.println("Deleted files: " + deletedFiles); System.out.println("Looking for: " + tempFile3); - + // Assert assertFalse(deletedFiles.contains(tempFile1), "Recent temp file should be preserved"); assertFalse(deletedFiles.contains(tempFile2), "Recent temp file should be preserved"); @@ -437,28 +437,28 @@ public class TempFileCleanupServiceTest { /** * Helper method to invoke the private cleanupDirectoryStreaming method using reflection */ - private void invokeCleanupDirectoryStreaming(Path directory, boolean containerMode, int depth, long maxAgeMillis) + private void invokeCleanupDirectoryStreaming(Path directory, boolean containerMode, int depth, long maxAgeMillis) throws IOException { try { // Create a consumer that tracks deleted files AtomicInteger deleteCount = new AtomicInteger(0); Consumer deleteCallback = path -> deleteCount.incrementAndGet(); - + // Get the method with updated signature var method = TempFileCleanupService.class.getDeclaredMethod( - "cleanupDirectoryStreaming", + "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); } catch (Exception e) { throw new RuntimeException("Error invoking cleanupDirectoryStreaming", e); } } - + // Matcher for exact path equality private static Path eq(Path path) { return argThat(arg -> arg != null && arg.equals(path)); } -} \ No newline at end of file +} diff --git a/common/src/test/java/stirling/software/common/util/FileToPdfTest.java b/common/src/test/java/stirling/software/common/util/FileToPdfTest.java index f1df1cf25..96def4fa3 100644 --- a/common/src/test/java/stirling/software/common/util/FileToPdfTest.java +++ b/common/src/test/java/stirling/software/common/util/FileToPdfTest.java @@ -27,7 +27,7 @@ public class FileToPdfTest { String fileName = "test.html"; // Sample file name indicating an HTML file boolean disableSanitize = false; // Flag to control sanitization TempFileManager tempFileManager = mock(TempFileManager.class); // Mock TempFileManager - + // Mock the temp file creation to return real temp files try { when(tempFileManager.createTempFile(anyString())) diff --git a/common/src/test/java/stirling/software/common/util/SpringContextHolderTest.java b/common/src/test/java/stirling/software/common/util/SpringContextHolderTest.java index 9aa1aadf1..fe3fea769 100644 --- a/common/src/test/java/stirling/software/common/util/SpringContextHolderTest.java +++ b/common/src/test/java/stirling/software/common/util/SpringContextHolderTest.java @@ -22,7 +22,7 @@ class SpringContextHolderTest { void testSetApplicationContext() { // Act contextHolder.setApplicationContext(mockApplicationContext); - + // Assert assertTrue(SpringContextHolder.isInitialized()); } @@ -33,10 +33,10 @@ class SpringContextHolderTest { contextHolder.setApplicationContext(mockApplicationContext); TestBean expectedBean = new TestBean(); when(mockApplicationContext.getBean(TestBean.class)).thenReturn(expectedBean); - + // Act TestBean result = SpringContextHolder.getBean(TestBean.class); - + // Assert assertSame(expectedBean, result); verify(mockApplicationContext).getBean(TestBean.class); @@ -46,10 +46,10 @@ class SpringContextHolderTest { @Test void testGetBean_ApplicationContextNotSet() { // Don't set application context - + // Act TestBean result = SpringContextHolder.getBean(TestBean.class); - + // Assert assertNull(result); } @@ -59,10 +59,10 @@ class SpringContextHolderTest { // Arrange contextHolder.setApplicationContext(mockApplicationContext); when(mockApplicationContext.getBean(TestBean.class)).thenThrow(new org.springframework.beans.BeansException("Bean not found") {}); - + // Act TestBean result = SpringContextHolder.getBean(TestBean.class); - + // Assert assertNull(result); } @@ -70,4 +70,4 @@ class SpringContextHolderTest { // Simple test class private static class TestBean { } -} \ No newline at end of file +} diff --git a/AGENTS.md b/devGuide/AGENTS.md similarity index 100% rename from AGENTS.md rename to devGuide/AGENTS.md diff --git a/devGuide/DeveloperGuide.md b/devGuide/DeveloperGuide.md new file mode 100644 index 000000000..c04b66dab --- /dev/null +++ b/devGuide/DeveloperGuide.md @@ -0,0 +1,588 @@ +# Stirling-PDF Developer Guide + +## 1. Introduction + +Stirling-PDF is a robust, locally hosted, web-based PDF manipulation tool. This guide focuses on Docker-based development and testing, which is the recommended approach for working with the full version of Stirling-PDF. + +## 2. Project Overview + +Stirling-PDF is built using: + +- Spring Boot + Thymeleaf +- PDFBox +- LibreOffice +- qpdf +- HTML, CSS, JavaScript +- Docker +- PDF.js +- PDF-LIB.js +- Lombok + +## 3. Development Environment Setup + +### Prerequisites + +- Docker +- Git +- Java JDK 17 or later +- Gradle 7.0 or later (Included within the repo) + +### Setup Steps + +1. Clone the repository: + + ```bash + git clone https://github.com/Stirling-Tools/Stirling-PDF.git + cd Stirling-PDF + ``` + +2. Install Docker and JDK17 if not already installed. + +3. Install a recommended Java IDE such as Eclipse, IntelliJ, or VSCode + 1. Only VSCode + 1. Open VS Code. + 2. When prompted, install the recommended extensions. + 3. Alternatively, open the command palette (`Ctrl + Shift + P` or `Cmd + Shift + P` on macOS) and run: + + ```sh + Extensions: Show Recommended Extensions + ``` + + 4. Install the required extensions from the list. + +4. Lombok Setup +Stirling-PDF uses Lombok to reduce boilerplate code. Some IDEs, like Eclipse, don't support Lombok out of the box. To set up Lombok in your development environment: +Visit the [Lombok website](https://projectlombok.org/setup/) for installation instructions specific to your IDE. + +5. Add environment variable +For local testing, you should generally be testing the full 'Security' version of Stirling PDF. To do this, you must add the environment flag DISABLE_ADDITIONAL_FEATURES=false to your system and/or IDE build/run step. + +## 4. Project Structure + +```bash +Stirling-PDF/ +├── .github/ # GitHub-specific files (workflows, issue templates) +├── configs/ # Configuration files used by stirling at runtime (generated at runtime) +├── cucumber/ # Cucumber test files +│ ├── features/ +├── customFiles/ # Custom static files and templates (generated at runtime used to replace existing files) +├── docs/ # Documentation files +├── exampleYmlFiles/ # Example YAML configuration files +├── images/ # Image assets +├── pipeline/ # Pipeline-related files (generated at runtime) +├── scripts/ # Utility scripts +├── src/ # Source code +│ ├── main/ +│ │ ├── java/ +│ │ │ └── stirling/ +│ │ │ └── software/ +│ │ │ └── SPDF/ +│ │ │ ├── config/ +│ │ │ ├── controller/ +│ │ │ ├── model/ +│ │ │ ├── repository/ +│ │ │ ├── service/ +│ │ │ └── utils/ +│ │ └── resources/ +│ │ ├── static/ +│ │ │ ├── css/ +│ │ │ ├── js/ +│ │ │ └── pdfjs/ +│ │ └── templates/ +│ └── test/ +│ └── java/ +│ └── stirling/ +│ └── software/ +│ └── SPDF/ +├── build.gradle # Gradle build configuration +├── Dockerfile # Main Dockerfile +├── Dockerfile.ultra-lite # Dockerfile for ultra-lite version +├── Dockerfile.fat # Dockerfile for fat version +├── docker-compose.yml # Docker Compose configuration +└── test.sh # Test script to deploy all docker versions and run cuke tests +``` + +## 5. Docker-based Development + +Stirling-PDF offers several Docker versions: + +- Full: All features included +- Ultra-Lite: Basic PDF operations only +- Fat: Includes additional libraries and fonts predownloaded + +### Example Docker Compose Files + +Stirling-PDF provides several example Docker Compose files in the `exampleYmlFiles` directory, such as: + +- `docker-compose-latest.yml`: Latest version without login and security features +- `docker-compose-latest-security.yml`: Latest version with login and security features enabled +- `docker-compose-latest-fat-security.yml`: Fat version with login and security features enabled + +These files provide pre-configured setups for different scenarios. For example, here's a snippet from `docker-compose-latest-security.yml`: + +```yaml +services: + stirling-pdf: + container_name: Stirling-PDF-Security + image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:latest + deploy: + resources: + limits: + memory: 4G + healthcheck: + test: ["CMD-SHELL", "curl -f http://localhost:8080/api/v1/info/status | grep -q 'UP' && curl -fL http://localhost:8080/ | grep -q 'Please sign in'"] + interval: 5s + timeout: 10s + retries: 16 + ports: + - "8080:8080" + volumes: + - ./stirling/latest/data:/usr/share/tessdata:rw + - ./stirling/latest/config:/configs:rw + - ./stirling/latest/logs:/logs:rw + environment: + DISABLE_ADDITIONAL_FEATURES: "false" + SECURITY_ENABLELOGIN: "true" + PUID: 1002 + PGID: 1002 + UMASK: "022" + SYSTEM_DEFAULTLOCALE: en-US + UI_APPNAME: Stirling-PDF + UI_HOMEDESCRIPTION: Demo site for Stirling-PDF Latest with Security + UI_APPNAMENAVBAR: Stirling-PDF Latest + SYSTEM_MAXFILESIZE: "100" + METRICS_ENABLED: "true" + SYSTEM_GOOGLEVISIBILITY: "true" + SHOW_SURVEY: "true" + restart: on-failure:5 +``` + +To use these example files, copy the desired file to your project root and rename it to `docker-compose.yml`, or specify the file explicitly when running Docker Compose: + +```bash +docker-compose -f exampleYmlFiles/docker-compose-latest-security.yml up +``` + +### Building Docker Images + +Stirling-PDF uses different Docker images for various configurations. The build process is controlled by environment variables and uses specific Dockerfile variants. Here's how to build the Docker images: + +1. Set the security environment variable: + + ```bash + export DISABLE_ADDITIONAL_FEATURES=true # or false for to enable login and security features for builds + ``` + +2. Build the project with Gradle: + + ```bash + ./gradlew clean build + ``` + +3. Build the Docker images: + + For the latest version: + + ```bash + docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest -f ./Dockerfile . + ``` + + For the ultra-lite version: + + ```bash + docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-ultra-lite -f ./Dockerfile.ultra-lite . + ``` + + For the fat version (with login and security features enabled): + + ```bash + export DISABLE_ADDITIONAL_FEATURES=false + docker build --no-cache --pull --build-arg VERSION_TAG=alpha -t stirlingtools/stirling-pdf:latest-fat -f ./Dockerfile.fat . + ``` + +Note: The `--no-cache` and `--pull` flags ensure that the build process uses the latest base images and doesn't use cached layers, which is useful for testing and ensuring reproducible builds. however to improve build times these can often be removed depending on your usecase + +## 6. Testing + +### Comprehensive Testing Script + +Stirling-PDF provides a `test.sh` script in the root directory. This script builds all versions of Stirling-PDF, checks that each version works, and runs Cucumber tests. It's recommended to run this script before submitting a final pull request. + +To run the test script: + +```bash +./test.sh +``` + +This script performs the following actions: + +1. Builds all Docker images (full, ultra-lite, fat). +2. Runs each version to ensure it starts correctly. +3. Executes Cucumber tests against the main version and ensures feature compatibility. In the event these tests fail, your PR will not be merged. + +Note: The `test.sh` script will run automatically when you raise a PR. However, it's recommended to run it locally first to save resources and catch any issues early. + +### Full Testing with Docker + +1. Build and run the Docker container per the above instructions: + +2. Access the application at `http://localhost:8080` and manually test all features developed. + +### Local Testing (Java and UI Components) + +For quick iterations and development of Java backend, JavaScript, and UI components, you can run and test Stirling-PDF locally without Docker. This approach allows you to work on and verify changes to: + +- Java backend logic +- RESTful API endpoints +- JavaScript functionality +- User interface components and styling +- Thymeleaf templates + +To run Stirling-PDF locally: + +1. Compile and run the project using built-in IDE methods or by running: + + ```bash + ./gradlew bootRun + ``` + +2. Access the application at `http://localhost:8080` in your web browser. + +3. Manually test the features you're working on through the UI. + +4. For API changes, use tools like Postman or curl to test endpoints directly. + +Important notes: + +- Local testing doesn't include features that depend on external tools like qpdf, LibreOffice, or Python scripts. +- There are currently no automated unit tests. All testing is done manually through the UI or API calls. (You are welcome to add JUnits!) +- Always verify your changes in the full Docker environment before submitting pull requests, as some integrations and features will only work in the complete setup. + +## 7. Contributing + +1. Fork the repository on GitHub. +2. Create a new branch for your feature or bug fix. +3. Make your changes and commit them with clear, descriptive messages and ensure any documentation is updated related to your changes. +4. Test your changes thoroughly in the Docker environment. +5. Run the `test.sh` script to ensure all versions build correctly and pass the Cucumber tests: + + ```bash + ./test.sh + ``` + +6. Push your changes to your fork. +7. Submit a pull request to the main repository. +8. See additional [contributing guidelines](../CONTRIBUTING.md). + +When you raise a PR: + +- The `test.sh` script will run automatically against your PR. +- The PR checks will verify versioning and dependency updates. +- Documentation will be automatically updated for dependency changes. +- Security issues will be checked using Snyk and PixeeBot. + +Address any issues that arise from these checks before finalizing your pull request. + +## 8. API Documentation + +API documentation is available at `/swagger-ui/index.html` when running the application. You can also view the latest API documentation [here](https://app.swaggerhub.com/apis-docs/Stirling-Tools/Stirling-PDF/). + +## 9. Customization + +Stirling-PDF can be customized through environment variables or a `settings.yml` file. Key customization options include: + +- Application name and branding +- Security settings +- UI customization +- Endpoint management + +When using Docker, pass environment variables using the `-e` flag or in your `docker-compose.yml` file. + +Example: + +```bash +docker run -p 8080:8080 -e APP_NAME="My PDF Tool" stirling-pdf:full +``` + +Refer to the main README for a full list of customization options. + +## 10. Language Translations + +For managing language translations that affect multiple files, Stirling-PDF provides a helper script: + +```bash +/scripts/replace_translation_line.sh +``` + +This script helps you make consistent replacements across language files. + +When contributing translations: + +1. Use the helper script for multi-file changes. +2. Ensure all language files are updated consistently. +3. The PR checks will verify consistency in language file updates. + +Remember to test your changes thoroughly to ensure they don't break any existing functionality. + +## Code examples + +### Overview of Thymeleaf + +Thymeleaf is a server-side Java HTML template engine. It is used in Stirling-PDF to render dynamic web pages. Thymeleaf integrates heavily with Spring Boot. + +### Thymeleaf overview + +In Stirling-PDF, Thymeleaf is used to create HTML templates that are rendered on the server side. These templates are located in the `stirling-pdf/src/main/resources/templates` directory. Thymeleaf templates use a combination of HTML and special Thymeleaf attributes to dynamically generate content. + +Some examples of this are: + +```html + +``` +or +```html + +``` + +Where it uses the `th:block`, `th:` indicating it's a special Thymeleaf element to be used server-side in generating the HTML, and block being the actual element type. +In this case, we are inserting the `navbar` entry within the `fragments/navbar.html` fragment into the `th:block` element. + +They can be more complex, such as: + +```html + +``` + +Which is the same as above but passes the parameters title and header into the fragment `common.html` to be used in its HTML generation. + +Thymeleaf can also be used to loop through objects or pass things from the Java side into the HTML side. + +```java + @GetMapping + public String newFeaturePage(Model model) { + model.addAttribute("exampleData", exampleData); + return "new-feature"; + } +``` + +In the above example, if exampleData is a list of plain java objects of class Person and within it, you had id, name, age, etc. You can reference it like so + +```html + + + + + + + + + +``` + +This would generate n entries of tr for each person in exampleData + +### Adding a New Feature to the Backend (API) + +1. **Create a New Controller:** + - Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/api` directory. + - Annotate the class with `@RestController` and `@RequestMapping` to define the API endpoint. + - Ensure to add API documentation annotations like `@Tag(name = "General", description = "General APIs")` and `@Operation(summary = "Crops a PDF document", description = "This operation takes an input PDF file and crops it according to the given coordinates. Input:PDF Output:PDF Type:SISO")`. + + ```java + package stirling.software.SPDF.controller.api; + + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RestController; + import io.swagger.v3.oas.annotations.Operation; + import io.swagger.v3.oas.annotations.tags.Tag; + + @RestController + @RequestMapping("/api/v1/new-feature") + @Tag(name = "General", description = "General APIs") + public class NewFeatureController { + + @GetMapping + @Operation(summary = "New Feature", description = "This is a new feature endpoint.") + public String newFeature() { + return "NewFeatureResponse"; // This refers to the NewFeatureResponse.html template presenting the user with the generated html from that file when they navigate to /api/v1/new-feature + } + } + ``` + +2. **Define the Service Layer:** (Not required but often useful) + - Create a new service class in the `stirling-pdf/src/main/java/stirling/software/SPDF/service` directory. + - Implement the business logic for the new feature. + + ```java + package stirling.software.SPDF.service; + + import org.springframework.stereotype.Service; + + @Service + public class NewFeatureService { + + public String getNewFeatureData() { + // Implement business logic here + return "New Feature Data"; + } + } + ``` + +2b. **Integrate the Service with the Controller:** + +- Autowire the service class in the controller and use it to handle the API request. + + ```java + package stirling.software.SPDF.controller.api; + + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.RequestMapping; + import org.springframework.web.bind.annotation.RestController; + import stirling.software.SPDF.service.NewFeatureService; + import io.swagger.v3.oas.annotations.Operation; + import io.swagger.v3.oas.annotations.tags.Tag; + + @RestController + @RequestMapping("/api/v1/new-feature") + @Tag(name = "General", description = "General APIs") + public class NewFeatureController { + + @Autowired + private NewFeatureService newFeatureService; + + @GetMapping + @Operation(summary = "New Feature", description = "This is a new feature endpoint.") + public String newFeature() { + return newFeatureService.getNewFeatureData(); + } + } + ``` + +### Adding a New Feature to the Frontend (UI) + +1. **Create a New Thymeleaf Template:** + - Create a new HTML file in the `stirling-pdf/src/main/resources/templates` directory. + - Use Thymeleaf attributes to dynamically generate content. + - Use `extract-page.html` as a base example for the HTML template, which is useful to ensure importing of the general layout, navbar, and footer. + + ```html + + + + + + + +
+
+ +

+
+
+
+
+ upload + +
+
+
+ +
+ + +
+ + +
+
+
+
+
+ +
+ + + ``` + +2. **Create a New Controller for the UI:** + - Create a new Java class in the `stirling-pdf/src/main/java/stirling/software/SPDF/controller/ui` directory. + - Annotate the class with `@Controller` and `@RequestMapping` to define the UI endpoint. + + ```java + package stirling.software.SPDF.controller.ui; + + import org.springframework.beans.factory.annotation.Autowired; + import org.springframework.stereotype.Controller; + import org.springframework.ui.Model; + import org.springframework.web.bind.annotation.GetMapping; + import org.springframework.web.bind.annotation.RequestMapping; + import stirling.software.SPDF.service.NewFeatureService; + + @Controller + @RequestMapping("/new-feature") + public class NewFeatureUIController { + + @Autowired + private NewFeatureService newFeatureService; + + @GetMapping + public String newFeaturePage(Model model) { + model.addAttribute("newFeatureData", newFeatureService.getNewFeatureData()); + return "new-feature"; + } + } + ``` + +3. **Update the Navigation Bar:** + - Add a link to the new feature page in the navigation bar. + - Update the `stirling-pdf/src/main/resources/templates/fragments/navbar.html` file. + + ```html + + ``` + +## Adding New Translations to Existing Language Files in Stirling-PDF + +When adding a new feature or modifying existing ones in Stirling-PDF, you'll need to add new translation entries to the existing language files. Here's a step-by-step guide: + +### 1. Locate Existing Language Files + +Find the existing `messages.properties` files in the `stirling-pdf/src/main/resources` directory. You'll see files like: + +- `messages.properties` (default, usually English) +- `messages_en_GB.properties` +- `messages_fr_FR.properties` +- `messages_de_DE.properties` +- etc. + +### 2. Add New Translation Entries + +Open each of these files and add your new translation entries. For example, if you're adding a new feature called "PDF Splitter", +Use descriptive, hierarchical keys (e.g., `feature.element.description`) +you might add: + +```properties +pdfSplitter.title=PDF Splitter +pdfSplitter.description=Split your PDF into multiple documents +pdfSplitter.button.split=Split PDF +pdfSplitter.input.pages=Enter page numbers to split +``` + +Add these entries to the default GB language file and any others you wish, translating the values as appropriate for each language. + +### 3. Use Translations in Thymeleaf Templates + +In your Thymeleaf templates, use the `#{key}` syntax to reference the new translations: + +```html +

PDF Splitter

+

Split your PDF into multiple documents

+ + +``` + +Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization. diff --git a/devGuide/EXCEPTION_HANDLING_GUIDE.md b/devGuide/EXCEPTION_HANDLING_GUIDE.md new file mode 100644 index 000000000..666b70e2b --- /dev/null +++ b/devGuide/EXCEPTION_HANDLING_GUIDE.md @@ -0,0 +1,66 @@ +# Exception Handling Guide + +This guide outlines the common error handling patterns used within Stirling-PDF and provides tips for internationalising error messages. The examples cover the main languages found in the project: Java, JavaScript, HTML/CSS, and a small amount of Python. + +## General Principles + +- **Fail fast and log clearly.** Exceptions should provide enough information for debugging without exposing sensitive data. +- **Use consistent user messages.** Text shown to users must be pulled from the localisation files so that translations are centrally managed. +- **Avoid silent failures.** Always log unexpected errors and provide the user with a helpful message. + +## Java + +Java forms the core of Stirling-PDF. When adding new features or handling errors: + +1. **Create custom exceptions** to represent specific failure cases. This keeps the code self-documenting and easier to handle at higher levels. +2. **Use `try-with-resources`** when working with streams or other closable resources to ensure clean-up even on failure. +3. **Return meaningful HTTP status codes** in controllers by throwing `ResponseStatusException` or using `@ExceptionHandler` methods. +4. **Log with context** using the project’s logging framework. Include identifiers or IDs that help trace the issue. +5. **Internationalise messages** by placing user-facing text in `messages_en_GB.properties` and referencing them with message keys. + +## JavaScript + +On the client side, JavaScript handles form validation and user interactions. + +- Use `try`/`catch` around asynchronous operations (e.g., `fetch`) and display a translated error notice if the call fails. +- Validate input before sending it to the server and provide inline feedback with messages from the translation files. +- Log unexpected errors to the browser console for easier debugging, but avoid revealing sensitive information. + +## HTML & CSS + +HTML templates should reserve a space for displaying error messages. Example pattern: + +```html + +``` + +Use CSS classes (e.g., `.error`) to style the message so it is clearly visible and accessible. Keep the markup simple to ensure screen readers can announce the error correctly. + +## Python + +Python scripts in this project are mainly for utility tasks. Follow these guidelines: + +- Wrap file operations or external calls in `try`/`except` blocks. +- Print or log errors in a consistent format. If the script outputs messages to end users, ensure they are translatable. + +Example: + +```python +try: + perform_task() +except Exception as err: + logger.error("Task failed: %s", err) + print(gettext("task.error")) +``` + +## Internationalisation (i18n) + +All user-visible error strings should be defined in the main translation file (`messages_en_GB.properties`). Other language files will use the same keys. Refer to messages in code rather than hard-coding text. + +When creating new messages: + +1. Add the English phrase to `messages_en_GB.properties`. +2. Reference the message key in your Java, JavaScript, or Python code. +3. Update other localisation files as needed. + +Following these patterns helps keep Stirling-PDF stable, easier to debug, and friendly to users in all supported languages. diff --git a/HowToAddNewLanguage.md b/devGuide/HowToAddNewLanguage.md similarity index 100% rename from HowToAddNewLanguage.md rename to devGuide/HowToAddNewLanguage.md diff --git a/devGuide/README.md b/devGuide/README.md new file mode 100644 index 000000000..f58f6e5d1 --- /dev/null +++ b/devGuide/README.md @@ -0,0 +1,29 @@ +# Developer Guide Directory + +This directory contains all development-related documentation for Stirling PDF. + +## 📚 Documentation Index + +### Core Development +- **[DeveloperGuide.md](./DeveloperGuide.md)** - Main developer setup and architecture guide +- **[EXCEPTION_HANDLING_GUIDE.md](./EXCEPTION_HANDLING_GUIDE.md)** - Exception handling patterns and i18n best practices +- **[HowToAddNewLanguage.md](./HowToAddNewLanguage.md)** - Internationalization and translation guide + +### Features & Documentation +- **[AGENTS.md](./AGENTS.md)** - Agent-based functionality documentation +- **[USERS.md](./USERS.md)** - User-focused documentation and guides + +## 🔗 Related Files in Root +- **[README.md](../README.md)** - Project overview and quick start +- **[CONTRIBUTING.md](../CONTRIBUTING.md)** - Contribution guidelines +- **[SECURITY.md](../SECURITY.md)** - Security policies and reporting +- **[DATABASE.md](../DATABASE.md)** - Database setup and configuration (usage guide) +- **[HowToUseOCR.md](../HowToUseOCR.md)** - OCR setup and configuration (usage guide) + +## 📝 Contributing to Documentation + +When adding new development documentation: +1. Place technical guides in this `devGuide/` directory +2. Update this index file with a brief description +3. Keep user-facing docs (README, CONTRIBUTING, SECURITY) in the root +4. Follow existing naming conventions (PascalCase for guides) diff --git a/USERS.md b/devGuide/USERS.md similarity index 100% rename from USERS.md rename to devGuide/USERS.md diff --git a/exampleYmlFiles/docker-compose-latest-ultra-lite.yml b/exampleYmlFiles/docker-compose-latest-ultra-lite.yml index 14e78b2ff..a3710ad82 100644 --- a/exampleYmlFiles/docker-compose-latest-ultra-lite.yml +++ b/exampleYmlFiles/docker-compose-latest-ultra-lite.yml @@ -17,7 +17,6 @@ services: - ./stirling/latest/config:/configs:rw - ./stirling/latest/logs:/logs:rw environment: - DISABLE_ADDITIONAL_FEATURES: "true" SECURITY_ENABLELOGIN: "false" SYSTEM_DEFAULTLOCALE: en-US UI_APPNAME: Stirling-PDF-Ultra-lite diff --git a/exampleYmlFiles/docker-compose-latest.yml b/exampleYmlFiles/docker-compose-latest.yml index 2db962e32..a68da538a 100644 --- a/exampleYmlFiles/docker-compose-latest.yml +++ b/exampleYmlFiles/docker-compose-latest.yml @@ -18,7 +18,6 @@ services: - ./stirling/latest/config:/configs:rw - ./stirling/latest/logs:/logs:rw environment: - DISABLE_ADDITIONAL_FEATURES: "true" SECURITY_ENABLELOGIN: "false" LANGS: "en_GB,en_US,ar_AR,de_DE,fr_FR,es_ES,zh_CN,zh_TW,ca_CA,it_IT,sv_SE,pl_PL,ro_RO,ko_KR,pt_BR,ru_RU,el_GR,hi_IN,hu_HU,tr_TR,id_ID" SYSTEM_DEFAULTLOCALE: en-US diff --git a/proprietary/build.gradle b/proprietary/build.gradle index 3e37b398e..1912eefcb 100644 --- a/proprietary/build.gradle +++ b/proprietary/build.gradle @@ -29,7 +29,7 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-data-jpa' api 'org.springframework.boot:spring-boot-starter-oauth2-client' api 'org.springframework.boot:spring-boot-starter-mail' - api 'io.swagger.core.v3:swagger-core-jakarta:2.2.33' + api 'io.swagger.core.v3:swagger-core-jakarta:2.2.34' implementation 'com.bucket4j:bucket4j_jdk17-core:8.14.0' // https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17 diff --git a/proprietary/src/main/resources/templates/accounts/team-details.html b/proprietary/src/main/resources/templates/accounts/team-details.html index 3fb779bae..bc7d2e533 100644 --- a/proprietary/src/main/resources/templates/accounts/team-details.html +++ b/proprietary/src/main/resources/templates/accounts/team-details.html @@ -168,7 +168,7 @@
- +
@@ -214,7 +214,7 @@
@@ -222,7 +222,7 @@
@@ -279,7 +279,7 @@
@@ -287,7 +287,7 @@
diff --git a/stirling-pdf/src/main/resources/templates/fragments/common.html b/stirling-pdf/src/main/resources/templates/fragments/common.html index 38e71f04f..d873fd7a1 100644 --- a/stirling-pdf/src/main/resources/templates/fragments/common.html +++ b/stirling-pdf/src/main/resources/templates/fragments/common.html @@ -23,6 +23,102 @@ @@ -250,6 +346,8 @@ window.stirlingPDF.uploadLimit = /*[[${@uploadLimitService.getUploadLimit()}]]*/ 0; window.stirlingPDF.uploadLimitExceededSingular = /*[[#{uploadLimitExceededSingular}]]*/ 'is too large. Maximum allowed size is'; window.stirlingPDF.uploadLimitExceededPlural = /*[[#{uploadLimitExceededPlural}]]*/ 'are too large. Maximum allowed size is'; + window.stirlingPDF.pdfCorruptedMessage = /*[[#{error.pdfInvalid}]]*/ 'The PDF file "{0}" appears to be corrupted or has an invalid structure. Please try using the \'Repair PDF\' feature to fix the file before proceeding.'; + window.stirlingPDF.tryRepairMessage = /*[[#{error.tryRepair}]]*/ 'Try using the Repair PDF feature to fix corrupted files.'; })(); @@ -287,10 +385,10 @@ Browse
-
-
-
-
+
+ + +

@@ -325,4 +423,4 @@ window.stirlingPDF.GoogleDriveAppId = /*[[${@GoogleDriveConfig.getAppId()}]]*/ null;
- \ No newline at end of file + diff --git a/stirling-pdf/src/main/resources/templates/fragments/footer.html b/stirling-pdf/src/main/resources/templates/fragments/footer.html index 324f550d9..2d8465bf5 100644 --- a/stirling-pdf/src/main/resources/templates/fragments/footer.html +++ b/stirling-pdf/src/main/resources/templates/fragments/footer.html @@ -1,7 +1,7 @@ -