mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
72
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8e46c451 | ||
|
|
d57c3ddeb7 | ||
|
|
031477b7b5 | ||
|
|
8725ba66bb | ||
|
|
115a24b16d | ||
|
|
c775fed17d | ||
|
|
ae9d29abf0 | ||
|
|
ddf93d2b1a | ||
|
|
e97f93924e | ||
|
|
8d5b3eb36b | ||
|
|
9f26dc4112 | ||
|
|
757a666f5e | ||
|
|
558c75a2b1 | ||
|
|
da2eb54fe8 | ||
|
|
a23c252af5 | ||
|
|
772dd4632e | ||
|
|
d5cf77cf50 | ||
|
|
f25b308e46 | ||
|
|
d3e13967e9 | ||
|
|
0e94ea156f | ||
|
|
46049a0a4a | ||
|
|
3d3c5f79a5 | ||
|
|
330a987faf | ||
|
|
5806dfecf6 | ||
|
|
b653e09c16 | ||
|
|
61f3000cea | ||
|
|
e310493966 | ||
|
|
0a1d2effdc | ||
|
|
b8ce4e47c1 | ||
|
|
946196de43 | ||
|
|
27bd34c29b | ||
|
|
5a1ed50e2b | ||
|
|
e01734fb7d | ||
|
|
7c3c7937b3 | ||
|
|
b1d44d5661 | ||
|
|
71c845bcd8 | ||
|
|
c62277a8e5 | ||
|
|
f3a4dbc903 | ||
|
|
4b14ddfb37 | ||
|
|
597cc460aa | ||
|
|
f88f1db7e7 | ||
|
|
e523190f39 | ||
|
|
f9d2f36ab7 | ||
|
|
5df466266a | ||
|
|
cc1931fa75 | ||
|
|
00a9174939 | ||
|
|
d135e25d02 | ||
|
|
ba72a2a623 | ||
|
|
94e517df3c | ||
|
|
c8bc57d31c | ||
|
|
e5150df8a5 | ||
|
|
a9faa2aa22 | ||
|
|
fb37ee8038 | ||
|
|
8555fe3fb5 | ||
|
|
3195f3f2ba | ||
|
|
47e0c9cb51 | ||
|
|
a23f0ec53d | ||
|
|
bf57b1f33b | ||
|
|
dc6daaad0d | ||
|
|
62d2819213 | ||
|
|
6f217066a1 | ||
|
|
691fb80554 | ||
|
|
d4fda73e01 | ||
|
|
0f5a0e694a | ||
|
|
00136f9e20 | ||
|
|
a844c7d09e | ||
|
|
0128cad683 | ||
|
|
fc5acc9d92 | ||
|
|
d89b96088b | ||
|
|
9cec2d14cb | ||
|
|
ffd1abbdb3 | ||
|
|
79bc62a5d2 |
@@ -259,10 +259,27 @@ def check_for_differences(reference_file, file_list, branch, actor):
|
||||
report.append(
|
||||
f" - **_Extra keys in `{locale_dir}/{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
report.append("")
|
||||
report.append(" Use the following command to remove them:")
|
||||
report.append(
|
||||
f" `python scripts/translations/translation_merger.py {locale_dir} remove-unused`"
|
||||
)
|
||||
report.append("")
|
||||
if extra_keys_list:
|
||||
report.append(
|
||||
f" - **_Missing keys in `{locale_dir}/{basename_current_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_reference_file}`_**."
|
||||
)
|
||||
report.append("")
|
||||
report.append(" Use the following command to add them:")
|
||||
report.append(
|
||||
f" `python scripts/translations/translation_merger.py {locale_dir} add-missing`"
|
||||
)
|
||||
report.append("")
|
||||
|
||||
if missing_keys_list or extra_keys_list:
|
||||
report.append(
|
||||
" See: https://github.com/Stirling-Tools/Stirling-PDF/tree/main/scripts/translations#2-translation_mergerpy"
|
||||
)
|
||||
else:
|
||||
report.append("2. **Test Status:** ✅ **_Passed_**")
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ github.repository }}
|
||||
ref: main
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
return newComment.id;
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ needs.check-pr.outputs.pr_repository }}
|
||||
ref: ${{ needs.check-pr.outputs.pr_ref }}
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
@@ -362,7 +362,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
@@ -145,19 +145,19 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: refs/pull/${{ needs.check-comment.outputs.pr_number }}/merge
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -179,7 +179,7 @@ jobs:
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
@@ -375,7 +375,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
- name: AI PR Title Analysis
|
||||
if: steps.actor.outputs.is_repo_dev == 'true'
|
||||
id: ai-title-analysis
|
||||
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
|
||||
uses: actions/ai-inference@a6101c89c6feaecc585efdd8d461f18bb7896f20 # v2.0.5
|
||||
with:
|
||||
model: openai/gpt-4o
|
||||
system-prompt-file: ".github/config/system-prompt.txt"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
|
||||
+23
-23
@@ -36,7 +36,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
@@ -60,16 +60,16 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK ${{ matrix.jdk-version }}
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: ${{ matrix.jdk-version }}
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
|
||||
- name: Upload Test Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
|
||||
path: |
|
||||
@@ -135,16 +135,16 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
DISABLE_ADDITIONAL_FEATURES: true
|
||||
|
||||
- name: Upload OpenAPI Documentation
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: openapi-docs
|
||||
path: ./SwaggerDoc.json
|
||||
@@ -172,9 +172,9 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -190,7 +190,7 @@ jobs:
|
||||
- name: Run frontend tests
|
||||
run: cd frontend && npm run test -- --run
|
||||
- name: Upload frontend build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: frontend-build
|
||||
path: frontend/dist/
|
||||
@@ -207,16 +207,16 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
|
||||
- name: FAILED - check the licenses for compatibility
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -261,16 +261,16 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -325,7 +325,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Free disk space on runner
|
||||
run: |
|
||||
@@ -335,13 +335,13 @@ jobs:
|
||||
echo "Disk space after cleanup:" && df -h
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -376,7 +376,7 @@ jobs:
|
||||
|
||||
- name: Upload Reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: reports-docker-${{ matrix.artifact-suffix }}
|
||||
path: |
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout main branch first
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@3c4e3dcb1aa7874d2c16be7d79418e9b7efd6261 # v4.8.2
|
||||
with:
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout PR head (default)
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- name: Checkout BASE branch (safe script)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
path: base
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "npm"
|
||||
@@ -273,7 +273,7 @@ jobs:
|
||||
- name: Create Pull Request (Push only)
|
||||
id: cpr
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Frontend 3rd Party Licenses"
|
||||
@@ -317,7 +317,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@@ -331,13 +331,13 @@ jobs:
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -364,7 +364,7 @@ jobs:
|
||||
|
||||
- name: Upload artifact on license issues
|
||||
if: env.LICENSE_WARNINGS_EXIST == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: backend-dependencies-without-allowed-license.json
|
||||
path: build/reports/dependency-license/dependencies-without-allowed-license.json
|
||||
@@ -490,7 +490,7 @@ jobs:
|
||||
- name: Create Pull Request (push only)
|
||||
if: github.event_name == 'push' && env.CHANGES_DETECTED == 'true'
|
||||
id: cpr
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: "Update Backend 3rd Party Licenses"
|
||||
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Check out the repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run Labeler
|
||||
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
|
||||
|
||||
@@ -42,16 +42,16 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -115,16 +115,16 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -154,7 +154,7 @@ jobs:
|
||||
cp app/core/build/libs/stirling-pdf-${{ needs.determine-matrix.outputs.version }}.jar ./jar-dist/Stirling-PDF${{ matrix.variant.file_suffix }}.jar
|
||||
|
||||
- name: Upload JAR artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: jar${{ matrix.variant.file_suffix }}
|
||||
path: ./jar-dist/*.jar
|
||||
@@ -176,7 +176,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -198,13 +198,13 @@ jobs:
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -291,7 +291,7 @@ jobs:
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && (github.event_name == 'release' || github.ref == 'refs/heads/V2-master') }}
|
||||
uses: digicert/ssm-code-signing@9476ceec3ea1c63298d4403b983e1ccf2556ff4c # v1.1.0
|
||||
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -533,7 +533,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
|
||||
@@ -26,9 +26,10 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -48,17 +49,24 @@ jobs:
|
||||
run: |
|
||||
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_pre_commit.txt
|
||||
|
||||
- run: pre-commit run --all-files -c .pre-commit-config.yaml
|
||||
- name: Run Pre-Commit
|
||||
run: |
|
||||
pre-commit run ruff --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run ruff-format --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run codespell --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run gitleaks --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run end-of-file-fixer --all-files -c .pre-commit-config.yaml
|
||||
pre-commit run trailing-whitespace --all-files -c .pre-commit-config.yaml
|
||||
continue-on-error: true
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: 21
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -76,7 +84,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.CHANGES_DETECTED == 'true'
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: ":file_folder: pre-commit"
|
||||
|
||||
@@ -37,16 +37,16 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -75,13 +75,13 @@ jobs:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: "Checkout code"
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: "Upload artifact"
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: SARIF file
|
||||
path: results.sarif
|
||||
|
||||
@@ -31,16 +31,16 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup GitHub App Bot
|
||||
id: setup-bot
|
||||
@@ -55,12 +57,16 @@ jobs:
|
||||
cache: "pip" # caching pip dependencies
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
|
||||
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt -r ./.github/scripts/requirements_pre_commit.txt
|
||||
|
||||
- name: Sync translation TOML files
|
||||
run: |
|
||||
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
|
||||
|
||||
- name: pre-commit run
|
||||
run: |
|
||||
pre-commit run toml-sort-fix --all-files
|
||||
|
||||
- name: Commit translation files
|
||||
run: |
|
||||
git add frontend/public/locales/*/translation.toml
|
||||
@@ -77,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: always()
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ steps.setup-bot.outputs.token }}
|
||||
commit-message: Update files
|
||||
|
||||
@@ -25,6 +25,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
determine-matrix:
|
||||
@@ -77,7 +78,7 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install dependencies (ubuntu only)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
@@ -99,13 +100,13 @@ jobs:
|
||||
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -193,7 +194,7 @@ jobs:
|
||||
- name: Setup DigiCert KeyLocker
|
||||
id: digicert-setup
|
||||
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' && github.ref == 'refs/heads/main' }}
|
||||
uses: digicert/ssm-code-signing@9476ceec3ea1c63298d4403b983e1ccf2556ff4c # v1.1.0
|
||||
uses: digicert/ssm-code-signing@af7f4b07694120e514fe5b19f0598af3c20256e3 # v1.2.0
|
||||
env:
|
||||
SM_API_KEY: ${{ secrets.SM_API_KEY }}
|
||||
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
|
||||
@@ -586,7 +587,7 @@ jobs:
|
||||
}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||
with:
|
||||
name: Stirling-PDF-${{ matrix.name }}
|
||||
path: ./dist/*
|
||||
@@ -639,6 +640,100 @@ jobs:
|
||||
fi
|
||||
done
|
||||
|
||||
pr-comment:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && needs.build.result == 'success'
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Harden the runner
|
||||
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Post/Update PR Comment with Download Links
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = context.issue.number;
|
||||
const runId = context.runId;
|
||||
|
||||
// Fetch artifacts for this workflow run
|
||||
const { data: artifactsList } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner,
|
||||
repo,
|
||||
run_id: runId
|
||||
});
|
||||
|
||||
// Map of expected artifact names to display info
|
||||
const artifactMap = {
|
||||
'Stirling-PDF-windows-x86_64': { icon: '🪟', platform: 'Windows x64', files: '.exe, .msi' },
|
||||
'Stirling-PDF-macos-aarch64': { icon: '🍎', platform: 'macOS ARM64', files: '.dmg' },
|
||||
'Stirling-PDF-macos-x86_64': { icon: '🍎', platform: 'macOS Intel', files: '.dmg' },
|
||||
'Stirling-PDF-linux-x86_64': { icon: '🐧', platform: 'Linux x64', files: '.deb, .AppImage' }
|
||||
};
|
||||
|
||||
let commentBody = `## 📦 Tauri Desktop Builds Ready!\n\n`;
|
||||
commentBody += `The desktop applications have been built and are ready for testing.\n\n`;
|
||||
commentBody += `### Download Artifacts:\n\n`;
|
||||
|
||||
// Add links for each found artifact
|
||||
let foundArtifacts = 0;
|
||||
for (const artifact of artifactsList.artifacts) {
|
||||
const info = artifactMap[artifact.name];
|
||||
if (info) {
|
||||
foundArtifacts++;
|
||||
// GitHub doesn't provide direct download URLs via API, but we can link to the artifact on the Actions page
|
||||
const artifactUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}/artifacts/${artifact.id}`;
|
||||
commentBody += `${info.icon} **${info.platform}**: [Download ${artifact.name}](${artifactUrl}) `;
|
||||
commentBody += `(${info.files}) - ${(artifact.size_in_bytes / 1024 / 1024).toFixed(1)} MB\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundArtifacts === 0) {
|
||||
commentBody += `⚠️ **Warning**: No artifacts found in workflow run.\n`;
|
||||
commentBody += `[View workflow run](https://github.com/${owner}/${repo}/actions/runs/${runId})\n`;
|
||||
}
|
||||
|
||||
commentBody += `\n---\n`;
|
||||
commentBody += `_Built from commit ${context.sha.substring(0, 7)}_\n`;
|
||||
commentBody += `_Artifacts expire in 7 days_`;
|
||||
|
||||
// Find existing comment
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber
|
||||
});
|
||||
|
||||
const botComment = comments.find(comment =>
|
||||
comment.user.type === 'Bot' &&
|
||||
comment.body.includes('📦 Tauri Desktop Builds Ready!')
|
||||
);
|
||||
|
||||
if (botComment) {
|
||||
// Update existing comment
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: botComment.id,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Updated existing comment');
|
||||
} else {
|
||||
// Create new comment
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body: commentBody
|
||||
});
|
||||
console.log('Created new comment');
|
||||
}
|
||||
|
||||
report:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -30,16 +30,16 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
|
||||
with:
|
||||
java-version: "21"
|
||||
distribution: "temurin"
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
|
||||
uses: gradle/actions/setup-gradle@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1
|
||||
with:
|
||||
gradle-version: 8.14
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check for file changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
@@ -152,10 +152,10 @@ jobs:
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
|
||||
with:
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
+1
-1
@@ -149,6 +149,7 @@ app/proprietary/build
|
||||
common/build
|
||||
proprietary/build
|
||||
stirling-pdf/build
|
||||
frontend/src-tauri/provisioner/target
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
@@ -243,4 +244,3 @@ docs/type3/signatures/
|
||||
|
||||
# Type3 sample PDFs (development only)
|
||||
**/type3/samples/
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.8
|
||||
rev: v0.14.14
|
||||
hooks:
|
||||
- id: ruff
|
||||
args:
|
||||
@@ -16,11 +16,11 @@ repos:
|
||||
hooks:
|
||||
- id: codespell
|
||||
args:
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls
|
||||
- --ignore-words-list=thirdParty,tabEl,tabEls,Sie,ist
|
||||
- --skip="./.*,*.csv,*.json,*.ambr"
|
||||
- --quiet-level=2
|
||||
files: \.(html|css|js|py|md)$
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
exclude: (.vscode|.devcontainer|app/core/src/main/resources|app/proprietary/src/main/resources|frontend/public/vendor|Dockerfile|.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js)
|
||||
- repo: https://github.com/gitleaks/gitleaks
|
||||
rev: v8.30.0
|
||||
hooks:
|
||||
@@ -34,6 +34,12 @@ repos:
|
||||
- id: trailing-whitespace
|
||||
files: ^.*(\.js|\.java|\.py|\.yml)$
|
||||
exclude: ^(.*/pdfjs.*|.*/thirdParty.*|bootstrap.*|.*\.min\..*|.*diff\.js|\.github/workflows/.*$)
|
||||
- repo: https://github.com/pappasam/toml-sort
|
||||
rev: v0.24.3
|
||||
hooks:
|
||||
- id: toml-sort-fix
|
||||
files: frontend/public/locales/.*\.toml$
|
||||
args: ['--in-place', '--all', '--ignore-case']
|
||||
# - repo: https://github.com/thibaudcolas/pre-commit-stylelint
|
||||
# rev: v16.21.1
|
||||
# hooks:
|
||||
|
||||
Vendored
+1
-1
@@ -13,7 +13,7 @@
|
||||
"vscjava.vscode-spring-boot-dashboard", // Spring Boot dashboard for managing and visualizing Spring Boot applications
|
||||
"EditorConfig.EditorConfig", // EditorConfig support for maintaining consistent coding styles
|
||||
"ms-azuretools.vscode-docker", // Docker extension for Visual Studio Code
|
||||
"GitHub.copilot", // GitHub Copilot AI pair programmer for Visual Studio Code
|
||||
"GitHub.copilot-chat", // GitHub Copilot AI pair programmer for Visual Studio Code
|
||||
"GitHub.vscode-pull-request-github", // GitHub Pull Requests extension for Visual Studio Code
|
||||
"charliermarsh.ruff", // Ruff code formatter for Python to follow the Ruff Style Guide
|
||||
"yzhang.markdown-all-in-one", // Markdown All-in-One extension for enhanced Markdown editing
|
||||
|
||||
@@ -30,6 +30,91 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { FileContext } from "@app/contexts/FileContext";
|
||||
|
||||
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
|
||||
import { AppLayout } from "@core/components/AppLayout";
|
||||
import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
```
|
||||
|
||||
**Only use explicit aliases when:**
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
**How it works:**
|
||||
1. Core defines stub component (returns null or no-op)
|
||||
2. Desktop/proprietary overrides with same path/name
|
||||
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
|
||||
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
|
||||
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Build resolution:**
|
||||
- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
|
||||
- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
|
||||
|
||||
**Benefits:**
|
||||
- No runtime checks or feature flags
|
||||
- Type-safe across all builds
|
||||
- Clean, readable code
|
||||
- Build-time optimization (dead code elimination)
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
@@ -37,7 +122,7 @@ Frontend designed for **stateful document processing**:
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `src/contexts/FileContext.tsx`
|
||||
**Location**: `frontend/src/core/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
@@ -62,7 +147,7 @@ Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
@@ -147,8 +232,34 @@ return useToolOperation({
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Frontend Directory Structure
|
||||
The frontend is organized with a clear separation of concerns:
|
||||
|
||||
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
|
||||
- **`core/components/`**: React components organized by feature
|
||||
- `core/components/tools/`: Individual PDF tool implementations
|
||||
- `core/components/viewer/`: PDF viewer components
|
||||
- `core/components/pageEditor/`: Page manipulation UI
|
||||
- `core/components/tooltips/`: Help tooltips for tools
|
||||
- `core/components/shared/`: Reusable UI components
|
||||
- **`core/contexts/`**: React Context providers
|
||||
- `FileContext.tsx`: Central file state management
|
||||
- `file/`: File reducer and selectors
|
||||
- `toolWorkflow/`: Tool workflow state
|
||||
- **`core/hooks/`**: Custom React hooks
|
||||
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
|
||||
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
|
||||
- **`core/constants/`**: Application constants and configuration
|
||||
- **`core/data/`**: Static data (tool taxonomy, etc.)
|
||||
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
|
||||
|
||||
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/src/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/public/`**: Static assets served directly
|
||||
- `public/locales/`: Translation JSON files
|
||||
|
||||
### Component Architecture
|
||||
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
@@ -203,6 +314,7 @@ return useToolOperation({
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
|
||||
@@ -391,13 +391,24 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Advance", "extract-image-scans");
|
||||
addEndpointToGroup("Advance", "repair");
|
||||
addEndpointToGroup("Advance", "auto-rename");
|
||||
addEndpointToGroup("Advance", "handleData");
|
||||
addEndpointToGroup("Advance", "scanner-effect");
|
||||
addEndpointToGroup("Advance", "show-javascript");
|
||||
addEndpointToGroup("Advance", "overlay-pdf");
|
||||
// Backend-only endpoints
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
addEndpointToGroup("Advance", "pipeline");
|
||||
|
||||
// Adding endpoints to "Automation" group
|
||||
addEndpointToGroup("Automation", "handleData");
|
||||
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
|
||||
addEndpointToGroup("Automation", "pipeline");
|
||||
|
||||
// Adding endpoints to "DeveloperTools" group
|
||||
addEndpointToGroup("DeveloperTools", "show-javascript");
|
||||
|
||||
// Adding endpoints to "DeveloperDocs" group (fake endpoints for link-only tools)
|
||||
addEndpointToGroup("DeveloperDocs", "dev-api-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-folder-scanning-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-sso-guide-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-airgapped-docs");
|
||||
|
||||
// CLI
|
||||
addEndpointToGroup("CLI", "compress-pdf");
|
||||
@@ -595,6 +606,12 @@ public class EndpointConfiguration {
|
||||
return endpointGroups.getOrDefault(group, new HashSet<>());
|
||||
}
|
||||
|
||||
public Set<String> getAllEndpoints() {
|
||||
return endpointGroups.values().stream()
|
||||
.flatMap(Set::stream)
|
||||
.collect(java.util.stream.Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean isToolGroup(String group) {
|
||||
return "qpdf".equals(group)
|
||||
|| "OCRmyPDF".equals(group)
|
||||
|
||||
+6
@@ -14,6 +14,7 @@ public class InstallationPathConfig {
|
||||
private static final String CUSTOM_FILES_PATH;
|
||||
private static final String CLIENT_WEBUI_PATH;
|
||||
private static final String PIPELINE_PATH;
|
||||
private static final String PLUGINS_PATH;
|
||||
|
||||
// Config paths
|
||||
private static final String SETTINGS_PATH;
|
||||
@@ -40,6 +41,7 @@ public class InstallationPathConfig {
|
||||
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
|
||||
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
|
||||
PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator;
|
||||
PLUGINS_PATH = CUSTOM_FILES_PATH + "plugins" + File.separator;
|
||||
|
||||
// Initialize config paths
|
||||
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
|
||||
@@ -110,6 +112,10 @@ public class InstallationPathConfig {
|
||||
return SIGNATURES_PATH;
|
||||
}
|
||||
|
||||
public static String getPluginsPath() {
|
||||
return PLUGINS_PATH;
|
||||
}
|
||||
|
||||
public static String getPrivateKeyPath() {
|
||||
return BACKUP_PRIVATE_KEY_PATH;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package stirling.software.common.constants;
|
||||
|
||||
/**
|
||||
* Centralized constants for JWT token management.
|
||||
*
|
||||
* <p>These defaults are used when configuration values are not explicitly set.
|
||||
*/
|
||||
public final class JwtConstants {
|
||||
|
||||
private JwtConstants() {
|
||||
throw new UnsupportedOperationException("Utility class");
|
||||
}
|
||||
|
||||
/** Default JWT access token lifetime in minutes (24 hours). */
|
||||
public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440;
|
||||
|
||||
/** Default desktop client token lifetime in minutes (30 days). */
|
||||
public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200;
|
||||
|
||||
/**
|
||||
* Default refresh grace period in minutes.
|
||||
*
|
||||
* <p>Allows refresh of expired tokens within this window after expiration.
|
||||
*/
|
||||
public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
|
||||
|
||||
/**
|
||||
* Default allowed clock skew in seconds.
|
||||
*
|
||||
* <p>Tolerates small time drift between client and server clocks during validation.
|
||||
*/
|
||||
public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60;
|
||||
|
||||
/** Milliseconds per minute. */
|
||||
public static final long MILLIS_PER_MINUTE = 60_000L;
|
||||
|
||||
/** Seconds per minute. */
|
||||
public static final long SECONDS_PER_MINUTE = 60L;
|
||||
|
||||
/** JWT issuer identifier. */
|
||||
public static final String ISSUER = "https://stirling.com";
|
||||
|
||||
/**
|
||||
* Maximum refresh attempts allowed within the grace period window.
|
||||
*
|
||||
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
|
||||
*/
|
||||
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
|
||||
}
|
||||
+147
-1
@@ -29,6 +29,8 @@ import org.springframework.stereotype.Component;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
@@ -37,6 +39,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.configuration.YamlPropertySourceFactory;
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||
import stirling.software.common.model.oauth2.GitHubProvider;
|
||||
import stirling.software.common.model.oauth2.GoogleProvider;
|
||||
@@ -98,6 +101,52 @@ public class ApplicationProperties {
|
||||
return propertySource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
|
||||
* SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
|
||||
*/
|
||||
@PostConstruct
|
||||
public void initializeFileUploadLimitFromEnv() {
|
||||
// Only override if fileUploadLimit is not already set in settings.yml
|
||||
if (system.getFileUploadLimit() == null || system.getFileUploadLimit().isEmpty()) {
|
||||
String fileUploadLimit = null;
|
||||
|
||||
// Check SYSTEMFILEUPLOADLIMIT first (format: "100MB", "1GB", etc.)
|
||||
String systemFileUploadLimit = java.lang.System.getenv("SYSTEMFILEUPLOADLIMIT");
|
||||
if (systemFileUploadLimit != null && !systemFileUploadLimit.trim().isEmpty()) {
|
||||
fileUploadLimit = systemFileUploadLimit.trim();
|
||||
log.info("Setting fileUploadLimit from SYSTEMFILEUPLOADLIMIT: {}", fileUploadLimit);
|
||||
} else {
|
||||
// Check SYSTEM_MAXFILESIZE (format: number in MB, e.g., "100")
|
||||
String systemMaxFileSize = java.lang.System.getenv("SYSTEM_MAXFILESIZE");
|
||||
if (systemMaxFileSize != null && !systemMaxFileSize.trim().isEmpty()) {
|
||||
try {
|
||||
// Validate it's a number
|
||||
long sizeInMB = Long.parseLong(systemMaxFileSize.trim());
|
||||
if (sizeInMB > 0 && sizeInMB <= 999) {
|
||||
fileUploadLimit = sizeInMB + "MB";
|
||||
log.info(
|
||||
"Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB",
|
||||
sizeInMB);
|
||||
} else {
|
||||
log.warn(
|
||||
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
|
||||
sizeInMB);
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
log.warn(
|
||||
"SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring",
|
||||
systemMaxFileSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fileUploadLimit != null) {
|
||||
system.setFileUploadLimit(fileUploadLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class AutoPipeline {
|
||||
private String outputFolder;
|
||||
@@ -345,12 +394,107 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT token configuration.
|
||||
*
|
||||
* <p><b>BREAKING CHANGE (v2.0):</b> Default token expiry increased from 12 hours (720
|
||||
* minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set
|
||||
* {@code tokenExpiryMinutes: 720} in your configuration.
|
||||
*/
|
||||
@Data
|
||||
public static class Jwt {
|
||||
private boolean enableKeystore = true;
|
||||
private boolean enableKeyRotation = false;
|
||||
private boolean enableKeyCleanup = true;
|
||||
private int keyRetentionDays = 7;
|
||||
|
||||
/**
|
||||
* JWT access token lifetime in minutes for web clients.
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
|
||||
*
|
||||
* <p><b>BREAKING CHANGE:</b> Previously hardcoded to 720 minutes (12 hours). Now
|
||||
* defaults to 1440 minutes (24 hours).
|
||||
*/
|
||||
private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
|
||||
/**
|
||||
* JWT access token lifetime in minutes for desktop clients (Tauri app).
|
||||
*
|
||||
* <p>Desktop clients are automatically detected via User-Agent header and receive
|
||||
* longer-lived tokens because they run on personal devices with OS-level encrypted
|
||||
* storage (macOS Keychain, Windows Credential Manager, Linux Secret Service).
|
||||
*
|
||||
* <p>This provides better UX (login once per month) while maintaining security through
|
||||
* device encryption and secure storage, matching the behavior of popular desktop apps
|
||||
* like Slack, Discord, VS Code, etc.
|
||||
*
|
||||
* <p>Default: 43200 minutes (30 days).
|
||||
*/
|
||||
private int desktopTokenExpiryMinutes = 43200;
|
||||
|
||||
/**
|
||||
* Allowed clock skew in seconds for JWT validation.
|
||||
*
|
||||
* <p>Tolerates small time drift between client and server clocks. Tokens that are
|
||||
* slightly expired or slightly in the future (within this window) will still be
|
||||
* accepted.
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
|
||||
*/
|
||||
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
|
||||
|
||||
/**
|
||||
* Grace period in minutes for refreshing expired tokens.
|
||||
*
|
||||
* <p>Allows token refresh using an expired access token if the token expired within
|
||||
* this many minutes. This provides better UX by allowing users to refresh slightly
|
||||
* expired tokens without re-authentication.
|
||||
*
|
||||
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
|
||||
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
|
||||
*
|
||||
* <p>Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes.
|
||||
*/
|
||||
private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
|
||||
|
||||
/**
|
||||
* Calculate number of days to retain old JWT signing keys.
|
||||
*
|
||||
* <p>Automatically calculated based on the longest token lifetime plus a proportional
|
||||
* safety buffer. Keys must be retained for at least as long as the tokens they signed
|
||||
* remain valid, otherwise token verification will fail.
|
||||
*
|
||||
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
|
||||
*
|
||||
* <p>The buffer includes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>10% of token lifetime (scales with token duration)
|
||||
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
|
||||
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
|
||||
* </ul>
|
||||
*
|
||||
* @return calculated key retention period in days
|
||||
*/
|
||||
public int getKeyRetentionDays() {
|
||||
final int MINUTES_PER_DAY = 1440;
|
||||
final double BUFFER_PERCENTAGE = 0.10; // 10% buffer
|
||||
|
||||
int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes);
|
||||
|
||||
// Add 10% buffer (scales with token lifetime)
|
||||
int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE);
|
||||
|
||||
// Add refresh grace period
|
||||
bufferMinutes += refreshGraceMinutes;
|
||||
|
||||
// Add clock skew (convert seconds to minutes, round up)
|
||||
bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0);
|
||||
|
||||
// Total retention in minutes, convert to days (round up)
|
||||
int totalMinutes = maxTokenExpiryMinutes + bufferMinutes;
|
||||
return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -568,6 +712,8 @@ public class ApplicationProperties {
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
private boolean defaultHideUnavailableTools = false;
|
||||
private boolean defaultHideUnavailableConversions = false;
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Form field information with coordinates for interactive form viewer. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "Form field with coordinates and metadata")
|
||||
public class FormFieldWithCoordinates {
|
||||
|
||||
@Schema(description = "Fully qualified field name", example = "form1.firstName")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "Display label for the field", example = "First Name")
|
||||
private String label;
|
||||
|
||||
@Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "Current field value")
|
||||
private String value;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Available options (export values) for choice fields"
|
||||
+ " (dropdown, radio, listbox)")
|
||||
private List<String> options;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Human-readable display labels for choice field options,"
|
||||
+ " parallel to the 'options' list. Null when identical to options.")
|
||||
private List<String> displayOptions;
|
||||
|
||||
@Schema(description = "Whether the field is required")
|
||||
private boolean required;
|
||||
|
||||
@Schema(description = "Whether the field is read-only")
|
||||
private boolean readOnly;
|
||||
|
||||
@Schema(description = "Whether this is a multi-select list box")
|
||||
private boolean multiSelect;
|
||||
|
||||
@Schema(description = "Whether this is a multi-line text field")
|
||||
private boolean multiline;
|
||||
|
||||
@Schema(description = "Tooltip/alternate name for the field")
|
||||
private String tooltip;
|
||||
|
||||
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
|
||||
private List<WidgetCoordinates> widgets;
|
||||
|
||||
/**
|
||||
* Coordinates for a single widget annotation (visual representation of the field). A field can
|
||||
* have multiple widgets if it appears on multiple pages.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "Widget coordinates in PDF space")
|
||||
public static class WidgetCoordinates {
|
||||
|
||||
@Schema(description = "Page index (0-based)", example = "0")
|
||||
private int pageIndex;
|
||||
|
||||
@Schema(description = "X coordinate in PDF points (lower-left origin)")
|
||||
private float x;
|
||||
|
||||
@Schema(description = "Y coordinate in PDF points (lower-left origin)")
|
||||
private float y;
|
||||
|
||||
@Schema(description = "Width in PDF points")
|
||||
private float width;
|
||||
|
||||
@Schema(description = "Height in PDF points")
|
||||
private float height;
|
||||
|
||||
@Schema(description = "Export value for this widget (radio/checkbox buttons only)")
|
||||
private String exportValue;
|
||||
|
||||
@Schema(description = "Font size in PDF points")
|
||||
private Float fontSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
/** Immutable descriptor that represents a loaded plugin. */
|
||||
@Value
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
public class PluginDescriptor {
|
||||
|
||||
String id;
|
||||
String icon;
|
||||
String name;
|
||||
String description;
|
||||
String version;
|
||||
String author;
|
||||
String frontendLabel;
|
||||
String frontendPath;
|
||||
String iconPath;
|
||||
String minHostVersion;
|
||||
String jarCreatedAt;
|
||||
|
||||
@Builder.Default boolean hasFrontend = false;
|
||||
|
||||
@Builder.Default List<String> backendEndpoints = Collections.emptyList();
|
||||
@Builder.Default Map<String, String> metadata = Collections.emptyMap();
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
/**
|
||||
* API-facing representation of a plugin descriptor with a fully resolved frontend URL.
|
||||
*
|
||||
* <p>This DTO is returned to clients so they can render plugin metadata and open plugin UIs.
|
||||
*/
|
||||
public class PluginDescriptorResponse {
|
||||
String id;
|
||||
String icon;
|
||||
String name;
|
||||
String description;
|
||||
String version;
|
||||
String author;
|
||||
String frontendUrl;
|
||||
String frontendLabel;
|
||||
String iconPath;
|
||||
String minHostVersion;
|
||||
String jarCreatedAt;
|
||||
boolean hasFrontend;
|
||||
List<String> backendEndpoints;
|
||||
Map<String, String> metadata;
|
||||
|
||||
/**
|
||||
* Creates a response object from an internal {@link PluginDescriptor}.
|
||||
*
|
||||
* @param descriptor loaded plugin descriptor
|
||||
* @param baseUrl optional API base URL used to build an absolute frontend URL
|
||||
* @return normalized response payload for API clients
|
||||
*/
|
||||
public static PluginDescriptorResponse from(PluginDescriptor descriptor, String baseUrl) {
|
||||
String frontendPath = descriptor.getFrontendPath();
|
||||
String normalizedBase = baseUrl != null ? baseUrl.replaceAll("/+$", "") : "";
|
||||
String normalizedPath = frontendPath != null ? frontendPath.replaceAll("^/+", "/") : "";
|
||||
String frontendUrl =
|
||||
(normalizedBase.isEmpty() || normalizedPath.isEmpty())
|
||||
? (normalizedPath.isEmpty() ? null : normalizedPath)
|
||||
: normalizedBase + normalizedPath;
|
||||
|
||||
return PluginDescriptorResponse.builder()
|
||||
.id(descriptor.getId())
|
||||
.icon(descriptor.getIcon())
|
||||
.name(descriptor.getName())
|
||||
.description(descriptor.getDescription())
|
||||
.version(descriptor.getVersion())
|
||||
.author(descriptor.getAuthor())
|
||||
.frontendUrl(frontendUrl)
|
||||
.frontendLabel(descriptor.getFrontendLabel())
|
||||
.iconPath(descriptor.getIconPath())
|
||||
.hasFrontend(descriptor.isHasFrontend())
|
||||
.backendEndpoints(descriptor.getBackendEndpoints())
|
||||
.metadata(descriptor.getMetadata())
|
||||
.minHostVersion(descriptor.getMinHostVersion())
|
||||
.jarCreatedAt(descriptor.getJarCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
/**
|
||||
* Utility responsible for discovering plugin jars, parsing their metadata, and integrating them
|
||||
* into the Stirling-PDF runtime.
|
||||
*/
|
||||
@Slf4j
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public final class PluginLoader {
|
||||
private static final String JAR_EXTENSION = ".jar";
|
||||
private static final String JAR_MIME_TYPE = "application/java-archive";
|
||||
private static final String METADATA_RESOURCE = "META-INF/stirling-plugin.json";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Discovers plugin jar files in the configured plugins directory.
|
||||
*
|
||||
* @return sorted list of valid plugin jar paths
|
||||
*/
|
||||
public static List<Path> listPluginJars() {
|
||||
Path pluginDir = ensurePluginDirectory();
|
||||
if (!Files.isDirectory(pluginDir)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(pluginDir)) {
|
||||
return stream.filter(Files::isRegularFile)
|
||||
.filter(PluginLoader::looksLikeJarFile)
|
||||
.filter(PluginLoader::isReadableJarArchive)
|
||||
.sorted(
|
||||
Comparator.comparing(
|
||||
path -> path.getFileName().toString().toLowerCase()))
|
||||
.collect(Collectors.toList());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to list plugin directory {}: {}", pluginDir, e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts discovered plugin jar paths into URL entries suitable for class/resource loading.
|
||||
*
|
||||
* @return immutable-style list of valid jar URLs
|
||||
*/
|
||||
public static List<URL> pluginJarUrls() {
|
||||
List<Path> jars = listPluginJars();
|
||||
if (jars.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<URL> urls = new ArrayList<>(jars.size());
|
||||
for (Path jar : jars) {
|
||||
try {
|
||||
urls.add(jar.toUri().toURL());
|
||||
} catch (MalformedURLException e) {
|
||||
log.warn("Skipping plugin jar with invalid URL {}: {}", jar, e.getMessage());
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a class loader that can load classes/resources from installed plugins.
|
||||
*
|
||||
* @param parent parent class loader
|
||||
* @return plugin-aware class loader or parent when no plugin jars exist
|
||||
*/
|
||||
public static ClassLoader buildPluginClassLoader(ClassLoader parent) {
|
||||
List<URL> urls = pluginJarUrls();
|
||||
if (urls.isEmpty()) {
|
||||
return parent;
|
||||
}
|
||||
log.info(
|
||||
"Scanning {} plugin jars in {}",
|
||||
urls.size(),
|
||||
InstallationPathConfig.getPluginsPath());
|
||||
return new URLClassLoader(urls.toArray(URL[]::new), parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads descriptors for all discovered plugin jars.
|
||||
*
|
||||
* @return immutable list of successfully parsed descriptors
|
||||
*/
|
||||
public static List<PluginDescriptor> loadDescriptors() {
|
||||
List<Path> jars = listPluginJars();
|
||||
if (jars.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<PluginDescriptor> descriptors = new ArrayList<>();
|
||||
for (Path jar : jars) {
|
||||
PluginDescriptor descriptor = readDescriptorFromJar(jar);
|
||||
if (descriptor != null) {
|
||||
descriptors.add(descriptor);
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableList(descriptors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads metadata for one plugin jar and maps it to a descriptor.
|
||||
*
|
||||
* @param jarPath plugin jar path
|
||||
* @return descriptor when valid metadata exists, otherwise {@code null}
|
||||
*/
|
||||
public static PluginDescriptor loadDescriptor(Path jarPath) {
|
||||
return readDescriptorFromJar(jarPath);
|
||||
}
|
||||
|
||||
private static Path ensurePluginDirectory() {
|
||||
Path pluginDir = Path.of(InstallationPathConfig.getPluginsPath());
|
||||
try {
|
||||
return Files.createDirectories(pluginDir);
|
||||
} catch (IOException e) {
|
||||
log.error("Unable to create plugin directory {}", pluginDir, e);
|
||||
return pluginDir;
|
||||
}
|
||||
}
|
||||
|
||||
private static PluginDescriptor readDescriptorFromJar(Path jarPath) {
|
||||
if (!Files.isRegularFile(jarPath)) {
|
||||
log.warn("Plugin jar {} is not a regular file, skipping", jarPath);
|
||||
return null;
|
||||
}
|
||||
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
|
||||
JarEntry entry = jarFile.getJarEntry(METADATA_RESOURCE);
|
||||
if (entry == null) {
|
||||
log.info("Plugin jar {} does not include {}, skipping", jarPath, METADATA_RESOURCE);
|
||||
return null;
|
||||
}
|
||||
PluginMetadata metadata;
|
||||
try (InputStream inputStream = jarFile.getInputStream(entry)) {
|
||||
metadata = OBJECT_MAPPER.readValue(inputStream, PluginMetadata.class);
|
||||
}
|
||||
|
||||
String createdAt = resolveJarTimestamp(jarPath);
|
||||
|
||||
if (metadata.getId() == null || metadata.getId().isBlank()) {
|
||||
log.warn("Plugin metadata in {} is missing required id, ignoring", jarPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
String pluginId = metadata.getId();
|
||||
log.info(
|
||||
"Loaded metadata for plugin '{}': name='{}' version='{}'",
|
||||
pluginId,
|
||||
metadata.getName(),
|
||||
metadata.getVersion());
|
||||
|
||||
return buildDescriptor(metadata, createdAt);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to inspect plugin jar {}: {}", jarPath, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static PluginDescriptor buildDescriptor(PluginMetadata metadata, String jarCreatedAt) {
|
||||
PluginMetadata.PluginFrontend frontend = metadata.getFrontend();
|
||||
String id = metadata.getId();
|
||||
String icon = metadata.getIcon();
|
||||
|
||||
String frontendPath =
|
||||
(frontend != null
|
||||
&& frontend.getEntrypoint() != null
|
||||
&& !frontend.getEntrypoint().isBlank())
|
||||
? ensureLeadingSlash(frontend.getEntrypoint())
|
||||
: "/plugins/" + id + "/index.html";
|
||||
|
||||
return PluginDescriptor.builder()
|
||||
.id(id)
|
||||
.icon(defaultIfEmpty(icon, null))
|
||||
.name(defaultIfEmpty(metadata.getName(), id))
|
||||
.description(defaultIfEmpty(metadata.getDescription(), ""))
|
||||
.version(defaultIfEmpty(metadata.getVersion(), "0.0.0"))
|
||||
.author(metadata.getAuthor())
|
||||
.frontendLabel(frontend != null ? frontend.getLabel() : null)
|
||||
.frontendPath(frontendPath)
|
||||
.iconPath(frontend != null ? frontend.getIconPath() : null)
|
||||
.hasFrontend(frontend != null)
|
||||
.backendEndpoints(
|
||||
metadata.getBackendEndpoints() == null
|
||||
? Collections.emptyList()
|
||||
: metadata.getBackendEndpoints())
|
||||
.metadata(
|
||||
metadata.getMetadata() == null
|
||||
? Collections.emptyMap()
|
||||
: metadata.getMetadata())
|
||||
.minHostVersion(defaultIfEmpty(metadata.getMinHostVersion(), null))
|
||||
.jarCreatedAt(jarCreatedAt)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String defaultIfEmpty(String value, String fallback) {
|
||||
return (value == null || value.isBlank()) ? fallback : value;
|
||||
}
|
||||
|
||||
private static String ensureLeadingSlash(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "/";
|
||||
}
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
|
||||
private static boolean looksLikeJarFile(Path path) {
|
||||
return path.getFileName().toString().toLowerCase().endsWith(JAR_EXTENSION);
|
||||
}
|
||||
|
||||
private static boolean isReadableJarArchive(Path path) {
|
||||
try {
|
||||
String mimeType = Files.probeContentType(path);
|
||||
if (mimeType != null && !JAR_MIME_TYPE.equals(mimeType)) {
|
||||
log.debug("Ignoring non-jar mime type {} for {}", mimeType, path);
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("Unable to probe mime type for {}: {}", path, e.getMessage());
|
||||
}
|
||||
|
||||
try (JarFile ignored = new JarFile(path.toFile())) {
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.warn("Skipping invalid jar archive {}: {}", path, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveJarTimestamp(Path jarPath) throws IOException {
|
||||
BasicFileAttributes attrs = Files.readAttributes(jarPath, BasicFileAttributes.class);
|
||||
FileTime creationTime = attrs.creationTime();
|
||||
FileTime lastModifiedTime = attrs.lastModifiedTime();
|
||||
FileTime preferredTime =
|
||||
creationTime == null || creationTime.toMillis() <= 0
|
||||
? lastModifiedTime
|
||||
: creationTime;
|
||||
return preferredTime.toInstant().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
/** Model for deserializing {@code META-INF/stirling-plugin.json} from a plugin jar. */
|
||||
public class PluginMetadata {
|
||||
private String id;
|
||||
private String icon;
|
||||
private String name;
|
||||
private String description;
|
||||
private String version;
|
||||
private String author;
|
||||
private String minHostVersion;
|
||||
private PluginFrontend frontend;
|
||||
private List<String> backendEndpoints;
|
||||
private Map<String, String> metadata;
|
||||
|
||||
/** Frontend-specific metadata block declared inside plugin metadata JSON. */
|
||||
@Getter
|
||||
@Setter
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class PluginFrontend {
|
||||
private String entrypoint;
|
||||
private String label;
|
||||
private String iconPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
/** Registers MVC resource handlers that expose static assets from plugin jars. */
|
||||
public class PluginResourceConfig implements WebMvcConfigurer {
|
||||
|
||||
/** Adds {@code /plugins/**} static resource mappings for every discovered plugin jar. */
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
List<String> locations =
|
||||
PluginLoader.pluginJarUrls().stream()
|
||||
.map(url -> "jar:" + url + "!/META-INF/resources/plugins/")
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!locations.isEmpty()) {
|
||||
registry.addResourceHandler("/plugins/**")
|
||||
.addResourceLocations(locations.toArray(String[]::new))
|
||||
.setCachePeriod(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
/**
|
||||
* Interface for checking license status dynamically. Implementation provided by proprietary module
|
||||
* when available.
|
||||
*/
|
||||
public interface LicenseServiceInterface {
|
||||
|
||||
/**
|
||||
* Get the license type as a string.
|
||||
*
|
||||
* @return "NORMAL", "SERVER", or "ENTERPRISE"
|
||||
*/
|
||||
String getLicenseTypeName();
|
||||
|
||||
/**
|
||||
* Check if running Pro or higher (SERVER or ENTERPRISE license).
|
||||
*
|
||||
* @return true if SERVER or ENTERPRISE license is active
|
||||
*/
|
||||
boolean isRunningProOrHigher();
|
||||
|
||||
/**
|
||||
* Check if running Enterprise edition.
|
||||
*
|
||||
* @return true if ENTERPRISE license is active
|
||||
*/
|
||||
boolean isRunningEE();
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -25,6 +26,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class MobileScannerService {
|
||||
|
||||
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
|
||||
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
|
||||
private final Path tempDirectory;
|
||||
|
||||
@@ -121,7 +125,8 @@ public class MobileScannerService {
|
||||
// Handle duplicate filenames
|
||||
int counter = 1;
|
||||
while (Files.exists(filePath)) {
|
||||
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
|
||||
String nameWithoutExt =
|
||||
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
|
||||
String ext =
|
||||
safeFilename.contains(".")
|
||||
? safeFilename.substring(safeFilename.lastIndexOf("."))
|
||||
@@ -271,14 +276,14 @@ public class MobileScannerService {
|
||||
throw new IllegalArgumentException("Session ID cannot be empty");
|
||||
}
|
||||
// Basic validation: alphanumeric and hyphens only
|
||||
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
|
||||
if (!SESSION_ID_VALIDATION_PATTERN.matcher(sessionId).matches()) {
|
||||
throw new IllegalArgumentException("Invalid session ID format");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
// Remove path traversal attempts and dangerous characters
|
||||
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
|
||||
// Ensure we have a non-empty, safe filename
|
||||
if (sanitized.isBlank()) {
|
||||
sanitized = "upload-" + System.currentTimeMillis();
|
||||
|
||||
@@ -107,56 +107,65 @@ public class PDFToFile {
|
||||
File[] outputFiles =
|
||||
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
|
||||
List<File> markdownFiles = new ArrayList<>();
|
||||
List<File> imageFiles = new ArrayList<>();
|
||||
|
||||
// Convert HTML files to Markdown
|
||||
// Convert HTML files to Markdown and collect image files
|
||||
for (File outputFile : outputFiles) {
|
||||
if (outputFile.getName().endsWith(".html")) {
|
||||
String html = Files.readString(outputFile.toPath());
|
||||
String markdown = htmlToMarkdownConverter.convert(html);
|
||||
|
||||
// Update image references to point to images/ folder
|
||||
markdown = updateImageReferences(markdown);
|
||||
|
||||
String mdFileName = outputFile.getName().replace(".html", ".md");
|
||||
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
|
||||
Files.writeString(mdFile.toPath(), markdown);
|
||||
markdownFiles.add(mdFile);
|
||||
} else if (!outputFile.getName().endsWith(".md")) {
|
||||
// Collect non-HTML, non-MD files as images/assets
|
||||
imageFiles.add(outputFile);
|
||||
}
|
||||
}
|
||||
|
||||
// If there's only one markdown file, return it directly
|
||||
if (markdownFiles.size() == 1) {
|
||||
fileName = pdfBaseName + ".md";
|
||||
fileBytes = Files.readAllBytes(markdownFiles.get(0).toPath());
|
||||
} else {
|
||||
// Multiple files - create a zip
|
||||
fileName = pdfBaseName + "ToMarkdown.zip";
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
// Always create a ZIP file
|
||||
fileName = pdfBaseName + "ToMarkdown.zip";
|
||||
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
|
||||
|
||||
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
|
||||
// Add markdown files
|
||||
for (File mdFile : markdownFiles) {
|
||||
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
|
||||
zipOutputStream.putNextEntry(mdEntry);
|
||||
Files.copy(mdFile.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
// Add images and other assets
|
||||
for (File file : outputFiles) {
|
||||
if (!file.getName().endsWith(".html") && !file.getName().endsWith(".md")) {
|
||||
ZipEntry assetEntry = new ZipEntry(file.getName());
|
||||
zipOutputStream.putNextEntry(assetEntry);
|
||||
Files.copy(file.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
}
|
||||
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
|
||||
// Add markdown files to root of ZIP
|
||||
for (File mdFile : markdownFiles) {
|
||||
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
|
||||
zipOutputStream.putNextEntry(mdEntry);
|
||||
Files.copy(mdFile.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
// Add images and other assets to images/ folder
|
||||
for (File imageFile : imageFiles) {
|
||||
ZipEntry assetEntry = new ZipEntry("images/" + imageFile.getName());
|
||||
zipOutputStream.putNextEntry(assetEntry);
|
||||
Files.copy(imageFile.toPath(), zipOutputStream);
|
||||
zipOutputStream.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
fileBytes = byteArrayOutputStream.toByteArray();
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates image references in markdown to point to the images/ folder. Matches patterns like
|
||||
*  and converts to 
|
||||
*/
|
||||
private String updateImageReferences(String markdown) {
|
||||
// Match markdown image syntax: 
|
||||
// Only update if the path doesn't already start with images/
|
||||
return markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
|
||||
}
|
||||
|
||||
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
|
||||
throws IOException, InterruptedException {
|
||||
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
|
||||
|
||||
@@ -574,34 +574,39 @@ public class PdfUtils {
|
||||
boolean everyPage)
|
||||
throws IOException {
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(pdfBytes);
|
||||
|
||||
// Get the first page of the PDF
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
|
||||
// Create an image object from the image bytes
|
||||
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
// Draw the image onto the page at the specified x and y coordinates
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF");
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
|
||||
// Get the first page of the PDF
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
// Create an image object from the image bytes
|
||||
PDImageXObject image =
|
||||
PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
// Draw the image onto the page at the specified x and y coordinates
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF");
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Log an error message if there is an issue overlaying the image onto the PDF
|
||||
log.error("Error overlaying image onto PDF", e);
|
||||
throw e;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Log an error message if there is an issue overlaying the image onto the PDF
|
||||
log.error("Error overlaying image onto PDF", e);
|
||||
throw e;
|
||||
}
|
||||
// Create a ByteArrayOutputStream to save the PDF to
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
log.info("PDF successfully saved to byte array");
|
||||
return baos.toByteArray();
|
||||
}
|
||||
// Create a ByteArrayOutputStream to save the PDF to
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
log.info("PDF successfully saved to byte array");
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
public boolean containsTextInFile(PDDocument pdfDocument, String text, String pagesToCheck)
|
||||
|
||||
@@ -47,6 +47,7 @@ public class SvgSanitizer {
|
||||
private static final Pattern DATA_SCRIPT_PATTERN =
|
||||
Pattern.compile(
|
||||
"^\\s*data\\s*:[^,]*(?:script|javascript|vbscript)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern NULL_BYTE_PATTERN = Pattern.compile("\u0000");
|
||||
private final SsrfProtectionService ssrfProtectionService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@@ -210,7 +211,7 @@ public class SvgSanitizer {
|
||||
|
||||
String result = url.trim();
|
||||
|
||||
result = result.replaceAll("\u0000", "");
|
||||
result = NULL_BYTE_PATTERN.matcher(result).replaceAll("");
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
try {
|
||||
|
||||
@@ -153,11 +153,12 @@ class PDFToFileTest {
|
||||
MediaType.APPLICATION_PDF_VALUE,
|
||||
"Fake PDF content".getBytes());
|
||||
|
||||
// Create a mock HTML output file
|
||||
// Create a mock HTML output file with image references
|
||||
Path htmlOutputFile = tempDir.resolve("test.html");
|
||||
Files.write(
|
||||
htmlOutputFile,
|
||||
"<html><body><h1>Test</h1><p>This is a test.</p></body></html>".getBytes());
|
||||
"<html><body><h1>Test</h1><p>This is a test.</p><img src=\"image1.png\" /></body></html>"
|
||||
.getBytes());
|
||||
|
||||
// Setup ProcessExecutor mock
|
||||
mockedStaticProcessExecutor
|
||||
@@ -174,18 +175,61 @@ class PDFToFileTest {
|
||||
Files.copy(
|
||||
htmlOutputFile, Path.of(outputDir.getPath(), "test.html"));
|
||||
|
||||
// Create a mock image file
|
||||
Files.write(
|
||||
Path.of(outputDir.getPath(), "image1.png"),
|
||||
"Fake image data".getBytes());
|
||||
|
||||
return mockExecutorResult;
|
||||
});
|
||||
|
||||
// Execute the method
|
||||
ResponseEntity<byte[]> response = pdfToFile.processPdfToMarkdown(pdfFile);
|
||||
|
||||
// Verify
|
||||
// Verify - should now return a ZIP file instead of plain markdown
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
assertNotNull(response.getBody());
|
||||
assertTrue(response.getBody().length > 0);
|
||||
|
||||
// Verify content disposition indicates a ZIP file
|
||||
assertTrue(
|
||||
response.getHeaders().getContentDisposition().toString().contains("test.md"));
|
||||
response.getHeaders()
|
||||
.getContentDisposition()
|
||||
.toString()
|
||||
.contains("ToMarkdown.zip"));
|
||||
|
||||
// Verify the content by unzipping it
|
||||
try (ZipInputStream zipStream =
|
||||
ZipSecurity.createHardenedInputStream(
|
||||
new java.io.ByteArrayInputStream(response.getBody()))) {
|
||||
ZipEntry entry;
|
||||
boolean foundMdFile = false;
|
||||
boolean foundImageInFolder = false;
|
||||
String markdownContent = null;
|
||||
|
||||
while ((entry = zipStream.getNextEntry()) != null) {
|
||||
if (entry.getName().endsWith(".md")) {
|
||||
foundMdFile = true;
|
||||
// Read markdown content to verify image references
|
||||
markdownContent =
|
||||
new String(
|
||||
zipStream.readAllBytes(),
|
||||
java.nio.charset.StandardCharsets.UTF_8);
|
||||
} else if (entry.getName().startsWith("images/")
|
||||
&& entry.getName().endsWith(".png")) {
|
||||
foundImageInFolder = true;
|
||||
}
|
||||
zipStream.closeEntry();
|
||||
}
|
||||
|
||||
assertTrue(foundMdFile, "ZIP should contain Markdown file");
|
||||
assertTrue(foundImageInFolder, "ZIP should contain image in images/ folder");
|
||||
assertNotNull(markdownContent, "Markdown content should be present");
|
||||
// Verify markdown references images with images/ prefix
|
||||
assertTrue(
|
||||
markdownContent.contains("images/"),
|
||||
"Markdown should reference images with images/ prefix");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,14 +300,15 @@ class PDFToFileTest {
|
||||
while ((entry = zipStream.getNextEntry()) != null) {
|
||||
if (entry.getName().endsWith(".md")) {
|
||||
foundMdFiles = true;
|
||||
} else if (entry.getName().endsWith(".png")) {
|
||||
} else if (entry.getName().startsWith("images/")
|
||||
&& entry.getName().endsWith(".png")) {
|
||||
foundImage = true;
|
||||
}
|
||||
zipStream.closeEntry();
|
||||
}
|
||||
|
||||
assertTrue(foundMdFiles, "ZIP should contain Markdown files");
|
||||
assertTrue(foundImage, "ZIP should contain image files");
|
||||
assertTrue(foundImage, "ZIP should contain image files in images/ folder");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ def generatedFrontendPaths = [
|
||||
]
|
||||
|
||||
tasks.register('npmInstall', Exec) {
|
||||
doNotTrackState("node_modules contains symlinks that Gradle cannot snapshot on Windows/WSL")
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Install frontend dependencies'
|
||||
@@ -214,6 +215,7 @@ tasks.register('npmInstall', Exec) {
|
||||
}
|
||||
|
||||
tasks.register('npmBuild', Exec) {
|
||||
doNotTrackState("Frontend build depends on untracked npmInstall task")
|
||||
enabled = buildWithFrontend
|
||||
group = 'frontend'
|
||||
description = 'Build frontend application'
|
||||
|
||||
@@ -9,9 +9,11 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.boot.web.context.WebServerInitializedEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.env.Environment;
|
||||
@@ -27,6 +29,7 @@ import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.configuration.ConfigInitializer;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.plugins.PluginLoader;
|
||||
|
||||
@Slf4j
|
||||
@EnableScheduling
|
||||
@@ -38,6 +41,10 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
})
|
||||
public class SPDFApplication {
|
||||
|
||||
private static final Pattern PORT_SUFFIX_PATTERN = Pattern.compile(".+:\\d+$");
|
||||
private static final Pattern URL_SCHEME_PATTERN =
|
||||
Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/+$");
|
||||
private static String serverPortStatic;
|
||||
private static String baseUrlStatic;
|
||||
private static String contextPathStatic;
|
||||
@@ -54,11 +61,11 @@ public class SPDFApplication {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException, InterruptedException {
|
||||
SpringApplication app = new SpringApplication(SPDFApplication.class);
|
||||
SpringApplicationBuilder builder = new SpringApplicationBuilder(SPDFApplication.class);
|
||||
|
||||
Properties props = new Properties();
|
||||
|
||||
app.setAdditionalProfiles(getActiveProfile(args));
|
||||
builder.profiles(getActiveProfile(args));
|
||||
|
||||
ConfigInitializer initializer = new ConfigInitializer();
|
||||
try {
|
||||
@@ -106,8 +113,13 @@ public class SPDFApplication {
|
||||
if (!props.isEmpty()) {
|
||||
finalProps.putAll(props);
|
||||
}
|
||||
ClassLoader pluginClassLoader =
|
||||
PluginLoader.buildPluginClassLoader(SPDFApplication.class.getClassLoader());
|
||||
if (pluginClassLoader != SPDFApplication.class.getClassLoader()) {
|
||||
Thread.currentThread().setContextClassLoader(pluginClassLoader);
|
||||
}
|
||||
SpringApplication app = builder.build();
|
||||
app.setDefaultProperties(finalProps);
|
||||
|
||||
app.run(args);
|
||||
|
||||
// Ensure directories are created
|
||||
@@ -244,8 +256,8 @@ public class SPDFApplication {
|
||||
String trimmedBase =
|
||||
(backendUrl == null || backendUrl.isBlank())
|
||||
? "http://localhost"
|
||||
: backendUrl.trim().replaceAll("/+$", "");
|
||||
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
: TRAILING_SLASH_PATTERN.matcher(backendUrl.trim()).replaceAll("");
|
||||
boolean hasScheme = URL_SCHEME_PATTERN.matcher(trimmedBase).matches();
|
||||
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
|
||||
Integer parsedPort = parsePort(port);
|
||||
|
||||
@@ -298,7 +310,7 @@ public class SPDFApplication {
|
||||
if (port == null) {
|
||||
return trimmedBase;
|
||||
}
|
||||
if (trimmedBase.matches(".+:\\d+$")) {
|
||||
if (PORT_SUFFIX_PATTERN.matcher(trimmedBase).matches()) {
|
||||
return trimmedBase;
|
||||
}
|
||||
return trimmedBase + ":" + port;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package stirling.software.SPDF.config;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.web.servlet.MultipartConfigFactory;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.DependsOn;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
|
||||
import jakarta.servlet.MultipartConfigElement;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.controller.web.UploadLimitService;
|
||||
|
||||
/**
|
||||
* Configuration for Spring multipart file upload settings. Synchronizes multipart limits with
|
||||
* fileUploadLimit from settings.yml or environment variables (SYSTEMFILEUPLOADLIMIT or
|
||||
* SYSTEM_MAXFILESIZE).
|
||||
*/
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class MultipartConfiguration {
|
||||
|
||||
@Autowired private UploadLimitService uploadLimitService;
|
||||
|
||||
/**
|
||||
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml or environment
|
||||
* variables (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE). Depends on ApplicationProperties
|
||||
* being initialized so @PostConstruct has run.
|
||||
*/
|
||||
@Bean
|
||||
@DependsOn("applicationProperties")
|
||||
public MultipartConfigElement multipartConfigElement() {
|
||||
MultipartConfigFactory factory = new MultipartConfigFactory();
|
||||
|
||||
// First check if SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE is explicitly set
|
||||
String springMaxFileSize =
|
||||
java.lang.System.getenv("SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE");
|
||||
long uploadLimitBytes = 0;
|
||||
|
||||
if (springMaxFileSize != null && !springMaxFileSize.trim().isEmpty()) {
|
||||
// Parse the Spring property format (e.g., "2000MB")
|
||||
try {
|
||||
DataSize dataSize = DataSize.parse(springMaxFileSize.trim());
|
||||
uploadLimitBytes = dataSize.toBytes();
|
||||
log.info("Using SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}", springMaxFileSize);
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to parse SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}",
|
||||
springMaxFileSize,
|
||||
e);
|
||||
}
|
||||
}
|
||||
|
||||
// If not set via Spring property, use UploadLimitService which reads from
|
||||
// fileUploadLimit (set from SYSTEMFILEUPLOADLIMIT/SYSTEM_MAXFILESIZE or settings.yml)
|
||||
if (uploadLimitBytes == 0) {
|
||||
uploadLimitBytes = uploadLimitService.getUploadLimit();
|
||||
if (uploadLimitBytes > 0) {
|
||||
log.info(
|
||||
"Using fileUploadLimit setting: {}",
|
||||
uploadLimitService.getReadableUploadLimit());
|
||||
}
|
||||
}
|
||||
|
||||
// If still no limit, use default of 2000MB
|
||||
if (uploadLimitBytes == 0) {
|
||||
uploadLimitBytes = 2000L * 1024 * 1024; // 2000MB default
|
||||
log.info("Using default multipart file upload limit: 2000MB");
|
||||
}
|
||||
|
||||
// Set max file size and max request size to the same value
|
||||
factory.setMaxFileSize(DataSize.ofBytes(uploadLimitBytes));
|
||||
factory.setMaxRequestSize(DataSize.ofBytes(uploadLimitBytes));
|
||||
|
||||
return factory.createMultipartConfig();
|
||||
}
|
||||
}
|
||||
+5
-5
@@ -46,18 +46,18 @@ public class EditTableOfContentsController {
|
||||
@Operation(
|
||||
summary = "Extract PDF Bookmarks",
|
||||
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
|
||||
@ResponseBody
|
||||
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
|
||||
throws Exception {
|
||||
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
|
||||
@RequestParam("file") MultipartFile file) throws Exception {
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
PDDocumentOutline outline = document.getDocumentCatalog().getDocumentOutline();
|
||||
|
||||
if (outline == null) {
|
||||
log.info("No outline/bookmarks found in PDF");
|
||||
return new ArrayList<>();
|
||||
return ResponseEntity.ok(new ArrayList<>());
|
||||
}
|
||||
|
||||
return extractBookmarkItems(document, outline);
|
||||
List<Map<String, Object>> bookmarks = extractBookmarkItems(document, outline);
|
||||
return ResponseEntity.ok(bookmarks);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -51,6 +52,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequiredArgsConstructor
|
||||
public class MergeController {
|
||||
|
||||
private static final Pattern QUOTE_WRAP_PATTERN = Pattern.compile("^\"|\"$");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -173,7 +175,7 @@ public class MergeController {
|
||||
String[] parts = inside.split(",");
|
||||
String[] result = new String[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
|
||||
result[i] = QUOTE_WRAP_PATTERN.matcher(parts[i].trim()).replaceAll("");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+56
-54
@@ -101,66 +101,68 @@ public class SplitPdfBySectionsController {
|
||||
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
|
||||
}
|
||||
} else {
|
||||
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
int pageNum = pageIndex + 1;
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
for (int i = 0; i < horiz; i++) {
|
||||
for (int j = 0; j < verti; j++) {
|
||||
try (PDDocument subDoc =
|
||||
pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addSingleSectionToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
subDoc,
|
||||
subLayerUtility,
|
||||
i,
|
||||
j,
|
||||
horiz,
|
||||
verti);
|
||||
int sectionNum = i * verti + j + 1;
|
||||
String entryName =
|
||||
filename
|
||||
+ "_"
|
||||
+ pageNum
|
||||
+ "_"
|
||||
+ sectionNum
|
||||
+ ".pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error creating section {} for page {}",
|
||||
(i * verti + j + 1),
|
||||
pageNum,
|
||||
e);
|
||||
throw e;
|
||||
try (TempFile zipTempFile = new TempFile(tempFileManager, ".zip")) {
|
||||
try (ZipOutputStream zipOut =
|
||||
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
|
||||
for (int pageIndex = 0;
|
||||
pageIndex < sourceDocument.getNumberOfPages();
|
||||
pageIndex++) {
|
||||
int pageNum = pageIndex + 1;
|
||||
if (pagesToSplit.contains(pageIndex)) {
|
||||
for (int i = 0; i < horiz; i++) {
|
||||
for (int j = 0; j < verti; j++) {
|
||||
try (PDDocument subDoc =
|
||||
pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addSingleSectionToTarget(
|
||||
sourceDocument,
|
||||
pageIndex,
|
||||
subDoc,
|
||||
subLayerUtility,
|
||||
i,
|
||||
j,
|
||||
horiz,
|
||||
verti);
|
||||
int sectionNum = i * verti + j + 1;
|
||||
String entryName =
|
||||
filename
|
||||
+ "_"
|
||||
+ pageNum
|
||||
+ "_"
|
||||
+ sectionNum
|
||||
+ ".pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error(
|
||||
"Error creating section {} for page {}",
|
||||
(i * verti + j + 1),
|
||||
pageNum,
|
||||
e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addPageToTarget(sourceDocument, pageIndex, subDoc, subLayerUtility);
|
||||
String entryName = filename + "_" + pageNum + "_1.pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error("Error processing unsplit page {}", pageNum, e);
|
||||
throw e;
|
||||
} else {
|
||||
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
|
||||
LayerUtility subLayerUtility = new LayerUtility(subDoc);
|
||||
addPageToTarget(
|
||||
sourceDocument, pageIndex, subDoc, subLayerUtility);
|
||||
String entryName = filename + "_" + pageNum + "_1.pdf";
|
||||
saveDocToZip(subDoc, zipOut, entryName);
|
||||
} catch (IOException e) {
|
||||
log.error("Error processing unsplit page {}", pageNum, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error creating ZIP file with split PDF sections", e);
|
||||
throw e;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.error("Error creating ZIP file with split PDF sections", e);
|
||||
throw e;
|
||||
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error splitting PDF file: {}", file.getOriginalFilename(), e);
|
||||
|
||||
+164
-23
@@ -44,7 +44,7 @@ public class ConvertMarkdownToPdf {
|
||||
@Operation(
|
||||
summary = "Convert a Markdown file to PDF",
|
||||
description =
|
||||
"This endpoint takes a Markdown file input, converts it to HTML, and then to"
|
||||
"This endpoint takes a Markdown file or ZIP (containing Markdown + images) input, converts it to HTML, and then to"
|
||||
+ " PDF format. Input:MARKDOWN Output:PDF Type:SISO")
|
||||
public ResponseEntity<byte[]> markdownToPdf(@ModelAttribute GeneralFile generalFile)
|
||||
throws Exception {
|
||||
@@ -52,40 +52,181 @@ public class ConvertMarkdownToPdf {
|
||||
|
||||
if (fileInput == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileFormatRequired", "File must be in {0} format", "Markdown");
|
||||
"error.fileFormatRequired", "File must be in {0} format", "Markdown or ZIP");
|
||||
}
|
||||
|
||||
String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
|
||||
if (originalFilename == null || !originalFilename.endsWith(".md")) {
|
||||
if (originalFilename == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileFormatRequired", "File must be in {0} format", ".md");
|
||||
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
|
||||
}
|
||||
|
||||
// Convert Markdown to HTML using CommonMark
|
||||
List<Extension> extensions = List.of(TablesExtension.create());
|
||||
Parser parser = Parser.builder().extensions(extensions).build();
|
||||
boolean isZip = originalFilename.toLowerCase().endsWith(".zip");
|
||||
boolean isMarkdown = originalFilename.toLowerCase().endsWith(".md");
|
||||
|
||||
Node document = parser.parse(new String(fileInput.getBytes()));
|
||||
HtmlRenderer renderer =
|
||||
HtmlRenderer.builder()
|
||||
.attributeProviderFactory(context -> new TableAttributeProvider())
|
||||
.extensions(extensions)
|
||||
.build();
|
||||
if (!isZip && !isMarkdown) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
|
||||
}
|
||||
|
||||
String htmlContent = renderer.render(document);
|
||||
byte[] pdfBytes;
|
||||
String outputFilename;
|
||||
|
||||
if (isZip) {
|
||||
// Handle ZIP file containing markdown + images
|
||||
try (TempDirectory tempDir = new TempDirectory(tempFileManager)) {
|
||||
// Extract ZIP to temp directory
|
||||
java.nio.file.Path tempDirPath = tempDir.getPath();
|
||||
try (java.util.zip.ZipInputStream zipIn =
|
||||
io.github.pixee.security.ZipSecurity.createHardenedInputStream(
|
||||
new java.io.ByteArrayInputStream(fileInput.getBytes()))) {
|
||||
java.util.zip.ZipEntry entry;
|
||||
while ((entry = zipIn.getNextEntry()) != null) {
|
||||
if (!entry.isDirectory()) {
|
||||
java.nio.file.Path filePath = tempDirPath.resolve(entry.getName());
|
||||
java.nio.file.Files.createDirectories(filePath.getParent());
|
||||
java.nio.file.Files.copy(zipIn, filePath);
|
||||
}
|
||||
zipIn.closeEntry();
|
||||
}
|
||||
}
|
||||
|
||||
// Find the markdown file (look for .md files, prefer index.md or first one)
|
||||
java.io.File markdownFile = findMarkdownFile(tempDirPath.toFile());
|
||||
if (markdownFile == null) {
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
"error.fileFormatRequired",
|
||||
"ZIP must contain at least one {0} file",
|
||||
".md");
|
||||
}
|
||||
|
||||
// Read and convert markdown to HTML
|
||||
String markdownContent = java.nio.file.Files.readString(markdownFile.toPath());
|
||||
List<Extension> extensions = List.of(TablesExtension.create());
|
||||
Parser parser = Parser.builder().extensions(extensions).build();
|
||||
Node document = parser.parse(markdownContent);
|
||||
HtmlRenderer renderer =
|
||||
HtmlRenderer.builder()
|
||||
.attributeProviderFactory(context -> new TableAttributeProvider())
|
||||
.extensions(extensions)
|
||||
.build();
|
||||
String htmlContent = renderer.render(document);
|
||||
|
||||
// Create a new ZIP with HTML + images for WeasyPrint
|
||||
byte[] htmlZipBytes = createHtmlZip(htmlContent, tempDirPath.toFile());
|
||||
|
||||
// Use FileToPdf which already supports ZIP files with images
|
||||
pdfBytes =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
null,
|
||||
htmlZipBytes,
|
||||
"package.zip",
|
||||
tempFileManager,
|
||||
customHtmlSanitizer);
|
||||
|
||||
outputFilename =
|
||||
GeneralUtils.generateFilename(
|
||||
originalFilename.substring(0, originalFilename.lastIndexOf('.')),
|
||||
".pdf");
|
||||
}
|
||||
} else {
|
||||
// Handle plain markdown file (no images)
|
||||
List<Extension> extensions = List.of(TablesExtension.create());
|
||||
Parser parser = Parser.builder().extensions(extensions).build();
|
||||
|
||||
Node document = parser.parse(new String(fileInput.getBytes()));
|
||||
HtmlRenderer renderer =
|
||||
HtmlRenderer.builder()
|
||||
.attributeProviderFactory(context -> new TableAttributeProvider())
|
||||
.extensions(extensions)
|
||||
.build();
|
||||
|
||||
String htmlContent = renderer.render(document);
|
||||
|
||||
pdfBytes =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
null,
|
||||
htmlContent.getBytes(),
|
||||
"converted.html",
|
||||
tempFileManager,
|
||||
customHtmlSanitizer);
|
||||
|
||||
outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
|
||||
}
|
||||
|
||||
byte[] pdfBytes =
|
||||
FileToPdf.convertHtmlToPdf(
|
||||
runtimePathConfig.getWeasyPrintPath(),
|
||||
null,
|
||||
htmlContent.getBytes(),
|
||||
"converted.html",
|
||||
tempFileManager,
|
||||
customHtmlSanitizer);
|
||||
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
|
||||
String outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a markdown file in the directory. Prefers index.md, otherwise returns the first .md
|
||||
* file found.
|
||||
*/
|
||||
private java.io.File findMarkdownFile(java.io.File directory) throws java.io.IOException {
|
||||
java.io.File indexMd = new java.io.File(directory, "index.md");
|
||||
if (indexMd.exists()) {
|
||||
return indexMd;
|
||||
}
|
||||
|
||||
// Search for any .md file
|
||||
try (java.util.stream.Stream<java.nio.file.Path> paths =
|
||||
java.nio.file.Files.walk(directory.toPath())) {
|
||||
return paths.filter(p -> p.toString().toLowerCase().endsWith(".md"))
|
||||
.findFirst()
|
||||
.map(java.nio.file.Path::toFile)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a ZIP file containing the HTML content and all other files (images) from the
|
||||
* directory.
|
||||
*/
|
||||
private byte[] createHtmlZip(String htmlContent, java.io.File sourceDir)
|
||||
throws java.io.IOException {
|
||||
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
|
||||
|
||||
try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) {
|
||||
// Add HTML file to root
|
||||
java.util.zip.ZipEntry htmlEntry = new java.util.zip.ZipEntry("index.html");
|
||||
zos.putNextEntry(htmlEntry);
|
||||
zos.write(htmlContent.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
zos.closeEntry();
|
||||
|
||||
// Add all other files (images, etc.)
|
||||
addDirectoryToZip(zos, sourceDir.toPath(), sourceDir.toPath());
|
||||
}
|
||||
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
/** Recursively adds files from a directory to a ZIP, excluding .md files. */
|
||||
private void addDirectoryToZip(
|
||||
java.util.zip.ZipOutputStream zos,
|
||||
java.nio.file.Path sourceDir,
|
||||
java.nio.file.Path rootDir)
|
||||
throws java.io.IOException {
|
||||
try (java.util.stream.Stream<java.nio.file.Path> paths =
|
||||
java.nio.file.Files.walk(sourceDir, 1)) {
|
||||
for (java.nio.file.Path path : paths.toList()) {
|
||||
if (java.nio.file.Files.isDirectory(path)) {
|
||||
if (!path.equals(sourceDir)) {
|
||||
addDirectoryToZip(zos, path, rootDir);
|
||||
}
|
||||
} else if (!path.toString().toLowerCase().endsWith(".md")) {
|
||||
// Add file to ZIP, maintaining relative path structure
|
||||
java.nio.file.Path relativePath = rootDir.relativize(path);
|
||||
java.util.zip.ZipEntry entry =
|
||||
new java.util.zip.ZipEntry(relativePath.toString());
|
||||
zos.putNextEntry(entry);
|
||||
java.nio.file.Files.copy(path, zos);
|
||||
zos.closeEntry();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class TableAttributeProvider implements AttributeProvider {
|
||||
|
||||
+39
-3
@@ -71,10 +71,12 @@ import org.apache.xmpbox.schema.PDFAIdentificationSchema;
|
||||
import org.apache.xmpbox.schema.XMPBasicSchema;
|
||||
import org.apache.xmpbox.xml.DomXmpParser;
|
||||
import org.apache.xmpbox.xml.XmpSerializer;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -99,6 +101,7 @@ public class ConvertPDFToPDFA {
|
||||
|
||||
private static final Pattern NON_PRINTABLE_ASCII = Pattern.compile("[^\\x20-\\x7E]");
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private final stirling.software.SPDF.service.VeraPDFService veraPDFService;
|
||||
|
||||
private static final String ICC_RESOURCE_PATH = "/icc/sRGB2014.icc";
|
||||
private static final int PDFA_COMPATIBILITY_POLICY = 1;
|
||||
@@ -587,7 +590,8 @@ public class ConvertPDFToPDFA {
|
||||
if (isPdfX) {
|
||||
return handlePdfXConversion(inputFile, outputFormat);
|
||||
} else {
|
||||
return handlePdfAConversion(inputFile, outputFormat);
|
||||
return handlePdfAConversion(
|
||||
inputFile, outputFormat, request.getStrict() != null && request.getStrict());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1793,7 +1797,7 @@ public class ConvertPDFToPDFA {
|
||||
}
|
||||
|
||||
private ResponseEntity<byte[]> handlePdfAConversion(
|
||||
MultipartFile inputFile, String outputFormat) throws Exception {
|
||||
MultipartFile inputFile, String outputFormat, boolean strict) throws Exception {
|
||||
PdfaProfile profile = PdfaProfile.fromRequest(outputFormat);
|
||||
|
||||
// Get the original filename without extension
|
||||
@@ -1822,6 +1826,10 @@ public class ConvertPDFToPDFA {
|
||||
|
||||
validateAndWarnPdfA(converted, profile, "Ghostscript");
|
||||
|
||||
if (strict) {
|
||||
verifyStrictCompliance(converted);
|
||||
}
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
converted, outputFilename, MediaType.APPLICATION_PDF);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
@@ -1839,14 +1847,42 @@ public class ConvertPDFToPDFA {
|
||||
// Validate with PDFBox preflight and warn if issues found
|
||||
validateAndWarnPdfA(converted, profile, "PDFBox/LibreOffice");
|
||||
|
||||
if (strict) {
|
||||
verifyStrictCompliance(converted);
|
||||
}
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
converted, outputFilename, MediaType.APPLICATION_PDF);
|
||||
|
||||
} finally {
|
||||
deleteQuietly(workingDir);
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyStrictCompliance(byte[] pdfBytes) throws IOException {
|
||||
try (InputStream is = new ByteArrayInputStream(pdfBytes)) {
|
||||
List<stirling.software.SPDF.model.api.security.PDFVerificationResult> results =
|
||||
veraPDFService.validatePDF(is);
|
||||
boolean isCompliant = results.stream().anyMatch(result -> result.isCompliant());
|
||||
if (!isCompliant) {
|
||||
String details =
|
||||
results.stream()
|
||||
.map(r -> r.getStandard() + ": " + r.getComplianceSummary())
|
||||
.collect(Collectors.joining("; "));
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.BAD_REQUEST,
|
||||
"Strict PDF/A mode enabled: Conversion is not perfectly compliant. Details: "
|
||||
+ details);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (e instanceof ResponseStatusException) {
|
||||
throw (ResponseStatusException) e;
|
||||
}
|
||||
log.error("Error during strict PDF/A verification", e);
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR, "Error during strict PDF/A verification");
|
||||
}
|
||||
}
|
||||
|
||||
private Path sanitizePdfWithPdfBox(Path inputPdf, boolean addWhiteBackground) {
|
||||
try {
|
||||
Path sanitizedPath =
|
||||
|
||||
+223
-6
@@ -1,7 +1,9 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -36,6 +38,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPdfJsonController {
|
||||
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
|
||||
@Autowired(required = false)
|
||||
@@ -56,10 +59,13 @@ public class ConvertPdfJsonController {
|
||||
}
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
|
||||
logJsonResponse("pdf/text-editor", jsonBytes);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
String docName = baseName + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
@@ -82,7 +88,9 @@ public class ConvertPdfJsonController {
|
||||
String originalName = jsonFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
|
||||
@@ -108,14 +116,17 @@ public class ConvertPdfJsonController {
|
||||
// Scope job to authenticated user if security is enabled
|
||||
String scopedJobKey = getScopedJobKey(baseJobId);
|
||||
|
||||
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
|
||||
log.debug("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
|
||||
|
||||
byte[] jsonBytes =
|
||||
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
|
||||
logJsonResponse("pdf/text-editor/metadata", jsonBytes);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
String docName = baseName + "_metadata.json";
|
||||
|
||||
@@ -152,7 +163,9 @@ public class ConvertPdfJsonController {
|
||||
|
||||
String baseName =
|
||||
(filename != null && !filename.isBlank())
|
||||
? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(filename))
|
||||
.replaceFirst("")
|
||||
: Optional.ofNullable(document.getMetadata())
|
||||
.map(PdfJsonMetadata::getTitle)
|
||||
.filter(title -> title != null && !title.isBlank())
|
||||
@@ -175,11 +188,33 @@ public class ConvertPdfJsonController {
|
||||
validateJobAccess(jobId);
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
|
||||
logJsonResponse("pdf/text-editor/page", jsonBytes);
|
||||
String docName = "page_" + pageNumber + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
|
||||
@GetMapping(value = "/pdf/text-editor/fonts/{jobId}/{pageNumber}")
|
||||
@Operation(
|
||||
summary = "Extract fonts used by a single cached page for text editor",
|
||||
description =
|
||||
"Retrieves the font payloads used by a single page from a previously cached PDF document."
|
||||
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
|
||||
+ " authenticated user. Output:JSON")
|
||||
public ResponseEntity<byte[]> extractPageFonts(
|
||||
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
|
||||
|
||||
// Validate job ownership
|
||||
validateJobAccess(jobId);
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.extractPageFonts(jobId, pageNumber);
|
||||
logJsonResponse("pdf/text-editor/fonts/page", jsonBytes);
|
||||
String docName = "page_fonts_" + pageNumber + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(
|
||||
value = "/pdf/text-editor/clear-cache/{jobId}",
|
||||
consumes = MediaType.ALL_VALUE)
|
||||
@Operation(
|
||||
summary = "Clear cached PDF document for text editor",
|
||||
description =
|
||||
@@ -209,6 +244,188 @@ public class ConvertPdfJsonController {
|
||||
return baseJobId;
|
||||
}
|
||||
|
||||
private void logJsonResponse(String label, byte[] jsonBytes) {
|
||||
if (jsonBytes == null) {
|
||||
log.warn("Returning {} JSON response: null bytes", label);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only perform expensive tail extraction if debug logging is enabled
|
||||
if (log.isDebugEnabled()) {
|
||||
int length = jsonBytes.length;
|
||||
boolean endsWithJson =
|
||||
length > 0 && (jsonBytes[length - 1] == '}' || jsonBytes[length - 1] == ']');
|
||||
String tail = "";
|
||||
if (length > 0) {
|
||||
int start = Math.max(0, length - 64);
|
||||
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
|
||||
tail = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
|
||||
}
|
||||
log.debug(
|
||||
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
|
||||
label,
|
||||
length,
|
||||
endsWithJson,
|
||||
tail);
|
||||
}
|
||||
|
||||
if (isPdfJsonDebugDumpEnabled()) {
|
||||
try {
|
||||
String tmpDir = System.getProperty("java.io.tmpdir");
|
||||
String customDir = System.getenv("SPDF_PDFJSON_DUMP_DIR");
|
||||
java.nio.file.Path dumpDir =
|
||||
customDir != null && !customDir.isBlank()
|
||||
? java.nio.file.Path.of(customDir)
|
||||
: java.nio.file.Path.of(tmpDir);
|
||||
java.nio.file.Path dumpPath =
|
||||
java.nio.file.Files.createTempFile(dumpDir, "pdfjson_", ".json");
|
||||
java.nio.file.Files.write(dumpPath, jsonBytes);
|
||||
log.debug("PDF JSON debug dump ({}): {}", label, dumpPath);
|
||||
} catch (Exception ex) {
|
||||
log.warn("Failed to write PDF JSON debug dump ({}): {}", label, ex.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (isPdfJsonRepeatScanEnabled()) {
|
||||
logRepeatedJsonStrings(label, jsonBytes);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPdfJsonDebugDumpEnabled() {
|
||||
String env = System.getenv("SPDF_PDFJSON_DUMP");
|
||||
if (env != null && env.equalsIgnoreCase("true")) {
|
||||
return true;
|
||||
}
|
||||
return Boolean.getBoolean("spdf.pdfjson.dump");
|
||||
}
|
||||
|
||||
private boolean isPdfJsonRepeatScanEnabled() {
|
||||
String env = System.getenv("SPDF_PDFJSON_REPEAT_SCAN");
|
||||
if (env != null && env.equalsIgnoreCase("true")) {
|
||||
return true;
|
||||
}
|
||||
return Boolean.getBoolean("spdf.pdfjson.repeatScan");
|
||||
}
|
||||
|
||||
private void logRepeatedJsonStrings(String label, byte[] jsonBytes) {
|
||||
final int minLen = 12;
|
||||
final int maxLen = 200;
|
||||
final int maxUnique = 50000;
|
||||
java.util.Map<String, Integer> counts = new java.util.HashMap<>();
|
||||
boolean inString = false;
|
||||
boolean escape = false;
|
||||
boolean tooLong = false;
|
||||
StringBuilder current = new StringBuilder(64);
|
||||
boolean capped = false;
|
||||
|
||||
for (byte b : jsonBytes) {
|
||||
char ch = (char) (b & 0xFF);
|
||||
if (!inString) {
|
||||
if (ch == '"') {
|
||||
inString = true;
|
||||
escape = false;
|
||||
tooLong = false;
|
||||
current.setLength(0);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (escape) {
|
||||
escape = false;
|
||||
if (!tooLong && current.length() < maxLen) {
|
||||
current.append(ch);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch == '\\') {
|
||||
escape = true;
|
||||
continue;
|
||||
}
|
||||
if (ch == '"') {
|
||||
inString = false;
|
||||
if (!tooLong) {
|
||||
int len = current.length();
|
||||
if (len >= minLen && len <= maxLen) {
|
||||
String value = current.toString();
|
||||
if (!looksLikeBase64(value)) {
|
||||
if (!capped || counts.containsKey(value)) {
|
||||
counts.merge(value, 1, Integer::sum);
|
||||
if (!capped && counts.size() >= maxUnique) {
|
||||
capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!tooLong) {
|
||||
if (current.length() < maxLen) {
|
||||
current.append(ch);
|
||||
} else {
|
||||
tooLong = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java.util.List<java.util.Map.Entry<String, Integer>> top =
|
||||
counts.entrySet().stream()
|
||||
.filter(e -> e.getValue() > 1)
|
||||
.sorted((a, b) -> Integer.compare(b.getValue(), a.getValue()))
|
||||
.limit(20)
|
||||
.toList();
|
||||
|
||||
if (!top.isEmpty()) {
|
||||
String summary =
|
||||
top.stream()
|
||||
.map(
|
||||
e ->
|
||||
String.format(
|
||||
"\"%s\"(len=%d,count=%d)",
|
||||
truncateForLog(e.getKey()),
|
||||
e.getKey().length(),
|
||||
e.getValue()))
|
||||
.collect(java.util.stream.Collectors.joining("; "));
|
||||
log.debug(
|
||||
"PDF JSON repeat scan ({}): top strings -> {}{}",
|
||||
label,
|
||||
summary,
|
||||
capped ? " (capped)" : "");
|
||||
} else {
|
||||
log.debug(
|
||||
"PDF JSON repeat scan ({}): no repeated strings found{}",
|
||||
label,
|
||||
capped ? " (capped)" : "");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean looksLikeBase64(String value) {
|
||||
if (value.length() < 32) {
|
||||
return false;
|
||||
}
|
||||
int base64Chars = 0;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if ((c >= 'A' && c <= 'Z')
|
||||
|| (c >= 'a' && c <= 'z')
|
||||
|| (c >= '0' && c <= '9')
|
||||
|| c == '+'
|
||||
|| c == '/'
|
||||
|| c == '=') {
|
||||
base64Chars++;
|
||||
}
|
||||
}
|
||||
return base64Chars >= value.length() * 0.9;
|
||||
}
|
||||
|
||||
private String truncateForLog(String value) {
|
||||
int max = 64;
|
||||
if (value.length() <= max) {
|
||||
return value.replaceAll("[\\r\\n\\t]+", " ");
|
||||
}
|
||||
return value.substring(0, max).replaceAll("[\\r\\n\\t]+", " ") + "...";
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the current user has access to the given job.
|
||||
*
|
||||
|
||||
+110
-16
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -8,20 +9,23 @@ import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.SPDF.service.plugin.PluginService;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.plugins.PluginDescriptorResponse;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
@@ -35,7 +39,9 @@ public class ConfigController {
|
||||
private final EndpointConfiguration endpointConfiguration;
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final UserServiceInterface userService;
|
||||
private final stirling.software.common.service.LicenseServiceInterface licenseService;
|
||||
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
|
||||
private final PluginService pluginService;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -45,13 +51,66 @@ public class ConfigController {
|
||||
ServerCertificateServiceInterface serverCertificateService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
UserServiceInterface userService,
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
stirling.software.common.service.LicenseServiceInterface licenseService,
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig,
|
||||
PluginService pluginService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.userService = userService;
|
||||
this.licenseService = licenseService;
|
||||
this.externalAppDepConfig = externalAppDepConfig;
|
||||
this.pluginService = pluginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current license type dynamically instead of from cached bean. This ensures the frontend
|
||||
* sees updated license status after admin changes the license key.
|
||||
*/
|
||||
private String getCurrentLicenseType() {
|
||||
// Use LicenseService for fresh license status if available
|
||||
if (licenseService != null) {
|
||||
return licenseService.getLicenseTypeName();
|
||||
}
|
||||
|
||||
// Fallback to cached bean if service not available
|
||||
if (applicationContext.containsBean("license")) {
|
||||
return applicationContext.getBean("license", String.class);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if running Pro or higher (SERVER or ENTERPRISE license) dynamically. */
|
||||
private Boolean isRunningProOrHigher() {
|
||||
// Use LicenseService for fresh license status if available
|
||||
if (licenseService != null) {
|
||||
return licenseService.isRunningProOrHigher();
|
||||
}
|
||||
|
||||
// Fallback to cached bean
|
||||
if (applicationContext.containsBean("runningProOrHigher")) {
|
||||
return applicationContext.getBean("runningProOrHigher", Boolean.class);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Check if running Enterprise edition dynamically. */
|
||||
private Boolean isRunningEE() {
|
||||
// Use LicenseService for fresh license status if available
|
||||
if (licenseService != null) {
|
||||
return licenseService.isRunningEE();
|
||||
}
|
||||
|
||||
// Fallback to cached bean
|
||||
if (applicationContext.containsBean("runningEE")) {
|
||||
return applicationContext.getBean("runningEE", Boolean.class);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@GetMapping("/app-config")
|
||||
@@ -101,6 +160,14 @@ public class ConfigController {
|
||||
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
|
||||
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
// User preference defaults
|
||||
configData.put(
|
||||
"defaultHideUnavailableTools",
|
||||
applicationProperties.getUi().isDefaultHideUnavailableTools());
|
||||
configData.put(
|
||||
"defaultHideUnavailableConversions",
|
||||
applicationProperties.getUi().isDefaultHideUnavailableConversions());
|
||||
|
||||
// Security settings
|
||||
// enableLogin requires both the config flag AND proprietary features to be loaded
|
||||
// If userService is null, proprietary module isn't loaded
|
||||
@@ -185,19 +252,23 @@ public class ConfigController {
|
||||
applicationProperties.getLegal().getAccessibilityStatement());
|
||||
|
||||
// Try to get EEAppConfig values if available
|
||||
// Get these dynamically to reflect current license status (not cached at startup)
|
||||
try {
|
||||
if (applicationContext.containsBean("runningProOrHigher")) {
|
||||
configData.put(
|
||||
"runningProOrHigher",
|
||||
applicationContext.getBean("runningProOrHigher", Boolean.class));
|
||||
Boolean runningProOrHigher = isRunningProOrHigher();
|
||||
if (runningProOrHigher != null) {
|
||||
configData.put("runningProOrHigher", runningProOrHigher);
|
||||
}
|
||||
if (applicationContext.containsBean("runningEE")) {
|
||||
configData.put(
|
||||
"runningEE", applicationContext.getBean("runningEE", Boolean.class));
|
||||
|
||||
Boolean runningEE = isRunningEE();
|
||||
if (runningEE != null) {
|
||||
configData.put("runningEE", runningEE);
|
||||
}
|
||||
if (applicationContext.containsBean("license")) {
|
||||
configData.put("license", applicationContext.getBean("license", String.class));
|
||||
|
||||
String licenseType = getCurrentLicenseType();
|
||||
if (licenseType != null) {
|
||||
configData.put("license", licenseType);
|
||||
}
|
||||
|
||||
if (applicationContext.containsBean("SSOAutoLogin")) {
|
||||
configData.put(
|
||||
"SSOAutoLogin",
|
||||
@@ -226,6 +297,10 @@ public class ConfigController {
|
||||
// Version/machine info not available
|
||||
}
|
||||
|
||||
// config directory path
|
||||
configData.put("basePath", InstallationPathConfig.getPath());
|
||||
configData.put("pluginsPath", InstallationPathConfig.getPluginsPath());
|
||||
|
||||
return ResponseEntity.ok(configData);
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -235,6 +310,23 @@ public class ConfigController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/plugins")
|
||||
public ResponseEntity<List<PluginDescriptorResponse>> getPlugins(HttpServletRequest request) {
|
||||
String baseUrl =
|
||||
ServletUriComponentsBuilder.fromRequestUri(request)
|
||||
.replacePath(null)
|
||||
.replaceQuery(null)
|
||||
.build()
|
||||
.toUriString();
|
||||
|
||||
List<PluginDescriptorResponse> mapped =
|
||||
pluginService.getPlugins().stream()
|
||||
.map(descriptor -> PluginDescriptorResponse.from(descriptor, baseUrl))
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(mapped);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoint-enabled")
|
||||
public ResponseEntity<Boolean> isEndpointEnabled(
|
||||
@RequestParam(name = "endpoint") String endpoint) {
|
||||
@@ -256,11 +348,13 @@ public class ConfigController {
|
||||
|
||||
@GetMapping("/endpoints-availability")
|
||||
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
|
||||
@RequestParam(name = "endpoints")
|
||||
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
|
||||
List<@NotBlank String> endpoints) {
|
||||
@RequestParam(name = "endpoints", required = false) List<String> endpoints) {
|
||||
Collection<String> toCheck =
|
||||
(endpoints == null || endpoints.isEmpty())
|
||||
? endpointConfiguration.getAllEndpoints()
|
||||
: endpoints;
|
||||
Map<String, EndpointAvailability> result = new HashMap<>();
|
||||
for (String endpoint : endpoints) {
|
||||
for (String endpoint : toCheck) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(
|
||||
trimmedEndpoint,
|
||||
|
||||
+32
-32
@@ -55,45 +55,45 @@ public class OverlayImageController {
|
||||
|
||||
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(pdfBytes);
|
||||
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
|
||||
int pages = document.getNumberOfPages();
|
||||
for (int i = 0; i < pages; i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
if (isSvg) {
|
||||
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
|
||||
} else {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject image =
|
||||
PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF page {}", i);
|
||||
}
|
||||
}
|
||||
|
||||
if (isSvg) {
|
||||
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
|
||||
} else {
|
||||
try (PDPageContentStream contentStream =
|
||||
new PDPageContentStream(
|
||||
document,
|
||||
page,
|
||||
PDPageContentStream.AppendMode.APPEND,
|
||||
true,
|
||||
true)) {
|
||||
PDImageXObject image =
|
||||
PDImageXObject.createFromByteArray(document, imageBytes, "");
|
||||
contentStream.drawImage(image, x, y);
|
||||
log.info("Image successfully overlaid onto PDF page {}", i);
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!everyPage && i == 0) {
|
||||
break;
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
log.info("PDF with overlaid image successfully created");
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(
|
||||
pdfFile.getOriginalFilename(), "_overlayed.pdf"));
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
document.save(baos);
|
||||
document.close();
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
log.info("PDF with overlaid image successfully created");
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_overlayed.pdf"));
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to add image to PDF", e);
|
||||
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
|
||||
|
||||
+10
-1
@@ -52,9 +52,17 @@ public class PageNumbersController {
|
||||
int pageNumber = request.getStartingNumber();
|
||||
String pagesToNumber = request.getPagesToNumber();
|
||||
String customText = request.getCustomText();
|
||||
int zeroPad = request.getZeroPad();
|
||||
float fontSize = request.getFontSize();
|
||||
String fontType = request.getFontType();
|
||||
String fontColor = request.getFontColor();
|
||||
// compute padded number string where requested
|
||||
String formatN;
|
||||
if (zeroPad > 0) {
|
||||
formatN = String.format("%%0%dd", Math.max(0, zeroPad));
|
||||
} else {
|
||||
formatN = "%d";
|
||||
}
|
||||
|
||||
Color color = Color.BLACK;
|
||||
if (fontColor != null && !fontColor.trim().isEmpty()) {
|
||||
@@ -93,9 +101,10 @@ public class PageNumbersController {
|
||||
PDPage page = document.getPage(i);
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
String nFormatted = String.format(formatN, pageNumber);
|
||||
String text =
|
||||
customText
|
||||
.replace("{n}", String.valueOf(pageNumber))
|
||||
.replace("{n}", nFormatted)
|
||||
.replace("{total}", String.valueOf(document.getNumberOfPages()))
|
||||
.replace(
|
||||
"{filename}",
|
||||
|
||||
+2
-1
@@ -57,6 +57,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequiredArgsConstructor
|
||||
public class StampController {
|
||||
|
||||
private static final Pattern NEWLINE_PATTERN = Pattern.compile("\\r?\\n");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -266,7 +267,7 @@ public class StampController {
|
||||
.getEscapedNewlinePattern()
|
||||
.matcher(processedStampText)
|
||||
.replaceAll("\n");
|
||||
String[] lines = normalizedText.split("\\r?\\n");
|
||||
String[] lines = NEWLINE_PATTERN.split(normalizedText);
|
||||
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
|
||||
+8
-4
@@ -21,6 +21,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -44,6 +45,7 @@ import stirling.software.common.util.FileMonitor;
|
||||
public class PipelineDirectoryProcessor {
|
||||
|
||||
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
|
||||
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("\\\\?watchedFolders");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiDocService apiDocService;
|
||||
@@ -433,10 +435,12 @@ public class PipelineDirectoryProcessor {
|
||||
|
||||
private Path determineOutputPath(PipelineConfig config, Path dir) {
|
||||
String outputDir =
|
||||
config.getOutputDir()
|
||||
.replace("{outputFolder}", finishedFoldersDir)
|
||||
.replace("{folderName}", dir.toString())
|
||||
.replaceAll("\\\\?watchedFolders", "");
|
||||
WATCHED_FOLDERS_PATTERN
|
||||
.matcher(
|
||||
config.getOutputDir()
|
||||
.replace("{outputFolder}", finishedFoldersDir)
|
||||
.replace("{folderName}", dir.toString()))
|
||||
.replaceAll("");
|
||||
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
|
||||
}
|
||||
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.plugin.PluginService;
|
||||
|
||||
@Controller
|
||||
@Slf4j
|
||||
/**
|
||||
* Serves static frontend assets embedded in plugin jars under {@code META-INF/resources/plugins}.
|
||||
*/
|
||||
public class PluginFrontendController {
|
||||
private static final String PLUGIN_RESOURCE_ROOT = "META-INF/resources/plugins/";
|
||||
|
||||
private final PluginService pluginService;
|
||||
|
||||
/**
|
||||
* @param pluginService service used to resolve plugin jar locations
|
||||
*/
|
||||
public PluginFrontendController(PluginService pluginService) {
|
||||
this.pluginService = pluginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects plugin root requests to the conventional {@code index.html} entrypoint.
|
||||
*
|
||||
* @param pluginId requested plugin identifier
|
||||
* @return permanent redirect to plugin index page
|
||||
*/
|
||||
@GetMapping("/plugins/{pluginId}")
|
||||
public ResponseEntity<Void> redirectToIndex(@PathVariable String pluginId) {
|
||||
return ResponseEntity.status(301)
|
||||
.location(URI.create("/plugins/" + pluginId + "/index.html"))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams an asset from the requested plugin jar, while validating path boundaries to avoid
|
||||
* traversal outside the plugin resource root.
|
||||
*
|
||||
* @param request incoming servlet request used to extract suffix path
|
||||
* @param pluginId requested plugin identifier
|
||||
* @return asset content when found; suitable HTTP error otherwise
|
||||
*/
|
||||
@GetMapping("/plugins/{pluginId}/**")
|
||||
public ResponseEntity<ByteArrayResource> servePluginAsset(
|
||||
HttpServletRequest request, @PathVariable String pluginId) {
|
||||
try {
|
||||
String suffix = resolveSuffix(request, pluginId);
|
||||
if (suffix == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (suffix.contains("..")) {
|
||||
log.warn(
|
||||
"[PluginFrontend] Blocked path traversal attempt for {}: {}",
|
||||
pluginId,
|
||||
suffix);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
Optional<Path> jarPath = pluginService.getPluginJarPath(pluginId);
|
||||
if (jarPath.isEmpty() || !Files.isRegularFile(jarPath.get())) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String resourcePath = PLUGIN_RESOURCE_ROOT + pluginId + suffix;
|
||||
return serveResourceFromJar(jarPath.get(), resourcePath);
|
||||
} catch (IOException e) {
|
||||
log.error("[PluginFrontend] Failed to stream plugin asset for {}", pluginId, e);
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveSuffix(HttpServletRequest request, String pluginId) {
|
||||
String contextPath = Optional.ofNullable(request.getContextPath()).orElse("");
|
||||
String requestUri = Optional.ofNullable(request.getRequestURI()).orElse("");
|
||||
String prefix = contextPath + "/plugins/" + pluginId;
|
||||
if (!requestUri.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String suffix = requestUri.substring(prefix.length());
|
||||
if (suffix.isEmpty() || "/".equals(suffix)) {
|
||||
return "/index.html";
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
private static ResponseEntity<ByteArrayResource> serveResourceFromJar(
|
||||
Path jarPath, String resourcePath) throws IOException {
|
||||
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
|
||||
JarEntry entry = jarFile.getJarEntry(resourcePath);
|
||||
if (entry == null || entry.isDirectory()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
byte[] content;
|
||||
try (InputStream stream = jarFile.getInputStream(entry)) {
|
||||
content = stream.readAllBytes();
|
||||
}
|
||||
|
||||
MediaType mediaType =
|
||||
MediaTypeFactory.getMediaType(entry.getName())
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(mediaType)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.body(new ByteArrayResource(content));
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
@@ -28,6 +29,8 @@ public class ReactRoutingController {
|
||||
|
||||
private static final org.slf4j.Logger log =
|
||||
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
|
||||
private static final Pattern BASE_HREF_PATTERN =
|
||||
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
|
||||
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
@@ -94,9 +97,9 @@ public class ReactRoutingController {
|
||||
html = html.replace("%BASE_URL%", baseUrl);
|
||||
// Also rewrite any existing <base> tag (Vite may have baked one in)
|
||||
html =
|
||||
html.replaceFirst(
|
||||
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
|
||||
"<base href=\\\"" + baseUrl + "\\\" />");
|
||||
BASE_HREF_PATTERN
|
||||
.matcher(html)
|
||||
.replaceFirst("<base href=\\\"" + baseUrl + "\\\" />");
|
||||
|
||||
// Inject context path as a global variable for API calls
|
||||
String contextPathScript =
|
||||
|
||||
+5
@@ -16,4 +16,9 @@ public class PdfToPdfARequest extends PDFFile {
|
||||
requiredMode = Schema.RequiredMode.REQUIRED,
|
||||
allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"})
|
||||
private String outputFormat;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"If true, the conversion will fail if the output is not perfectly compliant")
|
||||
private Boolean strict = false;
|
||||
}
|
||||
|
||||
+8
@@ -39,6 +39,14 @@ public class AddPageNumbersRequest extends PDFWithPageNums {
|
||||
requiredMode = RequiredMode.NOT_REQUIRED)
|
||||
private String fontColor;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Zero-padding width for page numbers (Bates Stamping). Set to 0 to disable padding",
|
||||
minimum = "0",
|
||||
defaultValue = "0",
|
||||
requiredMode = RequiredMode.NOT_REQUIRED)
|
||||
private int zeroPad = 0;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Position: 1-9 representing positions on the page (1=top-left, 2=top-center,"
|
||||
|
||||
+753
-113
File diff suppressed because it is too large
Load Diff
@@ -37,23 +37,68 @@ import stirling.software.SPDF.model.json.PdfJsonStream;
|
||||
@Component
|
||||
public class PdfJsonCosMapper {
|
||||
|
||||
public enum SerializationContext {
|
||||
DEFAULT,
|
||||
ANNOTATION_RAW_DATA,
|
||||
FORM_FIELD_RAW_DATA,
|
||||
CONTENT_STREAMS_LIGHTWEIGHT,
|
||||
RESOURCES_LIGHTWEIGHT;
|
||||
|
||||
public boolean omitStreamData() {
|
||||
return this == CONTENT_STREAMS_LIGHTWEIGHT || this == RESOURCES_LIGHTWEIGHT;
|
||||
}
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(PDStream stream) throws IOException {
|
||||
if (stream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(
|
||||
stream.getCOSObject(), Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
stream.getCOSObject(),
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(COSStream cosStream) throws IOException {
|
||||
if (cosStream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
return serializeStream(
|
||||
cosStream,
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(COSStream cosStream, SerializationContext context)
|
||||
throws IOException {
|
||||
if (cosStream == null) {
|
||||
return null;
|
||||
}
|
||||
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
|
||||
return serializeStream(
|
||||
cosStream, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(PDStream stream, SerializationContext context)
|
||||
throws IOException {
|
||||
if (stream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(stream.getCOSObject(), context);
|
||||
}
|
||||
|
||||
public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException {
|
||||
return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
return serializeCosValue(
|
||||
base,
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonCosValue serializeCosValue(COSBase base, SerializationContext context)
|
||||
throws IOException {
|
||||
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
|
||||
return serializeCosValue(
|
||||
base, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
|
||||
}
|
||||
|
||||
public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document)
|
||||
@@ -165,8 +210,8 @@ public class PdfJsonCosMapper {
|
||||
return cosStream;
|
||||
}
|
||||
|
||||
private PdfJsonCosValue serializeCosValue(COSBase base, Set<COSBase> visited)
|
||||
throws IOException {
|
||||
private PdfJsonCosValue serializeCosValue(
|
||||
COSBase base, Set<COSBase> visited, SerializationContext context) throws IOException {
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -220,21 +265,23 @@ public class PdfJsonCosMapper {
|
||||
if (base instanceof COSArray array) {
|
||||
List<PdfJsonCosValue> items = new ArrayList<>(array.size());
|
||||
for (COSBase item : array) {
|
||||
PdfJsonCosValue serialized = serializeCosValue(item, visited);
|
||||
PdfJsonCosValue serialized = serializeCosValue(item, visited, context);
|
||||
items.add(serialized);
|
||||
}
|
||||
builder.type(PdfJsonCosValue.Type.ARRAY).items(items);
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSStream stream) {
|
||||
builder.type(PdfJsonCosValue.Type.STREAM).stream(serializeStream(stream, visited));
|
||||
builder.type(PdfJsonCosValue.Type.STREAM).stream(
|
||||
serializeStream(stream, visited, context));
|
||||
return builder.build();
|
||||
}
|
||||
if (base instanceof COSDictionary dictionary) {
|
||||
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
|
||||
for (COSName key : dictionary.keySet()) {
|
||||
PdfJsonCosValue serialized =
|
||||
serializeCosValue(dictionary.getDictionaryObject(key), visited);
|
||||
serializeCosValue(
|
||||
dictionary.getDictionaryObject(key), visited, context);
|
||||
entries.put(key.getName(), serialized);
|
||||
}
|
||||
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
|
||||
@@ -248,16 +295,23 @@ public class PdfJsonCosMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private PdfJsonStream serializeStream(COSStream cosStream, Set<COSBase> visited)
|
||||
private PdfJsonStream serializeStream(
|
||||
COSStream cosStream, Set<COSBase> visited, SerializationContext context)
|
||||
throws IOException {
|
||||
Map<String, PdfJsonCosValue> dictionary = new LinkedHashMap<>();
|
||||
for (COSName key : cosStream.keySet()) {
|
||||
COSBase value = cosStream.getDictionaryObject(key);
|
||||
PdfJsonCosValue serialized = serializeCosValue(value, visited);
|
||||
PdfJsonCosValue serialized = serializeCosValue(value, visited, context);
|
||||
if (serialized != null) {
|
||||
dictionary.put(key.getName(), serialized);
|
||||
}
|
||||
}
|
||||
|
||||
if (context != null && context.omitStreamData()) {
|
||||
log.debug("Omitting stream rawData during {} serialization", context);
|
||||
return PdfJsonStream.builder().dictionary(dictionary).rawData(null).build();
|
||||
}
|
||||
|
||||
String rawData = null;
|
||||
try (InputStream inputStream = cosStream.createRawInputStream();
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
||||
|
||||
+17
-9
@@ -7,6 +7,7 @@ import java.io.InputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
@@ -310,6 +311,11 @@ public class PdfJsonFallbackFontService {
|
||||
"classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf",
|
||||
"DejaVuSansMono-BoldOblique",
|
||||
"ttf")));
|
||||
private static final Pattern BOLD_FONT_WEIGHT_PATTERN =
|
||||
Pattern.compile(".*[_-]?[6-9]00(wght)?.*");
|
||||
private static final Pattern FONT_NAME_DELIMITER_PATTERN = Pattern.compile("[-_,+]");
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
private static final Pattern PATTERN = Pattern.compile("^[A-Z]{6}\\+");
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final stirling.software.common.model.ApplicationProperties applicationProperties;
|
||||
@@ -418,16 +424,18 @@ public class PdfJsonFallbackFontService {
|
||||
// Normalize font name: remove subset prefix (e.g. "PXAAAC+"), convert to lowercase,
|
||||
// remove spaces
|
||||
String normalized =
|
||||
originalFontName
|
||||
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
|
||||
.toLowerCase()
|
||||
.replaceAll("\\s+", ""); // Remove spaces (e.g. "Times New Roman" ->
|
||||
WHITESPACE_PATTERN
|
||||
.matcher(
|
||||
PATTERN.matcher(originalFontName)
|
||||
.replaceAll("") // Remove subset prefix
|
||||
.toLowerCase())
|
||||
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
|
||||
// "timesnewroman")
|
||||
|
||||
// Extract base name without weight/style suffixes
|
||||
// Split on common delimiters: hyphen, underscore, comma, plus
|
||||
// Handles: "Arimo_700wght" -> "arimo", "Arial-Bold" -> "arial", "Arial,Bold" -> "arial"
|
||||
String baseName = normalized.split("[-_,+]")[0];
|
||||
String baseName = FONT_NAME_DELIMITER_PATTERN.split(normalized)[0];
|
||||
|
||||
String aliasedFontId = FONT_NAME_ALIASES.get(baseName);
|
||||
if (aliasedFontId != null) {
|
||||
@@ -470,7 +478,7 @@ public class PdfJsonFallbackFontService {
|
||||
|
||||
// Check for numeric weight indicators (600-900 = bold)
|
||||
// Handles: "Arimo_700wght", "Arial-700", "Font-w700"
|
||||
if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) {
|
||||
if (BOLD_FONT_WEIGHT_PATTERN.matcher(normalizedFontName).matches()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -514,7 +522,7 @@ public class PdfJsonFallbackFontService {
|
||||
// Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono)
|
||||
boolean isSupported =
|
||||
baseFontId.startsWith("fallback-liberation-")
|
||||
|| baseFontId.equals("fallback-noto-sans")
|
||||
|| "fallback-noto-sans".equals(baseFontId)
|
||||
|| baseFontId.startsWith("fallback-dejavu-");
|
||||
|
||||
if (!isSupported) {
|
||||
@@ -523,8 +531,8 @@ public class PdfJsonFallbackFontService {
|
||||
|
||||
// DejaVu Sans and Mono use "oblique" instead of "italic"
|
||||
boolean useOblique =
|
||||
baseFontId.equals("fallback-dejavu-sans")
|
||||
|| baseFontId.equals("fallback-dejavu-mono");
|
||||
"fallback-dejavu-sans".equals(baseFontId)
|
||||
|| "fallback-dejavu-mono".equals(baseFontId);
|
||||
|
||||
if (isBold && isItalic) {
|
||||
return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic");
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -27,6 +28,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
|
||||
@Slf4j
|
||||
public class SharedSignatureService {
|
||||
|
||||
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -105,7 +107,7 @@ public class SharedSignatureService {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
|
||||
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
|
||||
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||
}
|
||||
}
|
||||
@@ -113,7 +115,7 @@ public class SharedSignatureService {
|
||||
private String validateAndNormalizeExtension(String extension) {
|
||||
String normalized = extension.toLowerCase().trim();
|
||||
// Whitelist only safe image extensions
|
||||
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
|
||||
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported image extension: " + extension);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package stirling.software.SPDF.service.plugin;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.plugins.PluginDescriptor;
|
||||
import stirling.software.common.plugins.PluginLoader;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PluginService {
|
||||
private final List<PluginDescriptor> plugins;
|
||||
private final Map<String, Path> pluginJarPaths;
|
||||
|
||||
public PluginService() {
|
||||
List<Path> jars = PluginLoader.listPluginJars();
|
||||
Map<String, Path> jarMap = new LinkedHashMap<>();
|
||||
List<PluginDescriptor> descriptors = new java.util.ArrayList<>();
|
||||
|
||||
for (Path jar : jars) {
|
||||
PluginDescriptor descriptor = PluginLoader.loadDescriptor(jar);
|
||||
if (descriptor == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String pluginId = descriptor.getId();
|
||||
if (jarMap.containsKey(pluginId)) {
|
||||
log.warn(
|
||||
"Duplicate plugin id '{}' detected in {}. Keeping first jar at {}",
|
||||
pluginId,
|
||||
jar,
|
||||
jarMap.get(pluginId));
|
||||
continue;
|
||||
}
|
||||
|
||||
descriptors.add(descriptor);
|
||||
jarMap.put(pluginId, jar);
|
||||
}
|
||||
|
||||
this.plugins = Collections.unmodifiableList(descriptors);
|
||||
this.pluginJarPaths = Collections.unmodifiableMap(jarMap);
|
||||
}
|
||||
|
||||
public List<PluginDescriptor> getPlugins() {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
public Optional<Path> getPluginJarPath(String pluginId) {
|
||||
return Optional.ofNullable(pluginJarPaths.get(pluginId));
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,11 @@ spring.mvc.problemdetails.enabled=true
|
||||
#logging.level.org.springframework=DEBUG
|
||||
#logging.level.org.springframework.security=DEBUG
|
||||
|
||||
spring.servlet.multipart.max-file-size=2000MB
|
||||
spring.servlet.multipart.max-request-size=2000MB
|
||||
# Multipart file size limits
|
||||
# Can be set via environment variables: SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE and SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE
|
||||
# Or via SYSTEMFILEUPLOADLIMIT/SYSTEM_MAXFILESIZE which will also set fileUploadLimit in settings.yml
|
||||
spring.servlet.multipart.max-file-size=${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:2000MB}
|
||||
spring.servlet.multipart.max-request-size=${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:2000MB}
|
||||
server.servlet.session.tracking-modes=cookie
|
||||
server.servlet.context-path=${SYSTEM_ROOTURIPATH:/}
|
||||
spring.devtools.restart.enabled=true
|
||||
@@ -35,6 +38,7 @@ spring.devtools.livereload.enabled=true
|
||||
spring.devtools.restart.exclude=stirling.software.proprietary.security/**
|
||||
spring.web.resources.mime-mappings.webmanifest=application/manifest+json
|
||||
spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
|
||||
server.tomcat.max-http-header-size=32768
|
||||
|
||||
spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL
|
||||
spring.datasource.driver-class-name=org.h2.Driver
|
||||
|
||||
@@ -64,7 +64,10 @@ security:
|
||||
persistence: true # Set to 'true' to enable JWT key store
|
||||
enableKeyRotation: true # Set to 'true' to enable key pair rotation
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
||||
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
|
||||
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
|
||||
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
|
||||
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
@@ -230,10 +233,12 @@ ui:
|
||||
appNameNavbar: "" # name displayed on the navigation bar
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
|
||||
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
|
||||
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
|
||||
|
||||
endpoints:
|
||||
toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
|
||||
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice'])
|
||||
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice', 'DeveloperTools', 'DeveloperDocs', 'Automation'])
|
||||
|
||||
metrics:
|
||||
enabled: true # 'true' to enable Info APIs (`/api/*`) endpoints, 'false' to disable
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
{
|
||||
"dependencies": [
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-classic",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.5.22",
|
||||
"moduleLicense": "GNU Lesser General Public License",
|
||||
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-classic",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
@@ -7,6 +14,13 @@
|
||||
"moduleLicense": "GNU Lesser General Public License",
|
||||
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-core",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
"moduleVersion": "1.5.22",
|
||||
"moduleLicense": "GNU Lesser General Public License",
|
||||
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "ch.qos.logback:logback-core",
|
||||
"moduleUrl": "http://www.qos.ch",
|
||||
@@ -358,6 +372,13 @@
|
||||
"moduleLicense": "CDDL+GPL License",
|
||||
"moduleLicenseUrl": "http://glassfish.java.net/public/CDDL+GPL_1_1.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sun.xml.bind:jaxb-core",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sun.xml.bind:jaxb-impl",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
@@ -365,6 +386,13 @@
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.sun.xml.bind:jaxb-impl",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
"moduleVersion": "4.0.6",
|
||||
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
|
||||
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "com.twelvemonkeys.common:common-image",
|
||||
"moduleVersion": "3.13.0",
|
||||
@@ -758,6 +786,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
|
||||
"moduleVersion": "2.2.42",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
|
||||
@@ -765,6 +800,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-core-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-core",
|
||||
"moduleVersion": "2.2.42",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
|
||||
@@ -772,6 +814,13 @@
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "io.swagger.core.v3:swagger-models-jakarta",
|
||||
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-models",
|
||||
"moduleVersion": "2.2.42",
|
||||
"moduleLicense": "Apache License, Version 2.0",
|
||||
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
|
||||
},
|
||||
{
|
||||
"moduleName": "jakarta.activation:jakarta.activation-api",
|
||||
"moduleUrl": "https://www.eclipse.org",
|
||||
@@ -898,6 +947,13 @@
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://oss.oracle.com/licenses/CDDL+GPL-1.1"
|
||||
},
|
||||
{
|
||||
"moduleName": "javax.xml.bind:jaxb-api",
|
||||
"moduleUrl": "http://www.oracle.com/",
|
||||
"moduleVersion": "2.4.0-b180830.0359",
|
||||
"moduleLicense": "GPL2 w/ CPE",
|
||||
"moduleLicenseUrl": "https://oss.oracle.com/licenses/CDDL+GPL-1.1"
|
||||
},
|
||||
{
|
||||
"moduleName": "net.bytebuddy:byte-buddy",
|
||||
"moduleVersion": "1.17.8",
|
||||
@@ -1165,6 +1221,13 @@
|
||||
"moduleLicense": "The MIT License",
|
||||
"moduleLicenseUrl": "http://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.checkerframework:checker-qual",
|
||||
"moduleUrl": "https://checkerframework.org/",
|
||||
"moduleVersion": "3.52.0",
|
||||
"moduleLicense": "The MIT License",
|
||||
"moduleLicenseUrl": "http://opensource.org/licenses/MIT"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.commonmark:commonmark",
|
||||
"moduleVersion": "0.27.0",
|
||||
@@ -1696,6 +1759,13 @@
|
||||
"moduleLicense": "BSD-2-Clause",
|
||||
"moduleLicenseUrl": "https://jdbc.postgresql.org/about/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.postgresql:postgresql",
|
||||
"moduleUrl": "https://jdbc.postgresql.org/",
|
||||
"moduleVersion": "42.7.9",
|
||||
"moduleLicense": "BSD-2-Clause",
|
||||
"moduleLicenseUrl": "https://jdbc.postgresql.org/about/license.html"
|
||||
},
|
||||
{
|
||||
"moduleName": "org.simplejavamail:core-module",
|
||||
"moduleVersion": "8.12.6",
|
||||
|
||||
+20
-8
@@ -85,10 +85,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getBody());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertEquals(1, result.size());
|
||||
|
||||
Map<String, Object> bookmark = result.get(0);
|
||||
@@ -107,10 +110,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockCatalog.getDocumentOutline()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getBody());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertTrue(result.isEmpty());
|
||||
verify(mockDocument).close();
|
||||
}
|
||||
@@ -141,10 +147,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(childItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getBody());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertEquals(1, result.size());
|
||||
|
||||
Map<String, Object> parentBookmark = result.get(0);
|
||||
@@ -177,10 +186,13 @@ class EditTableOfContentsControllerTest {
|
||||
when(mockOutlineItem.getNextSibling()).thenReturn(null);
|
||||
|
||||
// When
|
||||
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
|
||||
ResponseEntity<List<Map<String, Object>>> response =
|
||||
editTableOfContentsController.extractBookmarks(mockFile);
|
||||
|
||||
// Then
|
||||
assertNotNull(result);
|
||||
assertNotNull(response);
|
||||
assertNotNull(response.getBody());
|
||||
List<Map<String, Object>> result = response.getBody();
|
||||
assertEquals(1, result.size());
|
||||
|
||||
Map<String, Object> bookmark = result.get(0);
|
||||
|
||||
+4
-2
@@ -18,6 +18,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -46,6 +47,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
public class ConvertWebsiteToPdfTest {
|
||||
|
||||
private static final Pattern PDF_FILENAME_PATTERN = Pattern.compile("[A-Za-z0-9_]+\\.pdf");
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@@ -142,7 +144,7 @@ public class ConvertWebsiteToPdfTest {
|
||||
|
||||
assertTrue(out.endsWith(".pdf"));
|
||||
// Only A–Z, a–z, 0–9, underscore and dot allowed
|
||||
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
|
||||
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
|
||||
// no truncation here (source not that long)
|
||||
assertTrue(out.length() <= 54);
|
||||
}
|
||||
@@ -159,7 +161,7 @@ public class ConvertWebsiteToPdfTest {
|
||||
String out = (String) m.invoke(sut, longUrl);
|
||||
|
||||
assertTrue(out.endsWith(".pdf"));
|
||||
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
|
||||
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
|
||||
// safeName limited to 50 -> total max 54 including '.pdf'
|
||||
assertTrue(out.length() <= 54, "Filename should be truncated to 50 + '.pdf'");
|
||||
}
|
||||
|
||||
+25
-9
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
@@ -26,6 +27,18 @@ import stirling.software.common.util.TempFileManager;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StampControllerTest {
|
||||
|
||||
private static final Pattern UUID_HEX_PATTERN = Pattern.compile("[0-9a-f]{8}");
|
||||
private static final Pattern DATE_LITERAL_REGEX =
|
||||
Pattern.compile("@date is \\d{4}-\\d{2}-\\d{2}");
|
||||
private static final Pattern DATE_TIME_MIN_PATTERN =
|
||||
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}");
|
||||
private static final Pattern DATE_SLASH_PATTERN = Pattern.compile("\\d{2}/\\d{2}/\\d{4}");
|
||||
private static final Pattern DAY_LABEL_PATTERN = Pattern.compile("Day: \\d{2}");
|
||||
private static final Pattern MONTH_LABEL_PATTERN = Pattern.compile("Month: \\d{2}");
|
||||
private static final Pattern DATE_TIME_FULL_PATTERN =
|
||||
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}");
|
||||
private static final Pattern TIME_LABEL_PATTERN = Pattern.compile("Time: \\d{2}:\\d{2}:\\d{2}");
|
||||
private static final Pattern DATE_LABEL_PATTERN = Pattern.compile("Date: \\d{4}-\\d{2}-\\d{2}");
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@@ -173,7 +186,7 @@ class StampControllerTest {
|
||||
void testDateReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
|
||||
DATE_LABEL_PATTERN.matcher(result).matches(),
|
||||
"Date should match YYYY-MM-DD format");
|
||||
}
|
||||
|
||||
@@ -182,7 +195,7 @@ class StampControllerTest {
|
||||
void testTimeReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
|
||||
TIME_LABEL_PATTERN.matcher(result).matches(),
|
||||
"Time should match HH:mm:ss format");
|
||||
}
|
||||
|
||||
@@ -192,7 +205,7 @@ class StampControllerTest {
|
||||
String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
|
||||
// DateTime format: YYYY-MM-DD HH:mm:ss
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
|
||||
DATE_TIME_FULL_PATTERN.matcher(result).matches(),
|
||||
"DateTime should match YYYY-MM-DD HH:mm:ss format");
|
||||
}
|
||||
|
||||
@@ -208,14 +221,15 @@ class StampControllerTest {
|
||||
@DisplayName("Should replace @month with zero-padded month")
|
||||
void testMonthReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
|
||||
assertTrue(
|
||||
MONTH_LABEL_PATTERN.matcher(result).matches(), "Month should be zero-padded");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @day with zero-padded day")
|
||||
void testDayReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
|
||||
assertTrue(DAY_LABEL_PATTERN.matcher(result).matches(), "Day should be zero-padded");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +242,7 @@ class StampControllerTest {
|
||||
void testCustomDateFormatSlash() throws Exception {
|
||||
String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{2}/\\d{2}/\\d{4}"),
|
||||
DATE_SLASH_PATTERN.matcher(result).matches(),
|
||||
"Should match dd/MM/yyyy format: " + result);
|
||||
}
|
||||
|
||||
@@ -238,7 +252,7 @@ class StampControllerTest {
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
|
||||
DATE_TIME_MIN_PATTERN.matcher(result).matches(),
|
||||
"Should match yyyy-MM-dd HH:mm format: " + result);
|
||||
}
|
||||
|
||||
@@ -345,7 +359,7 @@ class StampControllerTest {
|
||||
// @@date should become @date, and @date should be replaced with actual date
|
||||
assertTrue(result.startsWith("@date is "), "Should start with literal @date");
|
||||
assertTrue(
|
||||
result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
|
||||
DATE_LITERAL_REGEX.matcher(result).matches(),
|
||||
"Should have date after: " + result);
|
||||
}
|
||||
|
||||
@@ -463,7 +477,9 @@ class StampControllerTest {
|
||||
@DisplayName("UUID should contain only hex characters")
|
||||
void testUuidFormat() throws Exception {
|
||||
String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
|
||||
assertTrue(
|
||||
UUID_HEX_PATTERN.matcher(result).matches(),
|
||||
"UUID should be 8 hex characters: " + result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-mail'
|
||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||
api 'com.github.ben-manes.caffeine:caffeine'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.41'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.42'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
|
||||
|
||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||
@@ -62,7 +62,7 @@ dependencies {
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
|
||||
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database
|
||||
runtimeOnly 'org.postgresql:postgresql:42.7.8'
|
||||
runtimeOnly 'org.postgresql:postgresql:42.7.9'
|
||||
constraints {
|
||||
implementation "org.opensaml:opensaml-core:$openSamlVersion"
|
||||
implementation "org.opensaml:opensaml-saml-api:$openSamlVersion"
|
||||
|
||||
+3
-3
@@ -139,6 +139,7 @@ public class ProprietaryUIDataController {
|
||||
|
||||
// Add enableLogin flag so frontend doesn't need to call /app-config
|
||||
data.setEnableLogin(securityProps.isEnableLogin());
|
||||
data.setSsoAutoLogin(applicationProperties.getPremium().getProFeatures().isSsoAutoLogin());
|
||||
|
||||
// Check if this is first-time setup with default credentials
|
||||
// The isFirstLogin flag captures: default username/password usage and unchanged state
|
||||
@@ -218,9 +219,7 @@ public class ProprietaryUIDataController {
|
||||
String backendUrl = getBackendBaseUrl();
|
||||
String fullSamlPath = backendUrl + saml2AuthenticationPath;
|
||||
|
||||
if (!applicationProperties.getPremium().getProFeatures().isSsoAutoLogin()) {
|
||||
providerList.put(fullSamlPath, samlIdp + " (SAML 2)");
|
||||
}
|
||||
providerList.put(fullSamlPath, samlIdp + " (SAML 2)");
|
||||
}
|
||||
|
||||
// Remove null entries
|
||||
@@ -533,6 +532,7 @@ public class ProprietaryUIDataController {
|
||||
@Data
|
||||
public static class LoginData {
|
||||
private Boolean enableLogin;
|
||||
private boolean ssoAutoLogin;
|
||||
private Map<String, String> providerList;
|
||||
private String loginMethod;
|
||||
private boolean altLogin;
|
||||
|
||||
+30
@@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -104,12 +105,40 @@ public class FormFillController {
|
||||
|
||||
requirePdf(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
FormUtils.FormFieldExtraction extraction =
|
||||
FormUtils.extractFieldsWithTemplate(document);
|
||||
return ResponseEntity.ok(extraction);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/fields-with-coordinates", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Inspect PDF form fields with widget coordinates",
|
||||
description =
|
||||
"Returns metadata describing each field in the provided PDF form, "
|
||||
+ "including precise widget coordinates for interactive rendering")
|
||||
public ResponseEntity<List<FormFieldWithCoordinates>> listFieldsWithCoordinates(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file)
|
||||
throws IOException {
|
||||
|
||||
requirePdf(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(document);
|
||||
return ResponseEntity.ok(fields);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Modify existing form fields",
|
||||
@@ -215,6 +244,7 @@ public class FormFillController {
|
||||
|
||||
String baseName = buildBaseName(file, suffix);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
processor.accept(document);
|
||||
return saveDocument(document, baseName);
|
||||
}
|
||||
|
||||
+10
-3
@@ -2,7 +2,7 @@ package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
@@ -11,15 +11,22 @@ import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
@Value("${security.jwt.keyRetentionDays}")
|
||||
private int keyRetentionDays;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Autowired
|
||||
public CacheConfig(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
||||
cacheManager.setCaffeine(
|
||||
Caffeine.newBuilder()
|
||||
|
||||
+2
-1
@@ -361,7 +361,8 @@ public class SecurityConfiguration {
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
licenseSettingsService,
|
||||
applicationProperties))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package stirling.software.proprietary.security.configuration.ee;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.service.LicenseServiceInterface;
|
||||
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
|
||||
|
||||
/**
|
||||
* Service that provides dynamic license checking instead of cached beans. This ensures that when
|
||||
* admins update the license key, the changes are immediately reflected in the UI and config
|
||||
* endpoints without requiring a restart.
|
||||
*
|
||||
* <p>Note: Some components (EnterpriseEndpointAspect, PremiumEndpointAspect, filters) still inject
|
||||
* cached beans at startup for performance. These will require a restart to reflect license changes.
|
||||
* This is acceptable because: 1. Most deployments add licenses during initial setup 2. License
|
||||
* changes in production typically warrant a restart anyway 3. UI reflects changes immediately
|
||||
* (banner disappears, license status updates)
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DynamicLicenseService implements LicenseServiceInterface {
|
||||
|
||||
private final LicenseKeyChecker licenseKeyChecker;
|
||||
|
||||
/**
|
||||
* Get the current license type dynamically (not cached).
|
||||
*
|
||||
* @return Current license: NORMAL, SERVER, or ENTERPRISE
|
||||
*/
|
||||
public License getCurrentLicense() {
|
||||
return licenseKeyChecker.getPremiumLicenseEnabledResult();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunningProOrHigher() {
|
||||
License license = getCurrentLicense();
|
||||
return license == License.SERVER || license == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isRunningEE() {
|
||||
return getCurrentLicense() == License.ENTERPRISE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLicenseTypeName() {
|
||||
return getCurrentLicense().name();
|
||||
}
|
||||
}
|
||||
+14
-2
@@ -309,10 +309,16 @@ public class AdminLicenseController {
|
||||
}
|
||||
|
||||
try {
|
||||
log.info(
|
||||
"License upload: original filename='{}', size={} bytes, contentType='{}'",
|
||||
file.getOriginalFilename(),
|
||||
file.getSize(),
|
||||
file.getContentType());
|
||||
// Validate certificate format by reading content
|
||||
byte[] fileBytes = file.getBytes();
|
||||
String content = new String(fileBytes, StandardCharsets.UTF_8);
|
||||
if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) {
|
||||
log.warn("License upload rejected: invalid certificate header");
|
||||
return ResponseEntity.badRequest()
|
||||
.body(
|
||||
Map.of(
|
||||
@@ -324,9 +330,15 @@ public class AdminLicenseController {
|
||||
|
||||
// Get config directory and target path
|
||||
Path configPath = Paths.get(InstallationPathConfig.getConfigPath());
|
||||
Path targetPath = configPath.resolve(filename).normalize();
|
||||
Path configPathAbs = configPath.toAbsolutePath().normalize();
|
||||
Path targetPath = configPathAbs.resolve(filename).normalize();
|
||||
log.info(
|
||||
"License upload paths: configPath='{}', targetPath='{}'",
|
||||
configPathAbs,
|
||||
targetPath.toAbsolutePath());
|
||||
// Prevent directory traversal: ensure targetPath is inside configPath
|
||||
if (!targetPath.startsWith(configPath.normalize().toAbsolutePath())) {
|
||||
if (!targetPath.startsWith(configPathAbs)) {
|
||||
log.warn("License upload rejected: target path outside config path");
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("success", false, "error", "Invalid file path"));
|
||||
}
|
||||
|
||||
+208
-10
@@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
@@ -34,12 +35,15 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
|
||||
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.RefreshRateLimitService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||
|
||||
/** REST API Controller for authentication operations. */
|
||||
@RestController
|
||||
@@ -55,7 +59,9 @@ public class AuthController {
|
||||
private final LoginAttemptService loginAttemptService;
|
||||
private final MfaService mfaService;
|
||||
private final TotpService totpService;
|
||||
private final RefreshRateLimitService refreshRateLimitService;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
/**
|
||||
* Login endpoint - replaces Supabase signInWithPassword
|
||||
@@ -171,16 +177,52 @@ public class AuthController {
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||
// Detect desktop client and issue longer-lived tokens for better UX
|
||||
// Desktop apps run on personal devices with OS-level encryption (secure storage)
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(httpRequest);
|
||||
String token;
|
||||
int keyRetentionDays = securityProperties.getJwt().getKeyRetentionDays();
|
||||
if (isDesktopClient) {
|
||||
// Desktop: Use configured desktop token expiry (default 30 days)
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
|
||||
token = jwtService.generateToken(user.getUsername(), claims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Issued DESKTOP token for user '{}': expiry={}min ({}d), keyRetention={}d",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440,
|
||||
keyRetentionDays);
|
||||
} else {
|
||||
// Web: Use configured web expiry (default 24 hours)
|
||||
token = jwtService.generateToken(user.getUsername(), claims);
|
||||
int webExpiryMinutes =
|
||||
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
|
||||
log.info(
|
||||
"Issued WEB token for user '{}': expiry={}min ({}d), keyRetention={}d",
|
||||
username,
|
||||
webExpiryMinutes,
|
||||
webExpiryMinutes / 1440,
|
||||
keyRetentionDays);
|
||||
}
|
||||
|
||||
// Record successful login
|
||||
loginAttemptService.loginSucceeded(username);
|
||||
log.info("Login successful for user: {} from IP: {}", username, ip);
|
||||
log.info(
|
||||
"Login successful for user: {} from IP: {} (desktop: {})",
|
||||
username,
|
||||
ip,
|
||||
isDesktopClient);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
token,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds(isDesktopClient))));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
String username = request.getUsername();
|
||||
@@ -272,25 +314,92 @@ public class AuthController {
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
// Generate token hash for rate limiting (avoid storing actual tokens)
|
||||
String tokenHash = generateTokenHash(token);
|
||||
|
||||
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
|
||||
if (!isRefreshWithinGrace(claims)) {
|
||||
log.warn("Token refresh rejected: token expired beyond configured grace window");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
|
||||
// Only apply rate limiting if token is actually expired (not for valid tokens)
|
||||
// This prevents false-positive 429 errors with multiple tabs, retries, etc.
|
||||
long expMillis = extractEpochMillis(claims.get("exp"));
|
||||
boolean isExpired = expMillis > 0 && expMillis < System.currentTimeMillis();
|
||||
if (isExpired
|
||||
&& !refreshRateLimitService.isRefreshAllowed(
|
||||
tokenHash, getRefreshGraceMillis())) {
|
||||
log.warn(
|
||||
"Token refresh rejected: rate limit exceeded (max {} attempts allowed)",
|
||||
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.body(
|
||||
Map.of(
|
||||
"error",
|
||||
"Too many refresh attempts",
|
||||
"max_attempts",
|
||||
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE));
|
||||
}
|
||||
|
||||
Object usernameClaim = claims.get("sub");
|
||||
String username = usernameClaim != null ? usernameClaim.toString() : null;
|
||||
if (username == null || username.isBlank()) {
|
||||
log.warn("Token refresh rejected: missing subject claim");
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
}
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", user.getAuthenticationType());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
Map<String, Object> newClaims = new HashMap<>();
|
||||
newClaims.put("authType", user.getAuthenticationType());
|
||||
newClaims.put("role", user.getRolesAsString());
|
||||
|
||||
String newToken = jwtService.generateToken(username, claims);
|
||||
// Detect desktop client and issue longer-lived tokens
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
|
||||
String newToken;
|
||||
if (isDesktopClient) {
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
|
||||
newToken = jwtService.generateToken(username, newClaims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Refreshed DESKTOP token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440);
|
||||
} else {
|
||||
newToken = jwtService.generateToken(username, newClaims);
|
||||
int webExpiryMinutes =
|
||||
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
|
||||
log.info(
|
||||
"Refreshed WEB token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
webExpiryMinutes,
|
||||
webExpiryMinutes / 1440);
|
||||
}
|
||||
|
||||
// Don't clear rate limit tracking - let it expire naturally after grace period
|
||||
// This prevents reusing the same expired token indefinitely
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", newToken, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
newToken,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds(isDesktopClient))));
|
||||
|
||||
} catch (AuthenticationFailureException e) {
|
||||
log.warn("Token refresh failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(Map.of("error", "Token refresh failed"));
|
||||
} catch (Exception e) {
|
||||
log.error("Token refresh error", e);
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
@@ -532,6 +641,95 @@ public class AuthController {
|
||||
return userMap;
|
||||
}
|
||||
|
||||
private long getTokenExpirySeconds() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes =
|
||||
configuredMinutes > 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
return expiryMinutes * JwtConstants.SECONDS_PER_MINUTE;
|
||||
}
|
||||
|
||||
private long getTokenExpirySeconds(boolean isDesktop) {
|
||||
if (isDesktop) {
|
||||
// Desktop: use configured desktop token expiry
|
||||
return DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties)
|
||||
* JwtConstants.SECONDS_PER_MINUTE;
|
||||
}
|
||||
// Web: use configured web value
|
||||
return getTokenExpirySeconds();
|
||||
}
|
||||
|
||||
private boolean isRefreshWithinGrace(Map<String, Object> claims) {
|
||||
long expMillis = extractEpochMillis(claims.get("exp"));
|
||||
if (expMillis <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
if (expMillis >= now) {
|
||||
return true;
|
||||
}
|
||||
|
||||
long expiredForMillis = now - expMillis;
|
||||
return expiredForMillis <= getRefreshGraceMillis();
|
||||
}
|
||||
|
||||
private long getRefreshGraceMillis() {
|
||||
int configuredMinutes = securityProperties.getJwt().getRefreshGraceMinutes();
|
||||
int graceMinutes =
|
||||
configuredMinutes >= 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
|
||||
return graceMinutes * JwtConstants.MILLIS_PER_MINUTE;
|
||||
}
|
||||
|
||||
private long extractEpochMillis(Object claimValue) {
|
||||
if (claimValue == null) {
|
||||
return -1L;
|
||||
}
|
||||
|
||||
if (claimValue instanceof java.util.Date date) {
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
if (claimValue instanceof Number number) {
|
||||
long epochSeconds = number.longValue();
|
||||
return epochSeconds * 1000L;
|
||||
}
|
||||
|
||||
return -1L;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a hash of the token for rate limiting purposes.
|
||||
*
|
||||
* <p>Uses SHA-256 to avoid storing actual token values in memory.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return hex-encoded SHA-256 hash of the token
|
||||
*/
|
||||
private String generateTokenHash(String token) {
|
||||
try {
|
||||
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||
byte[] hashBytes =
|
||||
digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
StringBuilder hexString = new StringBuilder();
|
||||
for (byte b : hashBytes) {
|
||||
String hex = Integer.toHexString(0xff & b);
|
||||
if (hex.length() == 1) {
|
||||
hexString.append('0');
|
||||
}
|
||||
hexString.append(hex);
|
||||
}
|
||||
return hexString.toString();
|
||||
} catch (java.security.NoSuchAlgorithmException e) {
|
||||
// Fallback to hashCode if SHA-256 is not available (should never happen)
|
||||
log.warn("SHA-256 not available, using hashCode for token tracking", e);
|
||||
return String.valueOf(token.hashCode());
|
||||
}
|
||||
}
|
||||
|
||||
private ResponseEntity<?> ensureWebAuth(User user) {
|
||||
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
|
||||
+3
-1
@@ -9,6 +9,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -36,6 +37,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
|
||||
@RequiredArgsConstructor
|
||||
public class UIDataTessdataController {
|
||||
|
||||
private static final Pattern INVALID_LANG_CHARS_PATTERN = Pattern.compile("[^A-Za-z0-9_+\\-]");
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private static volatile List<String> cachedRemoteTessdata = null;
|
||||
private static volatile long cachedRemoteTessdataExpiry = 0L;
|
||||
@@ -88,7 +90,7 @@ public class UIDataTessdataController {
|
||||
failed.add(language);
|
||||
continue;
|
||||
}
|
||||
String safeLang = language.replaceAll("[^A-Za-z0-9_+\\-]", "");
|
||||
String safeLang = INVALID_LANG_CHARS_PATTERN.matcher(language).replaceAll("");
|
||||
if (!safeLang.equals(language)) {
|
||||
failed.add(language);
|
||||
continue;
|
||||
|
||||
+18
-17
@@ -69,27 +69,28 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||
if (!apiKeyExists(request, response)) {
|
||||
String jwtToken = jwtService.extractToken(request);
|
||||
|
||||
if (jwtToken == null) {
|
||||
// Allow auth endpoints to pass through without JWT
|
||||
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
|
||||
// For API requests, return 401 JSON
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (requestURI.startsWith(contextPath + "/api/")
|
||||
|| (acceptHeader != null
|
||||
&& acceptHeader.contains("application/json"))) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Authentication required\"}");
|
||||
return;
|
||||
}
|
||||
// Check if this is a public endpoint BEFORE validating JWT
|
||||
// This allows public endpoints to work even with expired tokens in the request
|
||||
if (isPublicAuthEndpoint(requestURI, contextPath)) {
|
||||
// For public auth endpoints, skip JWT validation and continue
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
// For HTML requests (SPA routes), let React Router handle it (serve
|
||||
// index.html)
|
||||
filterChain.doFilter(request, response);
|
||||
if (jwtToken == null) {
|
||||
// No JWT token and not a public endpoint
|
||||
// For API requests, return 401 JSON
|
||||
String acceptHeader = request.getHeader("Accept");
|
||||
if (requestURI.startsWith(contextPath + "/api/")
|
||||
|| (acceptHeader != null && acceptHeader.contains("application/json"))) {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json");
|
||||
response.getWriter().write("{\"error\":\"Authentication required\"}");
|
||||
return;
|
||||
}
|
||||
|
||||
// For public auth endpoints without JWT, continue to the endpoint
|
||||
// For HTML requests (SPA routes), let React Router handle it (serve
|
||||
// index.html)
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
+23
-3
@@ -36,6 +36,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
@@ -48,6 +49,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
private final JwtServiceInterface jwtService;
|
||||
private final stirling.software.proprietary.service.UserLicenseSettingsService
|
||||
licenseSettingsService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Override
|
||||
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
|
||||
@@ -150,9 +152,27 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
Map<String, Object> claims = Map.of("authType", AuthenticationType.OAUTH2);
|
||||
|
||||
// Detect desktop client and issue longer-lived tokens
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
|
||||
String jwt;
|
||||
if (isDesktopClient) {
|
||||
// Desktop: Use configured desktop token expiry (default 30 days)
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(
|
||||
applicationProperties);
|
||||
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Issued DESKTOP OAuth2 token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440);
|
||||
} else {
|
||||
// Web: Use default expiry
|
||||
jwt = jwtService.generateToken(authentication, claims);
|
||||
log.debug("Issued WEB OAuth2 token for user '{}'", username);
|
||||
}
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
|
||||
+40
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
|
||||
|
||||
@Slf4j
|
||||
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
|
||||
@@ -33,9 +34,33 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica
|
||||
|
||||
if (exception instanceof Saml2AuthenticationException) {
|
||||
Saml2Error error = ((Saml2AuthenticationException) exception).getSaml2Error();
|
||||
if (TauriSamlUtils.isTauriRelayState(request)) {
|
||||
String redirectUrl =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", error.getErrorCode());
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
return;
|
||||
}
|
||||
getRedirectStrategy()
|
||||
.sendRedirect(request, response, "/login?errorOAuth=" + error.getErrorCode());
|
||||
} else if (exception instanceof ProviderNotFoundException) {
|
||||
if (TauriSamlUtils.isTauriRelayState(request)) {
|
||||
String redirectUrl =
|
||||
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
|
||||
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
|
||||
if (nonce != null) {
|
||||
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
|
||||
}
|
||||
redirectUrl =
|
||||
appendQueryParam(
|
||||
redirectUrl, "errorOAuth", "not_authentication_provider_found");
|
||||
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
|
||||
return;
|
||||
}
|
||||
getRedirectStrategy()
|
||||
.sendRedirect(
|
||||
request,
|
||||
@@ -43,4 +68,19 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica
|
||||
"/login?errorOAuth=not_authentication_provider_found");
|
||||
}
|
||||
}
|
||||
|
||||
private String appendQueryParam(String path, String key, String value) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return path;
|
||||
}
|
||||
String separator = path.contains("?") ? "&" : "?";
|
||||
String encodedKey =
|
||||
java.net.URLEncoder.encode(key, java.nio.charset.StandardCharsets.UTF_8);
|
||||
String encodedValue =
|
||||
value == null
|
||||
? ""
|
||||
: java.net.URLEncoder.encode(
|
||||
value, java.nio.charset.StandardCharsets.UTF_8);
|
||||
return path + separator + encodedKey + "=" + encodedValue;
|
||||
}
|
||||
}
|
||||
|
||||
+36
-5
@@ -33,9 +33,11 @@ import stirling.software.proprietary.audit.AuditEventType;
|
||||
import stirling.software.proprietary.audit.AuditLevel;
|
||||
import stirling.software.proprietary.audit.Audited;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
import stirling.software.proprietary.security.util.DesktopClientUtils;
|
||||
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
@@ -190,10 +192,27 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication,
|
||||
Map.of("authType", AuthenticationType.SAML2));
|
||||
Map<String, Object> claims = Map.of("authType", AuthenticationType.SAML2);
|
||||
|
||||
// Detect desktop client and issue longer-lived tokens
|
||||
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
|
||||
String jwt;
|
||||
if (isDesktopClient) {
|
||||
// Desktop: Use configured desktop token expiry (default 30 days)
|
||||
int desktopExpiryMinutes =
|
||||
DesktopClientUtils.getDesktopTokenExpiryMinutes(
|
||||
applicationProperties);
|
||||
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
|
||||
log.info(
|
||||
"Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)",
|
||||
username,
|
||||
desktopExpiryMinutes,
|
||||
desktopExpiryMinutes / 1440);
|
||||
} else {
|
||||
// Web: Use default expiry
|
||||
jwt = jwtService.generateToken(authentication, claims);
|
||||
log.debug("Issued WEB SAML token for user '{}'", username);
|
||||
}
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
@@ -233,7 +252,16 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
String redirectPath = resolveRedirectPath(request, contextPath);
|
||||
String origin = resolveOrigin(request);
|
||||
clearRedirectCookie(response);
|
||||
return origin + redirectPath + "#access_token=" + jwt;
|
||||
String url = origin + redirectPath + "#access_token=" + jwt;
|
||||
|
||||
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
|
||||
if (nonce != null) {
|
||||
url +=
|
||||
"&nonce="
|
||||
+ java.net.URLEncoder.encode(
|
||||
nonce, java.nio.charset.StandardCharsets.UTF_8);
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -256,6 +284,9 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
}
|
||||
|
||||
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
|
||||
if (TauriSamlUtils.isTauriRelayState(request)) {
|
||||
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
|
||||
}
|
||||
return extractRedirectPathFromCookie(request)
|
||||
.filter(path -> path.startsWith("/"))
|
||||
.orElseGet(() -> defaultCallbackPath(contextPath));
|
||||
|
||||
+10
@@ -156,6 +156,16 @@ public class Saml2Configuration {
|
||||
OpenSaml4AuthenticationRequestResolver resolver =
|
||||
new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository);
|
||||
|
||||
resolver.setRelayStateResolver(
|
||||
request -> {
|
||||
String tauriParam = request.getParameter("tauri");
|
||||
if (!"1".equals(tauriParam)) {
|
||||
return null;
|
||||
}
|
||||
String nonce = request.getParameter("nonce");
|
||||
return TauriSamlUtils.buildRelayState(nonce);
|
||||
});
|
||||
|
||||
resolver.setAuthnRequestCustomizer(
|
||||
customizer -> {
|
||||
HttpServletRequest request = customizer.getRequest();
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package stirling.software.proprietary.security.saml2;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
/** Utility helpers for the Tauri desktop SAML flow. */
|
||||
public final class TauriSamlUtils {
|
||||
|
||||
public static final String TAURI_RELAY_STATE_PREFIX = "tauri:";
|
||||
|
||||
private TauriSamlUtils() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
|
||||
public static boolean isTauriRelayState(HttpServletRequest request) {
|
||||
String relayState = request.getParameter("RelayState");
|
||||
return relayState != null
|
||||
&& (relayState.equals("tauri") || relayState.startsWith(TAURI_RELAY_STATE_PREFIX));
|
||||
}
|
||||
|
||||
public static String extractNonceFromRelayState(String relayState) {
|
||||
if (relayState == null || !relayState.startsWith(TAURI_RELAY_STATE_PREFIX)) {
|
||||
return null;
|
||||
}
|
||||
String[] parts = relayState.split(":");
|
||||
if (parts.length >= 2) {
|
||||
String nonce = parts[parts.length - 1];
|
||||
return nonce.isBlank() ? null : nonce;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String extractNonceFromRequest(HttpServletRequest request) {
|
||||
return extractNonceFromRelayState(request.getParameter("RelayState"));
|
||||
}
|
||||
|
||||
public static String buildRelayState(String nonce) {
|
||||
if (nonce == null || nonce.isBlank()) {
|
||||
return "tauri";
|
||||
}
|
||||
return TAURI_RELAY_STATE_PREFIX + nonce;
|
||||
}
|
||||
}
|
||||
+137
-23
@@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -19,6 +20,9 @@ import org.springframework.security.core.userdetails.UserDetails;
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.ExpiredJwtException;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
@@ -30,6 +34,8 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
@@ -38,18 +44,20 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
@Service
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 43200000;
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Autowired
|
||||
public JwtService(
|
||||
@Qualifier("v2Enabled") boolean v2Enabled,
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
KeyPersistenceServiceInterface keyPersistenceService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
this.securityProperties = applicationProperties.getSecurity();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -84,9 +92,10 @@ public class JwtService implements JwtServiceInterface {
|
||||
Jwts.builder()
|
||||
.claims(claims)
|
||||
.subject(username)
|
||||
.issuer(ISSUER)
|
||||
.issuer(JwtConstants.ISSUER)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
|
||||
.expiration(
|
||||
new Date(System.currentTimeMillis() + getExpirationMillis()))
|
||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||
|
||||
String keyId = activeKey.getKeyId();
|
||||
@@ -100,6 +109,40 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String generateToken(String username, Map<String, Object> claims, int expiryMinutes) {
|
||||
try {
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
Optional<KeyPair> keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId());
|
||||
|
||||
if (keyPairOpt.isEmpty()) {
|
||||
throw new RuntimeException("Unable to retrieve key pair for active key");
|
||||
}
|
||||
|
||||
KeyPair keyPair = keyPairOpt.get();
|
||||
long customExpirationMillis = expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
|
||||
|
||||
var builder =
|
||||
Jwts.builder()
|
||||
.claims(claims)
|
||||
.subject(username)
|
||||
.issuer(JwtConstants.ISSUER)
|
||||
.issuedAt(new Date())
|
||||
.expiration(
|
||||
new Date(System.currentTimeMillis() + customExpirationMillis))
|
||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||
|
||||
String keyId = activeKey.getKeyId();
|
||||
if (keyId != null) {
|
||||
builder.header().keyId(keyId);
|
||||
}
|
||||
|
||||
return builder.compact();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to generate token with custom expiry", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void validateToken(String token) throws AuthenticationFailureException {
|
||||
extractAllClaims(token);
|
||||
@@ -114,12 +157,23 @@ public class JwtService implements JwtServiceInterface {
|
||||
return extractClaim(token, Claims::getSubject);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String extractUsernameAllowExpired(String token) {
|
||||
return extractClaim(token, Claims::getSubject, true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> extractClaims(String token) {
|
||||
Claims claims = extractAllClaims(token);
|
||||
return new HashMap<>(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> extractClaimsAllowExpired(String token) {
|
||||
Claims claims = extractAllClaims(token, true);
|
||||
return new HashMap<>(claims);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTokenExpired(String token) {
|
||||
return extractExpiration(token).before(new Date());
|
||||
@@ -130,11 +184,21 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
|
||||
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
final Claims claims = extractAllClaims(token);
|
||||
final Claims claims = extractAllClaims(token, false);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
private <T> T extractClaim(
|
||||
String token, Function<Claims, T> claimsResolver, boolean allowExpired) {
|
||||
final Claims claims = extractAllClaims(token, allowExpired);
|
||||
return claimsResolver.apply(claims);
|
||||
}
|
||||
|
||||
private Claims extractAllClaims(String token) {
|
||||
return extractAllClaims(token, false);
|
||||
}
|
||||
|
||||
private Claims extractAllClaims(String token, boolean allowExpired) {
|
||||
try {
|
||||
String keyId = extractKeyId(token);
|
||||
KeyPair keyPair;
|
||||
@@ -176,11 +240,12 @@ public class JwtService implements JwtServiceInterface {
|
||||
} else {
|
||||
log.debug("No key ID in token header, trying all available keys");
|
||||
// Try all available keys when no keyId is present
|
||||
return tryAllKeys(token);
|
||||
return tryAllKeys(token, allowExpired);
|
||||
}
|
||||
|
||||
return Jwts.parser()
|
||||
.verifyWith(keyPair.getPublic())
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -191,7 +256,13 @@ public class JwtService implements JwtServiceInterface {
|
||||
log.warn("Invalid token: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("Invalid token", e);
|
||||
} catch (ExpiredJwtException e) {
|
||||
log.warn("The token has expired: {}", e.getMessage());
|
||||
if (allowExpired) {
|
||||
log.debug(
|
||||
"Extracting claims from expired token (allowed for refresh grace period): {}",
|
||||
e.getMessage());
|
||||
return e.getClaims();
|
||||
}
|
||||
log.warn("Token validation failed - token has expired: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (UnsupportedJwtException e) {
|
||||
log.warn("The token is unsupported: {}", e.getMessage());
|
||||
@@ -202,7 +273,8 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private Claims tryAllKeys(String token) throws AuthenticationFailureException {
|
||||
private Claims tryAllKeys(String token, boolean allowExpired)
|
||||
throws AuthenticationFailureException {
|
||||
// First try the active key
|
||||
try {
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
@@ -210,9 +282,18 @@ public class JwtService implements JwtServiceInterface {
|
||||
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (ExpiredJwtException e) {
|
||||
if (allowExpired) {
|
||||
log.debug(
|
||||
"Extracting claims from expired token (allowed for refresh grace period)");
|
||||
return e.getClaims();
|
||||
}
|
||||
log.warn("Token validation failed - token has expired");
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (SignatureException
|
||||
| NoSuchAlgorithmException
|
||||
| InvalidKeySpecException activeKeyException) {
|
||||
@@ -230,9 +311,15 @@ public class JwtService implements JwtServiceInterface {
|
||||
verificationKey.getVerifyingKey());
|
||||
return Jwts.parser()
|
||||
.verifyWith(publicKey)
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
} catch (ExpiredJwtException e) {
|
||||
if (allowExpired) {
|
||||
return e.getClaims();
|
||||
}
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (SignatureException
|
||||
| NoSuchAlgorithmException
|
||||
| InvalidKeySpecException e) {
|
||||
@@ -266,24 +353,51 @@ public class JwtService implements JwtServiceInterface {
|
||||
return v2Enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract key ID from JWT header without validating the token.
|
||||
*
|
||||
* <p>Parses the Base64-encoded JWT header to retrieve the "kid" (key ID) claim. Returns null if
|
||||
* the header cannot be parsed or does not contain a key ID.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return the key ID, or null if not found or parsing fails
|
||||
*/
|
||||
private String extractKeyId(String token) {
|
||||
try {
|
||||
PublicKey signingKey =
|
||||
keyPersistenceService.decodePublicKey(
|
||||
keyPersistenceService.getActiveKey().getVerifyingKey());
|
||||
String[] tokenParts = token.split("\\.");
|
||||
if (tokenParts.length < 2) {
|
||||
log.debug(
|
||||
"Token does not have enough parts (expected at least 2, got {})",
|
||||
tokenParts.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
String keyId =
|
||||
(String)
|
||||
Jwts.parser()
|
||||
.verifyWith(signingKey)
|
||||
.build()
|
||||
.parse(token)
|
||||
.getHeader()
|
||||
.get("kid");
|
||||
return keyId;
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
|
||||
byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]);
|
||||
Map<String, Object> header =
|
||||
OBJECT_MAPPER.readValue(
|
||||
headerBytes, new TypeReference<Map<String, Object>>() {});
|
||||
Object keyId = header.get("kid");
|
||||
return keyId instanceof String ? (String) keyId : null;
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.debug("Failed to decode Base64 JWT header: {}", e.getMessage());
|
||||
return null;
|
||||
} catch (java.io.IOException e) {
|
||||
log.debug("Failed to parse JWT header as JSON: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private long getExpirationMillis() {
|
||||
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
|
||||
int expiryMinutes =
|
||||
configuredMinutes > 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
return expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
|
||||
}
|
||||
|
||||
private long getAllowedClockSkewSeconds() {
|
||||
int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds();
|
||||
return configuredSeconds >= 0 ? configuredSeconds : JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -25,6 +25,16 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String generateToken(String username, Map<String, Object> claims);
|
||||
|
||||
/**
|
||||
* Generate a JWT token for a specific username with custom expiry
|
||||
*
|
||||
* @param username the username for which to generate the token
|
||||
* @param claims additional claims to include in the token
|
||||
* @param expiryMinutes custom token lifetime in minutes
|
||||
* @return JWT token as a string
|
||||
*/
|
||||
String generateToken(String username, Map<String, Object> claims, int expiryMinutes);
|
||||
|
||||
/**
|
||||
* Validate a JWT token
|
||||
*
|
||||
@@ -41,6 +51,15 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
String extractUsername(String token);
|
||||
|
||||
/**
|
||||
* Extract username from JWT token while allowing expired tokens. Signature and token structure
|
||||
* must still be valid.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return username extracted from token
|
||||
*/
|
||||
String extractUsernameAllowExpired(String token);
|
||||
|
||||
/**
|
||||
* Extract all claims from JWT token
|
||||
*
|
||||
@@ -49,6 +68,15 @@ public interface JwtServiceInterface {
|
||||
*/
|
||||
Map<String, Object> extractClaims(String token);
|
||||
|
||||
/**
|
||||
* Extract all claims from JWT token while allowing expired tokens. Signature and token
|
||||
* structure must still be valid.
|
||||
*
|
||||
* @param token the JWT token
|
||||
* @return map of claims
|
||||
*/
|
||||
Map<String, Object> extractClaimsAllowExpired(String token);
|
||||
|
||||
/**
|
||||
* Check if token is expired
|
||||
*
|
||||
|
||||
+191
-13
@@ -10,8 +10,10 @@ import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.interfaces.RSAPrivateCrtKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.security.spec.RSAPublicKeySpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
@@ -41,6 +43,7 @@ import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
|
||||
public static final String KEY_SUFFIX = ".key";
|
||||
public static final String PUB_KEY_SUFFIX = ".pub";
|
||||
|
||||
private final ApplicationProperties.Security.Jwt jwtProperties;
|
||||
private final CacheManager cacheManager;
|
||||
@@ -59,19 +62,119 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
@PostConstruct
|
||||
public void initializeKeystore() {
|
||||
if (!isKeystoreEnabled()) {
|
||||
log.info("JWT keystore is disabled - keys will be generated in memory");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
ensurePrivateKeyDirectoryExists();
|
||||
loadKeyPair();
|
||||
loadExistingKeysFromDisk();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize keystore, using in-memory generation", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadKeyPair() {
|
||||
if (activeKey == null) {
|
||||
/**
|
||||
* Load all existing JWT keys from disk into memory on startup.
|
||||
*
|
||||
* <p>This ensures tokens signed with previous keys remain valid after server restart. If no
|
||||
* keys exist on disk, generates a new keypair.
|
||||
*/
|
||||
private void loadExistingKeysFromDisk() {
|
||||
try {
|
||||
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
|
||||
|
||||
if (!Files.exists(keyDirectory)) {
|
||||
log.info("No existing keys found, generating new keypair");
|
||||
generateAndStoreKeypair();
|
||||
return;
|
||||
}
|
||||
|
||||
List<Path> keyFiles;
|
||||
try (var stream = Files.list(keyDirectory)) {
|
||||
keyFiles =
|
||||
stream.filter(path -> path.toString().endsWith(KEY_SUFFIX))
|
||||
.sorted(
|
||||
(a, b) ->
|
||||
b.getFileName().compareTo(a.getFileName())) // Most
|
||||
// recent
|
||||
// first
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
if (keyFiles.isEmpty()) {
|
||||
log.info("No existing keys found in directory, generating new keypair");
|
||||
generateAndStoreKeypair();
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Loading {} existing JWT keys from disk", keyFiles.size());
|
||||
int loadedCount = 0;
|
||||
|
||||
for (Path keyFile : keyFiles) {
|
||||
try {
|
||||
String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, "");
|
||||
|
||||
// Load private key first
|
||||
PrivateKey privateKey = loadPrivateKey(keyId);
|
||||
|
||||
// Try to load public key, or generate it from private key if missing
|
||||
// (migration)
|
||||
String encodedPublicKey;
|
||||
try {
|
||||
encodedPublicKey = loadPublicKey(keyId);
|
||||
} catch (IOException e) {
|
||||
// Public key file doesn't exist - generate it from private key (migration)
|
||||
log.info("Migrating legacy key: generating public key file for {}", keyId);
|
||||
KeyPair keyPair = reconstructKeyPair(privateKey);
|
||||
|
||||
// Save the public key file
|
||||
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
|
||||
encodedPublicKey = encodePublicKey(keyPair.getPublic());
|
||||
Files.writeString(publicKeyFile, encodedPublicKey);
|
||||
publicKeyFile.toFile().setReadable(true, true);
|
||||
publicKeyFile.toFile().setWritable(true, true);
|
||||
publicKeyFile.toFile().setExecutable(false, false);
|
||||
|
||||
log.info("Successfully migrated key: {}", keyId);
|
||||
}
|
||||
|
||||
// Create verification key and add to cache
|
||||
JwtVerificationKey verifyingKey =
|
||||
new JwtVerificationKey(keyId, encodedPublicKey);
|
||||
verifyingKeyCache.put(keyId, verifyingKey);
|
||||
loadedCount++;
|
||||
|
||||
// Set the most recent key as active (first in sorted list)
|
||||
if (activeKey == null) {
|
||||
activeKey = verifyingKey;
|
||||
log.info("Set active JWT signing key: {}", keyId);
|
||||
} else {
|
||||
log.debug(
|
||||
"Loaded historical JWT key: {} (created: {})",
|
||||
keyId,
|
||||
verifyingKey.getCreatedAt());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"Failed to load key: {}, skipping. Error: {}",
|
||||
keyFile.getFileName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
if (loadedCount == 0) {
|
||||
log.warn("No valid keys could be loaded from disk, generating new keypair");
|
||||
generateAndStoreKeypair();
|
||||
} else {
|
||||
log.info(
|
||||
"Successfully loaded {} JWT keys, active key: {}",
|
||||
loadedCount,
|
||||
activeKey.getKeyId());
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to load keys from disk, generating new keypair", e);
|
||||
generateAndStoreKeypair();
|
||||
}
|
||||
}
|
||||
@@ -84,10 +187,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
KeyPair keyPair = generateRSAKeypair();
|
||||
String keyId = generateKeyId();
|
||||
|
||||
storePrivateKey(keyId, keyPair.getPrivate());
|
||||
storeKeyPair(keyId, keyPair);
|
||||
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
|
||||
verifyingKeyCache.put(keyId, verifyingKey);
|
||||
activeKey = verifyingKey;
|
||||
log.info("Generated and stored new JWT keypair: {}", keyId);
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to generate and store keypair", e);
|
||||
}
|
||||
@@ -200,16 +304,43 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException {
|
||||
Path keyFile =
|
||||
Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX);
|
||||
String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded());
|
||||
Files.writeString(keyFile, encodedKey);
|
||||
/**
|
||||
* Store both private and public keys to disk.
|
||||
*
|
||||
* <p>Private key stored as: keyId.key
|
||||
*
|
||||
* <p>Public key stored as: keyId.pub
|
||||
*/
|
||||
private void storeKeyPair(String keyId, KeyPair keyPair) throws IOException {
|
||||
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
|
||||
|
||||
// Set read/write to only the owner
|
||||
keyFile.toFile().setReadable(true, true);
|
||||
keyFile.toFile().setWritable(true, true);
|
||||
keyFile.toFile().setExecutable(false, false);
|
||||
// Store private key
|
||||
Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX);
|
||||
String encodedPrivateKey =
|
||||
Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
|
||||
Files.writeString(privateKeyFile, encodedPrivateKey);
|
||||
|
||||
// Set read/write to only the owner (security)
|
||||
privateKeyFile.toFile().setReadable(true, true);
|
||||
privateKeyFile.toFile().setWritable(true, true);
|
||||
privateKeyFile.toFile().setExecutable(false, false);
|
||||
|
||||
// Store public key
|
||||
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
|
||||
String encodedPublicKey =
|
||||
Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
|
||||
Files.writeString(publicKeyFile, encodedPublicKey);
|
||||
|
||||
// Public key can be more permissive but still restrict to owner
|
||||
publicKeyFile.toFile().setReadable(true, true);
|
||||
publicKeyFile.toFile().setWritable(true, true);
|
||||
publicKeyFile.toFile().setExecutable(false, false);
|
||||
|
||||
log.debug(
|
||||
"Stored keypair to disk: {} (private: {}, public: {})",
|
||||
keyId,
|
||||
privateKeyFile.getFileName(),
|
||||
publicKeyFile.getFileName());
|
||||
}
|
||||
|
||||
private PrivateKey loadPrivateKey(String keyId)
|
||||
@@ -229,6 +360,53 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
return keyFactory.generatePrivate(keySpec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load public key from disk.
|
||||
*
|
||||
* @param keyId the key identifier
|
||||
* @return Base64-encoded public key string
|
||||
* @throws IOException if the public key file is not found
|
||||
*/
|
||||
private String loadPublicKey(String keyId) throws IOException {
|
||||
Path publicKeyFile =
|
||||
Paths.get(InstallationPathConfig.getPrivateKeyPath())
|
||||
.resolve(keyId + PUB_KEY_SUFFIX);
|
||||
|
||||
if (!Files.exists(publicKeyFile)) {
|
||||
throw new IOException("Public key not found: " + publicKeyFile);
|
||||
}
|
||||
|
||||
return Files.readString(publicKeyFile).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruct a KeyPair from a PrivateKey.
|
||||
*
|
||||
* <p>For RSA keys, derives the public key from the private key.
|
||||
*
|
||||
* @param privateKey the RSA private key
|
||||
* @return reconstructed KeyPair
|
||||
* @throws NoSuchAlgorithmException if RSA algorithm is not available
|
||||
* @throws InvalidKeySpecException if the key specification is invalid
|
||||
*/
|
||||
private KeyPair reconstructKeyPair(PrivateKey privateKey)
|
||||
throws NoSuchAlgorithmException, InvalidKeySpecException {
|
||||
// For RSA, we can derive the public key from the private key
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
|
||||
// Get the private key spec
|
||||
RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privateKey;
|
||||
|
||||
// Create public key spec from private key parameters
|
||||
RSAPublicKeySpec publicKeySpec =
|
||||
new RSAPublicKeySpec(rsaPrivateKey.getModulus(), rsaPrivateKey.getPublicExponent());
|
||||
|
||||
// Generate public key
|
||||
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
|
||||
|
||||
return new KeyPair(publicKey, privateKey);
|
||||
}
|
||||
|
||||
private String encodePublicKey(PublicKey publicKey) {
|
||||
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||
}
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package stirling.software.proprietary.security.service;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Service to rate limit token refresh attempts within the grace period.
|
||||
*
|
||||
* <p>Prevents abuse of expired tokens by tracking and limiting refresh attempts per token. Tokens
|
||||
* are identified by a hash to avoid storing actual token values.
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
public class RefreshRateLimitService {
|
||||
|
||||
private final ApplicationProperties.Security.Jwt jwtProperties;
|
||||
|
||||
@Autowired
|
||||
public RefreshRateLimitService(ApplicationProperties applicationProperties) {
|
||||
this.jwtProperties = applicationProperties.getSecurity().getJwt();
|
||||
}
|
||||
|
||||
private static class RefreshAttempt {
|
||||
private final AtomicInteger count = new AtomicInteger(0);
|
||||
private final Instant firstAttempt = Instant.now();
|
||||
|
||||
int incrementAndGet() {
|
||||
return count.incrementAndGet();
|
||||
}
|
||||
|
||||
Instant getFirstAttempt() {
|
||||
return firstAttempt;
|
||||
}
|
||||
|
||||
int getCount() {
|
||||
return count.get();
|
||||
}
|
||||
}
|
||||
|
||||
private final Map<String, RefreshAttempt> attempts = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Check if a refresh attempt is allowed for the given token.
|
||||
*
|
||||
* @param tokenHash hash of the token attempting refresh
|
||||
* @param graceWindowMillis the configured grace window in milliseconds
|
||||
* @return true if refresh is allowed, false if rate limit exceeded
|
||||
*/
|
||||
public boolean isRefreshAllowed(String tokenHash, long graceWindowMillis) {
|
||||
RefreshAttempt attempt = attempts.computeIfAbsent(tokenHash, k -> new RefreshAttempt());
|
||||
|
||||
int attemptCount = attempt.incrementAndGet();
|
||||
|
||||
if (attemptCount > JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE) {
|
||||
log.warn(
|
||||
"Refresh rate limit exceeded for token (attempt {}). Token hash: {}",
|
||||
attemptCount,
|
||||
tokenHash.substring(0, Math.min(8, tokenHash.length())));
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clean up if outside grace window
|
||||
Instant cutoff = Instant.now().minusMillis(graceWindowMillis);
|
||||
if (attempt.getFirstAttempt().isBefore(cutoff)) {
|
||||
attempts.remove(tokenHash);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove tracking for a token after successful refresh.
|
||||
*
|
||||
* @param tokenHash hash of the refreshed token
|
||||
*/
|
||||
public void clearRefreshAttempts(String tokenHash) {
|
||||
attempts.remove(tokenHash);
|
||||
}
|
||||
|
||||
/** Clean up expired tracking entries every 5 minutes. */
|
||||
@Scheduled(fixedRate = 300000)
|
||||
public void cleanupExpiredEntries() {
|
||||
// Use configured grace period with same normalization as runtime checks
|
||||
int configuredMinutes = jwtProperties.getRefreshGraceMinutes();
|
||||
int graceMinutes =
|
||||
configuredMinutes >= 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
|
||||
Instant cutoff = Instant.now().minusMillis(graceMinutes * 60000L);
|
||||
int removed =
|
||||
attempts.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().getFirstAttempt().isBefore(cutoff))
|
||||
.mapToInt(
|
||||
entry -> {
|
||||
attempts.remove(entry.getKey());
|
||||
return 1;
|
||||
})
|
||||
.sum();
|
||||
|
||||
if (removed > 0) {
|
||||
log.debug("Cleaned up {} expired refresh tracking entries", removed);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get current tracking statistics for monitoring. */
|
||||
public Map<String, Object> getStats() {
|
||||
return Map.of(
|
||||
"tracked_tokens",
|
||||
attempts.size(),
|
||||
"max_attempts_allowed",
|
||||
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
@@ -33,6 +34,7 @@ public class TotpService {
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA1";
|
||||
private static final String DEFAULT_ISSUER = "Stirling PDF";
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
private static final Pattern TOTP_CODE_PATTERN = Pattern.compile("\\d{6}");
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@@ -71,7 +73,7 @@ public class TotpService {
|
||||
}
|
||||
|
||||
String normalizedCode = code.replace(" ", "");
|
||||
if (!normalizedCode.matches("\\d{6}")) {
|
||||
if (!TOTP_CODE_PATTERN.matcher(normalizedCode).matches()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package stirling.software.proprietary.security.util;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.constants.JwtConstants;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
/**
|
||||
* Utility class for detecting desktop clients and determining appropriate token expiry times.
|
||||
*
|
||||
* <p>Desktop clients (Tauri, Electron) receive longer-lived tokens because:
|
||||
*
|
||||
* <ul>
|
||||
* <li>They run on personal devices (not shared computers)
|
||||
* <li>Tokens stored in OS-level encrypted keychain (not browser localStorage)
|
||||
* <li>Better UX (users expect desktop apps to stay logged in)
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
public class DesktopClientUtils {
|
||||
|
||||
private DesktopClientUtils() {
|
||||
// Utility class - prevent instantiation
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the request is from a desktop client (Tauri app).
|
||||
*
|
||||
* @param request the HTTP request
|
||||
* @return true if desktop client, false if web browser
|
||||
*/
|
||||
public static boolean isDesktopClient(HttpServletRequest request) {
|
||||
String userAgent = request.getHeader("User-Agent");
|
||||
|
||||
if (userAgent == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tauri desktop app includes "Tauri" or "tauri-plugin" in User-Agent
|
||||
// Also check for common desktop app identifiers
|
||||
String userAgentLower = userAgent.toLowerCase();
|
||||
boolean hasTauri = userAgentLower.contains("tauri");
|
||||
boolean hasStirling = userAgentLower.contains("stirlingpdf-desktop");
|
||||
boolean hasElectron = userAgentLower.contains("electron");
|
||||
boolean isDesktop = hasTauri || hasStirling || hasElectron;
|
||||
|
||||
log.debug("Desktop client detection: {} (User-Agent: {})", isDesktop, userAgent);
|
||||
|
||||
return isDesktop;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured desktop token expiry time in minutes.
|
||||
*
|
||||
* @param applicationProperties the application properties
|
||||
* @return desktop token expiry in minutes (defaults to 30 days if not configured)
|
||||
*/
|
||||
public static int getDesktopTokenExpiryMinutes(ApplicationProperties applicationProperties) {
|
||||
int configuredMinutes =
|
||||
applicationProperties.getSecurity().getJwt().getDesktopTokenExpiryMinutes();
|
||||
// If not configured or invalid, default to 30 days (43200 minutes)
|
||||
return configuredMinutes > 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the configured web token expiry time in minutes.
|
||||
*
|
||||
* @param applicationProperties the application properties
|
||||
* @return web token expiry in minutes
|
||||
*/
|
||||
public static int getWebTokenExpiryMinutes(ApplicationProperties applicationProperties) {
|
||||
int configuredMinutes =
|
||||
applicationProperties.getSecurity().getJwt().getTokenExpiryMinutes();
|
||||
return configuredMinutes > 0
|
||||
? configuredMinutes
|
||||
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -10,6 +10,7 @@ import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -32,6 +33,7 @@ import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||
@Slf4j
|
||||
public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
|
||||
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
@@ -366,14 +368,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
|
||||
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||
}
|
||||
}
|
||||
|
||||
private String validateAndNormalizeExtension(String extension) {
|
||||
String normalized = extension.toLowerCase().trim();
|
||||
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
|
||||
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported image extension: " + extension);
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
@@ -18,6 +19,7 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -46,6 +48,7 @@ import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
@@ -67,6 +70,13 @@ public class FormUtils {
|
||||
public final Set<String> CHOICE_FIELD_TYPES =
|
||||
Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO);
|
||||
|
||||
/**
|
||||
* Threshold in PDF points for considering two widgets to be on the same line. Fields whose
|
||||
* y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead
|
||||
* of top-to-bottom.
|
||||
*/
|
||||
private static final float SAME_LINE_THRESHOLD_PT = 10.0f;
|
||||
|
||||
/**
|
||||
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
|
||||
* so all callers share identical mapping logic.
|
||||
@@ -109,6 +119,8 @@ public class FormUtils {
|
||||
List<FormFieldInfo> fields = new ArrayList<>();
|
||||
Map<String, Integer> typeCounters = new HashMap<>();
|
||||
Map<Integer, Integer> pageOrderCounters = new HashMap<>();
|
||||
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (!(field instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
@@ -125,7 +137,7 @@ public class FormUtils {
|
||||
|
||||
String currentValue = safeValue(terminalField);
|
||||
boolean required = field.isRequired();
|
||||
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField);
|
||||
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField, annotationPageMap);
|
||||
List<String> options = resolveOptions(terminalField);
|
||||
String tooltip = resolveTooltip(terminalField);
|
||||
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
|
||||
@@ -164,6 +176,396 @@ public class FormUtils {
|
||||
return Collections.unmodifiableList(fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form fields with widget coordinates for the interactive form viewer.
|
||||
*
|
||||
* @param document PDF document
|
||||
* @return List of form fields with coordinates and metadata
|
||||
*/
|
||||
public List<FormFieldWithCoordinates> extractFormFieldsWithCoordinates(PDDocument document) {
|
||||
if (document == null) return List.of();
|
||||
|
||||
PDAcroForm acroForm = getAcroFormSafely(document);
|
||||
if (acroForm == null) return List.of();
|
||||
|
||||
List<FormFieldWithCoordinates> fields = new ArrayList<>();
|
||||
Map<String, Integer> typeCounters = new HashMap<>();
|
||||
|
||||
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (!(field instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String type = detectFieldType(terminalField);
|
||||
String name =
|
||||
Optional.ofNullable(field.getFullyQualifiedName())
|
||||
.orElseGet(field::getPartialName);
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String currentValue = safeValue(terminalField);
|
||||
boolean required = field.isRequired();
|
||||
boolean readOnly = field.isReadOnly();
|
||||
List<String> options = resolveOptions(terminalField);
|
||||
List<String> displayOptions = resolveDisplayOptions(terminalField);
|
||||
String tooltip = resolveTooltip(terminalField);
|
||||
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
|
||||
String displayLabel =
|
||||
deriveDisplayLabel(field, name, tooltip, type, typeIndex, options);
|
||||
boolean multiSelect = resolveMultiSelect(terminalField);
|
||||
boolean multiline =
|
||||
terminalField instanceof PDTextField
|
||||
&& ((PDTextField) terminalField).isMultiline();
|
||||
|
||||
// Extract widget coordinates
|
||||
List<FormFieldWithCoordinates.WidgetCoordinates> widgets =
|
||||
extractWidgetCoordinates(document, terminalField, annotationPageMap);
|
||||
|
||||
// Only include displayOptions when they differ from export options
|
||||
List<String> displayOptsToSend = null;
|
||||
if (displayOptions != null
|
||||
&& !displayOptions.isEmpty()
|
||||
&& !displayOptions.equals(options)) {
|
||||
displayOptsToSend = displayOptions;
|
||||
}
|
||||
|
||||
fields.add(
|
||||
FormFieldWithCoordinates.builder()
|
||||
.name(name)
|
||||
.label(displayLabel)
|
||||
.type(type)
|
||||
.value(currentValue)
|
||||
.options(options.isEmpty() ? null : options)
|
||||
.displayOptions(displayOptsToSend)
|
||||
.required(required)
|
||||
.readOnly(readOnly)
|
||||
.multiSelect(multiSelect)
|
||||
.multiline(multiline)
|
||||
.tooltip(tooltip)
|
||||
.widgets(widgets.isEmpty() ? null : widgets)
|
||||
.build());
|
||||
}
|
||||
|
||||
// Sort by page and position
|
||||
fields.sort(new FieldCoordinateComparator());
|
||||
|
||||
log.debug("Total fields processed: {}", fields.size());
|
||||
log.debug(
|
||||
"Fields WITH widgets: {}",
|
||||
fields.stream()
|
||||
.filter(f -> f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
.count());
|
||||
log.debug(
|
||||
"Fields WITHOUT widgets: {}",
|
||||
fields.stream()
|
||||
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
|
||||
.count());
|
||||
|
||||
fields.stream()
|
||||
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
|
||||
.forEach(
|
||||
f ->
|
||||
log.debug(
|
||||
"Field '{}' type={} has NO widget coordinates",
|
||||
f.getName(),
|
||||
f.getType()));
|
||||
|
||||
return Collections.unmodifiableList(fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract widget coordinates for a form field.
|
||||
*
|
||||
* @param document PDF document
|
||||
* @param field Terminal field
|
||||
* @return List of widget coordinates
|
||||
*/
|
||||
private List<FormFieldWithCoordinates.WidgetCoordinates> extractWidgetCoordinates(
|
||||
PDDocument document,
|
||||
PDTerminalField field,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
List<FormFieldWithCoordinates.WidgetCoordinates> result = new ArrayList<>();
|
||||
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
|
||||
log.debug(
|
||||
"Field '{}' type={} has {} widgets",
|
||||
field.getFullyQualifiedName(),
|
||||
field.getClass().getSimpleName(),
|
||||
widgets != null ? widgets.size() : 0);
|
||||
|
||||
if (widgets == null || widgets.isEmpty()) {
|
||||
// Some fields (especially text fields) might be their own widget annotation
|
||||
log.trace(
|
||||
"Field '{}' has no widgets, checking if field acts as its own annotation",
|
||||
field.getFullyQualifiedName());
|
||||
try {
|
||||
COSDictionary fieldDict = field.getCOSObject();
|
||||
COSBase rectBase = fieldDict.getDictionaryObject(COSName.RECT);
|
||||
if (rectBase instanceof COSArray rectArray) {
|
||||
int pageIndex =
|
||||
findPageIndexForAnnotation(document, fieldDict, annotationPageMap);
|
||||
if (pageIndex >= 0) {
|
||||
PDRectangle rectangle = new PDRectangle(rectArray);
|
||||
result.add(
|
||||
createWidgetCoordinates(
|
||||
document, rectangle, pageIndex, null, field));
|
||||
} else {
|
||||
log.warn(
|
||||
"Found rectangle for field '{}' but could not resolve page index",
|
||||
field.getFullyQualifiedName());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Could not extract direct rectangle for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// For radio buttons, pre-resolve export values per widget
|
||||
List<String> exportValues = null;
|
||||
if (field instanceof PDRadioButton radio) {
|
||||
exportValues = radio.getExportValues();
|
||||
}
|
||||
|
||||
for (int i = 0; i < widgets.size(); i++) {
|
||||
PDAnnotationWidget widget = widgets.get(i);
|
||||
try {
|
||||
PDRectangle rectangle = widget.getRectangle();
|
||||
if (rectangle == null) {
|
||||
log.warn(
|
||||
"Field '{}' widget {} has NULL rectangle",
|
||||
field.getFullyQualifiedName(),
|
||||
i);
|
||||
continue;
|
||||
}
|
||||
|
||||
int pageIndex = resolveWidgetPageIndex(document, widget, annotationPageMap);
|
||||
if (pageIndex < 0) {
|
||||
log.warn(
|
||||
"Field '{}' widget {} could not resolve page index",
|
||||
field.getFullyQualifiedName(),
|
||||
i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve export value for radio/checkbox widgets
|
||||
String exportValue = null;
|
||||
if (exportValues != null && i < exportValues.size()) {
|
||||
exportValue = exportValues.get(i);
|
||||
} else if (field instanceof PDButton) {
|
||||
// Fall back to appearance state name from the widget's normal appearance
|
||||
try {
|
||||
var ap = widget.getAppearance();
|
||||
if (ap != null && ap.getNormalAppearance() != null) {
|
||||
var normalAp = ap.getNormalAppearance();
|
||||
if (normalAp.isSubDictionary()) {
|
||||
for (var cosName : normalAp.getSubDictionary().keySet()) {
|
||||
String key = cosName.getName();
|
||||
if (!"Off".equals(key)) {
|
||||
exportValue = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace(
|
||||
"Could not extract export value for widget in '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
result.add(
|
||||
createWidgetCoordinates(
|
||||
document, rectangle, pageIndex, exportValue, field));
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to extract coordinates for widget in field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates(
|
||||
PDDocument document,
|
||||
PDRectangle rectangle,
|
||||
int pageIndex,
|
||||
String exportValue,
|
||||
PDTerminalField field) {
|
||||
if (pageIndex < 0 || pageIndex >= document.getNumberOfPages()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PDPage page = document.getPage(pageIndex);
|
||||
PDRectangle cropBox = page.getCropBox();
|
||||
|
||||
// Use CropBox dimensions for the y-flip.
|
||||
// Note: getWidth() and getHeight() return dimensions BEFORE rotation.
|
||||
float cropHeight = cropBox.getHeight();
|
||||
|
||||
// Get absolute widget coordinates (in MediaBox space, un-rotated)
|
||||
float pdfX = rectangle.getLowerLeftX();
|
||||
float pdfY = rectangle.getLowerLeftY();
|
||||
float width = rectangle.getWidth();
|
||||
float height = rectangle.getHeight();
|
||||
|
||||
// Adjust relative to CropBox origin
|
||||
float relativeX = pdfX - cropBox.getLowerLeftX();
|
||||
float relativeY = pdfY - cropBox.getLowerLeftY();
|
||||
|
||||
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
|
||||
// Widget /Rect coordinates are always in un-rotated PDF user space.
|
||||
// The embedpdf viewer wraps all page content inside a <Rotate> CSS
|
||||
// component that handles visual rotation — we must NOT apply any
|
||||
// rotation transform here, or widgets would be double-rotated.
|
||||
float finalX = relativeX;
|
||||
float finalY = cropHeight - relativeY - height;
|
||||
float finalW = width;
|
||||
float finalH = height;
|
||||
|
||||
// Validate coordinates are within reasonable bounds
|
||||
if (finalX < -1.0f
|
||||
|| finalY < -1.0f
|
||||
|| finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow
|
||||
|| finalY > cropHeight + 1.0f) {
|
||||
log.warn(
|
||||
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}",
|
||||
field.getFullyQualifiedName(),
|
||||
pageIndex,
|
||||
finalX,
|
||||
finalY,
|
||||
finalW,
|
||||
finalH);
|
||||
return null;
|
||||
}
|
||||
|
||||
return FormFieldWithCoordinates.WidgetCoordinates.builder()
|
||||
.pageIndex(pageIndex)
|
||||
.x(finalX)
|
||||
.y(finalY)
|
||||
.width(finalW)
|
||||
.height(finalH)
|
||||
.exportValue(exportValue)
|
||||
.fontSize(extractFontSize(field))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs widgets with missing page references by scanning all pages and setting the /P entry
|
||||
* for orphan widgets.
|
||||
*
|
||||
* <p>This should be called BEFORE extracting form field coordinates.
|
||||
*
|
||||
* @param document PDF document to repair
|
||||
*/
|
||||
public void repairMissingWidgetPageReferences(PDDocument document) {
|
||||
try {
|
||||
PDAcroForm acroForm = getAcroFormSafely(document);
|
||||
if (acroForm == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Checking for widgets with missing page references...");
|
||||
int repairedCount = 0;
|
||||
|
||||
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (!(field instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<PDAnnotationWidget> widgets = terminalField.getWidgets();
|
||||
|
||||
if (widgets == null || widgets.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
if (widget.getPage() == null) {
|
||||
Integer pageIndex = annotationPageMap.get(widget.getCOSObject());
|
||||
if (pageIndex != null && pageIndex >= 0) {
|
||||
PDPage foundPage = document.getPage(pageIndex);
|
||||
widget.setPage(foundPage);
|
||||
repairedCount++;
|
||||
log.debug(
|
||||
"Repaired widget for field '{}' - set page reference via map",
|
||||
field.getFullyQualifiedName());
|
||||
} else {
|
||||
log.warn(
|
||||
"Could not find page for widget in field '{}'",
|
||||
field.getFullyQualifiedName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (repairedCount > 0) {
|
||||
log.debug(
|
||||
"Successfully repaired {} widgets with missing page references",
|
||||
repairedCount);
|
||||
} else {
|
||||
log.debug("No widgets needed repair");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error repairing widget page references: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private int findPageIndexForAnnotation(
|
||||
PDDocument document,
|
||||
COSDictionary annotDict,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
try {
|
||||
// Method 0: Check the pre-built lookup map (fastest)
|
||||
if (annotationPageMap != null) {
|
||||
Integer idx = annotationPageMap.get(annotDict);
|
||||
if (idx != null) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Method 1: Check the /P entry if it points to a page
|
||||
COSBase base = annotDict.getDictionaryObject(COSName.P);
|
||||
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
|
||||
if (pageDict != null) {
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
if (document.getPage(i).getCOSObject() == pageDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Fallback search through all pages' annotations
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
List<PDAnnotation> annotations = page.getAnnotations();
|
||||
if (annotations != null) {
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot != null && annot.getCOSObject() == annotDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace("Error finding page for annotation: {}", e.getMessage());
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single record object (field-name -> value placeholder) that can be directly submitted
|
||||
* to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless
|
||||
@@ -312,7 +714,24 @@ public class FormUtils {
|
||||
return;
|
||||
}
|
||||
|
||||
flattenViaRendering(document, acroForm);
|
||||
if (acroForm == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use PDFBox's built-in field flattening which bakes form field values
|
||||
// into the page content stream as static text/graphics, removing the
|
||||
// interactive form structure but preserving all other document content
|
||||
// (images, text, annotations, etc.) at full quality.
|
||||
try {
|
||||
ensureAppearances(acroForm);
|
||||
acroForm.flatten();
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"PDFBox acroForm.flatten() failed, falling back to rendering: {}",
|
||||
e.getMessage(),
|
||||
e);
|
||||
flattenViaRendering(document, acroForm);
|
||||
}
|
||||
}
|
||||
|
||||
private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi)
|
||||
@@ -385,7 +804,7 @@ public class FormUtils {
|
||||
|
||||
PDPage page = widget.getPage();
|
||||
if (page == null) {
|
||||
page = resolveWidgetPage(document, widget);
|
||||
page = resolveWidgetPage(document, widget, null);
|
||||
if (page != null) {
|
||||
widget.setPage(page);
|
||||
}
|
||||
@@ -820,6 +1239,16 @@ public class FormUtils {
|
||||
|
||||
private String safeValue(PDTerminalField field) {
|
||||
try {
|
||||
// PDChoice.getValueAsString() returns a raw COS string representation
|
||||
// that doesn't reliably reflect the selected value. Use getValue()
|
||||
// which returns the proper List<String> of selected options.
|
||||
if (field instanceof PDChoice choiceField) {
|
||||
List<String> selected = choiceField.getValue();
|
||||
if (selected == null || selected.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return String.join(",", selected);
|
||||
}
|
||||
return field.getValueAsString();
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
@@ -833,14 +1262,25 @@ public class FormUtils {
|
||||
List<String> resolveOptions(PDTerminalField field) {
|
||||
try {
|
||||
if (field instanceof PDChoice choice) {
|
||||
List<String> display = choice.getOptionsDisplayValues();
|
||||
if (display != null && !display.isEmpty()) {
|
||||
return new ArrayList<>(display);
|
||||
}
|
||||
LinkedHashSet<String> allowed = new LinkedHashSet<>();
|
||||
List<String> exportValues = choice.getOptionsExportValues();
|
||||
if (exportValues != null && !exportValues.isEmpty()) {
|
||||
return new ArrayList<>(exportValues);
|
||||
List<String> displayValues = choice.getOptionsDisplayValues();
|
||||
|
||||
if (exportValues != null) {
|
||||
exportValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
if (displayValues != null) {
|
||||
displayValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
return new ArrayList<>(allowed);
|
||||
} else if (field instanceof PDRadioButton radio) {
|
||||
List<String> exports = radio.getExportValues();
|
||||
if (exports != null && !exports.isEmpty()) {
|
||||
@@ -861,6 +1301,29 @@ public class FormUtils {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display-value labels for a choice field's options. For radio / checkbox this
|
||||
* returns an empty list (no separate display values). For PDChoice fields, if the PDF provides
|
||||
* distinct display values, those are returned; otherwise an empty list (indicating that the
|
||||
* export values from {@link #resolveOptions} should be shown directly).
|
||||
*/
|
||||
List<String> resolveDisplayOptions(PDTerminalField field) {
|
||||
try {
|
||||
if (field instanceof PDChoice choice) {
|
||||
List<String> display = choice.getOptionsDisplayValues();
|
||||
if (display != null && !display.isEmpty()) {
|
||||
return new ArrayList<>(display);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to resolve display options for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private boolean resolveMultiSelect(PDTerminalField field) {
|
||||
if (field instanceof PDListBox listBox) {
|
||||
try {
|
||||
@@ -875,6 +1338,44 @@ public class FormUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
private Float extractFontSize(PDTerminalField field) {
|
||||
try {
|
||||
String da = null;
|
||||
if (field instanceof PDVariableText vt) {
|
||||
da = vt.getDefaultAppearance();
|
||||
}
|
||||
|
||||
if (da == null || da.isBlank()) {
|
||||
// Check parent/acroform default appearance if field's is missing
|
||||
PDAcroForm form = field.getAcroForm();
|
||||
if (form != null) {
|
||||
da = form.getDefaultAppearance();
|
||||
}
|
||||
}
|
||||
|
||||
if (da != null && !da.isBlank()) {
|
||||
// Standard DA looks like: /Helv 12 Tf 0 g
|
||||
// We want the number before 'Tf'
|
||||
String[] tokens = da.split("\\s+");
|
||||
for (int i = 0; i < tokens.length; i++) {
|
||||
if ("Tf".equals(tokens[i]) && i > 0) {
|
||||
try {
|
||||
float size = Float.parseFloat(tokens[i - 1]);
|
||||
return size > 0 ? size : null;
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace(
|
||||
"Could not extract font size for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isSettableCheckBoxState(String state) {
|
||||
if (state == null) return false;
|
||||
String trimmed = state.trim();
|
||||
@@ -952,6 +1453,13 @@ public class FormUtils {
|
||||
|
||||
if (simplified.isEmpty()) return true;
|
||||
|
||||
// Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7")
|
||||
// Standard UUIDs are 32 hex characters; require at least that to avoid
|
||||
// false positives on short hex-like field names.
|
||||
String nospaces = simplified.replaceAll("\\s+", "");
|
||||
if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"))
|
||||
return true;
|
||||
|
||||
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|
||||
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
|
||||
|| patterns.getOptionalTNumericPattern().matcher(simplified).matches();
|
||||
@@ -1007,7 +1515,7 @@ public class FormUtils {
|
||||
|
||||
PDAnnotationWidget widget = widgets.get(0);
|
||||
PDRectangle originalRectangle = cloneRectangle(widget.getRectangle());
|
||||
PDPage page = resolveWidgetPage(document, widget);
|
||||
PDPage page = resolveWidgetPage(document, widget, null);
|
||||
if (page == null || originalRectangle == null) {
|
||||
log.warn(
|
||||
"Unable to resolve widget page or rectangle for '{}'; skipping",
|
||||
@@ -1064,7 +1572,7 @@ public class FormUtils {
|
||||
desiredName,
|
||||
modification.label(),
|
||||
resolvedType,
|
||||
determineWidgetPageIndex(document, widget),
|
||||
determineWidgetPageIndex(document, widget, null),
|
||||
originalRectangle.getLowerLeftX(),
|
||||
originalRectangle.getLowerLeftY(),
|
||||
originalRectangle.getWidth(),
|
||||
@@ -1205,59 +1713,43 @@ public class FormUtils {
|
||||
return null;
|
||||
}
|
||||
|
||||
private int resolveFirstWidgetPageIndex(PDDocument document, PDTerminalField field) {
|
||||
private int resolveFirstWidgetPageIndex(
|
||||
PDDocument document,
|
||||
PDTerminalField field,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets == null || widgets.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
Map<PDAnnotationWidget, Integer> widgetPageFallbacks = null;
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
int idx = resolveWidgetPageIndex(document, widget);
|
||||
int idx = resolveWidgetPageIndex(document, widget, annotationPageMap);
|
||||
if (idx >= 0) {
|
||||
return idx;
|
||||
}
|
||||
try {
|
||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||
if (widgetDictionary != null
|
||||
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
|
||||
if (widgetPageFallbacks == null) {
|
||||
widgetPageFallbacks = buildWidgetPageFallbackMap(document);
|
||||
}
|
||||
Integer fallbackIndex = widgetPageFallbacks.get(widget);
|
||||
if (fallbackIndex != null && fallbackIndex >= 0) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to inspect widget page reference for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int resolveWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
|
||||
private int resolveWidgetPageIndex(
|
||||
PDDocument document,
|
||||
PDAnnotationWidget widget,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
if (document == null || widget == null) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||
if (widgetDictionary != null
|
||||
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
|
||||
Map<PDAnnotationWidget, Integer> fallback = buildWidgetPageFallbackMap(document);
|
||||
Integer index = fallback.get(widget);
|
||||
if (index != null) {
|
||||
return index;
|
||||
}
|
||||
|
||||
// Method 0: Check the pre-built lookup map (fastest)
|
||||
if (annotationPageMap != null) {
|
||||
Integer idx = annotationPageMap.get(widget.getCOSObject());
|
||||
if (idx != null) {
|
||||
return idx;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Widget page lookup via fallback map failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
PDPage page = widget.getPage();
|
||||
if (page != null) {
|
||||
// indexOf is O(N), still slower than map but better than scanning annotations
|
||||
int idx = document.getPages().indexOf(page);
|
||||
if (idx >= 0) {
|
||||
return idx;
|
||||
@@ -1267,14 +1759,36 @@ public class FormUtils {
|
||||
log.debug("Widget page lookup failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Method 1: Check the /P entry if it points to a page
|
||||
try {
|
||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||
if (widgetDictionary != null) {
|
||||
COSBase base = widgetDictionary.getDictionaryObject(COSName.P);
|
||||
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
|
||||
if (pageDict != null) {
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
if (document.getPage(i).getCOSObject() == pageDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Widget page lookup via /P entry failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Method 2: Fallback search through all pages' annotations
|
||||
int pageCount = document.getNumberOfPages();
|
||||
COSDictionary widgetDict = widget.getCOSObject();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
try {
|
||||
PDPage candidate = document.getPage(i);
|
||||
List<PDAnnotation> annotations = candidate.getAnnotations();
|
||||
for (PDAnnotation annotation : annotations) {
|
||||
if (annotation == widget) {
|
||||
return i;
|
||||
if (annotations != null) {
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot != null && annot.getCOSObject() == widgetDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
@@ -1317,7 +1831,7 @@ public class FormUtils {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets != null) {
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
PDPage page = resolveWidgetPage(document, widget);
|
||||
PDPage page = resolveWidgetPage(document, widget, null);
|
||||
if (page != null) {
|
||||
page.getAnnotations().remove(widget);
|
||||
}
|
||||
@@ -1437,7 +1951,10 @@ public class FormUtils {
|
||||
rectangle.getHeight());
|
||||
}
|
||||
|
||||
private PDPage resolveWidgetPage(PDDocument document, PDAnnotationWidget widget) {
|
||||
private PDPage resolveWidgetPage(
|
||||
PDDocument document,
|
||||
PDAnnotationWidget widget,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
if (widget == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -1445,7 +1962,7 @@ public class FormUtils {
|
||||
if (page != null) {
|
||||
return page;
|
||||
}
|
||||
int pageIndex = determineWidgetPageIndex(document, widget);
|
||||
int pageIndex = determineWidgetPageIndex(document, widget, annotationPageMap);
|
||||
if (pageIndex >= 0) {
|
||||
try {
|
||||
return document.getPage(pageIndex);
|
||||
@@ -1456,11 +1973,21 @@ public class FormUtils {
|
||||
return null;
|
||||
}
|
||||
|
||||
private int determineWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
|
||||
private int determineWidgetPageIndex(
|
||||
PDDocument document,
|
||||
PDAnnotationWidget widget,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
if (document == null || widget == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (annotationPageMap != null) {
|
||||
Integer idx = annotationPageMap.get(widget.getCOSObject());
|
||||
if (idx != null) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
PDPage directPage = widget.getPage();
|
||||
if (directPage != null) {
|
||||
int index = 0;
|
||||
@@ -1488,6 +2015,33 @@ public class FormUtils {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of annotation COS dictionaries to their respective page index. Scan once
|
||||
* per-document to avoid O(N^2) lookups during field extraction.
|
||||
*/
|
||||
public Map<COSDictionary, Integer> buildAnnotationPageMap(PDDocument document) {
|
||||
if (document == null) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
Map<COSDictionary, Integer> map = new HashMap<>();
|
||||
int pageCount = document.getNumberOfPages();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
try {
|
||||
PDPage page = document.getPage(i);
|
||||
List<PDAnnotation> annotations = page.getAnnotations();
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot != null) {
|
||||
map.putIfAbsent(annot.getCOSObject(), i);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to index annotations for page {}: {}", i, e.getMessage());
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<PDAnnotationWidget, Integer> buildWidgetPageFallbackMap(PDDocument document) {
|
||||
if (document == null) {
|
||||
return Collections.emptyMap();
|
||||
@@ -1760,4 +2314,46 @@ public class FormUtils {
|
||||
boolean multiSelect,
|
||||
String tooltip,
|
||||
int pageOrder) {}
|
||||
|
||||
/**
|
||||
* Comparator for sorting form fields by page, then vertically (top-to-bottom), then
|
||||
* horizontally (left-to-right) for fields on approximately the same line.
|
||||
*/
|
||||
static final class FieldCoordinateComparator implements Comparator<FormFieldWithCoordinates> {
|
||||
|
||||
private static int firstWidgetPageIndex(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getPageIndex()
|
||||
: -1;
|
||||
}
|
||||
|
||||
private static float firstWidgetY(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getY()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static float firstWidgetX(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getX()
|
||||
: 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(FormFieldWithCoordinates a, FormFieldWithCoordinates b) {
|
||||
int pageA = firstWidgetPageIndex(a);
|
||||
int pageB = firstWidgetPageIndex(b);
|
||||
int pageCompare = Integer.compare(pageA, pageB);
|
||||
if (pageCompare != 0) return pageCompare;
|
||||
|
||||
float yA = firstWidgetY(a);
|
||||
float yB = firstWidgetY(b);
|
||||
|
||||
// Fields on approximately the same line should be sorted left-to-right
|
||||
if (Math.abs(yA - yB) < SAME_LINE_THRESHOLD_PT) {
|
||||
return Float.compare(firstWidgetX(a), firstWidgetX(b));
|
||||
}
|
||||
return Float.compare(yA, yB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
-3
@@ -10,6 +10,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -36,6 +38,7 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
|
||||
import stirling.software.proprietary.security.service.JwtServiceInterface;
|
||||
import stirling.software.proprietary.security.service.LoginAttemptService;
|
||||
import stirling.software.proprietary.security.service.MfaService;
|
||||
import stirling.software.proprietary.security.service.RefreshRateLimitService;
|
||||
import stirling.software.proprietary.security.service.TotpService;
|
||||
import stirling.software.proprietary.security.service.UserService;
|
||||
|
||||
@@ -53,11 +56,17 @@ class AuthControllerLoginTest {
|
||||
@Mock private LoginAttemptService loginAttemptService;
|
||||
@Mock private MfaService mfaService;
|
||||
@Mock private TotpService totpService;
|
||||
@Mock private RefreshRateLimitService refreshRateLimitService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setLoginMethod("all");
|
||||
securityProperties.getJwt().setTokenExpiryMinutes(60);
|
||||
securityProperties.getJwt().setRefreshGraceMinutes(5);
|
||||
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
applicationProperties.setSecurity(securityProperties);
|
||||
|
||||
AuthController controller =
|
||||
new AuthController(
|
||||
@@ -67,7 +76,9 @@ class AuthControllerLoginTest {
|
||||
loginAttemptService,
|
||||
mfaService,
|
||||
totpService,
|
||||
securityProperties);
|
||||
refreshRateLimitService,
|
||||
securityProperties,
|
||||
applicationProperties);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@@ -175,7 +186,11 @@ class AuthControllerLoginTest {
|
||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "user@example.com");
|
||||
claims.put("exp", new Date(System.currentTimeMillis() + 60_000));
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
// Rate limiting is not checked for valid tokens, so no stub needed
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
@@ -184,7 +199,75 @@ class AuthControllerLoginTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.user").exists())
|
||||
.andExpect(jsonPath("$.session.access_token").value("new-token"))
|
||||
.andExpect(jsonPath("$.session.expires_in").value(3600));
|
||||
.andExpect(
|
||||
jsonPath("$.session.expires_in")
|
||||
.value(3600)); // 60 minutes * 60 = 3600 seconds
|
||||
|
||||
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
|
||||
// period
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRejectsTokenExpiredBeyondGrace() throws Exception {
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "user@example.com");
|
||||
claims.put(
|
||||
"exp",
|
||||
new Date(
|
||||
System.currentTimeMillis()
|
||||
- (10 * 60_000))); // 10 minutes ago, beyond 5 minute grace
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isUnauthorized())
|
||||
.andExpect(jsonPath("$.error").value("Token refresh failed"));
|
||||
|
||||
verify(userDetailsService, never()).loadUserByUsername(any());
|
||||
verify(refreshRateLimitService, never()).isRefreshAllowed(any(), any(Long.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshAcceptsTokenExpiredWithinGrace() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "user@example.com");
|
||||
claims.put(
|
||||
"exp",
|
||||
new Date(
|
||||
System.currentTimeMillis()
|
||||
- 60_000)); // 1 minute ago, within 5 minute grace
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(true);
|
||||
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.session.access_token").value("new-token"));
|
||||
|
||||
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
|
||||
// period
|
||||
}
|
||||
|
||||
@Test
|
||||
void refreshRejectsWhenRateLimitExceeded() throws Exception {
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("sub", "user@example.com");
|
||||
claims.put("exp", new Date(System.currentTimeMillis() - 60_000)); // 1 minute ago
|
||||
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
|
||||
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(false);
|
||||
|
||||
mockMvc.perform(post("/api/v1/auth/refresh"))
|
||||
.andExpect(status().isTooManyRequests())
|
||||
.andExpect(jsonPath("$.error").value("Too many refresh attempts"))
|
||||
.andExpect(jsonPath("$.max_attempts").exists());
|
||||
|
||||
verify(userDetailsService, never()).loadUserByUsername(any());
|
||||
verify(refreshRateLimitService, never()).clearRefreshAttempts(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+7
-1
@@ -37,13 +37,19 @@ class CustomOAuth2AuthenticationSuccessHandlerTest {
|
||||
oauth2Props.setAutoCreateUser(true);
|
||||
oauth2Props.setBlockRegistration(false);
|
||||
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
ApplicationProperties.Security securityProperties = new ApplicationProperties.Security();
|
||||
securityProperties.setOauth2(oauth2Props);
|
||||
applicationProperties.setSecurity(securityProperties);
|
||||
|
||||
CustomOAuth2AuthenticationSuccessHandler handler =
|
||||
new CustomOAuth2AuthenticationSuccessHandler(
|
||||
loginAttemptService,
|
||||
oauth2Props,
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService);
|
||||
licenseSettingsService,
|
||||
applicationProperties);
|
||||
|
||||
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
|
||||
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
|
||||
|
||||
+3
-15
@@ -31,6 +31,7 @@ import org.springframework.security.core.Authentication;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.proprietary.security.model.JwtVerificationKey;
|
||||
import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
|
||||
@@ -64,7 +65,8 @@ class JwtServiceTest {
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
|
||||
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
|
||||
|
||||
jwtService = new JwtService(true, keystoreService);
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
jwtService = new JwtService(true, keystoreService, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,8 +75,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -94,8 +94,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -114,8 +112,6 @@ class JwtServiceTest {
|
||||
void testValidateTokenSuccess() throws Exception {
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn("testuser");
|
||||
|
||||
@@ -179,8 +175,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(user);
|
||||
when(user.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -207,8 +201,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -281,8 +273,6 @@ class JwtServiceTest {
|
||||
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
@@ -307,8 +297,6 @@ class JwtServiceTest {
|
||||
// First, generate a token successfully
|
||||
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
|
||||
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
|
||||
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
|
||||
.thenReturn(testKeyPair.getPublic());
|
||||
when(authentication.getPrincipal()).thenReturn(userDetails);
|
||||
when(userDetails.getUsername()).thenReturn(username);
|
||||
|
||||
|
||||
+4
-1
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -16,6 +17,8 @@ import stirling.software.proprietary.security.util.Base32Codec;
|
||||
|
||||
class TotpServiceTest {
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("[A-Z2-7]+");
|
||||
|
||||
private TotpService buildService(String appName) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
@@ -32,7 +35,7 @@ class TotpServiceTest {
|
||||
|
||||
assertNotNull(secret);
|
||||
assertEquals(32, secret.length());
|
||||
assertTrue(secret.matches("[A-Z2-7]+"));
|
||||
assertTrue(PATTERN.matcher(secret).matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+19
-1
@@ -67,7 +67,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.4.3'
|
||||
version = '2.5.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
@@ -461,6 +461,24 @@ swaggerhubUpload {
|
||||
oas = "3.0.0" // The version of the OpenAPI Specification you"re using
|
||||
}
|
||||
|
||||
repositories {
|
||||
if (!rootProject.ext.mavenUrl.isEmpty()) {
|
||||
maven {
|
||||
url = rootProject.ext.mavenUrl + '/releases'
|
||||
credentials(PasswordCredentials) {
|
||||
username = rootProject.ext.username
|
||||
password = rootProject.ext.password
|
||||
}
|
||||
authentication {
|
||||
basic(BasicAuthentication)
|
||||
}
|
||||
allowInsecureProtocol = true
|
||||
}
|
||||
}
|
||||
maven { url = "https://build.shibboleth.net/maven/releases" }
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(':stirling-pdf')
|
||||
implementation project(':common')
|
||||
|
||||
@@ -35,7 +35,7 @@ RUN DISABLE_ADDITIONAL_FEATURES=true \
|
||||
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
|
||||
|
||||
# Stage 2: Runtime image
|
||||
FROM alpine:3.23.2@sha256:865b95f46d98cf867a156fe4a135ad3fe50d2056aa3f25ed31662dff6da4eb62
|
||||
FROM alpine:3.23.3@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
|
||||
|
||||
ENV LANG=C.UTF-8 \
|
||||
LC_ALL=C.UTF-8
|
||||
@@ -58,6 +58,7 @@ LABEL org.opencontainers.image.keywords="PDF, manipulation, ultra-lite, API, Spr
|
||||
# Copy scripts
|
||||
COPY scripts/init-without-ocr.sh /scripts/init-without-ocr.sh
|
||||
COPY scripts/installFonts.sh /scripts/installFonts.sh
|
||||
COPY scripts/stirling-diagnostics.sh /scripts/stirling-diagnostics.sh
|
||||
|
||||
# Copy built JAR from build stage
|
||||
COPY --from=build /app/app/core/build/libs/*.jar /app.jar
|
||||
|
||||
Generated
+269
-175
@@ -10,29 +10,31 @@
|
||||
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^2.3.0",
|
||||
"@embedpdf/engines": "^2.3.0",
|
||||
"@embedpdf/models": "^2.3.0",
|
||||
"@embedpdf/plugin-annotation": "^2.3.0",
|
||||
"@embedpdf/plugin-bookmark": "^2.3.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.3.0",
|
||||
"@embedpdf/plugin-export": "^2.3.0",
|
||||
"@embedpdf/plugin-history": "^2.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.3.0",
|
||||
"@embedpdf/plugin-pan": "^2.3.0",
|
||||
"@embedpdf/plugin-print": "^2.3.0",
|
||||
"@embedpdf/plugin-redaction": "^2.3.0",
|
||||
"@embedpdf/plugin-render": "^2.3.0",
|
||||
"@embedpdf/plugin-rotate": "^2.3.0",
|
||||
"@embedpdf/plugin-scroll": "^2.3.0",
|
||||
"@embedpdf/plugin-search": "^2.3.0",
|
||||
"@embedpdf/plugin-selection": "^2.3.0",
|
||||
"@embedpdf/plugin-spread": "^2.3.0",
|
||||
"@embedpdf/plugin-thumbnail": "^2.3.0",
|
||||
"@embedpdf/plugin-tiling": "^2.3.0",
|
||||
"@embedpdf/plugin-viewport": "^2.3.0",
|
||||
"@embedpdf/plugin-zoom": "^2.3.0",
|
||||
"@embedpdf/core": "^2.6.0",
|
||||
"@embedpdf/engines": "^2.6.0",
|
||||
"@embedpdf/models": "^2.6.0",
|
||||
"@embedpdf/plugin-annotation": "^2.6.0",
|
||||
"@embedpdf/plugin-attachment": "^2.6.0",
|
||||
"@embedpdf/plugin-bookmark": "^2.6.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.6.0",
|
||||
"@embedpdf/plugin-export": "^2.6.0",
|
||||
"@embedpdf/plugin-history": "^2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.6.0",
|
||||
"@embedpdf/plugin-pan": "^2.6.0",
|
||||
"@embedpdf/plugin-print": "^2.6.0",
|
||||
"@embedpdf/plugin-redaction": "^2.6.0",
|
||||
"@embedpdf/plugin-render": "^2.6.0",
|
||||
"@embedpdf/plugin-rotate": "^2.6.0",
|
||||
"@embedpdf/plugin-scroll": "^2.6.0",
|
||||
"@embedpdf/plugin-search": "^2.6.0",
|
||||
"@embedpdf/plugin-selection": "^2.6.0",
|
||||
"@embedpdf/plugin-spread": "^2.6.0",
|
||||
"@embedpdf/plugin-thumbnail": "^2.6.0",
|
||||
"@embedpdf/plugin-tiling": "^2.6.0",
|
||||
"@embedpdf/plugin-viewport": "^2.6.0",
|
||||
"@embedpdf/plugin-zoom": "^2.6.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -49,6 +51,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-http": "^2.5.6",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
@@ -59,7 +62,6 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"posthog-js": "^1.268.0",
|
||||
@@ -364,6 +366,21 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@cantoo/pdf-lib": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.5.3.tgz",
|
||||
"integrity": "sha512-SBQp8i/XdWNUhLutn5P67Pwj4X9vU046BRpfOMODJZuYVrgChtsTfgdnlW2O7x8gdXs8j7NoTaWI/b78E2oVmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"color": "^4.2.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"node-html-better-parser": ">=1.4.0",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": ">=2"
|
||||
}
|
||||
},
|
||||
"node_modules/@csstools/color-helpers": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
|
||||
@@ -553,13 +570,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.3.0.tgz",
|
||||
"integrity": "sha512-aPD7lNSCOLc5Nos9xGA3qAT5jFZdrTT7IVcpxtM1BOKa1FI0XmotJ8vgzcRxH/FLwUASC4xwR9QxzTKp2aLsZQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.6.0.tgz",
|
||||
"integrity": "sha512-859GUvZ3BLpJuKTiwcPPMNn9CSlMaPjQ4yXnyQRngfbvDAiijIIpVLaC98B08Nx6QsUcD3cs/6+wkB888lNsDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "2.3.0",
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/engines": "2.6.0",
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -570,9 +587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/engines": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.3.0.tgz",
|
||||
"integrity": "sha512-QxNY58E2HgNgnbsTt5TnDUNvKoyabkf5IniGsiN5+rx6f4SFDpCnz3h1VJxNReWDyn9e16QlkUfgXX0qQWd3iQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.6.0.tgz",
|
||||
"integrity": "sha512-zW3927u0wbFBD2tQLWbE45DEBIMkZyN7n5O2p70er6u7mP1XYEz7Ud9NxcPL/3b5MzDfPBTSyxM3T12e+ZeAxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/fonts-arabic": "1.0.0",
|
||||
@@ -582,8 +599,8 @@
|
||||
"@embedpdf/fonts-latin": "1.0.0",
|
||||
"@embedpdf/fonts-sc": "1.0.0",
|
||||
"@embedpdf/fonts-tc": "1.0.0",
|
||||
"@embedpdf/models": "2.3.0",
|
||||
"@embedpdf/pdfium": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/pdfium": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -636,31 +653,48 @@
|
||||
"license": "OFL-1.1"
|
||||
},
|
||||
"node_modules/@embedpdf/models": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.3.0.tgz",
|
||||
"integrity": "sha512-YAH3YdXl/UOhVcvMPd6mtU+tJ3veh24Q5swRDfuWUsJ3L2CcAG2P+4pjj4EAwvWUQcmN/HlVOjVQL0PkbkytKw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.6.0.tgz",
|
||||
"integrity": "sha512-6zuoJE79WXyRXKhJXhl+8p4njuC1nxPpKYRIs54PRLgTkHOLaou+G+ZunEd99XOoVssHLCjxWBUpg46ihQwXDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/pdfium": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.3.0.tgz",
|
||||
"integrity": "sha512-AIWHDDG24we1r8sWVO9Uae6V2ISXji2gIkZS3+CjtYowaBCpMTSu4QEQRnjQam2EWrEMVIJOXwBfx11TZKrxWA==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.6.0.tgz",
|
||||
"integrity": "sha512-eYXU1VvVI0e9OqOzvsTcsU6YSLq9F7jcAiIbtMB+NxApvvH3kHz3FPEcf8ha2ZiLftF5OAD8K89SSE5GLE6t1A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-annotation": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.3.0.tgz",
|
||||
"integrity": "sha512-TIN/OiDTg5tCNsebp1SWnS6aa7nnDvRrrZe3jx7Sg5IMEiZc6P3z+0aOjJtvoz0cp3Xi7Bb0PQsTLwo+bdfpVg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.6.0.tgz",
|
||||
"integrity": "sha512-FJgGy6lhKrWsiJjh7jZ92NwMBob5GOwfYejQl28JFk6muEQORLtysz5gaeyMpMIyxnfjlf9Eqv8Z6LBBfGLGOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0",
|
||||
"@embedpdf/utils": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/utils": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/plugin-history": "2.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.3.0",
|
||||
"@embedpdf/plugin-selection": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-history": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"@embedpdf/plugin-selection": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"svelte": ">=5 <6",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-attachment": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.6.0.tgz",
|
||||
"integrity": "sha512-6UZkj7jFWCruR69OPQFMqbJTgwdra4rnJSBfLA8yLxgz2zTsgt3owjfQDmlJvAQ7G1/rZM2T+EJeuozulj4NoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -669,15 +703,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-bookmark": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.3.0.tgz",
|
||||
"integrity": "sha512-7XO2NntgRb/Jk1XN/EOf7+yVaOPVVFvBuF0xlCqnz2BGAnMNrTn8QE73FtluJBgNhuK9LwDT2C4W+BTD2gd59Q==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.6.0.tgz",
|
||||
"integrity": "sha512-4JmaFD+gFaLj8Bayi6Fm5qxMoRH+JUy+L3S6xk1KM8YWjJyzsoz9C2mHSXKJ0GBMgiOkjaBuJSWqLgMe/oz7OQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -686,15 +720,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-document-manager": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.3.0.tgz",
|
||||
"integrity": "sha512-hdKaWU1sjlLgXo2iWF4N734lklCfSO5Tj1xqk+0omxOpnVL1Ed5fzFO2N584pMkfFn1xo9Y2JPHSUtCdzF7/EQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.6.0.tgz",
|
||||
"integrity": "sha512-fcx0JKDboEV8eQ4r++ksDHPDuUz40oOmtHDqxYLw6cpos0fqW0p55OP+fKp6LfC/bY7ULVDrmcEQf1cD9Qho4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -703,15 +737,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.3.0.tgz",
|
||||
"integrity": "sha512-Xa048lKnc1jehWbaWv5qER1RVIHhHqt+JhgzAlqFSURXmzowbUzVEDBZ7fYImXRkpqp+ZeyBhWfZ60DBNE55Cw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.6.0.tgz",
|
||||
"integrity": "sha512-i1Xy7qUipVVLDPnnY22hm3RNMx33lvuNbCuPggql5Ws6WBLG9YhDsK+v0JVe2sDSlifa5SJwuBlMHZWPRTZyxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -720,15 +754,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-history": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.3.0.tgz",
|
||||
"integrity": "sha512-+fr/kjK2Z9BiC53IMlUZvWjkD6iilcI3XCUKQPXRgS5MDAuwpVlgdAtc+3VAMlG3IddElxVFdvvxRO9R89k5Mg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.6.0.tgz",
|
||||
"integrity": "sha512-cfVoBjkIbFiRsQu/cwPEi0rrTAF7jriAGzABWawnSTKYEPFrU3LDHO7TewgBz45kHl9pSwvRexaIdTR8ECIKbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -737,15 +771,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.3.0.tgz",
|
||||
"integrity": "sha512-1/tDLPoQm6skNe/WOd6QD7SA0XRKphbJHi/s9XY4fhGgBvlD5XHFrYxtmrsaheYjqIBFtAWWZ3m5lAXRaO/igA==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.6.0.tgz",
|
||||
"integrity": "sha512-9bruF6M6GKVdABRTinHsZ+izf2tDQwDEcNI0CHVc5gurrz3CQfAGP2sJkv8uQrXyYTK3zV2Oq6zGknk7Hdx9mA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -754,17 +788,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-pan": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.3.0.tgz",
|
||||
"integrity": "sha512-5yGxLpn28PHKCYx3tjzeVir7D5vHZ0Fk9HJRJr4K+Uqbg8pYFavb9tseXzPE4FcqpejqZo2DZyfo54ErQFXEyQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.6.0.tgz",
|
||||
"integrity": "sha512-r8AXcXUy6NMYDaQeixScbeFfmZIvWpUUjx3gxjP4J90xfxXnuz/g/lnh4D2DBaiK4mt6crIVBpXU9IUwMIcUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.3.0",
|
||||
"@embedpdf/plugin-viewport": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -773,15 +807,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-print": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.3.0.tgz",
|
||||
"integrity": "sha512-LNxvXm3rZkRXXC41IArBDiwPLzSflmBmxxi+L+91xvw8n/FWUeXfWwQn7oQEAGq9Ha/3pEVHTls48QSFZN0mhg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.6.0.tgz",
|
||||
"integrity": "sha512-cgWRqVtRgCCLCn1ViuZEFr+ZJ3QI61/5s9tl3T9x81rwkBN4HT582BYzyRnLBzTMYKxkQZDr1WxfS8ctdlHEUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0",
|
||||
@@ -790,18 +824,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-redaction": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.3.0.tgz",
|
||||
"integrity": "sha512-un6AQL5Pqcm9v1tCV9Mb3NeowsGUtlCT/198k4nd+SWOMWNsbuFqI+rWOGV3auqXRGSzKj0gnt29t8aaeLpLeA==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.6.0.tgz",
|
||||
"integrity": "sha512-DdDnmOl9K0N4dpTeUohavxQyrfollhkjT+zdfkna3Fc7F4jfl3Vg6uKoGmT71A+Vp4uTGNLt6cNCscbJW9E9kQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0",
|
||||
"@embedpdf/utils": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/utils": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.3.0",
|
||||
"@embedpdf/plugin-selection": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-annotation": "2.6.0",
|
||||
"@embedpdf/plugin-history": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"@embedpdf/plugin-selection": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -810,15 +846,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.3.0.tgz",
|
||||
"integrity": "sha512-UyQncK5NTokuEVISUcxPOXpZP4SItn4MjfeEaPsTXJkSRjHL4g3mU3iWy0nXJMCOT10OB+5m7qQ0/KkF4f+b5w==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.6.0.tgz",
|
||||
"integrity": "sha512-Rk4QCxDOzhQrvKPt/G3G+p5ELwnKFkC5ljHMd7ND23atR9E3wm5W3+Nx3FaAYYPrpfqQ7BrbKnfQ7SkUbDxS3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -827,15 +863,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-rotate": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.3.0.tgz",
|
||||
"integrity": "sha512-vibDXHA0L2LlMrmkSuanmdtUpc2JPBuQybiGwf9F4wlleKN3f7uSWxZsHdVAxWdzsaG+/26QTGl75otZLnVuig==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.6.0.tgz",
|
||||
"integrity": "sha512-zgF2S5cfkOxkOWrwoLQLN8scJgKBEhyhVOv/RNdeAKP6qE3h28AGRmDeMsekBDbiInlIxIHzynE5vVcTNf5EnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -844,16 +880,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-scroll": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.3.0.tgz",
|
||||
"integrity": "sha512-8pdaSY9QuqdX22Ykw2jKn07Rx6FIsDdj/O0+mlbccY/ISofj9WEFNeQgnOY64OUTDyurJYqpYvq6QqvgbGLs+A==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.6.0.tgz",
|
||||
"integrity": "sha512-BEgSy6cs9+MLCS0Z3/FYMdA4Ygt6ddYIAg28XlF20kN3tLj8BQUo5qx6adI+SlwrFFGY52VAjjK7VSBuGfn19g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/plugin-viewport": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -862,15 +898,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-search": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.3.0.tgz",
|
||||
"integrity": "sha512-VNXmNf7fIIRWGVwf2kIUeUeLkUTJlq9AGjUO2TyuYJTWTsmfT4LEqPDDpwC6NDVFhzWE6xwbb3bxvY/9bqBMzw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.6.0.tgz",
|
||||
"integrity": "sha512-GSzJkmuK9LE7LmlTwnDl71KdD9prHlCjgFs5Tm0K8qjELOSH+oduFXusIuf654+UQveDYczpzBVUcqb4yBf1xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -879,17 +915,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-selection": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.3.0.tgz",
|
||||
"integrity": "sha512-+emaY4vff3ynAf5C3PfCOlleQIqiImbBpb6zkG5SVUa9Vn5x0SfYGT4Jumtbzq8XBknC1QIRKVlplC9BcnjcmQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.6.0.tgz",
|
||||
"integrity": "sha512-VrW0duVxLwaquInwmuNDMz8o0tfCDwe3j81fvTUDW/s7KqnzFbxK7vEuq5TEtxWuSng2DXxyX3r1ntCm4X/NCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0",
|
||||
"@embedpdf/utils": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/utils": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -898,15 +934,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-spread": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.3.0.tgz",
|
||||
"integrity": "sha512-sFqYKwzKGPaCXn6hAyv6GHdVTlL2vg3poxRNd2W5kLQo07YtHlSjXr/XAhaGT/a4GtR9rtbSJ4hWNJjzIcwE0g==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.6.0.tgz",
|
||||
"integrity": "sha512-0mzPCJlw1X7jWeDg5JssU6/HCFtyOP7scEdbIaASYzofGXa2Rj8/+L+UDBrb+KTF6CR4X6fEfeNmxWmAatOAWQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -915,16 +951,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-thumbnail": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.3.0.tgz",
|
||||
"integrity": "sha512-CAOnipeBtdKSHGBuIm5420GykUw7k2rB7Z9GwouTbbycS7Cw+kiaGpOfHfenoKPTlWMkHYAwFcZiWKV3XG/nRQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.6.0.tgz",
|
||||
"integrity": "sha512-Sj4jCV1MNk+19zKWX4KfSl5c0YHrqVG83pEYfqexjSkSX7y7HRwAOtMBtNd3uLInPPSBnzDxj+KlJlIe8RPPJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/plugin-render": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-render": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -933,18 +969,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-tiling": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.3.0.tgz",
|
||||
"integrity": "sha512-6VJ042WksIyZVWyvXq1nf0Ct+U4Pl6+QUDy1ThJefwk/HKDfWU2zEr/+1STJKVWgfUx5QRdipf6Jghd+HnOg3Q==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.6.0.tgz",
|
||||
"integrity": "sha512-qyiHWljryHWQ7uzip2WDg4x28o/1QM0wh9oIyz5WlBnrDaK6bLJGsWUym5P6WfLp0Y8h6GFAslNNcgjBv6E3qw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/plugin-render": "2.3.0",
|
||||
"@embedpdf/plugin-scroll": "2.3.0",
|
||||
"@embedpdf/plugin-viewport": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-render": "2.6.0",
|
||||
"@embedpdf/plugin-scroll": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -953,15 +989,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-viewport": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.3.0.tgz",
|
||||
"integrity": "sha512-3NQp3hVfRF7DMUPNAVOfZsqQQrugEfY0voRUrQI90eyi16GFntN3CP9Mc5cOp2jnUICMYlirQ/om+KCseMHS2Q==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.6.0.tgz",
|
||||
"integrity": "sha512-Ea7s+LivQ4ph01mVngU2tu2Ni/zulxzIyiifCpMaBMHmvjGFQjJcNNYHR90YuM8keto82KCszxdNDuAEEzT6Wg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -970,17 +1006,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-zoom": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.3.0.tgz",
|
||||
"integrity": "sha512-wnBqK02ku0zCViqQfSD1Vohy+aBUogXrqUTwo1/1QFEphmgnCHnHbEUduh9M0ghcT4s26pBgbJqCrShpGYAdvQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.6.0.tgz",
|
||||
"integrity": "sha512-2XUgasN2ZQm2MgpB6ls/re/SKhsREvt2D1gIcvJgXvGkene0NcpxGNIRi/+JN7W0fw4x3QtwLQtyF+/0uMgPmg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.3.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.3.0",
|
||||
"@embedpdf/plugin-scroll": "2.3.0",
|
||||
"@embedpdf/plugin-viewport": "2.3.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-scroll": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -989,9 +1025,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/utils": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.3.0.tgz",
|
||||
"integrity": "sha512-9DV+tu+GsnijchNSG/NzslnxTGIUH6j2MxBR8QOoZLsWETEVaMLkHtbvzXPyMOx/5RlvBn8wR0jNKTNptOCnXQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.6.0.tgz",
|
||||
"integrity": "sha512-FT6U6L3Et688urUTyISpYH05w4sG+WzoWxaI7aPU4ieh4c/vVgadUtjZj/QCC8v+DebPYRAX1gpUY7e0Y0HlTQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -4178,6 +4214,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||
@@ -6450,11 +6495,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1",
|
||||
"color-string": "^1.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.5.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -6467,9 +6524,18 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/color-string": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "^1.0.0",
|
||||
"simple-swizzle": "^0.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -6588,6 +6654,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/css-tree": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
|
||||
@@ -8837,6 +8909,22 @@
|
||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-escaper": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||
@@ -10683,6 +10771,15 @@
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/node-html-better-parser": {
|
||||
"version": "1.5.8",
|
||||
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
|
||||
"integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"html-entities": "^2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
@@ -11238,24 +11335,6 @@
|
||||
"node": ">= 14.16"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/pdfjs-dist": {
|
||||
"version": "5.4.530",
|
||||
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz",
|
||||
@@ -12963,6 +13042,21 @@
|
||||
"integrity": "sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle/node_modules/is-arrayish": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/slash": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
|
||||
|
||||
+26
-23
@@ -7,28 +7,29 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^2.3.0",
|
||||
"@embedpdf/engines": "^2.3.0",
|
||||
"@embedpdf/models": "^2.3.0",
|
||||
"@embedpdf/plugin-annotation": "^2.3.0",
|
||||
"@embedpdf/plugin-bookmark": "^2.3.0",
|
||||
"@embedpdf/plugin-export": "^2.3.0",
|
||||
"@embedpdf/plugin-history": "^2.3.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.3.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.3.0",
|
||||
"@embedpdf/plugin-pan": "^2.3.0",
|
||||
"@embedpdf/plugin-print": "^2.3.0",
|
||||
"@embedpdf/plugin-redaction": "^2.3.0",
|
||||
"@embedpdf/plugin-render": "^2.3.0",
|
||||
"@embedpdf/plugin-rotate": "^2.3.0",
|
||||
"@embedpdf/plugin-scroll": "^2.3.0",
|
||||
"@embedpdf/plugin-search": "^2.3.0",
|
||||
"@embedpdf/plugin-selection": "^2.3.0",
|
||||
"@embedpdf/plugin-spread": "^2.3.0",
|
||||
"@embedpdf/plugin-thumbnail": "^2.3.0",
|
||||
"@embedpdf/plugin-tiling": "^2.3.0",
|
||||
"@embedpdf/plugin-viewport": "^2.3.0",
|
||||
"@embedpdf/plugin-zoom": "^2.3.0",
|
||||
"@embedpdf/core": "^2.6.0",
|
||||
"@embedpdf/engines": "^2.6.0",
|
||||
"@embedpdf/models": "^2.6.0",
|
||||
"@embedpdf/plugin-annotation": "^2.6.0",
|
||||
"@embedpdf/plugin-attachment": "^2.6.0",
|
||||
"@embedpdf/plugin-bookmark": "^2.6.0",
|
||||
"@embedpdf/plugin-export": "^2.6.0",
|
||||
"@embedpdf/plugin-history": "^2.6.0",
|
||||
"@embedpdf/plugin-document-manager": "^2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "^2.6.0",
|
||||
"@embedpdf/plugin-pan": "^2.6.0",
|
||||
"@embedpdf/plugin-print": "^2.6.0",
|
||||
"@embedpdf/plugin-redaction": "^2.6.0",
|
||||
"@embedpdf/plugin-render": "^2.6.0",
|
||||
"@embedpdf/plugin-rotate": "^2.6.0",
|
||||
"@embedpdf/plugin-scroll": "^2.6.0",
|
||||
"@embedpdf/plugin-search": "^2.6.0",
|
||||
"@embedpdf/plugin-selection": "^2.6.0",
|
||||
"@embedpdf/plugin-spread": "^2.6.0",
|
||||
"@embedpdf/plugin-thumbnail": "^2.6.0",
|
||||
"@embedpdf/plugin-tiling": "^2.6.0",
|
||||
"@embedpdf/plugin-viewport": "^2.6.0",
|
||||
"@embedpdf/plugin-zoom": "^2.6.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -45,6 +46,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-http": "^2.5.6",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
@@ -55,7 +57,7 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"posthog-js": "^1.268.0",
|
||||
@@ -71,6 +73,7 @@
|
||||
"web-vitals": "^5.1.0"
|
||||
},
|
||||
"scripts": {
|
||||
"pretauri-build": "node scripts/build-provisioner.mjs",
|
||||
"predev": "npm run generate-icons",
|
||||
"dev": "vite",
|
||||
"prebuild": "npm run generate-icons",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user