Compare commits

..
Author SHA1 Message Date
Anthony Stirling a182676d3a fix auto delete dont use try 2026-01-14 15:26:17 +00:00
Anthony Stirling 73f12f40bb temp file manager 2026-01-14 15:05:54 +00:00
Anthony Stirling 95d8917380 formatting 2026-01-14 14:33:42 +00:00
Anthony Stirling bdcc186b41 remove extra fix due to conflicts in naming so just reverting to
tempfiles
2026-01-14 14:29:47 +00:00
Anthony Stirling 77b813723a version bump 2026-01-14 11:37:00 +00:00
Anthony Stirling 40ac69f101 Merge branch 'main' into utf8 2026-01-14 11:22:38 +00:00
Anthony Stirling e8c67d9e37 formatting 2026-01-14 11:21:51 +00:00
Anthony Stirling 0b5c885750 utf8Fix 2026-01-14 11:03:21 +00:00
stirlingbot[bot]andLudy 72cc4e4963 Update Backend 3rd Party Licenses (#5458)
Auto-generated by stirlingbot[bot]

This PR updates the backend license report based on dependency changes.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-01-14 07:34:08 +00:00
stirlingbot[bot] b7041cfc78 🤖 format everything with pre-commit by stirlingbot (#5462)
Auto-generated by [create-pull-request][1] with **stirlingbot**

[1]: https://github.com/peter-evans/create-pull-request

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-01-14 01:09:41 +00:00
dependabot[bot] 8d6c8cbc11 build(deps): bump docker/login-action from 3.4.0 to 3.6.0 (#5464)
Bumps [docker/login-action](https://github.com/docker/login-action) from
3.4.0 to 3.6.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/login-action/releases">docker/login-action's
releases</a>.</em></p>
<blockquote>
<h2>v3.6.0</h2>
<ul>
<li>Add <code>registry-auth</code> input for raw authentication to
registries by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/887">docker/login-action#887</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> to 3.890.0 in <a
href="https://redirect.github.com/docker/login-action/pull/882">docker/login-action#882</a>
<a
href="https://redirect.github.com/docker/login-action/pull/890">docker/login-action#890</a></li>
<li>Bump <code>@​aws-sdk/client-ecr-public</code> to 3.890.0 in <a
href="https://redirect.github.com/docker/login-action/pull/882">docker/login-action#882</a>
<a
href="https://redirect.github.com/docker/login-action/pull/890">docker/login-action#890</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.62.1 to 0.63.0 in
<a
href="https://redirect.github.com/docker/login-action/pull/883">docker/login-action#883</a></li>
<li>Bump brace-expansion from 1.1.11 to 1.1.12 in <a
href="https://redirect.github.com/docker/login-action/pull/880">docker/login-action#880</a></li>
<li>Bump undici from 5.28.4 to 5.29.0 in <a
href="https://redirect.github.com/docker/login-action/pull/879">docker/login-action#879</a></li>
<li>Bump tmp from 0.2.3 to 0.2.4 in <a
href="https://redirect.github.com/docker/login-action/pull/881">docker/login-action#881</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v3.5.0...v3.6.0">https://github.com/docker/login-action/compare/v3.5.0...v3.6.0</a></p>
<h2>v3.5.0</h2>
<ul>
<li>Support dual-stack endpoints for AWS ECR by <a
href="https://github.com/Spacefish"><code>@​Spacefish</code></a> <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/login-action/pull/874">docker/login-action#874</a>
<a
href="https://redirect.github.com/docker/login-action/pull/876">docker/login-action#876</a></li>
<li>Bump <code>@​aws-sdk/client-ecr</code> to 3.859.0 in <a
href="https://redirect.github.com/docker/login-action/pull/860">docker/login-action#860</a>
<a
href="https://redirect.github.com/docker/login-action/pull/878">docker/login-action#878</a></li>
<li>Bump <code>@​aws-sdk/client-ecr-public</code> to 3.859.0 in <a
href="https://redirect.github.com/docker/login-action/pull/860">docker/login-action#860</a>
<a
href="https://redirect.github.com/docker/login-action/pull/878">docker/login-action#878</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.57.0 to 0.62.1 in
<a
href="https://redirect.github.com/docker/login-action/pull/870">docker/login-action#870</a></li>
<li>Bump form-data from 2.5.1 to 2.5.5 in <a
href="https://redirect.github.com/docker/login-action/pull/875">docker/login-action#875</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/login-action/compare/v3.4.0...v3.5.0">https://github.com/docker/login-action/compare/v3.4.0...v3.5.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/login-action/commit/5e57cd118135c172c3672efd75eb46360885c0ef"><code>5e57cd1</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/890">#890</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependenc...</li>
<li><a
href="https://github.com/docker/login-action/commit/97e31439e8b415da4e1322633630e1563c42c0f2"><code>97e3143</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/3a0796b57f440ff1af59165907392527fa832e0c"><code>3a0796b</code></a>
build(deps): bump the aws-sdk-dependencies group with 2 updates</li>
<li><a
href="https://github.com/docker/login-action/commit/5b7b28b1cc417bbd34cd8c225a957c9ce9adf7f2"><code>5b7b28b</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/882">#882</a>
from docker/dependabot/npm_and_yarn/aws-sdk-dependenc...</li>
<li><a
href="https://github.com/docker/login-action/commit/abc9fb3154ad354cf35d6c78a862bee018dd4cb8"><code>abc9fb3</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/d46868881477a1d16bdcc80a5b2c05208b1befe4"><code>d468688</code></a>
build(deps): bump the aws-sdk-dependencies group with 2 updates</li>
<li><a
href="https://github.com/docker/login-action/commit/a99b2f88fc4efabea32b8ba09581cf535c1577e9"><code>a99b2f8</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/883">#883</a>
from docker/dependabot/npm_and_yarn/docker/actions-to...</li>
<li><a
href="https://github.com/docker/login-action/commit/0d7fae8057d840a981e4132ce97862f6c8f48b42"><code>0d7fae8</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/login-action/commit/9832253cb7b14f93bd4134396bd26e855e8e4bd2"><code>9832253</code></a>
build(deps): bump <code>@​docker/actions-toolkit</code> from 0.62.1 to
0.63.0</li>
<li><a
href="https://github.com/docker/login-action/commit/09e05bbdf68bd9ce9eedefa6d2ebe03008c32b08"><code>09e05bb</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/login-action/issues/881">#881</a>
from docker/dependabot/npm_and_yarn/tmp-0.2.4</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/login-action/compare/v3.4.0...5e57cd118135c172c3672efd75eb46360885c0ef">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/login-action&package-manager=github_actions&previous-version=3.4.0&new-version=3.6.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 01:09:28 +00:00
dependabot[bot] 0e8992e3a5 build(deps): bump docker/setup-buildx-action from 3.11.1 to 3.12.0 (#5463)
Bumps
[docker/setup-buildx-action](https://github.com/docker/setup-buildx-action)
from 3.11.1 to 3.12.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/setup-buildx-action/releases">docker/setup-buildx-action's
releases</a>.</em></p>
<blockquote>
<h2>v3.12.0</h2>
<ul>
<li>Deprecate <code>install</code> input by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/455">docker/setup-buildx-action#455</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.62.1 to 0.63.0 in
<a
href="https://redirect.github.com/docker/setup-buildx-action/pull/434">docker/setup-buildx-action#434</a></li>
<li>Bump brace-expansion from 1.1.11 to 1.1.12 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/436">docker/setup-buildx-action#436</a></li>
<li>Bump form-data from 2.5.1 to 2.5.5 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/432">docker/setup-buildx-action#432</a></li>
<li>Bump undici from 5.28.4 to 5.29.0 in <a
href="https://redirect.github.com/docker/setup-buildx-action/pull/435">docker/setup-buildx-action#435</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/setup-buildx-action/compare/v3.11.1...v3.12.0">https://github.com/docker/setup-buildx-action/compare/v3.11.1...v3.12.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/8d2750c68a42422c14e847fe6c8ac0403b4cbd6f"><code>8d2750c</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/455">#455</a>
from crazy-max/install-deprecated</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/e81846bcc4416a0bf18b8389e4f6fce279619ddc"><code>e81846b</code></a>
deprecate install input</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/65d18f8f8a05aab1b2d761032bec9cd5578caadb"><code>65d18f8</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/454">#454</a>
from docker/dependabot/github_actions/actions/checkout-6</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/000d75d273dec231f74115df50b6ee04c9b25e55"><code>000d75d</code></a>
build(deps): bump actions/checkout from 5 to 6</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/1583c0f09d26c58c59d25b0eef29792b7ce99d9a"><code>1583c0f</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/443">#443</a>
from nicolasleger/patch-1</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/ed158e796328af702db08c75bebb92f2fb10be88"><code>ed158e7</code></a>
doc: bump actions/checkout from 4 to 5</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/4cc794f83e4b7488282e879f4469e86246e52ddd"><code>4cc794f</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/441">#441</a>
from docker/dependabot/github_actions/actions/checkout-5</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/4dfc3d6c5d9e9534040f8d8b55bd4a98459b62b7"><code>4dfc3d6</code></a>
build(deps): bump actions/checkout from 4 to 5</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/af1b253b8dc984466d22633f04ef341c1520ed2f"><code>af1b253</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-buildx-action/issues/440">#440</a>
from crazy-max/k3s-build</li>
<li><a
href="https://github.com/docker/setup-buildx-action/commit/3c6ab92b04d9f3e63233e0e0168ba114c6b448bc"><code>3c6ab92</code></a>
ci: k3s test with latest buildx</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/setup-buildx-action/compare/e468171a9de216ec08956ac3ada2f0791b6bd435...8d2750c68a42422c14e847fe6c8ac0403b4cbd6f">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/setup-buildx-action&package-manager=github_actions&previous-version=3.11.1&new-version=3.12.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 01:09:15 +00:00
dependabot[bot] aa39435303 build(deps): bump debian from 1c25564 to 449673e in /docker/embedded (#5357)
Bumps debian from `1c25564` to `449673e`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=debian&package-manager=docker&previous-version=stable-slim&new-version=stable-slim)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 00:40:24 +00:00
dependabot[bot] 3eaccc1e0e build(deps): bump alpine from 3.22.1 to 3.23.2 in /docker/embedded (#5319)
Bumps alpine from 3.22.1 to 3.23.2.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=alpine&package-manager=docker&previous-version=3.22.1&new-version=3.23.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 00:37:53 +00:00
dependabot[bot] 1a7fd6ac4e build(deps): bump alpine from 3.22.2 to 3.23.2 in /docker/backend (#5316)
Bumps alpine from 3.22.2 to 3.23.2.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=alpine&package-manager=docker&previous-version=3.22.2&new-version=3.23.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 00:37:34 +00:00
dependabot[bot] e49793a6b7 build(deps): bump urllib3 from 2.5.0 to 2.6.3 in /testing/cucumber in the pip group across 1 directory (#5456)
Bumps the pip group with 1 update in the /testing/cucumber directory:
[urllib3](https://github.com/urllib3/urllib3).

Updates `urllib3` from 2.5.0 to 2.6.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/releases">urllib3's
releases</a>.</em></p>
<blockquote>
<h2>2.6.3</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project after a sharp decline
in financial support. If your company or organization uses Python and
would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and
thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Changes</h2>
<ul>
<li>Fixed a security issue where decompression-bomb safeguards of the
streaming API were bypassed when HTTP redirects were followed.
(CVE-2026-21441 reported by <a
href="https://github.com/D47A"><code>@​D47A</code></a>, 8.9 High,
GHSA-38jv-5279-wg99)</li>
<li>Started treating <code>Retry-After</code> times greater than 6 hours
as 6 hours by default. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3743">urllib3/urllib3#3743</a>)</li>
<li>Fixed <code>urllib3.connection.VerifiedHTTPSConnection</code> on
Emscripten. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3752">urllib3/urllib3#3752</a>)</li>
</ul>
<h2>2.6.2</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project after a sharp decline
in financial support. If your company or organization uses Python and
would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and
thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Changes</h2>
<ul>
<li>Fixed <code>HTTPResponse.read_chunked()</code> to properly handle
leftover data in the decoder's buffer when reading compressed chunked
responses. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3734">urllib3/urllib3#3734</a>)</li>
</ul>
<h2>2.6.1</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project after a sharp decline
in financial support. If your company or organization uses Python and
would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and
thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Changes</h2>
<ul>
<li>Restore previously removed <code>HTTPResponse.getheaders()</code>
and <code>HTTPResponse.getheader()</code> methods. (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3731">#3731</a>)</li>
</ul>
<h2>2.6.0</h2>
<h2>🚀 urllib3 is fundraising for HTTP/2 support</h2>
<p><a
href="https://sethmlarson.dev/urllib3-is-fundraising-for-http2-support">urllib3
is raising ~$40,000 USD</a> to release HTTP/2 support and ensure
long-term sustainable maintenance of the project after a sharp decline
in financial support. If your company or organization uses Python and
would benefit from HTTP/2 support in Requests, pip, cloud SDKs, and
thousands of other projects <a
href="https://opencollective.com/urllib3">please consider contributing
financially</a> to ensure HTTP/2 support is developed sustainably and
maintained for the long-haul.</p>
<p>Thank you for your support.</p>
<h2>Security</h2>
<ul>
<li>Fixed a security issue where streaming API could improperly handle
highly compressed HTTP content (&quot;decompression bombs&quot;) leading
to excessive resource consumption even when a small amount of data was
requested. Reading small chunks of compressed data is safer and much
more efficient now. (CVE-2025-66471 reported by <a
href="https://github.com/Cycloctane"><code>@​Cycloctane</code></a>, 8.9
High, GHSA-2xpw-w6gg-jr37)</li>
<li>Fixed a security issue where an attacker could compose an HTTP
response with virtually unlimited links in the
<code>Content-Encoding</code> header, potentially leading to a denial of
service (DoS) attack by exhausting system resources during decoding. The
number of allowed chained encodings is now limited to 5. (CVE-2025-66418
reported by <a
href="https://github.com/illia-v"><code>@​illia-v</code></a>, 8.9 High,
GHSA-gm62-xv2j-4w53)</li>
</ul>
<blockquote>
<p>[!IMPORTANT]</p>
<ul>
<li>If urllib3 is not installed with the optional
<code>urllib3[brotli]</code> extra, but your environment contains a
Brotli/brotlicffi/brotlipy package anyway, make sure to upgrade it to at
least Brotli 1.2.0 or brotlicffi 1.2.0.0 to benefit from the security
fixes and avoid warnings. Prefer using <code>urllib3[brotli]</code> to
install a compatible Brotli package automatically.</li>
</ul>
</blockquote>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/urllib3/urllib3/blob/main/CHANGES.rst">urllib3's
changelog</a>.</em></p>
<blockquote>
<h1>2.6.3 (2026-01-07)</h1>
<ul>
<li>Fixed a high-severity security issue where decompression-bomb
safeguards of
the streaming API were bypassed when HTTP redirects were followed.
(<code>GHSA-38jv-5279-wg99
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-38jv-5279-wg99&gt;</code>__)</li>
<li>Started treating <code>Retry-After</code> times greater than 6 hours
as 6 hours by
default. (<code>[#3743](https://github.com/urllib3/urllib3/issues/3743)
&lt;https://github.com/urllib3/urllib3/issues/3743&gt;</code>__)</li>
<li>Fixed <code>urllib3.connection.VerifiedHTTPSConnection</code> on
Emscripten.
(<code>[#3752](https://github.com/urllib3/urllib3/issues/3752)
&lt;https://github.com/urllib3/urllib3/issues/3752&gt;</code>__)</li>
</ul>
<h1>2.6.2 (2025-12-11)</h1>
<ul>
<li>Fixed <code>HTTPResponse.read_chunked()</code> to properly handle
leftover data in
the decoder's buffer when reading compressed chunked responses.
(<code>[#3734](https://github.com/urllib3/urllib3/issues/3734)
&lt;https://github.com/urllib3/urllib3/issues/3734&gt;</code>__)</li>
</ul>
<h1>2.6.1 (2025-12-08)</h1>
<ul>
<li>Restore previously removed <code>HTTPResponse.getheaders()</code>
and
<code>HTTPResponse.getheader()</code> methods.
(<code>[#3731](https://github.com/urllib3/urllib3/issues/3731)
&lt;https://github.com/urllib3/urllib3/issues/3731&gt;</code>__)</li>
</ul>
<h1>2.6.0 (2025-12-05)</h1>
<h2>Security</h2>
<ul>
<li>Fixed a security issue where streaming API could improperly handle
highly
compressed HTTP content (&quot;decompression bombs&quot;) leading to
excessive resource
consumption even when a small amount of data was requested. Reading
small
chunks of compressed data is safer and much more efficient now.
(<code>GHSA-2xpw-w6gg-jr37
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-2xpw-w6gg-jr37&gt;</code>__)</li>
<li>Fixed a security issue where an attacker could compose an HTTP
response with
virtually unlimited links in the <code>Content-Encoding</code> header,
potentially
leading to a denial of service (DoS) attack by exhausting system
resources
during decoding. The number of allowed chained encodings is now limited
to 5.
(<code>GHSA-gm62-xv2j-4w53
&lt;https://github.com/urllib3/urllib3/security/advisories/GHSA-gm62-xv2j-4w53&gt;</code>__)</li>
</ul>
<p>.. caution::</p>
<ul>
<li>If urllib3 is not installed with the optional
<code>urllib3[brotli]</code> extra, but
your environment contains a Brotli/brotlicffi/brotlipy package anyway,
make
sure to upgrade it to at least Brotli 1.2.0 or brotlicffi 1.2.0.0 to
benefit from the security fixes and avoid warnings. Prefer using</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/urllib3/urllib3/commit/0248277dd7ac0239204889ca991353ad3e3a1ddc"><code>0248277</code></a>
Release 2.6.3</li>
<li><a
href="https://github.com/urllib3/urllib3/commit/8864ac407bba8607950025e0979c4c69bc7abc7b"><code>8864ac4</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/urllib3/urllib3/commit/70cecb27ca99d56aaaeb63ac27ee270ef2b24c5c"><code>70cecb2</code></a>
Fix Scorecard issues related to vulnerable dev dependencies (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3755">#3755</a>)</li>
<li><a
href="https://github.com/urllib3/urllib3/commit/41f249abe1ef3e20768588969c4035aba060a359"><code>41f249a</code></a>
Move &quot;v2.0 Migration Guide&quot; to the end of the table of
contents (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3747">#3747</a>)</li>
<li><a
href="https://github.com/urllib3/urllib3/commit/fd4dffd2fc544166b76151a2fa3d7b7c0eab540c"><code>fd4dffd</code></a>
Patch <code>VerifiedHTTPSConnection</code> for Emscripten (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3752">#3752</a>)</li>
<li><a
href="https://github.com/urllib3/urllib3/commit/13f0bfd55e4468fe1ea9c6f809d3a87b0f93ebab"><code>13f0bfd</code></a>
Handle massive values in Retry-After when calculating time to sleep for
(<a
href="https://redirect.github.com/urllib3/urllib3/issues/3743">#3743</a>)</li>
<li><a
href="https://github.com/urllib3/urllib3/commit/8c480bf87bcefd321b3a1ae47f04e908b6b2ed7b"><code>8c480bf</code></a>
Bump actions/upload-artifact from 5.0.0 to 6.0.0 (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3748">#3748</a>)</li>
<li><a
href="https://github.com/urllib3/urllib3/commit/4b40616e959c0a2c466e8075f2a785a9f99bb0c1"><code>4b40616</code></a>
Bump actions/cache from 4.3.0 to 5.0.1 (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3750">#3750</a>)</li>
<li><a
href="https://github.com/urllib3/urllib3/commit/82b8479663d037d220c883f1584dd01a43bb273b"><code>82b8479</code></a>
Bump actions/download-artifact from 6.0.0 to 7.0.0 (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3749">#3749</a>)</li>
<li><a
href="https://github.com/urllib3/urllib3/commit/34284cb01700bb7d4fdd472f909e22393e9174e2"><code>34284cb</code></a>
Mention experimental features in the security policy (<a
href="https://redirect.github.com/urllib3/urllib3/issues/3746">#3746</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/urllib3/urllib3/compare/2.5.0...2.6.3">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=urllib3&package-manager=pip&previous-version=2.5.0&new-version=2.6.3)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions
You can disable automated security fix PRs for this repo from the
[Security Alerts
page](https://github.com/Stirling-Tools/Stirling-PDF/network/alerts).

</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-14 00:37:18 +00:00
Ludy a366463496 ci(build): scope Docker layer cache per artifact to avoid cross-contamination (#5453)
This pull request updates the Docker build caching strategy in the
GitHub Actions workflow to improve cache isolation and efficiency. The
main change is the introduction of a cache `scope` based on the
`artifact-suffix` matrix value.

**Build caching improvements:**

*
[`.github/workflows/build.yml`](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721L329-R330):
Updated `cache-from` and `cache-to` in the Docker build step to use a
scoped cache (`scope=${{ matrix.artifact-suffix }}`), enabling better
cache isolation across different build variants.
2026-01-14 00:36:41 +00:00
Ludy 472ee54098 fix(translations): improve translation merger CLI and sync missing UI strings across locales (#5309)
# Description of Changes

This pull request updates the Arabic translation file
(`frontend/public/locales/ar-AR/translation.toml`) with a large number
of new and improved strings, adding support for new features and
enhancing clarity and coverage across the application. Additionally, it
makes several improvements to the TOML language check script
(`.github/scripts/check_language_toml.py`) and updates the corresponding
GitHub Actions workflow to better track and validate translation
changes.

**Translation updates and enhancements:**

* Added translations for new features and UI elements, including
annotation tools, PDF/A-3b conversion, line art compression, background
removal, split modes, onboarding tours, and more.
[[1]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR343-R346)
[[2]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR442-R460)
[[3]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR514-R523)
[[4]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR739-R743)
[[5]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR1281-R1295)
[[6]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR1412-R1416)
[[7]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR2362-R2365)
[[8]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR2411-R2415)
[[9]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR2990)
[[10]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR3408-R3420)
[[11]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR3782-R3794)
[[12]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR3812-R3815)
[[13]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR3828-R3832)
[[14]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effL3974-R4157)
[[15]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR4208-R4221)
[[16]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR5247)
[[17]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR5414-R5423)
[[18]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR5444-R5447)
* Improved and expanded coverage for settings, security, onboarding, and
help menus, including detailed descriptions and tooltips for new and
existing features.
[[1]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR442-R460)
[[2]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR5247)
[[3]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR5414-R5423)
[[4]](diffhunk://#diff-460d5f61a7649a5b149373af2e52a8a87d9a1964cf54240a78ad4747e7233effR5444-R5447)

**TOML language check script improvements:**

* Increased the maximum allowed TOML file size from 500 KB to 570 KB to
accommodate larger translation files.
* Improved file validation logic to more accurately skip or process
files based on directory structure and file type, and added informative
print statements for skipped files.
* Enhanced reporting in the difference check: now, instead of raising
exceptions for unsafe files or oversized files, the script logs warnings
and continues processing, improving robustness and clarity in CI
reports.
* Adjusted the placement of file check report lines for clarity in the
generated report.

**Workflow and CI improvements:**

* Updated the GitHub Actions workflow
(`.github/workflows/check_toml.yml`) to trigger on changes to the
translation script and workflow files, in addition to translation TOMLs,
ensuring all relevant changes are validated.

These changes collectively improve the translation quality and coverage
for Arabic users, enhance the reliability and clarity of the translation
validation process, and ensure smoother CI/CD workflows for localization
updates.

<img width="654" height="133" alt="image"
src="https://github.com/user-attachments/assets/9f3e505d-927f-4dc0-9098-cee70bbe85ca"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-14 00:31:05 +00:00
Ludy db049a3467 fix(core): reinstall frontend dependencies when iconify package is missing (#5432)
# Description of Changes

### What was changed
- Updated the Gradle `npmInstall` task to detect a missing required
devDependency (`@iconify-json/material-symbols`) even when
`node_modules` exists.
- If the package is missing, the task will no longer skip `npm install`;
it will force a reinstall instead.

### Why the change was made
- The frontend build (`npm run generate-icons`) can fail with `Cannot
find module '@iconify-json/material-symbols'` when `node_modules` is
present but incomplete/corrupted.
- The current "node_modules exists => skip install" shortcut is not
sufficient for this scenario, causing Gradle builds to fail during
`npmBuild`.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-14 00:22:05 +00:00
Balázs SzücsandAnthony Stirling b00bd760c8 feat(conversion): add PDF to EPUB/AZW3 conversion support and settings (#5434)
# Description of Changes


This pull request introduces support for converting PDF files to eBook
formats (EPUB and AZW3) in the frontend. It adds new user interface
options for PDF-to-eBook conversion, updates the conversion logic and
parameters, and ensures the new formats are integrated into the
conversion matrix and endpoints. The most important changes are grouped
below:

**PDF to eBook (EPUB/AZW3) Conversion Support**

* Added a new `ConvertToEpubSettings` component that provides UI
controls for PDF-to-eBook options, including chapter detection, target
device selection, and output format.
(`frontend/src/core/components/tools/convert/ConvertToEpubSettings.tsx`)
* Updated `ConvertSettings` to render the new eBook options when
converting from PDF to EPUB or AZW3, and set default values for these
options.
(`frontend/src/core/components/tools/convert/ConvertSettings.tsx`)
* Extended the `ConvertParameters` interface and default parameters to
include `epubOptions` for the new settings.
(`frontend/src/core/hooks/tools/convert/useConvertParameters.ts`)

**Conversion Logic and API Integration**

* Updated the conversion endpoints, endpoint names, and conversion
matrix to support PDF-to-EPUB/AZW3 conversions.
(`frontend/src/core/constants/convertConstants.ts`)
* Modified the conversion operation logic to handle `epubOptions` and
ensure that PDF-to-eBook conversions process each file separately and
send the correct options to the backend.
(`frontend/src/core/hooks/tools/convert/useConvertOperation.ts`)
**Localization and Tool Registry Updates**

* Added localization strings for the new eBook conversion options.
(`frontend/public/locales/en-GB/translation.toml`)
* Registered the new PDF-to-eBook operation in the tool catalog and test
helpers. (`frontend/src/core/data/useTranslatedToolRegistry.tsx`,
`frontend/src/core/tests/helpers/conversionEndpointDiscovery.ts`)



<img width="364" height="995" alt="image"
src="https://github.com/user-attachments/assets/c54c50c0-1b86-4074-aef8-b038c6caeb49"
/>

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-01-13 22:22:23 +00:00
e7b030e6b5 Add Telegram bot integration for pipeline processing (#5185)
# Description of Changes

This pull request introduces Telegram bot integration to the
application, enabling users to send files via Telegram for processing
through the pipeline. The main changes add configuration options,
dependency management, and a new service for handling Telegram
interactions.

**Telegram bot integration:**

* Added a new `TelegramPipelineBot` service (`TelegramPipelineBot.java`)
that listens for incoming Telegram messages, downloads attached files or
photos, places them in a pipeline inbox folder, waits for processing
results, and sends the output files back to the user. The service
includes error handling and status messaging.
* Introduced a `TelegramBotConfig` configuration class to initialize and
register the Telegram bot only when enabled via application properties.
* Added a new `Telegram` configuration section to
`ApplicationProperties` and the `settings.yml.template`, supporting
options like enabling/disabling the bot, bot token/username, pipeline
folder, processing timeout, and polling interval.
[[1]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R63)
[[2]](diffhunk://#diff-1c357db0a3e88cf5bedd4a5852415fadad83b8b3b9eb56e67059d8b9d8b10702R580-R589)
[[3]](diffhunk://#diff-12f23603ae35319a3ea08f91b6340d5d935216941fda2e69d2df1b6cd22a63f2R108-R115)

**Dependency management:**

* Added the `org.telegram:telegrambots` library to the project
dependencies to support Telegram bot functionality.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-01-13 22:14:59 +00:00
Anthony Stirling 83e96a9aa3 Update user login conditions in workflow (#5457)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-13 22:00:06 +00:00
dependabot[bot] 392462a325 build(deps): bump globals from 16.5.0 to 17.0.0 in /frontend (#5413)
Bumps [globals](https://github.com/sindresorhus/globals) from 16.5.0 to
17.0.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/sindresorhus/globals/releases">globals's
releases</a>.</em></p>
<blockquote>
<h2>v17.0.0</h2>
<h3>Breaking</h3>
<ul>
<li>Split <code>audioWorklet</code> environment from
<code>browser</code> (<a
href="https://redirect.github.com/sindresorhus/globals/issues/320">#320</a>)
7bc293e</li>
</ul>
<h3>Improvements</h3>
<ul>
<li>Update globals (<a
href="https://redirect.github.com/sindresorhus/globals/issues/329">#329</a>)
ebe1063</li>
<li>Get all browser globals from both <code>chrome</code> and
<code>firefox</code> (<a
href="https://redirect.github.com/sindresorhus/globals/issues/321">#321</a>)
59ceff8</li>
<li>Add <code>bunBuiltin</code> environment (<a
href="https://redirect.github.com/sindresorhus/globals/issues/324">#324</a>)
1bc6e3b</li>
<li>Add <code>denoBuiltin</code> environment (<a
href="https://redirect.github.com/sindresorhus/globals/issues/324">#324</a>)
1bc6e3b</li>
<li>Add <code>paintWorklet</code> environment (<a
href="https://redirect.github.com/sindresorhus/globals/issues/323">#323</a>)
4b78f56</li>
<li>Add <code>sharedWorker</code> environment (<a
href="https://redirect.github.com/sindresorhus/globals/issues/322">#322</a>)
4a02a85</li>
</ul>
<hr />
<p><a
href="https://github.com/sindresorhus/globals/compare/v16.5.0...v17.0.0">https://github.com/sindresorhus/globals/compare/v16.5.0...v17.0.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/sindresorhus/globals/commit/bb89b18916b00f6c4f089baa0d66cb7f0771ab1b"><code>bb89b18</code></a>
17.0.0</li>
<li><a
href="https://github.com/sindresorhus/globals/commit/ebe106340137419b4ac0150f746b7567fb78ce03"><code>ebe1063</code></a>
Update globals (2026-01-01) (<a
href="https://redirect.github.com/sindresorhus/globals/issues/329">#329</a>)</li>
<li><a
href="https://github.com/sindresorhus/globals/commit/e3d8da349da72d66fcadf94d926d4ff96da3319c"><code>e3d8da3</code></a>
Revert &quot;Automate script for <code>paintWorklet</code> environment
(<a
href="https://redirect.github.com/sindresorhus/globals/issues/325">#325</a>)&quot;
(<a
href="https://redirect.github.com/sindresorhus/globals/issues/328">#328</a>)</li>
<li><a
href="https://github.com/sindresorhus/globals/commit/e7c0453f80840ebcef3a6bccaabc7a9dc0cb7a84"><code>e7c0453</code></a>
Automate script for <code>paintWorklet</code> environment (<a
href="https://redirect.github.com/sindresorhus/globals/issues/325">#325</a>)</li>
<li><a
href="https://github.com/sindresorhus/globals/commit/1bc6e3bc6658d4243a714bb7dd7401f206fd3bc5"><code>1bc6e3b</code></a>
Add <code>bunBuiltin</code> and <code>denoBuiltin</code> (<a
href="https://redirect.github.com/sindresorhus/globals/issues/324">#324</a>)</li>
<li><a
href="https://github.com/sindresorhus/globals/commit/4b78f56c1c305e34161183e8fd5ac89e92c72e84"><code>4b78f56</code></a>
Add <code>paintWorklet</code> environment (<a
href="https://redirect.github.com/sindresorhus/globals/issues/323">#323</a>)</li>
<li><a
href="https://github.com/sindresorhus/globals/commit/59ceff80cb22de31a78651acdda4db1a91eefb11"><code>59ceff8</code></a>
Get all browser globals from both <code>chrome</code> and
<code>firefox</code> (<a
href="https://redirect.github.com/sindresorhus/globals/issues/321">#321</a>)</li>
<li><a
href="https://github.com/sindresorhus/globals/commit/4a02a852463c03ae722f14f06944c5aa7e0e06e0"><code>4a02a85</code></a>
Add <code>sharedWorker</code> environment (<a
href="https://redirect.github.com/sindresorhus/globals/issues/322">#322</a>)</li>
<li><a
href="https://github.com/sindresorhus/globals/commit/7bc293ec2498be49293e3aec46186a98a76e5ecd"><code>7bc293e</code></a>
Split <code>audioWorklet</code> environment from <code>browser</code>
(<a
href="https://redirect.github.com/sindresorhus/globals/issues/320">#320</a>)</li>
<li><a
href="https://github.com/sindresorhus/globals/commit/814075ffd71e6b0599f2611cd445b00906ad2181"><code>814075f</code></a>
Fix browser launch (<a
href="https://redirect.github.com/sindresorhus/globals/issues/319">#319</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/sindresorhus/globals/compare/v16.5.0...v17.0.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=globals&package-manager=npm_and_yarn&previous-version=16.5.0&new-version=17.0.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 21:28:28 +00:00
dependabot[bot] 0a801fe3a6 build(deps): bump nginx from 8491795 to c083c37 in /docker/frontend (#5448)
[//]: # (dependabot-start)
⚠️  **Dependabot is rebasing this PR** ⚠️ 

Rebasing might not happen immediately, so don't worry if this takes some
time.

Note: if you make any changes to this PR yourself, they will take
precedence over the rebase.

---

[//]: # (dependabot-end)

Bumps nginx from `8491795` to `c083c37`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=nginx&package-manager=docker&previous-version=alpine&new-version=alpine)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 21:26:40 +00:00
dependabot[bot] 646df1bfb5 build(deps): bump node from 20-alpine to 25-alpine in /docker/frontend (#5311)
Bumps node from 20-alpine to 25-alpine.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=node&package-manager=docker&previous-version=20-alpine&new-version=25-alpine)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 21:26:06 +00:00
stirlingbot[bot] bfc029f993 Update Backend 3rd Party Licenses (#5363)
Auto-generated by stirlingbot[bot]

This PR updates the backend license report based on dependency changes.

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-01-13 21:18:36 +00:00
LudyandJames Brunton b266b50bef fix(api): return JSON responses for admin settings + API key endpoints to prevent Tauri client parse errors (#5437)
# Description of Changes

```console
index-DsORDqQQ.js:124 \n [TauriHttpClient] Network error: \n{url: 'http://localhost:8080/api/v1/admin/settings', method: 'PUT', errorType: 'ERR_NETWORK', originalMessage: `Failed to execute 'close' on 'ReadableStreamDefaul…cted token 'S', "Successful"... is not valid JSON`, stack: `SyntaxError: Unexpected token 'S', "Successful"...…lback (<anonymous>:284:7)\n    at <anonymous>:1:28`}\nerrorType\n: \n"ERR_NETWORK"\nmethod\n: \n"PUT"\noriginalMessage\n: \n"Failed to execute 'close' on 'ReadableStreamDefaultController': Unexpected token 'S', \"Successful\"... is not valid JSON"\nstack\n: \n"SyntaxError: Unexpected token 'S', \"Successful\"... is not valid JSON\n    at A.onmessage (http://tauri.localhost/assets/index-DsORDqQQ.js:124:22714)\n    at http://tauri.localhost/assets/index-DsORDqQQ.js:124:20748\n    at <anonymous>:272:26\n    at Object.runCallback (<anonymous>:284:7)\n    at <anonymous>:1:28"\nurl\n: \n"http://localhost:8080/api/v1/admin/settings"

index-DXbk7lbS.js:124 \n [TauriHttpClient] Network error: \n{url: 'http://localhost:8080/api/v1/user/get-api-key', method: 'POST', errorType: 'ERR_NETWORK', originalMessage: `Failed to execute 'close' on 'ReadableStreamDefaul…cted token 'a', "a72f6b26-1"... is not valid JSON`, stack: `SyntaxError: Unexpected token 'a', "a72f6b26-1"...…lback (<anonymous>:284:7)\n    at <anonymous>:1:28`}\nerrorType\n: \n"ERR_NETWORK"\nmethod\n: \n"POST"\noriginalMessage\n: \n"Failed to execute 'close' on 'ReadableStreamDefaultController': Unexpected token 'a', \"a72f6b26-1\"... is not valid JSON"\nstack\n: \n"SyntaxError: Unexpected token 'a', \"a72f6b26-1\"... is not valid JSON\n    at A.onmessage (http://tauri.localhost/assets/index-DXbk7lbS.js:124:22714)\n    at http://tauri.localhost/assets/index-DXbk7lbS.js:124:20748\n    at <anonymous>:272:26\n    at Object.runCallback (<anonymous>:284:7)\n    at <anonymous>:1:28"\nurl\n: \n"http://localhost:8080/api/v1/user/get-api-key"
```


This pull request fixes a self-hosting issue where the Tauri HTTP client
fails with `Unexpected token ... is not valid JSON` because certain API
endpoints returned plain text responses.

## What was changed

- Updated `AdminSettingsController`:
- Changed `updateSettings` and `updateSettingsSection` to return
structured JSON objects instead of raw strings.
- Standardized success and error payloads using a `Map<String, Object>`
with keys like `message` and `error`.
- Updated `UserController`:
- Changed `/api/v1/user/get-api-key` and `/api/v1/user/update-api-key`
to return JSON objects (`{ "apiKey": "..." }`) and JSON error objects
instead of plain text.

## Why the change was made

- The Tauri client expects JSON responses and attempts to parse them.
Returning plain strings like `"Successful..."` or an API key string
causes JSON parsing to fail, resulting in network errors on self-hosted
setups.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-01-13 21:18:09 +00:00
Balázs Szücs 84ed1d7ecb feat(conversion): refactor EML parser to use Simple Java Mail library and add MSG support (#5427)
# Description of Changes


Note on Simple Java Mail:
- SJM contains Angus/Jakarta Mail in it.
- SJM is a very thin layer on Angus Mail; see here:
https://github.com/bbottema/simple-java-mail
- SJM gives high level methods to more reliably parse in email via Angus
Mail, but also contains lots of other interesting features.
- SJM is Apache 2 licensed

This pull request updates the email processing utilities to add support
for parsing and validating Outlook MSG files, refactors the
`EmlProcessingUtils` utility class to use instance methods and improved
resource management, and enhances the handling and styling of generated
email HTML. The changes also introduce external CSS resource loading
with a fallback mechanism, and update dependencies to support MSG file
parsing.

**MSG file support and validation:**
- Added `simple-java-mail` and `outlook-module` dependencies to enable
EML and MSG file parsing, and updated validation logic to recognize and
accept MSG files by checking their magic bytes.
(`app/common/build.gradle`, `EmlProcessingUtils.java`)
**Refactoring and modernization of `EmlProcessingUtils`:**
- Converted static methods and fields in `EmlProcessingUtils` to
instance methods/fields, improving testability and future extensibility.
(`EmlProcessingUtils.java`)

**Enhanced HTML/CSS styling for email rendering:**
- Updated HTML generation to use consistent formatting and improved
style variable usage, and refactored CSS injection to load from an
external resource (`email-pdf-styles.css`) with a synchronized cache and
a minimal fallback if the resource is missing.
(`EmlProcessingUtils.java`)
**Attachment and content rendering improvements:**
- Improved the formatting of meta-information (e.g., CC, BCC, Date) and
attachment sections in generated email HTML, and ensured more robust
handling of empty or missing content. (`EmlProcessingUtils.java`)

**General code cleanup and logging:**
- Added SLF4J logging for error handling when loading CSS resources, and
cleaned up imports and method signatures for clarity and
maintainability. (`EmlProcessingUtils.java`)


<img width="367" height="991" alt="image"
src="https://github.com/user-attachments/assets/0cfb959c-da92-4cff-9e52-ff4ab7fa806e"
/>


<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [X] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-01-13 21:17:40 +00:00
dependabot[bot] daf27b6128 build(deps): bump step-security/harden-runner from 2.12.1 to 2.14.0 (#5324)
Bumps
[step-security/harden-runner](https://github.com/step-security/harden-runner)
from 2.12.1 to 2.14.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/step-security/harden-runner/releases">step-security/harden-runner's
releases</a>.</em></p>
<blockquote>
<h2>v2.14.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Selective installation: Harden-Runner now skips installation on
GitHub-hosted runners when the repository has a custom property
skip_harden_runner, allowing organizations to opt out specific
repos.</li>
<li>Avoid double install: The action no longer installs Harden-Runner if
it’s already present on a GitHub-hosted runner, which could happen when
a composite action also installs it.</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/step-security/harden-runner/compare/v2.13.3...v2.14.0">https://github.com/step-security/harden-runner/compare/v2.13.3...v2.14.0</a></p>
<h2>v2.13.3</h2>
<h2>What's Changed</h2>
<ul>
<li>Fixed an issue where process events were not uploaded in certain
edge cases.</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/step-security/harden-runner/compare/v2.13.2...v2.13.3">https://github.com/step-security/harden-runner/compare/v2.13.2...v2.13.3</a></p>
<h2>v2.13.2</h2>
<h2>What's Changed</h2>
<ul>
<li>Fixed an issue where there was a limit of 512 allowed endpoints when
using block egress policy. This restriction has been removed, allowing
for an unlimited number of endpoints to be configured.</li>
<li>Harden Runner now automatically detects if the agent is already
pre-installed on a custom VM image used by a GitHub-hosted runner. When
detected, the action will skip reinstallation and use the existing
agent.</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/step-security/harden-runner/compare/v2.13.1...v2.13.2">https://github.com/step-security/harden-runner/compare/v2.13.1...v2.13.2</a></p>
<h2>v2.13.1</h2>
<h2>What's Changed</h2>
<ul>
<li>
<p>Graceful handling of HTTP errors: Improved error handling when
fetching Harden Runner policies from the StepSecurity Policy Store API,
ensuring more reliable execution even in case of temporary network/API
issues.</p>
</li>
<li>
<p>Security updates for npm dependencies: Updated vulnerable npm package
dependencies to the latest secure versions.</p>
</li>
<li>
<p>Faster enterprise agent downloads: The enterprise agent is now
downloaded from GitHub Releases instead of packages.stepsecurity.io,
improving download speed and reliability.</p>
</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/step-security/harden-runner/compare/v2.13.0...v2.13.1">https://github.com/step-security/harden-runner/compare/v2.13.0...v2.13.1</a></p>
<h2>v2.13.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Improved job markdown summary</li>
<li>Https monitoring for all domains (included with the enterprise
tier)</li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/step-security/harden-runner/compare/v2...v2.13.0">https://github.com/step-security/harden-runner/compare/v2...v2.13.0</a></p>
<h2>v2.12.2</h2>
<h2>What's Changed</h2>
<p>Added HTTPS Monitoring for additional destinations -
*.githubusercontent.com
Bug fixes:</p>
<ul>
<li>Implicitly allow local multicast, local unicast and broadcast IP
addresses in block mode</li>
<li>Increased policy map size for block mode</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/step-security/harden-runner/commit/20cf305ff2072d973412fa9b1e3a4f227bda3c76"><code>20cf305</code></a>
Merge pull request <a
href="https://redirect.github.com/step-security/harden-runner/issues/622">#622</a>
from step-security/feature/custom-property-skip</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/c51e8eeb6c4fdcd08f65e43a051dacdbfaa69702"><code>c51e8ee</code></a>
feat: skip agent install and post step on subsequent runs for
GitHub-hosted r...</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/e152b90204c3d85cefa1441b701a47a13ed28bd7"><code>e152b90</code></a>
feat: skip harden-runner based on repository custom property</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/ee1faec052d1000061fa79a13e030db11b3f86bd"><code>ee1faec</code></a>
feat: replace skip-harden-runner with skip-on-custom-property input</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/1dc7c1764659d537dab2a854b8e165a801103eb1"><code>1dc7c17</code></a>
feat: add skip-harden-runner input to conditionally skip execution</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/df199fb7be9f65074067a9eb93f12bb4c5547cf2"><code>df199fb</code></a>
Merge pull request <a
href="https://redirect.github.com/step-security/harden-runner/issues/620">#620</a>
from step-security/rc-29</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/03d096a772368b1f0222005a6899d3e35a7f62df"><code>03d096a</code></a>
update agent</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/40901073af04afd40408833437092a7467798f33"><code>4090107</code></a>
fix: update agent</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/95d9a5deda9de15063e7595e9719c11c38c90ae2"><code>95d9a5d</code></a>
Merge pull request <a
href="https://redirect.github.com/step-security/harden-runner/issues/606">#606</a>
from step-security/rc-28</li>
<li><a
href="https://github.com/step-security/harden-runner/commit/87e429d3fb470bcc827f338e5cce1155ff99c6eb"><code>87e429d</code></a>
Update limitations.md</li>
<li>Additional commits viewable in <a
href="https://github.com/step-security/harden-runner/compare/v2.12.1...20cf305ff2072d973412fa9b1e3a4f227bda3c76">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=step-security/harden-runner&package-manager=github_actions&previous-version=2.12.1&new-version=2.14.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 20:38:11 +00:00
dependabot[bot] 818c30cb55 build(deps): bump docker/setup-qemu-action from 3.6.0 to 3.7.0 (#5326)
Bumps
[docker/setup-qemu-action](https://github.com/docker/setup-qemu-action)
from 3.6.0 to 3.7.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/setup-qemu-action/releases">docker/setup-qemu-action's
releases</a>.</em></p>
<blockquote>
<h2>v3.7.0</h2>
<ul>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.56.0 to 0.67.0 in
<a
href="https://redirect.github.com/docker/setup-qemu-action/pull/217">docker/setup-qemu-action#217</a>
<a
href="https://redirect.github.com/docker/setup-qemu-action/pull/230">docker/setup-qemu-action#230</a></li>
<li>Bump brace-expansion from 1.1.11 to 1.1.12 in <a
href="https://redirect.github.com/docker/setup-qemu-action/pull/220">docker/setup-qemu-action#220</a></li>
<li>Bump form-data from 2.5.1 to 2.5.5 in <a
href="https://redirect.github.com/docker/setup-qemu-action/pull/218">docker/setup-qemu-action#218</a></li>
<li>Bump tmp from 0.2.3 to 0.2.4 in <a
href="https://redirect.github.com/docker/setup-qemu-action/pull/221">docker/setup-qemu-action#221</a></li>
<li>Bump undici from 5.28.4 to 5.29.0 in <a
href="https://redirect.github.com/docker/setup-qemu-action/pull/219">docker/setup-qemu-action#219</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/setup-qemu-action/compare/v3.6.0...v3.7.0">https://github.com/docker/setup-qemu-action/compare/v3.6.0...v3.7.0</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/c7c53464625b32c7a7e944ae62b3e17d2b600130"><code>c7c5346</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-qemu-action/issues/230">#230</a>
from docker/dependabot/npm_and_yarn/docker/actions-to...</li>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/3a517a1a6f815421d6c0f8ab069bd9cae3024828"><code>3a517a1</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/a5b45edf7e42dbe6d762ad8053bd35a7d336a850"><code>a5b45ed</code></a>
build(deps): bump <code>@​docker/actions-toolkit</code> from 0.62.1 to
0.67.0</li>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/3a64278e93930d340f1caabd280b2e78b36b5032"><code>3a64278</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-qemu-action/issues/220">#220</a>
from docker/dependabot/npm_and_yarn/brace-expansion-1...</li>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/94906ba253608103ef32875025dd58d7f74e2716"><code>94906ba</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/4027abfd67ddf4bd8f75dbf3bb8bf3b69764ded6"><code>4027abf</code></a>
build(deps): bump brace-expansion from 1.1.11 to 1.1.12</li>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/bee0aaad0f43394cdb4b8756cd994ae278b0667c"><code>bee0aaa</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-qemu-action/issues/221">#221</a>
from docker/dependabot/npm_and_yarn/tmp-0.2.4</li>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/0d7e25756e271776f152e7669522f64f54c5e3e2"><code>0d7e257</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/b86960130e28d1756dfa88e15e28ee5880e893de"><code>b869601</code></a>
build(deps): bump tmp from 0.2.3 to 0.2.4</li>
<li><a
href="https://github.com/docker/setup-qemu-action/commit/3a043edff31c211e56ebb1e00a76ce49a831bd24"><code>3a043ed</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/setup-qemu-action/issues/219">#219</a>
from docker/dependabot/npm_and_yarn/undici-5.29.0</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/setup-qemu-action/compare/v3.6.0...c7c53464625b32c7a7e944ae62b3e17d2b600130">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/setup-qemu-action&package-manager=github_actions&previous-version=3.6.0&new-version=3.7.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 20:35:11 +00:00
dependabot[bot]andLudy 7460e58abf build(deps): bump io.micrometer:micrometer-core from 1.16.0 to 1.16.1 (#5358)
Bumps
[io.micrometer:micrometer-core](https://github.com/micrometer-metrics/micrometer)
from 1.16.0 to 1.16.1.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/micrometer-metrics/micrometer/releases">io.micrometer:micrometer-core's
releases</a>.</em></p>
<blockquote>
<h2>1.16.1</h2>
<h2>🐞 Bug Fixes</h2>
<ul>
<li>Don't filter log events in LogbackMetricsBenchmark <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6891">#6891</a></li>
<li>Return value nullability is incorrect on function wrapper methods <a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/6869">#6869</a></li>
</ul>
<h2>📔 Documentation</h2>
<ul>
<li>Add link to the latest Micrometer Team talk <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6881">#6881</a></li>
<li>Document JSpecify dependency <a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/6886">#6886</a></li>
<li>Make cross-references more consistent in the docs <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6915">#6915</a></li>
</ul>
<h2>🔨 Dependency Upgrades</h2>
<ul>
<li>Bump ch.qos.logback:logback-classic from 1.5.20 to 1.5.21 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6877">#6877</a></li>
<li>Bump com.netflix.spectator:spectator-reg-atlas from 1.9.1 to 1.9.2
<a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6904">#6904</a></li>
<li>Bump com.uber.nullaway:nullaway from 0.12.12 to 0.12.14 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6937">#6937</a></li>
<li>Bump grpc from 1.76.0 to 1.76.1 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6902">#6902</a></li>
<li>Bump io.freefair.aspectj.post-compile-weaving from 8.14.2 to 8.14.3
<a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6876">#6876</a></li>
<li>Bump io.prometheus:prometheus-metrics-bom from 1.4.2 to 1.4.3 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6868">#6868</a></li>
<li>Bump spring6 from 6.2.12 to 6.2.14 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6890">#6890</a></li>
</ul>
<h2>❤️ Contributors</h2>
<p>Thank you to all the contributors who worked on this release:</p>
<p><a href="https://github.com/MiLabuda"><code>@​MiLabuda</code></a>, <a
href="https://github.com/izeye"><code>@​izeye</code></a>, and <a
href="https://github.com/ngocnhan-tran1996"><code>@​ngocnhan-tran1996</code></a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/0550e769cb9b4f15cff86c1e503fce8ae03ef84f"><code>0550e76</code></a>
Merge branch '1.15.x' into 1.16.x</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/e8dd176ffe60d6ea068c7f536e5662278499cd8f"><code>e8dd176</code></a>
Merge branch '1.14.x' into 1.15.x</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/d8a46b5a7080738e6f9afd623cfdfcf984f44185"><code>d8a46b5</code></a>
Fixed flaky test for mongo client (<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/6924">#6924</a>)</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/0bbe73b0f447ca5ca94e1d2b621eda83d7735477"><code>0bbe73b</code></a>
Bump com.uber.nullaway:nullaway from 0.12.13 to 0.12.14 (<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/6937">#6937</a>)</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/d0b368dbbc1cd8511f693e7be97810978f67787d"><code>d0b368d</code></a>
Polish <a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/6612">gh-6612</a>
(<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/6932">#6932</a>)</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/aa9eac0d31acb389a03be50f266bc968c8ef207f"><code>aa9eac0</code></a>
Bump com.uber.nullaway:nullaway from 0.12.12 to 0.12.13 (<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/6930">#6930</a>)</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/57bd4ff8ebb64d941ece6d9fb04d2c921bd77ac1"><code>57bd4ff</code></a>
Merge branch '1.15.x' into 1.16.x</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/11d8fb1f8081098ad91efc40ea8902b9113d03d3"><code>11d8fb1</code></a>
Merge branch '1.14.x' into 1.15.x</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/5779b09fcb1d5266c614b50ecefe1ad42264b3c2"><code>5779b09</code></a>
Backport fixing typos in docs</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/8b88e43a98221f50f56d43cdd5b0eafb986e835b"><code>8b88e43</code></a>
Merge branch '1.15.x' into 1.16.x</li>
<li>Additional commits viewable in <a
href="https://github.com/micrometer-metrics/micrometer/compare/v1.16.0...v1.16.1">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=io.micrometer:micrometer-core&package-manager=gradle&previous-version=1.16.0&new-version=1.16.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-01-13 20:34:36 +00:00
dependabot[bot] a9ee7f6a8e build(deps): bump pypdf from 6.4.0 to 6.6.0 in /testing/cucumber (#5422)
Bumps [pypdf](https://github.com/py-pdf/pypdf) from 6.4.0 to 6.6.0.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/py-pdf/pypdf/releases">pypdf's
releases</a>.</em></p>
<blockquote>
<h2>Version 6.6.0, 2026-01-09</h2>
<h2>What's new</h2>
<h3>Security (SEC)</h3>
<ul>
<li>Improve handling of partially broken PDF files (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3594">#3594</a>)
by <a
href="https://github.com/stefan6419846"><code>@​stefan6419846</code></a></li>
</ul>
<h3>Deprecations (DEP)</h3>
<ul>
<li>Block common page content modifications when assigned to reader (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3582">#3582</a>)
by <a
href="https://github.com/stefan6419846"><code>@​stefan6419846</code></a></li>
</ul>
<h3>New Features (ENH)</h3>
<ul>
<li>Embellishments to generated text appearance streams (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3571">#3571</a>)
by <a href="https://github.com/PJBrs"><code>@​PJBrs</code></a></li>
</ul>
<h3>Bug Fixes (BUG)</h3>
<ul>
<li>Do not consider multi-byte BOM-like sequences as BOMs (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3589">#3589</a>)
by <a
href="https://github.com/stefan6419846"><code>@​stefan6419846</code></a></li>
</ul>
<h3>Robustness (ROB)</h3>
<ul>
<li>Avoid empty FlateDecode outputs without warning (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3579">#3579</a>)
by <a
href="https://github.com/stefan6419846"><code>@​stefan6419846</code></a></li>
</ul>
<h3>Documentation (DOC)</h3>
<ul>
<li>Add outlines documentation and link it in User Guide (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3511">#3511</a>)
by <a
href="https://github.com/mainuddin-md"><code>@​mainuddin-md</code></a></li>
</ul>
<h3>Developer Experience (DEV)</h3>
<ul>
<li>Add PyPy 3.11 to test matrix and benchmarks (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3574">#3574</a>)
by <a href="https://github.com/rassie"><code>@​rassie</code></a></li>
</ul>
<h3>Maintenance (MAINT)</h3>
<ul>
<li>Fix compatibility with Pillow &gt;= 12.1.0 (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3590">#3590</a>)
by <a
href="https://github.com/stefan6419846"><code>@​stefan6419846</code></a></li>
</ul>
<p><a href="https://github.com/py-pdf/pypdf/compare/6.5.0...6.6.0">Full
Changelog</a></p>
<h2>Version 6.5.0, 2025-12-21</h2>
<h2>What's new</h2>
<h3>New Features (ENH)</h3>
<ul>
<li>Limit jbig2dec memory usage (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3576">#3576</a>)
by <a
href="https://github.com/stefan6419846"><code>@​stefan6419846</code></a></li>
<li>FontDescriptor: Initiate from embedded font resource (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3551">#3551</a>)
by <a href="https://github.com/PJBrs"><code>@​PJBrs</code></a></li>
</ul>
<h3>Robustness (ROB)</h3>
<ul>
<li>Allow fallback to PBM files for jbig2dec without PNG support (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3567">#3567</a>)
by <a
href="https://github.com/stefan6419846"><code>@​stefan6419846</code></a></li>
<li>Use warning instead of error for early EOD for RunLengthDecode (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3548">#3548</a>)
by <a
href="https://github.com/stefan6419846"><code>@​stefan6419846</code></a></li>
</ul>
<h3>Developer Experience (DEV)</h3>
<ul>
<li>Test with macOS as well (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3401">#3401</a>)
by <a
href="https://github.com/stefan6419846"><code>@​stefan6419846</code></a></li>
</ul>
<p><a href="https://github.com/py-pdf/pypdf/compare/6.4.2...6.5.0">Full
Changelog</a></p>
<h2>Version 6.4.2, 2025-12-14</h2>
<h2>What's new</h2>
<h3>Bug Fixes (BUG)</h3>
<ul>
<li>Fix KeyError when flattening form field without /Font in resources
(<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3554">#3554</a>)
by <a
href="https://github.com/jgillard"><code>@​jgillard</code></a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md">pypdf's
changelog</a>.</em></p>
<blockquote>
<h2>Version 6.6.0, 2026-01-09</h2>
<h3>Security (SEC)</h3>
<ul>
<li>Improve handling of partially broken PDF files (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3594">#3594</a>)</li>
</ul>
<h3>Deprecations (DEP)</h3>
<ul>
<li>Block common page content modifications when assigned to reader (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3582">#3582</a>)</li>
</ul>
<h3>New Features (ENH)</h3>
<ul>
<li>Embellishments to generated text appearance streams (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3571">#3571</a>)</li>
</ul>
<h3>Bug Fixes (BUG)</h3>
<ul>
<li>Do not consider multi-byte BOM-like sequences as BOMs (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3589">#3589</a>)</li>
</ul>
<h3>Robustness (ROB)</h3>
<ul>
<li>Avoid empty FlateDecode outputs without warning (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3579">#3579</a>)</li>
</ul>
<h3>Documentation (DOC)</h3>
<ul>
<li>Add outlines documentation and link it in User Guide (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3511">#3511</a>)</li>
</ul>
<h3>Developer Experience (DEV)</h3>
<ul>
<li>Add PyPy 3.11 to test matrix and benchmarks (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3574">#3574</a>)</li>
</ul>
<h3>Maintenance (MAINT)</h3>
<ul>
<li>Fix compatibility with Pillow &gt;= 12.1.0 (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3590">#3590</a>)</li>
</ul>
<p><a href="https://github.com/py-pdf/pypdf/compare/6.5.0...6.6.0">Full
Changelog</a></p>
<h2>Version 6.5.0, 2025-12-21</h2>
<h3>New Features (ENH)</h3>
<ul>
<li>Limit jbig2dec memory usage (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3576">#3576</a>)</li>
<li>FontDescriptor: Initiate from embedded font resource (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3551">#3551</a>)</li>
</ul>
<h3>Robustness (ROB)</h3>
<ul>
<li>Allow fallback to PBM files for jbig2dec without PNG support (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3567">#3567</a>)</li>
<li>Use warning instead of error for early EOD for RunLengthDecode (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3548">#3548</a>)</li>
</ul>
<h3>Developer Experience (DEV)</h3>
<ul>
<li>Test with macOS as well (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3401">#3401</a>)</li>
</ul>
<p><a href="https://github.com/py-pdf/pypdf/compare/6.4.2...6.5.0">Full
Changelog</a></p>
<h2>Version 6.4.2, 2025-12-14</h2>
<h3>Bug Fixes (BUG)</h3>
<ul>
<li>Fix KeyError when flattening form field without /Font in resources
(<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3554">#3554</a>)</li>
</ul>
<h3>Robustness (ROB)</h3>
<ul>
<li>Allow deleting non-existent annotations (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3559">#3559</a>)</li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/py-pdf/pypdf/commit/10df9c72fa7fb9ab14101b9cb911d66e680282a8"><code>10df9c7</code></a>
REL: 6.6.0</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/294165726b646bb7799be1cc787f593f2fdbcf45"><code>2941657</code></a>
SEC: Improve handling of partially broken PDF files (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3594">#3594</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/712688005e49d2d0a4427db0b16abbae160fe106"><code>7126880</code></a>
DEV: Update to urllib3 2.6.3 (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3593">#3593</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/f189f0755eb111564ae8667a990738a9e7ab4ba9"><code>f189f07</code></a>
DOC: Add outlines documentation and link it in User Guide (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3511">#3511</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/a29e5326f543f93c4c317cf9f92df54aff68c584"><code>a29e532</code></a>
BUG: Do not consider multi-byte BOM-like sequences as BOMs (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3589">#3589</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/d9ce594772717fdcfcd505d0309843461579bbf7"><code>d9ce594</code></a>
MAINT: Converge on one shared Font class for text extraction and
appearance s...</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/a65708c778467f1525662dfe5614a486adbd16b0"><code>a65708c</code></a>
DEV: Check for JavaScript library updates on GitHub Pages (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3592">#3592</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/6951bb7c039afd02bd273dda412c3f36df76eba3"><code>6951bb7</code></a>
MAINT: Fix compatibility with Pillow &gt;= 12.1.0 (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3590">#3590</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/97d47a001d574dfda54bf16c4dcee89ccbfabec8"><code>97d47a0</code></a>
TST: Improve test coverage (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3584">#3584</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/bda80a4846a8646419a04cc253d81b73773d3cec"><code>bda80a4</code></a>
DEV: Add PyPy 3.11 to test matrix and benchmarks (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3574">#3574</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/py-pdf/pypdf/compare/6.4.0...6.6.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=pypdf&package-manager=pip&previous-version=6.4.0&new-version=6.6.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 20:23:55 +00:00
dependabot[bot] 36e0fd566a build(deps): bump github/codeql-action from 4.31.5 to 4.31.10 (#5449)
Bumps [github/codeql-action](https://github.com/github/codeql-action)
from 4.31.5 to 4.31.10.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/releases">github/codeql-action's
releases</a>.</em></p>
<blockquote>
<h2>v4.31.10</h2>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>4.31.10 - 12 Jan 2026</h2>
<ul>
<li>Update default CodeQL bundle version to 2.23.9. <a
href="https://redirect.github.com/github/codeql-action/pull/3393">#3393</a></li>
</ul>
<p>See the full <a
href="https://github.com/github/codeql-action/blob/v4.31.10/CHANGELOG.md">CHANGELOG.md</a>
for more information.</p>
<h2>v4.31.9</h2>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>4.31.9 - 16 Dec 2025</h2>
<p>No user facing changes.</p>
<p>See the full <a
href="https://github.com/github/codeql-action/blob/v4.31.9/CHANGELOG.md">CHANGELOG.md</a>
for more information.</p>
<h2>v4.31.8</h2>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>4.31.8 - 11 Dec 2025</h2>
<ul>
<li>Update default CodeQL bundle version to 2.23.8. <a
href="https://redirect.github.com/github/codeql-action/pull/3354">#3354</a></li>
</ul>
<p>See the full <a
href="https://github.com/github/codeql-action/blob/v4.31.8/CHANGELOG.md">CHANGELOG.md</a>
for more information.</p>
<h2>v4.31.7</h2>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>4.31.7 - 05 Dec 2025</h2>
<ul>
<li>Update default CodeQL bundle version to 2.23.7. <a
href="https://redirect.github.com/github/codeql-action/pull/3343">#3343</a></li>
</ul>
<p>See the full <a
href="https://github.com/github/codeql-action/blob/v4.31.7/CHANGELOG.md">CHANGELOG.md</a>
for more information.</p>
<h2>v4.31.6</h2>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>4.31.6 - 01 Dec 2025</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/github/codeql-action/blob/main/CHANGELOG.md">github/codeql-action's
changelog</a>.</em></p>
<blockquote>
<h1>CodeQL Action Changelog</h1>
<p>See the <a
href="https://github.com/github/codeql-action/releases">releases
page</a> for the relevant changes to the CodeQL CLI and language
packs.</p>
<h2>[UNRELEASED]</h2>
<p>No user facing changes.</p>
<h2>4.31.10 - 12 Jan 2026</h2>
<ul>
<li>Update default CodeQL bundle version to 2.23.9. <a
href="https://redirect.github.com/github/codeql-action/pull/3393">#3393</a></li>
</ul>
<h2>4.31.9 - 16 Dec 2025</h2>
<p>No user facing changes.</p>
<h2>4.31.8 - 11 Dec 2025</h2>
<ul>
<li>Update default CodeQL bundle version to 2.23.8. <a
href="https://redirect.github.com/github/codeql-action/pull/3354">#3354</a></li>
</ul>
<h2>4.31.7 - 05 Dec 2025</h2>
<ul>
<li>Update default CodeQL bundle version to 2.23.7. <a
href="https://redirect.github.com/github/codeql-action/pull/3343">#3343</a></li>
</ul>
<h2>4.31.6 - 01 Dec 2025</h2>
<p>No user facing changes.</p>
<h2>4.31.5 - 24 Nov 2025</h2>
<ul>
<li>Update default CodeQL bundle version to 2.23.6. <a
href="https://redirect.github.com/github/codeql-action/pull/3321">#3321</a></li>
</ul>
<h2>4.31.4 - 18 Nov 2025</h2>
<p>No user facing changes.</p>
<h2>4.31.3 - 13 Nov 2025</h2>
<ul>
<li>CodeQL Action v3 will be deprecated in December 2026. The Action now
logs a warning for customers who are running v3 but could be running v4.
For more information, see <a
href="https://github.blog/changelog/2025-10-28-upcoming-deprecation-of-codeql-action-v3/">Upcoming
deprecation of CodeQL Action v3</a>.</li>
<li>Update default CodeQL bundle version to 2.23.5. <a
href="https://redirect.github.com/github/codeql-action/pull/3288">#3288</a></li>
</ul>
<h2>4.31.2 - 30 Oct 2025</h2>
<p>No user facing changes.</p>
<h2>4.31.1 - 30 Oct 2025</h2>
<ul>
<li>The <code>add-snippets</code> input has been removed from the
<code>analyze</code> action. This input has been deprecated since CodeQL
Action 3.26.4 in August 2024 when this removal was announced.</li>
</ul>
<h2>4.31.0 - 24 Oct 2025</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/github/codeql-action/commit/cdefb33c0f6224e58673d9004f47f7cb3e328b89"><code>cdefb33</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3394">#3394</a>
from github/update-v4.31.10-0fa411efd</li>
<li><a
href="https://github.com/github/codeql-action/commit/cfa77c6b134886357b1c716fbe58a7708833bf31"><code>cfa77c6</code></a>
Update changelog for v4.31.10</li>
<li><a
href="https://github.com/github/codeql-action/commit/0fa411efd0628aefdf9d03a0faa20a1e0edafc4a"><code>0fa411e</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3393">#3393</a>
from github/update-bundle/codeql-bundle-v2.23.9</li>
<li><a
href="https://github.com/github/codeql-action/commit/c2843242125c2fb8dcd892f204eb2f8622886b78"><code>c284324</code></a>
Add changelog note</li>
<li><a
href="https://github.com/github/codeql-action/commit/83e7d0046cd548fe4cb5d55f5b2ce30b0de62304"><code>83e7d00</code></a>
Update default bundle to codeql-bundle-v2.23.9</li>
<li><a
href="https://github.com/github/codeql-action/commit/f6a16bef8e5c39e398e4da16862d381f76824ac6"><code>f6a16be</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3391">#3391</a>
from github/dependabot/npm_and_yarn/npm-minor-f1cdf5...</li>
<li><a
href="https://github.com/github/codeql-action/commit/c1f5f1a8b57e6da99af540e7c2f23ed33152e270"><code>c1f5f1a</code></a>
Rebuild</li>
<li><a
href="https://github.com/github/codeql-action/commit/1805d8d0a48bdde6eb34e4427b3c00c431427f89"><code>1805d8d</code></a>
Bump the npm-minor group with 2 updates</li>
<li><a
href="https://github.com/github/codeql-action/commit/b2951d2a1ed70de8ec57301118b487b35c13595a"><code>b2951d2</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3353">#3353</a>
from github/kaspersv/bump-min-cli-v-for-overlay</li>
<li><a
href="https://github.com/github/codeql-action/commit/41448d92b9e7bb3a481b3134031a56e52f85528f"><code>41448d9</code></a>
Merge pull request <a
href="https://redirect.github.com/github/codeql-action/issues/3287">#3287</a>
from github/henrymercer/generate-mergeback-last</li>
<li>Additional commits viewable in <a
href="https://github.com/github/codeql-action/compare/fdbfb4d2750291e159f0156def62b853c2798ca2...cdefb33c0f6224e58673d9004f47f7cb3e328b89">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=4.31.5&new-version=4.31.10)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after
your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge
and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating
it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-13 20:23:33 +00:00
Ludy cfe90cff66 fix(ci): read workflow_dispatch PR number from inputs.pr in PR auto-deploy workflow (#5380)
# Description of Changes

This pull request makes a small update to the PR auto-deploy workflow.
The change ensures that the pull request number is correctly retrieved
from the workflow dispatch payload, improving reliability when the
workflow is manually triggered.

* Changed the PR number retrieval logic in
`.github/workflows/PR-Auto-Deploy-V2.yml` to use
`context.payload.inputs.pr` instead of `process.env.INPUT_PR` for
workflow dispatch events.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-13 16:58:50 +00:00
Ludy 65a5d05713 feat(ui): prevent self-modification in People management and highlight current user (#5441)
# Description of Changes

This PR improves the People management UI by preventing users from
modifying or deleting their own account and by visually highlighting the
currently logged-in user.

<img width="675" height="196" alt="image"
src="https://github.com/user-attachments/assets/c45fb0b6-c766-412c-a53b-b72aed2925d2"
/>


### What was changed
- Integrated session-based authentication context to identify the
currently logged-in user.
- Added a helper to detect the current user in the user list.
- Highlighted the current user's row with a subtle background color.
- Disabled self-actions:
  - Editing own role
  - Enabling/disabling own account
  - Deleting own account
- Kept password change available for the current user.

### Why the change was made
- Prevents accidental self-lockout or privilege removal.
- Aligns UI behavior with common security best practices.
- Improves clarity by visually distinguishing the active user account.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-13 16:13:28 +00:00
Aindriú Mac Giolla Eoin 8632ed9875 Updated ga-IE translations and added keys to ignore list (#5178)
# Description of Changes

Updated the Irish (ga-IE) translation by adding the 117 missing entries
and reviewing the remaining untranslated keys. After translation, some
34 items were identified as not requiring translation and have been
added to the ignore list (ignore_translation.toml).
2026-01-13 16:09:17 +00:00
stirlingbot[bot] 3af5970424 🤖 format everything with pre-commit by stirlingbot (#5426)
Auto-generated by [create-pull-request][1] with **stirlingbot**

[1]: https://github.com/peter-evans/create-pull-request

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-01-13 16:02:25 +00:00
Ludy 3c8fb8ac96 build(ci): pin base container images, switch npm install to npm ci, and harden EML error handling (#5353)
# Description of Changes

This pull request introduces several improvements focused on security
and reliability in both the Docker build process and the backend API.
The most significant changes are the use of digest-pinned Docker base
images to ensure reproducible builds, safer handling of user-provided
filenames in error messages, and a switch to more reliable dependency
installation in CI workflows.

**Docker image security and reproducibility:**

* All Dockerfiles now use digest-pinned base images (e.g.,
`node:20-alpine@sha256:...`, `gradle:8.14-jdk21@sha256:...`,
`alpine:3.22.1@sha256:...`, `nginx:alpine@sha256:...`) to guarantee
build consistency and protect against upstream image changes.
[[1]](diffhunk://#diff-f8faae0938488156cf26e9322ffdf755deaa8770a7ac8c524dd6126c19548888L5-R5)
[[2]](diffhunk://#diff-f8faae0938488156cf26e9322ffdf755deaa8770a7ac8c524dd6126c19548888L18-R18)
[[3]](diffhunk://#diff-f8faae0938488156cf26e9322ffdf755deaa8770a7ac8c524dd6126c19548888L38-R38)
[[4]](diffhunk://#diff-2f5cd3ad965c86a7a5b4af6e0513ad294e0426644d9f5b5358dfb16a2ef995a7L5-R5)
[[5]](diffhunk://#diff-2f5cd3ad965c86a7a5b4af6e0513ad294e0426644d9f5b5358dfb16a2ef995a7L18-R18)
[[6]](diffhunk://#diff-2f5cd3ad965c86a7a5b4af6e0513ad294e0426644d9f5b5358dfb16a2ef995a7L37-R37)
[[7]](diffhunk://#diff-e9edf3a05475d0721a0e65be1ba0eeb162ae972891b0f6d7e1285687efab1de0L9-R9)
[[8]](diffhunk://#diff-fa0700cfd7d90d832649eb1d0503904564bb3b28c48972be7d9f17e4ce32a3dcL9-R9)
[[9]](diffhunk://#diff-2e766aaf0c87e7b8a62d2a2986f6999c38cc35f677479e31b77d1b427c7aeef7L5-R5)
[[10]](diffhunk://#diff-1726db0cbef194c9be3cba9825c0794802b154e15e4c892c1544d0aace03e037L5-R5)
[[11]](diffhunk://#diff-c1b6dd504a16fc68cd064baf9cf07d9dd31da56eb55de69601844ab03a5ae319L5-R5)
[[12]](diffhunk://#diff-2fc7fcfcfdbb617dd8fbb6b1a2ea5709f9018d618d13942cb33d3e0ed127df16L5-R5)
[[13]](diffhunk://#diff-2fc7fcfcfdbb617dd8fbb6b1a2ea5709f9018d618d13942cb33d3e0ed127df16L39-R39)
[[14]](diffhunk://#diff-759e94102d21fe6f9bde8ddb0b4f95b5d5cd214b0355ea0419d3ea6c09e8ffbfL2-R2)
[[15]](diffhunk://#diff-759e94102d21fe6f9bde8ddb0b4f95b5d5cd214b0355ea0419d3ea6c09e8ffbfL19-R19)

**Backend API security:**

* In `ConvertEmlToPDF.java`, error messages now escape user-provided
filenames using `HtmlUtils.htmlEscape`, preventing potential XSS
vulnerabilities when displaying error messages that include filenames.
[[1]](diffhunk://#diff-45d22a96bae3e8a746b7fb2c39e25c80aee0bf733b528a3517db8fdd2a3d25cdR13)
[[2]](diffhunk://#diff-45d22a96bae3e8a746b7fb2c39e25c80aee0bf733b528a3517db8fdd2a3d25cdR156-R170)

**CI/CD reliability:**

* All GitHub Actions workflows (`multiOSReleases.yml`,
`releaseArtifacts.yml`, `tauri-build.yml`) now use `npm ci` instead of
`npm install` for frontend dependency installation, ensuring clean,
reproducible installs that match the lockfile.
[[1]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bL271-R271)
[[2]](diffhunk://#diff-699ff98fe113446c403eb07daf16dd1966c2a047ab0b9f7e38fd695d079f7dddL177-R177)
[[3]](diffhunk://#diff-b34ab107dd4bc92075b2e89b6f16e4a2813e267ca7c2afebdb1931a0a3900d5aL177-R177)

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-13 13:59:59 +00:00
Ludy 251ad63ea6 feat(build): enhance JaCoCo reporting with coverage summary and enforce thresholds (#5352)
# Description of Changes

### What was changed
- Refactored Gradle task configuration to use `tasks.named` and
`configureEach` for better lazy configuration and compatibility.
- Centralized JaCoCo report handling by introducing a single
`jacocoReport` task reference.
- Added a post-processing step to the JaCoCo XML report to:
  - Parse coverage metrics (LINE, INSTRUCTION, BRANCH).
  - Calculate coverage ratios.
  - Print a formatted coverage summary table directly to the build logs.
- Enabled and aligned `jacocoTestCoverageVerification` rules with
defined minimum coverage thresholds.
- Ensured the `build` task depends on the JaCoCo report to always
generate coverage output.

### Why the change was made
- To improve visibility of test coverage results directly in CI and
local builds without manually opening the HTML report.
- To enforce consistent and explicit coverage thresholds for key
metrics.
- To modernize Gradle task configuration and avoid eager task
realization.

---

> Task :proprietary:jacocoTestReport

==== JaCoCo Coverage Summary ====
Metric      | Coverage | Covered/Total | Status | Target   
------------|----------|---------------|--------|----------
LINE        | 9.01%    | 759/8426      | FAIL   | >= 16.00%
INSTRUCTION | 8.41%    | 2741/32590    | FAIL   | >= 14.00%
BRANCH      | 6.04%    | 248/4103      | FAIL   | >= 9.00% 

---

> Task :common:jacocoTestReport

==== JaCoCo Coverage Summary ====
| Metric      | Coverage | Covered/Total | Status | Target   
|------------|----------|---------------|--------|----------
LINE        | 39.47%   | 2996/7591     | PASS   | >= 16.00%
INSTRUCTION | 41.05%   | 12868/31345   | PASS   | >= 14.00%
BRANCH      | 33.43%   | 1166/3488     | PASS   | >= 9.00% 

---

> Task :stirling-pdf:jacocoTestReport

==== JaCoCo Coverage Summary ====
Metric      | Coverage | Covered/Total | Status | Target   
------------|----------|---------------|--------|----------
LINE        | 13.63%   | 2554/18741    | FAIL   | >= 16.00%
INSTRUCTION | 14.59%   | 11459/78532   | PASS   | >= 14.00%
BRANCH      | 10.68%   | 868/8124      | PASS   | >= 9.00% 

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-12 22:17:01 +00:00
Balázs Szücs c7b713ac80 fix(verify-pdf): verification to properly detect non-PDF/A documents with XMP metadata (#5397)
# Description of Changes

Fixed an issue where PDFs containing XMP metadata but lacking PDF/A
identification schema were incorrectly being validated as PDF/A
documents and reporting "PDF/A-1b with errors" instead of "NOT PDF/A".

### Changes Made
- Improved the PDF/A detection logic in `VeraPDFService.java` to check
for both missing XMP metadata and missing PDF/A identification schema
- Added validation for clause 6.7.11 (PDF/A Identification extension
schema requirement) in addition to clause 6.7.2 (XMP metadata presence)
- Documents with XMP metadata but without proper PDF/A identification
now correctly return "NOT PDF/A"

### Root Cause
The previous implementation only checked for missing XMP metadata
(clause 6.7.2) but didn't verify that the XMP contained the required
PDF/A identification schema (clause 6.7.11). This caused documents with
generic XMP metadata to be incorrectly treated as declared PDF/A files.

Fixes issue where non-PDF/A documents with XMP metadata were incorrectly
showing PDF/A validation errors.









<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-01-12 19:39:54 +00:00
Anthony Stirling d4d4538630 hide login if login type disabled (#5438)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-12 19:38:02 +00:00
Anthony Stirling d2677e64dd OCR fix and Mobile QR changes (#5433)
# Description of Changes
## OCR / Tesseract path handling

Makes tessDataPath resolution deterministic with priority: config >
TESSDATA_PREFIX env > default.
Updates language discovery to use runtimePathConfig.getTessDataPath()
instead of raw config value.
Ensure default OCR dir is debian based not alpine

## Mobile scanner: feature gating + new conversion settings
Adds system.mobileScannerSettings (convert-to-PDF + resolution + page
format + stretch) exposed via backend config and configurable in the
proprietary admin UI.
Enforces enableMobileScanner on the MobileScannerController endpoints
(403 when disabled).
Frontend mobile upload flow can now optionally convert received images
to PDF (pdf-lib + canvas).

## Desktop/Tauri connectivity work
Expands tauri-plugin-http permissions and enables dangerous-settings.
Adds a very comprehensive multi-stage server connection diagnostic
routine (with lots of logging).


<img width="688" height="475" alt="image"
src="https://github.com/user-attachments/assets/6f9c1aec-58c7-449b-96b0-52f25430d741"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-12 11:18:37 +00:00
Anthony Stirling 0ae108ca11 new VPS (#5430)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-10 23:50:18 +00:00
stirlingbot[bot] 5236c1ccf6 🤖 format everything with pre-commit by stirlingbot (#5367)
Auto-generated by [create-pull-request][1] with **stirlingbot**

[1]: https://github.com/peter-evans/create-pull-request

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-01-09 23:08:49 +00:00
ConnorYohandAnthony Stirling 72389f5872 Desktop self-hosted connection logging (#5410)
Also added in automotic protocol addition if missing. Defaults to
https://

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-01-09 23:01:31 +00:00
Ludy a284dbfc15 Update CODEOWNERS for backend and frontend (#5415)
# Description of Changes

This pull request updates the `.github/CODEOWNERS` file to reflect
changes in code ownership. The main updates are the addition of
`@balazs-szucs` as a code owner for several directories and some cleanup
of existing owner assignments.

Ownership updates:

* Added `@balazs-szucs` as a code owner for `/app/**`, `/frontend/**`,
`/app/core/src/main/resources/static/**`, and `/.github/**`.

Cleanup:

* Removed duplicate owner from `/docker/backend/**` entry.

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-09 19:05:59 +00:00
Anthony Stirling ff078b9d88 push docker (#5421)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-09 19:04:41 +00:00
James BruntonandAnthony Stirling 18be8f4692 Self-hosted desktop SSO (#5265)
# Description of Changes
Support SSO in self-hosted desktop app.

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-01-09 18:21:16 +00:00
Anthony Stirling dd09f7b7cf minor changes (#5419)
# Description of Changes

OLD
(MEMBER tag)
<img width="690" height="167" alt="image"
src="https://github.com/user-attachments/assets/079a32b6-2483-46a6-a307-8cacb664cbc8"
/>
<img width="719" height="158" alt="image"
src="https://github.com/user-attachments/assets/cba625f6-56de-4b32-b6ea-22cee59fffbd"
/>

NEW
<img width="748" height="248" alt="image"
src="https://github.com/user-attachments/assets/d3c556da-1859-4241-89c6-d0b96fd6072a"
/>
<img width="752" height="416" alt="image"
src="https://github.com/user-attachments/assets/ddeb7f0d-cccb-4690-b05a-7243695e9b61"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-09 12:28:15 +00:00
ConnorYoh 8394b71014 Login-colour-fix-v2 (#5418) 2026-01-09 10:30:34 +00:00
James Brunton b4dd7afe03 Optimise Tauri builds (#5404)
# Description of Changes
- Just build proper installers in CI for each platform
- Provide commands to build just the bundled apps without the need for
installers locally
  - `tauri-dev` - Builds quickly for an unoptimised version of the app
  - `tauri-build` - Builds the full optimised app installer for release
- `tauri-build-dev` - Builds an optimised app with no bundling (builds
to a folder on Mac; raw `.exe` on Windows; etc.)
- `tauri-build-dev-mac` - Builds an optimised bundled Mac app with no
installer (as an `.app` file)
- `tauri-build-dev-windows` - Builds an optimised bundled Windows app as
an `.nsis` installer
- `tauri-build-dev-linux` - Builds an optimised bundled Linux app as an
`.appimage`
2026-01-09 10:23:17 +00:00
James Brunton ef7a3fa933 Fix PDF editor (#5393)
# Description of Changes
PDF editor has been impossible to access since #5169. This fixes the bug
introduced in that which made it impossible to access. The main issue
was the refactor removed the `setLeftPanelView` call.
2026-01-08 09:27:55 +00:00
Anthony Stirling 772ed6f52b OOM logs (#5405)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-07 17:35:45 +00:00
James Brunton 4a1039fa97 Update frontend packages to latest minor version (#5401)
# Description of Changes
Fixes vulnerability warnings in dependencies
2026-01-06 17:06:42 +00:00
Reece BrowneandJames Brunton c158664d2c Chore/v2/improve uploads (#5351)
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-01-06 16:54:14 +00:00
EthanHealy01andJames Brunton d9caa3482c Stop the "Open Files" button from disappearing at certain zoom levels (#5384)
I have been trying to get this perfect for a while, but this is the best
I can do. I tried to get the open files button to be sticky at the
bottom, but beyond certain zoom levels, this causes it to disappear. Not
worth spending more time on it right now.

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2026-01-06 16:53:16 +00:00
James Brunton c44b7e229c Make stamp preview content private (#5392)
# Description of Changes
Currently when applying stamps, the preview of the PDF is not being
treated as private content. This correctly marks the content as private
2026-01-06 16:52:47 +00:00
Ludy f38e3d2b2c fix(Cert): ensure incremental save after PDF signing in CertSignController (#5390)
# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-06 00:01:31 +00:00
Balázs Szücs b6e675fab3 fix(conversion): improve PDF/A conversion quality, color accuracy, and compliance (#5396)
# Description of Changes


* Transparency Fix: Implemented a pre-processing step that adds an
opaque white background to pages before conversion. This ensures that
transparent elements are correctly
flattened against white rather than defaulting to black (a common issue
in Ghostscript flattening).
* Color Distortion Fix: Removed a misconfigured -sDefaultCMYKProfile
setting in the Ghostscript command that was incorrectly pointing to an
RGB profile. This resolves the
"dark/black" color corruption previously seen in print-ready CMYK PDFs.
   * PDF/A Compliance Improvements:
* Font Handling: Updated fixType1FontCharSet to only add a standard
CharSet if it is missing or empty. This prevents validation errors where
subsetted fonts were being forced
         to declare glyphs they did not contain.
* Spot Color Unification: Added fixSeparationColorSpaces to detect and
unify TintTransform objects for Separation colors with the same colorant
name, ensuring consistency
         across document resources.
* OCG Naming: Ensured all Optional Content Groups have a valid Name
entry.





<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-01-05 23:53:39 +00:00
Balázs Szücs 91bf9abbaa refactor(pdf): improve resource management, memory usage, and exception safety across controllers and utilities (#5379)
# Description of Changes

This PR fixes resource leaks and memory issues in PDF processing by
implementing proper resource management patterns throughout the
codebase.

## Key Changes

### Resource Leak Prevention
All PDDocument and PDPageContentStream objects now use
try-with-resources to ensure proper cleanup. Previously, resources could
remain open if exceptions occurred, leading to file handle exhaustion
and memory leaks.

### Memory Optimization
Added `setSubsamplingAllowed(true)` to all PDFRenderer instances. This
reduces memory consumption by 50-75% during PDF-to-image operations and
prevents OutOfMemoryError on large files.

**Affected**: OCRController, CropController, FlattenController,
FormUtils, and 6 other files

### Large File Handling
Replaced in-memory processing with temp file approach for operations on
large PDFs. This prevents loading entire documents into memory.

**Example (GetInfoOnPDF.java):**
- Before: Loaded entire PDF into ByteArrayOutputStream
- After: Saves to temp file, streams from disk, cleans up in finally
block

**Also changed**: PrintFileController, SplitPdfBySizeController

### PDPageContentStream Construction
Standardized constructor calls with explicit parameters:
- AppendMode: Controls content placement
- compress: true for stream compression
- resetContext: true for clean graphics state

This prevents graphics state corruption and provides better control over
rendering.

### Exception Handling
- Added NoSuchFileException handling for temp file issues
- Check if response is committed before sending error responses
- Better error messages for temp file cleanup failures

### Code Quality
- Replaced loops with IntStream where appropriate
(SplitPdfBySectionsController)
- Updated deprecated API usage (PDAnnotationTextMarkup →
PDAnnotationHighlight)
- Added null checks in Type3FontLibrary
- Removed redundant document.close() calls

### Dependencies
Added `org.apache.pdfbox:pdfbox-io` dependency for proper I/O handling.






<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [X] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-01-05 23:43:16 +00:00
Ludy faf0a3555e fix(certSign): accept .pfx/.p12 uploads for PKCS12 and ensure PFX files are included in form data (#5391)
# Description of Changes

This pull request improves the handling of PKCS12 and PFX certificate
file types in the certificate signing tool. The main changes unify and
simplify the logic for uploading and processing these file types,
ensuring consistent behavior and reducing code duplication.

**Certificate file upload improvements:**

* Updated the `CertificateFilesSettings` component to handle both
`PKCS12` and `PFX` certificate types in a single file upload input,
allowing selection of either `.p12` or `.pfx` files and dynamically
adjusting the placeholder text.

**Certificate signing logic updates:**

* Modified the `buildCertSignFormData` function to append the uploaded
file for both `PKCS12` and `PFX` certificate types, ensuring both are
supported during form data construction.

<img width="1916" height="463" alt="image"
src="https://github.com/user-attachments/assets/51dbe130-c25b-4b77-aecd-f3d55d5c62ae"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-05 23:38:27 +00:00
EthanHealy01andAnthony Stirling fbc5d91c2c adding opacity to the PDFs so we can see the numbers and stamps better on the add PDF numbers and the add stamp tools (#5383)
<img width="301" height="450" alt="Screenshot 2026-01-02 at 2 17 19 PM"
src="https://github.com/user-attachments/assets/f7a1fd6d-722e-454a-a63d-d43709dd4e23"
/>

<img width="304" height="612" alt="Screenshot 2026-01-02 at 2 17 53 PM"
src="https://github.com/user-attachments/assets/907ba03c-b95a-49e2-8285-8ccaffaf067f"
/>

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-01-05 23:25:31 +00:00
EthanHealy01 0c96133544 Feature/v2/redact (#5249)
# Description of Changes

- Add manual redaction and added it to the right rail in the viewer

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-01-05 15:12:14 +00:00
282 changed files with 29113 additions and 5258 deletions
+5 -8
View File
@@ -2,20 +2,17 @@
* @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
# Backend
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh
#V1 frontend
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/app/core/src/main/resources/templates/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/app/** @DarioGii @Frooodle @Ludy87 @jbrunton96 @ConnorYoh @balazs-szucs
#V2 frontend
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle
/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @balazs-szucs
/app/core/src/main/resources/static/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
#V2 docker
/docker/backend/** @Frooodle @Ludy87 @DarioGii @Ludy87
/docker/backend/** @Frooodle @Ludy87 @DarioGii
/docker/frontend/** @reecebrowne @ConnorYoh @EthanHealy01 @jbrunton96 @Frooodle @Ludy87
/docker/compose/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
#GHA (All users)
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87
/.github/** @reecebrowne @ConnorYoh @EthanHealy01 @DarioGii @jbrunton96 @Frooodle @Ludy87 @balazs-szucs
+3 -1
View File
@@ -13,6 +13,8 @@
"reecebrowne",
"DarioGii",
"ConnorYoh",
"EthanHealy01"
"EthanHealy01",
"jbrunton96",
"balazs-szucs"
]
}
+1 -1
View File
@@ -6,7 +6,7 @@
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directories:
directories:
- "/" # Location of package manifests
- "/app/common"
- "/app/core"
+1 -1
View File
@@ -200,4 +200,4 @@
from_name: "tauri"
- name: "license-review-required"
color: "EDEDED"
description: "This PR requires a license review"
description: "This PR requires a license review"
+51 -32
View File
@@ -11,13 +11,16 @@ adjusting the format.
Usage:
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
import argparse
import glob
import os
import argparse
import re
from pathlib import Path
import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files
@@ -36,7 +39,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
duplicates = []
# Load TOML file
with open(file_path, "rb") as file:
file_path = Path(file_path)
with file_path.open("rb") as file:
data = tomllib.load(file)
def process_dict(obj, current_prefix=""):
@@ -55,8 +59,8 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
return duplicates
# Maximum size for TOML files (e.g., 500 KB)
MAX_FILE_SIZE = 500 * 1024
# Maximum size for TOML files (e.g., 570 KB)
MAX_FILE_SIZE = 570 * 1024
def parse_toml_file(file_path):
@@ -65,7 +69,8 @@ def parse_toml_file(file_path):
:param file_path: Path to the TOML file.
:return: Dictionary with flattened keys.
"""
with open(file_path, "rb") as file:
file_path = Path(file_path)
with file_path.open("rb") as file:
data = tomllib.load(file)
def flatten_dict(d, parent_key="", sep="."):
@@ -108,7 +113,8 @@ def write_toml_file(file_path, updated_properties):
"""
nested_data = unflatten_dict(updated_properties)
with open(file_path, "wb") as file:
file_path = Path(file_path)
with file_path.open("wb") as file:
tomli_w.dump(nested_data, file)
@@ -119,18 +125,23 @@ def update_missing_keys(reference_file, file_list, branch=""):
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_file = Path(reference_file)
reference_properties = parse_toml_file(reference_file)
branch_path = Path(branch) if branch else Path()
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
file_path = Path(file_path)
language_dir = file_path.parent.name
reference_lang_dir = reference_file.parent.name
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".toml")
or not os.path.dirname(file_path).endswith("locales")
language_dir == reference_lang_dir
or file_path.suffix != ".toml"
or file_path.parents[1].name != "locales"
):
print(f"Skipping file: {file_path}")
continue
current_properties = parse_toml_file(os.path.join(branch, file_path))
current_properties = parse_toml_file(branch_path / file_path)
updated_properties = {}
for ref_key, ref_value in reference_properties.items():
@@ -141,7 +152,7 @@ def update_missing_keys(reference_file, file_list, branch=""):
# Add missing key with reference value
updated_properties[ref_key] = ref_value
write_toml_file(os.path.join(branch, file_path), updated_properties)
write_toml_file(branch_path / file_path, updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
@@ -149,14 +160,17 @@ def check_for_missing_keys(reference_file, file_list, branch):
def read_toml_keys(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
file_path = Path(file_path)
if file_path.is_file():
return parse_toml_file(file_path)
return {}
def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = branch
basename_reference_file = os.path.basename(reference_file)
reference_file = Path(reference_file)
basename_reference_file = reference_file.name
branch_path = Path(branch) if branch else Path()
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
@@ -170,39 +184,44 @@ def check_for_differences(reference_file, file_list, branch, actor):
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "frontend", "public", "locales")
)
base_dir = Path.cwd() / "frontend" / "public" / "locales"
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
absolute_path = os.path.abspath(file_normpath)
file_path = Path(file_path)
file_normpath = file_path
absolute_path = file_normpath.resolve()
basename_current_file = (branch_path / file_normpath).name
locale_dir = file_normpath.parent.name
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
# Verify that file is within the expected directory
if not absolute_path.startswith(base_dir):
raise ValueError(f"Unsafe file found: {file_normpath}")
if not absolute_path.is_relative_to(base_dir):
has_differences = True
report.append(
f"\n⚠️ Unsafe file found: `{locale_dir}/{basename_current_file}`\n\n---\n"
)
continue
# Verify file size before processing
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
raise ValueError(
f"The file {file_normpath} is too large and could pose a security risk."
if (branch_path / file_normpath).stat().st_size > MAX_FILE_SIZE:
has_differences = True
report.append(
f"\n⚠️ The file `{locale_dir}/{basename_current_file}` is too large and could pose a security risk.\n\n---\n"
)
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
locale_dir = os.path.basename(os.path.dirname(file_normpath))
continue
if basename_current_file == basename_reference_file and locale_dir == "en-GB":
continue
if (
not file_normpath.endswith(".toml")
file_normpath.suffix != ".toml"
or basename_current_file != "translation.toml"
):
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
current_keys = read_toml_keys(os.path.join(branch, file_path))
current_keys = read_toml_keys(branch_path / file_path)
reference_key_count = len(reference_keys)
current_key_count = len(current_keys)
@@ -247,13 +266,13 @@ def check_for_differences(reference_file, file_list, branch, actor):
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
if find_duplicate_keys(os.path.join(branch, file_normpath)):
if find_duplicate_keys(branch_path / file_normpath):
has_differences = True
output = "\n".join(
[
f" - `{key}`: first at {first}, duplicate at `{duplicate}`"
for key, first, duplicate in find_duplicate_keys(
os.path.join(branch, file_normpath)
branch_path / file_normpath
)
]
)
+17 -13
View File
@@ -11,6 +11,10 @@ on:
allow_fork:
description: "Allow deploying fork PR?"
required: false
type: choice
options:
- "true"
- "false"
default: "false"
permissions:
@@ -31,7 +35,7 @@ jobs:
pr_ref: ${{ steps.resolve.outputs.ref }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -42,7 +46,7 @@ jobs:
script: |
const { owner, repo } = context.repo;
let prNumber = context.eventName === 'workflow_dispatch'
? parseInt(process.env.INPUT_PR, 10)
? parseInt(context.payload.inputs.pr, 10)
: context.payload.number;
if (!Number.isInteger(prNumber)) { core.setFailed('Invalid PR number'); return; }
@@ -107,7 +111,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -176,7 +180,7 @@ jobs:
fetch-depth: 0 # Fetch full history for commit hash detection
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Get version number
id: versionNumber
@@ -185,7 +189,7 @@ jobs:
echo "versionNumber=$VERSION" >> $GITHUB_OUTPUT
- name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
@@ -271,7 +275,7 @@ jobs:
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy V2 to VPS
@@ -316,16 +320,16 @@ jobs:
ports:
- "${V2_PORT}:80" # Frontend port (same as regular PRs)
environment:
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:${BACKEND_PORT}"
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:${BACKEND_PORT}"
depends_on:
- stirling-pdf-v2-backend
restart: on-failure:5
EOF
# Deploy to VPS
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose-v2.yml
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose-v2.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
# Create V2 PR-specific directories
mkdir -p /stirling/V2-PR-${{ needs.check-pr.outputs.pr_number }}/{data,config,logs}
@@ -375,7 +379,7 @@ jobs:
}
}
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${v2Port}`;
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${v2Port}`;
const httpsUrl = `https://${v2Port}.ssl.stirlingpdf.cloud`;
const commentBody = `## 🚀 V2 Auto-Deployment Complete!\n\n` +
@@ -402,7 +406,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@6c439dc8bdf85cadbbce9ed30d1c7b959517bc49 # v2.12.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -450,12 +454,12 @@ jobs:
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup V2 deployment
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
if [ -d "/stirling/V2-PR-${{ github.event.pull_request.number }}" ]; then
echo "Found V2 PR directory, proceeding with cleanup..."
@@ -25,8 +25,7 @@ jobs:
github.event.comment.user.login == 'frooodle' ||
github.event.comment.user.login == 'sf298' ||
github.event.comment.user.login == 'Ludy87' ||
github.event.comment.user.login == 'LaserKaspar' ||
github.event.comment.user.login == 'sbplat' ||
github.event.comment.user.login == 'balazs-szucs' ||
github.event.comment.user.login == 'reecebrowne' ||
github.event.comment.user.login == 'DarioGii' ||
github.event.comment.user.login == 'EthanHealy01' ||
@@ -41,7 +40,7 @@ jobs:
enable_enterprise: ${{ steps.check-pro-flag.outputs.enable_enterprise }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -129,7 +128,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -169,7 +168,7 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
@@ -190,7 +189,7 @@ jobs:
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy to VPS
@@ -252,9 +251,9 @@ jobs:
EOF
# Then copy the file and execute commands
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
# Create PR-specific directories
mkdir -p /stirling/PR-${{ needs.check-comment.outputs.pr_number }}/{data,config,logs}
@@ -336,7 +335,7 @@ jobs:
const prNumber = ${{ needs.check-comment.outputs.pr_number }};
const securityStatus = process.env.security_status || "Security Disabled";
const deploymentUrl = `http://${{ secrets.VPS_HOST }}:${prNumber}`;
const deploymentUrl = `http://${{ secrets.NEW_VPS_HOST }}:${prNumber}`;
const commentBody = `## 🚀 PR Test Deployment\n\n` +
`Your PR has been deployed for testing!\n\n` +
`🔗 **Test URL:** [${deploymentUrl}](${deploymentUrl})\n` +
@@ -363,7 +362,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+4 -4
View File
@@ -8,7 +8,7 @@ permissions:
contents: read
env:
SERVER_IP: ${{ secrets.VPS_IP }} # Add this to your GitHub secrets
SERVER_IP: ${{ secrets.NEW_VPS_IP }} # Add this to your GitHub secrets
CLEANUP_PERFORMED: "false" # Add flag to track if cleanup occurred
jobs:
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -100,14 +100,14 @@ jobs:
if: steps.remove-label-comment.outputs.present == 'true'
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup PR deployment
if: steps.remove-label-comment.outputs.present == 'true'
id: cleanup
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << 'ENDSSH'
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -T ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << 'ENDSSH'
if [ -d "/stirling/PR-${{ github.event.pull_request.number }}" ]; then
echo "Found PR directory, proceeding with cleanup..."
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+24 -12
View File
@@ -32,7 +32,7 @@ jobs:
frontend: ${{ steps.changes.outputs.frontend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Checkout repository
@@ -56,7 +56,7 @@ jobs:
spring-security: [true, false]
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Checkout repository
@@ -97,6 +97,7 @@ jobs:
with:
name: test-reports-jdk-${{ matrix.jdk-version }}-spring-security-${{ matrix.spring-security }}
path: |
app/**/build/reports/jacoco/test
app/**/build/reports/tests/
app/**/build/test-results/
app/**/build/reports/problems/
@@ -104,13 +105,24 @@ jobs:
retention-days: 3
if-no-files-found: warn
- name: Add coverage to PR with spring security ${{ matrix.spring-security }} and JDK ${{ matrix.jdk-version }}
id: jacoco
uses: madrapps/jacoco-report@50d3aff4548aa991e6753342d9ba291084e63848 # v1.7.2
with:
paths: |
${{ github.workspace }}/**/build/reports/jacoco/test/jacocoTestReport.xml
token: ${{ secrets.GITHUB_TOKEN }}
min-coverage-overall: 10
min-coverage-changed-files: 0
comment-type: summary
check-generateOpenApiDocs:
if: needs.files-changed.outputs.openapi == 'true'
needs: [files-changed]
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -140,7 +152,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Checkout repository
@@ -174,7 +186,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -219,7 +231,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -233,7 +245,7 @@ jobs:
distribution: "temurin"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Install Docker Compose
run: |
@@ -274,7 +286,7 @@ jobs:
artifact-suffix: Dockerfile.fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -301,11 +313,11 @@ jobs:
STIRLING_PDF_DESKTOP_UI: false
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build ${{ matrix.docker-rev }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
@@ -314,8 +326,8 @@ jobs:
context: .
file: ./${{ matrix.docker-rev }}
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
cache-from: type=gha,scope=${{ matrix.artifact-suffix }}
cache-to: type=gha,mode=max,scope=${{ matrix.artifact-suffix }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
+3 -1
View File
@@ -7,6 +7,8 @@ on:
types: [opened, synchronize, reopened]
paths:
- "frontend/public/locales/*/translation.toml"
- ".github/scripts/check_language_toml.py"
- ".github/workflows/check_toml.yml"
# cancel in-progress jobs if a new job is triggered
concurrency:
@@ -25,7 +27,7 @@ jobs:
pull-requests: write # Allow writing to pull requests
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+6 -6
View File
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Get commit hashes for frontend and backend
id: commit-hashes
@@ -119,7 +119,7 @@ jobs:
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
chmod 600 ../private.key
- name: Deploy to VPS on port 3000
@@ -158,17 +158,17 @@ jobs:
ports:
- "3000:80"
environment:
VITE_API_BASE_URL: "http://${{ secrets.VPS_HOST }}:13000"
VITE_API_BASE_URL: "http://${{ secrets.NEW_VPS_HOST }}:13000"
depends_on:
- backend
restart: on-failure:5
EOF
# Copy to remote with unique name
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/$UNIQUE_NAME
scp -i ../private.key -o StrictHostKeyChecking=no $UNIQUE_NAME ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/$UNIQUE_NAME
# SSH and rename/move atomically to avoid interference
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << ENDSSH
ssh -i ../private.key -o StrictHostKeyChecking=no ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << ENDSSH
mkdir -p /stirling/V2/{data,config,logs}
mv /tmp/$UNIQUE_NAME /stirling/V2/docker-compose.yml
cd /stirling/V2
@@ -25,7 +25,7 @@ jobs:
licenses-backend: ${{ steps.changes.outputs.licenses-backend }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -49,7 +49,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -312,7 +312,7 @@ jobs:
repository-projects: write # Required for enabling automerge
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
issues: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+5 -5
View File
@@ -38,7 +38,7 @@ jobs:
version: ${{ steps.versionNumber.outputs.versionNumber }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -106,7 +106,7 @@ jobs:
file_suffix: "-server"
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -162,7 +162,7 @@ jobs:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -268,7 +268,7 @@ jobs:
- name: Install frontend dependencies
working-directory: ./frontend
run: npm install
run: npm ci
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
@@ -530,7 +530,7 @@ jobs:
contents: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
-245
View File
@@ -1,245 +0,0 @@
name: Push Docker Image - V2 Branch
on:
workflow_dispatch:
push:
branches:
- V2-master
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-24.04-8core
permissions:
packages: write
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up JDK 21
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@4d9f0ba0025fe599b4ebab900eb7f3a1d93ef4c2 # v5.0.0
with:
gradle-version: 8.14
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Install cosign
if: github.ref == 'refs/heads/V2-master'
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
with:
cosign-release: "v2.4.1"
- name: Login to Docker Hub
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up QEMU
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Generate tags for latest (V2-master branch - production)
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Generate tags for latest (V1_V2_merge branch - test)
id: meta-test
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref == 'refs/heads/V1_V2_merge'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Build and push Unified Dockerfile (latest variant)
id: build-push-latest
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.tags || steps.meta-test.outputs.tags }}
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta.outputs.labels || steps.meta-test.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign regular images
if: github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes \
--key env://COSIGN_PRIVATE_KEY \
"${tag}@${DIGEST}"
done
- name: Generate tags for latest-fat (V2-master branch - production)
id: meta-fat
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Generate tags for latest-fat (V1_V2_merge branch - test)
id: meta-fat-test
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref == 'refs/heads/V1_V2_merge'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/embedded/Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.tags || steps.meta-fat-test.outputs.tags }}
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-fat.outputs.labels || steps.meta-fat-test.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign fat images
if: github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta-fat.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
- name: Generate tags for ultra-lite (V2-master branch - production)
id: meta-lite
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref == 'refs/heads/V2-master'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
type=raw,value=latest-ultra-lite
- name: Generate tags for ultra-lite (V1_V2_merge branch - test)
id: meta-lite-test
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref == 'refs/heads/V1_V2_merge'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
type=raw,value=latest-ultra-lite
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.tags || steps.meta-lite-test.outputs.tags }}
labels: ${{ github.ref == 'refs/heads/V2-master' && steps.meta-lite.outputs.labels || steps.meta-lite-test.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign ultra-lite images
if: github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
TAGS: ${{ steps.meta-lite.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
+74 -65
View File
@@ -6,6 +6,8 @@ on:
branches:
- master
- main
- V2-master
- testMain
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -25,13 +27,13 @@ permissions:
jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
runs-on: ubuntu-24.04-8core
permissions:
packages: write
id-token: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -47,26 +49,20 @@ jobs:
with:
gradle-version: 8.14
- name: Run Gradle Command
run: ./gradlew clean build
env:
DISABLE_ADDITIONAL_FEATURES: true
STIRLING_PDF_DESKTOP_UI: false
- name: Install cosign
if: github.ref == 'refs/heads/master'
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.4.1"
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Get version number
id: versionNumber
run: echo "versionNumber=$(./gradlew printVersion --quiet | tail -1)" >> $GITHUB_OUTPUT
- name: Install cosign
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.4.1"
- name: Login to Docker Hub
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
@@ -87,10 +83,9 @@ jobs:
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Generate tags
- name: Generate tags for latest
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref != 'refs/heads/main'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -98,13 +93,13 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }},enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/testMain' }}
- name: Build and push main Dockerfile
id: build-push-regular
- name: Build and push Unified Dockerfile (latest variant)
id: build-push-latest
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -120,9 +115,9 @@ jobs:
sbom: true
- name: Sign regular images
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-regular.outputs.digest }}
DIGEST: ${{ steps.build-push-latest.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
@@ -133,10 +128,10 @@ jobs:
"${tag}@${DIGEST}"
done
- name: Generate tags ultra-lite
id: meta2
- name: Generate tags for latest-fat
id: meta-fat
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref != 'refs/heads/main'
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
@@ -144,43 +139,13 @@ jobs:
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
- name: Build and push Dockerfile-ultra-lite
id: build-push-lite
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main'
with:
context: .
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta2.outputs.tags }}
labels: ${{ steps.meta2.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Generate tags fat
id: meta3
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=latest-fat,enable=${{ github.ref == 'refs/heads/master' }}
type=raw,value=alpha,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push main Dockerfile fat
- name: Build and push Unified Dockerfile (fat variant)
id: build-push-fat
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
@@ -188,18 +153,62 @@ jobs:
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta3.outputs.tags }}
labels: ${{ steps.meta3.outputs.labels }}
tags: ${{ steps.meta-fat.outputs.tags }}
labels: ${{ steps.meta-fat.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign fat images
if: github.ref == 'refs/heads/master'
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-fat.outputs.digest }}
TAGS: ${{ steps.meta3.outputs.tags }}
TAGS: ${{ steps.meta-fat.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --key env://COSIGN_PRIVATE_KEY --yes "${tag}@${DIGEST}"
done
- name: Generate tags for ultra-lite
id: meta-lite
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
images: |
${{ secrets.DOCKER_HUB_USERNAME }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/s-pdf
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf
tags: |
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
type=raw,value=latest-ultra-lite,enable=${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master' }}
- name: Build and push Unified Dockerfile (ultra-lite variant)
id: build-push-lite
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
if: github.ref != 'refs/heads/main' && github.ref != 'refs/heads/testMain'
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.meta-lite.outputs.tags }}
labels: ${{ steps.meta-lite.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Sign ultra-lite images
if: github.ref == 'refs/heads/master' || github.ref == 'refs/heads/V2-master'
env:
DIGEST: ${{ steps.build-push-lite.outputs.digest }}
TAGS: ${{ steps.meta-lite.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -75,6 +75,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
uses: github/codeql-action/upload-sarif@fdbfb4d2750291e159f0156def62b853c2798ca2 # v3.29.5
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v3.29.5
with:
sarif_file: results.sarif
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
pull-requests: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+1 -1
View File
@@ -32,7 +32,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
+4 -5
View File
@@ -67,7 +67,7 @@ jobs:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -174,7 +174,7 @@ jobs:
- name: Install frontend dependencies
working-directory: ./frontend
run: npm install
run: npm ci
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
@@ -507,7 +507,6 @@ jobs:
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
@@ -595,8 +594,8 @@ jobs:
fi
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
echo "Checking for macOS artifacts..."
find . -name "*.dmg" -o -name "*.app" | head -5
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
find . -name "*.dmg" | head -5
if [ $(find . -name "*.dmg" | wc -l) -eq 0 ]; then
echo "❌ No macOS artifacts found"
exit 1
fi
+10 -10
View File
@@ -25,7 +25,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -49,7 +49,7 @@ jobs:
DISABLE_ADDITIONAL_FEATURES: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Get version number
id: versionNumber
@@ -76,7 +76,7 @@ jobs:
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Deploy to VPS
@@ -107,9 +107,9 @@ jobs:
restart: on-failure:5
EOF
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }}:/tmp/docker-compose.yml
scp -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null docker-compose.yml ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }}:/tmp/docker-compose.yml
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
mkdir -p /stirling/test-${{ github.sha }}/{data,config,logs}
mv /tmp/docker-compose.yml /stirling/test-${{ github.sha }}/docker-compose.yml
cd /stirling/test-${{ github.sha }}
@@ -140,7 +140,7 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
@@ -161,7 +161,7 @@ jobs:
npm install
npm run build
npm install dashcam-chrome --save
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.VPS_HOST }}:1337"
Start-Process "C:/Program Files/Google/Chrome/Application/chrome.exe" -ArgumentList "--start-maximized", "--load-extension=$(pwd)/node_modules/dashcam-chrome/build", "http://${{ secrets.NEW_VPS_HOST }}:1337"
Start-Sleep -Seconds 20
prompt: |
1. /run testing/testdriver/test.yml
@@ -176,20 +176,20 @@ jobs:
steps:
- name: Harden Runner
uses: step-security/harden-runner@95d9a5deda9de15063e7595e9719c11c38c90ae2 # v2.13.2
uses: step-security/harden-runner@20cf305ff2072d973412fa9b1e3a4f227bda3c76 # v2.14.0
with:
egress-policy: audit
- name: Set up SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.VPS_SSH_KEY }}" > ../private.key
echo "${{ secrets.NEW_VPS_SSH_KEY }}" > ../private.key
sudo chmod 600 ../private.key
- name: Cleanup deployment
if: always()
run: |
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.VPS_USERNAME }}@${{ secrets.VPS_HOST }} << EOF
ssh -i ../private.key -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${{ secrets.NEW_VPS_USERNAME }}@${{ secrets.NEW_VPS_HOST }} << EOF
cd /stirling/test-${{ github.sha }}
docker-compose down
cd /stirling
+4
View File
@@ -37,12 +37,16 @@ dependencies {
api 'com.drewnoakes:metadata-extractor:2.19.0' // Image metadata extractor
api 'com.vladsch.flexmark:flexmark-html2md-converter:0.64.8'
api "org.apache.pdfbox:pdfbox:$pdfboxVersion"
api "org.apache.pdfbox:pdfbox-io:$pdfboxVersion"
api "org.apache.pdfbox:xmpbox:$pdfboxVersion"
api "org.apache.pdfbox:preflight:$pdfboxVersion"
api 'com.github.junrar:junrar:7.5.7' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:2.10'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.14"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
}
@@ -94,16 +94,19 @@ public class RuntimePathConfig {
defaultSOfficePath, operations != null ? operations.getSoffice() : null);
// Initialize Tesseract data path
String defaultTessDataPath =
isDocker ? "/usr/share/tesseract-ocr/5/tessdata" : "/usr/share/tessdata";
// Priority: config setting > TESSDATA_PREFIX env var > default path
String tessPath = system.getTessdataDir();
String tessdataDir = java.lang.System.getenv("TESSDATA_PREFIX");
String tessdataPrefix = java.lang.System.getenv("TESSDATA_PREFIX");
String defaultPath = "/usr/share/tesseract-ocr/5/tessdata";
if (tessPath != null && !tessPath.isEmpty()) {
this.tessDataPath = tessPath;
} else if (tessdataPrefix != null && !tessdataPrefix.isEmpty()) {
this.tessDataPath = tessdataPrefix;
} else {
this.tessDataPath = defaultPath;
}
this.tessDataPath =
resolvePath(
defaultTessDataPath,
(tessPath != null && !tessPath.isEmpty()) ? tessPath : tessdataDir);
log.info("Using Tesseract data path: {}", this.tessDataPath);
}
@@ -61,6 +61,7 @@ public class ApplicationProperties {
private AutomaticallyGenerated automaticallyGenerated = new AutomaticallyGenerated();
private Mail mail = new Mail();
private Telegram telegram = new Telegram();
private Premium premium = new Premium();
@@ -418,6 +419,15 @@ public class ApplicationProperties {
// 'https://app.example.com'). If not set, falls back to backendUrl.
private boolean enableMobileScanner = false; // Enable mobile phone QR code upload feature
private MobileScannerSettings mobileScannerSettings = new MobileScannerSettings();
@Data
public static class MobileScannerSettings {
private boolean convertToPdf = true; // Whether to automatically convert images to PDF
private String imageResolution = "full"; // Options: "full", "reduced"
private String pageFormat = "A4"; // Options: "keep", "A4", "letter"
private boolean stretchToFit = false; // Whether to stretch image to fill page
}
public boolean isAnalyticsEnabled() {
return this.getEnableAnalytics() != null && this.getEnableAnalytics();
@@ -542,10 +552,10 @@ public class ApplicationProperties {
@Override
public String toString() {
return """
Driver {
driverName='%s'
}
"""
Driver {
driverName='%s'
}
"""
.formatted(driverName);
}
}
@@ -598,6 +608,7 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private CustomMetadata customMetadata = new CustomMetadata();
@Deprecated
@Data
public static class CustomMetadata {
private boolean autoUpdateMetadata;
@@ -605,16 +616,23 @@ public class ApplicationProperties {
private String creator;
private String producer;
@Deprecated
public String getCreator() {
return creator == null || creator.trim().isEmpty() ? "Stirling-PDF" : creator;
}
@Deprecated
public String getProducer() {
return producer == null || producer.trim().isEmpty() ? "Stirling-PDF" : producer;
}
}
}
/**
* Mail server configuration properties.
*
* @since 0.46.1
*/
@Data
public static class Mail {
private boolean enabled;
@@ -637,6 +655,102 @@ public class ApplicationProperties {
private Boolean sslCheckServerIdentity;
}
/**
* Telegram bot configuration properties.
*
* @since 2.2.x
*/
@Data
public static class Telegram {
private Boolean enabled = false;
@ToString.Exclude private String botToken;
private String botUsername;
private String pipelineInboxFolder = "telegram";
private Boolean customFolderSuffix = false;
private Boolean enableAllowUserIDs = false;
private List<Long> allowUserIDs = new ArrayList<>();
private Boolean enableAllowChannelIDs = false;
private List<Long> allowChannelIDs = new ArrayList<>();
private long processingTimeoutSeconds = 180;
private long pollingIntervalMillis = 2000;
private Feedback feedback = new Feedback();
/**
* Configuration for feedback messages sent by the Telegram bot.
*
* @since 2.2.x
*/
@Data
public static class Feedback {
private Channel channel = new Channel();
private User user = new User();
/**
* Channel-specific feedback settings.
*
* @since 2.2.x
*/
@Data
public static class Channel {
/**
* Set to {@code false} to hide/suppress "no valid document" feedback messages to
* the channel (to avoid spam).
*/
private Boolean noValidDocument = true;
/**
* Set to {@code false} to hide/suppress generic error feedback messages to the
* channel (to avoid spam).
*/
private Boolean errorMessage = true;
/**
* Set to {@code false} to hide/suppress processing error feedback messages to the
* channel (to avoid spam).
*/
private Boolean errorProcessing = true;
/**
* Set to {@code false} to hide/suppress "processing" feedback messages to the
* channel (to avoid spam).
*/
private Boolean processing = true;
}
/**
* User-specific feedback settings.
*
* @since 2.2.x
*/
@Data
public static class User {
/**
* Set to {@code false} to hide/suppress "no valid document" feedback messages to
* users (to avoid spam).
*/
private Boolean noValidDocument = true;
/**
* Set to {@code false} to hide/suppress generic error feedback messages to users
* (to avoid spam).
*/
private Boolean errorMessage = true;
/**
* Set to {@code false} to hide/suppress processing error feedback messages to users
* (to avoid spam).
*/
private Boolean errorProcessing = true;
/**
* Set to {@code false} to hide/suppress "processing" feedback messages to users (to
* avoid spam).
*/
private Boolean processing = true;
}
}
}
@Data
public static class Premium {
private boolean enabled;
@@ -129,7 +129,12 @@ public class CbrUtils {
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(document, page)) {
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
contentStream.drawImage(pdImage, 0, 0);
}
} catch (IOException e) {
@@ -97,7 +97,12 @@ public class CbzUtils {
new PDRectangle(pdImage.getWidth(), pdImage.getHeight()));
document.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(document, page)) {
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
contentStream.drawImage(pdImage, 0, 0);
}
} catch (IOException e) {
@@ -1,651 +1,417 @@
package stirling.software.common.util;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Constructor;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Properties;
import java.util.regex.Pattern;
import java.util.Objects;
import java.util.stream.Collectors;
import org.springframework.http.MediaType;
import org.simplejavamail.api.email.AttachmentResource;
import org.simplejavamail.api.email.Email;
import org.simplejavamail.api.email.Recipient;
import org.simplejavamail.converter.EmailConverter;
import jakarta.activation.DataSource;
import jakarta.mail.Message.RecipientType;
import lombok.Data;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
@Slf4j
@UtilityClass
public class EmlParser {
private static volatile Boolean jakartaMailAvailable = null;
private static volatile Method mimeUtilityDecodeTextMethod = null;
private static volatile boolean mimeUtilityChecked = false;
// Configuration constants
private final int DEFAULT_MAX_ATTACHMENT_MB = 10;
private final long MAX_SIZE_ESTIMATION_BYTES = 500L * 1024 * 1024; // 500MB
private static final Pattern MIME_ENCODED_PATTERN =
RegexPatternUtils.getInstance().getMimeEncodedWordPattern();
// Message constants
private final String NO_CONTENT_MESSAGE = "Email content could not be parsed";
private final String ATTACHMENT_PREFIX = "attachment-";
private static final String DISPOSITION_ATTACHMENT = "attachment";
private static final String TEXT_PLAIN = MediaType.TEXT_PLAIN_VALUE;
private static final String TEXT_HTML = MediaType.TEXT_HTML_VALUE;
private static final String MULTIPART_PREFIX = "multipart/";
private static final String HEADER_CONTENT_TYPE = "content-type:";
private static final String HEADER_CONTENT_DISPOSITION = "content-disposition:";
private static final String HEADER_CONTENT_TRANSFER_ENCODING = "content-transfer-encoding:";
private static final String HEADER_CONTENT_ID = "Content-ID";
private static final String HEADER_SUBJECT = "Subject:";
private static final String HEADER_FROM = "From:";
private static final String HEADER_TO = "To:";
private static final String HEADER_CC = "Cc:";
private static final String HEADER_BCC = "Bcc:";
private static final String HEADER_DATE = "Date:";
private static synchronized boolean isJakartaMailAvailable() {
if (jakartaMailAvailable == null) {
try {
Class.forName("jakarta.mail.internet.MimeMessage");
Class.forName("jakarta.mail.Session");
Class.forName("jakarta.mail.internet.MimeUtility");
Class.forName("jakarta.mail.internet.MimePart");
Class.forName("jakarta.mail.internet.MimeMultipart");
Class.forName("jakarta.mail.Multipart");
Class.forName("jakarta.mail.Part");
jakartaMailAvailable = true;
} catch (ClassNotFoundException e) {
jakartaMailAvailable = false;
}
}
return jakartaMailAvailable;
}
public static EmailContent extractEmailContent(
public EmailContent extractEmailContent(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
throws IOException {
EmlProcessingUtils.validateEmlInput(emlBytes);
if (isJakartaMailAvailable()) {
return extractEmailContentAdvanced(emlBytes, request, customHtmlSanitizer);
} else {
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
}
Email email = parseEmail(emlBytes);
return buildEmailContent(email, request, customHtmlSanitizer);
}
private static EmailContent extractEmailContentBasic(
byte[] emlBytes, CustomHtmlSanitizer customHtmlSanitizer) {
String emlContent = new String(emlBytes, StandardCharsets.UTF_8);
EmailContent content = new EmailContent();
content.setSubject(extractBasicHeader(emlContent, HEADER_SUBJECT));
content.setFrom(extractBasicHeader(emlContent, HEADER_FROM));
content.setTo(extractBasicHeader(emlContent, HEADER_TO));
content.setCc(extractBasicHeader(emlContent, HEADER_CC));
content.setBcc(extractBasicHeader(emlContent, HEADER_BCC));
String dateStr = extractBasicHeader(emlContent, HEADER_DATE);
if (!dateStr.isEmpty()) {
content.setDateString(dateStr);
}
String htmlBody = extractHtmlBody(emlContent);
if (htmlBody != null) {
content.setHtmlBody(htmlBody);
} else {
String textBody = extractTextBody(emlContent);
content.setTextBody(textBody != null ? textBody : "Email content could not be parsed");
}
content.getAttachments().addAll(extractAttachmentsBasic(emlContent));
return content;
}
private static EmailContent extractEmailContentAdvanced(
byte[] emlBytes, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
try {
Class<?> sessionClass = Class.forName("jakarta.mail.Session");
Class<?> mimeMessageClass = Class.forName("jakarta.mail.internet.MimeMessage");
Method getDefaultInstance =
sessionClass.getMethod("getDefaultInstance", Properties.class);
Object session = getDefaultInstance.invoke(null, new Properties());
Class<?>[] constructorArgs = new Class<?>[] {sessionClass, InputStream.class};
Constructor<?> mimeMessageConstructor =
mimeMessageClass.getConstructor(constructorArgs);
Object message =
mimeMessageConstructor.newInstance(session, new ByteArrayInputStream(emlBytes));
return extractFromMimeMessage(message, request, customHtmlSanitizer);
} catch (ReflectiveOperationException e) {
return extractEmailContentBasic(emlBytes, customHtmlSanitizer);
}
}
private static EmailContent extractFromMimeMessage(
Object message, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer) {
EmailContent content = new EmailContent();
try {
Class<?> messageClass = message.getClass();
Method getSubject = messageClass.getMethod("getSubject");
String subject = (String) getSubject.invoke(message);
content.setSubject(subject != null ? safeMimeDecode(subject) : "No Subject");
Method getFrom = messageClass.getMethod("getFrom");
Object[] fromAddresses = (Object[]) getFrom.invoke(message);
content.setFrom(buildAddressString(fromAddresses));
extractRecipients(message, messageClass, content);
Method getSentDate = messageClass.getMethod("getSentDate");
Date legacyDate = (Date) getSentDate.invoke(message);
if (legacyDate != null) {
content.setDate(
ZonedDateTime.ofInstant(legacyDate.toInstant(), ZoneId.systemDefault()));
}
Method getContent = messageClass.getMethod("getContent");
Object messageContent = getContent.invoke(message);
processMessageContent(message, messageContent, content, request, customHtmlSanitizer);
} catch (ReflectiveOperationException | RuntimeException e) {
content.setSubject("Email Conversion");
content.setFrom("Unknown");
content.setTo("Unknown");
content.setCc("");
content.setBcc("");
content.setTextBody("Email content could not be parsed with advanced processing");
}
return content;
}
private static void extractRecipients(
Object message, Class<?> messageClass, EmailContent content) {
try {
Method getRecipients =
messageClass.getMethod(
"getRecipients", Class.forName("jakarta.mail.Message$RecipientType"));
Class<?> recipientTypeClass = Class.forName("jakarta.mail.Message$RecipientType");
Object toType = recipientTypeClass.getField("TO").get(null);
Object[] toRecipients = (Object[]) getRecipients.invoke(message, toType);
content.setTo(buildAddressString(toRecipients));
Object ccType = recipientTypeClass.getField("CC").get(null);
Object[] ccRecipients = (Object[]) getRecipients.invoke(message, ccType);
content.setCc(buildAddressString(ccRecipients));
Object bccType = recipientTypeClass.getField("BCC").get(null);
Object[] bccRecipients = (Object[]) getRecipients.invoke(message, bccType);
content.setBcc(buildAddressString(bccRecipients));
} catch (ReflectiveOperationException e) {
try {
Method getAllRecipients = messageClass.getMethod("getAllRecipients");
Object[] recipients = (Object[]) getAllRecipients.invoke(message);
content.setTo(buildAddressString(recipients));
content.setCc("");
content.setBcc("");
} catch (ReflectiveOperationException ex) {
content.setTo("");
content.setCc("");
content.setBcc("");
}
}
}
private static String buildAddressString(Object[] addresses) {
if (addresses == null || addresses.length == 0) {
return "";
}
StringBuilder builder = new StringBuilder();
for (int i = 0; i < addresses.length; i++) {
if (i > 0) builder.append(", ");
builder.append(safeMimeDecode(addresses[i].toString()));
}
return builder.toString();
}
private static void processMessageContent(
Object message,
Object messageContent,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
try {
if (messageContent instanceof String stringContent) {
Method getContentType = message.getClass().getMethod("getContentType");
String contentType = (String) getContentType.invoke(message);
if (contentType != null
&& contentType.toLowerCase(Locale.ROOT).contains(TEXT_HTML)) {
content.setHtmlBody(stringContent);
} else {
content.setTextBody(stringContent);
private Email parseEmail(byte[] emlBytes) throws IOException {
boolean isMsgFile = EmlProcessingUtils.isMsgFile(emlBytes);
try (ByteArrayInputStream input = new ByteArrayInputStream(emlBytes)) {
Email email;
if (isMsgFile) {
try {
email = EmailConverter.outlookMsgToEmail(input);
} catch (Exception e) {
// OLE2 magic bytes match but parsing failed - might be DOC/XLS/other OLE2 file
throw new IOException(
"The file appears to be an OLE2 file (MSG/DOC/XLS) but could not be "
+ "parsed as an Outlook email. Ensure it is a valid .msg file: "
+ e.getMessage(),
e);
}
} else {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(messageContent)) {
processMultipart(messageContent, content, request, customHtmlSanitizer, 0);
}
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setTextBody("Email content could not be parsed with advanced processing");
}
}
private static void processMultipart(
Object multipart,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
final int MAX_MULTIPART_DEPTH = 10;
if (depth > MAX_MULTIPART_DEPTH) {
content.setHtmlBody("<div class=\"error\">Maximum multipart depth exceeded</div>");
return;
}
try {
Class<?> multipartClass = multipart.getClass();
Method getCount = multipartClass.getMethod("getCount");
int count = (Integer) getCount.invoke(multipart);
Method getBodyPart = multipartClass.getMethod("getBodyPart", int.class);
for (int i = 0; i < count; i++) {
Object part = getBodyPart.invoke(multipart, i);
processPart(part, content, request, customHtmlSanitizer, depth + 1);
email = EmailConverter.emlToEmail(input);
}
} catch (ReflectiveOperationException | ClassCastException e) {
content.setHtmlBody("<div class=\"error\">Error processing multipart content</div>");
}
}
private static void processPart(
Object part,
EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer,
int depth) {
try {
Class<?> partClass = part.getClass();
Method isMimeType = partClass.getMethod("isMimeType", String.class);
Method getContent = partClass.getMethod("getContent");
Method getDisposition = partClass.getMethod("getDisposition");
Method getFileName = partClass.getMethod("getFileName");
Method getContentType = partClass.getMethod("getContentType");
Method getHeader = partClass.getMethod("getHeader", String.class);
Object disposition = getDisposition.invoke(part);
String filename = (String) getFileName.invoke(part);
String contentType = (String) getContentType.invoke(part);
String normalizedDisposition =
disposition != null ? ((String) disposition).toLowerCase(Locale.ROOT) : null;
if ((Boolean) isMimeType.invoke(part, TEXT_PLAIN) && normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
content.setTextBody(stringContent);
}
} else if ((Boolean) isMimeType.invoke(part, TEXT_HTML)
&& normalizedDisposition == null) {
Object partContent = getContent.invoke(part);
if (partContent instanceof String stringContent) {
String htmlBody =
customHtmlSanitizer != null
? customHtmlSanitizer.sanitize(stringContent)
: stringContent;
content.setHtmlBody(htmlBody);
}
} else if ((normalizedDisposition != null
&& normalizedDisposition.contains(DISPOSITION_ATTACHMENT))
|| (filename != null && !filename.trim().isEmpty())) {
processAttachment(
part, content, request, getHeader, getContent, filename, contentType);
} else if ((Boolean) isMimeType.invoke(part, "multipart/*")) {
Object multipartContent = getContent.invoke(part);
if (multipartContent != null) {
Class<?> multipartClass = Class.forName("jakarta.mail.Multipart");
if (multipartClass.isInstance(multipartContent)) {
processMultipart(
multipartContent, content, request, customHtmlSanitizer, depth + 1);
}
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue processing other parts if one fails
}
}
private static void processAttachment(
Object part,
EmailContent content,
EmlToPdfRequest request,
Method getHeader,
Method getContent,
String filename,
String contentType) {
content.setAttachmentCount(content.getAttachmentCount() + 1);
if (filename != null && !filename.trim().isEmpty()) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(safeMimeDecode(filename));
attachment.setContentType(contentType);
try {
String[] contentIdHeaders = (String[]) getHeader.invoke(part, HEADER_CONTENT_ID);
if (contentIdHeaders != null) {
for (String contentIdHeader : contentIdHeaders) {
if (contentIdHeader != null && !contentIdHeader.trim().isEmpty()) {
attachment.setEmbedded(true);
String contentId =
RegexPatternUtils.getInstance()
.getAngleBracketsPattern()
.matcher(contentIdHeader.trim())
.replaceAll("");
attachment.setContentId(contentId);
break;
}
}
}
} catch (ReflectiveOperationException e) {
}
if ((request != null && request.isIncludeAttachments()) || attachment.isEmbedded()) {
extractAttachmentData(part, attachment, getContent, request);
}
content.getAttachments().add(attachment);
}
}
private static void extractAttachmentData(
Object part, EmailAttachment attachment, Method getContent, EmlToPdfRequest request) {
try {
Object attachmentContent = getContent.invoke(part);
byte[] attachmentData = null;
if (attachmentContent instanceof InputStream inputStream) {
try (InputStream stream = inputStream) {
attachmentData = stream.readAllBytes();
} catch (IOException e) {
if (attachment.isEmbedded()) {
attachmentData = new byte[0];
} else {
throw new RuntimeException(e);
}
}
} else if (attachmentContent instanceof byte[] byteArray) {
attachmentData = byteArray;
} else if (attachmentContent instanceof String stringContent) {
attachmentData = stringContent.getBytes(StandardCharsets.UTF_8);
}
if (attachmentData != null) {
long maxSizeMB = request != null ? request.getMaxAttachmentSizeMB() : 10L;
long maxSizeBytes = maxSizeMB * 1024 * 1024;
if (attachmentData.length <= maxSizeBytes || attachment.isEmbedded()) {
attachment.setData(attachmentData);
attachment.setSizeBytes(attachmentData.length);
} else {
attachment.setSizeBytes(attachmentData.length);
}
}
} catch (ReflectiveOperationException | RuntimeException e) {
// Continue without attachment data
}
}
private static String extractBasicHeader(String emlContent, String headerName) {
try {
String[] lines =
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.toLowerCase(Locale.ROOT).startsWith(headerName.toLowerCase(Locale.ROOT))) {
StringBuilder value =
new StringBuilder(line.substring(headerName.length()).trim());
for (int j = i + 1; j < lines.length; j++) {
if (lines[j].startsWith(" ") || lines[j].startsWith("\t")) {
value.append(" ").append(lines[j].trim());
} else {
break;
}
}
return safeMimeDecode(value.toString());
}
if (line.trim().isEmpty()) break;
}
} catch (RuntimeException e) {
// Ignore errors in header extraction
}
return "";
}
private static String extractHtmlBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
int htmlStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_HTML);
if (htmlStart == -1) return null;
int bodyStart = emlContent.indexOf("\r\n\r\n", htmlStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", htmlStart);
if (bodyStart == -1) return null;
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
return email;
} catch (IOException e) {
throw e; // Re-throw IOException as-is
} catch (Exception e) {
return null;
throw new IOException(
String.format(
"Failed to parse EML file with Simple Java Mail: %s", e.getMessage()),
e);
}
}
private static String extractTextBody(String emlContent) {
try {
String lowerContent = emlContent.toLowerCase(Locale.ROOT);
int textStart = lowerContent.indexOf(HEADER_CONTENT_TYPE + " " + TEXT_PLAIN);
if (textStart == -1) {
int bodyStart = emlContent.indexOf("\r\n\r\n");
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n");
if (bodyStart != -1) {
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
}
return null;
}
private EmailContent buildEmailContent(
Email email, EmlToPdfRequest request, CustomHtmlSanitizer customHtmlSanitizer)
throws IOException {
int bodyStart = emlContent.indexOf("\r\n\r\n", textStart);
if (bodyStart == -1) bodyStart = emlContent.indexOf("\n\n", textStart);
if (bodyStart == -1) return null;
EmailContent content = new EmailContent();
content.setSubject(defaultString(email.getSubject()));
content.setFrom(formatRecipient(email.getFromRecipient()));
content.setTo(formatRecipients(email.getRecipients(), RecipientType.TO));
content.setCc(formatRecipients(email.getRecipients(), RecipientType.CC));
content.setBcc(formatRecipients(email.getRecipients(), RecipientType.BCC));
bodyStart += (emlContent.charAt(bodyStart + 1) == '\r') ? 4 : 2;
int bodyEnd = findPartEnd(emlContent, bodyStart);
return emlContent.substring(bodyStart, bodyEnd).trim();
} catch (RuntimeException e) {
return null;
}
}
private static int findPartEnd(String content, int start) {
String[] lines =
RegexPatternUtils.getInstance()
.getNewlineSplitPattern()
.split(content.substring(start));
StringBuilder result = new StringBuilder();
for (String line : lines) {
if (line.startsWith("--") && line.length() > 10) break;
result.append(line).append("\n");
Date sentDate = email.getSentDate();
if (sentDate != null) {
// Use UTC for consistent timezone handling across deployments
content.setDate(ZonedDateTime.ofInstant(sentDate.toInstant(), ZoneOffset.UTC));
}
return start + result.length();
}
String htmlBody = email.getHTMLText();
if (customHtmlSanitizer != null && htmlBody != null) {
htmlBody = customHtmlSanitizer.sanitize(htmlBody);
}
content.setHtmlBody(htmlBody);
String textBody = email.getPlainText();
if (customHtmlSanitizer != null && textBody != null) {
textBody = customHtmlSanitizer.sanitize(textBody);
}
content.setTextBody(textBody);
if (isBlank(content.getHtmlBody()) && isBlank(content.getTextBody())) {
content.setTextBody(NO_CONTENT_MESSAGE);
}
private static List<EmailAttachment> extractAttachmentsBasic(String emlContent) {
List<EmailAttachment> attachments = new ArrayList<>();
try {
String[] lines =
RegexPatternUtils.getInstance().getNewlineSplitPattern().split(emlContent);
boolean inHeaders = true;
String currentContentType = "";
String currentDisposition = "";
String currentFilename = "";
String currentEncoding = "";
attachments.addAll(mapResources(email.getEmbeddedImages(), request, true));
attachments.addAll(mapResources(email.getAttachments(), request, false));
content.setAttachments(attachments);
content.setAttachmentCount(attachments.size());
for (String line : lines) {
String lowerLine = line.toLowerCase(Locale.ROOT).trim();
return content;
}
if (line.trim().isEmpty()) {
inHeaders = false;
if (isAttachment(currentDisposition, currentFilename, currentContentType)) {
EmailAttachment attachment = new EmailAttachment();
attachment.setFilename(currentFilename);
attachment.setContentType(currentContentType);
attachment.setTransferEncoding(currentEncoding);
attachments.add(attachment);
private List<EmailAttachment> mapResources(
List<AttachmentResource> resources, EmlToPdfRequest request, boolean embedded)
throws IOException {
if (resources == null || resources.isEmpty()) {
return List.of();
}
List<EmailAttachment> mapped = new ArrayList<>(resources.size());
int unnamedCounter = 0; // Start at 0, increment before use
for (AttachmentResource resource : resources) {
if (resource == null) {
continue; // Skip null resources early
}
// Pre-determine if this resource needs a generated filename
boolean needsGeneratedName = !embedded && needsGeneratedFilename(resource);
if (needsGeneratedName) {
unnamedCounter++;
}
EmailAttachment attachment =
toEmailAttachment(resource, request, embedded, unnamedCounter);
if (attachment != null) {
mapped.add(attachment);
}
}
return mapped;
}
/** Checks if a resource needs a generated filename (has no usable name). */
private boolean needsGeneratedFilename(AttachmentResource resource) {
if (resource == null) {
return false;
}
String resourceName = resource.getName();
if (!isBlank(resourceName)) {
return false;
}
DataSource dataSource = resource.getDataSource();
return isBlank(dataSource.getName());
}
private EmailAttachment toEmailAttachment(
AttachmentResource resource, EmlToPdfRequest request, boolean embedded, int counter)
throws IOException {
if (resource == null) {
return null;
}
EmailAttachment attachment = new EmailAttachment();
attachment.setEmbedded(embedded);
String resourceName = defaultString(resource.getName());
String filename = resourceName;
DataSource dataSource = resource.getDataSource();
String contentType = dataSource.getContentType();
if (!isBlank(dataSource.getName())) {
filename = dataSource.getName();
}
filename = safeMimeDecode(filename);
// Generate unique filename for unnamed attachments
if (isBlank(filename)) {
String extension = detectExtensionFromMimeType(contentType);
filename = embedded ? resourceName : (ATTACHMENT_PREFIX + counter + extension);
}
attachment.setFilename(filename);
String contentId = embedded ? stripCid(resourceName) : null;
attachment.setContentId(contentId);
String detectedContentType = EmlProcessingUtils.detectMimeType(filename, contentType);
attachment.setContentType(detectedContentType);
// Read data with size limit to prevent OOM
ReadResult readResult = readData(dataSource, embedded, request);
if (readResult != null) {
attachment.setSizeBytes(readResult.totalSize);
if (shouldIncludeAttachmentData(embedded, request, readResult)) {
attachment.setData(readResult.data);
}
}
return attachment;
}
private boolean shouldIncludeAttachmentData(
boolean embedded, EmlToPdfRequest request, ReadResult readResult) {
// Always include embedded images for proper rendering
if (embedded) {
return readResult != null && readResult.data() != null;
}
// Check if attachments are requested and data is available within size limit
if (request == null || !request.isIncludeAttachments()) {
return false;
}
if (readResult == null || readResult.data() == null) {
return false;
}
return readResult.data().length <= getMaxAttachmentSizeBytes(request);
}
private String detectExtensionFromMimeType(String mimeType) {
if (mimeType == null) {
return "";
}
String lower = mimeType.toLowerCase(Locale.ROOT);
// Remove any parameters (e.g., "text/plain; charset=utf-8" -> "text/plain")
int semicolon = lower.indexOf(';');
if (semicolon > 0) {
lower = lower.substring(0, semicolon).trim();
}
// Match exact MIME types first, then fall back to contains() for variants
return switch (lower) {
case "application/pdf" -> ".pdf";
case "image/png" -> ".png";
case "image/jpeg", "image/jpg" -> ".jpg";
case "image/gif" -> ".gif";
case "image/webp" -> ".webp";
case "image/bmp" -> ".bmp";
case "text/plain" -> ".txt";
case "text/html" -> ".html";
case "text/xml", "application/xml" -> ".xml";
case "application/json" -> ".json";
case "application/zip" -> ".zip";
case "application/octet-stream" -> ".bin";
default -> {
if (lower.contains("wordprocessingml") || lower.contains("msword")) yield ".docx";
if (lower.contains("spreadsheetml") || lower.contains("excel")) yield ".xlsx";
if (lower.contains("presentationml") || lower.contains("powerpoint")) yield ".pptx";
if (lower.contains("opendocument.text")) yield ".odt";
if (lower.contains("opendocument.spreadsheet")) yield ".ods";
yield "";
}
};
}
private ReadResult readData(DataSource dataSource, boolean embedded, EmlToPdfRequest request)
throws IOException {
if (dataSource == null) {
return null;
}
long maxBytes = getMaxAttachmentSizeBytes(request);
try (InputStream input = dataSource.getInputStream()) {
// Embedded images are usually needed for display regardless of size,
// but regular attachments should be guarded against OOM
if (!embedded && request != null) {
byte[] buffer = new byte[8192];
ByteArrayOutputStream output = new ByteArrayOutputStream();
int bytesRead;
long totalBytes = 0;
while ((bytesRead = input.read(buffer)) != -1) {
totalBytes += bytesRead;
if (totalBytes > maxBytes) {
// Attachment too large - skip remaining data but estimate total size
long remainingBytes = countRemainingBytes(input, totalBytes);
log.debug(
"Attachment exceeds size limit: {} bytes (max: {} bytes), skipping",
remainingBytes,
maxBytes);
return new ReadResult(null, remainingBytes);
}
currentContentType = "";
currentDisposition = "";
currentFilename = "";
currentEncoding = "";
inHeaders = true;
continue;
}
if (!inHeaders) continue;
if (lowerLine.startsWith(HEADER_CONTENT_TYPE)) {
currentContentType = line.substring(HEADER_CONTENT_TYPE.length()).trim();
} else if (lowerLine.startsWith(HEADER_CONTENT_DISPOSITION)) {
currentDisposition = line.substring(HEADER_CONTENT_DISPOSITION.length()).trim();
currentFilename = extractFilenameFromDisposition(currentDisposition);
} else if (lowerLine.startsWith(HEADER_CONTENT_TRANSFER_ENCODING)) {
currentEncoding =
line.substring(HEADER_CONTENT_TRANSFER_ENCODING.length()).trim();
output.write(buffer, 0, bytesRead);
}
byte[] data = output.toByteArray();
return new ReadResult(data, data.length);
} else {
byte[] data = input.readAllBytes();
return new ReadResult(data, data.length);
}
} catch (RuntimeException e) {
// Continue with empty list
} catch (IOException e) {
if (embedded) {
log.debug(
"Failed to read embedded image, using empty placeholder: {}",
e.getMessage());
return new ReadResult(new byte[0], 0);
}
throw e;
}
return attachments;
}
private static boolean isAttachment(String disposition, String filename, String contentType) {
return (disposition.toLowerCase(Locale.ROOT).contains(DISPOSITION_ATTACHMENT)
&& !filename.isEmpty())
|| (!filename.isEmpty()
&& !contentType.toLowerCase(Locale.ROOT).startsWith("text/"))
|| (contentType.toLowerCase(Locale.ROOT).contains("application/")
&& !filename.isEmpty());
private long countRemainingBytes(InputStream input, long alreadyRead) throws IOException {
long count = alreadyRead;
long skipped;
while (count < MAX_SIZE_ESTIMATION_BYTES
&& (skipped = input.skip(MAX_SIZE_ESTIMATION_BYTES - count)) > 0) {
count += skipped;
}
if (count < MAX_SIZE_ESTIMATION_BYTES && input.available() > 0) {
byte[] buffer = new byte[8192];
int read;
while ((read = input.read(buffer)) != -1 && count < MAX_SIZE_ESTIMATION_BYTES) {
count += read;
}
}
return count;
}
private static String extractFilenameFromDisposition(String disposition) {
if (disposition == null || !disposition.contains("filename=")) {
private String formatRecipients(List<Recipient> recipients, RecipientType type) {
if (recipients == null || type == null) {
return "";
}
// Handle filename*= (RFC 2231 encoded filename)
if (disposition.toLowerCase(Locale.ROOT).contains("filename*=")) {
int filenameStarStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename*=") + 10;
int filenameStarEnd = disposition.indexOf(";", filenameStarStart);
if (filenameStarEnd == -1) filenameStarEnd = disposition.length();
String extendedFilename =
disposition.substring(filenameStarStart, filenameStarEnd).trim();
extendedFilename =
RegexPatternUtils.getInstance()
.getQuotesRemovalPattern()
.matcher(extendedFilename)
.replaceAll("");
if (extendedFilename.contains("'")) {
String[] parts = extendedFilename.split("'", 3);
if (parts.length == 3) {
return EmlProcessingUtils.decodeUrlEncoded(parts[2]);
}
}
}
// Handle regular filename=
int filenameStart = disposition.toLowerCase(Locale.ROOT).indexOf("filename=") + 9;
int filenameEnd = disposition.indexOf(";", filenameStart);
if (filenameEnd == -1) filenameEnd = disposition.length();
String filename = disposition.substring(filenameStart, filenameEnd).trim();
filename =
RegexPatternUtils.getInstance()
.getQuotesRemovalPattern()
.matcher(filename)
.replaceAll("");
return safeMimeDecode(filename);
return recipients.stream()
.filter(Objects::nonNull)
// Use type.equals() for null-safe comparison (recipient.getType() may be null)
.filter(recipient -> type.equals(recipient.getType()))
.map(EmlParser::formatRecipient)
.filter(string -> !isBlank(string))
.collect(Collectors.joining(", "));
}
public static String safeMimeDecode(String headerValue) {
if (headerValue == null || headerValue.trim().isEmpty()) {
private String formatRecipient(Recipient recipient) {
if (recipient == null) {
return "";
}
if (!mimeUtilityChecked) {
synchronized (EmlParser.class) {
if (!mimeUtilityChecked) {
initializeMimeUtilityDecoding();
}
}
}
String name = safeMimeDecode(recipient.getName());
String address = safeMimeDecode(recipient.getAddress());
if (mimeUtilityDecodeTextMethod != null) {
try {
return (String) mimeUtilityDecodeTextMethod.invoke(null, headerValue.trim());
} catch (ReflectiveOperationException | RuntimeException e) {
// Fall through to custom implementation
}
if (!isBlank(name) && !isBlank(address)) {
return name + " <" + address + ">";
}
return !isBlank(name) ? name : address;
}
public String safeMimeDecode(String headerValue) {
if (isBlank(headerValue)) {
return "";
}
return EmlProcessingUtils.decodeMimeHeader(headerValue.trim());
}
private static void initializeMimeUtilityDecoding() {
try {
Class<?> mimeUtilityClass = Class.forName("jakarta.mail.internet.MimeUtility");
mimeUtilityDecodeTextMethod = mimeUtilityClass.getMethod("decodeText", String.class);
} catch (ClassNotFoundException | NoSuchMethodException e) {
mimeUtilityDecodeTextMethod = null;
private String stripCid(String contentId) {
if (contentId == null) {
return null;
}
return RegexPatternUtils.getInstance()
.getAngleBracketsPattern()
.matcher(contentId)
.replaceAll("")
.trim();
}
private long getMaxAttachmentSizeBytes(EmlToPdfRequest request) {
long maxMb = request != null ? request.getMaxAttachmentSizeMB() : DEFAULT_MAX_ATTACHMENT_MB;
return maxMb * 1024L * 1024L;
}
private boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
private String defaultString(String value) {
return value != null ? value : "";
}
private record ReadResult(byte[] data, long totalSize) {
public ReadResult {
if (totalSize < 0) {
throw new IllegalArgumentException("Size cannot be negative: " + totalSize);
}
if (data != null && data.length > totalSize) {
throw new IllegalArgumentException(
"Data length (" + data.length + ") exceeds total size (" + totalSize + ")");
}
}
mimeUtilityChecked = true;
}
@Data
public static class EmailContent {
public class EmailContent {
private String subject;
private String from;
private String to;
private String cc;
private String bcc;
private ZonedDateTime date;
private String dateString; // For basic parsing fallback
private String dateString; // Maintained for compatibility
private String htmlBody;
private String textBody;
private int attachmentCount;
@@ -673,7 +439,7 @@ public class EmlParser {
}
@Data
public static class EmailAttachment {
public class EmailAttachment {
private String filename;
private String contentType;
private byte[] data;
@@ -1,5 +1,7 @@
package stirling.software.common.util;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
@@ -8,32 +10,41 @@ import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import lombok.Synchronized;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.converters.EmlToPdfRequest;
import stirling.software.common.model.api.converters.HTMLToPdfRequest;
@Slf4j
@UtilityClass
public class EmlProcessingUtils {
// Style constants
private static final int DEFAULT_FONT_SIZE = 12;
private static final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
private static final float DEFAULT_LINE_HEIGHT = 1.4f;
private static final String DEFAULT_ZOOM = "1.0";
private static final String DEFAULT_TEXT_COLOR = "#202124";
private static final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
private static final String DEFAULT_BORDER_COLOR = "#e8eaed";
private static final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
private static final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
private final int DEFAULT_FONT_SIZE = 12;
private final String DEFAULT_FONT_FAMILY = "Helvetica, sans-serif";
private final float DEFAULT_LINE_HEIGHT = 1.4f;
private final String DEFAULT_ZOOM = "1.0";
private final String DEFAULT_TEXT_COLOR = "#202124";
private final String DEFAULT_BACKGROUND_COLOR = "#ffffff";
private final String DEFAULT_BORDER_COLOR = "#e8eaed";
private final String ATTACHMENT_BACKGROUND_COLOR = "#f9f9f9";
private final String ATTACHMENT_BORDER_COLOR = "#eeeeee";
private static final int EML_CHECK_LENGTH = 8192;
private static final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
// MIME type detection
private static final Map<String, String> EXTENSION_TO_MIME_TYPE =
private final String CSS_RESOURCE_PATH = "templates/email-pdf-styles.css";
private final int EML_CHECK_LENGTH = 8192;
private final int MIN_HEADER_COUNT_FOR_VALID_EML = 2;
// MSG file magic bytes (Compound File Binary Format / OLE2)
// D0 CF 11 E0 A1 B1 1A E1
private final byte[] MSG_MAGIC_BYTES = {
(byte) 0xD0, (byte) 0xCF, (byte) 0x11, (byte) 0xE0,
(byte) 0xA1, (byte) 0xB1, (byte) 0x1A, (byte) 0xE1
};
private final Map<String, String> EXTENSION_TO_MIME_TYPE =
Map.of(
".png", MediaType.IMAGE_PNG_VALUE,
".jpg", MediaType.IMAGE_JPEG_VALUE,
@@ -45,18 +56,36 @@ public class EmlProcessingUtils {
".ico", "image/x-icon",
".tiff", "image/tiff",
".tif", "image/tiff");
private volatile String cachedCssContent = null;
public static void validateEmlInput(byte[] emlBytes) {
public void validateEmlInput(byte[] emlBytes) {
if (emlBytes == null || emlBytes.length == 0) {
throw ExceptionUtils.createEmlEmptyException();
}
if (isMsgFile(emlBytes)) {
return; // Valid MSG file, no further EML validation needed
}
if (isInvalidEmlFormat(emlBytes)) {
throw ExceptionUtils.createEmlInvalidFormatException();
}
}
private static boolean isInvalidEmlFormat(byte[] emlBytes) {
public boolean isMsgFile(byte[] fileBytes) {
if (fileBytes == null || fileBytes.length < MSG_MAGIC_BYTES.length) {
return false;
}
for (int i = 0; i < MSG_MAGIC_BYTES.length; i++) {
if (fileBytes[i] != MSG_MAGIC_BYTES[i]) {
return false;
}
}
return true;
}
private boolean isInvalidEmlFormat(byte[] emlBytes) {
try {
int checkLength = Math.min(emlBytes.length, EML_CHECK_LENGTH);
String content;
@@ -101,7 +130,7 @@ public class EmlProcessingUtils {
}
}
public static String generateEnhancedEmailHtml(
public String generateEnhancedEmailHtml(
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
@@ -145,7 +174,7 @@ public class EmlProcessingUtils {
html.append(
String.format(
Locale.ROOT,
"<div><strong>CC:</strong> %s</div>\n",
"<div><strong>CC:</strong> %s</div>%n",
sanitizeText(content.getCc(), customHtmlSanitizer)));
}
@@ -153,7 +182,7 @@ public class EmlProcessingUtils {
html.append(
String.format(
Locale.ROOT,
"<div><strong>BCC:</strong> %s</div>\n",
"<div><strong>BCC:</strong> %s</div>%n",
sanitizeText(content.getBcc(), customHtmlSanitizer)));
}
@@ -161,19 +190,19 @@ public class EmlProcessingUtils {
html.append(
String.format(
Locale.ROOT,
"<div><strong>Date:</strong> %s</div>\n",
"<div><strong>Date:</strong> %s</div>%n",
PdfAttachmentHandler.formatEmailDate(content.getDate())));
} else if (content.getDateString() != null && !content.getDateString().trim().isEmpty()) {
html.append(
String.format(
Locale.ROOT,
"<div><strong>Date:</strong> %s</div>\n",
"<div><strong>Date:</strong> %s</div>%n",
sanitizeText(content.getDateString(), customHtmlSanitizer)));
}
html.append("</div></div>\n");
html.append(String.format(Locale.ROOT, "</div></div>%n"));
html.append("<div class=\"email-body\">\n");
html.append(String.format(Locale.ROOT, "<div class=\"email-body\">%n"));
if (content.getHtmlBody() != null && !content.getHtmlBody().trim().isEmpty()) {
String processedHtml =
processEmailHtmlBody(content.getHtmlBody(), content, customHtmlSanitizer);
@@ -187,17 +216,17 @@ public class EmlProcessingUtils {
} else {
html.append("<div class=\"no-content\"><p><em>No content available</em></p></div>");
}
html.append("</div>\n");
html.append(String.format(Locale.ROOT, "</div>%n"));
if (content.getAttachmentCount() > 0 || !content.getAttachments().isEmpty()) {
appendAttachmentsSection(html, content, request, customHtmlSanitizer);
appendAttachmentsSection(html, content, request);
}
html.append("</div>\n</body></html>");
html.append(String.format(Locale.ROOT, "</div>%n</body></html>"));
return html.toString();
}
public static String processEmailHtmlBody(
public String processEmailHtmlBody(
String htmlBody,
EmlParser.EmailContent emailContent,
CustomHtmlSanitizer customHtmlSanitizer) {
@@ -224,8 +253,7 @@ public class EmlProcessingUtils {
return processed;
}
public static String convertTextToHtml(
String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
public String convertTextToHtml(String textBody, CustomHtmlSanitizer customHtmlSanitizer) {
if (textBody == null) return "";
String html =
@@ -255,129 +283,25 @@ public class EmlProcessingUtils {
return html;
}
private static void appendEnhancedStyles(StringBuilder html) {
String css =
private void appendEnhancedStyles(StringBuilder html) {
html.append(
String.format(
Locale.ROOT,
"""
body {
font-family: %s;
font-size: %dpx;
line-height: %s;
color: %s;
margin: 0;
padding: 16px;
background-color: %s;
}
.email-container {
width: 100%%;
max-width: 100%%;
margin: 0 auto;
}
.email-header {
padding-bottom: 10px;
border-bottom: 1px solid %s;
margin-bottom: 10px;
}
.email-header h1 {
margin: 0 0 10px 0;
font-size: %dpx;
font-weight: bold;
}
.email-meta div {
margin-bottom: 2px;
font-size: %dpx;
}
.email-body {
word-wrap: break-word;
}
.attachment-section {
margin-top: 15px;
padding: 10px;
background-color: %s;
border: 1px solid %s;
border-radius: 3px;
}
.attachment-section h3 {
margin: 0 0 8px 0;
font-size: %dpx;
}
.attachment-item {
padding: 5px 0;
}
.attachment-icon {
margin-right: 5px;
}
.attachment-details, .attachment-type {
font-size: %dpx;
color: #555555;
}
.attachment-inclusion-note, .attachment-info-note {
margin-top: 8px;
padding: 6px;
font-size: %dpx;
border-radius: 3px;
}
.attachment-inclusion-note {
background-color: #e6ffed;
border: 1px solid #d4f7dc;
color: #006420;
}
.attachment-info-note {
background-color: #fff9e6;
border: 1px solid #fff0c2;
color: #664d00;
}
.attachment-link-container {
display: flex;
align-items: center;
padding: 8px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
margin: 4px 0;
}
.attachment-link-container:hover {
background-color: #e9ecef;
}
.attachment-note {
font-size: %dpx;
color: #6c757d;
font-style: italic;
margin-left: 8px;
}
.no-content {
padding: 20px;
text-align: center;
color: #666;
font-style: italic;
}
.text-body {
white-space: pre-wrap;
}
img {
max-width: 100%%;
height: auto;
display: block;
:root {
--font-family: %s;
--font-size: %dpx;
--line-height: %s;
--text-color: %s;
--bg-color: %s;
--border-color: %s;
--header-font-size: %dpx;
--meta-font-size: %dpx;
--attachment-bg: %s;
--attachment-border: %s;
--attachment-header-size: %dpx;
--attachment-detail-size: %dpx;
--note-font-size: %dpx;
}
""",
DEFAULT_FONT_FAMILY,
@@ -386,29 +310,70 @@ public class EmlProcessingUtils {
DEFAULT_TEXT_COLOR,
DEFAULT_BACKGROUND_COLOR,
DEFAULT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 4,
DEFAULT_FONT_SIZE - 1,
DEFAULT_FONT_SIZE + 6,
DEFAULT_FONT_SIZE,
ATTACHMENT_BACKGROUND_COLOR,
ATTACHMENT_BORDER_COLOR,
DEFAULT_FONT_SIZE + 1,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 2,
DEFAULT_FONT_SIZE - 3);
DEFAULT_FONT_SIZE + 2,
DEFAULT_FONT_SIZE - 1,
DEFAULT_FONT_SIZE - 1));
html.append(css);
html.append(loadEmailStyles());
}
private static void appendAttachmentsSection(
StringBuilder html,
EmlParser.EmailContent content,
EmlToPdfRequest request,
CustomHtmlSanitizer customHtmlSanitizer) {
html.append("<div class=\"attachment-section\">\n");
@Synchronized
private String loadEmailStyles() {
if (cachedCssContent != null) {
return cachedCssContent;
}
try {
ClassPathResource resource = new ClassPathResource(CSS_RESOURCE_PATH);
try (InputStream inputStream = resource.getInputStream()) {
cachedCssContent = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
return cachedCssContent;
}
} catch (IOException e) {
log.warn("Failed to load email CSS from resource, using fallback: {}", e.getMessage());
cachedCssContent = getFallbackStyles(); // Cache fallback to avoid repeated attempts
return cachedCssContent;
}
}
private String getFallbackStyles() {
return """
/* Minimal fallback - main CSS resource failed to load */
body {
font-family: var(--font-family, Helvetica, sans-serif);
font-size: var(--font-size, 12px);
line-height: var(--line-height, 1.4);
color: var(--text-color, #202124);
margin: 0;
padding: 20px;
word-wrap: break-word;
}
.email-container { max-width: 100%; }
.email-header { border-bottom: 1px solid #ccc; margin-bottom: 16px; padding-bottom: 12px; }
.email-header h1 { margin: 0 0 8px 0; font-size: 18px; }
.email-meta { font-size: 12px; color: #666; }
.email-body { line-height: 1.6; }
.attachment-section { margin-top: 20px; padding: 12px; background: #f5f5f5; border-radius: 4px; }
.attachment-item { padding: 6px 0; border-bottom: 1px solid #ddd; }
.no-content { padding: 20px; text-align: center; color: #888; font-style: italic; }
img { max-width: 100%; height: auto; }
""";
}
private void appendAttachmentsSection(
StringBuilder html, EmlParser.EmailContent content, EmlToPdfRequest request) {
html.append(String.format(Locale.ROOT, "<div class=\"attachment-section\">%n"));
int displayedAttachmentCount =
content.getAttachmentCount() > 0
? content.getAttachmentCount()
: content.getAttachments().size();
html.append("<h3>Attachments (").append(displayedAttachmentCount).append(")</h3>\n");
html.append(
String.format(
Locale.ROOT, "<h3>Attachments (%d)</h3>%n", displayedAttachmentCount));
if (!content.getAttachments().isEmpty()) {
for (int i = 0; i < content.getAttachments().size(); i++) {
@@ -461,10 +426,10 @@ public class EmlProcessingUtils {
</div>
""");
}
html.append("</div>\n");
html.append(String.format(Locale.ROOT, "</div>%n"));
}
public static HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
public HTMLToPdfRequest createHtmlRequest(EmlToPdfRequest request) {
HTMLToPdfRequest htmlRequest = new HTMLToPdfRequest();
if (request != null) {
@@ -475,7 +440,7 @@ public class EmlProcessingUtils {
return htmlRequest;
}
public static String detectMimeType(String filename, String existingMimeType) {
public String detectMimeType(String filename, String existingMimeType) {
if (existingMimeType != null && !existingMimeType.isEmpty()) {
return existingMimeType;
}
@@ -492,7 +457,7 @@ public class EmlProcessingUtils {
return MediaType.IMAGE_PNG_VALUE; // Default MIME type
}
public static String decodeUrlEncoded(String encoded) {
public String decodeUrlEncoded(String encoded) {
try {
return java.net.URLDecoder.decode(encoded, StandardCharsets.UTF_8);
} catch (Exception e) {
@@ -500,7 +465,7 @@ public class EmlProcessingUtils {
}
}
public static String decodeMimeHeader(String encodedText) {
public String decodeMimeHeader(String encodedText) {
if (encodedText == null || encodedText.trim().isEmpty()) {
return encodedText;
}
@@ -566,7 +531,7 @@ public class EmlProcessingUtils {
}
}
private static String decodeQuotedPrintable(String encodedText, String charset) {
private String decodeQuotedPrintable(String encodedText, String charset) {
StringBuilder result = new StringBuilder();
for (int i = 0; i < encodedText.length(); i++) {
char c = encodedText.charAt(i);
@@ -609,7 +574,7 @@ public class EmlProcessingUtils {
}
}
public static String escapeHtml(String text) {
public String escapeHtml(String text) {
if (text == null) return "";
return text.replace("&", "&amp;")
.replace("<", "&lt;")
@@ -618,7 +583,7 @@ public class EmlProcessingUtils {
.replace("'", "&#39;");
}
public static String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
public String sanitizeText(String text, CustomHtmlSanitizer customHtmlSanitizer) {
if (customHtmlSanitizer != null) {
return customHtmlSanitizer.sanitize(text);
} else {
@@ -626,7 +591,7 @@ public class EmlProcessingUtils {
}
}
public static String simplifyHtmlContent(String htmlContent) {
public String simplifyHtmlContent(String htmlContent) {
String simplified =
RegexPatternUtils.getInstance()
.getScriptTagPattern()
@@ -41,7 +41,7 @@ import lombok.extern.slf4j.Slf4j;
* <pre>{@code
* // In service layer - create exception with ExceptionUtils
* try {
* PDDocument doc = PDDocument.load(file);
* PDDocument doc = Loader.loadPDF(file);
* } catch (IOException e) {
* throw ExceptionUtils.createPdfCorruptedException("during load", e);
* }
@@ -89,6 +89,11 @@ public class PDFToFile {
TempDirectory tempOutputDir = new TempDirectory(tempFileManager)) {
inputFile.transferTo(tempInputFile.getFile());
// Create unique subdirectory for pdftohtml output (collision-proof even if called
// multiple times)
Path pdftohtmlDir = Files.createTempDirectory(tempOutputDir.getPath(), "pdftohtml_");
String outputBasename = pdftohtmlDir.resolve("output").toString();
List<String> command =
new ArrayList<>(
Arrays.asList(
@@ -97,15 +102,13 @@ public class PDFToFile {
"-noframes",
"-c",
tempInputFile.getAbsolutePath(),
pdfBaseName));
outputBasename));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.PDFTOHTML)
.runCommandWithOutputHandling(
command, tempOutputDir.getPath().toFile());
.runCommandWithOutputHandling(command, pdftohtmlDir.toFile());
// Process HTML files to Markdown
File[] outputFiles =
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
File[] outputFiles = Objects.requireNonNull(pdftohtmlDir.toFile().listFiles());
List<File> markdownFiles = new ArrayList<>();
// Convert HTML files to Markdown
@@ -115,7 +118,7 @@ public class PDFToFile {
String markdown = htmlToMarkdownConverter.convert(html);
String mdFileName = outputFile.getName().replace(".html", ".md");
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
File mdFile = new File(pdftohtmlDir.toFile(), mdFileName);
Files.writeString(mdFile.toPath(), markdown);
markdownFiles.add(mdFile);
}
@@ -182,18 +185,22 @@ public class PDFToFile {
// Save the uploaded file to a temporary location
inputFile.transferTo(tempInputFile);
// Run the pdftohtml command with complex output
// Create unique subdirectory for pdftohtml output (collision-proof even if called
// multiple times)
Path pdftohtmlDir = Files.createTempDirectory(tempOutputDir, "pdftohtml_");
String outputBasename = pdftohtmlDir.resolve("output").toString();
List<String> command =
new ArrayList<>(
Arrays.asList(
"pdftohtml", "-c", tempInputFile.toString(), pdfBaseName));
"pdftohtml", "-c", tempInputFile.toString(), outputBasename));
ProcessExecutorResult returnCode =
ProcessExecutor.getInstance(ProcessExecutor.Processes.PDFTOHTML)
.runCommandWithOutputHandling(command, tempOutputDir.toFile());
.runCommandWithOutputHandling(command, pdftohtmlDir.toFile());
// Get output files
File[] outputFiles = Objects.requireNonNull(tempOutputDir.toFile().listFiles());
File[] outputFiles = Objects.requireNonNull(pdftohtmlDir.toFile().listFiles());
// Return output files in a ZIP archive
fileName = pdfBaseName + "ToHtml.zip";
@@ -254,9 +261,6 @@ public class PDFToFile {
Path tempInputFile = inputFileTemp.getPath();
Path tempOutputDir = outputDirTemp.getPath();
Path unoOutputFile =
tempOutputDir.resolve(
pdfBaseName + "." + resolvePrimaryExtension(outputFormat));
// Save the uploaded file to a temporary location
inputFile.transferTo(tempInputFile);
@@ -265,8 +269,15 @@ public class PDFToFile {
ProcessExecutorResult returnCode = null;
IOException unoconvertException = null;
Path unoOutputFile = null;
if (isUnoConvertEnabled()) {
try {
// Create output file only for unoconvert (it needs specific path)
unoOutputFile =
Files.createTempFile(
tempOutputDir,
"output_",
"." + resolvePrimaryExtension(outputFormat));
List<String> unoCommand =
buildUnoConvertCommand(
tempInputFile, unoOutputFile, outputFormat, libreOfficeFilter);
@@ -275,6 +286,14 @@ public class PDFToFile {
.runCommandWithOutputHandling(unoCommand);
} catch (IOException e) {
unoconvertException = e;
// Clean up temp file if unoconvert failed, so soffice doesn't see it
if (unoOutputFile != null && Files.exists(unoOutputFile)) {
try {
Files.delete(unoOutputFile);
} catch (IOException deleteException) {
log.debug("Failed to clean up temp file after unoconvert failure");
}
}
log.warn(
"Unoconvert command failed ({}). Falling back to soffice command.",
e.getMessage());
@@ -60,6 +60,7 @@ public class PdfToCbrUtils {
private static byte[] createCbrFromPdf(PDDocument document, int dpi) throws IOException {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
Path tempDir = Files.createTempDirectory("stirling-pdf-cbr-");
List<Path> generatedImages = new ArrayList<>();
@@ -55,6 +55,7 @@ public class PdfToCbzUtils {
private static byte[] createCbzFromPdf(PDDocument document, int dpi) throws IOException {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
try (ByteArrayOutputStream cbzOutputStream = new ByteArrayOutputStream();
ZipOutputStream zipOut = new ZipOutputStream(cbzOutputStream)) {
@@ -119,11 +119,11 @@ public class PdfUtils {
public boolean hasTextOnPage(PDPage page, String phrase) throws IOException {
PDFTextStripper textStripper = new PDFTextStripper();
PDDocument tempDoc = new PDDocument();
tempDoc.addPage(page);
String pageText = textStripper.getText(tempDoc);
tempDoc.close();
return pageText.contains(phrase);
try (PDDocument tempDoc = new PDDocument()) {
tempDoc.addPage(page);
String pageText = textStripper.getText(tempDoc);
return pageText.contains(phrase);
}
}
public byte[] convertFromPdf(
@@ -153,7 +153,8 @@ public class PdfUtils {
maxSafeDpi);
}
try (PDDocument document = pdfDocumentFactory.load(inputStream)) {
try (PDDocument document = pdfDocumentFactory.load(inputStream);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
if (!includeAnnotations) {
@@ -161,9 +162,6 @@ public class PdfUtils {
}
int pageCount = document.getNumberOfPages();
// Create a ByteArrayOutputStream to save the image(s) to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
if (singleImage) {
if ("tiff".equals(imageType.toLowerCase(Locale.ROOT))
|| "tif".equals(imageType.toLowerCase(Locale.ROOT))) {
@@ -400,55 +398,61 @@ public class PdfUtils {
*/
public PDDocument convertPdfToPdfImage(PDDocument document) throws IOException {
PDDocument imageDocument = new PDDocument();
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
for (int page = 0; page < document.getNumberOfPages(); ++page) {
final int pageIndex = page;
BufferedImage bim;
try {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
for (int page = 0; page < document.getNumberOfPages(); ++page) {
final int pageIndex = page;
BufferedImage bim;
// Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 300; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
final int dpi = renderDpi;
try {
bim =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, dpi, ImageType.RGB));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigFor300Dpi",
"PDF page {0} is too large to render at 300 DPI. The resulting image"
+ " would exceed Java's maximum array size. Please use a lower DPI"
+ " value for PDF-to-image conversion.",
pageIndex + 1);
// Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 300; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
}
throw e;
final int dpi = renderDpi;
try {
bim =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
dpi,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, dpi, ImageType.RGB));
} catch (IllegalArgumentException e) {
if (e.getMessage() != null
&& e.getMessage().contains("Maximum size of image exceeded")) {
throw ExceptionUtils.createIllegalArgumentException(
"error.pageTooBigFor300Dpi",
"PDF page {0} is too large to render at 300 DPI. The resulting image"
+ " would exceed Java's maximum array size. Please use a lower DPI"
+ " value for PDF-to-image conversion.",
pageIndex + 1);
}
throw e;
}
PDPage originalPage = document.getPage(page);
float width = originalPage.getMediaBox().getWidth();
float height = originalPage.getMediaBox().getHeight();
PDPage newPage = new PDPage(new PDRectangle(width, height));
imageDocument.addPage(newPage);
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
try (PDPageContentStream contentStream =
new PDPageContentStream(
imageDocument, newPage, AppendMode.APPEND, true, true)) {
contentStream.drawImage(pdImage, 0, 0, width, height);
}
bim.flush();
}
PDPage originalPage = document.getPage(page);
float width = originalPage.getMediaBox().getWidth();
float height = originalPage.getMediaBox().getHeight();
PDPage newPage = new PDPage(new PDRectangle(width, height));
imageDocument.addPage(newPage);
PDImageXObject pdImage = LosslessFactory.createFromImage(imageDocument, bim);
PDPageContentStream contentStream =
new PDPageContentStream(imageDocument, newPage, AppendMode.APPEND, true, true);
contentStream.drawImage(pdImage, 0, 0, width, height);
contentStream.close();
return imageDocument;
} catch (Exception e) {
throw e;
}
return imageDocument;
}
private BufferedImage prepareImageForPdfToImage(int maxWidth, int height, String imageType) {
@@ -69,7 +69,6 @@ public class WebResponseUtils {
// Open Byte Array and save document to it
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
return baosToWebResponse(baos, docName);
}
@@ -146,13 +146,18 @@ public class CustomColorReplaceStrategy extends ReplaceAndInvertColorStrategy {
// Save the modified PDF to a ByteArrayOutputStream
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
document.save(byteArrayOutputStream);
document.close();
// Prepare the modified PDF for download
ByteArrayInputStream inputStream =
new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
InputStreamResource resource = new InputStreamResource(inputStream);
return resource;
} finally {
try {
Files.deleteIfExists(file.toPath());
} catch (IOException e) {
log.warn("Failed to delete temporary file: {}", file.getAbsolutePath(), e);
}
}
}
@@ -7,6 +7,7 @@ import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.imageio.ImageIO;
@@ -19,11 +20,14 @@ import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.core.io.InputStreamResource;
import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.ExceptionUtils;
@Slf4j
public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
public InvertFullColorStrategy(MultipartFile file, ReplaceAndInvert replaceAndInvert) {
@@ -43,6 +47,8 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
try (PDDocument document = Loader.loadPDF(tempFile.getFile())) {
// Render each page and invert colors
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(
true); // Enable subsampling to reduce memory usage
for (int page = 0; page < document.getNumberOfPages(); page++) {
BufferedImage image;
@@ -73,12 +79,27 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
PDImageXObject pdImage =
PDImageXObject.createFromFileByContent(tempImageFile, document);
// Delete temp file immediately after loading into memory to prevent disk
// exhaustion
// The file content is now in the PDImageXObject, so the file is no longer
// needed
try {
Files.deleteIfExists(tempImageFile.toPath());
tempImageFile = null; // Mark as deleted to avoid double deletion
} catch (IOException e) {
log.warn(
"Failed to delete temporary image file: {}",
tempImageFile.getAbsolutePath(),
e);
}
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
pdPage,
PDPageContentStream.AppendMode.OVERWRITE,
true)) {
true,
true)) { // resetContext=true ensures clean graphics state
contentStream.drawImage(
pdImage,
0,
@@ -87,8 +108,16 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
pdPage.getMediaBox().getHeight());
}
} finally {
// Safety net: ensure temp file is deleted even if an exception occurred
if (tempImageFile != null && tempImageFile.exists()) {
Files.delete(tempImageFile.toPath());
try {
Files.deleteIfExists(tempImageFile.toPath());
} catch (IOException e) {
log.warn(
"Failed to delete temporary image file: {}",
tempImageFile.getAbsolutePath(),
e);
}
}
}
}
@@ -128,7 +157,10 @@ public class InvertFullColorStrategy extends ReplaceAndInvertColorStrategy {
// Helper method to convert BufferedImage to InputStream
private File convertToBufferedImageTpFile(BufferedImage image) throws IOException {
File file = File.createTempFile("image", ".png");
// Use Files.createTempFile instead of File.createTempFile for better security and modern
// Java practices
Path tempPath = Files.createTempFile("image", ".png");
File file = tempPath.toFile();
ImageIO.write(image, "png", file);
return file;
}
@@ -0,0 +1,233 @@
*, *::before, *::after {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
body {
font-family: var(--font-family, 'Helvetica, sans-serif');
font-size: var(--font-size, 12px);
line-height: var(--line-height, 1.4);
color: var(--text-color, #202124);
margin: 0;
padding: 20px 24px;
background-color: var(--bg-color, #ffffff);
word-wrap: break-word;
overflow-wrap: break-word;
hyphens: auto;
}
.email-container {
width: 100%;
max-width: 100%;
margin: 0 auto;
}
.email-header {
padding-bottom: 16px;
border-bottom: 2px solid var(--border-color, #e8eaed);
margin-bottom: 20px;
}
.email-header h1 {
margin: 0 0 12px 0;
font-size: var(--header-font-size, 18px);
font-weight: 600;
color: #1a1a1a;
line-height: 1.3;
word-break: break-word;
}
.email-meta {
font-size: var(--meta-font-size, 12px);
color: #5f6368;
}
.email-meta div {
margin-bottom: 4px;
line-height: 1.5;
}
.email-meta strong {
color: #3c4043;
font-weight: 600;
min-width: 50px;
display: inline-block;
}
.email-body {
word-wrap: break-word;
overflow-wrap: break-word;
line-height: 1.6;
}
.email-body p {
margin: 0 0 1em 0;
}
.email-body a {
color: #1a73e8;
text-decoration: underline;
}
.email-body table {
border-collapse: collapse;
width: auto;
max-width: 100%;
margin: 8px 0;
}
.email-body td,
.email-body th {
padding: 8px 12px;
vertical-align: top;
border: 1px solid #e0e0e0;
}
.email-body ul,
.email-body ol {
margin: 0.5em 0;
padding-left: 2em;
}
.email-body li {
margin-bottom: 0.25em;
}
.email-body blockquote {
margin: 1em 0;
padding: 0 0 0 16px;
border-left: 3px solid #dadce0;
color: #5f6368;
}
.email-body pre,
.email-body code {
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 0.9em;
background-color: #f8f9fa;
border-radius: 3px;
}
.email-body pre {
padding: 12px;
overflow-x: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.email-body code {
padding: 2px 6px;
}
.email-body hr {
border: none;
border-top: 1px solid #e0e0e0;
margin: 1.5em 0;
}
.attachment-section {
margin-top: 24px;
padding: 16px;
background-color: var(--attachment-bg, #f9f9f9);
border: 1px solid var(--attachment-border, #eeeeee);
border-radius: 6px;
page-break-inside: avoid;
}
.attachment-section h3 {
margin: 0 0 12px 0;
font-size: var(--attachment-header-size, 14px);
font-weight: 600;
color: #3c4043;
}
.attachment-item {
padding: 8px 0;
border-bottom: 1px solid #eeeeee;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.attachment-item:last-child {
border-bottom: none;
}
.attachment-icon {
margin-right: 8px;
font-weight: bold;
color: #5f6368;
}
.attachment-name {
font-weight: 500;
color: #1a1a1a;
word-break: break-all;
}
.attachment-details,
.attachment-type {
font-size: var(--attachment-detail-size, 11px);
color: #5f6368;
margin-left: 8px;
}
.attachment-info-note {
margin-top: 12px;
padding: 10px 12px;
font-size: var(--note-font-size, 11px);
border-radius: 4px;
background-color: #e8f0fe;
border: 1px solid #d2e3fc;
color: #1967d2;
}
.attachment-info-note p {
margin: 0;
}
.no-content {
padding: 32px 20px;
text-align: center;
color: #80868b;
font-style: italic;
background-color: #f8f9fa;
border-radius: 6px;
}
.text-body {
white-space: pre-wrap;
word-wrap: break-word;
font-family: inherit;
line-height: 1.6;
}
img {
max-width: 100%;
height: auto;
display: block;
margin: 8px 0;
}
@media print {
body {
padding: 0;
font-size: 11pt;
}
.email-header {
page-break-after: avoid;
}
.attachment-section {
page-break-inside: avoid;
}
a {
text-decoration: none;
color: inherit;
}
}
.email-body div[class*="signature"],
.email-body table[class*="signature"] {
margin-top: 1.5em;
padding-top: 1em;
border-top: 1px solid #e0e0e0;
font-size: 0.95em;
color: #5f6368;
}
@@ -439,9 +439,7 @@ class EmlToPdfTest {
"binary data");
testEmailConversion(
emlContent,
new String[] {"Attachment Only Test", "data.bin", "No content available"},
true);
emlContent, new String[] {"Attachment Only Test", "data.bin"}, true);
}
@Test
@@ -469,10 +467,13 @@ class EmlToPdfTest {
}
@Test
@DisplayName("Should handle non-standard but valid character sets like ISO-8859-1")
@DisplayName("Should accept ISO-8859-1 charset declaration without errors")
void handleIso88591Charset() throws IOException {
String subject = "Subject with special characters: ñ é ü";
String body = "Body with special characters: ñ é ü";
// Note: Uses ASCII content to test charset header parsing without
// platform-dependent encoding issues. Actual charset decoding is
// handled by Simple Java Mail library which is thoroughly tested upstream.
String subject = "Subject with ISO-8859-1 charset";
String body = "Body content encoded in ISO-8859-1";
String emlContent =
createSimpleTextEmailWithCharset(
@@ -488,8 +489,13 @@ class EmlToPdfTest {
String htmlResult = EmlToPdf.convertEmlToHtml(emlBytes, request);
assertNotNull(htmlResult);
assertTrue(htmlResult.contains(subject));
assertTrue(htmlResult.contains(body));
// Verify the core subject text is present (charset should be decoded properly)
assertTrue(
htmlResult.contains("Subject with ISO-8859-1 charset"),
"HTML should contain subject text");
assertTrue(
htmlResult.contains("Body content encoded in ISO-8859-1"),
"HTML should contain body text");
}
@Test
@@ -92,8 +92,7 @@ public class WebResponseUtilsTest {
@Test
public void testPdfDocToWebResponse() {
try {
PDDocument document = new PDDocument();
try (PDDocument document = new PDDocument()) {
document.addPage(new org.apache.pdfbox.pdmodel.PDPage());
String docName = "sample.pdf";
+10 -1
View File
@@ -59,10 +59,11 @@ dependencies {
implementation project(':common')
implementation 'org.springframework.boot:spring-boot-starter-jetty'
implementation 'com.posthog.java:posthog:1.2.0'
implementation 'org.telegram:telegrambots:6.9.7.1'
implementation 'commons-io:commons-io:2.21.0'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation 'io.micrometer:micrometer-core:1.16.0'
implementation 'io.micrometer:micrometer-core:1.16.1'
implementation 'com.google.zxing:core:3.5.4'
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
@@ -207,6 +208,14 @@ tasks.register('npmInstall', Exec) {
println "node_modules not found, will install..."
return true
}
// if required devDependency is missing, reinstall
def iconifyPkg = new File(frontendDir, 'node_modules/@iconify-json/material-symbols/package.json')
if (!iconifyPkg.exists()) {
println "@iconify-json/material-symbols missing, will reinstall..."
return true
}
def packageJson = new File(frontendDir, 'package.json')
def packageLock = new File(frontendDir, 'package-lock.json')
def isOutdated = nodeModules.lastModified() < packageJson.lastModified() ||
@@ -141,10 +141,10 @@ public class SPDFApplication {
String backendUrl = appConfig.getBackendUrl();
String contextPath = appConfig.getContextPath();
String serverPort = appConfig.getServerPort();
baseUrlStatic = backendUrl;
baseUrlStatic = normalizeBackendUrl(backendUrl, serverPort);
contextPathStatic = contextPath;
serverPortStatic = serverPort;
String url = backendUrl + ":" + getStaticPort() + contextPath;
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
// Log Tauri mode information
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_TAURI_MODE", "false"))) {
@@ -210,7 +210,7 @@ public class SPDFApplication {
private static void printStartupLogs() {
log.info("Stirling-PDF Started.");
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
String url = buildFullUrl(baseUrlStatic, getStaticPort(), contextPathStatic);
log.info("Navigate to {}", url);
}
@@ -258,4 +258,79 @@ public class SPDFApplication {
public static String getStaticContextPath() {
return contextPathStatic;
}
private static String buildFullUrl(String backendUrl, String port, String contextPath) {
String normalizedBase = normalizeBackendUrl(backendUrl, port);
String normalizedContextPath =
(contextPath == null || contextPath.isBlank() || "/".equals(contextPath))
? "/"
: (contextPath.startsWith("/") ? contextPath : "/" + contextPath);
return normalizedBase + normalizedContextPath;
}
private static String normalizeBackendUrl(String backendUrl, String port) {
String trimmedBase =
(backendUrl == null || backendUrl.isBlank())
? "http://localhost"
: backendUrl.trim().replaceAll("/+$", "");
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
Integer parsedPort = parsePort(port);
try {
java.net.URI uri = new java.net.URI(baseForParsing);
String scheme = uri.getScheme() == null ? "http" : uri.getScheme();
String host = uri.getHost();
if (host == null) {
return appendPortFallback(trimmedBase, parsedPort);
}
boolean defaultHttp =
"http".equalsIgnoreCase(scheme) && Integer.valueOf(80).equals(parsedPort);
boolean defaultHttps =
"https".equalsIgnoreCase(scheme) && Integer.valueOf(443).equals(parsedPort);
int effectivePort = uri.getPort();
if (effectivePort == -1 && parsedPort != null && !defaultHttp && !defaultHttps) {
effectivePort = parsedPort;
}
java.net.URI rebuilt =
new java.net.URI(
scheme,
uri.getUserInfo(),
host,
effectivePort,
uri.getPath(),
uri.getQuery(),
uri.getFragment());
return rebuilt.toString();
} catch (java.net.URISyntaxException e) {
return appendPortFallback(trimmedBase, parsedPort);
}
}
private static Integer parsePort(String port) {
if (port == null || port.isBlank()) {
return null;
}
try {
int parsed = Integer.parseInt(port);
return parsed > 0 ? parsed : null;
} catch (NumberFormatException e) {
return null;
}
}
private static String appendPortFallback(String trimmedBase, Integer port) {
if (port == null) {
return trimmedBase;
}
if (trimmedBase.matches(".+:\\d+$")) {
return trimmedBase;
}
return trimmedBase + ":" + port;
}
}
@@ -0,0 +1,18 @@
package stirling.software.SPDF.config;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.telegram.telegrambots.meta.TelegramBotsApi;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import org.telegram.telegrambots.updatesreceivers.DefaultBotSession;
@Configuration
@ConditionalOnProperty(prefix = "telegram", name = "enabled", havingValue = "true")
public class TelegramBotConfig {
@Bean
public TelegramBotsApi telegramBotsApi() throws TelegramApiException {
return new TelegramBotsApi(DefaultBotSession.class);
}
}
@@ -29,6 +29,7 @@ import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@@ -68,33 +69,33 @@ public class BookletImpositionController {
"Booklet printing uses 2 pages per side (landscape). For 4-up, use the N-up feature.");
}
PDDocument sourceDocument = pdfDocumentFactory.load(file);
int totalPages = sourceDocument.getNumberOfPages();
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
int totalPages = sourceDocument.getNumberOfPages();
// Create proper booklet with signature-based page ordering
PDDocument newDocument =
createSaddleBooklet(
sourceDocument,
totalPages,
addBorder,
spineLocation,
addGutter,
gutterSize,
doubleSided,
duplexPass,
flipOnShortEdge);
// Create proper booklet with signature-based page ordering
try (PDDocument newDocument =
createSaddleBooklet(
sourceDocument,
totalPages,
addBorder,
spineLocation,
addGutter,
gutterSize,
doubleSided,
duplexPass,
flipOnShortEdge)) {
sourceDocument.close();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
newDocument.close();
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
+ "_booklet.pdf");
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(
Filenames.toSimpleFileName(file.getOriginalFilename()),
"_booklet.pdf"));
}
}
}
private static int padToMultipleOf4(int n) {
@@ -155,6 +155,7 @@ public class CropController {
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
PDFRenderer renderer = new PDFRenderer(sourceDocument);
renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
LayerUtility layerUtility = new LayerUtility(newDocument);
for (int i = 0; i < sourceDocument.getNumberOfPages(); i++) {
@@ -3,6 +3,7 @@ package stirling.software.SPDF.controller.api;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
@@ -27,7 +28,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import io.swagger.v3.oas.annotations.Operation;
@@ -277,7 +277,7 @@ public class MergeController {
"This endpoint merges multiple PDF files into a single PDF file. The merged"
+ " file will contain all pages from the input files in the order they were"
+ " provided. Input:PDF Output:PDF Type:MISO")
public ResponseEntity<StreamingResponseBody> mergePdfs(
public ResponseEntity<byte[]> mergePdfs(
@ModelAttribute MergePdfsRequest request,
@RequestParam(value = "fileOrder", required = false) String fileOrder)
throws IOException {
@@ -304,8 +304,6 @@ public class MergeController {
request.getSortType())); // Sort files based on requested sort type
}
ResponseEntity<StreamingResponseBody> response;
try (TempFile mt = new TempFile(tempFileManager, ".pdf")) {
PDFMergerUtility mergerUtility = new PDFMergerUtility();
@@ -399,7 +397,7 @@ public class MergeController {
String mergedFileName =
GeneralUtils.generateFilename(firstFilename, "_merged_unsigned.pdf");
response = WebResponseUtils.pdfFileToWebResponse(outputTempFile, mergedFileName);
return response;
byte[] pdfBytes = Files.readAllBytes(outputTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(pdfBytes, mergedFileName);
}
}
@@ -70,108 +70,109 @@ public class MultiPageLayoutController {
: (int) Math.sqrt(pagesPerSheet);
int rows = pagesPerSheet == 2 || pagesPerSheet == 3 ? 1 : (int) Math.sqrt(pagesPerSheet);
PDDocument sourceDocument = pdfDocumentFactory.load(file);
PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
PDPage newPage = new PDPage(PDRectangle.A4);
newDocument.addPage(newPage);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument)) {
int totalPages = sourceDocument.getNumberOfPages();
LayerUtility layerUtility = new LayerUtility(newDocument);
int totalPages = sourceDocument.getNumberOfPages();
float cellWidth = newPage.getMediaBox().getWidth() / cols;
float cellHeight = newPage.getMediaBox().getHeight() / rows;
// Calculate cell dimensions once (all output pages are A4) - declare outside try
// blocks
float cellWidth = PDRectangle.A4.getWidth() / cols;
float cellHeight = PDRectangle.A4.getHeight() / rows;
PDPageContentStream contentStream =
new PDPageContentStream(
newDocument, newPage, PDPageContentStream.AppendMode.APPEND, true, true);
LayerUtility layerUtility = new LayerUtility(newDocument);
// Process pages in groups of pagesPerSheet, creating a new page and content stream
// for each group
for (int i = 0; i < totalPages; i += pagesPerSheet) {
// Create a new output page for each group of pagesPerSheet
PDPage newPage = new PDPage(PDRectangle.A4);
newDocument.addPage(newPage);
float borderThickness = 1.5f; // Specify border thickness as required
contentStream.setLineWidth(borderThickness);
contentStream.setStrokingColor(Color.BLACK);
// Use try-with-resources for each content stream to ensure proper cleanup
// resetContext=true: Start with a clean graphics state for new content
try (PDPageContentStream contentStream =
new PDPageContentStream(
newDocument,
newPage,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
float borderThickness = 1.5f; // Specify border thickness as required
contentStream.setLineWidth(borderThickness);
contentStream.setStrokingColor(Color.BLACK);
for (int i = 0; i < totalPages; i++) {
if (i != 0 && i % pagesPerSheet == 0) {
// Close the current content stream and create a new page and content stream
contentStream.close();
newPage = new PDPage(PDRectangle.A4);
newDocument.addPage(newPage);
contentStream =
new PDPageContentStream(
// Process all pages in this group
for (int j = 0; j < pagesPerSheet && (i + j) < totalPages; j++) {
int pageIndex = i + j;
PDPage sourcePage = sourceDocument.getPage(pageIndex);
PDRectangle rect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / rect.getWidth();
float scaleHeight = cellHeight / rect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
int adjustedPageIndex = j % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
float x =
colIndex * cellWidth
+ (cellWidth - rect.getWidth() * scale) / 2;
float y =
newPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - rect.getHeight() * scale) / 2);
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getScaleInstance(scale, scale));
PDFormXObject formXObject =
layerUtility.importPageAsForm(sourceDocument, pageIndex);
contentStream.drawForm(formXObject);
contentStream.restoreGraphicsState();
if (addBorder) {
// Draw border around each page
float borderX = colIndex * cellWidth;
float borderY =
newPage.getMediaBox().getHeight()
- (rowIndex + 1) * cellHeight;
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
contentStream.stroke();
}
}
} // contentStream is automatically closed here
}
// If any source page is rotated, skip form copying/transformation entirely
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
if (hasRotation) {
log.info("Source document has rotated pages; skipping form field copying.");
} else {
try {
GeneralFormCopyUtils.copyAndTransformFormFields(
sourceDocument,
newDocument,
newPage,
PDPageContentStream.AppendMode.APPEND,
true,
true);
}
totalPages,
pagesPerSheet,
cols,
rows,
cellWidth,
cellHeight);
} catch (Exception e) {
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
}
}
PDPage sourcePage = sourceDocument.getPage(i);
PDRectangle rect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / rect.getWidth();
float scaleHeight = cellHeight / rect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
int adjustedPageIndex =
i % pagesPerSheet; // Close the current content stream and create a new
// page and content stream
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
float x = colIndex * cellWidth + (cellWidth - rect.getWidth() * scale) / 2;
float y =
newPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - rect.getHeight() * scale) / 2);
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getScaleInstance(scale, scale));
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDocument, i);
contentStream.drawForm(formXObject);
contentStream.restoreGraphicsState();
if (addBorder) {
// Draw border around each page
float borderX = colIndex * cellWidth;
float borderY = newPage.getMediaBox().getHeight() - (rowIndex + 1) * cellHeight;
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
contentStream.stroke();
}
}
contentStream.close();
// If any source page is rotated, skip form copying/transformation entirely
boolean hasRotation = GeneralFormCopyUtils.hasAnyRotatedPage(sourceDocument);
if (hasRotation) {
log.info("Source document has rotated pages; skipping form field copying.");
} else {
try {
GeneralFormCopyUtils.copyAndTransformFormFields(
sourceDocument,
newDocument,
totalPages,
pagesPerSheet,
cols,
rows,
cellWidth,
cellHeight);
} catch (Exception e) {
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
}
}
sourceDocument.close();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
newDocument.close();
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(
file.getOriginalFilename(), "_multi_page_layout.pdf"));
ByteArrayOutputStream baos = new ByteArrayOutputStream();
newDocument.save(baos);
byte[] result = baos.toByteArray();
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(
file.getOriginalFilename(), "_multi_page_layout.pdf"));
} // newDocument is closed here
} // sourceDocument is closed here
}
}
@@ -53,25 +53,28 @@ public class PdfImageRemovalController {
"This endpoint remove images from file to reduce the file size.Input:PDF"
+ " Output:PDF Type:SISO")
public ResponseEntity<byte[]> removeImages(@ModelAttribute PDFFile file) throws IOException {
// Load the PDF document
PDDocument document = pdfDocumentFactory.load(file);
// Load the PDF document with proper resource management
try (PDDocument document = pdfDocumentFactory.load(file)) {
// Remove images from the PDF document using the service
PDDocument modifiedDocument = pdfImageRemovalService.removeImagesFromPdf(document);
// Remove images from the PDF document using the service
try (PDDocument modifiedDocument =
pdfImageRemovalService.removeImagesFromPdf(document)) {
// Create a ByteArrayOutputStream to hold the modified PDF data
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// Create a ByteArrayOutputStream to hold the modified PDF data
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
// Save the modified PDF document to the output stream
modifiedDocument.save(outputStream);
modifiedDocument.close();
// Save the modified PDF document to the output stream
modifiedDocument.save(outputStream);
// Generate a new filename for the modified PDF
String mergedFileName =
GeneralUtils.generateFilename(
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
// Generate a new filename for the modified PDF
String mergedFileName =
GeneralUtils.generateFilename(
file.getFileInput().getOriginalFilename(), "_images_removed.pdf");
// Convert the byte array to a web response and return it
return WebResponseUtils.bytesToWebResponse(outputStream.toByteArray(), mergedFileName);
// Convert the byte array to a web response and return it
return WebResponseUtils.bytesToWebResponse(
outputStream.toByteArray(), mergedFileName);
}
}
}
}
@@ -50,23 +50,25 @@ public class RearrangePagesPDFController {
MultipartFile pdfFile = request.getFileInput();
String pagesToDelete = request.getPageNumbers();
PDDocument document = pdfDocumentFactory.load(pdfFile);
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
// Split the page order string into an array of page numbers or range of numbers
String[] pageOrderArr = pagesToDelete.split(",");
// Split the page order string into an array of page numbers or range of numbers
String[] pageOrderArr = pagesToDelete.split(",");
List<Integer> pagesToRemove =
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages(), false);
List<Integer> pagesToRemove =
GeneralUtils.parsePageList(pageOrderArr, document.getNumberOfPages(), false);
Collections.sort(pagesToRemove);
Collections.sort(pagesToRemove);
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
int pageIndex = pagesToRemove.get(i);
document.removePage(pageIndex);
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
int pageIndex = pagesToRemove.get(i);
document.removePage(pageIndex);
}
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
pdfFile.getOriginalFilename(), "_removed_pages.pdf"));
}
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_removed_pages.pdf"));
}
private List<Integer> removeFirst(int totalPages) {
@@ -243,41 +245,43 @@ public class RearrangePagesPDFController {
String pageOrder = request.getPageNumbers();
String sortType = request.getCustomMode();
try {
// Load the input PDF
PDDocument document = pdfDocumentFactory.load(pdfFile);
// Load the input PDF with proper resource management
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
// Split the page order string into an array of page numbers or range of numbers
String[] pageOrderArr = pageOrder != null ? pageOrder.split(",") : new String[0];
int totalPages = document.getNumberOfPages();
List<Integer> newPageOrder;
if (sortType != null
&& !sortType.isEmpty()
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
} else {
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
// Split the page order string into an array of page numbers or range of numbers
String[] pageOrderArr = pageOrder != null ? pageOrder.split(",") : new String[0];
int totalPages = document.getNumberOfPages();
List<Integer> newPageOrder;
if (sortType != null
&& !sortType.isEmpty()
&& !"custom".equals(sortType.toLowerCase(Locale.ROOT))) {
newPageOrder = processSortTypes(sortType, totalPages, pageOrder);
} else {
newPageOrder = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
}
log.info("newPageOrder = {}", newPageOrder);
log.info("totalPages = {}", totalPages);
// Create a new list to hold the pages in the new order
List<PDPage> newPages = new ArrayList<>();
for (int i = 0; i < newPageOrder.size(); i++) {
newPages.add(document.getPage(newPageOrder.get(i)));
}
// Create a new document based on the original one
try (PDDocument rearrangedDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
// Add the pages in the new order
for (PDPage page : newPages) {
rearrangedDocument.addPage(page);
}
return WebResponseUtils.pdfDocToWebResponse(
rearrangedDocument,
GeneralUtils.generateFilename(
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
}
}
log.info("newPageOrder = {}", newPageOrder);
log.info("totalPages = {}", totalPages);
// Create a new list to hold the pages in the new order
List<PDPage> newPages = new ArrayList<>();
for (int i = 0; i < newPageOrder.size(); i++) {
newPages.add(document.getPage(newPageOrder.get(i)));
}
// Create a new document based on the original one
PDDocument rearrangedDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
// Add the pages in the new order
for (PDPage page : newPages) {
rearrangedDocument.addPage(page);
}
return WebResponseUtils.pdfDocToWebResponse(
rearrangedDocument,
GeneralUtils.generateFilename(
pdfFile.getOriginalFilename(), "_rearranged.pdf"));
} catch (IOException e) {
ExceptionUtils.logException("document rearrangement", e);
throw e;
@@ -47,19 +47,20 @@ public class RotationController {
"error.angleNotMultipleOf90", "Angle must be a multiple of 90");
}
// Load the PDF document
PDDocument document = pdfDocumentFactory.load(request);
// Load the PDF document with proper resource management
try (PDDocument document = pdfDocumentFactory.load(request)) {
// Get the list of pages in the document
PDPageTree pages = document.getPages();
// Get the list of pages in the document
PDPageTree pages = document.getPages();
for (PDPage page : pages) {
page.setRotation(page.getRotation() + angle);
for (PDPage page : pages) {
page.setRotation(page.getRotation() + angle);
}
// Return the rotated PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_rotated.pdf"));
}
// Return the rotated PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_rotated.pdf"));
}
}
@@ -32,16 +32,19 @@ public class SettingsController {
@AutoJobPostMapping("/update-enable-analytics")
@Hidden
public ResponseEntity<String> updateApiKey(@RequestParam Boolean enabled) throws IOException {
public ResponseEntity<Map<String, Object>> updateApiKey(@RequestParam Boolean enabled)
throws IOException {
if (applicationProperties.getSystem().getEnableAnalytics() != null) {
return ResponseEntity.status(HttpStatus.ALREADY_REPORTED)
.body(
"Setting has already been set, To adjust please edit "
+ InstallationPathConfig.getSettingsPath());
Map.of(
"message",
"Setting has already been set, To adjust please edit "
+ InstallationPathConfig.getSettingsPath()));
}
GeneralUtils.saveKeyToSettings("system.enableAnalytics", enabled);
applicationProperties.getSystem().setEnableAnalytics(enabled);
return ResponseEntity.ok("Updated");
return ResponseEntity.ok(Map.of("message", "Updated"));
}
@GetMapping("/get-endpoints-status")
@@ -4,6 +4,7 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.*;
import java.util.stream.IntStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -18,18 +19,18 @@ import org.apache.pdfbox.util.Matrix;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.MultiFileResponse;
import stirling.software.SPDF.model.SplitTypes;
import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -37,16 +38,18 @@ import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@GeneralApi
@Slf4j
@RequiredArgsConstructor
public class SplitPdfBySectionsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@PostMapping(value = "/split-pdf-by-sections", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/split-pdf-by-sections")
@MultiFileResponse
@Operation(
summary = "Split PDF pages into smaller sections",
description =
@@ -54,7 +57,7 @@ public class SplitPdfBySectionsController {
+ " which page to split, and how to split"
+ " ( halves, thirds, quarters, etc.), both vertically and horizontally."
+ " Input:PDF Output:ZIP-PDF Type:SISO")
public ResponseEntity<?> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
public ResponseEntity<byte[]> splitPdf(@ModelAttribute SplitPdfBySectionsRequest request)
throws Exception {
MultipartFile file = request.getFileInput();
String pageNumbers = request.getPageNumbers();
@@ -63,10 +66,9 @@ public class SplitPdfBySectionsController {
.map(SplitTypes::valueOf)
.orElse(SplitTypes.SPLIT_ALL);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file, true)) {
int totalPages = sourceDocument.getNumberOfPages();
Set<Integer> customPagesToSplit =
getCustomPagesToSplit(pageNumbers, splitMode, totalPages);
try (PDDocument sourceDocument = pdfDocumentFactory.load(file)) {
Set<Integer> pagesToSplit =
getPagesToSplit(pageNumbers, splitMode, sourceDocument.getNumberOfPages());
// Process the PDF based on split parameters
int horiz = request.getHorizontalDivisions() + 1;
@@ -80,8 +82,10 @@ public class SplitPdfBySectionsController {
sourceDocument);
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
LayerUtility layerUtility = new LayerUtility(mergedDoc);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
if (pagesToSplit.contains(pageIndex)) {
addSplitPageToTarget(
sourceDocument,
pageIndex,
@@ -100,9 +104,11 @@ public class SplitPdfBySectionsController {
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
int pageNum = pageIndex + 1;
if (shouldSplitPage(pageIndex, totalPages, splitMode, customPagesToSplit)) {
if (pagesToSplit.contains(pageIndex)) {
for (int i = 0; i < horiz; i++) {
for (int j = 0; j < verti; j++) {
try (PDDocument subDoc =
@@ -126,6 +132,13 @@ public class SplitPdfBySectionsController {
+ sectionNum
+ ".pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error(
"Error creating section {} for page {}",
(i * verti + j + 1),
pageNum,
e);
throw e;
}
}
}
@@ -135,12 +148,23 @@ public class SplitPdfBySectionsController {
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;
}
return WebResponseUtils.zipFileToWebResponse(zipTempFile, filename + ".zip");
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);
throw e;
}
}
@@ -155,6 +179,9 @@ public class SplitPdfBySectionsController {
try (PDPageContentStream contentStream =
new PDPageContentStream(targetDoc, newPage, AppendMode.APPEND, true, true)) {
contentStream.drawForm(form);
} catch (IOException e) {
log.error("Error adding page {} to target document", pageIndex, e);
throw e;
}
}
@@ -192,6 +219,10 @@ public class SplitPdfBySectionsController {
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
} catch (IOException e) {
log.error(
"Error adding split section ({}, {}) for page {}", i, j, pageIndex, e);
throw e;
}
}
}
@@ -228,6 +259,14 @@ public class SplitPdfBySectionsController {
contentStream.transform(new Matrix(1, 0, 0, 1, translateX, translateY));
contentStream.drawForm(form);
contentStream.restoreGraphicsState();
} catch (IOException e) {
log.error(
"Error adding single section ({}, {}) for page {} to target",
horizIndex,
vertIndex,
pageIndex,
e);
throw e;
}
}
@@ -240,37 +279,45 @@ public class SplitPdfBySectionsController {
}
// Based on the mode, get the pages that need to be split and return the pages set
private Set<Integer> getCustomPagesToSplit(
String pageNumbers, SplitTypes splitMode, int totalPages) {
if (splitMode != SplitTypes.CUSTOM) {
return Collections.emptySet();
}
if (pageNumbers == null || pageNumbers.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.argumentRequired",
"{0} is required for {1} mode",
"page numbers",
"custom");
}
String[] pageOrderArr = pageNumbers.split(",");
List<Integer> pageListToSplit = GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
return new HashSet<>(pageListToSplit);
}
private Set<Integer> getPagesToSplit(String pageNumbers, SplitTypes splitMode, int totalPages) {
Set<Integer> pagesToSplit = new HashSet<>();
private boolean shouldSplitPage(
int pageIndex, int totalPages, SplitTypes splitMode, Set<Integer> customPagesToSplit) {
return switch (splitMode) {
case CUSTOM -> customPagesToSplit.contains(pageIndex);
case SPLIT_ALL -> true;
case SPLIT_ALL_EXCEPT_FIRST -> pageIndex > 0;
case SPLIT_ALL_EXCEPT_LAST -> pageIndex < totalPages - 1;
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST -> pageIndex > 0 && pageIndex < totalPages - 1;
default ->
switch (splitMode) {
case CUSTOM:
if (pageNumbers == null || pageNumbers.isBlank()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"split mode",
splitMode);
};
"error.argumentRequired",
"{0} is required for {1} mode",
"page numbers",
"custom");
}
String[] pageOrderArr = pageNumbers.split(",");
List<Integer> pageListToSplit =
GeneralUtils.parsePageList(pageOrderArr, totalPages, false);
pagesToSplit.addAll(pageListToSplit);
break;
case SPLIT_ALL:
pagesToSplit.addAll(IntStream.range(0, totalPages).boxed().toList());
break;
case SPLIT_ALL_EXCEPT_FIRST:
pagesToSplit.addAll(IntStream.range(1, totalPages).boxed().toList());
break;
case SPLIT_ALL_EXCEPT_LAST:
pagesToSplit.addAll(IntStream.range(0, totalPages - 1).boxed().toList());
break;
case SPLIT_ALL_EXCEPT_FIRST_AND_LAST:
pagesToSplit.addAll(IntStream.range(1, totalPages - 1).boxed().toList());
break;
default:
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "split mode", splitMode);
}
return pagesToSplit;
}
}
@@ -473,34 +473,36 @@ public class SplitPdfBySizeController {
PDDocument document, ZipOutputStream zipOut, String baseFilename, int index)
throws IOException {
log.debug("Starting saveDocumentToZip for document part {}", index);
ByteArrayOutputStream outStream = new ByteArrayOutputStream();
try (ByteArrayOutputStream outStream = new ByteArrayOutputStream()) {
try (PDDocument doc = document) {
log.debug("Saving document part {} to byte array", index);
doc.save(outStream);
log.debug("Successfully saved document part {} ({} bytes)", index, outStream.size());
} catch (Exception e) {
log.error("Error saving document part {} to byte array", index, e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
try (PDDocument doc = document) {
log.debug("Saving document part {} to byte array", index);
doc.save(outStream);
log.debug(
"Successfully saved document part {} ({} bytes)", index, outStream.size());
} catch (Exception e) {
log.error("Error saving document part {} to byte array", index, e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
try {
// Create a new zip entry
String entryName = baseFilename + "_" + index + ".pdf";
log.debug("Creating ZIP entry: {}", entryName);
ZipEntry zipEntry = new ZipEntry(entryName);
zipOut.putNextEntry(zipEntry);
try {
// Create a new zip entry
String entryName = baseFilename + "_" + index + ".pdf";
log.debug("Creating ZIP entry: {}", entryName);
ZipEntry zipEntry = new ZipEntry(entryName);
zipOut.putNextEntry(zipEntry);
byte[] bytes = outStream.toByteArray();
log.debug("Writing {} bytes to ZIP entry", bytes.length);
zipOut.write(bytes);
byte[] bytes = outStream.toByteArray();
log.debug("Writing {} bytes to ZIP entry", bytes.length);
zipOut.write(bytes);
log.debug("Closing ZIP entry");
zipOut.closeEntry();
log.debug("Successfully added document part {} to ZIP", index);
} catch (Exception e) {
log.error("Error adding document part {} to ZIP", index, e);
throw ExceptionUtils.createFileProcessingException("split", e);
log.debug("Closing ZIP entry");
zipOut.closeEntry();
log.debug("Successfully added document part {} to ZIP", index);
} catch (Exception e) {
log.error("Error adding document part {} to ZIP", index, e);
throw ExceptionUtils.createFileProcessingException("split", e);
}
}
}
}
@@ -191,7 +191,7 @@ public class UIDataController {
}
private List<String> getAvailableTesseractLanguages() {
String tessdataDir = applicationProperties.getSystem().getTessdataDir();
String tessdataDir = runtimePathConfig.getTessDataPath();
java.io.File[] files = new java.io.File(tessdataDir).listFiles();
if (files == null) {
return Collections.emptyList();
@@ -10,6 +10,7 @@ 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.util.HtmlUtils;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
@@ -41,12 +42,12 @@ public class ConvertEmlToPDF {
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/eml/pdf")
@StandardPdfResponse
@Operation(
summary = "Convert EML to PDF",
summary = "Convert EML/MSG to PDF",
description =
"This endpoint converts EML (email) files to PDF format with extensive"
+ " customization options. Features include font settings, image"
+ " constraints, display modes, attachment handling, and HTML debug output."
+ " Input: EML file, Output: PDF or HTML file. Type: SISO")
"This endpoint converts EML (email) and MSG (Outlook) files to PDF format"
+ " with extensive customization options. Features include font settings,"
+ " image constraints, display modes, attachment handling, and HTML debug"
+ " output. Input: EML or MSG file, Output: PDF or HTML file. Type: SISO")
public ResponseEntity<byte[]> convertEmlToPdf(@ModelAttribute EmlToPdfRequest request) {
MultipartFile inputFile = request.getFileInput();
@@ -54,7 +55,7 @@ public class ConvertEmlToPDF {
// Validate input
if (inputFile.isEmpty()) {
log.error("No file provided for EML to PDF conversion.");
log.error("No file provided for EML/MSG to PDF conversion.");
return ResponseEntity.badRequest()
.body("No file provided".getBytes(StandardCharsets.UTF_8));
}
@@ -65,12 +66,12 @@ public class ConvertEmlToPDF {
.body("Please provide a valid filename".getBytes(StandardCharsets.UTF_8));
}
// Validate file type - support EML
// Validate file type - support EML and MSG (Outlook) files
String lowerFilename = originalFilename.toLowerCase(Locale.ROOT);
if (!lowerFilename.endsWith(".eml")) {
log.error("Invalid file type for EML to PDF: {}", originalFilename);
if (!lowerFilename.endsWith(".eml") && !lowerFilename.endsWith(".msg")) {
log.error("Invalid file type for EML/MSG to PDF: {}", originalFilename);
return ResponseEntity.badRequest()
.body("Please upload a valid EML file".getBytes(StandardCharsets.UTF_8));
.body("Please upload a valid EML or MSG file".getBytes(StandardCharsets.UTF_8));
}
String baseFilename = Filenames.toSimpleFileName(originalFilename); // Use Filenames utility
@@ -81,7 +82,7 @@ public class ConvertEmlToPDF {
if (request.isDownloadHtml()) {
try {
String htmlContent = EmlToPdf.convertEmlToHtml(fileBytes, request);
log.info("Successfully converted EML to HTML: {}", originalFilename);
log.info("Successfully converted email to HTML: {}", originalFilename);
return WebResponseUtils.bytesToWebResponse(
htmlContent.getBytes(StandardCharsets.UTF_8),
baseFilename + ".html",
@@ -95,12 +96,11 @@ public class ConvertEmlToPDF {
}
}
// Convert EML to PDF with enhanced options
// Convert EML/MSG to PDF with enhanced options
try {
byte[] pdfBytes =
EmlToPdf.convertEmlToPdf(
runtimePathConfig
.getWeasyPrintPath(), // Use configured WeasyPrint path
runtimePathConfig.getWeasyPrintPath(),
request,
fileBytes,
originalFilename,
@@ -115,19 +115,19 @@ public class ConvertEmlToPDF {
"PDF conversion failed - empty output"
.getBytes(StandardCharsets.UTF_8));
}
log.info("Successfully converted EML to PDF: {}", originalFilename);
log.info("Successfully converted email to PDF: {}", originalFilename);
return WebResponseUtils.bytesToWebResponse(
pdfBytes, baseFilename + ".pdf", MediaType.APPLICATION_PDF);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.error("EML to PDF conversion was interrupted for {}", originalFilename, e);
log.error("Email to PDF conversion was interrupted for {}", originalFilename, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Conversion was interrupted".getBytes(StandardCharsets.UTF_8));
} catch (IllegalArgumentException e) {
String errorMessage = buildErrorMessage(e, originalFilename);
log.error(
"EML to PDF conversion failed for {}: {}",
"Email to PDF conversion failed for {}: {}",
originalFilename,
errorMessage,
e);
@@ -136,7 +136,7 @@ public class ConvertEmlToPDF {
} catch (RuntimeException e) {
String errorMessage = buildErrorMessage(e, originalFilename);
log.error(
"EML to PDF conversion failed for {}: {}",
"Email to PDF conversion failed for {}: {}",
originalFilename,
errorMessage,
e);
@@ -145,27 +145,31 @@ public class ConvertEmlToPDF {
}
} catch (IOException e) {
log.error("File processing error for EML to PDF: {}", originalFilename, e);
log.error("File processing error for email to PDF: {}", originalFilename, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("File processing error".getBytes(StandardCharsets.UTF_8));
}
}
private static @NotNull String buildErrorMessage(Exception e, String originalFilename) {
String safeFilename = HtmlUtils.htmlEscape(originalFilename);
String exceptionMessage = e.getMessage();
String safeExceptionMessage =
exceptionMessage == null ? "Unknown error" : HtmlUtils.htmlEscape(exceptionMessage);
String errorMessage;
if (e.getMessage() != null && e.getMessage().contains("Invalid EML")) {
if (exceptionMessage != null && exceptionMessage.contains("Invalid EML")) {
errorMessage =
"Invalid EML file format. Please ensure you've uploaded a valid email"
+ " file ("
+ originalFilename
+ safeFilename
+ ").";
} else if (e.getMessage() != null && e.getMessage().contains("WeasyPrint")) {
} else if (exceptionMessage != null && exceptionMessage.contains("WeasyPrint")) {
errorMessage =
"PDF generation failed for "
+ originalFilename
+ safeFilename
+ ". This may be due to complex email formatting.";
} else {
errorMessage = "Conversion failed for " + originalFilename + ": " + e.getMessage();
errorMessage = "Conversion failed for " + safeFilename + ": " + safeExceptionMessage;
}
return errorMessage;
}
@@ -188,15 +188,15 @@ public class ConvertImgPDFController {
bodyBytes = Files.readAllBytes(webpFilePath);
} else {
// Create a ZIP file containing all WebP images
ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
try (ByteArrayOutputStream zipOutputStream = new ByteArrayOutputStream();
ZipOutputStream zos = new ZipOutputStream(zipOutputStream)) {
for (Path webpFile : webpFiles) {
zos.putNextEntry(new ZipEntry(webpFile.getFileName().toString()));
Files.copy(webpFile, zos);
zos.closeEntry();
}
bodyBytes = zipOutputStream.toByteArray();
}
bodyBytes = zipOutputStream.toByteArray();
}
// Clean up the temporary files
Files.deleteIfExists(tempFile);
@@ -36,6 +36,8 @@ import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.TempDirectory;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ConvertApi
@@ -47,6 +49,7 @@ public class ConvertOfficeController {
private final RuntimePathConfig runtimePathConfig;
private final CustomHtmlSanitizer customHtmlSanitizer;
private final EndpointConfiguration endpointConfiguration;
private final TempFileManager tempFileManager;
private boolean isUnoconvertAvailable() {
return endpointConfiguration.isGroupEnabled("Unoconvert")
@@ -68,29 +71,24 @@ public class ConvertOfficeController {
}
String extensionLower = extension.toLowerCase(Locale.ROOT);
String baseName = FilenameUtils.getBaseName(originalFilename);
if (baseName == null || baseName.isBlank()) {
baseName = "input";
}
// create temporary working directory
// Create work directory - caller (processFileToPDF) is responsible for cleanup
Path workDir = Files.createTempDirectory("office2pdf_");
Path inputPath = workDir.resolve(baseName + "." + extensionLower);
Path outputPath = workDir.resolve(baseName + ".pdf");
Path inputPath = Files.createTempFile(workDir, "input_", "." + extensionLower);
Path outputPath = Files.createTempFile(workDir, "output_", ".pdf");
// Check if the file is HTML and apply sanitization if needed
if ("html".equals(extensionLower) || "htm".equals(extensionLower)) {
// Read and sanitize HTML content
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
Files.writeString(inputPath, sanitizedHtml, StandardCharsets.UTF_8);
} else {
// copy file content
Files.copy(inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
}
Path libreOfficeProfile = null;
try {
// Check if the file is HTML and apply sanitization if needed
if ("html".equals(extensionLower) || "htm".equals(extensionLower)) {
// Read and sanitize HTML content
String htmlContent = new String(inputFile.getBytes(), StandardCharsets.UTF_8);
String sanitizedHtml = customHtmlSanitizer.sanitize(htmlContent);
Files.writeString(inputPath, sanitizedHtml, StandardCharsets.UTF_8);
} else {
// copy file content
Files.copy(
inputFile.getInputStream(), inputPath, StandardCopyOption.REPLACE_EXISTING);
}
ProcessExecutorResult result;
// Run Unoconvert command
if (isUnoconvertAvailable()) {
@@ -109,21 +107,23 @@ public class ConvertOfficeController {
.runCommandWithOutputHandling(command);
} // Run soffice command
else {
libreOfficeProfile = Files.createTempDirectory("libreoffice_profile_");
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getSOfficePath());
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
command.add("--headless");
command.add("--nologo");
command.add("--convert-to");
command.add("pdf:writer_pdf_Export");
command.add("--outdir");
command.add(workDir.toString());
command.add(inputPath.toString());
try (TempDirectory libreOfficeProfileManager = new TempDirectory(tempFileManager)) {
Path libreOfficeProfile = libreOfficeProfileManager.getPath();
List<String> command = new ArrayList<>();
command.add(runtimePathConfig.getSOfficePath());
command.add("-env:UserInstallation=" + libreOfficeProfile.toUri().toString());
command.add("--headless");
command.add("--nologo");
command.add("--convert-to");
command.add("pdf:writer_pdf_Export");
command.add("--outdir");
command.add(workDir.toString());
command.add(inputPath.toString());
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.LIBRE_OFFICE)
.runCommandWithOutputHandling(command);
}
}
// Check the result
@@ -162,15 +162,12 @@ public class ConvertOfficeController {
return outputPath.toFile();
} finally {
// Clean up the temporary files
// Clean up the temporary input file (output and workDir cleaned by caller)
try {
Files.deleteIfExists(inputPath);
} catch (IOException e) {
log.warn("Failed to delete temp input file: {}", inputPath, e);
}
if (libreOfficeProfile != null) {
FileUtils.deleteQuietly(libreOfficeProfile.toFile());
}
}
}
@@ -196,11 +193,12 @@ public class ConvertOfficeController {
try {
file = convertToPdf(inputFile);
PDDocument doc = pdfDocumentFactory.load(file);
return WebResponseUtils.pdfDocToWebResponse(
doc,
GeneralUtils.generateFilename(
inputFile.getOriginalFilename(), "_convertedToPDF.pdf"));
try (PDDocument doc = pdfDocumentFactory.load(file)) {
return WebResponseUtils.pdfDocToWebResponse(
doc,
GeneralUtils.generateFilename(
inputFile.getOriginalFilename(), "_convertedToPDF.pdf"));
}
} finally {
if (file != null && file.getParent() != null) {
FileUtils.deleteDirectory(file.getParentFile());
@@ -21,6 +21,7 @@ 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.cos.COSStream;
import org.apache.pdfbox.io.RandomAccessRead;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
import org.apache.pdfbox.pdfwriter.compress.CompressParameters;
@@ -51,7 +52,7 @@ import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationTextMarkup;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationHighlight;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary;
import org.apache.pdfbox.pdmodel.interactive.viewerpreferences.PDViewerPreferences;
@@ -378,7 +379,6 @@ public class ConvertPDFToPDFA {
command.add("-sOutputICCProfile=" + colorProfiles.rgb().toAbsolutePath());
command.add("-sDefaultRGBProfile=" + colorProfiles.rgb().toAbsolutePath());
command.add("-sDefaultGrayProfile=" + colorProfiles.gray().toAbsolutePath());
command.add("-sDefaultCMYKProfile=" + colorProfiles.rgb().toAbsolutePath());
// Font handling optimized for PDF/A CIDSet compliance
command.add("-dEmbedAllFonts=true");
@@ -673,25 +673,55 @@ public class ConvertPDFToPDFA {
if (descriptor == null) continue;
// Check if this is a Type1 font
if (fontNameStr.contains("Type1")
|| descriptor.getFontFile() != null
|| (descriptor.getFontFile2() == null
&& descriptor.getFontFile3() == null)) {
boolean isType1 =
isType1Font(font)
|| descriptor.getFontFile() != null
|| (descriptor.getFontFile2() == null
&& descriptor.getFontFile3() == null);
String existingCharSet =
descriptor.getCOSObject().getString(COSName.CHAR_SET);
if (isType1) {
COSDictionary descDict = descriptor.getCOSObject();
String existingCharSet = descDict.getString(COSName.CHAR_SET);
String glyphSet = buildStandardType1GlyphSet();
if (!glyphSet.isEmpty()) {
if (existingCharSet == null
|| existingCharSet.trim().isEmpty()
|| countGlyphs(existingCharSet) < countGlyphs(glyphSet)) {
descriptor.getCOSObject().setString(COSName.CHAR_SET, glyphSet);
// Check if font is embedded and if CharSet might be invalid
boolean fontEmbedded = font.isEmbedded();
boolean hasFontFile =
descriptor.getFontFile() != null
|| descriptor.getFontFile2() != null
|| descriptor.getFontFile3() != null;
// For PDF/A compliance: if CharSet exists but font is subsetted or
// we can't verify it matches the font file, remove it to avoid validation
// errors
if (existingCharSet != null && !existingCharSet.trim().isEmpty()) {
// If the font appears to be subsetted (indicated by subset prefix in
// name)
// or if we can't verify the CharSet is correct, remove it
if (fontNameStr.contains("+") || fontNameStr.contains("Subset")) {
descDict.removeItem(COSName.CHAR_SET);
log.debug(
"Fixed CharSet for Type1 font {} with {} glyphs (was: {})",
fontNameStr,
countGlyphs(glyphSet),
existingCharSet != null ? countGlyphs(existingCharSet) : 0);
"Removed potentially invalid CharSet from subsetted Type1 font: {}",
fontNameStr);
} else if (!hasFontFile && fontEmbedded) {
// Font is embedded but we can't verify CharSet, remove it
descDict.removeItem(COSName.CHAR_SET);
log.debug(
"Removed unverifiable CharSet from embedded Type1 font: {}",
fontNameStr);
}
} else if (existingCharSet == null || existingCharSet.trim().isEmpty()) {
// Only add CharSet if font is not subsetted and we can verify it
if (!fontNameStr.contains("+")
&& !fontNameStr.contains("Subset")
&& hasFontFile) {
String glyphSet = buildStandardType1GlyphSet();
if (!glyphSet.isEmpty()) {
descDict.setString(COSName.CHAR_SET, glyphSet);
log.debug(
"Added missing CharSet for Type1 font {} with {} glyphs",
fontNameStr,
countGlyphs(glyphSet));
}
}
}
}
@@ -1211,7 +1241,7 @@ public class ConvertPDFToPDFA {
List<PDAnnotation> annotations = page.getAnnotations();
for (PDAnnotation annot : annotations) {
if (ANNOTATION_HIGHLIGHT.equals(annot.getSubtype())
&& annot instanceof PDAnnotationTextMarkup highlight) {
&& annot instanceof PDAnnotationHighlight highlight) {
float[] colorComponents =
highlight.getColor() != null
? highlight.getColor().getComponents()
@@ -1349,13 +1379,22 @@ public class ConvertPDFToPDFA {
for (COSBase base : ocgArray) {
if (base instanceof COSDictionary ocgDict) {
if (!ocgDict.containsKey(COSName.NAME)) {
// Ensure Name entry exists and is not empty
String nameValue = ocgDict.getString(COSName.NAME);
if (nameValue == null || nameValue.trim().isEmpty()) {
String newName = "Layer " + unnamedCount++;
ocgDict.setString(COSName.NAME, newName);
log.debug("Fixed OCG missing name, set to: {}", newName);
log.debug("Fixed OCG missing or empty name, set to: {}", newName);
}
}
}
} else if (ocgs instanceof COSDictionary ocgDict) {
// Handle case where OCGS is a single dictionary instead of array
String nameValue = ocgDict.getString(COSName.NAME);
if (nameValue == null || nameValue.trim().isEmpty()) {
ocgDict.setString(COSName.NAME, "Layer 1");
log.debug("Fixed single OCG missing or empty name");
}
}
}
@@ -1479,7 +1518,9 @@ public class ConvertPDFToPDFA {
Path pdfaDefFile = createPdfaDefFile(workingDir, colorProfiles, profile);
// Preprocess PDF for PDF/A compliance using the sanitizer
Path sanitizedInputPdf = sanitizePdfWithPdfBox(inputPdf);
// We add a white background to ensure transparency is flattened correctly against white
// instead of black, addressing common PDF/A conversion issues.
Path sanitizedInputPdf = sanitizePdfWithPdfBox(inputPdf, true);
Path preprocessedPdf = sanitizedInputPdf != null ? sanitizedInputPdf : inputPdf;
// For PDF/A-1, clean CIDSet issues that may cause validation failures
@@ -1500,11 +1541,14 @@ public class ConvertPDFToPDFA {
buildGhostscriptCommand(
inputForGs, outputPdf, colorProfiles, workingDir, profile, pdfaDefFile);
log.info("Running Ghostscript command: {}", String.join(" ", command));
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
if (result.getRc() != 0) {
log.error("Ghostscript failed with output: {}", result.getMessages());
throw new IOException("Ghostscript exited with code " + result.getRc());
}
@@ -1665,6 +1709,7 @@ public class ConvertPDFToPDFA {
}
private byte[] convertWithPdfBoxMethod(Path inputPath, PdfaProfile profile) throws Exception {
log.info("Starting PDFBox/LibreOffice conversion for PDF/A-{}", profile.getPart());
Path tempInputFile = null;
byte[] fileBytes;
Path loPdfPath = null;
@@ -1720,17 +1765,20 @@ public class ConvertPDFToPDFA {
ColorProfiles colorProfiles = prepareColorProfiles(workingDir);
// Sanitize the PDF before PDF/X conversion for better Ghostscript compatibility
Path sanitizedInputPdf = sanitizePdfWithPdfBox(inputPdf);
Path sanitizedInputPdf = sanitizePdfWithPdfBox(inputPdf, true);
Path inputForGs = sanitizedInputPdf != null ? sanitizedInputPdf : inputPdf;
List<String> command =
buildGhostscriptCommandX(inputForGs, outputPdf, colorProfiles, workingDir, profile);
log.info("Running Ghostscript PDF/X command: {}", String.join(" ", command));
ProcessExecutorResult result =
ProcessExecutor.getInstance(ProcessExecutor.Processes.GHOSTSCRIPT)
.runCommandWithOutputHandling(command);
if (result.getRc() != 0) {
log.error("Ghostscript PDF/X failed with output: {}", result.getMessages());
throw new IOException("Ghostscript exited with code " + result.getRc());
}
@@ -1796,12 +1844,12 @@ public class ConvertPDFToPDFA {
}
}
private Path sanitizePdfWithPdfBox(Path inputPdf) {
private Path sanitizePdfWithPdfBox(Path inputPdf, boolean addWhiteBackground) {
try {
Path sanitizedPath =
inputPdf.getParent().resolve("sanitized_" + inputPdf.getFileName().toString());
sanitizeDocument(inputPdf, sanitizedPath);
sanitizeDocument(inputPdf, sanitizedPath, addWhiteBackground);
log.info("PDF sanitized with PDFBox for better Ghostscript compatibility");
return sanitizedPath;
@@ -1813,7 +1861,8 @@ public class ConvertPDFToPDFA {
}
}
private void sanitizeDocument(Path inputPath, Path outputPath) throws IOException {
private void sanitizeDocument(Path inputPath, Path outputPath, boolean addWhiteBackground)
throws IOException {
try (PDDocument doc = Loader.loadPDF(inputPath.toFile())) {
Map<String, DocumentSanitizer> sanitizers = new LinkedHashMap<>();
sanitizers.put("Flatten highlight annotations", this::flattenHighlightsToContent);
@@ -1824,6 +1873,11 @@ public class ConvertPDFToPDFA {
sanitizers.put("Ensure embedded file compliance", this::ensureEmbeddedFileCompliance);
sanitizers.put(
"Fix optional content groups", ConvertPDFToPDFA::fixOptionalContentGroups);
sanitizers.put("Fix separation color spaces", this::fixSeparationColorSpaces);
if (addWhiteBackground) {
sanitizers.put("Add white background", this::addWhiteBackground);
}
for (Map.Entry<String, DocumentSanitizer> entry : sanitizers.entrySet()) {
try {
@@ -1841,6 +1895,191 @@ public class ConvertPDFToPDFA {
}
}
private void fixSeparationColorSpaces(PDDocument doc) throws IOException {
Map<String, COSBase> knownTintTransforms = new HashMap<>();
Set<COSBase> visitedResources = new HashSet<>();
// Process all pages first to collect all separation color spaces
for (PDPage page : doc.getPages()) {
PDResources resources = page.getResources();
processResourcesForSeparation(resources, knownTintTransforms, visitedResources);
}
// Process document-level resources if they exist
PDDocumentCatalog catalog = doc.getDocumentCatalog();
if (catalog != null) {
PDResources docResources =
catalog.getAcroForm() != null
? catalog.getAcroForm().getDefaultResources()
: null;
if (docResources != null) {
processResourcesForSeparation(docResources, knownTintTransforms, visitedResources);
}
}
// Second pass: ensure all separations with the same name use the same tintTransform
visitedResources.clear();
for (PDPage page : doc.getPages()) {
PDResources resources = page.getResources();
enforceSeparationConsistency(resources, knownTintTransforms, visitedResources);
}
}
private void processResourcesForSeparation(
PDResources resources,
Map<String, COSBase> knownTintTransforms,
Set<COSBase> visitedResources) {
if (resources == null) return;
// Prevent infinite recursion if resources are shared or cyclic
if (!visitedResources.add(resources.getCOSObject())) {
return;
}
// Check defined ColorSpaces
COSDictionary csDict =
(COSDictionary) resources.getCOSObject().getDictionaryObject(COSName.COLORSPACE);
if (csDict != null) {
for (COSName name : csDict.keySet()) {
COSBase csVal = csDict.getDictionaryObject(name);
checkAndFixSeparation(csVal, knownTintTransforms);
}
}
// Recursively check XObjects (Forms)
COSDictionary xObjDict =
(COSDictionary) resources.getCOSObject().getDictionaryObject(COSName.XOBJECT);
if (xObjDict != null) {
for (COSName name : xObjDict.keySet()) {
COSBase xObj = xObjDict.getDictionaryObject(name);
if (xObj instanceof COSStream stream) {
COSName type = (COSName) stream.getDictionaryObject(COSName.SUBTYPE);
if (COSName.FORM.equals(type)) {
COSBase formRes = stream.getDictionaryObject(COSName.RESOURCES);
if (formRes instanceof COSDictionary formResDict) {
processResourcesForSeparation(
new PDResources(formResDict),
knownTintTransforms,
visitedResources);
}
}
}
}
}
}
private void checkAndFixSeparation(COSBase cs, Map<String, COSBase> knownTintTransforms) {
if (cs instanceof COSArray arr && arr.size() >= 4) {
COSBase type = arr.getObject(0);
if (COSName.SEPARATION.equals(type)) {
// Separation: [/Separation name altSpace tintTransform]
COSBase nameBase = arr.getObject(1);
if (nameBase instanceof COSName colorName) {
String name = colorName.getName();
COSBase tintTransform = arr.getObject(3);
if (knownTintTransforms.containsKey(name)) {
COSBase known = knownTintTransforms.get(name);
// If objects are not identical (same reference), unify them
if (known != tintTransform) {
arr.set(3, known);
log.debug("Unified TintTransform for Separation color: {}", name);
}
} else {
// Store the first encountered tintTransform for this color name
knownTintTransforms.put(name, tintTransform);
}
}
}
}
}
private void enforceSeparationConsistency(
PDResources resources,
Map<String, COSBase> knownTintTransforms,
Set<COSBase> visitedResources) {
if (resources == null) return;
// Prevent infinite recursion
if (!visitedResources.add(resources.getCOSObject())) {
return;
}
// Check defined ColorSpaces
COSDictionary csDict =
(COSDictionary) resources.getCOSObject().getDictionaryObject(COSName.COLORSPACE);
if (csDict != null) {
for (COSName name : csDict.keySet()) {
COSBase csVal = csDict.getDictionaryObject(name);
enforceSeparationTintTransform(csVal, knownTintTransforms);
}
}
// Recursively check XObjects (Forms)
COSDictionary xObjDict =
(COSDictionary) resources.getCOSObject().getDictionaryObject(COSName.XOBJECT);
if (xObjDict != null) {
for (COSName name : xObjDict.keySet()) {
COSBase xObj = xObjDict.getDictionaryObject(name);
if (xObj instanceof COSStream stream) {
COSName type = (COSName) stream.getDictionaryObject(COSName.SUBTYPE);
if (COSName.FORM.equals(type)) {
COSBase formRes = stream.getDictionaryObject(COSName.RESOURCES);
if (formRes instanceof COSDictionary formResDict) {
enforceSeparationConsistency(
new PDResources(formResDict),
knownTintTransforms,
visitedResources);
}
}
}
}
}
}
private void enforceSeparationTintTransform(
COSBase cs, Map<String, COSBase> knownTintTransforms) {
if (cs instanceof COSArray arr && arr.size() >= 4) {
COSBase type = arr.getObject(0);
if (COSName.SEPARATION.equals(type)) {
COSBase nameBase = arr.getObject(1);
if (nameBase instanceof COSName colorName) {
String name = colorName.getName();
COSBase tintTransform = arr.getObject(3);
// Ensure all separations with the same name use the same tintTransform
// reference
if (knownTintTransforms.containsKey(name)) {
COSBase known = knownTintTransforms.get(name);
if (known != tintTransform) {
arr.set(3, known);
log.debug(
"Enforced consistent TintTransform for Separation color: {}",
name);
}
}
}
}
}
}
private void addWhiteBackground(PDDocument doc) throws IOException {
for (PDPage page : doc.getPages()) {
PDRectangle mediaBox = page.getMediaBox();
try (PDPageContentStream cs =
new PDPageContentStream(
doc, page, PDPageContentStream.AppendMode.PREPEND, true, true)) {
cs.setNonStrokingColor(Color.WHITE);
cs.addRect(
mediaBox.getLowerLeftX(),
mediaBox.getLowerLeftY(),
mediaBox.getWidth(),
mediaBox.getHeight());
cs.fill();
}
}
}
private void flattenHighlightsToContent(PDDocument doc) throws IOException {
for (PDPage page : doc.getPages()) {
List<PDAnnotation> annotations = new ArrayList<>(page.getAnnotations());
@@ -1851,7 +2090,7 @@ public class ConvertPDFToPDFA {
doc, page, PDPageContentStream.AppendMode.PREPEND, true, true)) {
for (PDAnnotation annot : annotations) {
if (annot instanceof PDAnnotationTextMarkup highlight
if (annot instanceof PDAnnotationHighlight highlight
&& ANNOTATION_HIGHLIGHT.equals(annot.getSubtype())) {
PDColor color = highlight.getColor();
@@ -1973,7 +2212,7 @@ public class ConvertPDFToPDFA {
return annot.getAppearance() != null;
}
if (annot instanceof PDAnnotationTextMarkup) {
if (annot instanceof PDAnnotationHighlight) {
return false; // Will be handled by flattening
}
@@ -47,102 +47,105 @@ public class AutoRenameController {
MultipartFile file = request.getFileInput();
boolean useFirstTextAsFallback = Boolean.TRUE.equals(request.getUseFirstTextAsFallback());
PDDocument document = pdfDocumentFactory.load(file);
PDFTextStripper reader =
new PDFTextStripper() {
List<LineInfo> lineInfos = new ArrayList<>();
StringBuilder lineBuilder = new StringBuilder();
float lastY = -1;
float maxFontSizeInLine = 0.0f;
int lineCount = 0;
try (PDDocument document = pdfDocumentFactory.load(file)) {
PDFTextStripper reader =
new PDFTextStripper() {
List<LineInfo> lineInfos = new ArrayList<>();
StringBuilder lineBuilder = new StringBuilder();
float lastY = -1;
float maxFontSizeInLine = 0.0f;
int lineCount = 0;
@Override
protected void processTextPosition(TextPosition text) {
if (lastY != text.getY() && lineCount < LINE_LIMIT) {
processLine();
lineBuilder = new StringBuilder(text.getUnicode());
maxFontSizeInLine = text.getFontSizeInPt();
lastY = text.getY();
lineCount++;
} else if (lineCount < LINE_LIMIT) {
lineBuilder.append(text.getUnicode());
if (text.getFontSizeInPt() > maxFontSizeInLine) {
@Override
protected void processTextPosition(TextPosition text) {
if (lastY != text.getY() && lineCount < LINE_LIMIT) {
processLine();
lineBuilder = new StringBuilder(text.getUnicode());
maxFontSizeInLine = text.getFontSizeInPt();
lastY = text.getY();
lineCount++;
} else if (lineCount < LINE_LIMIT) {
lineBuilder.append(text.getUnicode());
if (text.getFontSizeInPt() > maxFontSizeInLine) {
maxFontSizeInLine = text.getFontSizeInPt();
}
}
}
}
private void processLine() {
if (!lineBuilder.isEmpty() && lineCount < LINE_LIMIT) {
lineInfos.add(new LineInfo(lineBuilder.toString(), maxFontSizeInLine));
}
}
@Override
public String getText(PDDocument doc) throws IOException {
this.lineInfos.clear();
this.lineBuilder = new StringBuilder();
this.lastY = -1;
this.maxFontSizeInLine = 0.0f;
this.lineCount = 0;
super.getText(doc);
processLine(); // Process the last line
// Merge lines with same font size
List<LineInfo> mergedLineInfos = new ArrayList<>();
for (int i = 0; i < lineInfos.size(); i++) {
StringBuilder mergedText = new StringBuilder(lineInfos.get(i).text);
float fontSize = lineInfos.get(i).fontSize;
while (i + 1 < lineInfos.size()
&& lineInfos.get(i + 1).fontSize == fontSize) {
mergedText.append(" ").append(lineInfos.get(i + 1).text);
i++;
private void processLine() {
if (!lineBuilder.isEmpty() && lineCount < LINE_LIMIT) {
lineInfos.add(
new LineInfo(lineBuilder.toString(), maxFontSizeInLine));
}
mergedLineInfos.add(new LineInfo(mergedText.toString(), fontSize));
}
// Sort lines by font size in descending order and get the first one
mergedLineInfos.sort(
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
String title =
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
@Override
public String getText(PDDocument doc) throws IOException {
this.lineInfos.clear();
this.lineBuilder = new StringBuilder();
this.lastY = -1;
this.maxFontSizeInLine = 0.0f;
this.lineCount = 0;
super.getText(doc);
processLine(); // Process the last line
return title != null
? title
: (useFirstTextAsFallback
? (mergedLineInfos.isEmpty()
? null
: mergedLineInfos.get(mergedLineInfos.size() - 1)
.text)
: null);
}
// Merge lines with same font size
List<LineInfo> mergedLineInfos = new ArrayList<>();
for (int i = 0; i < lineInfos.size(); i++) {
StringBuilder mergedText = new StringBuilder(lineInfos.get(i).text);
float fontSize = lineInfos.get(i).fontSize;
while (i + 1 < lineInfos.size()
&& lineInfos.get(i + 1).fontSize == fontSize) {
mergedText.append(" ").append(lineInfos.get(i + 1).text);
i++;
}
mergedLineInfos.add(new LineInfo(mergedText.toString(), fontSize));
}
class LineInfo {
String text;
float fontSize;
// Sort lines by font size in descending order and get the first one
mergedLineInfos.sort(
Comparator.comparing((LineInfo li) -> li.fontSize).reversed());
String title =
mergedLineInfos.isEmpty() ? null : mergedLineInfos.get(0).text;
LineInfo(String text, float fontSize) {
this.text = text;
this.fontSize = fontSize;
return title != null
? title
: (useFirstTextAsFallback
? (mergedLineInfos.isEmpty()
? null
: mergedLineInfos.get(
mergedLineInfos.size() - 1)
.text)
: null);
}
}
};
String header = reader.getText(document);
class LineInfo {
String text;
float fontSize;
// Sanitize the header string by removing characters not allowed in a filename.
if (header != null && header.length() < 255) {
header =
RegexPatternUtils.getInstance()
.getSafeFilenamePattern()
.matcher(header)
.replaceAll("")
.trim();
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
} else {
log.info("File has no good title to be found");
return WebResponseUtils.pdfDocToWebResponse(
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
LineInfo(String text, float fontSize) {
this.text = text;
this.fontSize = fontSize;
}
}
};
String header = reader.getText(document);
// Sanitize the header string by removing characters not allowed in a filename.
if (header != null && header.length() < 255) {
header =
RegexPatternUtils.getInstance()
.getSafeFilenamePattern()
.matcher(header)
.replaceAll("")
.trim();
return WebResponseUtils.pdfDocToWebResponse(document, header + ".pdf");
} else {
log.info("File has no good title to be found");
return WebResponseUtils.pdfDocToWebResponse(
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
}
}
}
}
@@ -1100,8 +1100,9 @@ public class CompressController {
inputFile.getOriginalFilename(), "_Optimized.pdf");
try {
return WebResponseUtils.pdfDocToWebResponse(
pdfDocumentFactory.load(currentFile.toFile()), outputFilename);
try (PDDocument document = pdfDocumentFactory.load(currentFile.toFile())) {
return WebResponseUtils.pdfDocToWebResponse(document, outputFilename);
}
} catch (IOException e) {
throw ExceptionUtils.handlePdfException(e, "PDF optimization");
}
@@ -75,10 +75,25 @@ public class ConfigController {
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
configData.put("frontendUrl", frontendUrl != null ? frontendUrl : "");
// Add mobile scanner setting
// Add mobile scanner settings
configData.put(
"enableMobileScanner",
applicationProperties.getSystem().isEnableMobileScanner());
configData.put(
"mobileScannerConvertToPdf",
applicationProperties.getSystem().getMobileScannerSettings().isConvertToPdf());
configData.put(
"mobileScannerImageResolution",
applicationProperties
.getSystem()
.getMobileScannerSettings()
.getImageResolution());
configData.put(
"mobileScannerPageFormat",
applicationProperties.getSystem().getMobileScannerSettings().getPageFormat());
configData.put(
"mobileScannerStretchToFit",
applicationProperties.getSystem().getMobileScannerSettings().isStretchToFit());
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
@@ -49,93 +49,103 @@ public class FlattenController {
public ResponseEntity<byte[]> flatten(@ModelAttribute FlattenRequest request) throws Exception {
MultipartFile file = request.getFileInput();
PDDocument document = pdfDocumentFactory.load(file);
Boolean flattenOnlyForms = request.getFlattenOnlyForms();
try (PDDocument document = pdfDocumentFactory.load(file)) {
Boolean flattenOnlyForms = request.getFlattenOnlyForms();
if (Boolean.TRUE.equals(flattenOnlyForms)) {
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
if (acroForm != null) {
acroForm.flatten();
}
return WebResponseUtils.pdfDocToWebResponse(
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
} else {
// flatten whole page aka convert each page to image and re-add it (making text
// unselectable)
PDFRenderer pdfRenderer = new PDFRenderer(document);
PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document);
if (Boolean.TRUE.equals(flattenOnlyForms)) {
PDAcroForm acroForm = document.getDocumentCatalog().getAcroForm();
if (acroForm != null) {
acroForm.flatten();
}
return WebResponseUtils.pdfDocToWebResponse(
document, Filenames.toSimpleFileName(file.getOriginalFilename()));
} else {
// flatten whole page aka convert each page to image and re-add it (making text
// unselectable)
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(
true); // Enable subsampling to reduce memory usage
int defaultRenderDpi = 100; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
Integer configuredMaxDpi = null;
if (properties != null && properties.getSystem() != null) {
configuredMaxDpi = properties.getSystem().getMaxDPI();
}
try (PDDocument newDocument =
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
int maxDpi =
(configuredMaxDpi != null && configuredMaxDpi > 0)
? configuredMaxDpi
: defaultRenderDpi;
Integer requestedDpi = request.getRenderDpi();
int renderDpiTemp = maxDpi;
if (requestedDpi != null) {
renderDpiTemp = Math.min(requestedDpi, maxDpi);
renderDpiTemp = Math.max(renderDpiTemp, 72);
}
final int renderDpi = renderDpiTemp;
int numPages = document.getNumberOfPages();
for (int i = 0; i < numPages; i++) {
final int pageIndex = i;
BufferedImage image = null;
try {
// Validate dimensions BEFORE rendering to prevent OOM
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, renderDpi);
// Wrap entire rendering operation to catch OutOfMemoryError from any depth
image =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
renderDpi,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, renderDpi, ImageType.RGB));
PDPage page = new PDPage();
page.setMediaBox(document.getPage(i).getMediaBox());
newDocument.addPage(page);
try (PDPageContentStream contentStream =
new PDPageContentStream(newDocument, page)) {
PDImageXObject pdImage = JPEGFactory.createFromImage(newDocument, image);
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
int defaultRenderDpi = 100; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
Integer configuredMaxDpi = null;
if (properties != null && properties.getSystem() != null) {
configuredMaxDpi = properties.getSystem().getMaxDPI();
}
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OutOfMemoryDpiException to be handled by GlobalExceptionHandler
newDocument.close();
document.close();
throw e;
} catch (IOException e) {
log.error("IOException during page processing: ", e);
// Continue processing other pages
} catch (OutOfMemoryError e) {
// Catch any OutOfMemoryError that escaped the inner try block
newDocument.close();
document.close();
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
} finally {
// Help GC by clearing the image reference
image = null;
int maxDpi =
(configuredMaxDpi != null && configuredMaxDpi > 0)
? configuredMaxDpi
: defaultRenderDpi;
Integer requestedDpi = request.getRenderDpi();
int renderDpiTemp = maxDpi;
if (requestedDpi != null) {
renderDpiTemp = Math.min(requestedDpi, maxDpi);
renderDpiTemp = Math.max(renderDpiTemp, 72);
}
final int renderDpi = renderDpiTemp;
int numPages = document.getNumberOfPages();
for (int i = 0; i < numPages; i++) {
final int pageIndex = i;
BufferedImage image = null;
try {
// Validate dimensions BEFORE rendering to prevent OOM
ExceptionUtils.validateRenderingDimensions(
document.getPage(pageIndex), pageIndex + 1, renderDpi);
// Wrap entire rendering operation to catch OutOfMemoryError from any
// depth
image =
ExceptionUtils.handleOomRendering(
pageIndex + 1,
renderDpi,
() ->
pdfRenderer.renderImageWithDPI(
pageIndex, renderDpi, ImageType.RGB));
PDPage page = new PDPage();
page.setMediaBox(document.getPage(i).getMediaBox());
newDocument.addPage(page);
// resetContext=true: Ensure clean graphics state when overwriting.
try (PDPageContentStream contentStream =
new PDPageContentStream(
newDocument,
page,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
PDImageXObject pdImage =
JPEGFactory.createFromImage(newDocument, image);
float pageWidth = page.getMediaBox().getWidth();
float pageHeight = page.getMediaBox().getHeight();
contentStream.drawImage(pdImage, 0, 0, pageWidth, pageHeight);
}
} catch (ExceptionUtils.OutOfMemoryDpiException e) {
// Re-throw OutOfMemoryDpiException to be handled by
// GlobalExceptionHandler
throw e;
} catch (IOException e) {
log.error("IOException during page processing: ", e);
// Continue processing other pages
} catch (OutOfMemoryError e) {
// Catch any OutOfMemoryError that escaped the inner try block
throw ExceptionUtils.createOutOfMemoryDpiException(i + 1, renderDpi, e);
} finally {
// Help GC by clearing the image reference
image = null;
}
}
return WebResponseUtils.pdfDocToWebResponse(
newDocument, Filenames.toSimpleFileName(file.getOriginalFilename()));
}
}
return WebResponseUtils.pdfDocToWebResponse(
newDocument, Filenames.toSimpleFileName(file.getOriginalFilename()));
}
}
}
@@ -84,93 +84,102 @@ public class MetadataController {
if (allRequestParams == null) {
allRequestParams = new java.util.HashMap<String, String>();
}
// Load the PDF file into a PDDocument
PDDocument document = pdfDocumentFactory.load(pdfFile, true);
// Load the PDF file into a PDDocument with proper resource management
try (PDDocument document = pdfDocumentFactory.load(pdfFile, true)) {
// Get the document information from the PDF
PDDocumentInformation info = document.getDocumentInformation();
// Get the document information from the PDF
PDDocumentInformation info = document.getDocumentInformation();
// Check if each metadata value is "undefined" and set it to null if it is
author = checkUndefined(author);
creationDate = checkUndefined(creationDate);
creator = checkUndefined(creator);
keywords = checkUndefined(keywords);
modificationDate = checkUndefined(modificationDate);
producer = checkUndefined(producer);
subject = checkUndefined(subject);
title = checkUndefined(title);
trapped = checkUndefined(trapped);
// Check if each metadata value is "undefined" and set it to null if it is
author = checkUndefined(author);
creationDate = checkUndefined(creationDate);
creator = checkUndefined(creator);
keywords = checkUndefined(keywords);
modificationDate = checkUndefined(modificationDate);
producer = checkUndefined(producer);
subject = checkUndefined(subject);
title = checkUndefined(title);
trapped = checkUndefined(trapped);
// If the "deleteAll" flag is set, remove all metadata from the document
// information
if (deleteAll) {
for (String key : info.getMetadataKeys()) {
info.setCustomMetadataValue(key, null);
}
// Remove metadata from the PDF history
document.getDocumentCatalog().getCOSObject().removeItem(COSName.getPDFName("Metadata"));
document.getDocumentCatalog()
.getCOSObject()
.removeItem(COSName.getPDFName("PieceInfo"));
author = null;
creationDate = null;
creator = null;
keywords = null;
modificationDate = null;
producer = null;
subject = null;
title = null;
trapped = null;
} else {
// Iterate through the request parameters and set the metadata values
for (Entry<String, String> entry : allRequestParams.entrySet()) {
String key = entry.getKey();
// Check if the key is a standard metadata key
if (!"Author".equalsIgnoreCase(key)
&& !"CreationDate".equalsIgnoreCase(key)
&& !"Creator".equalsIgnoreCase(key)
&& !"Keywords".equalsIgnoreCase(key)
&& !"modificationDate".equalsIgnoreCase(key)
&& !"Producer".equalsIgnoreCase(key)
&& !"Subject".equalsIgnoreCase(key)
&& !"Title".equalsIgnoreCase(key)
&& !"Trapped".equalsIgnoreCase(key)
&& !key.contains("customKey")
&& !key.contains("customValue")) {
info.setCustomMetadataValue(key, entry.getValue());
} else if (key.contains("customKey")) {
int number =
Integer.parseInt(
RegexPatternUtils.getInstance()
.getNumericExtractionPattern()
.matcher(key)
.replaceAll(""));
String customKey = entry.getValue();
String customValue = allRequestParams.get("customValue" + number);
info.setCustomMetadataValue(customKey, customValue);
// If the "deleteAll" flag is set, remove all metadata from the document
// information
if (deleteAll) {
for (String key : info.getMetadataKeys()) {
info.setCustomMetadataValue(key, null);
}
// Remove metadata from the PDF history
document.getDocumentCatalog()
.getCOSObject()
.removeItem(COSName.getPDFName("Metadata"));
document.getDocumentCatalog()
.getCOSObject()
.removeItem(COSName.getPDFName("PieceInfo"));
author = null;
creationDate = null;
creator = null;
keywords = null;
modificationDate = null;
producer = null;
subject = null;
title = null;
trapped = null;
} else {
// Iterate through the request parameters and set the metadata values
for (Entry<String, String> entry : allRequestParams.entrySet()) {
String key = entry.getKey();
// Check if the key is a standard metadata key
if (!"Author".equalsIgnoreCase(key)
&& !"CreationDate".equalsIgnoreCase(key)
&& !"Creator".equalsIgnoreCase(key)
&& !"Keywords".equalsIgnoreCase(key)
&& !"modificationDate".equalsIgnoreCase(key)
&& !"Producer".equalsIgnoreCase(key)
&& !"Subject".equalsIgnoreCase(key)
&& !"Title".equalsIgnoreCase(key)
&& !"Trapped".equalsIgnoreCase(key)
&& !key.contains("customKey")
&& !key.contains("customValue")) {
info.setCustomMetadataValue(key, entry.getValue());
} else if (key.contains("customKey")) {
try {
int number =
Integer.parseInt(
RegexPatternUtils.getInstance()
.getNumericExtractionPattern()
.matcher(key)
.replaceAll(""));
String customKey = entry.getValue();
String customValue = allRequestParams.get("customValue" + number);
info.setCustomMetadataValue(customKey, customValue);
} catch (NumberFormatException e) {
// Skip invalid custom key entries that don't have valid numeric
// suffixes
log.warn("Skipping invalid custom key '{}': {}", key, e.getMessage());
}
}
}
}
// Set creation date using utility method
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
info.setCreationDate(creationDateCal);
// Set modification date using utility method
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
info.setModificationDate(modificationDateCal);
info.setCreator(creator);
info.setKeywords(keywords);
info.setAuthor(author);
info.setProducer(producer);
info.setSubject(subject);
info.setTitle(title);
info.setTrapped(trapped);
document.setDocumentInformation(info);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.removeExtension(
Filenames.toSimpleFileName(pdfFile.getOriginalFilename()))
+ "_metadata.pdf");
}
// Set creation date using utility method
Calendar creationDateCal = PdfMetadataService.parseToCalendar(creationDate);
info.setCreationDate(creationDateCal);
// Set modification date using utility method
Calendar modificationDateCal = PdfMetadataService.parseToCalendar(modificationDate);
info.setModificationDate(modificationDateCal);
info.setCreator(creator);
info.setKeywords(keywords);
info.setAuthor(author);
info.setProducer(producer);
info.setSubject(subject);
info.setTitle(title);
info.setTrapped(trapped);
document.setDocumentInformation(info);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.removeExtension(
Filenames.toSimpleFileName(pdfFile.getOriginalFilename()))
+ "_metadata.pdf");
}
}
@@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.MobileScannerService;
import stirling.software.common.service.MobileScannerService.FileMetadata;
@@ -51,9 +52,31 @@ import stirling.software.common.service.MobileScannerService.FileMetadata;
public class MobileScannerController {
private final MobileScannerService mobileScannerService;
private final ApplicationProperties applicationProperties;
public MobileScannerController(MobileScannerService mobileScannerService) {
public MobileScannerController(
MobileScannerService mobileScannerService,
ApplicationProperties applicationProperties) {
this.mobileScannerService = mobileScannerService;
this.applicationProperties = applicationProperties;
}
/**
* Check if mobile scanner feature is enabled
*
* @return Error response if disabled, null if enabled
*/
private ResponseEntity<Map<String, Object>> checkFeatureEnabled() {
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(
Map.of(
"error",
"Mobile scanner feature is not enabled",
"enabled",
false));
}
return null;
}
/**
@@ -71,10 +94,16 @@ public class MobileScannerController {
description = "Session created successfully",
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid session ID")
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
public ResponseEntity<Map<String, Object>> createSession(
@Parameter(description = "Session ID for QR code", required = true) @PathVariable
String sessionId) {
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
if (featureCheck != null) {
return featureCheck;
}
try {
MobileScannerService.SessionInfo sessionInfo =
mobileScannerService.createSession(sessionId);
@@ -109,10 +138,16 @@ public class MobileScannerController {
description = "Session is valid",
content = @Content(schema = @Schema(implementation = SessionInfoResponse.class)))
@ApiResponse(responseCode = "404", description = "Session not found or expired")
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
public ResponseEntity<Map<String, Object>> validateSession(
@Parameter(description = "Session ID to validate", required = true) @PathVariable
String sessionId) {
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
if (featureCheck != null) {
return featureCheck;
}
MobileScannerService.SessionInfo sessionInfo =
mobileScannerService.validateSession(sessionId);
@@ -147,6 +182,7 @@ public class MobileScannerController {
description = "Files uploaded successfully",
content = @Content(schema = @Schema(implementation = UploadResponse.class)))
@ApiResponse(responseCode = "400", description = "Invalid session ID or files")
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
@ApiResponse(responseCode = "500", description = "Upload failed")
public ResponseEntity<Map<String, Object>> uploadFiles(
@Parameter(description = "Session ID from QR code", required = true) @PathVariable
@@ -154,6 +190,11 @@ public class MobileScannerController {
@Parameter(description = "Files to upload", required = true) @RequestParam("files")
List<MultipartFile> files) {
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
if (featureCheck != null) {
return featureCheck;
}
try {
if (files == null || files.isEmpty()) {
return ResponseEntity.badRequest().body(Map.of("error", "No files provided"));
@@ -194,10 +235,16 @@ public class MobileScannerController {
responseCode = "200",
description = "File list retrieved",
content = @Content(schema = @Schema(implementation = FileListResponse.class)))
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
public ResponseEntity<Map<String, Object>> getSessionFiles(
@Parameter(description = "Session ID", required = true) @PathVariable
String sessionId) {
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
if (featureCheck != null) {
return featureCheck;
}
List<FileMetadata> files = mobileScannerService.getSessionFiles(sessionId);
Map<String, Object> response = new HashMap<>();
@@ -221,12 +268,17 @@ public class MobileScannerController {
description =
"Download a file that was uploaded to a session. File is automatically deleted after download.")
@ApiResponse(responseCode = "200", description = "File downloaded successfully")
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
@ApiResponse(responseCode = "404", description = "File or session not found")
public ResponseEntity<Resource> downloadFile(
@Parameter(description = "Session ID", required = true) @PathVariable String sessionId,
@Parameter(description = "Filename to download", required = true) @PathVariable
String filename) {
if (!applicationProperties.getSystem().isEnableMobileScanner()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
try {
Path filePath = mobileScannerService.getFile(sessionId, filename);
@@ -268,10 +320,16 @@ public class MobileScannerController {
summary = "Delete a session",
description = "Manually delete a session and all its uploaded files")
@ApiResponse(responseCode = "200", description = "Session deleted successfully")
@ApiResponse(responseCode = "403", description = "Mobile scanner feature not enabled")
public ResponseEntity<Map<String, Object>> deleteSession(
@Parameter(description = "Session ID to delete", required = true) @PathVariable
String sessionId) {
ResponseEntity<Map<String, Object>> featureCheck = checkFeatureEnabled();
if (featureCheck != null) {
return featureCheck;
}
mobileScannerService.deleteSession(sessionId);
return ResponseEntity.ok(
@@ -15,6 +15,7 @@ import java.util.zip.ZipOutputStream;
import javax.imageio.ImageIO;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
@@ -326,6 +327,8 @@ public class OCRController {
try (PDDocument document = pdfDocumentFactory.load(tempInputFile.toFile())) {
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(
true); // Enable subsampling to reduce memory usage
int pageCount = document.getNumberOfPages();
for (int pageNum = 0; pageNum < pageCount; pageNum++) {
@@ -415,7 +418,7 @@ public class OCRController {
}
// Merge all pages into final PDF
merger.mergeDocuments(null);
merger.mergeDocuments(IOUtils.createTempFileOnlyStreamCache());
// Copy final output to the expected location
Files.copy(
@@ -65,113 +65,114 @@ public class PageNumbersController {
}
}
PDDocument document = pdfDocumentFactory.load(file);
float marginFactor =
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
case "small" -> 0.02f;
case "large" -> 0.05f;
case "x-large" -> 0.075f;
case "medium" -> 0.035f;
default -> 0.035f;
};
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
pagesToNumber = "all";
}
if (customText == null || customText.isEmpty()) {
customText = "{n}";
}
final String baseFilename =
Filenames.toSimpleFileName(file.getOriginalFilename())
.replaceFirst("[.][^.]+$", "");
List<Integer> pagesToNumberList =
GeneralUtils.parsePageList(pagesToNumber.split(","), document.getNumberOfPages());
// Clamp position to 1..9 (1 = top-left, 9 = bottom-right)
int pos = Math.max(1, Math.min(9, position));
for (int i : pagesToNumberList) {
PDPage page = document.getPage(i);
PDRectangle pageSize = page.getMediaBox();
String text =
customText
.replace("{n}", String.valueOf(pageNumber))
.replace("{total}", String.valueOf(document.getNumberOfPages()))
.replace(
"{filename}",
GeneralUtils.removeExtension(
Filenames.toSimpleFileName(
file.getOriginalFilename())));
PDType1Font currentFont =
switch (fontType == null ? "" : fontType.toLowerCase(Locale.ROOT)) {
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
try (PDDocument document = pdfDocumentFactory.load(file)) {
float marginFactor =
switch (customMargin == null ? "" : customMargin.toLowerCase(Locale.ROOT)) {
case "small" -> 0.02f;
case "large" -> 0.05f;
case "x-large" -> 0.075f;
case "medium" -> 0.035f;
default -> 0.035f;
};
// Text dimensions and font metrics
float textWidth = currentFont.getStringWidth(text) / 1000f * fontSize;
float ascent = currentFont.getFontDescriptor().getAscent() / 1000f * fontSize;
float descent = currentFont.getFontDescriptor().getDescent() / 1000f * fontSize;
// Derive column/row in range 1..3 (1 = left/top, 2 = center/middle, 3 = right/bottom)
int col = ((pos - 1) % 3) + 1; // 1 = left, 2 = center, 3 = right
int row = ((pos - 1) / 3) + 1; // 1 = top, 2 = middle, 3 = bottom
// Anchor coordinates with margin
float leftX = pageSize.getLowerLeftX() + marginFactor * pageSize.getWidth();
float midX = pageSize.getLowerLeftX() + pageSize.getWidth() / 2f;
float rightX = pageSize.getUpperRightX() - marginFactor * pageSize.getWidth();
float botY = pageSize.getLowerLeftY() + marginFactor * pageSize.getHeight();
float midY = pageSize.getLowerLeftY() + pageSize.getHeight() / 2f;
float topY = pageSize.getUpperRightY() - marginFactor * pageSize.getHeight();
// Horizontal alignment: left = anchor, center = centered, right = right-aligned
float x =
switch (col) {
case 1 -> leftX;
case 2 -> midX - textWidth / 2f;
default -> rightX - textWidth;
};
// Vertical alignment (baseline!):
// top = align text top at topY,
// middle = optical middle using ascent/descent,
// bottom = baseline at botY
float y =
switch (row) {
case 1 -> topY - ascent;
case 2 -> midY - (ascent + descent) / 2f;
default -> botY;
};
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
contentStream.beginText();
contentStream.setFont(currentFont, fontSize);
contentStream.setNonStrokingColor(color);
contentStream.newLineAtOffset(x, y);
contentStream.showText(text);
contentStream.endText();
if (pagesToNumber == null || pagesToNumber.isEmpty()) {
pagesToNumber = "all";
}
if (customText == null || customText.isEmpty()) {
customText = "{n}";
}
pageNumber++;
List<Integer> pagesToNumberList =
GeneralUtils.parsePageList(
pagesToNumber.split(","), document.getNumberOfPages());
// Clamp position to 1..9 (1 = top-left, 9 = bottom-right)
int pos = Math.max(1, Math.min(9, position));
for (int i : pagesToNumberList) {
PDPage page = document.getPage(i);
PDRectangle pageSize = page.getMediaBox();
String text =
customText
.replace("{n}", String.valueOf(pageNumber))
.replace("{total}", String.valueOf(document.getNumberOfPages()))
.replace(
"{filename}",
GeneralUtils.removeExtension(
Filenames.toSimpleFileName(
file.getOriginalFilename())));
PDType1Font currentFont =
switch (fontType == null ? "" : fontType.toLowerCase(Locale.ROOT)) {
case "courier" -> new PDType1Font(Standard14Fonts.FontName.COURIER);
case "times" -> new PDType1Font(Standard14Fonts.FontName.TIMES_ROMAN);
default -> new PDType1Font(Standard14Fonts.FontName.HELVETICA);
};
// Text dimensions and font metrics
float textWidth = currentFont.getStringWidth(text) / 1000f * fontSize;
float ascent = currentFont.getFontDescriptor().getAscent() / 1000f * fontSize;
float descent = currentFont.getFontDescriptor().getDescent() / 1000f * fontSize;
// Derive column/row in range 1..3 (1 = left/top, 2 = center/middle, 3 =
// right/bottom)
int col = ((pos - 1) % 3) + 1; // 1 = left, 2 = center, 3 = right
int row = ((pos - 1) / 3) + 1; // 1 = top, 2 = middle, 3 = bottom
// Anchor coordinates with margin
float leftX = pageSize.getLowerLeftX() + marginFactor * pageSize.getWidth();
float midX = pageSize.getLowerLeftX() + pageSize.getWidth() / 2f;
float rightX = pageSize.getUpperRightX() - marginFactor * pageSize.getWidth();
float botY = pageSize.getLowerLeftY() + marginFactor * pageSize.getHeight();
float midY = pageSize.getLowerLeftY() + pageSize.getHeight() / 2f;
float topY = pageSize.getUpperRightY() - marginFactor * pageSize.getHeight();
// Horizontal alignment: left = anchor, center = centered, right = right-aligned
float x =
switch (col) {
case 1 -> leftX;
case 2 -> midX - textWidth / 2f;
default -> rightX - textWidth;
};
// Vertical alignment (baseline!):
// top = align text top at topY,
// middle = optical middle using ascent/descent,
// bottom = baseline at botY
float y =
switch (row) {
case 1 -> topY - ascent;
case 2 -> midY - (ascent + descent) / 2f;
default -> botY;
};
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
contentStream.beginText();
contentStream.setFont(currentFont, fontSize);
contentStream.setNonStrokingColor(color);
contentStream.newLineAtOffset(x, y);
contentStream.showText(text);
contentStream.endText();
}
pageNumber++;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(),
GeneralUtils.generateFilename(
file.getOriginalFilename(), "_page_numbers_added.pdf"));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
return WebResponseUtils.bytesToWebResponse(
baos.toByteArray(),
GeneralUtils.generateFilename(
file.getOriginalFilename(), "_page_numbers_added.pdf"));
}
}
@@ -7,7 +7,10 @@ import java.awt.print.Printable;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Arrays;
import java.util.Locale;
@@ -77,11 +80,19 @@ public class PrintFileController {
log.info("Selected Printer: {}", selectedService.getName());
if (MediaType.APPLICATION_PDF_VALUE.equals(contentType)) {
try (PDDocument document = Loader.loadPDF(file.getBytes())) {
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(selectedService);
job.setPageable(new PDFPageable(document));
job.print();
// Use Stream-to-File pattern: write to temp file first, then load from file
Path tempFile = Files.createTempFile("print-", ".pdf");
try {
Files.copy(
file.getInputStream(), tempFile, StandardCopyOption.REPLACE_EXISTING);
try (PDDocument document = Loader.loadPDF(tempFile.toFile())) {
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(selectedService);
job.setPageable(new PDFPageable(document));
job.print();
}
} finally {
Files.deleteIfExists(tempFile);
}
} else if (contentType.startsWith("image/")) {
try (var inputStream = file.getInputStream()) {
@@ -463,7 +463,13 @@ public class ScannerEffectController {
PDPage newPage = new PDPage(new PDRectangle(page.origW, page.origH));
document.addPage(newPage);
try (PDPageContentStream contentStream = new PDPageContentStream(document, newPage)) {
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
newPage,
PDPageContentStream.AppendMode.OVERWRITE,
true,
true)) {
PDImageXObject pdImage = LosslessFactory.createFromImage(document, page.image);
contentStream.drawImage(
pdImage, page.offsetX, page.offsetY, page.drawW, page.drawH);
@@ -134,60 +134,65 @@ public class StampController {
};
// Load the input PDF
PDDocument document = pdfDocumentFactory.load(pdfFile);
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
List<Integer> pageNumbers = request.getPageNumbersList(document, true);
for (int pageIndex : pageNumbers) {
int zeroBasedIndex = pageIndex - 1;
if (zeroBasedIndex >= 0 && zeroBasedIndex < document.getNumberOfPages()) {
PDPage page = document.getPage(zeroBasedIndex);
PDRectangle pageSize = page.getMediaBox();
float margin = marginFactor * (pageSize.getWidth() + pageSize.getHeight()) / 2;
for (int pageIndex : pageNumbers) {
int zeroBasedIndex = pageIndex - 1;
if (zeroBasedIndex >= 0 && zeroBasedIndex < document.getNumberOfPages()) {
PDPage page = document.getPage(zeroBasedIndex);
PDRectangle pageSize = page.getMediaBox();
float margin = marginFactor * (pageSize.getWidth() + pageSize.getHeight()) / 2;
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true);
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(opacity);
contentStream.setGraphicsStateParameters(graphicsState);
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(opacity);
contentStream.setGraphicsStateParameters(graphicsState);
if ("text".equalsIgnoreCase(stampType)) {
addTextStamp(
contentStream,
stampText,
document,
page,
rotation,
position,
fontSize,
alphabet,
overrideX,
overrideY,
margin,
customColor);
} else if ("image".equalsIgnoreCase(stampType)) {
addImageStamp(
contentStream,
stampImage,
document,
page,
rotation,
position,
fontSize,
overrideX,
overrideY,
margin);
if ("text".equalsIgnoreCase(stampType)) {
addTextStamp(
contentStream,
stampText,
document,
page,
rotation,
position,
fontSize,
alphabet,
overrideX,
overrideY,
margin,
customColor);
} else if ("image".equalsIgnoreCase(stampType)) {
addImageStamp(
contentStream,
stampImage,
document,
page,
rotation,
position,
fontSize,
overrideX,
overrideY,
margin);
}
contentStream.close();
}
contentStream.close();
}
// Return the stamped PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_stamped.pdf"));
}
// Return the stamped PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_stamped.pdf"));
}
private void addTextStamp(
@@ -139,11 +139,12 @@ public class CertSignController {
signatureOptions.setPage(pageNumber);
doc.addSignature(signature, instance, signatureOptions);
doc.saveIncremental(output);
}
} else {
doc.addSignature(signature, instance);
doc.saveIncremental(output);
}
doc.saveIncremental(output);
} catch (Exception e) {
ExceptionUtils.logException("PDF signing", e);
}
@@ -3,6 +3,8 @@ package stirling.software.SPDF.controller.api.security;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@@ -14,7 +16,8 @@ import java.util.regex.Pattern;
import org.apache.pdfbox.cos.COSInputStream;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.io.RandomAccessReadBuffer;
import org.apache.pdfbox.io.RandomAccessRead;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
import org.apache.pdfbox.pdmodel.*;
import org.apache.pdfbox.pdmodel.common.PDMetadata;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
@@ -198,10 +201,20 @@ public class GetInfoOnPDF {
return false;
}
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
document.save(baos);
// Use Stream-to-File pattern: save to temp file instead of loading into memory
// This prevents OutOfMemoryError on large PDFs
Path tempFile = null;
try {
tempFile = Files.createTempFile("preflight-", ".pdf");
try (RandomAccessReadBuffer source = new RandomAccessReadBuffer(baos.toByteArray())) {
// Save document to temp file (avoids loading entire document into memory)
try (var outputStream = Files.newOutputStream(tempFile)) {
document.save(outputStream);
}
// Use RandomAccessReadBufferedFile for efficient file-based reading
// This avoids Windows file locking issues that occur with memory-mapped files
try (RandomAccessRead source = new RandomAccessReadBufferedFile(tempFile.toFile())) {
PreflightParser parser = new PreflightParser(source);
try (PDDocument parsedDocument = parser.parse()) {
@@ -243,6 +256,19 @@ public class GetInfoOnPDF {
log.debug("IOException during PDF/A validation: {}", e.getMessage());
} catch (Exception e) {
log.debug("Unexpected error during PDF/A validation: {}", e.getMessage());
} finally {
// Explicitly clean up temp file to prevent disk exhaustion
// This must be in finally block to ensure cleanup even on exceptions
if (tempFile != null) {
try {
Files.deleteIfExists(tempFile);
} catch (IOException e) {
log.warn(
"Failed to delete temp file during PDF/A validation cleanup: {}",
tempFile,
e);
}
}
}
return false;
@@ -42,25 +42,17 @@ public class PasswordController {
MultipartFile fileInput = request.getFileInput();
String password = request.getPassword();
PDDocument document;
try {
document = pdfDocumentFactory.load(fileInput, password);
} catch (IOException e) {
// Handle password errors specifically
if (ExceptionUtils.isPasswordError(e)) {
throw ExceptionUtils.createPdfPasswordException(e);
}
throw ExceptionUtils.handlePdfException(e);
}
try {
try (PDDocument document = pdfDocumentFactory.load(fileInput, password)) {
document.setAllSecurityToBeRemoved(true);
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
fileInput.getOriginalFilename(), "_password_removed.pdf"));
} catch (IOException e) {
document.close();
// Handle password errors specifically
if (ExceptionUtils.isPasswordError(e)) {
throw ExceptionUtils.createPdfPasswordException(e);
}
ExceptionUtils.logException("password removal", e);
throw ExceptionUtils.handlePdfException(e);
}
@@ -91,31 +83,36 @@ public class PasswordController {
boolean preventPrinting = Boolean.TRUE.equals(request.getPreventPrinting());
boolean preventPrintingFaithful = Boolean.TRUE.equals(request.getPreventPrintingFaithful());
PDDocument document = pdfDocumentFactory.load(fileInput);
AccessPermission ap = new AccessPermission();
ap.setCanAssembleDocument(!preventAssembly);
ap.setCanExtractContent(!preventExtractContent);
ap.setCanExtractForAccessibility(!preventExtractForAccessibility);
ap.setCanFillInForm(!preventFillInForm);
ap.setCanModify(!preventModify);
ap.setCanModifyAnnotations(!preventModifyAnnotations);
ap.setCanPrint(!preventPrinting);
ap.setCanPrintFaithful(!preventPrintingFaithful);
StandardProtectionPolicy spp = new StandardProtectionPolicy(ownerPassword, password, ap);
try (PDDocument document = pdfDocumentFactory.load(fileInput)) {
AccessPermission ap = new AccessPermission();
ap.setCanAssembleDocument(!preventAssembly);
ap.setCanExtractContent(!preventExtractContent);
ap.setCanExtractForAccessibility(!preventExtractForAccessibility);
ap.setCanFillInForm(!preventFillInForm);
ap.setCanModify(!preventModify);
ap.setCanModifyAnnotations(!preventModifyAnnotations);
ap.setCanPrint(!preventPrinting);
ap.setCanPrintFaithful(!preventPrintingFaithful);
StandardProtectionPolicy spp =
new StandardProtectionPolicy(ownerPassword, password, ap);
if (!"".equals(ownerPassword) || !"".equals(password)) {
spp.setEncryptionKeyLength(keyLength);
}
spp.setPermissions(ap);
document.protect(spp);
if ((ownerPassword != null && ownerPassword.length() > 0)
|| (password != null && password.length() > 0)) {
spp.setEncryptionKeyLength(keyLength);
}
spp.setPermissions(ap);
document.protect(spp);
if ("".equals(ownerPassword) && "".equals(password))
if ((ownerPassword == null || ownerPassword.length() == 0)
&& (password == null || password.length() == 0))
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
fileInput.getOriginalFilename(), "_permissions.pdf"));
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
fileInput.getOriginalFilename(), "_permissions.pdf"));
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(fileInput.getOriginalFilename(), "_passworded.pdf"));
fileInput.getOriginalFilename(), "_passworded.pdf"));
}
}
}
@@ -41,28 +41,29 @@ public class RemoveCertSignController {
throws Exception {
MultipartFile pdf = request.getFileInput();
// Load the PDF document
PDDocument document = pdfDocumentFactory.load(pdf);
// Load the PDF document with proper resource management
try (PDDocument document = pdfDocumentFactory.load(pdf)) {
// Get the document catalog
PDDocumentCatalog catalog = document.getDocumentCatalog();
// Get the document catalog
PDDocumentCatalog catalog = document.getDocumentCatalog();
// Get the AcroForm
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
// Remove signature fields safely
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(field -> field instanceof PDSignatureField)
.toList();
// Get the AcroForm
PDAcroForm acroForm = catalog.getAcroForm();
if (acroForm != null) {
// Remove signature fields safely
List<PDField> fieldsToRemove =
acroForm.getFields().stream()
.filter(field -> field instanceof PDSignatureField)
.toList();
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(fieldsToRemove, false);
if (!fieldsToRemove.isEmpty()) {
acroForm.flatten(fieldsToRemove, false);
}
}
// Return the modified PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_unsigned.pdf"));
}
// Return the modified PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(pdf.getOriginalFilename(), "_unsigned.pdf"));
}
}
@@ -67,39 +67,40 @@ public class SanitizeController {
boolean removeLinks = Boolean.TRUE.equals(request.getRemoveLinks());
boolean removeFonts = Boolean.TRUE.equals(request.getRemoveFonts());
PDDocument document = pdfDocumentFactory.load(inputFile, true);
if (removeJavaScript) {
sanitizeJavaScript(document);
try (PDDocument document = pdfDocumentFactory.load(inputFile, true)) {
if (removeJavaScript) {
sanitizeJavaScript(document);
}
if (removeEmbeddedFiles) {
sanitizeEmbeddedFiles(document);
}
if (removeXMPMetadata) {
sanitizeXMPMetadata(document);
}
if (removeMetadata) {
sanitizeDocumentInfoMetadata(document);
}
if (removeLinks) {
sanitizeLinks(document);
}
if (removeFonts) {
sanitizeFonts(document);
}
// Save the sanitized document to output stream
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
document.save(outputStream);
return WebResponseUtils.bytesToWebResponse(
outputStream.toByteArray(),
GeneralUtils.generateFilename(
inputFile.getOriginalFilename(), "_sanitized.pdf"));
}
if (removeEmbeddedFiles) {
sanitizeEmbeddedFiles(document);
}
if (removeXMPMetadata) {
sanitizeXMPMetadata(document);
}
if (removeMetadata) {
sanitizeDocumentInfoMetadata(document);
}
if (removeLinks) {
sanitizeLinks(document);
}
if (removeFonts) {
sanitizeFonts(document);
}
// Save the sanitized document to output stream
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
document.save(outputStream);
document.close();
return WebResponseUtils.bytesToWebResponse(
outputStream.toByteArray(),
GeneralUtils.generateFilename(inputFile.getOriginalFilename(), "_sanitized.pdf"));
}
private static void sanitizeJavaScript(PDDocument document) throws IOException {
@@ -98,60 +98,67 @@ public class WatermarkController {
String customColor = request.getCustomColor();
boolean convertPdfToImage = Boolean.TRUE.equals(request.getConvertPDFToImage());
// Load the input PDF
PDDocument document = pdfDocumentFactory.load(pdfFile);
// Load the input PDF with proper resource management
try (PDDocument document = pdfDocumentFactory.load(pdfFile)) {
// Create a page in the document
for (PDPage page : document.getPages()) {
// Create a page in the document
for (PDPage page : document.getPages()) {
// Get the page's content stream
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
// Get the page's content stream
PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true);
// Set transparency
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(opacity);
contentStream.setGraphicsStateParameters(graphicsState);
// Set transparency
PDExtendedGraphicsState graphicsState = new PDExtendedGraphicsState();
graphicsState.setNonStrokingAlphaConstant(opacity);
contentStream.setGraphicsStateParameters(graphicsState);
if ("text".equalsIgnoreCase(watermarkType)) {
addTextWatermark(
contentStream,
watermarkText,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize,
alphabet,
customColor);
} else if ("image".equalsIgnoreCase(watermarkType)) {
addImageWatermark(
contentStream,
watermarkImage,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize);
if ("text".equalsIgnoreCase(watermarkType)) {
addTextWatermark(
contentStream,
watermarkText,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize,
alphabet,
customColor);
} else if ("image".equalsIgnoreCase(watermarkType)) {
addImageWatermark(
contentStream,
watermarkImage,
document,
page,
rotation,
widthSpacer,
heightSpacer,
fontSize);
}
}
}
// Close the content stream
contentStream.close();
if (convertPdfToImage) {
try (PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document)) {
// Return the watermarked PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
convertedPdf,
GeneralUtils.generateFilename(
pdfFile.getOriginalFilename(), "_watermarked.pdf"));
}
} else {
// Return the watermarked PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(
pdfFile.getOriginalFilename(), "_watermarked.pdf"));
}
}
if (convertPdfToImage) {
PDDocument convertedPdf = PdfUtils.convertPdfToPdfImage(document);
document.close();
document = convertedPdf;
}
// Return the watermarked PDF as a response
return WebResponseUtils.pdfDocToWebResponse(
document,
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_watermarked.pdf"));
}
private void addTextWatermark(
@@ -15,84 +15,106 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.util.HtmlUtils;
import org.springframework.web.util.JavaScriptUtils;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
@Controller
public class ReactRoutingController {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
@Value("${server.servlet.context-path:/}")
private String contextPath;
private String cachedIndexHtml;
private String cachedCallbackHtml;
private boolean indexHtmlExists = false;
private boolean useExternalIndexHtml = false;
private boolean loggedMissingIndex = false;
@PostConstruct
public void init() {
log.info("Static files custom path: {}", InstallationPathConfig.getStaticPath());
// Always initialize callback HTML (used for OAuth desktop flow)
this.cachedCallbackHtml = buildCallbackHtml();
// Check for external index.html first (customFiles/static/)
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
log.debug("Checking for custom index.html at: {}", externalIndexPath);
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
log.info("Using custom index.html from: {}", externalIndexPath);
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = true;
return;
} catch (IOException e) {
log.warn("Failed to load custom index.html, falling back to classpath", e);
}
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = true;
return;
}
// Fall back to classpath index.html
ClassPathResource resource = new ClassPathResource("static/index.html");
if (resource.exists()) {
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
} catch (IOException e) {
// Failed to cache, will process on each request
log.warn("Failed to cache index.html", e);
this.indexHtmlExists = false;
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
return;
}
// Neither external nor classpath index.html exists - cache fallback once
this.cachedIndexHtml = buildFallbackHtml();
this.indexHtmlExists = true;
this.useExternalIndexHtml = false;
this.loggedMissingIndex = true;
log.warn(
"index.html not found in classpath or custom path; using lightweight fallback page");
}
private String processIndexHtml() {
try {
Resource resource = getIndexHtmlResource();
if (!resource.exists()) {
if (!loggedMissingIndex) {
log.warn("index.html not found, using lightweight fallback page");
loggedMissingIndex = true;
}
return buildFallbackHtml();
}
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// Replace %BASE_URL% with the actual context path for base href
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
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 + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
html = html.replace("</head>", contextPathScript + "</head>");
return html;
}
} catch (Exception ex) {
if (!loggedMissingIndex) {
log.warn("index.html not found, using lightweight fallback page", ex);
loggedMissingIndex = true;
}
return buildFallbackHtml();
}
}
private String processIndexHtml() throws IOException {
Resource resource = getIndexHtmlResource();
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// Replace %BASE_URL% with the actual context path for base href
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
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 + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
html = html.replace("</head>", contextPathScript + "</head>");
return html;
}
}
private Resource getIndexHtmlResource() throws IOException {
private Resource getIndexHtmlResource() {
// Check external location first
Path externalIndexPath = Paths.get(InstallationPathConfig.getStaticPath(), "index.html");
if (Files.exists(externalIndexPath) && Files.isReadable(externalIndexPath)) {
@@ -106,12 +128,28 @@ public class ReactRoutingController {
@GetMapping(
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) {
try {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
}
// Fallback: process on each request (dev mode or cache failed)
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
} catch (Exception ex) {
log.error("Failed to serve index.html, returning fallback", ex);
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(buildFallbackHtml());
}
// Fallback: process on each request (dev mode or cache failed)
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
}
@GetMapping(value = "/auth/callback", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveAuthCallback(HttpServletRequest request) {
return serveIndexHtml(request);
}
@GetMapping(value = "/auth/callback/tauri", produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveTauriAuthCallback(HttpServletRequest request) {
// cachedCallbackHtml is always initialized in @PostConstruct
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedCallbackHtml);
}
@GetMapping(
@@ -126,4 +164,309 @@ public class ReactRoutingController {
throws IOException {
return serveIndexHtml(request);
}
private String buildFallbackHtml() {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
// Escape for HTML attribute context
String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl);
// Escape for JavaScript string context
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Stirling PDF</title>
<script>
// Minimal handler for SSO callback when index.html is missing (desktop fallback)
(function() {
const baseUrl = '%s';
window.STIRLING_PDF_API_BASE_URL = baseUrl;
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const serverUrl = %s;
if (token) {
// Extract nonce from URL to send back to desktop app for validation
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
console.log('[Fallback Auth] Token received, sending to desktop app via deep link');
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
return;
} catch (_) {
// ignore deep link errors
}
}
// No redirect to avoid loops when index.html is missing
})();
</script>
</head>
<body>
<p>Stirling PDF is running.</p>
</body>
</html>
"""
.formatted(escapedBaseUrlHtml, escapedBaseUrlJs, serverUrl);
}
private String buildCallbackHtml() {
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
// Escape for HTML attribute context
String escapedBaseUrlHtml = HtmlUtils.htmlEscape(baseUrl);
// Escape for JavaScript string context
String escapedBaseUrlJs = JavaScriptUtils.javaScriptEscape(baseUrl);
String serverUrl = "(window.location.origin + '" + escapedBaseUrlJs + "')";
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<base href="%s" />
<title>Authentication Complete</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
text-align: center;
padding: 50px 20px;
background: #f5f5f5;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.container {
background: #ffffff;
border-radius: 12px;
padding: 40px;
max-width: 420px;
width: 100%%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #e5e7eb;
color: #1a1a1a;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
color: #2e7d32;
}
.icon.error {
color: #d32f2f;
}
h1 {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
color: #1a1a1a;
}
p {
color: #666;
line-height: 1.6;
font-size: 15px;
}
.error-details {
background: #ffebee;
border: 1px solid #ffcdd2;
padding: 16px;
border-radius: 8px;
margin-top: 20px;
font-size: 14px;
color: #c62828;
word-break: break-word;
text-align: left;
line-height: 1.5;
display: none;
}
@media (prefers-color-scheme: dark) {
body {
background: #1a1a1a;
color: #e0e0e0;
}
.container {
background: #2d2d2d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
border-color: #374151;
color: #e5e7eb;
}
.icon {
color: #66bb6a;
}
.icon.error {
color: #ef5350;
}
h1 {
color: #f5f5f5;
}
p {
color: #b0b0b0;
}
.error-details {
background: #3d2020;
border: 1px solid #5d3030;
color: #ef9a9a;
}
}
@media (max-width: 480px) {
body {
padding: 20px 16px;
}
.container {
padding: 32px 24px;
}
h1 {
font-size: 20px;
}
.icon {
font-size: 40px;
}
}
</style>
<script>
(function() {
const run = () => {
const hashParams = new URLSearchParams(window.location.hash.replace(/^#/, ''));
const searchParams = new URLSearchParams(window.location.search);
const token = hashParams.get('access_token') || hashParams.get('token') || searchParams.get('access_token');
const errorCode = searchParams.get('errorOAuth')
|| searchParams.get('error')
|| hashParams.get('error')
|| searchParams.get('error_description')
|| hashParams.get('error_description');
const serverUrl = %s;
const iconEl = document.getElementById('auth-icon');
const titleEl = document.getElementById('auth-title');
const messageEl = document.getElementById('auth-message');
const detailsEl = document.getElementById('auth-error-details');
const sendDeepLink = (type, value, key) => {
try {
const encodedValue = encodeURIComponent(value || '');
const encodedServer = encodeURIComponent(serverUrl);
const hashKey = key || 'access_token';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#${hashKey}=${encodedValue}&type=${type}`;
window.location.href = deepLink;
} catch (_) {
// ignore deep link errors
}
};
const showError = (message, details) => {
if (iconEl) {
iconEl.textContent = '✗';
iconEl.classList.add('error');
}
if (titleEl) {
titleEl.textContent = 'Authentication failed';
}
if (messageEl) {
messageEl.textContent = message;
}
if (detailsEl && details) {
detailsEl.textContent = details;
detailsEl.style.display = 'block';
}
};
if (token) {
// Extract nonce from URL to send back to desktop app for validation
// (System browser doesn't have access to desktop app's sessionStorage)
const nonceFromUrl = hashParams.get('nonce') || searchParams.get('nonce');
console.log('[Auth Callback] Token received, sending to desktop app via deep link');
// Send token + nonce via deep link to desktop app
// Desktop app will validate nonce before accepting token
setTimeout(() => {
try {
const encodedToken = encodeURIComponent(token);
const encodedServer = encodeURIComponent(serverUrl);
const encodedNonce = nonceFromUrl ? encodeURIComponent(nonceFromUrl) : '';
const deepLink = `stirlingpdf://auth/sso-complete?server=${encodedServer}#access_token=${encodedToken}&nonce=${encodedNonce}&type=sso-selfhosted`;
window.location.href = deepLink;
} catch (err) {
console.error('[Auth Callback] Failed to trigger deep link:', err);
}
}, 200);
return;
}
if (errorCode) {
const isCancelled = errorCode === 'access_denied';
sendDeepLink('sso-error', errorCode, 'error');
showError(
isCancelled
? 'Authentication was cancelled. You can close this window and return to the app.'
: 'Authentication was not successful. You can close this window and return to the app.',
errorCode
);
return;
}
showError(
'Authentication did not complete. You can close this window and try again.',
'missing_token'
);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', run);
} else {
run();
}
})();
</script>
</head>
<body>
<div class="container">
<div class="icon" id="auth-icon">&#10003;</div>
<h1 id="auth-title">Authentication complete</h1>
<p id="auth-message">You can close this window and return to Stirling PDF.</p>
<div class="error-details" id="auth-error-details"></div>
</div>
</body>
</html>
"""
.formatted(escapedBaseUrlHtml, serverUrl);
}
}
@@ -80,7 +80,7 @@ import stirling.software.common.util.RegexPatternUtils;
* <pre>{@code
* // In controllers/services - use ExceptionUtils to create typed exceptions:
* try {
* PDDocument doc = PDDocument.load(file);
* PDDocument doc = Loader.loadPDF(file);
* } catch (IOException e) {
* throw ExceptionUtils.createPdfCorruptedException("during load", e);
* }
@@ -1117,6 +1117,34 @@ public class GlobalExceptionHandler {
return handleBaseApp((BaseAppException) processedException, request);
}
// Check if this is a NoSuchFileException (temp file was deleted prematurely)
if (ex instanceof java.nio.file.NoSuchFileException) {
log.error(
"Temporary file not found at {}: {}",
request.getRequestURI(),
ex.getMessage(),
ex);
String message =
getLocalizedMessage(
"error.tempFileNotFound.detail",
"The temporary file was not found. This may indicate a processing error or cleanup issue. Please try again.");
String title =
getLocalizedMessage("error.tempFileNotFound.title", "Temporary File Not Found");
ProblemDetail problemDetail =
createBaseProblemDetail(HttpStatus.INTERNAL_SERVER_ERROR, message, request);
problemDetail.setType(URI.create("https://stirlingpdf.com/errors/temp-file-not-found"));
problemDetail.setTitle(title);
problemDetail.setProperty("title", title);
problemDetail.setProperty("errorCode", "E999");
problemDetail.setProperty(
"hint.1",
"This error usually occurs when temporary files are cleaned up before processing completes.");
problemDetail.setProperty("hint.2", "Try submitting your request again.");
return new ResponseEntity<>(problemDetail, HttpStatus.INTERNAL_SERVER_ERROR);
}
log.error("IO error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
String message =
@@ -1161,9 +1189,19 @@ public class GlobalExceptionHandler {
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleGenericException(
Exception ex, HttpServletRequest request) {
Exception ex, HttpServletRequest request, HttpServletResponse response) {
log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex);
// If response is already committed (e.g., during streaming), we can't send an error
// response
// Log the error and return null to let Spring handle it gracefully
if (response.isCommitted()) {
log.warn(
"Cannot send error response because response is already committed for URI: {}",
request.getRequestURI());
return null; // Spring will handle gracefully
}
String userMessage =
getLocalizedMessage(
"error.unexpected",
@@ -204,11 +204,65 @@ public class VeraPDFService {
detectedFlavours = detectionParser.getFlavours();
}
// For PDF/A flavours, we need to validate first to check if PDF/A identification exists in
// XMP
// If declaredFlavour is PDF/A, do a quick validation to check for PDF/A identification
// schema
boolean hasValidPdfaMetadata = false;
if (isPdfaFlavour(declaredFlavour)) {
try (PDFAParser quickParser =
Foundries.defaultInstance()
.createParser(new ByteArrayInputStream(pdfBytes), declaredFlavour)) {
PDFAValidator quickValidator =
Foundries.defaultInstance().createValidator(declaredFlavour, false);
ValidationResult quickResult = quickValidator.validate(quickParser);
// Check if the document has the PDF/A Identification extension schema (clause
// 6.7.11, test 1)
// OR if it lacks XMP metadata entirely (clause 6.7.2, test 1)
// If either of these errors is present, the document is NOT a declared PDF/A
hasValidPdfaMetadata = true;
for (TestAssertion assertion : quickResult.getTestAssertions()) {
if (assertion.getStatus() == TestAssertion.Status.FAILED
&& assertion.getRuleId() != null) {
String clause = assertion.getRuleId().getClause();
int testNumber = assertion.getRuleId().getTestNumber();
// Missing XMP metadata entirely (clause 6.7.2, test 1)
if ("6.7.2".equals(clause) && testNumber == 1) {
hasValidPdfaMetadata = false;
log.debug(
"Document lacks XMP metadata (6.7.2): {}",
assertion.getMessage());
break;
}
// Missing PDF/A identification schema in XMP (clause 6.7.11, test 1)
if ("6.7.11".equals(clause) && testNumber == 1) {
hasValidPdfaMetadata = false;
log.debug(
"Document lacks PDF/A identification in XMP (6.7.11): {}",
assertion.getMessage());
break;
}
}
}
} catch (Exception e) {
log.debug("Error checking for PDF/A identification: {}", e.getMessage());
hasValidPdfaMetadata = false;
}
}
List<PDFAFlavour> flavoursToValidate = new ArrayList<>();
boolean hasPdfaDeclaration = isPdfaFlavour(declaredFlavour);
boolean hasPdfaDeclaration = isPdfaFlavour(declaredFlavour) && hasValidPdfaMetadata;
if (declaredFlavour != null) {
flavoursToValidate.add(declaredFlavour);
boolean isDeclaredPdfa = isPdfaFlavour(declaredFlavour);
if (isDeclaredPdfa && hasPdfaDeclaration) {
flavoursToValidate.add(declaredFlavour);
} else if (!isDeclaredPdfa) {
flavoursToValidate.add(declaredFlavour);
}
}
for (PDFAFlavour flavour : detectedFlavours) {
@@ -220,6 +220,9 @@ public class Type3FontLibrary {
}
private byte[] loadResourceBytes(String location) throws IOException {
if (location == null || location.isBlank()) {
throw new IOException("Resource location is null or blank");
}
String resolved = resolveLocation(location);
Resource resource = resourceLoader.getResource(resolved);
if (!resource.exists()) {
@@ -0,0 +1,20 @@
package stirling.software.SPDF.service.telegram;
/**
* Enumeration representing different feedback types for Telegram service.
*
* @since 2.2.x
*/
public enum FeedbackEnum {
/** Indicates that the provided document is not valid. */
NO_VALID_DOCUMENT,
/** Represents a generic error message. */
ERROR_MESSAGE,
/** Indicates that an error occurred during processing. */
ERROR_PROCESSING,
/** Indicates that processing is ongoing. */
PROCESSING
}
@@ -0,0 +1,524 @@
package stirling.software.SPDF.service.telegram;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import org.telegram.telegrambots.bots.TelegramLongPollingBot;
import org.telegram.telegrambots.meta.TelegramBotsApi;
import org.telegram.telegrambots.meta.api.methods.GetFile;
import org.telegram.telegrambots.meta.api.methods.send.SendDocument;
import org.telegram.telegrambots.meta.api.methods.send.SendMessage;
import org.telegram.telegrambots.meta.api.objects.Chat;
import org.telegram.telegrambots.meta.api.objects.Document;
import org.telegram.telegrambots.meta.api.objects.File;
import org.telegram.telegrambots.meta.api.objects.InputFile;
import org.telegram.telegrambots.meta.api.objects.Message;
import org.telegram.telegrambots.meta.api.objects.Update;
import org.telegram.telegrambots.meta.api.objects.User;
import org.telegram.telegrambots.meta.exceptions.TelegramApiException;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
/**
* Telegram bot that processes incoming files through a defined pipeline.
*
* @since 2.2.x
*/
@Slf4j
@Component
@ConditionalOnProperty(prefix = "telegram", name = "enabled", havingValue = "true")
public class TelegramPipelineBot extends TelegramLongPollingBot {
private static final String CHAT_PRIVATE = "private";
private static final String CHAT_GROUP = "group";
private static final String CHAT_SUPERGROUP = "supergroup";
private static final String CHAT_CHANNEL = "channel";
private static final Set<String> SUPPORTED_CHAT_TYPES =
Set.of(CHAT_PRIVATE, CHAT_GROUP, CHAT_SUPERGROUP, CHAT_CHANNEL);
private static final Set<String> ALLOWED_MIME_TYPES = Set.of("application/pdf");
private final Object pipelinePollMonitor = new Object();
private final ApplicationProperties.Telegram telegramProperties;
private final RuntimePathConfig runtimePathConfig;
private final TelegramBotsApi telegramBotsApi;
public TelegramPipelineBot(
ApplicationProperties applicationProperties,
RuntimePathConfig runtimePathConfig,
TelegramBotsApi telegramBotsApi) {
super(applicationProperties.getTelegram().getBotToken());
this.telegramProperties = applicationProperties.getTelegram();
this.runtimePathConfig = runtimePathConfig;
this.telegramBotsApi = telegramBotsApi;
}
@PostConstruct
public void register() {
if (StringUtils.isAnyBlank(getBotUsername(), getBotToken())) {
log.warn("Telegram bot disabled because botToken or botUsername is not configured");
return;
}
try {
telegramBotsApi.registerBot(this);
log.info("Telegram pipeline bot registered as {}", getBotUsername());
} catch (TelegramApiException e) {
log.error("Failed to register Telegram bot", e);
}
}
@Override
public void onUpdateReceived(Update update) {
Message message = extractMessage(update);
if (message == null) {
return;
}
Chat chat = message.getChat();
if (chat == null || !isSupportedChatType(chat.getType())) {
log.info(
"Ignoring message {}, unsupported chat type {}",
message.getMessageId(),
chat != null ? chat.getType() : "null");
return;
}
if (!isAuthorized(message, chat)) {
return;
}
if (update.hasMessage() && update.getMessage().hasText()) {
String messageText = update.getMessage().getText();
long chatId = update.getMessage().getChatId();
if ("/start".equals(messageText)) {
sendMessage(
chatId,
"""
Welcome to the SPDF Telegram Bot!
To get started, please send me a PDF document that you would like to process.
Make sure the document is in PDF format.
Once I receive your document, I'll begin processing it through the pipeline.
""");
return;
}
}
if (message.hasDocument()) {
handleIncomingFile(message);
return;
}
if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chat.getType())) {
sendMessage(
chat.getId(),
"No valid file found in the message. Please send a document to process.");
}
}
private boolean feedback(FeedbackEnum feedbackEnum, String chatType) {
return switch (feedbackEnum) {
case NO_VALID_DOCUMENT ->
switch (chatType) {
case CHAT_CHANNEL ->
telegramProperties.getFeedback().getChannel().getNoValidDocument();
case CHAT_PRIVATE ->
telegramProperties.getFeedback().getUser().getNoValidDocument();
default -> true;
};
case ERROR_MESSAGE ->
switch (chatType) {
case CHAT_CHANNEL ->
telegramProperties.getFeedback().getChannel().getErrorMessage();
case CHAT_PRIVATE ->
telegramProperties.getFeedback().getUser().getErrorMessage();
default -> true;
};
case ERROR_PROCESSING ->
switch (chatType) {
case CHAT_CHANNEL ->
telegramProperties.getFeedback().getChannel().getErrorProcessing();
case CHAT_PRIVATE ->
telegramProperties.getFeedback().getUser().getErrorProcessing();
default -> true;
};
case PROCESSING ->
switch (chatType) {
case CHAT_CHANNEL ->
telegramProperties.getFeedback().getChannel().getProcessing();
case CHAT_PRIVATE ->
telegramProperties.getFeedback().getUser().getProcessing();
default -> true;
};
default -> true;
};
}
// ---------------------------
// Message Extraction / Chat Type
// ---------------------------
private Message extractMessage(Update update) {
if (update.hasMessage()) return update.getMessage();
if (update.hasChannelPost()) return update.getChannelPost();
return null;
}
private boolean isSupportedChatType(String type) {
return type != null && SUPPORTED_CHAT_TYPES.contains(type);
}
// ---------------------------
// Authorization
// ---------------------------
private boolean isAuthorized(Message message, Chat chat) {
if (!(telegramProperties.getEnableAllowUserIDs()
|| telegramProperties.getEnableAllowChannelIDs())) {
return true;
}
return switch (chat.getType()) {
case CHAT_CHANNEL -> checkChannelAccess(message, chat);
case CHAT_PRIVATE -> checkUserAccess(message, chat);
case CHAT_GROUP, CHAT_SUPERGROUP -> true; // groups allowed by default
default -> false;
};
}
private boolean checkUserAccess(Message message, Chat chat) {
if (!telegramProperties.getEnableAllowUserIDs()) return true;
User from = message.getFrom();
List<Long> allow = telegramProperties.getAllowUserIDs();
if (allow.isEmpty()) {
log.warn("No allowed user IDs configured - allowing all users.");
return true;
}
if (from == null || !allow.contains(from.getId())) {
log.info(
"Rejecting user {} in private chat {}",
from != null ? from.getId() : "unknown",
chat.getId());
if (feedback(FeedbackEnum.ERROR_MESSAGE, chat.getType())) {
sendMessage(chat.getId(), "You are not authorized to use this bot.");
}
return false;
}
return true;
}
private boolean checkChannelAccess(Message message, Chat chat) {
if (!telegramProperties.getEnableAllowChannelIDs()) return true;
Chat senderChat = message.getSenderChat();
List<Long> allow = telegramProperties.getAllowChannelIDs();
if (allow.isEmpty()) {
log.warn("No allowed channel IDs configured - allowing all channels.");
return true;
}
if (senderChat == null || !allow.contains(senderChat.getId())) {
log.info(
"Rejecting channel {} in chat {}",
senderChat != null ? senderChat.getId() : "unknown",
chat.getId());
if (feedback(FeedbackEnum.ERROR_MESSAGE, chat.getType())) {
sendMessage(chat.getId(), "This channel is not authorized to use this bot.");
}
return false;
}
return true;
}
// ---------------------------
// File Handling
// ---------------------------
private void handleIncomingFile(Message message) {
Long chatId = message.getChatId();
Document doc = message.getDocument();
String chatType = message.getChat().getType();
if (doc == null) {
if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chatType)) {
sendMessage(chatId, "No document found.");
}
return;
}
if (doc.getMimeType() != null
&& !ALLOWED_MIME_TYPES.contains(doc.getMimeType().toLowerCase())) {
if (feedback(FeedbackEnum.NO_VALID_DOCUMENT, chatType)) {
sendMessage(
chatId,
"Unsupported MIME type: "
+ doc.getMimeType()
+ "\nAllowed: "
+ String.join(", ", ALLOWED_MIME_TYPES));
}
return;
}
if (!hasJsonConfig(chatId)) {
if (feedback(FeedbackEnum.ERROR_PROCESSING, chatType)) {
sendMessage(
chatId,
"No JSON configuration file found in the pipeline inbox folder. Please"
+ " contact the administrator.");
}
return;
}
try {
if (!CHAT_CHANNEL.equalsIgnoreCase(chatType)
&& feedback(FeedbackEnum.PROCESSING, chatType)) {
sendMessage(chatId, "File received. Starting processing...");
}
PipelineFileInfo info = downloadMessageFile(message);
List<Path> outputs = waitForPipelineOutputs(info);
if (outputs.isEmpty()) {
if (feedback(FeedbackEnum.ERROR_PROCESSING, chatType)) {
sendMessage(
chatId,
"No results were found in the pipeline output folder. Check"
+ " configuration.");
}
return;
}
for (Path file : outputs) {
SendDocument out = new SendDocument();
out.setChatId(chatId);
out.setDocument(new InputFile(file.toFile(), file.getFileName().toString()));
execute(out);
}
} catch (TelegramApiException e) {
log.error("Telegram API error", e);
if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) {
sendMessage(chatId, "Telegram API error occurred.");
}
} catch (IOException e) {
log.error("IO error", e);
if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) {
sendMessage(chatId, "An IO error occurred.");
}
} catch (Exception e) {
log.error("Unexpected error", e);
if (feedback(FeedbackEnum.ERROR_MESSAGE, chatType)) {
sendMessage(chatId, "Unexpected error occurred.");
}
}
}
private PipelineFileInfo downloadMessageFile(Message message)
throws TelegramApiException, IOException {
Document document = message.getDocument();
String filename = document.getFileName();
String name =
StringUtils.isNotBlank(filename) ? filename : document.getFileUniqueId() + ".bin";
return downloadFile(document.getFileId(), name, message);
}
private PipelineFileInfo downloadFile(String fileId, String originalName, Message message)
throws TelegramApiException, IOException {
Long chatId = message.getChatId();
Path inboxFolder = getInboxFolder(chatId);
GetFile getFile = new GetFile(fileId);
File tgFile = execute(getFile);
if (tgFile == null || StringUtils.isBlank(tgFile.getFilePath())) {
throw new IOException("Telegram did not return a file path.");
}
URL url = buildDownloadUrl(tgFile.getFilePath());
String base = FilenameUtils.getBaseName(originalName) + "-" + UUID.randomUUID();
String ext = FilenameUtils.getExtension(originalName);
String outFile = ext.isBlank() ? base : base + "." + ext;
Path targetFile = inboxFolder.resolve(outFile);
try (InputStream in = url.openStream()) {
Files.copy(in, targetFile);
}
log.info("Saved Telegram file {} to {}", originalName, targetFile);
return new PipelineFileInfo(targetFile, base, Instant.now());
}
private URL buildDownloadUrl(String filePath) throws MalformedURLException {
try {
URI uri =
new URI(
"https",
"api.telegram.org",
"/file/bot" + getBotToken() + "/" + filePath,
null);
return uri.toURL();
} catch (URISyntaxException e) {
throw new MalformedURLException("Failed to build Telegram download URL");
} catch (MalformedURLException e) {
MalformedURLException sanitized =
new MalformedURLException("Failed to build Telegram download URL");
sanitized.initCause(e);
throw sanitized;
}
}
// ---------------------------
// Inbox-Ordner & JSON-Check
// ---------------------------
private Path getInboxFolder(Long chatId) throws IOException {
Path baseInbox =
Paths.get(
runtimePathConfig.getPipelineWatchedFoldersPath(),
telegramProperties.getPipelineInboxFolder());
Files.createDirectories(baseInbox);
Path inboxFolder =
telegramProperties.getCustomFolderSuffix()
? baseInbox.resolve(chatId.toString())
: baseInbox;
Files.createDirectories(inboxFolder);
return inboxFolder;
}
private boolean hasJsonConfig(Long chatId) {
try {
Path inboxFolder = getInboxFolder(chatId);
try (Stream<Path> s = Files.list(inboxFolder)) {
return s.anyMatch(p -> p.toString().endsWith(".json"));
}
} catch (IOException e) {
log.error("Failed to check JSON config for chat {}", chatId, e);
return false;
}
}
// ---------------------------
// Pipeline polling
// ---------------------------
private List<Path> waitForPipelineOutputs(PipelineFileInfo info) throws IOException {
Path finishedDir = Paths.get(runtimePathConfig.getPipelineFinishedFoldersPath());
Files.createDirectories(finishedDir);
Instant start = info.savedAt();
Duration timeout = Duration.ofSeconds(telegramProperties.getProcessingTimeoutSeconds());
Duration poll = Duration.ofMillis(telegramProperties.getPollingIntervalMillis());
List<Path> results = new ArrayList<>();
while (Duration.between(start, Instant.now()).compareTo(timeout) <= 0) {
try (Stream<Path> s = Files.list(finishedDir)) {
results =
s.filter(Files::isRegularFile)
.filter(path -> matchesBaseName(info.uniqueBaseName(), path))
.filter(path -> isNewerThan(path, start))
.sorted(Comparator.comparing(Path::toString))
.toList();
}
if (!results.isEmpty()) {
break;
}
synchronized (pipelinePollMonitor) {
try {
pipelinePollMonitor.wait(poll.toMillis());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
}
return results;
}
private boolean matchesBaseName(String base, Path file) {
return file.getFileName().toString().contains(base);
}
private boolean isNewerThan(Path path, Instant since) {
try {
return Files.getLastModifiedTime(path).toInstant().isAfter(since);
} catch (IOException e) {
log.info("Could not read modification time for {}", path);
return false;
}
}
// ---------------------------
// Messaging
// ---------------------------
private void sendMessage(Long chatId, String text) {
if (chatId == null) return;
SendMessage msg = new SendMessage();
msg.setChatId(chatId);
msg.setText(text);
try {
execute(msg);
} catch (TelegramApiException e) {
log.warn("Failed to send message to {}", chatId, e);
}
}
private record PipelineFileInfo(Path originalFile, String uniqueBaseName, Instant savedAt) {}
@Override
public String getBotUsername() {
return telegramProperties.getBotUsername();
}
@Override
public String getBotToken() {
return telegramProperties.getBotToken();
}
}
@@ -16,30 +16,30 @@ security:
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
initialLogin:
username: '' # initial username for the first login
password: '' # initial password for the first login
username: "" # initial username for the first login
password: "" # initial password for the first login
oauth2:
enabled: false # set to 'true' to enable login (Note: enableLogin must also be 'true' for this to work)
client:
keycloak:
issuer: '' # URL of the Keycloak realm's OpenID Connect Discovery endpoint
clientId: '' # client ID for Keycloak OAuth2
clientSecret: '' # client secret for Keycloak OAuth2
issuer: "" # URL of the Keycloak realm's OpenID Connect Discovery endpoint
clientId: "" # client ID for Keycloak OAuth2
clientSecret: "" # client secret for Keycloak OAuth2
scopes: openid, profile, email # scopes for Keycloak OAuth2
useAsUsername: preferred_username # field to use as the username for Keycloak OAuth2. Available options are: [email | name | given_name | family_name | preferred_name]
google:
clientId: '' # client ID for Google OAuth2
clientSecret: '' # client secret for Google OAuth2
clientId: "" # client ID for Google OAuth2
clientSecret: "" # client secret for Google OAuth2
scopes: email, profile # scopes for Google OAuth2
useAsUsername: email # field to use as the username for Google OAuth2. Available options are: [email | name | given_name | family_name]
github:
clientId: '' # client ID for GitHub OAuth2
clientSecret: '' # client secret for GitHub OAuth2
clientId: "" # client ID for GitHub OAuth2
clientSecret: "" # client secret for GitHub OAuth2
scopes: read:user # scope for GitHub OAuth2
useAsUsername: login # field to use as the username for GitHub OAuth2. Available options are: [email | login | name]
issuer: '' # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint
clientId: '' # client ID from your Provider
clientSecret: '' # client secret from your Provider
issuer: "" # set to any Provider that supports OpenID Connect Discovery (/.well-known/openid-configuration) endpoint
clientId: "" # client ID from your Provider
clientSecret: "" # client secret from your Provider
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
useAsUsername: email # default is 'email'; custom fields can be used as the username
@@ -47,14 +47,14 @@ security:
provider: google # set this to your OAuth Provider's name, e.g., 'google' or 'keycloak'
saml2:
enabled: false # Only enabled for paid enterprise clients (enterpriseEdition.enabled must be true)
provider: '' # The name of your Provider
provider: "" # The name of your Provider
autoCreateUser: true # set to 'true' to allow auto-creation of non-existing users
blockRegistration: false # set to 'true' to deny login with SSO without prior registration by an admin
registrationId: stirling # The name of your Service Provider (SP) app name. Should match the name in the path for your SSO & SLO URLs
idpMetadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # The uri for your Provider's metadata
idpSingleLoginUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/sso/saml # The URL for initiating SSO. Provided by your Provider
idpSingleLogoutUrl: https://dev-XXXXXXXX.okta.com/app/dev-XXXXXXXX_stirlingpdf_1/externalKey/slo/saml # The URL for initiating SLO. Provided by your Provider
idpIssuer: '' # The ID of your Provider
idpIssuer: "" # The ID of your Provider
idpCert: classpath:okta.cert # The certificate your Provider will use to authenticate your app's SAML authentication requests. Provided by your Provider
privateKey: classpath:saml-private-key.key # Your private key. Generated from your keypair
spCert: classpath:saml-public-cert.crt # Your signing certificate. Generated from your keypair
@@ -110,21 +110,45 @@ mail:
enableInvites: false # set to 'true' to enable email invites for user management (requires mail.enabled and security.enableLogin)
host: smtp.example.com # SMTP server hostname
port: 587 # SMTP server port
username: '' # SMTP server username
password: '' # SMTP server password
from: '' # sender email address
username: "" # SMTP server username
password: "" # SMTP server password
from: "" # sender email address
startTlsEnable: true # enable STARTTLS (explicit TLS upgrade after connecting) when supported by the SMTP server
startTlsRequired: false # require STARTTLS; connection fails if the upgrade command is not supported
sslEnable: false # enable SSL/TLS wrapper for implicit TLS (typically used with port 465)
sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
sslTrust: "" # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
sslCheckServerIdentity: false # enable hostname verification when using SSL/TLS
telegram:
enabled: false # set to 'true' to enable Telegram bot integration
botToken: "" # Telegram bot token obtained from BotFather
botUsername: "" # Telegram bot username (without @)
pipelineInboxFolder: telegram # Name of the pipeline inbox folder for Telegram uploads
customFolderSuffix: true # set to 'true' to allow users to specify custom target folders via UserID
enableAllowUserIDs: true # set to 'true' to restrict access to specific Telegram user IDs
allowUserIDs: [] # List of allowed Telegram user IDs (e.g. [123456789, 987654321]). Leave empty to allow all users.
enableAllowChannelIDs: true # set to 'true' to restrict access to specific Telegram channel IDs
allowChannelIDs: [] # List of allowed Telegram channel IDs (e.g. [-1001234567890, -1009876543210]). Leave empty to allow all channels.
processingTimeoutSeconds: 180 # Maximum time in seconds to wait for processing a Telegram request
pollingIntervalMillis: 2000 # Interval in milliseconds between polling for new messages
feedback:
channel:
noValidDocument: true # set to 'false' to hide/suppress feedback messages in channels (to avoid spam)
errorProcessing: true # set to 'false' to hide/suppress feedback messages in channels (to avoid spam)
errorMessage: true # set to 'false' to hide/suppress error messages in channels (to avoid spam)
processing: true # set to 'false' to hide/suppress processing messages in channels (to avoid spam)
user:
noValidDocument: true # set to 'false' to hide/suppress feedback messages to users (to avoid spam)
errorProcessing: true # set to 'false' to hide/suppress feedback messages to users (to avoid spam)
errorMessage: true # set to 'false' to hide/suppress error messages to users (to avoid spam)
processing: true # set to 'false' to hide/suppress processing messages to users (to avoid spam)
legal:
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
privacyPolicy: https://www.stirling.com/legal/privacy-policy # URL to the privacy policy of your application (e.g. https://example.com/privacy). Empty string to disable or filename to load from local file in static folder
accessibilityStatement: '' # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: '' # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
impressum: '' # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
accessibilityStatement: "" # URL to the accessibility statement of your application (e.g. https://example.com/accessibility). Empty string to disable or filename to load from local file in static folder
cookiePolicy: "" # URL to the cookie policy of your application (e.g. https://example.com/cookie). Empty string to disable or filename to load from local file in static folder
impressum: "" # URL to the impressum of your application (e.g. https://example.com/impressum). Empty string to disable or filename to load from local file in static folder
system:
defaultLocale: en-US # set the default language (e.g. 'de-DE', 'fr-FR', etc)
@@ -143,9 +167,14 @@ system:
disableSanitize: false # set to true to disable Sanitize HTML; (can lead to injections in HTML)
maxDPI: 500 # Maximum allowed DPI for PDF to image conversion
corsAllowedOrigins: [] # List of allowed origins for CORS (e.g. ['http://localhost:5173', 'https://app.example.com']). Leave empty to disable CORS. For local development with frontend on port 5173, add 'http://localhost:5173'
backendUrl: '' # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: '' # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
backendUrl: "" # Backend base URL for SAML/OAuth/API callbacks (e.g. 'http://localhost:8080' for dev, 'https://api.example.com' for production). REQUIRED for SSO authentication to work correctly. This is where your IdP will send SAML responses and OAuth callbacks. Leave empty to default to 'http://localhost:8080' in development.
frontendUrl: "" # Frontend URL for invite email links (e.g. 'https://app.example.com'). Optional - if not set, will use backendUrl. This is the URL users click in invite emails.
enableMobileScanner: false # Enable mobile phone QR code upload feature. Requires frontendUrl to be configured.
mobileScannerSettings:
convertToPdf: true # Automatically convert uploaded images to PDF format. If false, images are kept as-is.
imageResolution: full # Image resolution for mobile uploads: 'full' (original size) or 'reduced' (max 1200px on longest side). Only applies when convertToPdf is true.
pageFormat: A4 # Page format for converted PDFs: 'keep' (original image dimensions), 'A4' (A4 page size), or 'letter' (US Letter page size). Only applies when convertToPdf is true.
stretchToFit: false # Whether to stretch images to fill the entire page (may distort aspect ratio). If false, images are centered with preserved aspect ratio. Only applies when convertToPdf is true.
serverCertificate:
enabled: true # Enable server-side certificate for "Sign with Stirling-PDF" option
organizationName: Stirling-PDF # Organization name for generated certificates
@@ -157,14 +186,14 @@ system:
level: MEDIUM # Security level: MAX (whitelist only), MEDIUM (block internal networks), OFF (no restrictions)
allowedDomains: [] # Whitelist of allowed domains (e.g. ['cdn.example.com', 'images.google.com'])
blockedDomains: [] # Additional domains to block (e.g. ['evil.com', 'malicious.org'])
internalTlds: ['.local', '.internal', '.corp', '.home'] # Block domains with these TLD patterns
internalTlds: [".local", ".internal", ".corp", ".home"] # Block domains with these TLD patterns
blockPrivateNetworks: true # Block RFC 1918 private networks (10.x.x.x, 192.168.x.x, 172.16-31.x.x)
blockLocalhost: true # Block localhost and loopback addresses (127.x.x.x, ::1)
blockLinkLocal: true # Block link-local addresses (169.254.x.x, fe80::/10)
blockCloudMetadata: true # Block cloud provider metadata endpoints (169.254.169.254)
datasource:
enableCustomDatabase: false # Enterprise users ONLY, set this property to 'true' if you would like to use your own custom database configuration
customDatabaseUrl: '' # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used
customDatabaseUrl: "" # eg jdbc:postgresql://localhost:5432/postgres, set the url for your own custom database connection. If provided, the type, hostName, port and name are not necessary and will not be used
username: postgres # set the database username
password: postgres # set the database password
type: postgresql # the type of the database to set (e.g. 'h2', 'postgresql')
@@ -173,29 +202,29 @@ system:
name: postgres # set the name of your database. Should match the name of the database you create
customPaths:
pipeline:
watchedFoldersDir: '' # Defaults to /pipeline/watchedFolders
finishedFoldersDir: '' # Defaults to /pipeline/finishedFolders
watchedFoldersDir: "" # Defaults to /pipeline/watchedFolders
finishedFoldersDir: "" # Defaults to /pipeline/finishedFolders
operations:
weasyprint: '' # Defaults to /opt/venv/bin/weasyprint
unoconvert: '' # Defaults to /opt/venv/bin/unoconvert
calibre: '' # Defaults to /usr/bin/ebook-convert
ocrmypdf: '' # Defaults to /usr/bin/ocrmypdf
soffice: '' # Defaults to /usr/bin/soffice
fileUploadLimit: '' # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB".
weasyprint: "" # Defaults to /opt/venv/bin/weasyprint
unoconvert: "" # Defaults to /opt/venv/bin/unoconvert
calibre: "" # Defaults to /usr/bin/ebook-convert
ocrmypdf: "" # Defaults to /usr/bin/ocrmypdf
soffice: "" # Defaults to /usr/bin/soffice
fileUploadLimit: "" # Defaults to "". No limit when string is empty. Set a number, between 0 and 999, followed by one of the following strings to set a limit. "KB", "MB", "GB".
tempFileManagement:
baseTmpDir: '' # Defaults to java.io.tmpdir/stirling-pdf
libreofficeDir: '' # Defaults to tempFileManagement.baseTmpDir/libreoffice
systemTempDir: '' # Only used if cleanupSystemTemp is true
baseTmpDir: "" # Defaults to java.io.tmpdir/stirling-pdf
libreofficeDir: "" # Defaults to tempFileManagement.baseTmpDir/libreoffice
systemTempDir: "" # Only used if cleanupSystemTemp is true
prefix: stirling-pdf- # Prefix for temp file names
maxAgeHours: 24 # Maximum age in hours before temp files are cleaned up
cleanupIntervalMinutes: 30 # How often to run cleanup (in minutes)
startupCleanup: true # Clean up old temp files on startup
cleanupSystemTemp: false # Whether to clean broader system temp directory
databaseBackup:
cron: '0 0 0 * * ?' # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
ui:
appNameNavbar: '' # name displayed on the navigation bar
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.
@@ -7,6 +7,13 @@
"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",
"moduleVersion": "1.5.23",
"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",
@@ -14,6 +21,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.23",
"moduleLicense": "GNU Lesser General Public License",
"moduleLicenseUrl": "http://www.gnu.org/licenses/old-licenses/lgpl-2.1.html"
},
{
"moduleName": "com.adobe.xmp:xmpcore",
"moduleUrl": "https://www.adobe.com/devnet/xmp/library/eula-xmp-library-java.html",
@@ -98,6 +112,20 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.jaxrs:jackson-jaxrs-base",
"moduleUrl": "https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-base",
"moduleVersion": "2.19.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider",
"moduleUrl": "https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-json-provider",
"moduleVersion": "2.19.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.module:jackson-module-jakarta-xmlbind-annotations",
"moduleUrl": "https://github.com/FasterXML/jackson-modules-base",
@@ -105,6 +133,13 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.module:jackson-module-jaxb-annotations",
"moduleUrl": "https://github.com/FasterXML/jackson-modules-base",
"moduleVersion": "2.19.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.fasterxml.jackson.module:jackson-module-parameter-names",
"moduleUrl": "https://github.com/FasterXML/jackson-modules-java8/jackson-module-parameter-names",
@@ -132,6 +167,20 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.github.bbottema:jetbrains-runtime-annotations",
"moduleUrl": "https://github.com/bbottema/jetbrains-runtime-nullability-annotations",
"moduleVersion": "1.0.2",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.github.bbottema:rtf-to-html",
"moduleUrl": "http:///github.com/bbottema/rtf-to-html",
"moduleVersion": "1.1.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.github.ben-manes.caffeine:caffeine",
"moduleUrl": "https://github.com/ben-manes/caffeine",
@@ -172,13 +221,6 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.code.gson:gson",
"moduleUrl": "https://github.com/google/gson",
"moduleVersion": "2.13.2",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
@@ -186,13 +228,6 @@
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.errorprone:error_prone_annotations",
"moduleUrl": "https://errorprone.info/error_prone_annotations",
"moduleVersion": "2.41.0",
"moduleLicense": "Apache 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.google.guava:failureaccess",
"moduleUrl": "https://github.com/google/guava/",
@@ -223,7 +258,7 @@
{
"moduleName": "com.google.zxing:core",
"moduleUrl": "https://github.com/zxing/zxing/core",
"moduleVersion": "3.5.3",
"moduleVersion": "3.5.4",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -295,6 +330,13 @@
"moduleLicense": "Apache 2",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.pivovarit:throwing-function",
"moduleUrl": "https://github.com/pivovarit/throwing-function",
"moduleVersion": "1.6.1",
"moduleLicense": "The Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.posthog.java:posthog",
"moduleUrl": "http://github.com/PostHog/posthog-java",
@@ -302,6 +344,20 @@
"moduleLicense": "MIT License",
"moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php"
},
{
"moduleName": "com.sanctionco.jmail:jmail",
"moduleUrl": "https://github.com/RohanNagar/jmail",
"moduleVersion": "1.6.3",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "https://opensource.org/licenses/mit-license.php"
},
{
"moduleName": "com.sun.activation:jakarta.activation",
"moduleUrl": "https://www.eclipse.org",
"moduleVersion": "1.2.2",
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "com.sun.istack:istack-commons-runtime",
"moduleUrl": "https://www.eclipse.org",
@@ -309,69 +365,97 @@
"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-core",
"moduleUrl": "http://www.oracle.com/",
"moduleVersion": "2.3.0.1",
"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",
"moduleVersion": "2.3.9",
"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.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-io",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.common:common-lang",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-batik",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-bmp",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-core",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-jpeg",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-metadata",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-psd",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-tiff",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
{
"moduleName": "com.twelvemonkeys.imageio:imageio-webp",
"moduleVersion": "3.12.0",
"moduleVersion": "3.13.0",
"moduleLicense": "The BSD License",
"moduleLicenseUrl": "https://github.com/haraldk/TwelveMonkeys#license"
},
@@ -522,6 +606,13 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "com.zaxxer:SparseBitSet",
"moduleUrl": "https://github.com/brettwooldridge/SparseBitSet",
"moduleVersion": "1.3",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "commons-beanutils:commons-beanutils",
"moduleUrl": "https://commons.apache.org/proper/commons-beanutils",
@@ -560,7 +651,7 @@
{
"moduleName": "commons-io:commons-io",
"moduleUrl": "https://commons.apache.org/proper/commons-io/",
"moduleVersion": "2.20.0",
"moduleVersion": "2.21.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -612,6 +703,13 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.micrometer:micrometer-core",
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
"moduleVersion": "1.16.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "io.micrometer:micrometer-jakarta9",
"moduleUrl": "https://github.com/micrometer-metrics/micrometer",
@@ -678,42 +776,42 @@
{
"moduleName": "io.swagger.core.v3:swagger-annotations-jakarta",
"moduleUrl": "https://github.com/swagger-api/swagger-core/modules/swagger-annotations",
"moduleVersion": "2.2.36",
"moduleVersion": "2.2.38",
"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.40",
"moduleVersion": "2.2.41",
"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.36",
"moduleVersion": "2.2.38",
"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.40",
"moduleVersion": "2.2.41",
"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.36",
"moduleVersion": "2.2.38",
"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.40",
"moduleVersion": "2.2.41",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
@@ -815,6 +913,13 @@
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "jakarta.ws.rs:jakarta.ws.rs-api",
"moduleUrl": "https://www.eclipse.org/org/foundation/",
"moduleVersion": "3.1.0",
"moduleLicense": "GPL-2.0-with-classpath-exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "jakarta.xml.bind:jakarta.xml.bind-api",
"moduleUrl": "https://www.eclipse.org",
@@ -823,32 +928,25 @@
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "me.friwi:gluegen-rt",
"moduleUrl": "http://jogamp.org/gluegen/www/",
"moduleVersion": "v2.4.0-rc-20210111",
"moduleLicense": "BSD-4 License",
"moduleLicenseUrl": "http://www.spdx.org/licenses/BSD-4-Clause"
"moduleName": "javax.activation:javax.activation-api",
"moduleUrl": "http://www.oracle.com",
"moduleVersion": "1.2.0",
"moduleLicense": "COMMON DEVELOPMENT AND DISTRIBUTION LICENSE (CDDL) Version 1.0",
"moduleLicenseUrl": "https://opensource.org/licenses/CDDL-1.0"
},
{
"moduleName": "me.friwi:jcef-api",
"moduleUrl": "https://bitbucket.org/chromiumembedded/java-cef/",
"moduleVersion": "jcef-1770317+cef-132.3.1+g144febe+chromium-132.0.6834.83",
"moduleLicense": "BSD License",
"moduleLicenseUrl": "https://bitbucket.org/chromiumembedded/java-cef/src/master/LICENSE.txt"
"moduleName": "javax.xml.bind:jaxb-api",
"moduleUrl": "http://www.oracle.com/",
"moduleVersion": "2.3.1",
"moduleLicense": "GPL2 w/ CPE",
"moduleLicenseUrl": "https://oss.oracle.com/licenses/CDDL+GPL-1.1"
},
{
"moduleName": "me.friwi:jcefmaven",
"moduleUrl": "https://github.com/jcefmaven/jcefmaven/",
"moduleVersion": "132.3.1",
"moduleLicense": "Apache-2.0 License",
"moduleLicenseUrl": "https://github.com/jcefmaven/jcefmaven/blob/master/LICENSE"
},
{
"moduleName": "me.friwi:jogl-all",
"moduleUrl": "http://jogamp.org/jogl/www/",
"moduleVersion": "v2.4.0-rc-20210111",
"moduleLicense": "Ubuntu Font Licence 1.0",
"moduleLicenseUrl": "http://font.ubuntu.com/ufl/ubuntu-font-licence-1.0.txt"
"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",
@@ -856,6 +954,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "net.java.dev.stax-utils:stax-utils",
"moduleUrl": "http://java.net/projects/stax-utils/",
"moduleVersion": "20070216",
"moduleLicense": "BSD",
"moduleLicenseUrl": "http://www.opensource.org/licenses/bsd-license.html"
},
{
"moduleName": "net.minidev:accessors-smart",
"moduleUrl": "https://urielch.github.io/",
@@ -886,14 +991,14 @@
{
"moduleName": "org.apache.commons:commons-collections4",
"moduleUrl": "https://commons.apache.org/proper/commons-collections/",
"moduleVersion": "4.5.0",
"moduleLicense": "Apache-2.0",
"moduleVersion": "4.4",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.commons:commons-compress",
"moduleUrl": "https://commons.apache.org/proper/commons-compress/",
"moduleVersion": "1.27.1",
"moduleName": "org.apache.commons:commons-collections4",
"moduleUrl": "https://commons.apache.org/proper/commons-collections/",
"moduleVersion": "4.5.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -914,10 +1019,17 @@
{
"moduleName": "org.apache.commons:commons-lang3",
"moduleUrl": "https://commons.apache.org/proper/commons-lang/",
"moduleVersion": "3.19.0",
"moduleVersion": "3.20.0",
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.commons:commons-math3",
"moduleUrl": "http://commons.apache.org/proper/commons-math/",
"moduleVersion": "3.6.1",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.commons:commons-text",
"moduleUrl": "https://commons.apache.org/proper/commons-text",
@@ -946,6 +1058,13 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.httpcomponents:httpmime",
"moduleUrl": "http://hc.apache.org/httpcomponents-client-ga",
"moduleVersion": "4.5.14",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.logging.log4j:log4j-api",
"moduleVersion": "2.24.3",
@@ -999,6 +1118,20 @@
"moduleLicense": "Apache-2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.poi:poi",
"moduleUrl": "https://poi.apache.org/",
"moduleVersion": "5.2.5",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.poi:poi-scratchpad",
"moduleUrl": "https://poi.apache.org/",
"moduleVersion": "5.2.5",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.apache.santuario:xmlsec",
"moduleUrl": "https://www.apache.org/",
@@ -1057,14 +1190,14 @@
{
"moduleName": "org.bouncycastle:bcpkix-jdk18on",
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
"moduleVersion": "1.82",
"moduleVersion": "1.83",
"moduleLicense": "Bouncy Castle Licence",
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
},
{
"moduleName": "org.bouncycastle:bcprov-jdk18on",
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
"moduleVersion": "1.82",
"moduleVersion": "1.83",
"moduleLicense": "Bouncy Castle Licence",
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
},
@@ -1078,7 +1211,7 @@
{
"moduleName": "org.bouncycastle:bcutil-jdk18on",
"moduleUrl": "https://www.bouncycastle.org/download/bouncy-castle-java/",
"moduleVersion": "1.82",
"moduleVersion": "1.83",
"moduleLicense": "Bouncy Castle Licence",
"moduleLicenseUrl": "https://www.bouncycastle.org/licence.html"
},
@@ -1311,6 +1444,62 @@
"moduleLicense": "Eclipse Public License - Version 2.0",
"moduleLicenseUrl": "https://www.eclipse.org/legal/epl-2.0/"
},
{
"moduleName": "org.glassfish.grizzly:grizzly-framework",
"moduleUrl": "http://www.oracle.com",
"moduleVersion": "4.0.2",
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "org.glassfish.grizzly:grizzly-http",
"moduleUrl": "http://www.oracle.com",
"moduleVersion": "4.0.2",
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "org.glassfish.grizzly:grizzly-http-server",
"moduleUrl": "http://www.oracle.com",
"moduleVersion": "4.0.2",
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "org.glassfish.hk2.external:aopalliance-repackaged",
"moduleUrl": "http://www.oracle.com",
"moduleVersion": "3.0.6",
"moduleLicense": "GPL2 w/ CPE",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "org.glassfish.hk2:hk2-api",
"moduleUrl": "http://www.oracle.com",
"moduleVersion": "3.0.6",
"moduleLicense": "GPL2 w/ CPE",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "org.glassfish.hk2:hk2-locator",
"moduleUrl": "http://www.oracle.com",
"moduleVersion": "3.0.6",
"moduleLicense": "GPL2 w/ CPE",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "org.glassfish.hk2:hk2-utils",
"moduleUrl": "http://www.oracle.com",
"moduleVersion": "3.0.6",
"moduleLicense": "GPL2 w/ CPE",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "org.glassfish.hk2:osgi-resource-locator",
"moduleUrl": "https://www.eclipse.org",
"moduleVersion": "1.0.3",
"moduleLicense": "GPL2 w/ CPE",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "org.glassfish.jaxb:jaxb-core",
"moduleUrl": "https://www.eclipse.org",
@@ -1332,6 +1521,55 @@
"moduleLicense": "GNU General Public License, version 2 with the GNU Classpath Exception",
"moduleLicenseUrl": "https://www.gnu.org/software/classpath/license.html"
},
{
"moduleName": "org.glassfish.jersey.containers:jersey-container-grizzly2-http",
"moduleUrl": "https://www.eclipse.org/org/foundation/",
"moduleVersion": "3.1.11",
"moduleLicense": "jQuery license",
"moduleLicenseUrl": "jquery.org/license"
},
{
"moduleName": "org.glassfish.jersey.core:jersey-client",
"moduleUrl": "https://www.eclipse.org/org/foundation/",
"moduleVersion": "3.1.11",
"moduleLicense": "jQuery license",
"moduleLicenseUrl": "jquery.org/license"
},
{
"moduleName": "org.glassfish.jersey.core:jersey-common",
"moduleUrl": "https://www.eclipse.org/org/foundation/",
"moduleVersion": "3.1.11",
"moduleLicense": "jQuery license",
"moduleLicenseUrl": "jquery.org/license"
},
{
"moduleName": "org.glassfish.jersey.core:jersey-server",
"moduleUrl": "https://www.eclipse.org/org/foundation/",
"moduleVersion": "3.1.11",
"moduleLicense": "jQuery license",
"moduleLicenseUrl": "jquery.org/license"
},
{
"moduleName": "org.glassfish.jersey.ext:jersey-entity-filtering",
"moduleUrl": "https://www.eclipse.org/org/foundation/",
"moduleVersion": "3.1.11",
"moduleLicense": "jQuery license",
"moduleLicenseUrl": "jquery.org/license"
},
{
"moduleName": "org.glassfish.jersey.inject:jersey-hk2",
"moduleUrl": "https://www.eclipse.org/org/foundation/",
"moduleVersion": "3.1.11",
"moduleLicense": "jQuery license",
"moduleLicenseUrl": "jquery.org/license"
},
{
"moduleName": "org.glassfish.jersey.media:jersey-media-json-jackson",
"moduleUrl": "https://www.eclipse.org/org/foundation/",
"moduleVersion": "3.1.11",
"moduleLicense": "jQuery license",
"moduleLicenseUrl": "jquery.org/license"
},
{
"moduleName": "org.hdrhistogram:HdrHistogram",
"moduleUrl": "http://hdrhistogram.github.io/HdrHistogram/",
@@ -1360,6 +1598,13 @@
"moduleLicense": "Apache License 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.javassist:javassist",
"moduleUrl": "https://www.javassist.org/",
"moduleVersion": "3.30.2-GA",
"moduleLicense": "MPL 1.1",
"moduleLicenseUrl": "https://www.mozilla.org/en-US/MPL/1.1/"
},
{
"moduleName": "org.jboss.logging:jboss-logging",
"moduleUrl": "http://www.jboss.org",
@@ -1401,6 +1646,13 @@
"moduleLicense": "Eclipse Public License, Version 2.0",
"moduleLicenseUrl": "https://github.com/locationtech/jts/blob/master/LICENSE_EPLv2.txt"
},
{
"moduleName": "org.mozilla:rhino",
"moduleUrl": "https://developer.mozilla.org/en/Rhino",
"moduleVersion": "1.7.13",
"moduleLicense": "Mozilla Public License, Version 2.0",
"moduleLicenseUrl": "http://www.mozilla.org/MPL/2.0/index.txt"
},
{
"moduleName": "org.openjfx:javafx-base",
"moduleVersion": "21",
@@ -1525,6 +1777,31 @@
"moduleLicense": "BSD-2-Clause",
"moduleLicenseUrl": "https://jdbc.postgresql.org/about/license.html"
},
{
"moduleName": "org.simplejavamail:core-module",
"moduleVersion": "8.12.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.simplejavamail:outlook-message-parser",
"moduleUrl": "https://github.com/bbottema/outlook-message-parser",
"moduleVersion": "1.14.1",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.simplejavamail:outlook-module",
"moduleVersion": "8.12.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.simplejavamail:simple-java-mail",
"moduleVersion": "8.12.6",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.slf4j:jul-to-slf4j",
"moduleUrl": "http://www.slf4j.org",
@@ -1548,19 +1825,19 @@
},
{
"moduleName": "org.springdoc:springdoc-openapi-starter-common",
"moduleVersion": "2.8.13",
"moduleVersion": "2.8.14",
"moduleLicense": "The Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.springdoc:springdoc-openapi-starter-webmvc-api",
"moduleVersion": "2.8.13",
"moduleVersion": "2.8.14",
"moduleLicense": "The Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.springdoc:springdoc-openapi-starter-webmvc-ui",
"moduleVersion": "2.8.13",
"moduleVersion": "2.8.14",
"moduleLicense": "The Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
@@ -1683,13 +1960,6 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-thymeleaf",
"moduleUrl": "https://spring.io/projects/spring-boot",
"moduleVersion": "3.5.7",
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.springframework.boot:spring-boot-starter-validation",
"moduleUrl": "https://spring.io/projects/spring-boot",
@@ -1872,6 +2142,20 @@
"moduleLicense": "Apache License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0"
},
{
"moduleName": "org.telegram:telegrambots",
"moduleUrl": "https://github.com/rubenlagus/TelegramBots",
"moduleVersion": "6.9.7.1",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php"
},
{
"moduleName": "org.telegram:telegrambots-meta",
"moduleUrl": "https://github.com/rubenlagus/TelegramBots",
"moduleVersion": "6.9.7.1",
"moduleLicense": "MIT License",
"moduleLicenseUrl": "http://www.opensource.org/licenses/mit-license.php"
},
{
"moduleName": "org.thymeleaf.extras:thymeleaf-extras-springsecurity5",
"moduleVersion": "3.1.3.RELEASE",
@@ -1890,12 +2174,6 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.thymeleaf:thymeleaf-spring6",
"moduleVersion": "3.1.3.RELEASE",
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "https://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.unbescape:unbescape",
"moduleUrl": "http://www.unbescape.org",
@@ -1903,10 +2181,53 @@
"moduleLicense": "The Apache Software License, Version 2.0",
"moduleLicenseUrl": "http://www.apache.org/licenses/LICENSE-2.0.txt"
},
{
"moduleName": "org.verapdf:core",
"moduleVersion": "1.28.2",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
},
{
"moduleName": "org.verapdf:feature-reporting",
"moduleVersion": "1.28.2",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
},
{
"moduleName": "org.verapdf:metadata-fixer",
"moduleVersion": "1.28.2",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
},
{
"moduleName": "org.verapdf:parser",
"moduleVersion": "1.28.2",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
},
{
"moduleName": "org.verapdf:pdf-model",
"moduleUrl": "https://github.com/veraPDF/veraPDF-model/",
"moduleVersion": "1.28.2",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
},
{
"moduleName": "org.verapdf:validation-model",
"moduleVersion": "1.28.2",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
},
{
"moduleName": "org.verapdf:verapdf-xmp-core",
"moduleVersion": "1.28.2",
"moduleLicense": "Mozilla Public License 2.0 (MPL-2.0)",
"moduleLicenseUrl": "https://opensource.org/licenses/MPL-2.0"
},
{
"moduleName": "org.webjars:swagger-ui",
"moduleUrl": "https://www.webjars.org",
"moduleVersion": "5.28.1",
"moduleVersion": "5.30.1",
"moduleLicense": "Apache-2.0"
},
{
@@ -55,7 +55,7 @@ public class SPDFApplicationTest {
sPDFApplication.init();
assertEquals("http://localhost", SPDFApplication.getStaticBaseUrl());
assertEquals("http://localhost:8080", SPDFApplication.getStaticBaseUrl());
assertEquals("/app", SPDFApplication.getStaticContextPath());
assertEquals("8080", SPDFApplication.getStaticPort());
}
@@ -169,7 +169,10 @@ public class ProprietaryUIDataController {
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
// Only add OAuth2 providers if loginMethod allows it
if (oauth != null
&& oauth.getEnabled()
&& securityProps.isOauth2Active()) { // This checks loginMethod
if (oauth.isSettingsValid()) {
String firstChar = String.valueOf(oauth.getProvider().charAt(0));
String clientName =
@@ -201,6 +204,7 @@ public class ProprietaryUIDataController {
}
SAML2 saml2 = securityProps.getSaml2();
// Only add SAML2 providers if loginMethod allows it
if (securityProps.isSaml2Active() && applicationProperties.getPremium().isEnabled()) {
String samlIdp = saml2.getProvider();
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
@@ -18,6 +18,7 @@ import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider;
import org.springframework.security.saml2.provider.service.registration.RelyingPartyRegistrationRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
@@ -46,6 +47,7 @@ import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.oauth2.TauriAuthorizationRequestResolver;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
@@ -82,6 +84,7 @@ public class SecurityConfiguration {
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final ClientRegistrationRepository clientRegistrationRepository;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
@@ -102,6 +105,7 @@ public class SecurityConfiguration {
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false)
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
@Autowired(required = false) ClientRegistrationRepository clientRegistrationRepository,
stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService) {
this.userDetailsService = userDetailsService;
@@ -120,6 +124,7 @@ public class SecurityConfiguration {
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
this.saml2AuthenticationRequestResolver = saml2AuthenticationRequestResolver;
this.clientRegistrationRepository = clientRegistrationRepository;
this.licenseSettingsService = licenseSettingsService;
}
@@ -290,6 +295,15 @@ public class SecurityConfiguration {
http.oauth2Login(
oauth2 -> {
oauth2.loginPage("/login")
.authorizationEndpoint(
authorizationEndpoint -> {
if (clientRegistrationRepository != null) {
authorizationEndpoint
.authorizationRequestResolver(
new TauriAuthorizationRequestResolver(
clientRegistrationRepository));
}
})
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
@@ -163,12 +163,13 @@ public class AdminSettingsController {
responseCode = "500",
description = "Failed to save settings to configuration file")
})
public ResponseEntity<String> updateSettings(
public ResponseEntity<Map<String, Object>> updateSettings(
@Valid @RequestBody UpdateSettingsRequest request) {
try {
Map<String, Object> settings = request.getSettings();
if (settings == null || settings.isEmpty()) {
return ResponseEntity.badRequest().body("No settings provided to update");
return ResponseEntity.badRequest()
.body(Map.of("error", "No settings provided to update"));
}
int updatedCount = 0;
@@ -178,7 +179,11 @@ public class AdminSettingsController {
if (!isValidSettingKey(key)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(key));
.body(
Map.of(
"error",
"Invalid setting key format: "
+ HtmlUtils.htmlEscape(key)));
}
log.info("Admin updating setting: {} = {}", key, value);
@@ -191,22 +196,26 @@ public class AdminSettingsController {
}
return ResponseEntity.ok(
String.format(
"Successfully updated %d setting(s). Changes will take effect on"
+ " application restart.",
updatedCount));
Map.of(
"message",
String.format(
"Successfully updated %d setting(s). Changes will take effect on"
+ " application restart.",
updatedCount)));
} catch (IOException e) {
log.error("Failed to save settings to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", GENERIC_FILE_ERROR));
} catch (IllegalArgumentException e) {
log.error("Invalid setting key or value: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SETTING);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", GENERIC_INVALID_SETTING));
} catch (Exception e) {
log.error("Unexpected error while updating settings: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(GENERIC_SERVER_ERROR);
.body(Map.of("error", GENERIC_SERVER_ERROR));
}
}
@@ -283,20 +292,23 @@ public class AdminSettingsController {
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to save settings")
})
public ResponseEntity<String> updateSettingsSection(
public ResponseEntity<Map<String, Object>> updateSettingsSection(
@PathVariable String sectionName, @Valid @RequestBody Map<String, Object> sectionData) {
try {
if (sectionData == null || sectionData.isEmpty()) {
return ResponseEntity.badRequest().body("No section data provided to update");
return ResponseEntity.badRequest()
.body(Map.of("error", "No section data provided to update"));
}
if (!isValidSectionName(sectionName)) {
return ResponseEntity.badRequest()
.body(
"Invalid section name: "
+ HtmlUtils.htmlEscape(sectionName)
+ ". Valid sections: "
+ String.join(", ", VALID_SECTION_NAMES));
Map.of(
"error",
"Invalid section name: "
+ HtmlUtils.htmlEscape(sectionName)
+ ". Valid sections: "
+ String.join(", ", VALID_SECTION_NAMES)));
}
// Auto-enable premium features if license key is provided
@@ -317,7 +329,11 @@ public class AdminSettingsController {
if (!isValidSettingKey(fullKey)) {
return ResponseEntity.badRequest()
.body("Invalid setting key format: " + HtmlUtils.htmlEscape(fullKey));
.body(
Map.of(
"error",
"Invalid setting key format: "
+ HtmlUtils.htmlEscape(fullKey)));
}
log.info("Admin updating section setting: {} = {}", fullKey, value);
@@ -331,21 +347,25 @@ public class AdminSettingsController {
String escapedSectionName = HtmlUtils.htmlEscape(sectionName);
return ResponseEntity.ok(
String.format(
"Successfully updated %d setting(s) in section '%s'. Changes will take"
+ " effect on application restart.",
updatedCount, escapedSectionName));
Map.of(
"message",
String.format(
"Successfully updated %d setting(s) in section '%s'. Changes will take"
+ " effect on application restart.",
updatedCount, escapedSectionName)));
} catch (IOException e) {
log.error("Failed to save section settings to file: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(GENERIC_FILE_ERROR);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", GENERIC_FILE_ERROR));
} catch (IllegalArgumentException e) {
log.error("Invalid section data: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(GENERIC_INVALID_SECTION);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", GENERIC_INVALID_SECTION));
} catch (Exception e) {
log.error("Unexpected error while updating section settings: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(GENERIC_SERVER_ERROR);
.body(Map.of("error", GENERIC_SERVER_ERROR));
}
}
@@ -453,7 +473,7 @@ public class AdminSettingsController {
description = "Access denied - Admin role required"),
@ApiResponse(responseCode = "500", description = "Failed to initiate restart")
})
public ResponseEntity<String> restartApplication() {
public ResponseEntity<Map<String, Object>> restartApplication() {
try {
log.warn("Admin initiated application restart");
@@ -465,13 +485,18 @@ public class AdminSettingsController {
log.error("Cannot restart: not running from JAR (likely development mode)");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(
"Restart not available in development mode. Please restart the application manually.");
Map.of(
"error",
"Restart not available in development mode. Please restart the application manually."));
}
if (helperJar == null || !Files.isRegularFile(helperJar)) {
log.error("Cannot restart: restart-helper.jar not found at expected location");
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body("Restart helper not found. Please restart the application manually.");
.body(
Map.of(
"error",
"Restart helper not found. Cannot perform application restart."));
}
// Get current application arguments
@@ -526,12 +551,17 @@ public class AdminSettingsController {
.start();
return ResponseEntity.ok(
"Application restart initiated. The server will be back online shortly.");
Map.of(
"message",
"Application restart initiated. The server will be back online shortly."));
} catch (Exception e) {
log.error("Failed to initiate restart: {}", e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to initiate application restart: " + e.getMessage());
.body(
Map.of(
"error",
"Failed to initiate application restart: " + e.getMessage()));
}
}
@@ -551,6 +581,7 @@ public class AdminSettingsController {
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
case "legal" -> applicationProperties.getLegal();
case "telegram" -> applicationProperties.getTelegram();
default -> null;
};
}
@@ -572,7 +603,8 @@ public class AdminSettingsController {
"processexecutor",
"autoPipeline",
"autopipeline",
"legal");
"legal",
"telegram");
// Pattern to validate safe property paths - only alphanumeric, dots, and underscores
private static final Pattern SAFE_KEY_PATTERN =
@@ -21,6 +21,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
@@ -44,6 +45,7 @@ public class AuthController {
private final JwtServiceInterface jwtService;
private final CustomUserDetailsService userDetailsService;
private final LoginAttemptService loginAttemptService;
private final ApplicationProperties.Security securityProperties;
/**
* Login endpoint - replaces Supabase signInWithPassword
@@ -60,6 +62,17 @@ public class AuthController {
HttpServletRequest httpRequest,
HttpServletResponse response) {
try {
// Check if username/password authentication is allowed
if (!securityProperties.isUserPass()) {
log.warn(
"Username/password login attempted but not allowed by current login method configuration");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(
Map.of(
"error",
"Username/password authentication is not enabled. Please use the configured authentication method."));
}
// Validate input parameters
if (request.getUsername() == null || request.getUsername().trim().isEmpty()) {
log.warn("Login attempt with null or empty username");
@@ -741,31 +741,35 @@ public class UserController {
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/get-api-key")
public ResponseEntity<String> getApiKey(Principal principal) {
public ResponseEntity<Map<String, String>> getApiKey(Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("User not authenticated.");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "User not authenticated."));
}
String username = principal.getName();
String apiKey = userService.getApiKeyForUser(username);
if (apiKey == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("API key not found for user.");
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "API key not found for user."));
}
return ResponseEntity.ok(apiKey);
return ResponseEntity.ok(Map.of("apiKey", apiKey));
}
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/update-api-key")
public ResponseEntity<String> updateApiKey(Principal principal) {
public ResponseEntity<Map<String, String>> updateApiKey(Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("User not authenticated.");
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "User not authenticated."));
}
String username = principal.getName();
User user = userService.refreshApiKeyForUser(username);
String apiKey = user.getApiKey();
if (apiKey == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("API key not found for user.");
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "API key not found for user."));
}
return ResponseEntity.ok(apiKey);
return ResponseEntity.ok(Map.of("apiKey", apiKey));
}
/**
@@ -1,7 +1,11 @@
package stirling.software.proprietary.security.oauth2;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.DisabledException;
import org.springframework.security.authentication.LockedException;
@@ -60,9 +64,66 @@ public class CustomOAuth2AuthenticationFailureHandler
"OAuth2 Authentication error: {}",
errorCode != null ? errorCode : exception.getMessage(),
exception);
getRedirectStrategy().sendRedirect(request, response, "/login?errorOAuth=" + errorCode);
String errorValue = errorCode != null ? errorCode : "oauth2AuthenticationError";
clearRedirectCookie(response);
boolean tauriState = TauriOAuthUtils.isTauriState(request);
String redirectUrl;
if (tauriState) {
String basePath =
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
redirectUrl = basePath;
String stateParam = request.getParameter("state");
if (stateParam != null && !stateParam.isBlank()) {
redirectUrl = appendQueryParam(redirectUrl, "state", stateParam);
// Extract and pass nonce for CSRF validation
String nonce = TauriOAuthUtils.extractNonceFromState(stateParam);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
}
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", errorValue);
} else {
redirectUrl = buildFailureRedirectUrl(request, errorValue);
}
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
return;
}
log.error("Unhandled authentication exception", exception);
super.onAuthenticationFailure(request, response, exception);
}
private String buildFailureRedirectUrl(HttpServletRequest request, String errorValue) {
String contextPath = request.getContextPath();
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
String redirectPath =
cookiePath != null ? cookiePath : TauriOAuthUtils.defaultCallbackPath(contextPath);
if (TauriOAuthUtils.isTauriState(request)) {
redirectPath = appendQueryParam(redirectPath, "tauri", "1");
}
String resolvedPath =
redirectPath.startsWith("/")
? TauriOAuthUtils.normalizeContextPath(contextPath) + redirectPath
: TauriOAuthUtils.normalizeContextPath(contextPath) + "/" + redirectPath;
return appendQueryParam(resolvedPath, "errorOAuth", errorValue);
}
private void clearRedirectCookie(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "")
.path("/")
.sameSite("Lax")
.maxAge(0)
.build();
response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
}
private String appendQueryParam(String path, String key, String value) {
if (path == null || path.isBlank()) {
return path;
}
String separator = path.contains("?") ? "&" : "?";
String encodedKey = URLEncoder.encode(key, StandardCharsets.UTF_8);
String encodedValue = value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
return path + separator + encodedKey + "=" + encodedValue;
}
}
@@ -4,8 +4,6 @@ import static stirling.software.proprietary.security.model.AuthenticationType.OA
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
import java.io.IOException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
@@ -21,7 +19,6 @@ import org.springframework.security.web.authentication.SavedRequestAwareAuthenti
import org.springframework.security.web.savedrequest.SavedRequest;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
@@ -45,9 +42,6 @@ import stirling.software.proprietary.security.service.UserService;
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
private static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
private static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
private final LoginAttemptService loginAttemptService;
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
private final UserService userService;
@@ -210,39 +204,28 @@ public class CustomOAuth2AuthenticationSuccessHandler
resolveOriginFromReferer(request)
.orElseGet(() -> buildOriginFromRequest(request)));
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
// Extract nonce from state for CSRF validation in callback
String nonce = TauriOAuthUtils.extractNonceFromRequest(request);
String url = origin + redirectPath + "#access_token=" + jwt;
if (nonce != null) {
url +=
"&nonce="
+ java.net.URLEncoder.encode(
nonce, java.nio.charset.StandardCharsets.UTF_8);
}
return url;
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
.orElseGet(() -> defaultCallbackPath(contextPath));
}
private Optional<String> extractRedirectPathFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return Optional.empty();
if (TauriOAuthUtils.isTauriState(request)) {
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
}
for (Cookie cookie : cookies) {
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
String value = URLDecoder.decode(cookie.getValue(), StandardCharsets.UTF_8).trim();
if (!value.isEmpty()) {
return Optional.of(value);
}
}
String cookiePath = TauriOAuthUtils.extractRedirectPathFromCookie(request);
if (cookiePath != null && cookiePath.startsWith("/")) {
return cookiePath;
}
return Optional.empty();
}
private String defaultCallbackPath(String contextPath) {
if (contextPath == null
|| contextPath.isBlank()
|| "/".equals(contextPath)
|| "\\".equals(contextPath)) {
return DEFAULT_CALLBACK_PATH;
}
return contextPath + DEFAULT_CALLBACK_PATH;
return TauriOAuthUtils.defaultCallbackPath(contextPath);
}
private Optional<String> resolveForwardedOrigin(HttpServletRequest request) {
@@ -326,7 +309,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
private void clearRedirectCookie(HttpServletResponse response) {
ResponseCookie cookie =
ResponseCookie.from(SPA_REDIRECT_COOKIE, "")
ResponseCookie.from(TauriOAuthUtils.SPA_REDIRECT_COOKIE, "")
.path("/")
.sameSite("Lax")
.maxAge(0)
@@ -0,0 +1,58 @@
package stirling.software.proprietary.security.oauth2;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
import jakarta.servlet.http.HttpServletRequest;
public class TauriAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver {
private static final String TAURI_STATE_PREFIX = "tauri:";
private final OAuth2AuthorizationRequestResolver delegate;
public TauriAuthorizationRequestResolver(
ClientRegistrationRepository clientRegistrationRepository) {
this.delegate =
new DefaultOAuth2AuthorizationRequestResolver(
clientRegistrationRepository, "/oauth2/authorization");
}
@Override
public OAuth2AuthorizationRequest resolve(HttpServletRequest request) {
return customize(request, delegate.resolve(request));
}
@Override
public OAuth2AuthorizationRequest resolve(
HttpServletRequest request, String clientRegistrationId) {
return customize(request, delegate.resolve(request, clientRegistrationId));
}
private OAuth2AuthorizationRequest customize(
HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) {
if (authorizationRequest == null) {
return null;
}
String tauriParam = request.getParameter("tauri");
if (!"1".equals(tauriParam)) {
return authorizationRequest;
}
String state = authorizationRequest.getState();
if (state == null || state.startsWith(TAURI_STATE_PREFIX)) {
return authorizationRequest;
}
// Extract nonce from request for CSRF protection
String nonce = request.getParameter("nonce");
String customState = TAURI_STATE_PREFIX + state;
if (nonce != null && !nonce.isBlank()) {
customState = customState + ":" + nonce;
}
return OAuth2AuthorizationRequest.from(authorizationRequest).state(customState).build();
}
}
@@ -0,0 +1,122 @@
package stirling.software.proprietary.security.oauth2;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
/**
* Utility class for Tauri desktop OAuth flow handling. Centralizes common logic for OAuth state
* management, nonce validation, and callback path construction.
*/
public final class TauriOAuthUtils {
public static final String TAURI_STATE_PREFIX = "tauri:";
public static final String SPA_REDIRECT_COOKIE = "stirling_redirect_path";
public static final String DEFAULT_CALLBACK_PATH = "/auth/callback";
public static final String TAURI_CALLBACK_SUFFIX = "/tauri";
private TauriOAuthUtils() {
// Utility class - prevent instantiation
}
/**
* Extracts nonce from OAuth state parameter for CSRF validation. State format:
* tauri:<original-state>:<nonce>
*
* @param state The state parameter value
* @return The nonce if present, null otherwise
*/
public static String extractNonceFromState(String state) {
if (state == null || !state.startsWith(TAURI_STATE_PREFIX)) {
return null;
}
// Split by ':' and get the last part (nonce)
String[] parts = state.split(":");
if (parts.length >= 3) {
return parts[parts.length - 1];
}
return null;
}
/**
* Extracts nonce from request's state parameter.
*
* @param request The HTTP request
* @return The nonce if present, null otherwise
*/
public static String extractNonceFromRequest(HttpServletRequest request) {
String state = request.getParameter("state");
return extractNonceFromState(state);
}
/**
* Checks if the request has a Tauri state parameter (desktop OAuth flow).
*
* @param request The HTTP request
* @return true if this is a Tauri desktop OAuth flow, false otherwise
*/
public static boolean isTauriState(HttpServletRequest request) {
String state = request.getParameter("state");
return state != null && state.startsWith(TAURI_STATE_PREFIX);
}
/**
* Builds the default callback path for the given context path.
*
* @param contextPath The application context path
* @return The full callback path
*/
public static String defaultCallbackPath(String contextPath) {
if (contextPath == null
|| contextPath.isBlank()
|| "/".equals(contextPath)
|| "\\".equals(contextPath)) {
return DEFAULT_CALLBACK_PATH;
}
return contextPath + DEFAULT_CALLBACK_PATH;
}
/**
* Builds the Tauri-specific callback path (includes /tauri suffix).
*
* @param contextPath The application context path
* @return The full Tauri callback path
*/
public static String defaultTauriCallbackPath(String contextPath) {
return defaultCallbackPath(contextPath) + TAURI_CALLBACK_SUFFIX;
}
/**
* Normalizes context path by removing trailing slashes and handling empty/root paths.
*
* @param contextPath The context path to normalize
* @return Normalized context path (empty string for root)
*/
public static String normalizeContextPath(String contextPath) {
if (contextPath == null || contextPath.isBlank() || "/".equals(contextPath)) {
return "";
}
return contextPath;
}
/**
* Finds the SPA redirect cookie value from the request.
*
* @param request The HTTP request
* @return The redirect path from cookie, or null if not found
*/
public static String extractRedirectPathFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
return null;
}
for (Cookie cookie : cookies) {
if (SPA_REDIRECT_COOKIE.equals(cookie.getName())) {
String value =
java.net.URLDecoder.decode(
cookie.getValue(), java.nio.charset.StandardCharsets.UTF_8);
return value.trim().isEmpty() ? null : value.trim();
}
}
return null;
}
}
@@ -292,6 +292,7 @@ public class FormUtils {
}
PDFRenderer renderer = new PDFRenderer(document);
renderer.setSubsamplingAllowed(true); // Enable subsampling to reduce memory usage
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
@@ -0,0 +1,47 @@
package stirling.software.proprietary.security.oauth2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.OAuth2Error;
class CustomOAuth2AuthenticationFailureHandlerTest {
@Test
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
CustomOAuth2AuthenticationFailureHandler handler =
new CustomOAuth2AuthenticationFailureHandler();
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContextPath("");
request.setParameter("state", "tauri:abc");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationFailure(
request,
response,
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
assertEquals(
"/auth/callback/tauri?state=tauri%3Aabc&errorOAuth=access_denied",
response.getRedirectedUrl());
}
@Test
void redirectsToDefaultCallbackWithoutTauriState() throws Exception {
CustomOAuth2AuthenticationFailureHandler handler =
new CustomOAuth2AuthenticationFailureHandler();
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContextPath("");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationFailure(
request,
response,
new OAuth2AuthenticationException(new OAuth2Error("access_denied")));
assertEquals("/auth/callback?errorOAuth=access_denied", response.getRedirectedUrl());
}
}
@@ -0,0 +1,79 @@
package stirling.software.proprietary.security.oauth2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.oauth2.core.user.DefaultOAuth2User;
import stirling.software.common.model.ApplicationProperties;
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.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class CustomOAuth2AuthenticationSuccessHandlerTest {
@Test
void redirectsToTauriCallbackWhenStateMarked() throws Exception {
LoginAttemptService loginAttemptService = mock(LoginAttemptService.class);
UserService userService = mock(UserService.class);
JwtServiceInterface jwtService = mock(JwtServiceInterface.class);
UserLicenseSettingsService licenseSettingsService = mock(UserLicenseSettingsService.class);
ApplicationProperties.Security.OAUTH2 oauth2Props =
new ApplicationProperties.Security.OAUTH2();
oauth2Props.setAutoCreateUser(true);
oauth2Props.setBlockRegistration(false);
CustomOAuth2AuthenticationSuccessHandler handler =
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
oauth2Props,
userService,
jwtService,
licenseSettingsService);
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
when(userService.isUserDisabled("user")).thenReturn(false);
when(jwtService.isJwtEnabled()).thenReturn(true);
when(jwtService.generateToken(
org.mockito.Mockito.any(
org.springframework.security.core.Authentication.class),
org.mockito.Mockito.anyMap()))
.thenReturn("jwt");
Map<String, Object> attributes = Map.of("sub", "provider-sub", "name", "user");
DefaultOAuth2User oauthUser =
new DefaultOAuth2User(
List.of(new SimpleGrantedAuthority("ROLE_USER")), attributes, "name");
OAuth2AuthenticationToken authentication =
new OAuth2AuthenticationToken(oauthUser, oauthUser.getAuthorities(), "google");
MockHttpServletRequest request = new MockHttpServletRequest();
request.setContextPath("");
request.setScheme("http");
request.setServerName("localhost");
request.setServerPort(8080);
request.setParameter("state", "tauri:abc");
MockHttpServletResponse response = new MockHttpServletResponse();
handler.onAuthenticationSuccess(request, response, authentication);
assertEquals(
"http://localhost:8080/auth/callback/tauri#access_token=jwt",
response.getRedirectedUrl());
}
}

Some files were not shown because too many files have changed in this diff Show More