Compare commits

...
61 Commits
Author SHA1 Message Date
Dario Ghunney WareandGitHub eaafa21842 Merge branch 'main' into fix/saml-login 2026-01-30 16:16:32 +00:00
James BruntonandGitHub 1cc562a6b1 Stop type checking TypeScript files that won't be run (#5607)
# Description of Changes
This PR fixes false-positive TypeScript errors in our layered build
setup (core → proprietary → desktop) by ensuring each build’s typecheck
only evaluates files that are actually part of that build’s reachable
module graph. This prevents overridden core implementations from being
typechecked in higher-layer builds where they are effectively
unreachable due to alias-based overrides.

## Background

We maintain multiple build targets from a layered source tree:

- core: open source baseline
- proprietary: core + proprietary additions/overrides
- desktop: proprietary + desktop-specific additions/overrides

We implement overrides via paths/aliases such that placing a file in a
higher layer at the same relative path supersedes the lower-layer file
at runtime.

For safety, we run TypeScript typechecking independently per build
target to ensure all builds remain valid.

## Problem

Our existing tsconfig setup often typechecked files that are not
actually reachable in a given build. Specifically:

- When a file in core is overridden by a file in proprietary or desktop,
the overridden core file can still be included in the TypeScript Program
for the higher-layer build (typically due to broad include globs).
- This produces false-positive type errors in higher-layer typecheck
runs, even though those core files are effectively unreachable in the
build.

This created friction and noise, and meant we had to make unnecessary
changes to `core` to make the other builds happy, reducing type safety
in the process.

## Solution

This PR adjusts the tsconfig strategy so each build target's typecheck
is driven by reachable entrypoints rather than blanket inclusion of all
layer source trees. Concretely:

- Each build’s tsconfig now includes only:
- that build’s entrypoints and layer sources that are intended to be
compiled for the target
  - any shared/top-level sources required by the target
- Lower layers (e.g., core) are not globally included in higher-layer
builds; they are instead pulled in through module resolution only when
actually referenced (with paths ordering ensuring the correct override
wins).
- This means that we still check all the files that will actually be run
with whatever the overridden logic is, but avoid wasting time and
introducing false-positives by not checking files which have been
overridden.

## Notes
Unfortunately, the config we use for the type checking can't be the same
as the one we use for Vite in this strategy. Vite needs to know about
the entire source tree, so it can't only include the subfolders because
it causes build errors. Because of this, I've duplicated the existing
(valid) tsconfig files and use them for Vite. This is a little clunky
but it does the job. Some day hopefully I'll come back to it and be able
to figure out a nicer way to do it, but for now at least, this solves
the type checking issues without impacting the runtime builds.

Also, I noticed that `@desktop` is defined as an alias, which was
presumably missed when I was removing the self-aliases from the files. I
don't see why you'd ever need to have a desktop file reference
`@desktop` to say "import this but make it impossible for something else
to override the import". I've removed the `@desktop` alias in this PR
while I was in there.
2026-01-30 15:27:35 +00:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>Anthony Stirling
d90e51233d 🤖 format everything with pre-commit by stirlingbot (#5538)
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>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-01-29 23:07:59 +00:00
1cd3e2846e feat(stamp): add dynamic variables and templates for stamp text customization (#5546)
# Description of Changes

This pull request improves the PDF stamping feature, particularly the
text stamping functionality, by introducing dynamic variables, improving
formatting flexibility, and refining positioning logic. The changes
include backend support for dynamic stamp variables (such as date, time,
filename, and metadata), improvements to text layout and positioning,
updates to the API and frontend for usability, and localization
enhancements for user guidance.

**Dynamic Stamp Variables and Text Processing:**

* Added support for dynamic variables in stamp text (e.g., `@date`,
`@time`, `@page_number`, `@filename`, `@uuid`, and metadata fields),
including custom date formats and escaping for literal `@` symbols. This
is handled by the new `processStampText` method in
`StampController.java`.
* Implemented validation and formatting for custom date variables,
ensuring only safe formats are accepted and providing user-friendly
error messages for invalid formats.

**Text Layout and Positioning Improvements:**

* Refactored text and image stamp positioning: now calculates line
heights, block heights, and widths for multi-line stamps, and adjusts
placement logic for more accurate alignment (top, center, bottom) and
margins.
* Updated the default font size for stamps to 40pt and improved font
size handling in both backend and frontend, including validation for
positive values

**API and Method Signature Updates:**

* Extended method signatures to include additional context (such as page
index and filename) for more powerful variable substitution in stamps
**Frontend and Localization Enhancements:**

* Added comprehensive help text, variable descriptions, and template
examples to the UI, making it easier for users to understand and use
dynamic stamp variables.
* Improved accessibility and clarity in the stamp formatting UI by
disabling controls appropriately and providing clearer descriptions.



<!--
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.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-01-29 21:16:14 +00:00
BitTobyandGitHub 080faf9353 fix: PDF Text Editor file open (#5572)
# Description of Changes

## Content
This pull requests fix the problem when opening the file in the ALPHA
feature PDF Text Editor.

## Page Where the Problem Occurred
http://localhost/pdf-text-editor

## Problem
convert_cff_to_ttf.py does not support named CLI arguments.

But Java is calling it like this:
convert_cff_to_ttf.py --input file.cff --output file.otf --to-unicode
file.tounicode

## Solved
convert_cff_to_ttf.py support named CLI arguments.

Closes #5518
---

## 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.
2026-01-29 16:52:21 +00:00
Dario Ghunney WareandGitHub d486bb4939 Fix Audit & Usage Analytics Sections (#5586)
Fixed issue where @lob annotation on audit column was casing
`org.postgresql.util.PSQLException: Large Objects may not be used in
auto-commit mode.` data retrieval issues with Postgres
2026-01-29 16:51:52 +00:00
Anthony StirlingandGitHub 41f9929fd2 Fix tool disabling (#5585)
# 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-29 14:36:10 +00:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
0b0db1793e 🌐 Sync Translations + Update README Progress Table (#5581)
### Description of Changes

This Pull Request was automatically generated to synchronize updates to
translation files and documentation. Below are the details of the
changes made:

#### **1. Synchronization of Translation Files**
- Updated translation files
(`frontend/public/locales/*/translation.toml`) to reflect changes in the
reference file `en-GB/translation.toml`.
- Ensured consistency and synchronization across all supported language
files.
- Highlighted any missing or incomplete translations.
- **Format**: TOML

#### **2. Update README.md**
- Generated the translation progress table in `README.md` using
`counter_translation_v3.py`.
- Added a summary of the current translation status for all supported
languages.
- Included up-to-date statistics on translation coverage.

#### **Why these changes are necessary**
- Keeps translation files aligned with the latest reference updates.
- Ensures the documentation reflects the current translation progress.

---

Auto-generated by [create-pull-request][1].

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

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-01-28 23:58:44 +00:00
Anthony StirlingandGitHub f3cf747cfe possible login fixes (#5444)
# Description of Changes

Disable TLS checks and various cert checks to allow all sorts of
selfhost machines to be connected via tauri app

Version bump

Crop tool correctly shows ghostscript as optional so its not disabled on
java only installations

---

## 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-28 23:57:43 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Ludy
e3982ed4c5 build(deps): bump pypdf from 6.6.0 to 6.6.2 in /testing/cucumber in the pip group across 1 directory (#5577)
Bumps the pip group with 1 update in the /testing/cucumber directory:
[pypdf](https://github.com/py-pdf/pypdf).

Updates `pypdf` from 6.6.0 to 6.6.2
<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.2, 2026-01-26</h2>
<h2>What's new</h2>
<h3>Security (SEC)</h3>
<ul>
<li>Detect cyclic references when retrieving outlines (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3610">#3610</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.6.1...6.6.2">Full
Changelog</a></p>
<h2>Version 6.6.1, 2026-01-25</h2>
<h2>What's new</h2>
<h3>Robustness (ROB)</h3>
<ul>
<li><code>/AcroForm</code> might be NullObject (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3601">#3601</a>)
by <a
href="https://github.com/joshkersey"><code>@​joshkersey</code></a></li>
<li>Handle missing font bounding boxes gracefully (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3600">#3600</a>)
by <a href="https://github.com/LudovA"><code>@​LudovA</code></a></li>
</ul>
<p><a href="https://github.com/py-pdf/pypdf/compare/6.6.0...6.6.1">Full
Changelog</a></p>
</blockquote>
</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.2, 2026-01-26</h2>
<h3>Security (SEC)</h3>
<ul>
<li>Detect cyclic references when retrieving outlines (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3610">#3610</a>)</li>
</ul>
<p><a href="https://github.com/py-pdf/pypdf/compare/6.6.1...6.6.2">Full
Changelog</a></p>
<h2>Version 6.6.1, 2026-01-25</h2>
<h3>Robustness (ROB)</h3>
<ul>
<li><code>/AcroForm</code> might be NullObject (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3601">#3601</a>)</li>
<li>Handle missing font bounding boxes gracefully (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3600">#3600</a>)</li>
</ul>
<p><a href="https://github.com/py-pdf/pypdf/compare/6.6.0...6.6.1">Full
Changelog</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/py-pdf/pypdf/commit/ad47d50fd7475650d71913c8c0927e9a79249c75"><code>ad47d50</code></a>
REL: 6.6.2</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/b1282f8dcdc1a7b41ceab6740ffddfdf31b1fec1"><code>b1282f8</code></a>
SEC: Detect cyclic references when retrieving outlines (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3610">#3610</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/f18e13c91cd47fa740997df4a7307fbc976388e1"><code>f18e13c</code></a>
REL: 6.6.1</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/19735763b856cccf0f69630d0f582a448ec5d8bb"><code>1973576</code></a>
DEV: Bump wheel from 0.44.0 to 0.46.2 (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3608">#3608</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/4740225eaa67ad2e032e63d0453ea6c80bcae158"><code>4740225</code></a>
ROB: <code>/AcroForm</code> might be NullObject (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3601">#3601</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/26fd6388754ed167e862bdd7a3eba614da191d34"><code>26fd638</code></a>
ROB: Handle missing font bounding boxes gracefully (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3600">#3600</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/affe8eddf185b2875dcfa45090b7d09f06d3ba12"><code>affe8ed</code></a>
DEV: Bump virtualenv from 20.27.0 to 20.36.1 (<a
href="https://redirect.github.com/py-pdf/pypdf/issues/3597">#3597</a>)</li>
<li><a
href="https://github.com/py-pdf/pypdf/commit/df1b91da41e38ffaf0815e3fd687c037b375c728"><code>df1b91d</code></a>
DEV: Add missing dependency to URL check</li>
<li>See full diff in <a
href="https://github.com/py-pdf/pypdf/compare/6.6.0...6.6.2">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.6.0&new-version=6.6.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 <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>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-01-28 16:11:15 +00:00
5c7d675960 deps(embedPDF): Bump codebase to embedPDF v2.3.0 and adjust codebase for new features (#5567)
# 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.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-01-28 16:10:43 +00:00
Anthony StirlingandGitHub 7fc6ec5fe1 tool tags (#5568)
# 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-28 10:36:56 +00:00
Anthony StirlingandGitHub 7722001463 xframe fix new (#5580)
# 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-28 10:36:21 +00:00
43d4b46b31 deps(frontend, tauri): update Tauri, Rust crates, and frontend dependencies (#5569)
# Description of Changes

This pull request primarily updates dependencies for both the frontend
JavaScript and Rust (Tauri) codebases, and refactors the
`MobileUploadModal` component to consistently use a centralized API
client for backend communication. The refactor improves code
consistency, error handling, and logging in the file upload workflow.

**Dependency updates**

* Updated several Tauri-related dependencies in both
`frontend/package.json` and `frontend/src-tauri/Cargo.toml` to their
latest versions, including `@tauri-apps/api`, `@tauri-apps/plugin-fs`,
`@tauri-apps/plugin-http`, `@tauri-apps/plugin-shell`, and associated
Rust crates. This ensures better compatibility, security, and access to
new features.
[[1]](diffhunk://#diff-da6498268e99511d9ba0df3c13e439d10556a812881c9d03955b2ef7c6c1c655L46-R49)
[[2]](diffhunk://#diff-da6498268e99511d9ba0df3c13e439d10556a812881c9d03955b2ef7c6c1c655L129-R132)
[[3]](diffhunk://#diff-91e702206f8c6459b43ae72dbd6abfed8104de661dd239d13956985210f67fd0L21-R35)
* Updated `@iconify-json/material-symbols` and `@tauri-apps/cli` in
`package.json` for improved icon support and build tooling.

**Refactor: API client usage in `MobileUploadModal`**

* Replaced all direct `fetch` calls in `MobileUploadModal.tsx` with the
centralized `apiClient`, standardizing backend requests and improving
maintainability.
[[1]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aR13)
[[2]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL84-R98)
[[3]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL116-R122)
[[4]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL130-R138)
[[5]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL160-R177)
[[6]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL187-R207)
* Improved error handling, status checks, and logging throughout the
upload and session management flow, making debugging easier and the user
experience more robust.
[[1]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL84-R98)
[[2]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL130-R138)
[[3]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL148-R153)
[[4]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL160-R177)
[[5]](diffhunk://#diff-fafb4b340343062aba7b763dea5e6e13e0e330ab2ac7dfd04a2032ba79620c8aL187-R207)

**Session cleanup improvements**

* Ensured that mobile scanner sessions are reliably cleaned up both when
the modal closes and when the component unmounts, using the `apiClient`
and React's effect cleanup mechanism.

---

## 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: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-28 10:35:59 +00:00
Anthony StirlingandGitHub eee17d4d19 pipeline fixes for naming issues (#5570)
# 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-28 10:35:46 +00:00
Dario Ghunney Ware 1a62680dcc cleanup 2026-01-26 11:07:39 +00:00
DarioGii 789642a824 pr comments 2026-01-26 11:07:39 +00:00
DarioGii e1199a1fd3 updated deprecated version 2026-01-26 11:07:39 +00:00
DarioGii c0f9c9570e added missing check 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware a8c4a0bfb1 Testing saml login/logout
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java

# Conflicts:
#	frontend/src/proprietary/routes/AuthCallback.tsx

# Conflicts:
#	app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java
#	app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
2026-01-26 11:07:38 +00:00
LudyandDario Ghunney Ware 99d728d815 refactor(security): clean up SSO handling, harden URL parsing, and bump dependencies (#5523)
# Description of Changes

This PR performs a broad cleanup and refactor across the security, SSO,
and dependency layers to improve correctness, maintainability, and
robustness.

### What was changed

- **SSO / Authentication cleanup**
- Removed deprecated and ambiguous `SSO` authentication handling in
favor of explicit `OAUTH2` and `SAML2`.
- Introduced a centralized helper (`isSsoAuthenticationTypeByUsername`)
to consistently detect SSO-backed users.
- Hardened user creation logic to strictly validate authentication types
and reject invalid values.
- Updated OAuth2 and SAML2 authentication success handlers to use
unified SSO detection logic and clearer control flow.
  - Adjusted tests to reflect the new canonical authentication types.

- **Security & robustness improvements**
- Replaced direct `new URL(...)` usage with `URI.create(...).toURL()` to
avoid malformed URL edge cases.
- Hardened `Referer` parsing logic to safely handle invalid or host-less
URIs.
- Improved string comparison patterns (`"literal".equals(x)`) to avoid
potential `NullPointerException`s.

- **Controller and API cleanup**
- Removed large blocks of unused and legacy admin settings endpoints
from `SettingsController`.
- Updated OpenAPI annotations to use `requiredMode` instead of
deprecated `required`.

- **Dependency and build maintenance**
  - Updated Spring Boot from `3.5.7` to `3.5.9`.
- Updated multiple dependencies (Spring Security, Jackson, Micrometer,
Jetty, Hibernate, SnakeYAML, Springdoc, Swagger UI, etc.).
- Synced dependency versions in `3rdPartyLicenses.json` and removed
duplicate or obsolete entries.
- Modernized Gradle DSL usage (`url =`, `username =`,
`allowInsecureProtocol = true`).
- Ensured Spotless disabling applies consistently across all
subprojects.
  - Added `.build-cache` to `.gitignore`.

### Why the change was made

- To eliminate legacy and ambiguous SSO handling that could lead to
incorrect authentication decisions.
- To improve security and stability when dealing with user-controlled
URLs and headers.
- To reduce technical debt by removing unused controllers and deprecated
patterns.
- To keep dependencies up to date and aligned with the current Spring
Boot release.
- To improve overall code clarity, consistency, and long-term
maintainability.

---

This pull request contains dependency updates, minor code cleanups, and
some refactoring to improve maintainability and correctness. The most
significant change is the removal of all admin settings endpoints
(GET/POST) from the `SettingsController`, which impacts how application
settings can be managed via the API. Additionally, there are dependency
version bumps, minor improvements to static resource checks, and small
refactors in certificate download logic and Telegram bot service.

**Major API changes:**

* Removed all admin settings endpoints (general, security, connections,
privacy, advanced) from `SettingsController`, including both GET and
POST handlers for updating and retrieving settings. This eliminates the
ability to manage these settings via the API.

**Dependency updates:**

* Upgraded `snakeyaml-engine` from 2.10 to 3.0.1 and
`springdoc-openapi-starter-webmvc-ui` from 2.8.14 to 2.8.15 in
`build.gradle`.

**Refactoring and bug fixes:**

* Refactored static resource check in `RequestUriUtils.isStaticResource`
to use constant-first string comparison for better null safety and
clarity.
* Updated certificate download logic in `CertificateValidationService`
to use `URI.create(urlStr).toURL()` instead of `new URL(urlStr)` for
improved URL parsing and error handling.
[[1]](diffhunk://#diff-d2646f37bfd3e0963cbce16ab13edb52f2092795f54203b999dd82651154f26dL513-R514)
[[2]](diffhunk://#diff-d2646f37bfd3e0963cbce16ab13edb52f2092795f54203b999dd82651154f26dL703-R704)
* Refactored `TelegramPipelineBot` to consistently use
`telegramProperties.getBotToken()` instead of `getBotToken()`, and
removed the `getBotToken()` method override.
[[1]](diffhunk://#diff-a2466b92f58750ea37960cd1533e3194d9ecc3b4ef5ad7b64a017ee0e636ad93L85-R85)
[[2]](diffhunk://#diff-a2466b92f58750ea37960cd1533e3194d9ecc3b4ef5ad7b64a017ee0e636ad93L395-R395)
[[3]](diffhunk://#diff-a2466b92f58750ea37960cd1533e3194d9ecc3b4ef5ad7b64a017ee0e636ad93L519-L523)

---

## 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-26 11:07:38 +00:00
LudyandDario Ghunney Ware 3317babe5c Add sync-versions job to GitHub Actions workflow (#5228)
# Description of Changes

This pull request updates the `.github/workflows/build.yml` file to
improve automation and consistency in the build process. The most
significant change is the addition of a new `sync-versions` job that
automatically checks for and synchronizes version differences across key
files, and posts a comment on the pull request if mismatches are
detected. Additionally, there are minor formatting updates for
consistency.

**Automation and version synchronization:**

* Added a new `sync-versions` job that runs after the build, checks for
version mismatches between `build.gradle` and several frontend files,
and posts or updates a comment on the pull request to notify
contributors if differences are found. This job uses a GitHub App bot
for authentication and ensures that versioning remains consistent across
the project.

**Formatting consistency:**

* Updated the YAML formatting for `node-version`, `cache`, and
`python-version` fields to use double quotes for consistency in the
`build.yml` workflow.
[[1]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721L150-R151)
[[2]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721L244-R338)


<img width="1127" height="401" alt="image"
src="https://github.com/user-attachments/assets/6930a72d-f72b-4909-97e2-51f0e0f61613"
/>


---

## 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-26 11:07:38 +00:00
LudyandDario Ghunney Ware 5ad5d851b1 chore(ci): enable Gradle dependency caching across GitHub workflows (#5400)
# Description of Changes

This pull request updates the CI/CD workflows and Gradle configuration
to improve build reproducibility, security, and external dependency
management. The main changes include standardizing Gradle setup across
workflows, securely injecting Maven credentials, and enabling Gradle
build caching. There are also minor improvements to dependency version
management and plugin repository configuration.

**CI/CD Workflow Improvements:**

- Standardized Gradle setup across all GitHub Actions workflows by
explicitly adding a `Setup Gradle` step using
`gradle/actions/setup-gradle@v5.0.0` and specifying Gradle version 8.14.
This replaces previous usages and ensures consistency.
[[1]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R71-R81)
[[2]](diffhunk://#diff-8d23782ae5caff72d55828bb25814854f5f2523f299d7dbcda4a3537dd84c5c3L157-R176)
[[3]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R134-R144)
[[4]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R206-R216)
[[5]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R260-R264)
[[6]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R331-R341)
[[7]](diffhunk://#diff-3c0f521958c53ad27c967692b4d5480ead136acb33622ee97d39df814b1b202eR339-R351)
[[8]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bL53-R54)
[[9]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bL121-R127)
[[10]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bR206-R217)
[[11]](diffhunk://#diff-6a2e9fb077e57351f4a7e10d03b114e256298babdf06e7e7ae666781a5cf36a1R60-R70)
[[12]](diffhunk://#diff-62dcbe64a950b4efb54d691e1e87451a8cd535400aa9ea1e40893de5b57cd73bL45-R46)
[[13]](diffhunk://#diff-76056236de05155107f6a660f1e3956059e37338011b8f0e72188afcb9b17b6fL46-R56)
[[14]](diffhunk://#diff-fd60dc2adec58c1005c4e4164e9c24362fd6082fd3ab0403e54d276d9835fa6eL42-R65)
[[15]](diffhunk://#diff-b34ab107dd4bc92075b2e89b6f16e4a2813e267ca7c2afebdb1931a0a3900d5aR102-R114)
[[16]](diffhunk://#diff-98b618771a57e1758961359ecacbac2cff7cfef29aa021c3bc294ae926c4ce5bL47-R51)

- Enabled Gradle build cache (`--build-cache`) for all build-related
commands in workflows, improving build performance and consistency. Also
removed unnecessary `clean` commands before builds to further optimize
workflow times.
[[1]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R71-R81)
[[2]](diffhunk://#diff-8d23782ae5caff72d55828bb25814854f5f2523f299d7dbcda4a3537dd84c5c3L157-R176)
[[3]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R134-R144)
[[4]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R206-R216)
[[5]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R331-R341)
[[6]](diffhunk://#diff-3c0f521958c53ad27c967692b4d5480ead136acb33622ee97d39df814b1b202eR339-R351)
[[7]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bL134-R144)
[[8]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bR206-R217)
[[9]](diffhunk://#diff-6a2e9fb077e57351f4a7e10d03b114e256298babdf06e7e7ae666781a5cf36a1R60-R70)
[[10]](diffhunk://#diff-76056236de05155107f6a660f1e3956059e37338011b8f0e72188afcb9b17b6fL46-R56)
[[11]](diffhunk://#diff-fd60dc2adec58c1005c4e4164e9c24362fd6082fd3ab0403e54d276d9835fa6eL42-R65)
[[12]](diffhunk://#diff-b34ab107dd4bc92075b2e89b6f16e4a2813e267ca7c2afebdb1931a0a3900d5aR102-R114)
[[13]](diffhunk://#diff-98b618771a57e1758961359ecacbac2cff7cfef29aa021c3bc294ae926c4ce5bL47-R51)

**Security and Dependency Management:**

- Injected Maven credentials (`MAVEN_USER`, `MAVEN_PASSWORD`,
`MAVEN_PUBLIC_URL`) as environment variables in all relevant workflow
steps, supporting secure access to private or custom Maven repositories.
[[1]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R71-R81)
[[2]](diffhunk://#diff-8d23782ae5caff72d55828bb25814854f5f2523f299d7dbcda4a3537dd84c5c3L157-R176)
[[3]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R134-R144)
[[4]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R206-R216)
[[5]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R290-R293)
[[6]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721R331-R341)
[[7]](diffhunk://#diff-3c0f521958c53ad27c967692b4d5480ead136acb33622ee97d39df814b1b202eR339-R351)
[[8]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bR66-R69)
[[9]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bL134-R144)
[[10]](diffhunk://#diff-895b214ee023c8c26048a2a3b946cfb1ebc4f26fbc8a9c2fa54b77c12e763b6bR281-R283)
[[11]](diffhunk://#diff-62dcbe64a950b4efb54d691e1e87451a8cd535400aa9ea1e40893de5b57cd73bR57-R60)
[[12]](diffhunk://#diff-76056236de05155107f6a660f1e3956059e37338011b8f0e72188afcb9b17b6fR73-R76)
[[13]](diffhunk://#diff-fd60dc2adec58c1005c4e4164e9c24362fd6082fd3ab0403e54d276d9835fa6eL42-R65)
[[14]](diffhunk://#diff-b34ab107dd4bc92075b2e89b6f16e4a2813e267ca7c2afebdb1931a0a3900d5aR178-R180)
[[15]](diffhunk://#diff-98b618771a57e1758961359ecacbac2cff7cfef29aa021c3bc294ae926c4ce5bL47-R51)

- Added a `pluginManagement` block in `settings.gradle` to allow Gradle
plugins to be resolved from a custom Maven repository if specified by
environment variables, increasing flexibility for plugin sourcing.

**Build and Dependency Versioning:**

- Updated `app/proprietary/build.gradle` to use the
`bouncycastleVersion` variable for the Bouncy Castle dependency version,
improving maintainability and consistency of dependency versioning.

**Workflow Trigger Improvements:**

- Expanded the file path triggers in
`.github/workflows/sync_files_v2.yml` to include additional Gradle build
files, ensuring the workflow runs when any core build files are changed.

---

## 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-26 11:07:38 +00:00
Anthony StirlingandDario Ghunney Ware 6cedc8c994 Libre threads (#5303)
# 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-26 11:07:38 +00:00
Dario Ghunney Ware eba24b4d6c resolving conflicts 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 68e95c2652 eslint fix 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware de23752b44 clean up 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware b44bb7c50c Improved SAML config settings 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 537aee3ab6 Streamlined config with metadata uri 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware a6ae74e794 Addressing XML vulnerability 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 50ec2d82d4 Hardening 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware f95e2fdaa3 Fixing tests 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 70c9cb36c3 Implemented SP-initiated SLO enabled with flag
# Conflicts:
#	frontend/src/proprietary/routes/Login.tsx
2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 1b737cbce5 Fixing SAML logout 2026-01-26 11:07:38 +00:00
Dario Ghunney Ware 4492fa39be Testing saml login/logout
# Conflicts:
#	app/proprietary/src/main/java/stirling/software/proprietary/security/CustomLogoutSuccessHandler.java

# Conflicts:
#	frontend/src/proprietary/routes/AuthCallback.tsx

# Conflicts:
#	app/core/src/main/java/stirling/software/SPDF/controller/api/SettingsController.java
#	app/proprietary/src/main/java/stirling/software/proprietary/security/config/AccountWebController.java
2026-01-26 11:07:38 +00:00
c40144db19 chore(ci): remove automated version sync job from build workflow (#5547)
# Description of Changes

This pull request removes the `sync-versions` job from the GitHub
Actions workflow configuration. The `sync-versions` job was responsible
for ensuring that version numbers in certain frontend files matched the
version defined in `build.gradle`, and for posting a comment on pull
requests if discrepancies were found.

Workflow cleanup:

* Removed the entire `sync-versions` job from
`.github/workflows/build.yml`, including steps for syncing app versions,
checking for changes, and posting PR comments when version mismatches
were detected.

---

## 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: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-24 23:45:48 +00:00
Anthony StirlingandGitHub c77cd73deb translations (#5541)
# 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-24 00:02:31 +00:00
Anthony StirlingandGitHub b76f3662e4 Export folder scanning (#5544)
# 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-23 22:34:39 +00:00
Balázs SzücsandGitHub 188408fc1e feat(compliance): implement compliance verfication for get info on PDF (#5435)
# Description of Changes


This PR replaces the legacy, fragile PDF/A validation logic (which
relied on manual regex parsing and PDFBox Preflight) with the robust
**VeraPDF** integration. Additionally, it introduces a new check for
**SEC/EDGAR compliance** and refactors the frontend report view to
display detailed verification results.

### Key Changes

**Backend (`GetInfoOnPDF.java`):**

* **VeraPDF Integration:** Replaced the custom `checkForStandard` and
`validatePdfAWithPreflight` methods with `VeraPDFService.validatePDF()`.
This aligns validation with industry standards.
* **Code Cleanup:** Removed approximately ~200 lines of technical debt,
including raw XML regex parsing and file-locking prone `PreflightParser`
logic.
* **SEC Compliance:** Added `isSECCompliant()` logic to validate
documents against typical EDGAR requirements:
* No Encryption.
* No Active Content (JavaScript).
* No External Links.
* No Embedded Files.
* No AcroForms.



**Frontend (`GetPdfInfo`):**

* **New Component:** Created `ComplianceSection.tsx` to handle the
complexity of compliance reporting.
* **Enhanced UI:**
* Added visual badges (Passed/Failed/Not Detected) using
`CheckIcon`/`CloseIcon`.
* Added support for displaying the detailed VeraPDF summary alongside
legacy boolean flags.
* Improved handling of "Not PDF/A" states.


### Motivation

The previous validation logic was prone to false positives/negatives and
memory issues with large files (due to Preflight). Moving to VeraPDF
provides accurate, profile-based validation (e.g., PDF/A-1b, 2b, etc.).
The SEC check satisfies a growing need for users validating documents
for financial filings.



<!--
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>
Signed-off-by: brios <brios@heim-041-30.jkh.uni-linz.ac.at>
2026-01-23 21:36:35 +00:00
0b86dd79d3 feat(security): add TOTP-based multi-factor authentication with backend and UI support (#5417)
# Description of Changes

This pull request introduces several improvements and new features
across the authentication and admin data APIs, with a particular focus
on multi-factor authentication (MFA) support and better handling of user
settings. The changes include integrating MFA status into account data
responses, masking sensitive user settings in admin views, and
refactoring code to use more robust user creation methods. Additionally,
there are minor code cleanups and consistency improvements.

### Multi-factor Authentication (MFA) Integration

* Added `mfaEnabled` and `mfaRequired` fields to the `AccountData`
response, populated using the new `MfaService`, to provide clients with
MFA status information for users.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R430-R431)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R72)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L83-R86)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R98)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R574-R575)

### User Settings Handling and Security

* Admin settings API now returns user settings for each user, with the
`mfaSecret` field masked to protect sensitive information. This is
achieved by fetching settings via `findByIdWithSettings` and
copying/masking the relevant field before returning.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R252-R259)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L302-R322)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R378)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1R563)

### Refactoring and Code Consistency

* Refactored user creation in `InitialSecuritySetup` to use the
`SaveUserRequest` builder and `saveUserCore` for better maintainability
and clarity, replacing direct calls to `saveUser`.
[[1]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850R22)
[[2]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L116-R124)
[[3]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L130-R144)
[[4]](diffhunk://#diff-0c7960a6283a07c4905ac9785b2820b412574c9f86918ada30caba0356d34850L140-R160)
* Standardized checks for internal team membership by comparing with
`TeamService.INTERNAL_TEAM_NAME` on the left side for consistency.
[[1]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L267-R273)
[[2]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L332-R351)
[[3]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L421-R443)
[[4]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L449-R471)
[[5]](diffhunk://#diff-2ead183708656f2c6894b28457623820c83b1ed4b0814533caa0e8f0dd6fbcd1L462-R485)

### API and DTO Changes

* Changed the login API to accept `UsernameAndPassMfa` instead of
`UsernameAndPass`, paving the way for MFA code support in authentication
requests.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L30-R41)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L61-R71)
* Updated import statements and controller dependencies to include new
DTOs and services related to MFA.
[[1]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8L14-R19)
[[2]](diffhunk://#diff-9ca4f9246abe79368552264e2e18d7ed039e084c70c0794eb02cfd1b75fbd8a8R56-R57)

These updates improve security, prepare the system for MFA rollout, and
make admin and authentication APIs more robust and informative.

---

## 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: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-23 21:34:57 +00:00
Anthony StirlingandGitHub 0436460c03 V2 Show enterprise demo messages for audit and usage (#5226)
## Summary
- show demo data for audit and usage sections when enterprise licensing
is unavailable
- add enterprise-required banner messaging and reuse demo content
instead of erroring
- update translations for the new enterprise notice

## Testing
- Not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693af73ad9248328885eb8bb81ccf51a)
2026-01-23 16:26:46 +00:00
4d84dcdd42 [V2] feat(convert): add PDF/X export option (#5285)
# Description of Changes

This pull request adds support for converting PDF files to PDF/X format
in the frontend, alongside the existing PDF/A conversion. The changes
include UI updates, translation strings, configuration, and processing
logic to enable PDF to PDF/X conversion using the same backend endpoint
as PDF/A, with appropriate user options and warnings.

**PDF/X Conversion Support:**

- Added a new `ConvertToPdfxSettings` component for configuring PDF/X
conversion options, including output format selection and digital
signature warnings.
(`frontend/src/core/components/tools/convert/ConvertToPdfxSettings.tsx`)
- Updated the conversion settings UI to display PDF/X options when
converting from PDF to PDF/X.
(`frontend/src/core/components/tools/convert/ConvertSettings.tsx`)

**Configuration and Processing Logic:**

- Extended conversion constants and parameters to recognize PDF/X as a
target format, map it to the PDF/A backend endpoint, and ensure correct
file naming and processing behavior.
(`frontend/src/core/constants/convertConstants.ts`,
`frontend/src/core/hooks/tools/convert/useConvertOperation.ts`,
`frontend/src/core/hooks/tools/convert/useConvertParameters.ts`)
- Ensured that PDF/X conversions are processed separately per file,
similar to PDF/A.
(`frontend/src/core/hooks/tools/convert/useConvertOperation.ts`)

**UI and Translation Updates:**

- Updated translation strings to include PDF/X options, descriptions,
and warnings. (`frontend/public/locales/en-GB/translation.toml`)

<img width="366" height="998" alt="image"
src="https://github.com/user-attachments/assets/b28fa095-9350-4db2-a0b5-bddcf003fa46"
/>


<!--
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.

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-01-23 15:50:39 +00:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
039e3b5fa8 Update Backend 3rd Party Licenses (#5539)
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-23 11:44:45 +00:00
JoeyandGitHub 15c5b0eb92 Date and time placeholders in addTextStamp are not working (#5446)
# Description of Changes

The processedStampText variable should be used as parameter when
splitting text into lines. This never happened so date and time
placeholder were never used.

---

## 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)

- [ ] 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-23 11:43:27 +00:00
Anthony StirlingandGitHub 36c9369404 removal of old java desktop + workspace change in tauri (#5459)
# 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-23 11:42:33 +00:00
Anthony StirlingandGitHub 43f3261972 Fix V2 SSO user creation (#5079)
# Description of Changes


---

## 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-22 21:24:49 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3d7efc5d94 build(deps): bump debian from f668110 to ed542b2 in /docker/embedded (#5495)
Bumps debian from `f668110` to `ed542b2`.


[![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-22 20:12:44 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ebf256fd50 build(deps): bump node from f4769ca to e80397b in /docker/frontend (#5498)
[//]: # (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 node from `f4769ca` to `e80397b`.


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=node&package-manager=docker&previous-version=25-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-22 20:12:29 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f678e1d2e3 build(deps): bump nginx from c083c37 to b0f7830 in /docker/frontend (#5500)
Bumps nginx from `c083c37` to `b0f7830`.


[![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-22 20:12:12 +00:00
LudyandGitHub 81c14351ee fix(common): 🛡️CWE-681 & CWE-197 eliminate tainted numeric casts in size parsing by using BigDecimal with range guards (#5521)
# Description of Changes

This pull request refactors and improves the logic for converting
human-readable size strings (like "10MB", "2.5GB") to bytes in the
`GeneralUtils` utility class. The main enhancement is switching from
imprecise floating-point arithmetic to `BigDecimal` for more accurate
and robust conversions, and centralizing the conversion logic to reduce
code duplication and improve maintainability.

**Improvements to size conversion logic:**

* Replaced all floating-point arithmetic in `convertSizeToBytes` with
`BigDecimal` operations to ensure precision and to handle large values
more safely.
* Introduced a new private method `toBytes(BigDecimal value, int
powerOf1024)` to centralize and standardize the conversion from size
units to bytes, including error handling for negative and excessively
large values.
* Added constants `KIB` and `LONG_MAX_DECIMAL` for improved readability
and maintainability of size calculations.
* Added a helper method `parseSizeValue(String value)` to consistently
parse size values as `BigDecimal`.
* Updated imports to include `BigDecimal` and `RoundingMode` for the new
conversion logic.

---

## 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-22 19:48:49 +00:00
LudyandGitHub 3711d8d6b1 refactor(security): clean up SSO handling, harden URL parsing, and bump dependencies (#5523)
# Description of Changes

This PR performs a broad cleanup and refactor across the security, SSO,
and dependency layers to improve correctness, maintainability, and
robustness.

### What was changed

- **SSO / Authentication cleanup**
- Removed deprecated and ambiguous `SSO` authentication handling in
favor of explicit `OAUTH2` and `SAML2`.
- Introduced a centralized helper (`isSsoAuthenticationTypeByUsername`)
to consistently detect SSO-backed users.
- Hardened user creation logic to strictly validate authentication types
and reject invalid values.
- Updated OAuth2 and SAML2 authentication success handlers to use
unified SSO detection logic and clearer control flow.
  - Adjusted tests to reflect the new canonical authentication types.

- **Security & robustness improvements**
- Replaced direct `new URL(...)` usage with `URI.create(...).toURL()` to
avoid malformed URL edge cases.
- Hardened `Referer` parsing logic to safely handle invalid or host-less
URIs.
- Improved string comparison patterns (`"literal".equals(x)`) to avoid
potential `NullPointerException`s.

- **Controller and API cleanup**
- Removed large blocks of unused and legacy admin settings endpoints
from `SettingsController`.
- Updated OpenAPI annotations to use `requiredMode` instead of
deprecated `required`.

- **Dependency and build maintenance**
  - Updated Spring Boot from `3.5.7` to `3.5.9`.
- Updated multiple dependencies (Spring Security, Jackson, Micrometer,
Jetty, Hibernate, SnakeYAML, Springdoc, Swagger UI, etc.).
- Synced dependency versions in `3rdPartyLicenses.json` and removed
duplicate or obsolete entries.
- Modernized Gradle DSL usage (`url =`, `username =`,
`allowInsecureProtocol = true`).
- Ensured Spotless disabling applies consistently across all
subprojects.
  - Added `.build-cache` to `.gitignore`.

### Why the change was made

- To eliminate legacy and ambiguous SSO handling that could lead to
incorrect authentication decisions.
- To improve security and stability when dealing with user-controlled
URLs and headers.
- To reduce technical debt by removing unused controllers and deprecated
patterns.
- To keep dependencies up to date and aligned with the current Spring
Boot release.
- To improve overall code clarity, consistency, and long-term
maintainability.

---

This pull request contains dependency updates, minor code cleanups, and
some refactoring to improve maintainability and correctness. The most
significant change is the removal of all admin settings endpoints
(GET/POST) from the `SettingsController`, which impacts how application
settings can be managed via the API. Additionally, there are dependency
version bumps, minor improvements to static resource checks, and small
refactors in certificate download logic and Telegram bot service.

**Major API changes:**

* Removed all admin settings endpoints (general, security, connections,
privacy, advanced) from `SettingsController`, including both GET and
POST handlers for updating and retrieving settings. This eliminates the
ability to manage these settings via the API.

**Dependency updates:**

* Upgraded `snakeyaml-engine` from 2.10 to 3.0.1 and
`springdoc-openapi-starter-webmvc-ui` from 2.8.14 to 2.8.15 in
`build.gradle`.

**Refactoring and bug fixes:**

* Refactored static resource check in `RequestUriUtils.isStaticResource`
to use constant-first string comparison for better null safety and
clarity.
* Updated certificate download logic in `CertificateValidationService`
to use `URI.create(urlStr).toURL()` instead of `new URL(urlStr)` for
improved URL parsing and error handling.
[[1]](diffhunk://#diff-d2646f37bfd3e0963cbce16ab13edb52f2092795f54203b999dd82651154f26dL513-R514)
[[2]](diffhunk://#diff-d2646f37bfd3e0963cbce16ab13edb52f2092795f54203b999dd82651154f26dL703-R704)
* Refactored `TelegramPipelineBot` to consistently use
`telegramProperties.getBotToken()` instead of `getBotToken()`, and
removed the `getBotToken()` method override.
[[1]](diffhunk://#diff-a2466b92f58750ea37960cd1533e3194d9ecc3b4ef5ad7b64a017ee0e636ad93L85-R85)
[[2]](diffhunk://#diff-a2466b92f58750ea37960cd1533e3194d9ecc3b4ef5ad7b64a017ee0e636ad93L395-R395)
[[3]](diffhunk://#diff-a2466b92f58750ea37960cd1533e3194d9ecc3b4ef5ad7b64a017ee0e636ad93L519-L523)

---

## 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-22 19:39:34 +00:00
a3b2a9b3e3 feat(frontend): enhance icon detection and update config navigation icon (#5524)
# Description of Changes

### What was changed
- Extended the `generate-icons.js` script to detect additional icon
usage patterns:
  - `<LocalIcon icon='...' />` with single-quoted icon names
- Icon definitions inside configuration objects (`icon: '...'` or `icon:
"..."`)
- Updated the configuration navigation icon for the *Connection Mode*
section from `cloud-rounded` to `desktop-cloud-rounded` in both
hook-based and static section definitions.

### Why the change was made
- The icon generation script previously missed icons referenced via
`LocalIcon` with single quotes and icons defined in config objects,
which could lead to missing assets in builds.
- The navigation icon update aligns the UI with the intended
desktop-specific icon semantics.


---

## 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: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-22 19:37:37 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
dd7925fbf4 build(deps): bump io.micrometer:micrometer-core from 1.16.1 to 1.16.2 (#5466)
Bumps
[io.micrometer:micrometer-core](https://github.com/micrometer-metrics/micrometer)
from 1.16.1 to 1.16.2.
<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.2</h2>
<h2>🐞 Bug Fixes</h2>
<ul>
<li>CaffeineCacheMetrics nullability is incompatible with Caffeine 3.2.3
<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/6973">#6973</a></li>
<li>ExecutorServiceMetrics: repeatedly logs exception when monitoring
<code>ThreadPerTaskExecutor</code> without <code>--add-opens</code> <a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/6726">#6726</a></li>
</ul>
<h2>🔨 Dependency Upgrades</h2>
<ul>
<li>Bump ch.qos.logback:logback-classic from 1.5.21 to 1.5.24 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/7027">#7027</a></li>
<li>Bump com.netflix.spectator:spectator-reg-atlas from 1.9.2 to 1.9.3
<a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/7051">#7051</a></li>
<li>Bump com.uber.nullaway:nullaway from 0.12.14 to 0.12.15 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/7020">#7020</a></li>
<li>Bump grpc from 1.76.1 to 1.76.2 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6952">#6952</a></li>
<li>Bump maven-resolver from 1.9.24 to 1.9.25 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6965">#6965</a></li>
<li>Bump org.apache.httpcomponents.client5:httpclient5 from 5.5.1 to
5.5.2 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/7019">#7019</a></li>
<li>Bump org.junit:junit-bom from 5.14.1 to 5.14.2 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/7056">#7056</a></li>
<li>Bump spring6 from 6.2.14 to 6.2.15 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6971">#6971</a></li>
<li>Bump testcontainers from 1.21.3 to 1.21.4 <a
href="https://redirect.github.com/micrometer-metrics/micrometer/pull/6993">#6993</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/MariusVolkhart"><code>@​MariusVolkhart</code></a>
and <a href="https://github.com/izeye"><code>@​izeye</code></a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/6286e854f943bce9a2daf1214ce3cef6c7aad84d"><code>6286e85</code></a>
Bump ch.qos.logback:logback-classic from 1.5.23 to 1.5.24 (<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/7059">#7059</a>)</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/5263cc740b73fd414ce30a910c78bc9bbb522854"><code>5263cc7</code></a>
Merge branch '1.15.x' into 1.16.x</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/0523810d0d3aada310d9ed91e04d52aa420ffd9f"><code>0523810</code></a>
Polish CurrentObservationTest (<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/7048">#7048</a>)</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/3165fdffb9b1f7099f017805b3322c2a050653aa"><code>3165fdf</code></a>
Merge branch '1.15.x' into 1.16.x</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/50ba16bb3d55912e7f25a3903aa2a3e57f3eb98a"><code>50ba16b</code></a>
Handle when ThreadPerTaskExecutor.threadCount() is not available (<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/7045">#7045</a>)</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/c38e88c7b2f0e6fd2e6e48ccc1949b389d61f2a4"><code>c38e88c</code></a>
Bump org.junit:junit-bom from 5.14.1 to 5.14.2 (<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/7056">#7056</a>)</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/22ba833062aacac4d877686e76c539c873d7c659"><code>22ba833</code></a>
Bump com.netflix.spectator:spectator-reg-atlas from 1.9.2 to 1.9.3 (<a
href="https://redirect.github.com/micrometer-metrics/micrometer/issues/7051">#7051</a>)</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/916ced5cbc257a865c84dd2c59d6c2285c087e1b"><code>916ced5</code></a>
Merge branch '1.15.x' into 1.16.x</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/50d273be26275a4e0d5a1549bb7ab6453e085bbe"><code>50d273b</code></a>
Merge branch '1.14.x' into 1.15.x</li>
<li><a
href="https://github.com/micrometer-metrics/micrometer/commit/6b44cf2b2148cd655c98592b183843823074f1d7"><code>6b44cf2</code></a>
Relax time limit for
PushMeterRegistryTest.waitForScheduledPublishToFinish_wh...</li>
<li>Additional commits viewable in <a
href="https://github.com/micrometer-metrics/micrometer/compare/v1.16.1...v1.16.2">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.1&new-version=1.16.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-22 19:27:23 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1b1d17f6f5 build(deps): bump logback from 1.5.23 to 1.5.24 (#5468)
Bumps `logback` from 1.5.23 to 1.5.24.
Updates `ch.qos.logback:logback-core` from 1.5.23 to 1.5.24
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/qos-ch/logback/releases">ch.qos.logback:logback-core's
releases</a>.</em></p>
<blockquote>
<h2>Logback 1.5.24</h2>
<p><strong>2026-01-06 Release of logback version 1.5.24</strong></p>
<p>• Added ExpressionPropertyCondition a PropertyCondition that can
evaluate boolean expressions similar to Java. See <a
href="https://logback.qos.ch/manual/configuration.html#conditionalExp">the
relevant documentation</a> for further details.</p>
<p>• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
62bc5fc245dd3a52f3dd45e232733f4cefb4806d associated with the tag
v_1.5.24. Release built using Java &quot;21&quot; 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/qos-ch/logback/commit/62bc5fc245dd3a52f3dd45e232733f4cefb4806d"><code>62bc5fc</code></a>
prepare release 1.5.24</li>
<li><a
href="https://github.com/qos-ch/logback/commit/aac604d7e8ab4f91f240256755f3a09e53e909f3"><code>aac604d</code></a>
typo fix of local variable name</li>
<li><a
href="https://github.com/qos-ch/logback/commit/8a6df9e5c4e935d158b85811d33f72d10373d914"><code>8a6df9e</code></a>
ExpressionPropertyCondition constructor should be public</li>
<li><a
href="https://github.com/qos-ch/logback/commit/95e588c4e37b3e76ff2a5c13e60d7e0485d43fb2"><code>95e588c</code></a>
minor changes in ExpressionPropertyCondition</li>
<li><a
href="https://github.com/qos-ch/logback/commit/859f5a1f34cdec0f63a1830394df8238e780a9f4"><code>859f5a1</code></a>
added ExpressionPropertyCondition capable of parsing logical expressions
on p...</li>
<li><a
href="https://github.com/qos-ch/logback/commit/348075adfa7cdd8f7bba60225ec570efb7761d3c"><code>348075a</code></a>
start work on 1.5.24-SNAPSHOT</li>
<li>See full diff in <a
href="https://github.com/qos-ch/logback/compare/v_1.5.23...v_1.5.24">compare
view</a></li>
</ul>
</details>
<br />

Updates `ch.qos.logback:logback-classic` from 1.5.23 to 1.5.24
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/qos-ch/logback/releases">ch.qos.logback:logback-classic's
releases</a>.</em></p>
<blockquote>
<h2>Logback 1.5.24</h2>
<p><strong>2026-01-06 Release of logback version 1.5.24</strong></p>
<p>• Added ExpressionPropertyCondition a PropertyCondition that can
evaluate boolean expressions similar to Java. See <a
href="https://logback.qos.ch/manual/configuration.html#conditionalExp">the
relevant documentation</a> for further details.</p>
<p>• A bit-wise identical binary of this version can be reproduced by
building from source code at commit
62bc5fc245dd3a52f3dd45e232733f4cefb4806d associated with the tag
v_1.5.24. Release built using Java &quot;21&quot; 2023-10-17 LTS build
21.0.1.+12-LTS-29 under Linux Debian 11.6.</p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/qos-ch/logback/commit/62bc5fc245dd3a52f3dd45e232733f4cefb4806d"><code>62bc5fc</code></a>
prepare release 1.5.24</li>
<li><a
href="https://github.com/qos-ch/logback/commit/aac604d7e8ab4f91f240256755f3a09e53e909f3"><code>aac604d</code></a>
typo fix of local variable name</li>
<li><a
href="https://github.com/qos-ch/logback/commit/8a6df9e5c4e935d158b85811d33f72d10373d914"><code>8a6df9e</code></a>
ExpressionPropertyCondition constructor should be public</li>
<li><a
href="https://github.com/qos-ch/logback/commit/95e588c4e37b3e76ff2a5c13e60d7e0485d43fb2"><code>95e588c</code></a>
minor changes in ExpressionPropertyCondition</li>
<li><a
href="https://github.com/qos-ch/logback/commit/859f5a1f34cdec0f63a1830394df8238e780a9f4"><code>859f5a1</code></a>
added ExpressionPropertyCondition capable of parsing logical expressions
on p...</li>
<li><a
href="https://github.com/qos-ch/logback/commit/348075adfa7cdd8f7bba60225ec570efb7761d3c"><code>348075a</code></a>
start work on 1.5.24-SNAPSHOT</li>
<li>See full diff in <a
href="https://github.com/qos-ch/logback/compare/v_1.5.23...v_1.5.24">compare
view</a></li>
</ul>
</details>
<br />


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-22 19:26:24 +00:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
58a00dde24 Update Backend 3rd Party Licenses (#5490)
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-22 19:26:02 +00:00
LudyandGitHub a95583b9e0 Add sync-versions job to GitHub Actions workflow (#5228)
# Description of Changes

This pull request updates the `.github/workflows/build.yml` file to
improve automation and consistency in the build process. The most
significant change is the addition of a new `sync-versions` job that
automatically checks for and synchronizes version differences across key
files, and posts a comment on the pull request if mismatches are
detected. Additionally, there are minor formatting updates for
consistency.

**Automation and version synchronization:**

* Added a new `sync-versions` job that runs after the build, checks for
version mismatches between `build.gradle` and several frontend files,
and posts or updates a comment on the pull request to notify
contributors if differences are found. This job uses a GitHub App bot
for authentication and ensures that versioning remains consistent across
the project.

**Formatting consistency:**

* Updated the YAML formatting for `node-version`, `cache`, and
`python-version` fields to use double quotes for consistency in the
`build.yml` workflow.
[[1]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721L150-R151)
[[2]](diffhunk://#diff-5c3fa597431eda03ac3339ae6bf7f05e1a50d6fc7333679ec38e21b337cb6721L244-R338)


<img width="1127" height="401" alt="image"
src="https://github.com/user-attachments/assets/6930a72d-f72b-4909-97e2-51f0e0f61613"
/>


---

## 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-22 19:23:52 +00:00
LudyandGitHub 56f788957a feat(admin): add H2 database backup & restore management to admin UI (#5528)
# Description of Changes

This PR introduces comprehensive database backup and restore management
for embedded H2 databases directly within the admin UI, along with a
corrected backend condition for H2 detection.

### What was changed
- Fixed `H2SQLCondition` logic so embedded H2 is correctly detected when
custom databases are disabled.
- Updated and expanded unit tests to reflect the corrected H2 enablement
behavior.
- Added a new **Backups & Restore** section to the Admin Database
settings UI.
- Implemented UI features to:
  - Create H2 database backups
  - List available backup files with metadata
  - Download, import, and delete existing backups
- Upload and import `.sql` backup files with a confirmation code safety
step
- Added a new frontend service (`databaseManagementService`) to
encapsulate database management API calls.
- Extended English (en-GB) translations for all new database backup and
restore UI elements.

### Why the change was made
- Embedded H2 is the default database when no custom database is
configured, but this was not consistently reflected in backend
conditions.
- Administrators need a safe, UI-driven way to manage H2 backups without
manual filesystem access.
- The new UI improves operability, safety (confirmation code on import),
and transparency (version and status badges).

<img width="1920" height="1080" alt="image"
src="https://github.com/user-attachments/assets/15869b9c-fa86-4b31-ac9b-8e6424138b73"
/>
<img width="1920" height="1032" alt="image"
src="https://github.com/user-attachments/assets/8668dc07-0384-40f0-91e1-c6973c1ce535"
/>


Closes #5515

---

## 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-22 19:16:30 +00:00
Anthony StirlingandGitHub df1295059a API cleanups (#5537)
# 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-22 18:19:24 +00:00
853161e891 Fix: Whitelist health endpoints in Security and Enterprise Filter + fix keep original commit (#5494)
Community PR that had a bug, kept original Commit for history with bug
fixed, Merge dont squash merge

# Description of Changes

This PR fixes #5159 by allowing standard health check endpoints to be
accessed without authentication, which is required for container
orchestration (Kubernetes/Docker) liveness and readiness probes.

Changes:

1. **Security Whitelist (RequestUriUtils):** Added `/actuator/health`,
`/healthz`, `/liveness`, and `/readiness` to the public whitelist to
prevent 401 Unauthorized.

2. **Enterprise Filter (EnterpriseEndpointFilter):** Added an exception
for health endpoints. Previously, this filter was aggressively blocking
all /actuator/ requests for non-Pro users, returning 404 Not Found even
if the security config was correct.

**Testing:** Verified locally.

**Before:** `curl http://localhost:8080/actuator/health` returned `401
Unauthorized`.
**After:** curl `http://localhost:8080/actuator/health` returns `200 OK`
and `{"status":"UP"}`.

A sample response from cURL:
After the fix
```bash
$ curl -v http://localhost:8080/actuator/health
```
Now returns:
```bash
* Host localhost:8080 was resolved.
* IPv6: ::1
* IPv4: 127.0.0.1
*   Trying [::1]:8080...
* Connected to localhost (::1) port 8080
> GET /actuator/health HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Date: Mon, 15 Dec 2025 18:39:18 GMT
< Vary: Origin
< Vary: Access-Control-Request-Method
< Vary: Access-Control-Request-Headers
< X-Request-Id: 775fb1ee-35de-400d-9e8b-bd1805e3c61a
< Content-Type: application/vnd.spring-boot.actuator.v3+json
< X-Content-Type-Options: nosniff
< X-XSS-Protection: 0
< Cache-Control: no-cache, no-store, max-age=0, must-revalidate
< Pragma: no-cache
< Expires: 0
< X-Frame-Options: DENY
< Transfer-Encoding: chunked
<
* Connection #0 to host localhost left intact
{"status":"UP"}
```

---

## 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
**NOT APPLICABLE**
- [ ] 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)
**NOT 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.

---------

# 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.

---------

Co-authored-by: Saksham Mittal <saksham2005mittal@gmail.com>
Co-authored-by: Dario Ghunney Ware <dariogware@gmail.com>
2026-01-22 16:14:29 +00:00
Balázs SzücsandGitHub 64b33ea62b fix(annotations): fix error 300 on sign frontend (#5536)
# Description of Changes

the bug


https://github.com/user-attachments/assets/7f524c95-e57a-4188-a7a9-8d3020a3bb47


<!--


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-22 16:13:54 +00:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
0ca4600371 🤖 format everything with pre-commit by stirlingbot (#5530)
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-21 22:59:05 +00:00
270 changed files with 18762 additions and 5318 deletions
+1 -1
View File
@@ -213,7 +213,7 @@ id_ed25519.pub
.mypy_cache
.pytest_cache
.ipynb_checkpoints
.build-cache
**/jcef-bundle/
-1
View File
@@ -199,7 +199,6 @@ return useToolOperation({
- **Java Version**: Minimum JDK 17, supports and recommends JDK 21
- **Lombok**: Used extensively - ensure IDE plugin is installed
- **Desktop Mode**: Set `STIRLING_PDF_DESKTOP_UI=true` for desktop application mode
- **File Persistence**:
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
+2 -2
View File
@@ -41,8 +41,8 @@ dependencies {
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"
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.15"
// 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
@@ -303,22 +303,23 @@ public class EndpointConfiguration {
// Adding endpoints to "PageOps" group
addEndpointToGroup("PageOps", "remove-pages");
addEndpointToGroup("PageOps", "merge-pdfs");
addEndpointToGroup("PageOps", "split-pdfs");
addEndpointToGroup("PageOps", "pdf-organizer");
addEndpointToGroup("PageOps", "split-pages");
addEndpointToGroup("PageOps", "rearrange-pages");
addEndpointToGroup("PageOps", "rotate-pdf");
addEndpointToGroup("PageOps", "multi-page-layout");
addEndpointToGroup("PageOps", "booklet-imposition");
addEndpointToGroup("PageOps", "scale-pages");
addEndpointToGroup("PageOps", "crop");
addEndpointToGroup("PageOps", "extract-page");
addEndpointToGroup("PageOps", "pdf-to-single-page");
addEndpointToGroup("PageOps", "auto-split-pdf");
addEndpointToGroup("PageOps", "split-by-size-or-count");
addEndpointToGroup("PageOps", "overlay-pdf");
addEndpointToGroup("PageOps", "split-pdf-by-sections");
addEndpointToGroup("PageOps", "split-pdf-by-chapters");
addEndpointToGroup("PageOps", "add-page-numbers");
addEndpointToGroup("PageOps", "extract-pages");
// Adding endpoints to "Convert" group
// Adding endpoints to "Convert" group (Frontend has 15 convert endpoints)
addEndpointToGroup("Convert", "pdf-to-img");
addEndpointToGroup("Convert", "img-to-pdf");
addEndpointToGroup("Convert", "pdf-to-pdfa");
@@ -334,6 +335,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "pdf-to-epub");
// Backend-only endpoints (not in frontend tool registry)
addEndpointToGroup("Convert", "pdf-to-vector");
addEndpointToGroup("Convert", "vector-to-pdf");
addEndpointToGroup("Convert", "pdf-to-video");
@@ -341,6 +344,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-cbz");
addEndpointToGroup("Convert", "pdf-to-json");
addEndpointToGroup("Convert", "json-to-pdf");
addEndpointToGroup("Convert", "pdf-to-rtf");
// Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password");
@@ -351,51 +355,49 @@ public class EndpointConfiguration {
addEndpointToGroup("Security", "remove-cert-sign");
addEndpointToGroup("Security", "sanitize-pdf");
addEndpointToGroup("Security", "auto-redact");
addEndpointToGroup("Security", "redact");
addEndpointToGroup("Security", "validate-signature");
addEndpointToGroup("Security", "add-stamp");
addEndpointToGroup("Security", "unlock-pdf-forms");
// Backend-only endpoints (not in frontend tool registry endpoints)
addEndpointToGroup("Security", "redact");
addEndpointToGroup("Security", "verify-pdf");
addEndpointToGroup("Security", "stamp");
addEndpointToGroup("Security", "sign");
// Adding endpoints to "Other" group
addEndpointToGroup("Other", "ocr-pdf");
addEndpointToGroup("Other", "add-image");
addEndpointToGroup("Other", "extract-images");
addEndpointToGroup("Other", "change-metadata");
addEndpointToGroup("Other", "update-metadata");
addEndpointToGroup("Other", "flatten");
addEndpointToGroup("Other", "unlock-pdf-forms");
addEndpointToGroup("Other", REMOVE_BLANKS);
addEndpointToGroup("Other", "remove-annotations");
addEndpointToGroup("Other", "compare");
addEndpointToGroup("Other", "add-page-numbers");
addEndpointToGroup("Other", "get-info-on-pdf");
addEndpointToGroup("Other", "remove-image-pdf");
addEndpointToGroup("Other", "add-attachments");
addEndpointToGroup("Other", "replace-invert-pdf");
addEndpointToGroup("Other", "edit-table-of-contents");
addEndpointToGroup("Other", "text-editor-pdf");
// Backend-only endpoints (not in frontend tool registry endpoints)
addEndpointToGroup("Other", "add-image");
addEndpointToGroup("Other", "compare");
addEndpointToGroup("Other", "view-pdf");
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
addEndpointToGroup("Other", "multi-tool");
// Adding form-related endpoints to "Other" group
addEndpointToGroup("Other", "fields");
addEndpointToGroup("Other", "modify-fields");
addEndpointToGroup("Other", "delete-fields");
addEndpointToGroup("Other", "fill");
// Adding endpoints to "Advance" group
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "compress-pdf");
addEndpointToGroup("Advance", "extract-image-scans");
addEndpointToGroup("Advance", "repair");
addEndpointToGroup("Advance", "auto-rename");
addEndpointToGroup("Advance", "pipeline");
addEndpointToGroup("Advance", "handleData");
addEndpointToGroup("Advance", "scanner-effect");
addEndpointToGroup("Advance", "auto-split-pdf");
addEndpointToGroup("Advance", "show-javascript");
addEndpointToGroup("Advance", "split-by-size-or-count");
addEndpointToGroup("Advance", "overlay-pdf");
addEndpointToGroup("Advance", "split-pdf-by-sections");
addEndpointToGroup("Advance", "edit-table-of-contents");
addEndpointToGroup("Advance", "split-pdf-by-chapters");
// Backend-only endpoints
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "pipeline");
// CLI
addEndpointToGroup("CLI", "compress-pdf");
@@ -436,8 +438,8 @@ public class EndpointConfiguration {
// Java
addEndpointToGroup("Java", "merge-pdfs");
addEndpointToGroup("Java", "remove-pages");
addEndpointToGroup("Java", "split-pdfs");
addEndpointToGroup("Java", "pdf-organizer");
addEndpointToGroup("Java", "split-pages");
addEndpointToGroup("Java", "rearrange-pages");
addEndpointToGroup("Java", "rotate-pdf");
addEndpointToGroup("Java", "pdf-to-img");
addEndpointToGroup("Java", "img-to-pdf");
@@ -445,9 +447,10 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "remove-password");
addEndpointToGroup("Java", "change-permissions");
addEndpointToGroup("Java", "add-watermark");
addEndpointToGroup("Java", "add-stamp");
addEndpointToGroup("Java", "add-image");
addEndpointToGroup("Java", "extract-images");
addEndpointToGroup("Java", "change-metadata");
addEndpointToGroup("Java", "update-metadata");
addEndpointToGroup("Java", "cert-sign");
addEndpointToGroup("Java", "remove-cert-sign");
addEndpointToGroup("Java", "multi-page-layout");
@@ -459,7 +462,6 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "sanitize-pdf");
addEndpointToGroup("Java", "crop");
addEndpointToGroup("Java", "get-info-on-pdf");
addEndpointToGroup("Java", "extract-page");
addEndpointToGroup("Java", "pdf-to-single-page");
addEndpointToGroup("Java", "markdown-to-pdf");
addEndpointToGroup("Java", "show-javascript");
@@ -469,7 +471,9 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "split-by-size-or-count");
addEndpointToGroup("Java", "overlay-pdf");
addEndpointToGroup("Java", "split-pdf-by-sections");
addEndpointToGroup("Java", "split-pdf-by-chapters");
addEndpointToGroup("Java", REMOVE_BLANKS);
addEndpointToGroup("Java", "remove-annotations");
addEndpointToGroup("Java", "pdf-to-text");
addEndpointToGroup("Java", "remove-image-pdf");
addEndpointToGroup("Java", "pdf-to-markdown");
@@ -479,15 +483,24 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-cbz");
addEndpointToGroup("Java", "pdf-to-json");
addEndpointToGroup("Java", "json-to-pdf");
addEndpointToGroup("rar", "pdf-to-cbr");
addEndpointToGroup("Java", "pdf-to-video");
addEndpointToGroup("Java", "verify-pdf");
addEndpointToGroup("Java", "flatten");
addEndpointToGroup("Java", "unlock-pdf-forms");
addEndpointToGroup("Java", "validate-signature");
addEndpointToGroup("Java", "text-editor-pdf");
addEndpointToGroup("Java", "edit-table-of-contents");
addEndpointToGroup("Java", "pdf-to-epub");
addEndpointToGroup("Java", "eml-to-pdf");
addEndpointToGroup("Java", "handleData");
addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript
addEndpointToGroup("Javascript", "pdf-organizer");
addEndpointToGroup("Javascript", "rearrange-pages");
addEndpointToGroup("Javascript", "sign");
addEndpointToGroup("Javascript", "compare");
addEndpointToGroup("Javascript", "adjust-contrast");
addEndpointToGroup("Javascript", "text-editor-pdf");
/* qpdf */
addEndpointToGroup("qpdf", "repair");
@@ -498,6 +511,7 @@ public class EndpointConfiguration {
addEndpointToGroup("Ghostscript", "compress-pdf");
addEndpointToGroup("Ghostscript", "crop");
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
addEndpointToGroup("Ghostscript", "scanner-effect");
addEndpointToGroup("Ghostscript", "pdf-to-vector");
addEndpointToGroup("Ghostscript", "vector-to-pdf");
@@ -516,6 +530,8 @@ public class EndpointConfiguration {
addEndpointAlternative("compress-pdf", "qpdf");
addEndpointAlternative("compress-pdf", "Ghostscript");
addEndpointAlternative("compress-pdf", "Java");
addEndpointAlternative("crop", "Ghostscript");
addEndpointAlternative("crop", "Java");
addEndpointAlternative("ocr-pdf", "tesseract");
addEndpointAlternative("ocr-pdf", "OCRmyPDF");
@@ -544,6 +560,9 @@ public class EndpointConfiguration {
// Pdftohtml dependent endpoints
addEndpointToGroup("Pdftohtml", "pdf-to-html");
addEndpointToGroup("Pdftohtml", "pdf-to-markdown");
// Calibre dependent endpoints
addEndpointToGroup("Calibre", "pdf-to-epub");
}
private void processEnvironmentConfigs() {
@@ -1,8 +1,6 @@
package stirling.software.common.configuration;
import java.io.File;
import java.nio.file.Paths;
import java.util.Locale;
import lombok.extern.slf4j.Slf4j;
@@ -61,28 +59,6 @@ public class InstallationPathConfig {
}
private static String initializeBasePath() {
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
String os = System.getProperty("os.name").toLowerCase(Locale.ROOT);
if (os.contains("win")) {
return Paths.get(
System.getenv("APPDATA"), // parent path
"Stirling-PDF")
+ File.separator;
} else if (os.contains("mac")) {
return Paths.get(
System.getProperty("user.home"),
"Library",
"Application Support",
"Stirling-PDF")
+ File.separator;
} else {
return Paths.get(
System.getProperty("user.home"), // parent path
".config",
"Stirling-PDF")
+ File.separator;
}
}
return "." + File.separator;
}
@@ -164,6 +164,7 @@ public class ApplicationProperties {
private String customGlobalAPIKey;
private Jwt jwt = new Jwt();
private Validation validation = new Validation();
private String xFrameOptions = "DENY";
public Boolean isAltLogin() {
return saml2.getEnabled() || oauth2.getEnabled();
@@ -214,75 +215,182 @@ public class ApplicationProperties {
@Setter
@ToString
public static class SAML2 {
private String provider;
private Boolean enabled = false;
private Boolean autoCreateUser = false;
private Boolean blockRegistration = false;
private String registrationId = "stirling";
private Boolean enableSingleLogout = false;
@ToString.Exclude
@JsonProperty("idpMetadataUri")
@ToString.Exclude private String metadataUri;
private Provider provider = new Provider();
private SP sp = new SP();
// Legacy field mappings for backward compatibility
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpMetadataUri;
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpSingleLogoutUrl;
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpSingleLoginUrl;
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpIssuer;
@JsonProperty("idpCert")
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpEntityId;
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String idpCert;
@ToString.Exclude
@JsonProperty("privateKey")
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String privateKey;
@ToString.Exclude
@JsonProperty("spCert")
@Deprecated(since = "2.3.1", forRemoval = true)
@JsonIgnore
private String spCert;
/** Migrate legacy flat properties to new nested structure on set. */
public void setIdpMetadataUri(String value) {
this.idpMetadataUri = value;
if (value != null
&& !value.isBlank()
&& (metadataUri == null || metadataUri.isBlank())) {
this.metadataUri = value;
}
}
public void setIdpSingleLoginUrl(String value) {
this.idpSingleLoginUrl = value;
if (value != null && !value.isBlank()) {
this.provider.setSingleLoginUrl(value);
}
}
public void setIdpSingleLogoutUrl(String value) {
this.idpSingleLogoutUrl = value;
if (value != null && !value.isBlank()) {
this.provider.setSingleLogoutUrl(value);
}
}
public void setIdpIssuer(String value) {
this.idpIssuer = value;
if (value != null && !value.isBlank()) {
this.provider.setEntityId(value);
}
}
public void setIdpEntityId(String value) {
this.idpEntityId = value;
if (value != null && !value.isBlank()) {
this.provider.setEntityId(value);
}
}
public void setIdpCert(String value) {
this.idpCert = value;
if (value != null && !value.isBlank()) {
this.provider.setCert(value);
}
}
public void setPrivateKey(String value) {
this.privateKey = value;
if (value != null && !value.isBlank()) {
this.sp.setPrivateKey(value);
}
}
public void setSpCert(String value) {
this.spCert = value;
if (value != null && !value.isBlank()) {
this.sp.setCert(value);
}
}
@JsonIgnore
public InputStream getIdpMetadataUri() throws IOException {
if (idpMetadataUri.startsWith("classpath:")) {
return new ClassPathResource(idpMetadataUri.substring("classpath:".length()))
public InputStream getMetadataUriAsStream() throws IOException {
String uri = getEffectiveMetadataUri();
if (uri == null || uri.isBlank()) {
throw new IOException("security.saml2.metadataUri is not configured");
}
if (uri.startsWith("classpath:")) {
return new ClassPathResource(uri.substring("classpath:".length()))
.getInputStream();
}
try {
URI uri = new URI(idpMetadataUri);
URL url = uri.toURL();
URI parsedUri = new URI(uri);
URL url = parsedUri.toURL();
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
return connection.getInputStream();
} catch (URISyntaxException e) {
throw new IOException("Invalid URI format: " + idpMetadataUri, e);
throw new IOException("Invalid URI format: " + uri, e);
}
}
@JsonIgnore
public Resource getSpCert() {
if (spCert == null) return null;
if (spCert.startsWith("classpath:")) {
return new ClassPathResource(spCert.substring("classpath:".length()));
} else {
return new FileSystemResource(spCert);
public String getEffectiveMetadataUri() {
if (metadataUri != null && !metadataUri.isBlank()) {
return metadataUri;
}
return idpMetadataUri; // Legacy fallback
}
/** IdP configuration - manual fallback when metadata is unavailable. */
@Data
public static class Provider {
private String name = ""; // Display name only
private String singleLoginUrl;
private String singleLogoutUrl;
private String entityId;
@ToString.Exclude private String cert;
@JsonIgnore
public Resource getCertResource() {
if (cert == null) return null;
if (cert.startsWith("classpath:")) {
return new ClassPathResource(cert.substring("classpath:".length()));
} else {
return new FileSystemResource(cert);
}
}
}
@JsonIgnore
public Resource getIdpCert() {
if (idpCert == null) return null;
if (idpCert.startsWith("classpath:")) {
return new ClassPathResource(idpCert.substring("classpath:".length()));
} else {
return new FileSystemResource(idpCert);
}
}
/** Service Provider (SP) credentials for signing SAML requests. */
@Data
public static class SP {
@ToString.Exclude private String privateKey;
@ToString.Exclude private String cert;
@JsonIgnore
public Resource getPrivateKey() {
if (privateKey == null) return null;
if (privateKey.startsWith("classpath:")) {
return new ClassPathResource(privateKey.substring("classpath:".length()));
} else {
return new FileSystemResource(privateKey);
@JsonIgnore
public Resource getPrivateKeyResource() {
if (privateKey == null) return null;
if (privateKey.startsWith("classpath:")) {
return new ClassPathResource(privateKey.substring("classpath:".length()));
} else {
return new FileSystemResource(privateKey);
}
}
@JsonIgnore
public Resource getCertResource() {
if (cert == null) return null;
if (cert.startsWith("classpath:")) {
return new ClassPathResource(cert.substring("classpath:".length()));
} else {
return new FileSystemResource(cert);
}
}
}
}
@@ -4,6 +4,8 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
@@ -38,6 +40,10 @@ public class GeneralUtils {
*/
private static final int MAX_DNS_ADDRESSES = 20;
// Constants for size conversion
private static final BigDecimal KIB = BigDecimal.valueOf(1024L);
private static final BigDecimal LONG_MAX_DECIMAL = BigDecimal.valueOf(Long.MAX_VALUE);
private final Set<String> DEFAULT_VALID_SCRIPTS = Set.of("png_to_webp.py", "split_photos.py");
private final Set<String> DEFAULT_VALID_PIPELINE =
Set.of(
@@ -539,39 +545,26 @@ public class GeneralUtils {
try {
if (sizeStr.endsWith("TB")) {
return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
* 1024L
* 1024L
* 1024L
* 1024L);
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 4);
} else if (sizeStr.endsWith("GB")) {
return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
* 1024L
* 1024L
* 1024L);
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 3);
} else if (sizeStr.endsWith("MB")) {
return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2))
* 1024L
* 1024L);
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 2);
} else if (sizeStr.endsWith("KB")) {
return (long)
(Double.parseDouble(sizeStr.substring(0, sizeStr.length() - 2)) * 1024L);
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 2)), 1);
} else if (!sizeStr.isEmpty() && sizeStr.charAt(sizeStr.length() - 1) == 'B') {
return Long.parseLong(sizeStr.substring(0, sizeStr.length() - 1));
return toBytes(parseSizeValue(sizeStr.substring(0, sizeStr.length() - 1)), 0);
} else {
// Use provided default unit or fall back to MB
String unit = defaultUnit != null ? defaultUnit.toUpperCase(Locale.ROOT) : "MB";
double value = Double.parseDouble(sizeStr);
BigDecimal value = parseSizeValue(sizeStr);
return switch (unit) {
case "TB" -> (long) (value * 1024L * 1024L * 1024L * 1024L);
case "GB" -> (long) (value * 1024L * 1024L * 1024L);
case "MB" -> (long) (value * 1024L * 1024L);
case "KB" -> (long) (value * 1024L);
case "B" -> (long) value;
default -> (long) (value * 1024L * 1024L); // Default to MB
case "TB" -> toBytes(value, 4);
case "GB" -> toBytes(value, 3);
case "MB" -> toBytes(value, 2);
case "KB" -> toBytes(value, 1);
case "B" -> toBytes(value, 0);
default -> toBytes(value, 2); // Default to MB
};
}
} catch (NumberFormatException e) {
@@ -590,6 +583,30 @@ public class GeneralUtils {
return convertSizeToBytes(sizeStr, "MB");
}
private Long toBytes(BigDecimal value, int powerOf1024) {
if (value == null) {
return null;
}
if (value.compareTo(BigDecimal.ZERO) < 0) {
log.warn("Size value cannot be negative: {}", value);
return null;
}
if (powerOf1024 < 0 || powerOf1024 > 4) {
throw new IllegalArgumentException("Invalid power for size conversion: " + powerOf1024);
}
BigDecimal multiplier = powerOf1024 == 0 ? BigDecimal.ONE : KIB.pow(powerOf1024);
BigDecimal bytes = value.multiply(multiplier).setScale(0, RoundingMode.DOWN);
if (bytes.compareTo(LONG_MAX_DECIMAL) > 0) {
log.warn("Size value too large to fit in long: {}", bytes);
return null;
}
return bytes.longValue();
}
private BigDecimal parseSizeValue(String value) {
return new BigDecimal(value);
}
/* Validates if a string represents a valid size unit. */
private boolean isValidSizeUnit(String unit) {
// Use a precomputed Set for O(1) lookup, normalize using a locale-safe toUpperCase
@@ -29,7 +29,7 @@ public class RequestUriUtils {
|| normalizedUri.startsWith("/pdfium/")
|| normalizedUri.startsWith("/assets/")
|| normalizedUri.startsWith("/locales/")
|| normalizedUri.startsWith("/Login/")
|| normalizedUri.startsWith("/login/")
|| normalizedUri.startsWith("/samples/")
|| normalizedUri.startsWith("/classic-logo/")
|| normalizedUri.startsWith("/modern-logo/")
@@ -38,12 +38,12 @@ public class RequestUriUtils {
}
// Specific static files bundled with the frontend
if (normalizedUri.equals("/robots.txt")
|| normalizedUri.equals("/favicon.ico")
|| normalizedUri.equals("/manifest.json")
|| normalizedUri.equals("/site.webmanifest")
|| normalizedUri.equals("/manifest-classic.json")
|| normalizedUri.equals("/index.html")) {
if ("/robots.txt".equals(normalizedUri)
|| "/favicon.ico".equals(normalizedUri)
|| "/manifest.json".equals(normalizedUri)
|| "/site.webmanifest".equals(normalizedUri)
|| "/manifest-classic.json".equals(normalizedUri)
|| "/index.html".equals(normalizedUri)) {
return true;
}
@@ -165,14 +165,20 @@ public class RequestUriUtils {
|| trimmedUri.contains("/oauth2/authorization/") // OAuth2 authorization endpoint
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/logout")
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
// Health Endoints
|| trimmedUri.startsWith("/actuator/health")
|| trimmedUri.startsWith("/health")
|| trimmedUri.startsWith("/healthz")
|| trimmedUri.startsWith("/liveness")
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs");
@@ -31,9 +31,9 @@ class ApplicationPropertiesSaml2HttpTest {
String url = server.url("/meta").toString();
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri(url);
s.setMetadataUri(url);
try (InputStream in = s.getIdpMetadataUri()) {
try (InputStream in = s.getMetadataUriAsStream()) {
String body = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(body.contains("EntityDescriptor"));
}
@@ -44,9 +44,9 @@ class ApplicationPropertiesSaml2HttpTest {
void idpMetadataUri_invalidUri_triggers_catch_and_throwsIOException() {
// Ungültige URI -> new URI(...) wirft URISyntaxException -> catch -> IOException
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
s.setMetadataUri("http:##invalid uri"); // absichtlich kaputt (Leerzeichen + ##)
assertThrows(IOException.class, s::getIdpMetadataUri);
assertThrows(IOException.class, s::getMetadataUriAsStream);
}
@Test
@@ -57,8 +57,8 @@ class ApplicationPropertiesSaml2HttpTest {
Path tmp = Files.createTempFile("spdf-spcert-", ".crt");
Files.writeString(tmp, "CERT");
s.setSpCert(tmp.toString());
Resource r = s.getSpCert();
s.getSp().setCert(tmp.toString());
Resource r = s.getSp().getCertResource();
assertNotNull(r);
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
@@ -71,8 +71,8 @@ class ApplicationPropertiesSaml2HttpTest {
// bewusst nicht existierender Pfad -> else-Zweig wird trotzdem genommen
String missing = "/this/path/does/not/exist/idp.crt";
s.setIdpCert(missing);
Resource r = s.getIdpCert();
s.getProvider().setCert(missing);
Resource r = s.getProvider().getCertResource();
assertNotNull(r);
assertInstanceOf(FileSystemResource.class, r, "Expected FileSystemResource for FS path");
@@ -15,9 +15,9 @@ class ApplicationPropertiesSaml2ResourceTest {
@Test
void idpMetadataUri_classpath_is_resolved() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setIdpMetadataUri("classpath:saml/dummy.txt");
s.setMetadataUri("classpath:saml/dummy.txt");
try (InputStream in = s.getIdpMetadataUri()) {
try (InputStream in = s.getMetadataUriAsStream()) {
assertNotNull(in, "Classpath InputStream should not be null");
String txt = new String(in.readAllBytes(), StandardCharsets.UTF_8);
assertTrue(txt.contains("ok"));
@@ -28,27 +28,26 @@ class ApplicationPropertiesSaml2ResourceTest {
void spCert_idpCert_privateKey_null_classpath_and_filesystem() throws Exception {
var s = new ApplicationProperties.Security.SAML2();
s.setSpCert(null);
s.setIdpCert(null);
s.setPrivateKey(null);
assertNull(s.getSpCert());
assertNull(s.getIdpCert());
assertNull(s.getPrivateKey());
// Test null values via the nested structure
assertNull(s.getSp().getCertResource());
assertNull(s.getProvider().getCertResource());
assertNull(s.getSp().getPrivateKeyResource());
s.setSpCert("classpath:saml/dummy.txt");
s.setIdpCert("classpath:saml/dummy.txt");
s.setPrivateKey("classpath:saml/dummy.txt");
Resource sp = s.getSpCert();
Resource idp = s.getIdpCert();
Resource pk = s.getPrivateKey();
// Set classpath resources via the nested structure
s.getSp().setCert("classpath:saml/dummy.txt");
s.getProvider().setCert("classpath:saml/dummy.txt");
s.getSp().setPrivateKey("classpath:saml/dummy.txt");
Resource sp = s.getSp().getCertResource();
Resource idp = s.getProvider().getCertResource();
Resource pk = s.getSp().getPrivateKeyResource();
assertTrue(sp.exists());
assertTrue(idp.exists());
assertTrue(pk.exists());
Path tmp = Files.createTempFile("spdf-key-", ".pem");
Files.writeString(tmp, "KEY");
s.setPrivateKey(tmp.toString());
Resource pkFs = s.getPrivateKey();
s.getSp().setPrivateKey(tmp.toString());
Resource pkFs = s.getSp().getPrivateKeyResource();
assertNotNull(pkFs);
assertTrue(pkFs.exists());
}
@@ -16,6 +16,14 @@ class GeneralUtilsAdditionalTest {
assertNull(GeneralUtils.convertSizeToBytes(null));
}
@Test
void testConvertSizeToBytesEdgeCases() {
assertNull(GeneralUtils.convertSizeToBytes("-10MB"));
assertNull(GeneralUtils.convertSizeToBytes("10000000TB")); // overflow beyond long
assertEquals(1099511627776L, GeneralUtils.convertSizeToBytes("1TB"));
assertEquals(2684354560L, GeneralUtils.convertSizeToBytes("2.5GB"));
}
@Test
void testFormatBytes() {
assertEquals("512 B", GeneralUtils.formatBytes(512));
+2 -19
View File
@@ -38,13 +38,6 @@ spotless {
}
dependencies {
if (System.getenv('STIRLING_PDF_DESKTOP_UI') != 'false'
|| (project.hasProperty('STIRLING_PDF_DESKTOP_UI')
&& project.getProperty('STIRLING_PDF_DESKTOP_UI') != 'false')) {
implementation 'org.openjfx:javafx-controls:21'
implementation 'org.openjfx:javafx-swing:21'
}
if (System.getenv('DISABLE_ADDITIONAL_FEATURES') != 'true'
|| (project.hasProperty('DISABLE_ADDITIONAL_FEATURES')
&& System.getProperty('DISABLE_ADDITIONAL_FEATURES') != 'true')) {
@@ -58,7 +51,7 @@ dependencies {
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.1'
implementation 'io.micrometer:micrometer-core:1.16.2'
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"
@@ -115,19 +108,8 @@ sourceSets {
resources {
srcDirs += ['../configs']
}
java {
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
exclude 'stirling/software/SPDF/UI/impl/**'
}
}
}
test {
java {
if (System.getenv('STIRLING_PDF_DESKTOP_UI') == 'false') {
exclude 'stirling/software/SPDF/UI/impl/**'
}
}
}
}
@@ -239,6 +221,7 @@ tasks.register('npmBuild', Exec) {
commandLine = Os.isFamily(Os.FAMILY_WINDOWS) ? ['cmd', '/c', 'npm', 'run', 'build'] : ['npm', 'run', 'build']
dependsOn npmInstall
inputs.dir(new File(frontendDir, 'src'))
inputs.dir(new File(frontendDir, 'public'))
inputs.file(new File(frontendDir, 'package.json'))
outputs.dir(frontendDistDir)
@@ -20,7 +20,6 @@ import org.springframework.scheduling.annotation.EnableScheduling;
import io.github.pixee.security.SystemCommand;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
@@ -28,7 +27,6 @@ import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.ConfigInitializer;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.UrlUtils;
@Slf4j
@EnableScheduling
@@ -60,19 +58,6 @@ public class SPDFApplication {
Properties props = new Properties();
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
System.setProperty("java.awt.headless", "false");
app.setHeadless(false);
props.put("java.awt.headless", "false");
props.put("spring.main.web-application-type", "servlet");
int desiredPort = 8080;
String port = UrlUtils.findAvailablePort(desiredPort);
props.put("server.port", port);
System.setProperty("server.port", port);
log.info("Desktop UI mode: Using port {}", port);
}
app.setAdditionalProfiles(getActiveProfile(args));
ConfigInitializer initializer = new ConfigInitializer();
@@ -153,13 +138,6 @@ public class SPDFApplication {
"Running in Tauri mode. Parent process PID: {}",
parentPid != null ? parentPid : "not set");
}
// Desktop UI initialization removed - webBrowser dependency eliminated
// Keep backwards compatibility for STIRLING_PDF_DESKTOP_UI system property
if (Boolean.parseBoolean(System.getProperty("STIRLING_PDF_DESKTOP_UI", "false"))) {
log.info("Desktop UI mode enabled, but WebBrowser functionality has been removed");
// webBrowser.initWebUI(url); // Removed - desktop UI eliminated
}
// Standard browser opening logic
String browserOpenEnv = env.getProperty("BROWSER_OPEN");
boolean browserOpen = browserOpenEnv != null && "true".equalsIgnoreCase(browserOpenEnv);
@@ -192,14 +170,6 @@ public class SPDFApplication {
}
}
@PreDestroy
public void cleanup() {
// webBrowser cleanup removed - desktop UI eliminated
// if (webBrowser != null) {
// webBrowser.cleanup();
// }
}
@EventListener
public void onWebServerInitialized(WebServerInitializedEvent event) {
int actualPort = event.getWebServer().getPort();
@@ -118,7 +118,8 @@ public class ExternalAppDepConfig {
for (String group : affectedGroups) {
List<String> affectedFeatures = getAffectedFeatures(group);
endpointConfiguration.disableGroup(group);
endpointConfiguration.disableGroup(
group, EndpointConfiguration.DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
command,
@@ -143,7 +144,8 @@ public class ExternalAppDepConfig {
commandToGroupMapping.getOrDefault(
command, List.of("Weasyprint"));
for (String group : affectedGroups) {
endpointConfiguration.disableGroup(group);
endpointConfiguration.disableGroup(
group, EndpointConfiguration.DisableReason.DEPENDENCY);
}
log.warn(
"WeasyPrint version {} is below required {} - disabling"
@@ -172,7 +174,8 @@ public class ExternalAppDepConfig {
List<String> affectedGroups =
commandToGroupMapping.getOrDefault(command, List.of("qpdf"));
for (String group : affectedGroups) {
endpointConfiguration.disableGroup(group);
endpointConfiguration.disableGroup(
group, EndpointConfiguration.DisableReason.DEPENDENCY);
}
log.warn(
"qpdf version {} is below required {} - disabling group(s): {}",
@@ -226,7 +229,8 @@ public class ExternalAppDepConfig {
int ec = runAndWait(List.of(python, "-c", "import cv2"), DEFAULT_TIMEOUT).exitCode();
if (ec != 0) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup(
"OpenCV", EndpointConfiguration.DisableReason.DEPENDENCY);
log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures));
@@ -236,8 +240,10 @@ public class ExternalAppDepConfig {
private void disablePythonAndOpenCV(String reason) {
List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("Python");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup(
"Python", EndpointConfiguration.DisableReason.DEPENDENCY);
endpointConfiguration.disableGroup(
"OpenCV", EndpointConfiguration.DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: Python (reason: {}) - Disabling Python features: {} and OpenCV"
+ " features: {}",
@@ -18,26 +18,22 @@ import org.apache.pdfbox.rendering.PDFRenderer;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.general.CropPdfForm;
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;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@GeneralApi
@RequiredArgsConstructor
@Slf4j
public class CropController {
@@ -122,7 +118,7 @@ public class CropController {
return r >= threshold && g >= threshold && b >= threshold;
}
@PostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Crops a PDF document",
description =
@@ -19,7 +19,6 @@ import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@@ -27,21 +26,23 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@GeneralApi
@Slf4j
@Tag(name = "General", description = "General APIs")
@RequiredArgsConstructor
public class EditTableOfContentsController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
@PostMapping(value = "/extract-bookmarks", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(
value = "/extract-bookmarks",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@@ -142,7 +143,9 @@ public class EditTableOfContentsController {
return bookmark;
}
@PostMapping(value = "/edit-table-of-contents", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(
value = "/edit-table-of-contents",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Edit Table of Contents",
description = "Add or edit bookmarks/table of contents in a PDF document.")
@@ -14,34 +14,32 @@ 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.model.api.general.MergeMultiplePagesRequest;
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.GeneralFormCopyUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@GeneralApi
@RequiredArgsConstructor
@Slf4j
public class MultiPageLayoutController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(value = "/multi-page-layout", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(
value = "/multi-page-layout",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Merge multiple pages of a PDF document into a single page",
description =
@@ -15,26 +15,22 @@ 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.model.api.general.ScalePagesRequest;
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;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/general")
@Tag(name = "General", description = "General APIs")
@GeneralApi
@Slf4j
@RequiredArgsConstructor
public class ScalePagesController {
@@ -116,7 +112,7 @@ public class ScalePagesController {
return sizeMap;
}
@PostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(value = "/scale-pages", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Change the size of a PDF page/document",
description =
@@ -1,14 +1,11 @@
package stirling.software.SPDF.controller.api;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
@@ -52,385 +49,4 @@ public class SettingsController {
public ResponseEntity<Map<String, Boolean>> getDisabledEndpoints() {
return ResponseEntity.ok(endpointConfiguration.getEndpointStatuses());
}
// ========== GENERAL SETTINGS ==========
@GetMapping("/admin/settings/general")
@Hidden
public ResponseEntity<Map<String, Object>> getGeneralSettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("ui", applicationProperties.getUi());
settings.put(
"system",
Map.of(
"defaultLocale", applicationProperties.getSystem().getDefaultLocale(),
"showUpdate", applicationProperties.getSystem().isShowUpdate(),
"showUpdateOnlyAdmin",
applicationProperties.getSystem().isShowUpdateOnlyAdmin(),
"customHTMLFiles", applicationProperties.getSystem().isCustomHTMLFiles(),
"fileUploadLimit", applicationProperties.getSystem().getFileUploadLimit()));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/general")
@Hidden
public ResponseEntity<String> updateGeneralSettings(@RequestBody Map<String, Object> settings)
throws IOException {
// Update UI settings
if (settings.containsKey("ui")) {
Map<String, String> ui = (Map<String, String>) settings.get("ui");
if (ui.containsKey("appNameNavbar")) {
GeneralUtils.saveKeyToSettings("ui.appNameNavbar", ui.get("appNameNavbar"));
applicationProperties.getUi().setAppNameNavbar(ui.get("appNameNavbar"));
}
}
// Update System settings
if (settings.containsKey("system")) {
Map<String, Object> system = (Map<String, Object>) settings.get("system");
if (system.containsKey("defaultLocale")) {
GeneralUtils.saveKeyToSettings("system.defaultLocale", system.get("defaultLocale"));
applicationProperties
.getSystem()
.setDefaultLocale((String) system.get("defaultLocale"));
}
if (system.containsKey("showUpdate")) {
GeneralUtils.saveKeyToSettings("system.showUpdate", system.get("showUpdate"));
applicationProperties.getSystem().setShowUpdate((Boolean) system.get("showUpdate"));
}
if (system.containsKey("showUpdateOnlyAdmin")) {
GeneralUtils.saveKeyToSettings(
"system.showUpdateOnlyAdmin", system.get("showUpdateOnlyAdmin"));
applicationProperties
.getSystem()
.setShowUpdateOnlyAdmin((Boolean) system.get("showUpdateOnlyAdmin"));
}
if (system.containsKey("fileUploadLimit")) {
GeneralUtils.saveKeyToSettings(
"system.fileUploadLimit", system.get("fileUploadLimit"));
applicationProperties
.getSystem()
.setFileUploadLimit((String) system.get("fileUploadLimit"));
}
}
return ResponseEntity.ok(
"General settings updated. Restart required for changes to take effect.");
}
// ========== SECURITY SETTINGS ==========
@GetMapping("/admin/settings/security")
@Hidden
public ResponseEntity<Map<String, Object>> getSecuritySettings() {
Map<String, Object> settings = new HashMap<>();
ApplicationProperties.Security security = applicationProperties.getSecurity();
settings.put("enableLogin", security.isEnableLogin());
settings.put("loginMethod", security.getLoginMethod());
settings.put("loginAttemptCount", security.getLoginAttemptCount());
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
settings.put(
"initialLogin",
Map.of(
"username",
security.getInitialLogin().getUsername() != null
? security.getInitialLogin().getUsername()
: ""));
// JWT settings
ApplicationProperties.Security.Jwt jwt = security.getJwt();
settings.put(
"jwt",
Map.of(
"enableKeystore", jwt.isEnableKeystore(),
"enableKeyRotation", jwt.isEnableKeyRotation(),
"enableKeyCleanup", jwt.isEnableKeyCleanup(),
"keyRetentionDays", jwt.getKeyRetentionDays()));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/security")
@Hidden
public ResponseEntity<String> updateSecuritySettings(@RequestBody Map<String, Object> settings)
throws IOException {
if (settings.containsKey("enableLogin")) {
GeneralUtils.saveKeyToSettings("security.enableLogin", settings.get("enableLogin"));
applicationProperties
.getSecurity()
.setEnableLogin((Boolean) settings.get("enableLogin"));
}
if (settings.containsKey("loginMethod")) {
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
applicationProperties
.getSecurity()
.setLoginMethod((String) settings.get("loginMethod"));
}
if (settings.containsKey("loginAttemptCount")) {
GeneralUtils.saveKeyToSettings(
"security.loginAttemptCount", settings.get("loginAttemptCount"));
applicationProperties
.getSecurity()
.setLoginAttemptCount((Integer) settings.get("loginAttemptCount"));
}
if (settings.containsKey("loginResetTimeMinutes")) {
GeneralUtils.saveKeyToSettings(
"security.loginResetTimeMinutes", settings.get("loginResetTimeMinutes"));
applicationProperties
.getSecurity()
.setLoginResetTimeMinutes(
((Number) settings.get("loginResetTimeMinutes")).longValue());
}
// JWT settings
if (settings.containsKey("jwt")) {
Map<String, Object> jwt = (Map<String, Object>) settings.get("jwt");
if (jwt.containsKey("keyRetentionDays")) {
GeneralUtils.saveKeyToSettings(
"security.jwt.keyRetentionDays", jwt.get("keyRetentionDays"));
applicationProperties
.getSecurity()
.getJwt()
.setKeyRetentionDays((Integer) jwt.get("keyRetentionDays"));
}
}
return ResponseEntity.ok(
"Security settings updated. Restart required for changes to take effect.");
}
// ========== CONNECTIONS SETTINGS (OAuth/SAML) ==========
@GetMapping("/admin/settings/connections")
@Hidden
public ResponseEntity<Map<String, Object>> getConnectionsSettings() {
Map<String, Object> settings = new HashMap<>();
ApplicationProperties.Security security = applicationProperties.getSecurity();
// OAuth2 settings
ApplicationProperties.Security.OAUTH2 oauth2 = security.getOauth2();
settings.put(
"oauth2",
Map.of(
"enabled", oauth2.getEnabled(),
"issuer", oauth2.getIssuer() != null ? oauth2.getIssuer() : "",
"clientId", oauth2.getClientId() != null ? oauth2.getClientId() : "",
"provider", oauth2.getProvider() != null ? oauth2.getProvider() : "",
"autoCreateUser", oauth2.getAutoCreateUser(),
"blockRegistration", oauth2.getBlockRegistration(),
"useAsUsername",
oauth2.getUseAsUsername() != null
? oauth2.getUseAsUsername()
: ""));
// SAML2 settings
ApplicationProperties.Security.SAML2 saml2 = security.getSaml2();
settings.put(
"saml2",
Map.of(
"enabled", saml2.getEnabled(),
"provider", saml2.getProvider() != null ? saml2.getProvider() : "",
"autoCreateUser", saml2.getAutoCreateUser(),
"blockRegistration", saml2.getBlockRegistration(),
"registrationId", saml2.getRegistrationId()));
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/connections")
@Hidden
public ResponseEntity<String> updateConnectionsSettings(
@RequestBody Map<String, Object> settings) throws IOException {
// OAuth2 settings
if (settings.containsKey("oauth2")) {
Map<String, Object> oauth2 = (Map<String, Object>) settings.get("oauth2");
if (oauth2.containsKey("enabled")) {
GeneralUtils.saveKeyToSettings("security.oauth2.enabled", oauth2.get("enabled"));
applicationProperties
.getSecurity()
.getOauth2()
.setEnabled((Boolean) oauth2.get("enabled"));
}
if (oauth2.containsKey("issuer")) {
GeneralUtils.saveKeyToSettings("security.oauth2.issuer", oauth2.get("issuer"));
applicationProperties
.getSecurity()
.getOauth2()
.setIssuer((String) oauth2.get("issuer"));
}
if (oauth2.containsKey("clientId")) {
GeneralUtils.saveKeyToSettings("security.oauth2.clientId", oauth2.get("clientId"));
applicationProperties
.getSecurity()
.getOauth2()
.setClientId((String) oauth2.get("clientId"));
}
if (oauth2.containsKey("clientSecret")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.clientSecret", oauth2.get("clientSecret"));
applicationProperties
.getSecurity()
.getOauth2()
.setClientSecret((String) oauth2.get("clientSecret"));
}
if (oauth2.containsKey("provider")) {
GeneralUtils.saveKeyToSettings("security.oauth2.provider", oauth2.get("provider"));
applicationProperties
.getSecurity()
.getOauth2()
.setProvider((String) oauth2.get("provider"));
}
if (oauth2.containsKey("autoCreateUser")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.autoCreateUser", oauth2.get("autoCreateUser"));
applicationProperties
.getSecurity()
.getOauth2()
.setAutoCreateUser((Boolean) oauth2.get("autoCreateUser"));
}
if (oauth2.containsKey("blockRegistration")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.blockRegistration", oauth2.get("blockRegistration"));
applicationProperties
.getSecurity()
.getOauth2()
.setBlockRegistration((Boolean) oauth2.get("blockRegistration"));
}
if (oauth2.containsKey("useAsUsername")) {
GeneralUtils.saveKeyToSettings(
"security.oauth2.useAsUsername", oauth2.get("useAsUsername"));
applicationProperties
.getSecurity()
.getOauth2()
.setUseAsUsername((String) oauth2.get("useAsUsername"));
}
}
// SAML2 settings
if (settings.containsKey("saml2")) {
Map<String, Object> saml2 = (Map<String, Object>) settings.get("saml2");
if (saml2.containsKey("enabled")) {
GeneralUtils.saveKeyToSettings("security.saml2.enabled", saml2.get("enabled"));
applicationProperties
.getSecurity()
.getSaml2()
.setEnabled((Boolean) saml2.get("enabled"));
}
if (saml2.containsKey("provider")) {
GeneralUtils.saveKeyToSettings("security.saml2.provider", saml2.get("provider"));
applicationProperties
.getSecurity()
.getSaml2()
.setProvider((String) saml2.get("provider"));
}
if (saml2.containsKey("autoCreateUser")) {
GeneralUtils.saveKeyToSettings(
"security.saml2.autoCreateUser", saml2.get("autoCreateUser"));
applicationProperties
.getSecurity()
.getSaml2()
.setAutoCreateUser((Boolean) saml2.get("autoCreateUser"));
}
if (saml2.containsKey("blockRegistration")) {
GeneralUtils.saveKeyToSettings(
"security.saml2.blockRegistration", saml2.get("blockRegistration"));
applicationProperties
.getSecurity()
.getSaml2()
.setBlockRegistration((Boolean) saml2.get("blockRegistration"));
}
}
return ResponseEntity.ok(
"Connection settings updated. Restart required for changes to take effect.");
}
// ========== PRIVACY SETTINGS ==========
@GetMapping("/admin/settings/privacy")
@Hidden
public ResponseEntity<Map<String, Object>> getPrivacySettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("enableAnalytics", applicationProperties.getSystem().getEnableAnalytics());
settings.put("googleVisibility", applicationProperties.getSystem().isGooglevisibility());
settings.put("metricsEnabled", applicationProperties.getMetrics().isEnabled());
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/privacy")
@Hidden
public ResponseEntity<String> updatePrivacySettings(@RequestBody Map<String, Object> settings)
throws IOException {
if (settings.containsKey("enableAnalytics")) {
GeneralUtils.saveKeyToSettings(
"system.enableAnalytics", settings.get("enableAnalytics"));
applicationProperties
.getSystem()
.setEnableAnalytics((Boolean) settings.get("enableAnalytics"));
}
if (settings.containsKey("googleVisibility")) {
GeneralUtils.saveKeyToSettings(
"system.googlevisibility", settings.get("googleVisibility"));
applicationProperties
.getSystem()
.setGooglevisibility((Boolean) settings.get("googleVisibility"));
}
if (settings.containsKey("metricsEnabled")) {
GeneralUtils.saveKeyToSettings("metrics.enabled", settings.get("metricsEnabled"));
applicationProperties.getMetrics().setEnabled((Boolean) settings.get("metricsEnabled"));
}
return ResponseEntity.ok(
"Privacy settings updated. Restart required for changes to take effect.");
}
// ========== ADVANCED SETTINGS ==========
@GetMapping("/admin/settings/advanced")
@Hidden
public ResponseEntity<Map<String, Object>> getAdvancedSettings() {
Map<String, Object> settings = new HashMap<>();
settings.put("endpoints", applicationProperties.getEndpoints());
settings.put(
"enableAlphaFunctionality",
applicationProperties.getSystem().isEnableAlphaFunctionality());
settings.put("maxDPI", applicationProperties.getSystem().getMaxDPI());
settings.put("enableUrlToPDF", applicationProperties.getSystem().isEnableUrlToPDF());
settings.put("customPaths", applicationProperties.getSystem().getCustomPaths());
settings.put(
"tempFileManagement", applicationProperties.getSystem().getTempFileManagement());
return ResponseEntity.ok(settings);
}
@PostMapping("/admin/settings/advanced")
@Hidden
public ResponseEntity<String> updateAdvancedSettings(@RequestBody Map<String, Object> settings)
throws IOException {
if (settings.containsKey("enableAlphaFunctionality")) {
GeneralUtils.saveKeyToSettings(
"system.enableAlphaFunctionality", settings.get("enableAlphaFunctionality"));
applicationProperties
.getSystem()
.setEnableAlphaFunctionality(
(Boolean) settings.get("enableAlphaFunctionality"));
}
if (settings.containsKey("maxDPI")) {
GeneralUtils.saveKeyToSettings("system.maxDPI", settings.get("maxDPI"));
applicationProperties.getSystem().setMaxDPI((Integer) settings.get("maxDPI"));
}
if (settings.containsKey("enableUrlToPDF")) {
GeneralUtils.saveKeyToSettings("system.enableUrlToPDF", settings.get("enableUrlToPDF"));
applicationProperties
.getSystem()
.setEnableUrlToPDF((Boolean) settings.get("enableUrlToPDF"));
}
return ResponseEntity.ok(
"Advanced settings updated. Restart required for changes to take effect.");
}
}
@@ -15,20 +15,18 @@ import org.apache.pdfbox.pdmodel.PDDocument;
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.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertEbookToPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -36,9 +34,7 @@ import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@ConvertApi
@RequiredArgsConstructor
@Slf4j
public class ConvertEbookToPDFController {
@@ -58,7 +54,7 @@ public class ConvertEbookToPDFController {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ebook/pdf")
@Operation(
summary = "Convert an eBook file to PDF",
description =
@@ -12,14 +12,10 @@ import org.apache.commons.io.FilenameUtils;
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.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -28,15 +24,15 @@ import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest;
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.OutputFormat;
import stirling.software.SPDF.model.api.converters.ConvertPdfToEpubRequest.TargetDevice;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@ConvertApi
@RequiredArgsConstructor
@Slf4j
public class ConvertPDFToEpubController {
@@ -77,7 +73,7 @@ public class ConvertPDFToEpubController {
return command;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/epub")
@Operation(
summary = "Convert PDF to EPUB/AZW3",
description =
@@ -3,31 +3,27 @@ package stirling.software.SPDF.controller.api.converters;
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 stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@RequestMapping("/api/v1/convert")
@ConvertApi
@RequiredArgsConstructor
public class ConvertPDFToHtml {
private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/html")
@Operation(
summary = "Convert PDF to HTML",
description =
@@ -7,19 +7,17 @@ import org.apache.pdfbox.text.PDFTextStripper;
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 stirling.software.SPDF.model.api.converters.PdfToPresentationRequest;
import stirling.software.SPDF.model.api.converters.PdfToTextOrRTFRequest;
import stirling.software.SPDF.model.api.converters.PdfToWordRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -28,9 +26,7 @@ import stirling.software.common.util.PDFToFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@ConvertApi
@RequiredArgsConstructor
public class ConvertPDFToOffice {
@@ -38,7 +34,7 @@ public class ConvertPDFToOffice {
private final TempFileManager tempFileManager;
private final RuntimePathConfig runtimePathConfig;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/presentation")
@Operation(
summary = "Convert PDF to Presentation format",
description =
@@ -53,7 +49,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "impress_pdf_import");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/text")
@Operation(
summary = "Convert PDF to Text or RTF format",
description =
@@ -79,7 +75,7 @@ public class ConvertPDFToOffice {
}
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/word")
@Operation(
summary = "Convert PDF to Word document",
description =
@@ -93,7 +89,7 @@ public class ConvertPDFToOffice {
return pdfToFile.processPdfToOfficeFormat(inputFile, outputFormat, "writer_pdf_import");
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/xml")
@Operation(
summary = "Convert PDF to XML",
description =
@@ -74,30 +74,26 @@ import org.apache.xmpbox.xml.XmpSerializer;
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.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.converters.PdfToPdfARequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@ConvertApi
@Slf4j
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
public class ConvertPDFToPDFA {
@@ -483,15 +479,22 @@ public class ConvertPDFToPDFA {
command.add("-dCompatibilityLevel=" + profile.getCompatibilityLevel());
command.add("-sDEVICE=pdfwrite");
command.add("-sColorConversionStrategy=RGB");
command.add("-dProcessColorModel=/DeviceRGB");
command.add("-sOutputICCProfile=" + colorProfiles.rgb().toAbsolutePath());
command.add("-sDefaultRGBProfile=" + colorProfiles.rgb().toAbsolutePath());
command.add("-sDefaultGrayProfile=" + colorProfiles.gray().toAbsolutePath());
command.add("-dEmbedAllFonts=true");
command.add("-dSubsetFonts=false"); // Embed complete fonts to avoid incomplete glyphs
command.add("-dSubsetFonts=true");
command.add("-dCompressFonts=true");
command.add("-dNOSUBSTFONTS=false"); // Allow font substitution for problematic fonts
command.add("-dPDFSETTINGS=/prepress");
// Explicitly tune downsampling/compression for high-quality print
command.add("-dColorImageDownsampleType=/Bicubic");
command.add("-dColorImageResolution=300");
command.add("-dGrayImageDownsampleType=/Bicubic");
command.add("-dGrayImageResolution=300");
command.add("-dMonoImageDownsampleType=/Bicubic");
command.add("-dMonoImageResolution=1200");
command.add("-dNOPAUSE");
command.add("-dBATCH");
command.add("-dNOOUTERSAVE");
@@ -562,7 +565,7 @@ public class ConvertPDFToPDFA {
}
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/pdfa")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/pdfa")
@Operation(
summary = "Convert a PDF to a PDF/A or PDF/X",
description =
@@ -2449,9 +2452,7 @@ public class ConvertPDFToPDFA {
@Getter
private enum PdfXProfile {
PDF_X_1("PDF/X-1", "_PDFX-1.pdf", "1.3", "2001", "pdfx-1", "pdfx"),
PDF_X_3("PDF/X-3", "_PDFX-3.pdf", "1.3", "2003", "pdfx-3"),
PDF_X_4("PDF/X-4", "_PDFX-4.pdf", "1.4", "2008", "pdfx-4");
PDF_X("PDF/X", "_PDFX.pdf", "1.6", "2008", "pdfx");
private final String displayName;
private final String suffix;
@@ -2477,7 +2478,7 @@ public class ConvertPDFToPDFA {
static PdfXProfile fromRequest(String requestToken) {
if (requestToken == null) {
return PDF_X_4;
return PDF_X;
}
String normalized = requestToken.trim().toLowerCase(Locale.ROOT);
Optional<PdfXProfile> match =
@@ -2485,7 +2486,7 @@ public class ConvertPDFToPDFA {
.filter(profile -> profile.requestTokens.contains(normalized))
.findFirst();
return match.orElse(PDF_X_4);
return match.orElse(PDF_X);
}
String outputSuffix() {
@@ -9,7 +9,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
@@ -89,7 +88,7 @@ public class ConvertPdfJsonController {
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
}
@PostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf/text-editor/metadata")
@Operation(
summary = "Extract PDF metadata for text editor lazy loading",
description =
@@ -127,7 +126,7 @@ public class ConvertPdfJsonController {
.body(jsonBytes);
}
@PostMapping(
@AutoJobPostMapping(
value = "/pdf/text-editor/partial/{jobId}",
consumes = MediaType.APPLICATION_JSON_VALUE)
@StandardPdfResponse
@@ -180,7 +179,7 @@ public class ConvertPdfJsonController {
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@PostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
@Operation(
summary = "Clear cached PDF document for text editor",
description =
@@ -27,18 +27,16 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
@@ -51,9 +49,7 @@ import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@Tag(name = "Convert", description = "Convert APIs")
@ConvertApi
@RequiredArgsConstructor
public class ConvertPdfToVideoController {
@@ -68,7 +64,7 @@ public class ConvertPdfToVideoController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
@Operation(
summary = "Convert PDF to Video Slideshow",
description =
@@ -21,19 +21,17 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import org.springframework.web.util.UriComponentsBuilder;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.converters.UrlToPdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -43,10 +41,8 @@ import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@Tag(name = "Convert", description = "Convert APIs")
@ConvertApi
@Slf4j
@RequestMapping("/api/v1/convert")
@RequiredArgsConstructor
public class ConvertWebsiteToPDF {
@@ -59,7 +55,7 @@ public class ConvertWebsiteToPDF {
private static final Pattern NUMERIC_HTML_ENTITY_PATTERN = Pattern.compile("&#(x?[0-9a-f]+);");
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/url/pdf")
@Operation(
summary = "Convert a URL to a PDF",
description =
@@ -13,12 +13,8 @@ import org.apache.commons.io.FilenameUtils;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
@@ -27,6 +23,8 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.converters.PdfVectorExportRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.ProcessExecutor;
@@ -35,10 +33,8 @@ import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/convert")
@ConvertApi
@Slf4j
@Tag(name = "Convert", description = "Convert APIs")
@RequiredArgsConstructor
public class PdfVectorExportController {
@@ -49,7 +45,7 @@ public class PdfVectorExportController {
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/vector/pdf")
@Operation(
summary = "Convert PostScript formats to PDF",
description =
@@ -94,7 +90,7 @@ public class PdfVectorExportController {
}
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/vector")
@Operation(
summary = "Convert PDF to vector format",
description =
@@ -8,9 +8,6 @@ import org.apache.pdfbox.pdmodel.common.PDRectangle;
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.github.pixee.security.Filenames;
@@ -18,7 +15,6 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
@@ -28,20 +24,22 @@ import stirling.software.SPDF.model.api.filter.ContainsTextRequest;
import stirling.software.SPDF.model.api.filter.FileSizeRequest;
import stirling.software.SPDF.model.api.filter.PageRotationRequest;
import stirling.software.SPDF.model.api.filter.PageSizeRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.FilterApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.PdfUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/filter")
@Tag(name = "Filter", description = "Filter APIs")
@FilterApi
@RequiredArgsConstructor
public class FilterController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-text")
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-contains-text")
@Operation(
summary = "Checks if a PDF contains set text, returns true if does",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -70,7 +68,9 @@ public class FilterController {
return ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-contains-image")
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-contains-image")
@Operation(
summary = "Checks if a PDF contains an image",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -98,7 +98,9 @@ public class FilterController {
return ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-count")
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-page-count")
@Operation(
summary = "Checks if a PDF is greater, less or equal to a setPageCount",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -129,7 +131,7 @@ public class FilterController {
: ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-size")
@Operation(
summary = "Checks if a PDF is of a certain size",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -166,7 +168,7 @@ public class FilterController {
: ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-file-size")
@Operation(
summary = "Checks if a PDF is a set file size",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -194,7 +196,9 @@ public class FilterController {
: ResponseEntity.noContent().build();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/filter-page-rotation")
@AutoJobPostMapping(
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
value = "/filter-page-rotation")
@Operation(
summary = "Checks if a PDF is of a certain rotation",
description = "Input:PDF Output:Boolean Type:SISO")
@@ -36,20 +36,18 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.misc.OptimizePdfRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.LineArtConversionService;
import stirling.software.common.util.ExceptionUtils;
@@ -60,10 +58,8 @@ import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@MiscApi
@Slf4j
@Tag(name = "Misc", description = "Miscellaneous APIs")
@RequiredArgsConstructor
public class CompressController {
@@ -922,7 +918,7 @@ public class CompressController {
return Math.min(9, currentLevel + 1);
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/compress-pdf")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/compress-pdf")
@Operation(
summary = "Optimize PDF file",
description =
@@ -112,6 +112,16 @@ public class ConfigController {
"showSettingsWhenNoLogin",
applicationProperties.getSystem().isShowSettingsWhenNoLogin());
// SSO Provider settings
boolean enableOAuth =
applicationProperties.getSecurity().getOauth2() != null
&& applicationProperties.getSecurity().getOauth2().getEnabled();
boolean enableSaml =
applicationProperties.getSecurity().getSaml2() != null
&& applicationProperties.getSecurity().getSaml2().getEnabled();
configData.put("enableOAuth", enableOAuth);
configData.put("enableSaml", enableSaml);
// Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
@@ -24,20 +24,18 @@ import org.apache.pdfbox.text.PDFTextStripper;
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.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.model.api.misc.ProcessPdfWithOcrRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -50,9 +48,7 @@ import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@MiscApi
@Slf4j
@RequiredArgsConstructor
public class OCRController {
@@ -85,7 +81,7 @@ public class OCRController {
.toList();
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/ocr-pdf")
@Operation(
summary = "Process a PDF file with OCR",
description =
@@ -7,11 +7,13 @@ import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.imageio.ImageIO;
@@ -34,17 +36,15 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
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 stirling.software.SPDF.model.api.misc.AddStampRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
@@ -53,15 +53,20 @@ import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/misc")
@Tag(name = "Misc", description = "Miscellaneous APIs")
@MiscApi
@RequiredArgsConstructor
public class StampController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private static final int MAX_DATE_FORMAT_LENGTH = 50;
private static final Pattern SAFE_DATE_FORMAT_PATTERN =
Pattern.compile("^[yMdHhmsS/\\-:\\s.,'+EGuwWDFzZXa]+$");
private static final Pattern CUSTOM_DATE_PATTERN = Pattern.compile("@date\\{([^}]{1,50})\\}");
// Placeholder for escaped @ symbol (using Unicode private use area)
private static final String ESCAPED_AT_PLACEHOLDER = "\uE000ESCAPED_AT\uE000";
/**
* Initialize data binder for multipart file uploads. This method registers a custom editor for
* MultipartFile to handle file uploads. It sets the MultipartFile to null if the uploaded file
@@ -79,7 +84,7 @@ public class StampController {
});
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/add-stamp")
@Operation(
summary = "Add stamp to a PDF file",
description =
@@ -170,7 +175,9 @@ public class StampController {
overrideX,
overrideY,
margin,
customColor);
customColor,
pageIndex,
pdfFileName);
} else if ("image".equalsIgnoreCase(stampType)) {
addImageStamp(
contentStream,
@@ -205,9 +212,11 @@ public class StampController {
float fontSize,
String alphabet,
float overrideX, // X override
float overrideY,
float overrideY, // Y override
float margin,
String colorString) // Y override
String colorString,
int currentPageNumber,
String filename)
throws IOException {
String resourceDir;
PDFont font = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
@@ -235,8 +244,6 @@ public class StampController {
}
}
contentStream.setFont(font, fontSize);
Color redactColor;
try {
if (!colorString.startsWith("#")) {
@@ -244,45 +251,54 @@ public class StampController {
}
redactColor = Color.decode(colorString);
} catch (NumberFormatException e) {
redactColor = Color.LIGHT_GRAY;
}
contentStream.setNonStrokingColor(redactColor);
PDRectangle pageSize = page.getMediaBox();
float x, y;
if (overrideX >= 0 && overrideY >= 0) {
// Use override values if provided
x = overrideX;
y = overrideY;
} else {
x = calculatePositionX(pageSize, position, fontSize, font, fontSize, stampText, margin);
y =
calculatePositionY(
pageSize, position, calculateTextCapHeight(font, fontSize), margin);
}
String currentDate = LocalDate.now().toString();
String currentTime = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
int pageCount = document.getNumberOfPages();
String processedStampText =
stampText
.replace("@date", currentDate)
.replace("@time", currentTime)
.replace("@page_count", String.valueOf(pageCount));
processStampText(stampText, currentPageNumber, pageCount, filename, document);
// Split the stampText into multiple lines
String[] lines =
RegexPatternUtils.getInstance().getEscapedNewlinePattern().split(stampText);
String normalizedText =
RegexPatternUtils.getInstance()
.getEscapedNewlinePattern()
.matcher(processedStampText)
.replaceAll("\n");
String[] lines = normalizedText.split("\\r?\\n");
PDRectangle pageSize = page.getMediaBox();
// Use fontSize directly (default 40 if not specified)
float effectiveFontSize = fontSize > 0 ? fontSize : 40f;
contentStream.setFont(font, effectiveFontSize);
// Calculate dynamic line height based on font ascent and descent
float ascent = font.getFontDescriptor().getAscent();
float descent = font.getFontDescriptor().getDescent();
float lineHeight = ((ascent - descent) / 1000) * fontSize;
float lineHeight = ((ascent - descent) / 1000) * effectiveFontSize;
float maxLineWidth = 0;
for (String line : lines) {
float lineWidth = calculateTextWidth(line, font, effectiveFontSize);
if (lineWidth > maxLineWidth) {
maxLineWidth = lineWidth;
}
}
float totalTextHeight = lines.length * lineHeight;
float x, y;
if (overrideX >= 0 && overrideY >= 0) {
x = overrideX;
y = overrideY;
} else {
x = calculatePositionX(pageSize, position, maxLineWidth, margin);
y = calculatePositionY(pageSize, position, totalTextHeight, margin);
}
contentStream.beginText();
for (int i = 0; i < lines.length; i++) {
@@ -295,6 +311,140 @@ public class StampController {
contentStream.endText();
}
/**
* Process stamp text by replacing all @commands with their actual values. Supported commands:
*
* <p>Date & Time:
*
* <ul>
* <li>@date - Current date (YYYY-MM-DD)
* <li>@time - Current time (HH:mm:ss)
* <li>@datetime - Current date and time (YYYY-MM-DD HH:mm:ss)
* <li>@date{format} - Custom date/time format (e.g., @date{dd/MM/yyyy})
* <li>@year - Current year (4 digits)
* <li>@month - Current month (01-12)
* <li>@day - Current day of month (01-31)
* </ul>
*
* <p>Page Information:
*
* <ul>
* <li>@page_number or @page - Current page number
* <li>@total_pages or @page_count - Total number of pages
* </ul>
*
* <p>File Information:
*
* <ul>
* <li>@filename - Original filename (without extension)
* <li>@filename_full - Original filename (with extension)
* </ul>
*
* <p>Document Metadata:
*
* <ul>
* <li>@author - Document author (from PDF metadata)
* <li>@title - Document title (from PDF metadata)
* <li>@subject - Document subject (from PDF metadata)
* </ul>
*
* <p>Other:
*
* <ul>
* <li>@uuid - Short unique identifier (8 characters)
* </ul>
*/
private String processStampText(
String stampText,
int currentPageNumber,
int totalPages,
String filename,
PDDocument document) {
if (stampText == null || stampText.isEmpty()) {
return "";
}
// Handle escaped @@ sequences first - replace with placeholder to preserve literal @
String result = stampText.replace("@@", ESCAPED_AT_PLACEHOLDER);
LocalDateTime now = LocalDateTime.now();
String currentDate = now.toLocalDate().toString();
String currentTime = now.toLocalTime().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
String currentDateTime = now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
String filenameWithoutExt = filename != null ? filename : "";
if (filename != null && filename.contains(".")) {
int lastDot = filename.lastIndexOf('.');
if (lastDot > 0) { // Ensure there's actually a name before the dot
filenameWithoutExt = filename.substring(0, lastDot);
}
}
String author = "";
String title = "";
String subject = "";
if (document != null && document.getDocumentInformation() != null) {
var info = document.getDocumentInformation();
author = info.getAuthor() != null ? info.getAuthor() : "";
title = info.getTitle() != null ? info.getTitle() : "";
subject = info.getSubject() != null ? info.getSubject() : "";
}
String uuid = UUID.randomUUID().toString().substring(0, 8);
// Process @date{format} with custom format first (must be before simple @date)
Matcher matcher = CUSTOM_DATE_PATTERN.matcher(result);
StringBuilder sb = new StringBuilder();
while (matcher.find()) {
String format = matcher.group(1);
String replacement = processCustomDateFormat(format, now);
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
}
matcher.appendTail(sb);
result = sb.toString();
result =
result.replace("@datetime", currentDateTime)
.replace("@date", currentDate)
.replace("@time", currentTime)
.replace("@year", String.valueOf(now.getYear()))
.replace("@month", String.format("%02d", now.getMonthValue()))
.replace("@day", String.format("%02d", now.getDayOfMonth()))
.replace("@page_number", String.valueOf(currentPageNumber))
.replace(
"@page_count", String.valueOf(totalPages)) // Must come before @page
.replace("@total_pages", String.valueOf(totalPages))
.replace(
"@page",
String.valueOf(currentPageNumber)) // Must come after @page_count
.replace("@filename_full", filename != null ? filename : "")
.replace("@filename", filenameWithoutExt)
.replace("@author", author)
.replace("@title", title)
.replace("@subject", subject)
.replace("@uuid", uuid);
result = result.replace(ESCAPED_AT_PLACEHOLDER, "@");
return result;
}
private String processCustomDateFormat(String format, LocalDateTime now) {
if (format == null || format.length() > MAX_DATE_FORMAT_LENGTH) {
return "[invalid format: too long]";
}
if (!SAFE_DATE_FORMAT_PATTERN.matcher(format).matches()) {
return "[invalid format]";
}
try {
return now.format(DateTimeFormatter.ofPattern(format));
} catch (IllegalArgumentException e) {
return "[invalid format: " + format + "]";
}
}
private void addImageStamp(
PDPageContentStream contentStream,
MultipartFile stampImage,
@@ -331,8 +481,8 @@ public class StampController {
x = overrideX;
y = overrideY;
} else {
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, null, 0, null, margin);
y = calculatePositionY(pageSize, position, fontSize, margin);
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, margin);
y = calculatePositionY(pageSize, position, desiredPhysicalHeight, margin);
}
contentStream.saveGraphicsState();
@@ -343,23 +493,14 @@ public class StampController {
}
private float calculatePositionX(
PDRectangle pageSize,
int position,
float contentWidth,
PDFont font,
float fontSize,
String text,
float margin)
throws IOException {
float actualWidth =
(text != null) ? calculateTextWidth(text, font, fontSize) : contentWidth;
PDRectangle pageSize, int position, float contentWidth, float margin) {
return switch (position % 3) {
case 1: // Left
yield pageSize.getLowerLeftX() + margin;
case 2: // Center
yield (pageSize.getWidth() - actualWidth) / 2;
yield (pageSize.getWidth() - contentWidth) / 2;
case 0: // Right
yield pageSize.getUpperRightX() - actualWidth - margin;
yield pageSize.getUpperRightX() - contentWidth - margin;
default:
yield 0;
};
@@ -368,12 +509,12 @@ public class StampController {
private float calculatePositionY(
PDRectangle pageSize, int position, float height, float margin) {
return switch ((position - 1) / 3) {
case 0: // Top
yield pageSize.getUpperRightY() - height - margin;
case 1: // Middle
yield (pageSize.getHeight() - height) / 2;
case 2: // Bottom
yield pageSize.getLowerLeftY() + margin;
case 0: // Top - first line near the top
yield pageSize.getUpperRightY() - margin;
case 1: // Middle - center of text block at page center
yield (pageSize.getHeight() + height) / 2;
case 2: // Bottom - first line positioned so last line is at bottom margin
yield pageSize.getLowerLeftY() + margin + height;
default:
yield 0;
};
@@ -382,8 +523,4 @@ public class StampController {
private float calculateTextWidth(String text, PDFont font, float fontSize) throws IOException {
return font.getStringWidth(text) / 1000 * fontSize;
}
private float calculateTextCapHeight(PDFont font, float fontSize) {
return font.getFontDescriptor().getCapHeight() / 1000 * fontSize;
}
}
@@ -3,21 +3,15 @@ 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;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.regex.Matcher;
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.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;
@@ -35,8 +29,7 @@ import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentGroup;
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.action.*;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationFileAttachment;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
@@ -44,23 +37,14 @@ import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlin
import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.preflight.PreflightDocument;
import org.apache.pdfbox.preflight.ValidationResult;
import org.apache.pdfbox.preflight.exception.SyntaxValidationException;
import org.apache.pdfbox.preflight.exception.ValidationException;
import org.apache.pdfbox.preflight.parser.PreflightParser;
import org.apache.pdfbox.text.PDFTextStripper;
import org.apache.xmpbox.XMPMetadata;
import org.apache.xmpbox.schema.PDFAIdentificationSchema;
import org.apache.xmpbox.xml.DomXmpParser;
import org.apache.xmpbox.xml.XmpParsingException;
import org.apache.xmpbox.xml.XmpSerializer;
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 com.fasterxml.jackson.databind.ObjectMapper;
@@ -68,21 +52,22 @@ import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.WebResponseUtils;
@RestController
@RequestMapping("/api/v1/security")
@SecurityApi
@Slf4j
@Tag(name = "Security", description = "Security APIs")
@RequiredArgsConstructor
public class GetInfoOnPDF {
@@ -95,6 +80,7 @@ public class GetInfoOnPDF {
private static final ObjectMapper objectMapper = new ObjectMapper();
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final VeraPDFService veraPDFService;
private static void addOutlinesToArray(PDOutlineItem outline, ArrayNode arrayNode) {
if (outline == null) return;
@@ -111,214 +97,8 @@ public class GetInfoOnPDF {
}
}
public static boolean checkForStandard(PDDocument document, String standardKeyword) {
if ("PDF/A".equalsIgnoreCase(standardKeyword)) {
return getPdfAConformanceLevel(document) != null;
}
return checkStandardInMetadata(document, standardKeyword);
}
public static String getPdfAConformanceLevel(PDDocument document) {
if (document == null || document.isEncrypted()) {
return null;
}
return getPdfAVersionFromMetadata(document);
}
private static String getPdfAVersionFromMetadata(PDDocument document) {
try {
PDMetadata pdMetadata = document.getDocumentCatalog().getMetadata();
if (pdMetadata != null) {
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
DomXmpParser domXmpParser = new DomXmpParser();
XMPMetadata xmpMeta = domXmpParser.parse(metaStream);
PDFAIdentificationSchema pdfId = xmpMeta.getPDFAIdentificationSchema();
if (pdfId != null) {
Integer part = pdfId.getPart();
String conformance = pdfId.getConformance();
if (part != null && conformance != null) {
return part + conformance.toUpperCase(Locale.ROOT);
}
} else {
try (COSInputStream rawStream = pdMetadata.createInputStream()) {
byte[] metadataBytes = rawStream.readAllBytes();
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
String extracted = extractPdfAVersionFromRawXml(rawMetadata);
if (extracted != null) {
return extracted;
}
}
}
} catch (XmpParsingException e) {
log.debug("XMP parsing failed, trying raw metadata search: {}", e.getMessage());
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
byte[] metadataBytes = metaStream.readAllBytes();
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
String extracted = extractPdfAVersionFromRawXml(rawMetadata);
if (extracted != null) {
return extracted;
}
}
}
}
} catch (Exception e) {
log.debug("Error reading PDF/A metadata: {}", e.getMessage());
}
return null;
}
private static String extractPdfAVersionFromRawXml(String rawXml) {
if (rawXml == null || rawXml.isEmpty()) {
return null;
}
try {
Pattern partPattern = RegexPatternUtils.getInstance().getPdfAidPartPattern();
Pattern confPattern = RegexPatternUtils.getInstance().getPdfAidConformancePattern();
Matcher partMatcher = partPattern.matcher(rawXml);
Matcher confMatcher = confPattern.matcher(rawXml);
if (partMatcher.find() && confMatcher.find()) {
String part = partMatcher.group(1);
String conformance = confMatcher.group(1).toUpperCase(Locale.ROOT);
return part + conformance;
}
} catch (Exception e) {
log.debug("Error parsing raw XMP for PDF/A version: {}", e.getMessage());
}
return null;
}
private static boolean validatePdfAWithPreflight(PDDocument document, String version) {
if (document == null || document.isEncrypted()) {
return false;
}
// 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");
// 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()) {
if (!(parsedDocument instanceof PreflightDocument preflightDocument)) {
log.debug(
"Parsed document is not a PreflightDocument; unable to validate claimed PDF/A {}",
version);
return false;
}
try {
ValidationResult result = preflightDocument.validate();
if (!result.isValid() && log.isDebugEnabled()) {
log.debug(
"PDF/A validation found {} errors for claimed version {}",
result.getErrorsList().size(),
version);
int logged = 0;
for (ValidationResult.ValidationError error : result.getErrorsList()) {
log.debug(
" Error {}: {}", error.getErrorCode(), error.getDetails());
if (++logged >= MAX_LOGGED_ERRORS) {
break;
}
}
}
return result.isValid();
} catch (ValidationException e) {
log.debug(
"Validation exception during PDF/A validation: {}", e.getMessage());
}
} catch (SyntaxValidationException e) {
log.debug(
"Syntax validation failed during PDF/A validation: {}", e.getMessage());
return false;
}
}
} catch (IOException e) {
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;
}
private static boolean checkStandardInMetadata(PDDocument document, String standardKeyword) {
// Check XMP Metadata
try {
PDMetadata pdMetadata = document.getDocumentCatalog().getMetadata();
if (pdMetadata != null) {
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
// First try to read raw metadata as string to check for standard keywords
byte[] metadataBytes = metaStream.readAllBytes();
String rawMetadata = new String(metadataBytes, StandardCharsets.UTF_8);
if (rawMetadata.contains(standardKeyword)) {
return true;
}
}
// If raw check doesn't find it, try parsing with XMP parser
try (COSInputStream metaStream = pdMetadata.createInputStream()) {
try {
DomXmpParser domXmpParser = new DomXmpParser();
XMPMetadata xmpMeta = domXmpParser.parse(metaStream);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
new XmpSerializer().serialize(xmpMeta, baos, true);
String xmpString = baos.toString(StandardCharsets.UTF_8);
if (xmpString.contains(standardKeyword)) {
return true;
}
} catch (XmpParsingException e) {
// XMP parsing failed, but we already checked raw metadata above
log.debug(
"XMP parsing failed for standard check, but raw metadata was already checked: {}",
e.getMessage());
}
}
}
} catch (Exception e) {
ExceptionUtils.logException("PDF standard checking", e);
}
return false;
}
private static ObjectNode generatePDFSummaryData(
PDDocument document, String pdfaConformanceLevel, Boolean pdfaValidationPassed) {
PDDocument document, List<PDFVerificationResult> verificationResults) {
ObjectNode summaryData = objectMapper.createObjectNode();
// Check if encrypted
@@ -346,24 +126,16 @@ public class GetInfoOnPDF {
}
// Check standard compliance
if (pdfaConformanceLevel != null) {
summaryData.put("standardCompliance", "PDF/A-" + pdfaConformanceLevel);
summaryData.put("standardPurpose", "long-term archiving");
if (pdfaValidationPassed != null) {
summaryData.put("standardValidationPassed", pdfaValidationPassed);
if (verificationResults != null && !verificationResults.isEmpty()) {
ArrayNode complianceArray = objectMapper.createArrayNode();
for (PDFVerificationResult result : verificationResults) {
ObjectNode complianceNode = objectMapper.createObjectNode();
complianceNode.put("Standard", result.getStandard());
complianceNode.put("Compliant", result.isCompliant());
complianceNode.put("Summary", result.getComplianceSummary());
complianceArray.add(complianceNode);
}
} else if (checkForStandard(document, "PDF/X")) {
summaryData.put("standardCompliance", "PDF/X");
summaryData.put("standardPurpose", "graphic exchange");
} else if (checkForStandard(document, "PDF/UA")) {
summaryData.put("standardCompliance", "PDF/UA");
summaryData.put("standardPurpose", "universal accessibility");
} else if (checkForStandard(document, "PDF/E")) {
summaryData.put("standardCompliance", "PDF/E");
summaryData.put("standardPurpose", "engineering workflows");
} else if (checkForStandard(document, "PDF/VT")) {
summaryData.put("standardCompliance", "PDF/VT");
summaryData.put("standardPurpose", "variable and transactional printing");
summaryData.set("Compliance", complianceArray);
}
return summaryData;
@@ -591,40 +363,113 @@ public class GetInfoOnPDF {
return docInfoNode;
}
private static ObjectNode extractComplianceInfo(PDDocument document) {
private static ObjectNode extractComplianceInfo(
PDDocument doc, List<PDFVerificationResult> verificationResults) {
ObjectNode compliancy = objectMapper.createObjectNode();
try {
String pdfaConformanceLevel = getPdfAConformanceLevel(document);
boolean isPdfACompliant = pdfaConformanceLevel != null;
boolean isPdfXCompliant = checkForStandard(document, "PDF/X");
boolean isPdfECompliant = checkForStandard(document, "PDF/E");
boolean isPdfVTCompliant = checkForStandard(document, "PDF/VT");
boolean isPdfUACompliant = checkForStandard(document, "PDF/UA");
boolean isPdfBCompliant = checkForStandard(document, "PDF/B");
boolean isPdfSECCompliant = checkForStandard(document, "PDF/SEC");
boolean isPdfA = false;
boolean isPdfUA = false;
boolean isPdfX = false;
boolean isPdfE = false;
boolean isPdfB = false;
String pdfAConformanceLevel = null;
compliancy.put("IsPDF/ACompliant", isPdfACompliant);
if (pdfaConformanceLevel != null) {
compliancy.put("PDF/AConformanceLevel", pdfaConformanceLevel);
Boolean pdfaValidationPassed =
validatePdfAWithPreflight(document, pdfaConformanceLevel);
compliancy.put("IsPDF/AValidated", pdfaValidationPassed);
if (verificationResults != null) {
for (PDFVerificationResult result : verificationResults) {
if (result == null) continue;
if (result.isCompliant()) {
String std = result.getStandard().toLowerCase();
if (std.contains("pdf_a") || std.contains("pdfa")) {
isPdfA = true;
String profile = result.getValidationProfile();
if (profile != null) {
if (profile.contains("1b")
|| profile.contains("2b")
|| profile.contains("3b")) {
isPdfB = true;
}
// Simple extraction: remove "pdfa-" prefix
pdfAConformanceLevel = profile.replace("pdfa-", "");
}
}
if (std.contains("pdf_ua") || std.contains("pdfua")) isPdfUA = true;
if (std.contains("pdf_x") || std.contains("pdfx")) isPdfX = true;
if (std.contains("pdf_e") || std.contains("pdfe")) isPdfE = true;
}
}
compliancy.put("IsPDF/XCompliant", isPdfXCompliant);
compliancy.put("IsPDF/ECompliant", isPdfECompliant);
compliancy.put("IsPDF/VTCompliant", isPdfVTCompliant);
compliancy.put("IsPDF/UACompliant", isPdfUACompliant);
compliancy.put("IsPDF/BCompliant", isPdfBCompliant);
compliancy.put("IsPDF/SECCompliant", isPdfSECCompliant);
}
} catch (Exception e) {
log.error("Error extracting compliance info: {}", e.getMessage());
compliancy.put("IsPDF/ACompliant", isPdfA);
compliancy.put("IsPDF/UACompliant", isPdfUA);
compliancy.put("IsPDF/ECompliant", isPdfE);
compliancy.put("IsPDF/VTCompliant", false); // Not currently implemented
compliancy.put("IsPDF/BCompliant", isPdfB);
if (pdfAConformanceLevel != null) {
compliancy.put("PDF/AConformanceLevel", pdfAConformanceLevel);
}
compliancy.put("IsPDF/SECCompliant", isSECCompliant(doc));
if (verificationResults != null && !verificationResults.isEmpty()) {
// Keep original simple structure as backup or extra info
for (PDFVerificationResult result : verificationResults) {
if (result == null) continue;
String standard = result.getStandard();
boolean isCompliant = result.isCompliant();
if (standard != null) {
// Avoid overwriting specific keys if collision, but here keys are distinct
// enough
compliancy.put(standard, isCompliant);
}
}
}
return compliancy;
}
private static boolean isSECCompliant(PDDocument doc) {
try {
// 1. Check Encryption
if (doc.isEncrypted()) return false;
PDDocumentCatalog catalog = doc.getDocumentCatalog();
// 2. Check for JavaScript (Active Content)
if (catalog.getOpenAction() instanceof PDActionJavaScript) return false;
if (catalog.getNames() != null && catalog.getNames().getJavaScript() != null)
return false;
// Check for AcroForm
if (catalog.getAcroForm() != null) return false;
// 3. Check for Embedded Files
if (catalog.getNames() != null && catalog.getNames().getEmbeddedFiles() != null)
return false;
// 4. Check for External Links or Navigation Actions
for (PDPage page : doc.getPages()) {
for (PDAnnotation annotation : page.getAnnotations()) {
if (annotation instanceof PDAnnotationLink) {
PDAnnotationLink link = (PDAnnotationLink) annotation;
PDAction action = link.getAction();
if (action instanceof PDActionURI
|| action instanceof PDActionLaunch
|| action instanceof PDActionRemoteGoTo
|| action instanceof PDActionSubmitForm) {
return false;
}
}
}
}
return true;
} catch (Exception e) {
log.error("Error checking SEC compliance: {}", e.getMessage());
return false;
}
}
private static ObjectNode extractEncryptionInfo(PDDocument document) {
ObjectNode encryption = objectMapper.createObjectNode();
@@ -1215,7 +1060,7 @@ public class GetInfoOnPDF {
return stats;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/get-info-on-pdf")
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/get-info-on-pdf")
@Operation(
summary = "Get comprehensive PDF information",
description =
@@ -1231,6 +1076,13 @@ public class GetInfoOnPDF {
return createErrorResponse("Invalid PDF file: " + e.getMessage());
}
List<PDFVerificationResult> verificationResults = null;
try {
verificationResults = veraPDFService.validatePDF(inputFile.getInputStream());
} catch (Exception e) {
log.error("VeraPDF validation failed", e);
}
boolean readonly = true;
try (PDDocument pdfBoxDoc = pdfDocumentFactory.load(inputFile, readonly)) {
@@ -1239,20 +1091,13 @@ public class GetInfoOnPDF {
ObjectNode metadata = extractMetadata(pdfBoxDoc);
ObjectNode basicInfo = extractBasicInfo(pdfBoxDoc, inputFile.getSize());
ObjectNode docInfoNode = extractDocumentInfo(pdfBoxDoc);
ObjectNode compliancy = extractComplianceInfo(pdfBoxDoc);
ObjectNode compliancy = extractComplianceInfo(pdfBoxDoc, verificationResults);
ObjectNode encryption = extractEncryptionInfo(pdfBoxDoc);
ObjectNode permissionsNode = extractPermissions(pdfBoxDoc);
ObjectNode other = extractOtherInfo(pdfBoxDoc);
ObjectNode formFieldsNode = extractFormFields(pdfBoxDoc);
// Generate summary data
String pdfaConformanceLevel = getPdfAConformanceLevel(pdfBoxDoc);
Boolean pdfaValidationPassed = null;
if (pdfaConformanceLevel != null) {
pdfaValidationPassed = validatePdfAWithPreflight(pdfBoxDoc, pdfaConformanceLevel);
}
ObjectNode summaryData =
generatePDFSummaryData(pdfBoxDoc, pdfaConformanceLevel, pdfaValidationPassed);
ObjectNode summaryData = generatePDFSummaryData(pdfBoxDoc, verificationResults);
// Extract per-page information
ObjectNode pageInfoParent = extractPerPageInfo(pdfBoxDoc);
@@ -6,16 +6,12 @@ import java.util.List;
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 org.verapdf.core.EncryptedPdfException;
import org.verapdf.core.ModelParsingException;
import org.verapdf.core.ValidationException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -23,11 +19,11 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.api.security.PDFVerificationRequest;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.SecurityApi;
import stirling.software.common.util.ExceptionUtils;
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs")
@SecurityApi
@RequiredArgsConstructor
@Slf4j
public class VerifyPDFController {
@@ -41,7 +37,7 @@ public class VerifyPDFController {
+ "Automatically detects PDF/A, PDF/UA-1, PDF/UA-2, and WTPDF standards "
+ "from the document's XMP metadata and validates compliance. "
+ "Input:PDF Output:JSON Type:SISO")
@PostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@AutoJobPostMapping(value = "/verify-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<List<PDFVerificationResult>> verifyPDF(
@ModelAttribute PDFVerificationRequest request) {
@@ -14,9 +14,6 @@ public class PdfToPdfARequest extends PDFFile {
@Schema(
description = "The output format type (PDF/A or PDF/X)",
requiredMode = Schema.RequiredMode.REQUIRED,
allowableValues = {
"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx", "pdfx-1",
"pdfx-3", "pdfx-4"
})
allowableValues = {"pdfa", "pdfa-1", "pdfa-2", "pdfa-2b", "pdfa-3", "pdfa-3b", "pdfx"})
private String outputFormat;
}
@@ -32,8 +32,8 @@ public class AddStampRequest extends PDFWithPageNums {
private String alphabet = "roman";
@Schema(
description = "The font size of the stamp text and image",
defaultValue = "30",
description = "The font size of the stamp text and image in points.",
defaultValue = "40",
requiredMode = Schema.RequiredMode.REQUIRED)
private float fontSize;
@@ -2,6 +2,7 @@ package stirling.software.SPDF.service;
import java.io.*;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
@@ -510,7 +511,7 @@ public class CertificateValidationService {
private byte[] downloadTrustList(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
URL url = URI.create(urlStr).toURL();
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
@@ -700,7 +701,7 @@ public class CertificateValidationService {
private byte[] downloadXml(String urlStr) {
HttpURLConnection conn = null;
try {
URL url = new URL(urlStr);
URL url = URI.create(urlStr).toURL();
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(10_000);
@@ -82,7 +82,7 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
@PostConstruct
public void register() {
if (StringUtils.isAnyBlank(getBotUsername(), getBotToken())) {
if (StringUtils.isAnyBlank(getBotUsername(), this.telegramProperties.getBotToken())) {
log.warn("Telegram bot disabled because botToken or botUsername is not configured");
return;
}
@@ -392,7 +392,7 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
new URI(
"https",
"api.telegram.org",
"/file/bot" + getBotToken() + "/" + filePath,
"/file/bot" + this.telegramProperties.getBotToken() + "/" + filePath,
null);
return uri.toURL();
} catch (URISyntaxException e) {
@@ -516,9 +516,4 @@ public class TelegramPipelineBot extends TelegramLongPollingBot {
public String getBotUsername() {
return telegramProperties.getBotUsername();
}
@Override
public String getBotToken() {
return telegramProperties.getBotToken();
}
}
@@ -1,10 +1,9 @@
multipart.enabled=true
logging.level.org.springframework=WARN
logging.level.org.springframework.security=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.oauth2=DEBUG
#logging.level.org.springframework.security=DEBUG
#logging.level.org.springframework.security.oauth2=DEBUG
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
logging.level.com.zaxxer.hikari=WARN
@@ -47,20 +47,23 @@ 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
enableSingleLogout: false # set to 'true' to enable Single Logout (SP-initiated SLO). Logs the user out from the IdP
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
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
metadataUri: https://dev-XXXXXXXX.okta.com/app/externalKey/sso/saml/metadata # RECOMMENDED: Your IdP's metadata URI. When provided, IdP config is auto-discovered.
provider: # IdP manual configuration - use if metadataUri is not available
name: "" # Display name for your IdP (optional)
singleLoginUrl: "" # SSO URL
singleLogoutUrl: "" # SLO URL
entityId: "" # IdP Entity ID
cert: classpath:okta.cert # IdP signing certificate
sp: # Service Provider (your app) credentials for signing SAML requests. Generated from your keypair
privateKey: classpath:saml-private-key.key # Your private key.
cert: classpath:saml-public-cert.crt # Your signing certificate.
# IMPORTANT: For SAML setup, download your SP metadata from the BACKEND URL: http://localhost:8080/saml2/service-provider-metadata/{registrationId}
# Do NOT use the frontend dev server URL (localhost:5173) as it will generate incorrect ACS URLs. Always use the backend URL (localhost:8080) for SAML configuration.
jwt: # This feature is currently under development and not yet fully supported. Do not use in production.
jwt:
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
@@ -81,6 +84,7 @@ security:
revocation:
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
xFrameOptions: DENY # X-Frame-Options header value. Options: 'DENY' (default, prevents all framing), 'SAMEORIGIN' (allows framing from same domain), 'DISABLED' (no X-Frame-Options header sent). Note: automatically set to DISABLED when login is disabled
premium:
key: 00000000-0000-0000-0000-000000000000
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,567 @@
package stirling.software.SPDF.controller.api.misc;
import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class StampControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private StampController stampController;
private Method processStampTextMethod;
private Method processCustomDateFormatMethod;
@BeforeEach
void setUp() throws NoSuchMethodException {
processStampTextMethod =
StampController.class.getDeclaredMethod(
"processStampText",
String.class,
int.class,
int.class,
String.class,
PDDocument.class);
processStampTextMethod.setAccessible(true);
processCustomDateFormatMethod =
StampController.class.getDeclaredMethod(
"processCustomDateFormat", String.class, LocalDateTime.class);
processCustomDateFormatMethod.setAccessible(true);
}
private String invokeProcessStampText(
String stampText, int pageNumber, int totalPages, String filename, PDDocument document)
throws Exception {
try {
return (String)
processStampTextMethod.invoke(
stampController, stampText, pageNumber, totalPages, filename, document);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private String invokeProcessCustomDateFormat(String format, LocalDateTime now)
throws Exception {
try {
return (String) processCustomDateFormatMethod.invoke(stampController, format, now);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
@Nested
@DisplayName("Basic Variable Substitution Tests")
class BasicVariableTests {
@Test
@DisplayName("Should replace @page_number with current page")
void testPageNumberReplacement() throws Exception {
String result = invokeProcessStampText("Page @page_number", 5, 20, "test.pdf", null);
assertEquals("Page 5", result);
}
@Test
@DisplayName("Should replace @total_pages with total page count")
void testTotalPagesReplacement() throws Exception {
String result =
invokeProcessStampText("of @total_pages pages", 1, 100, "test.pdf", null);
assertEquals("of 100 pages", result);
}
@Test
@DisplayName("Should replace combined page variables")
void testCombinedPageVariables() throws Exception {
String result =
invokeProcessStampText(
"Page @page_number of @total_pages", 5, 20, "test.pdf", null);
assertEquals("Page 5 of 20", result);
}
@Test
@DisplayName("Should replace @page alias")
void testPageAlias() throws Exception {
String result = invokeProcessStampText("Page @page", 7, 10, "test.pdf", null);
assertEquals("Page 7", result);
}
@Test
@DisplayName("Should replace @page_count alias for total pages")
void testPageCountAlias() throws Exception {
String result = invokeProcessStampText("Total: @page_count", 1, 50, "test.pdf", null);
assertEquals("Total: 50", result);
}
}
@Nested
@DisplayName("Filename Variable Tests")
class FilenameTests {
@Test
@DisplayName("Should replace @filename with filename without extension")
void testFilenameWithoutExtension() throws Exception {
String result = invokeProcessStampText("File: @filename", 1, 1, "document.pdf", null);
assertEquals("File: document", result);
}
@Test
@DisplayName("Should replace @filename_full with full filename")
void testFilenameWithExtension() throws Exception {
String result =
invokeProcessStampText("File: @filename_full", 1, 1, "document.pdf", null);
assertEquals("File: document.pdf", result);
}
@Test
@DisplayName("Should handle filename without extension")
void testFilenameWithoutDot() throws Exception {
String result = invokeProcessStampText("@filename", 1, 1, "document", null);
assertEquals("document", result);
}
@Test
@DisplayName("Should handle null filename")
void testNullFilename() throws Exception {
String result = invokeProcessStampText("File: @filename", 1, 1, null, null);
assertEquals("File: ", result);
}
@Test
@DisplayName("Should handle filename with multiple dots")
void testFilenameMultipleDots() throws Exception {
String result = invokeProcessStampText("@filename", 1, 1, "my.document.v2.pdf", null);
assertEquals("my.document.v2", result);
}
@Test
@DisplayName("Should handle hidden file (starts with dot)")
void testHiddenFile() throws Exception {
String result = invokeProcessStampText("@filename", 1, 1, ".hidden.pdf", null);
assertEquals(".hidden", result);
}
}
@Nested
@DisplayName("Date/Time Variable Tests")
class DateTimeTests {
@Test
@DisplayName("Should replace @date with current date")
void testDateReplacement() throws Exception {
String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
assertTrue(
result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
"Date should match YYYY-MM-DD format");
}
@Test
@DisplayName("Should replace @time with current time")
void testTimeReplacement() throws Exception {
String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
assertTrue(
result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
"Time should match HH:mm:ss format");
}
@Test
@DisplayName("Should replace @datetime with combined date and time")
void testDateTimeReplacement() throws Exception {
String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
// DateTime format: YYYY-MM-DD HH:mm:ss
assertTrue(
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
"DateTime should match YYYY-MM-DD HH:mm:ss format");
}
@Test
@DisplayName("Should replace @year with current year")
void testYearReplacement() throws Exception {
String result = invokeProcessStampText("© @year", 1, 1, "test.pdf", null);
int currentYear = LocalDateTime.now().getYear();
assertEquals("© " + currentYear, result);
}
@Test
@DisplayName("Should replace @month with zero-padded month")
void testMonthReplacement() throws Exception {
String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
}
@Test
@DisplayName("Should replace @day with zero-padded day")
void testDayReplacement() throws Exception {
String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
}
}
@Nested
@DisplayName("Custom Date Format Tests")
class CustomDateFormatTests {
@Test
@DisplayName("Should handle custom date format dd/MM/yyyy")
void testCustomDateFormatSlash() throws Exception {
String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
assertTrue(
result.matches("\\d{2}/\\d{2}/\\d{4}"),
"Should match dd/MM/yyyy format: " + result);
}
@Test
@DisplayName("Should handle custom date format with time")
void testCustomDateFormatWithTime() throws Exception {
String result =
invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
assertTrue(
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
"Should match yyyy-MM-dd HH:mm format: " + result);
}
@Test
@DisplayName("Should handle multiple custom date formats in same text")
void testMultipleCustomDateFormats() throws Exception {
String result =
invokeProcessStampText(
"Start: @date{dd/MM/yyyy} End: @date{yyyy}", 1, 1, "test.pdf", null);
assertTrue(result.contains("/"), "Should contain slash from first format");
// Should have year twice (once with slashes, once alone)
}
}
@Nested
@DisplayName("Custom Date Format Security Tests")
class CustomDateFormatSecurityTests {
@Test
@DisplayName("Should not match format that is too long - regex won't capture it")
void testFormatTooLong() throws Exception {
String longFormat = "y".repeat(51); // 51 chars, over the 50 char regex limit
String result =
invokeProcessStampText("@date{" + longFormat + "}", 1, 1, "test.pdf", null);
// The CUSTOM_DATE_PATTERN only captures up to 50 chars, so this won't match
// The @date part will be replaced by simple replacement, leaving {yyy...}
assertTrue(
result.contains("{"), "Should contain { because regex didn't match: " + result);
}
@Test
@DisplayName("Should reject format with unsafe characters - shell injection attempt")
void testShellInjectionAttempt() throws Exception {
String result =
invokeProcessStampText("@date{yyyy-MM-dd$(rm -rf /)}", 1, 1, "test.pdf", null);
assertEquals("[invalid format]", result);
}
@Test
@DisplayName("Should reject format with unsafe characters - semicolon")
void testSemicolonInjection() throws Exception {
String result = invokeProcessStampText("@date{yyyy;rm}", 1, 1, "test.pdf", null);
assertEquals("[invalid format]", result);
}
@Test
@DisplayName("Should reject format with unsafe characters - backticks")
void testBacktickInjection() throws Exception {
String result = invokeProcessStampText("@date{`whoami`}", 1, 1, "test.pdf", null);
assertEquals("[invalid format]", result);
}
@ParameterizedTest
@ValueSource(strings = {"$(cmd)", "`cmd`", ";cmd", "|cmd", "&cmd", "<cmd", ">cmd"})
@DisplayName("Should reject various injection attempts")
void testVariousInjectionAttempts(String injection) throws Exception {
String result =
invokeProcessStampText("@date{yyyy" + injection + "}", 1, 1, "test.pdf", null);
assertEquals("[invalid format]", result);
}
@Test
@DisplayName("Should accept valid format characters")
void testValidFormatCharacters() throws Exception {
// All these should be valid based on SAFE_DATE_FORMAT_PATTERN: yMdHhmsS/-:.,
// '+EGuwWDFzZXa and space
String result =
invokeProcessStampText("@date{yyyy-MM-dd HH:mm:ss}", 1, 1, "test.pdf", null);
assertFalse(
result.startsWith("[invalid"), "Valid format should be accepted: " + result);
}
@Test
@DisplayName("Should handle invalid DateTimeFormatter pattern gracefully")
void testInvalidFormatterPattern() throws Exception {
LocalDateTime now = LocalDateTime.now();
// Use 'sssss' - too many seconds digits will throw IllegalArgumentException from
// DateTimeFormatter
// Note: The pattern 'sssss' passes the SAFE_DATE_FORMAT_PATTERN but fails
// DateTimeFormatter.ofPattern()
String result = invokeProcessCustomDateFormat("sssss", now);
assertTrue(
result.startsWith("[invalid format:"),
"Invalid pattern should return error message: " + result);
}
}
@Nested
@DisplayName("Escape Sequence Tests")
class EscapeSequenceTests {
@Test
@DisplayName("Should convert @@ to literal @")
void testDoubleAtEscape() throws Exception {
String result =
invokeProcessStampText("Email: test@@example.com", 1, 1, "test.pdf", null);
assertEquals("Email: test@example.com", result);
}
@Test
@DisplayName("Should preserve @@ before variable")
void testEscapeBeforeVariable() throws Exception {
String result = invokeProcessStampText("@@date is @date", 1, 1, "test.pdf", null);
// @@date should become @date, and @date should be replaced with actual date
assertTrue(result.startsWith("@date is "), "Should start with literal @date");
assertTrue(
result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
"Should have date after: " + result);
}
@Test
@DisplayName("Should handle multiple escape sequences")
void testMultipleEscapes() throws Exception {
String result = invokeProcessStampText("@@one @@two @@three", 1, 1, "test.pdf", null);
assertEquals("@one @two @three", result);
}
@Test
@DisplayName("Should handle escape at end of string")
void testEscapeAtEnd() throws Exception {
String result = invokeProcessStampText("Contact: user@@", 1, 1, "test.pdf", null);
assertEquals("Contact: user@", result);
}
}
@Nested
@DisplayName("Document Metadata Tests")
class DocumentMetadataTests {
@Test
@DisplayName("Should replace @author with document author")
void testAuthorReplacement() throws Exception {
PDDocument doc = new PDDocument();
PDDocumentInformation info = new PDDocumentInformation();
info.setAuthor("John Doe");
doc.setDocumentInformation(info);
try {
String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", doc);
assertEquals("Author: John Doe", result);
} finally {
doc.close();
}
}
@Test
@DisplayName("Should replace @title with document title")
void testTitleReplacement() throws Exception {
PDDocument doc = new PDDocument();
PDDocumentInformation info = new PDDocumentInformation();
info.setTitle("My Document Title");
doc.setDocumentInformation(info);
try {
String result = invokeProcessStampText("Title: @title", 1, 1, "test.pdf", doc);
assertEquals("Title: My Document Title", result);
} finally {
doc.close();
}
}
@Test
@DisplayName("Should replace @subject with document subject")
void testSubjectReplacement() throws Exception {
PDDocument doc = new PDDocument();
PDDocumentInformation info = new PDDocumentInformation();
info.setSubject("Important Subject");
doc.setDocumentInformation(info);
try {
String result = invokeProcessStampText("Subject: @subject", 1, 1, "test.pdf", doc);
assertEquals("Subject: Important Subject", result);
} finally {
doc.close();
}
}
@Test
@DisplayName("Should handle null metadata gracefully")
void testNullMetadata() throws Exception {
PDDocument doc = new PDDocument();
// Don't set any document information
try {
String result =
invokeProcessStampText("@author @title @subject", 1, 1, "test.pdf", doc);
assertEquals(" ", result); // All should be empty strings
} finally {
doc.close();
}
}
@Test
@DisplayName("Should handle null document gracefully")
void testNullDocument() throws Exception {
String result = invokeProcessStampText("Author: @author", 1, 1, "test.pdf", null);
assertEquals("Author: ", result);
}
}
@Nested
@DisplayName("UUID Variable Tests")
class UuidTests {
@Test
@DisplayName("Should generate 8-character UUID")
void testUuidLength() throws Exception {
String result = invokeProcessStampText("ID: @uuid", 1, 1, "test.pdf", null);
// UUID format: "ID: " + 8 chars
assertEquals(12, result.length(), "Should be 'ID: ' + 8 char UUID");
}
@Test
@DisplayName("Should generate different UUIDs for each call")
void testUuidUniqueness() throws Exception {
String result1 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
String result2 = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
assertNotEquals(result1, result2, "UUIDs should be unique");
}
@Test
@DisplayName("UUID should contain only hex characters")
void testUuidFormat() throws Exception {
String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
}
}
@Nested
@DisplayName("Edge Cases and Error Handling")
class EdgeCaseTests {
@Test
@DisplayName("Should handle null stamp text")
void testNullStampText() throws Exception {
String result = invokeProcessStampText(null, 1, 1, "test.pdf", null);
assertEquals("", result);
}
@Test
@DisplayName("Should handle empty stamp text")
void testEmptyStampText() throws Exception {
String result = invokeProcessStampText("", 1, 1, "test.pdf", null);
assertEquals("", result);
}
@Test
@DisplayName("Should handle text with no variables")
void testNoVariables() throws Exception {
String result = invokeProcessStampText("Just plain text", 1, 1, "test.pdf", null);
assertEquals("Just plain text", result);
}
@Test
@DisplayName("Should handle unknown variables")
void testUnknownVariable() throws Exception {
String result = invokeProcessStampText("@unknown_var", 1, 1, "test.pdf", null);
assertEquals("@unknown_var", result);
}
@Test
@DisplayName("Should preserve text around variables")
void testPreservesSurroundingText() throws Exception {
String result =
invokeProcessStampText("Before @page_number After", 5, 10, "test.pdf", null);
assertEquals("Before 5 After", result);
}
@Test
@DisplayName("Should handle multiple same variables")
void testMultipleSameVariables() throws Exception {
String result =
invokeProcessStampText("@page_number / @page_number", 3, 10, "test.pdf", null);
assertEquals("3 / 3", result);
}
@Test
@DisplayName("Should handle variables adjacent to each other")
void testAdjacentVariables() throws Exception {
String result = invokeProcessStampText("@page@page_number", 5, 10, "test.pdf", null);
// @page should be replaced first (it's in the order), then @page_number
// Since @page_number is longer and comes first in replace chain, should work
assertEquals("55", result);
}
}
@Nested
@DisplayName("Complex Scenario Tests")
class ComplexScenarioTests {
@Test
@DisplayName("Should handle legal footer template")
void testLegalFooterTemplate() throws Exception {
String template = "© @year - All Rights Reserved\\n@filename - Page @page_number";
String result = invokeProcessStampText(template, 3, 15, "contract.pdf", null);
int year = LocalDateTime.now().getYear();
String expected = "© " + year + " - All Rights Reserved\\ncontract - Page 3";
assertEquals(expected, result);
}
@Test
@DisplayName("Should handle Brazilian date format template")
void testBrazilianDateFormat() throws Exception {
String template = "Documento criado em @date{dd/MM/yyyy} às @time";
String result = invokeProcessStampText(template, 1, 1, "doc.pdf", null);
assertTrue(result.startsWith("Documento criado em "));
assertTrue(result.contains("/"));
assertTrue(result.contains(":"));
}
@ParameterizedTest
@CsvSource({
"'Page @page_number of @total_pages', 1, 10, 'Page 1 of 10'",
"'Page @page_number of @total_pages', 5, 20, 'Page 5 of 20'",
"'Page @page_number of @total_pages', 100, 1000, 'Page 100 of 1000'"
})
@DisplayName("Should handle page number template with various values")
void testPageNumberTemplates(String template, int page, int total, String expected)
throws Exception {
String result = invokeProcessStampText(template, page, total, "test.pdf", null);
assertEquals(expected, result);
}
}
}
@@ -1,22 +1,30 @@
package stirling.software.SPDF.controller.api.security;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
import java.util.GregorianCalendar;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.*;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
import org.apache.pdfbox.pdmodel.encryption.AccessPermission;
import org.apache.pdfbox.pdmodel.encryption.ProtectionPolicy;
import org.apache.pdfbox.pdmodel.encryption.StandardProtectionPolicy;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionJavaScript;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionLaunch;
import org.apache.pdfbox.pdmodel.interactive.action.PDActionURI;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
@@ -35,6 +43,8 @@ import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.SPDF.model.api.security.PDFVerificationResult;
import stirling.software.SPDF.service.VeraPDFService;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.CustomPDFDocumentFactory;
@@ -43,6 +53,7 @@ import stirling.software.common.service.CustomPDFDocumentFactory;
class GetInfoOnPDFTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private VeraPDFService veraPDFService;
@InjectMocks private GetInfoOnPDF getInfoOnPDF;
@@ -661,41 +672,6 @@ class GetInfoOnPDFTest {
void testGetPageSize(float width, float height, String expected) {
Assertions.assertEquals(expected, GetInfoOnPDF.getPageSize(width, height));
}
@Test
@DisplayName("Should check for PDF/A standard")
void testCheckForStandard_PdfA() throws IOException {
// This would require a real PDF/A document or mocking
PDDocument document = createSimplePdfWithText("Test");
boolean result = GetInfoOnPDF.checkForStandard(document, "PDF/A");
Assertions.assertFalse(result); // Simple PDF is not PDF/A compliant
document.close();
}
@Test
@DisplayName("Should handle null document in checkForStandard")
void testCheckForStandard_NullDocument() {
boolean result = GetInfoOnPDF.checkForStandard(null, "PDF/A");
Assertions.assertFalse(result);
}
@Test
@DisplayName("Should get PDF/A conformance level")
void testGetPdfAConformanceLevel() throws IOException {
PDDocument document = createSimplePdfWithText("Test");
String level = GetInfoOnPDF.getPdfAConformanceLevel(document);
Assertions.assertNull(level);
document.close();
}
@Test
@DisplayName("Should handle encrypted document in getPdfAConformanceLevel")
void testGetPdfAConformanceLevel_EncryptedDocument() throws IOException {
PDDocument document = createEncryptedPdf();
String level = GetInfoOnPDF.getPdfAConformanceLevel(document);
Assertions.assertNull(level); // Encrypted documents return null
document.close();
}
}
@Nested
@@ -776,8 +752,8 @@ class GetInfoOnPDFTest {
class ComplianceTests {
@Test
@DisplayName("Should check PDF/A compliance")
void testCompliance_PdfA() throws IOException {
@DisplayName("Should extract compliance info using VeraPDF")
void testCompliance_PdfA() throws Exception {
PDDocument document = createSimplePdfWithText("Test PDF/A");
MockMultipartFile mockFile = documentToMultipartFile(document, "pdfa.pdf");
@@ -791,16 +767,22 @@ class GetInfoOnPDFTest {
ArgumentMatchers.anyBoolean()))
.thenReturn(loadedDoc);
// Mock VeraPDFService
PDFVerificationResult result = new PDFVerificationResult();
result.setStandard("pdfa-1b");
result.setCompliant(true);
result.setComplianceSummary("PDF/A-1b compliant");
Mockito.when(veraPDFService.validatePDF(ArgumentMatchers.any(InputStream.class)))
.thenReturn(List.of(result));
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
JsonNode compliancy = jsonNode.get("Compliancy");
Assertions.assertTrue(compliancy.has("IsPDF/ACompliant"));
Assertions.assertTrue(compliancy.has("IsPDF/XCompliant"));
Assertions.assertTrue(compliancy.has("IsPDF/ECompliant"));
Assertions.assertTrue(compliancy.has("IsPDF/UACompliant"));
Assertions.assertTrue(compliancy.has("pdfa-1b"));
Assertions.assertTrue(compliancy.get("pdfa-1b").asBoolean());
loadedDoc.close();
}
@@ -840,4 +822,104 @@ class GetInfoOnPDFTest {
loadedDoc.close();
}
}
@Test
@DisplayName("SEC Compliance: Clean document should pass")
void testSecCompliance_Clean() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
checkSecCompliance(doc, true);
}
}
@Test
@DisplayName("SEC Compliance: JavaScript action should fail")
void testSecCompliance_JavaScript() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
PDActionJavaScript jsAction = new PDActionJavaScript("app.alert('Hi')");
doc.getDocumentCatalog().setOpenAction(jsAction);
checkSecCompliance(doc, false);
}
}
@Test
@DisplayName("SEC Compliance: External URI Link should fail")
void testSecCompliance_ExternalLink() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
PDAnnotationLink link = new PDAnnotationLink();
PDActionURI action = new PDActionURI();
action.setURI("http://google.com");
link.setAction(action);
page.getAnnotations().add(link);
checkSecCompliance(doc, false);
}
}
@Test
@DisplayName("SEC Compliance: Launch Action should fail")
void testSecCompliance_LaunchAction() throws Exception {
try (PDDocument doc = new PDDocument()) {
PDPage page = new PDPage();
doc.addPage(page);
PDAnnotationLink link = new PDAnnotationLink();
PDActionLaunch action = new PDActionLaunch();
link.setAction(action);
page.getAnnotations().add(link);
checkSecCompliance(doc, false);
}
}
@Test
@DisplayName("SEC Compliance: Embedded File should fail")
void testSecCompliance_EmbeddedFile() throws Exception {
try (PDDocument doc = new PDDocument()) {
doc.addPage(new PDPage());
PDComplexFileSpecification fs = new PDComplexFileSpecification();
fs.setFile("test.txt");
PDEmbeddedFile ef =
new PDEmbeddedFile(doc, new ByteArrayInputStream("test".getBytes()));
fs.setEmbeddedFile(ef);
PDEmbeddedFilesNameTreeNode efTree = new PDEmbeddedFilesNameTreeNode();
efTree.setNames(Collections.singletonMap("test", fs));
PDDocumentNameDictionary names = new PDDocumentNameDictionary(doc.getDocumentCatalog());
names.setEmbeddedFiles(efTree);
doc.getDocumentCatalog().setNames(names);
checkSecCompliance(doc, false);
}
}
private void checkSecCompliance(PDDocument doc, boolean expected) throws Exception {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
doc.save(baos);
byte[] bytes = baos.toByteArray();
Mockito.when(
pdfDocumentFactory.load(
ArgumentMatchers.any(MultipartFile.class),
ArgumentMatchers.anyBoolean()))
.thenReturn(Loader.loadPDF(bytes));
PDFFile request = new PDFFile();
request.setFileInput(
new MockMultipartFile("file", "test.pdf", "application/pdf", bytes));
ResponseEntity<byte[]> response = getInfoOnPDF.getPdfInfo(request);
String jsonResponse = new String(response.getBody(), StandardCharsets.UTF_8);
JsonNode jsonNode = objectMapper.readTree(jsonResponse);
boolean actual = jsonNode.get("Compliancy").get("IsPDF/SECCompliant").asBoolean();
Assertions.assertEquals(expected, actual, "SEC Compliance check failed");
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
repositories {
maven { url "https://build.shibboleth.net/maven/releases" }
maven { url = "https://build.shibboleth.net/maven/releases" }
}
ext {
@@ -119,7 +119,7 @@ public class AuditDashboardController {
@Schema(
description = "Number of days to look back for audit events",
example = "7",
required = true)
requiredMode = Schema.RequiredMode.REQUIRED)
@RequestParam(value = "days", defaultValue = "7")
int days) {
@@ -49,6 +49,7 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@@ -68,6 +69,7 @@ public class ProprietaryUIDataController {
private final ObjectMapper objectMapper;
private final UserLicenseSettingsService licenseSettingsService;
private final PersistentAuditEventRepository auditRepository;
private final MfaService mfaService;
public ProprietaryUIDataController(
ApplicationProperties applicationProperties,
@@ -80,7 +82,8 @@ public class ProprietaryUIDataController {
ObjectMapper objectMapper,
@Qualifier("runningEE") boolean runningEE,
UserLicenseSettingsService licenseSettingsService,
PersistentAuditEventRepository auditRepository) {
PersistentAuditEventRepository auditRepository,
MfaService mfaService) {
this.applicationProperties = applicationProperties;
this.auditConfig = auditConfig;
this.sessionPersistentRegistry = sessionPersistentRegistry;
@@ -92,6 +95,7 @@ public class ProprietaryUIDataController {
this.runningEE = runningEE;
this.licenseSettingsService = licenseSettingsService;
this.auditRepository = auditRepository;
this.mfaService = mfaService;
}
/**
@@ -206,7 +210,10 @@ 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 samlIdp =
saml2.getProvider().getEntityId() != null
? saml2.getProvider().getEntityId()
: saml2.getIdpIssuer(); // legacy fallback
String saml2AuthenticationPath = "/saml2/authenticate/" + saml2.getRegistrationId();
// For SAML, we need to use the backend URL directly, not a relative path
@@ -245,12 +252,14 @@ public class ProprietaryUIDataController {
Map<String, Boolean> userSessions = new HashMap<>();
Map<String, Date> userLastRequest = new HashMap<>();
Map<String, Map<String, String>> userSettings = new HashMap<>();
int activeUsers = 0;
int disabledUsers = 0;
while (iterator.hasNext()) {
User user = iterator.next();
if (user != null) {
String username = user.getUsername();
boolean shouldRemove = false;
// Check if user is an INTERNAL_API_USER
@@ -264,7 +273,7 @@ public class ProprietaryUIDataController {
// Check if user is part of the Internal team
if (user.getTeam() != null
&& user.getTeam().getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
&& TeamService.INTERNAL_TEAM_NAME.equals(user.getTeam().getName())) {
shouldRemove = true;
}
@@ -278,7 +287,7 @@ public class ProprietaryUIDataController {
boolean hasActiveSession = false;
Date lastRequest = null;
Optional<SessionEntity> latestSession =
sessionPersistentRegistry.findLatestSession(user.getUsername());
sessionPersistentRegistry.findLatestSession(username);
if (latestSession.isPresent()) {
SessionEntity sessionEntity = latestSession.get();
@@ -299,8 +308,21 @@ public class ProprietaryUIDataController {
lastRequest = new Date(0);
}
userSessions.put(user.getUsername(), hasActiveSession);
userLastRequest.put(user.getUsername(), lastRequest);
User userWithSettings =
userRepository.findByIdWithSettings(user.getId()).orElse(user);
// Mask mfaSecret if present in settings
Map<String, String> originalSettings = userWithSettings.getSettings();
Map<String, String> settingsCopy =
originalSettings != null
? new HashMap<>(originalSettings)
: new HashMap<>();
if (settingsCopy.containsKey("mfaSecret")) {
settingsCopy.put("mfaSecret", "********");
}
userSettings.put(username, settingsCopy);
userSessions.put(username, hasActiveSession);
userLastRequest.put(username, lastRequest);
if (hasActiveSession) activeUsers++;
if (!user.isEnabled()) disabledUsers++;
@@ -329,7 +351,7 @@ public class ProprietaryUIDataController {
List<Team> allTeams =
teamRepository.findAll().stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
.filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
.toList();
// Calculate license limits
@@ -356,6 +378,7 @@ public class ProprietaryUIDataController {
data.setLicenseMaxUsers(licenseMaxUsers);
data.setPremiumEnabled(premiumEnabled);
data.setMailEnabled(applicationProperties.getMail().isEnabled());
data.setUserSettings(userSettings);
return ResponseEntity.ok(data);
}
@@ -407,6 +430,8 @@ public class ProprietaryUIDataController {
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
data.setOAuth2Login(isOAuth2Login);
data.setSaml2Login(isSaml2Login);
data.setMfaEnabled(mfaService.isMfaEnabled(user.get()));
data.setMfaRequired(mfaService.isMfaRequired(user.get()));
return ResponseEntity.ok(data);
}
@@ -418,7 +443,7 @@ public class ProprietaryUIDataController {
List<TeamWithUserCountDTO> allTeamsWithCounts = teamRepository.findAllTeamsWithUserCount();
List<TeamWithUserCountDTO> teamsWithCounts =
allTeamsWithCounts.stream()
.filter(team -> !team.getName().equals(TeamService.INTERNAL_TEAM_NAME))
.filter(team -> !TeamService.INTERNAL_TEAM_NAME.equals(team.getName()))
.toList();
List<Object[]> teamActivities = sessionRepository.findLatestActivityByTeam();
@@ -446,7 +471,7 @@ public class ProprietaryUIDataController {
.findById(id)
.orElseThrow(() -> new RuntimeException("Team not found"));
if (team.getName().equals(TeamService.INTERNAL_TEAM_NAME)) {
if (TeamService.INTERNAL_TEAM_NAME.equals(team.getName())) {
return ResponseEntity.status(403).build();
}
@@ -459,11 +484,8 @@ public class ProprietaryUIDataController {
(user.getTeam() == null
|| !user.getTeam().getId().equals(id))
&& (user.getTeam() == null
|| !user.getTeam()
.getName()
.equals(
TeamService
.INTERNAL_TEAM_NAME)))
|| !TeamService.INTERNAL_TEAM_NAME.equals(
user.getTeam().getName())))
.toList();
List<Object[]> userSessions = sessionRepository.findLatestSessionByTeamId(id);
@@ -541,6 +563,7 @@ public class ProprietaryUIDataController {
private int licenseMaxUsers;
private boolean premiumEnabled;
private boolean mailEnabled;
private Map<String, Map<String, String>> userSettings;
}
@Data
@@ -551,6 +574,8 @@ public class ProprietaryUIDataController {
private boolean changeCredsFlag;
private boolean oAuth2Login;
private boolean saml2Login;
private boolean mfaEnabled;
private boolean mfaRequired;
}
@Data
@@ -33,7 +33,8 @@ public class PersistentAuditEvent {
private String principal;
private String type;
@Lob private String data; // JSON blob
@Column(columnDefinition = "text")
private String data; // JSON blob
private Instant timestamp;
}
@@ -1,53 +1,58 @@
package stirling.software.proprietary.security;
import java.io.IOException;
import java.security.cert.X509Certificate;
import java.security.interfaces.RSAPrivateKey;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import org.springframework.core.io.Resource;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
import com.coveo.saml.SamlClient;
import com.coveo.saml.SamlException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.OAUTH2;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.model.oauth2.KeycloakProvider;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.common.util.UrlUtils;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.saml2.CertificateUtils;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Slf4j
@RequiredArgsConstructor
public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
public static final String LOGOUT_PATH = "/login?logout=true";
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final JwtServiceInterface jwtService;
private final LogoutSuccessHandler samlLogoutHandler;
public CustomLogoutSuccessHandler(
ApplicationProperties.Security securityProperties, JwtServiceInterface jwtService) {
this(securityProperties, jwtService, null);
}
public CustomLogoutSuccessHandler(
ApplicationProperties.Security securityProperties,
JwtServiceInterface jwtService,
LogoutSuccessHandler samlLogoutHandler) {
this.securityProperties = securityProperties;
this.jwtService = jwtService;
this.samlLogoutHandler = samlLogoutHandler;
}
@Override
@Audited(type = AuditEventType.USER_LOGOUT, level = AuditLevel.BASIC)
@@ -56,13 +61,14 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
throws IOException {
if (!response.isCommitted()) {
if (handleSamlLogout(request, response, authentication)) {
return;
}
if (authentication != null) {
if (authentication instanceof Saml2Authentication samlAuthentication) {
// Handle SAML2 logout redirection
getRedirect_saml2(request, response, samlAuthentication);
} else if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
if (authentication instanceof OAuth2AuthenticationToken oAuthToken) {
// Handle OAuth2 logout redirection
getRedirect_oauth2(request, response, oAuthToken);
getRedirectOauth2(request, response, oAuthToken);
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
// Handle Username/Password logout
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
@@ -74,71 +80,152 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else {
if (jwtService != null) {
String token = jwtService.extractToken(request);
if (token != null && !token.isBlank()) {
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
return;
}
}
// Redirect to login page after logout
String path = checkForErrors(request);
getRedirectStrategy().sendRedirect(request, response, path);
// Redirect to login page after logout (handles error parameters if present)
String queryParams = checkForErrors(request);
getRedirectStrategy().sendRedirect(request, response, "/login?" + queryParams);
}
}
}
// Redirect for SAML2 authentication logout
private void getRedirect_saml2(
HttpServletRequest request,
HttpServletResponse response,
Saml2Authentication samlAuthentication)
/**
* Handles SAML logout - either via IdP Single Logout (SLO) or local logout.
*
* @return true if this was a SAML user and logout was handled, false otherwise
*/
private boolean handleSamlLogout(
HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException {
// Logout locally if this is a SAMLResponse from to /logout instead of /logout/saml2/slo
String samlResponse = request.getParameter("SAMLResponse");
if (samlResponse != null && !samlResponse.isBlank()) {
if (samlResponse.contains("/saml2/slo")) {
log.info(
"Received SAML LogoutResponse at /logout endpoint, completing logout locally");
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
return true;
}
}
SAML2 samlConf = securityProperties.getSaml2();
String registrationId = samlConf.getRegistrationId();
if (securityProperties.getSaml2().getEnableSingleLogout()) {
log.info("SP-initiated SLO detected, logging out via IdP");
CustomSaml2AuthenticatedPrincipal principal =
(CustomSaml2AuthenticatedPrincipal) samlAuthentication.getPrincipal();
if (authentication instanceof Saml2Authentication samlAuthentication) {
if (samlLogoutHandler != null) {
try {
samlLogoutHandler.onLogoutSuccess(request, response, samlAuthentication);
} catch (Exception e) {
log.error("SP-initiated SLO failed, falling back to local logout", e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else {
log.warn(
"SAML SLO enabled but handler not configured, performing local logout only");
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
String nameIdValue = principal.name();
return true;
} else {
// Reconstruct Saml2Authentication from JWT claims for SLO
Optional<Saml2Authentication> reconstructedAuth =
reconstructSaml2AuthenticationFromJwt(request);
if (reconstructedAuth.isPresent()) {
Saml2Authentication samlAuth = reconstructedAuth.get();
if (samlLogoutHandler != null) {
try {
samlLogoutHandler.onLogoutSuccess(request, response, samlAuth);
} catch (Exception e) {
log.error("SP-initiated SLO failed, falling back to local logout", e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
} else {
log.warn(
"SAML SLO enabled but handler not configured, performing local logout only");
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
}
return true;
}
}
}
return false;
}
/**
* Reconstructs a Saml2Authentication from JWT claims for SAML Single Logout. This allows SLO to
* work even with stateless JWT sessions by extracting the SAML attributes that were stored in
* the JWT during initial authentication.
*/
@SuppressWarnings("unchecked")
private Optional<Saml2Authentication> reconstructSaml2AuthenticationFromJwt(
HttpServletRequest request) {
try {
// Read certificate from the resource
Resource certificateResource = samlConf.getSpCert();
X509Certificate certificate = CertificateUtils.readCertificate(certificateResource);
String token = jwtService.extractToken(request);
if (token == null || token.isBlank()) {
return Optional.empty();
}
List<X509Certificate> certificates = new ArrayList<>();
certificates.add(certificate);
Map<String, Object> claims = jwtService.extractClaims(token);
Object authType = claims.get("authType");
// Construct URLs required for SAML configuration
SamlClient samlClient = getSamlClient(registrationId, samlConf, certificates);
if (authType == null || !"SAML2".equalsIgnoreCase(authType.toString())) {
return Optional.empty();
}
// Read private key for service provider
Resource privateKeyResource = samlConf.getPrivateKey();
RSAPrivateKey privateKey = CertificateUtils.readPrivateKey(privateKeyResource);
// Extract SAML claims from JWT
String username = (String) claims.get("sub");
String nameId = (String) claims.get("samlNameId");
String registrationId = (String) claims.get("samlRegistrationId");
Object sessionIndexesObj = claims.get("samlSessionIndexes");
// Set service provider keys for the SamlClient
samlClient.setSPKeys(certificate, privateKey);
if (nameId == null || registrationId == null) {
log.debug(
"Missing required SAML claims for SLO reconstruction: nameId={}, registrationId={}",
nameId,
registrationId);
return Optional.empty();
}
// Build relay state to return user to login page after IdP logout
String relayState =
UrlUtils.getOrigin(request) + request.getContextPath() + LOGOUT_PATH;
List<String> sessionIndexes = Collections.emptyList();
if (sessionIndexesObj instanceof List<?>) {
sessionIndexes =
((List<?>) sessionIndexesObj).stream().map(Object::toString).toList();
}
// Redirect to identity provider for logout with relay state
samlClient.redirectToIdentityProvider(response, relayState, nameIdValue);
} catch (Exception e) {
log.error(
"Error retrieving logout URL from Provider {} for user {}",
samlConf.getProvider(),
nameIdValue,
e);
getRedirectStrategy().sendRedirect(request, response, LOGOUT_PATH);
// Create principal with all SAML attributes needed for SLO
CustomSaml2AuthenticatedPrincipal principal =
new CustomSaml2AuthenticatedPrincipal(
username,
Collections.emptyMap(), // Attributes not needed for logout
nameId,
sessionIndexes,
registrationId);
// Create Saml2Authentication with the reconstructed principal
// The saml2Response parameter is not used by the logout handler, but constructor
// requires non-empty value, so we provide a placeholder
Saml2Authentication samlAuth =
new Saml2Authentication(
principal,
"<!-- reconstructed for logout -->",
Collections.singletonList(new SimpleGrantedAuthority("ROLE_USER")));
log.debug(
"Reconstructed Saml2Authentication from JWT for user {} with registrationId {}",
username,
registrationId);
return Optional.of(samlAuth);
} catch (Exception ex) {
log.error("Unable to reconstruct Saml2Authentication from JWT", ex);
return Optional.empty();
}
}
// Redirect for OAuth2 authentication logout
private void getRedirect_oauth2(
private void getRedirectOauth2(
HttpServletRequest request,
HttpServletResponse response,
OAuth2AuthenticationToken oAuthToken)
@@ -169,7 +256,7 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
logoutUrl +=
"/protocol/openid-connect/logout"
+ "?client_id="
+ oauth.getClientId()
+ keycloak.getClientId()
+ "&post_logout_redirect_uri="
+ response.encodeRedirectURL(redirectUrl);
log.info("Redirecting to Keycloak logout URL: {}", logoutUrl);
@@ -196,30 +283,6 @@ public class CustomLogoutSuccessHandler extends SimpleUrlLogoutSuccessHandler {
}
}
private SamlClient getSamlClient(
String registrationId, SAML2 samlConf, List<X509Certificate> certificates)
throws SamlException {
String serverUrl = appConfig.getBackendUrl() + ":" + appConfig.getServerPort();
String relyingPartyIdentifier =
serverUrl + "/saml2/service-provider-metadata/" + registrationId;
String assertionConsumerServiceUrl = serverUrl + "/login/saml2/sso/" + registrationId;
String idpSLOUrl = samlConf.getIdpSingleLogoutUrl();
String idpIssuer = samlConf.getIdpIssuer();
// Create SamlClient instance for SAML logout
return new SamlClient(
relyingPartyIdentifier,
assertionConsumerServiceUrl,
idpSLOUrl,
idpIssuer,
certificates,
SamlClient.SamlIdpBinding.POST);
}
/**
* Handles different error scenarios during logout. Will return a <code>String</code> containing
* the error request parameter.
@@ -19,6 +19,7 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.SaveUserRequest;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@@ -113,8 +114,14 @@ public class InitialSecuritySetup {
&& userService.findByUsernameIgnoreCase(initialUsername).isEmpty()) {
Team team = teamService.getOrCreateDefaultTeam();
userService.saveUser(
initialUsername, initialPassword, team, Role.ADMIN.getRoleId(), false);
SaveUserRequest.Builder builder =
SaveUserRequest.builder()
.username(initialUsername)
.password(initialPassword)
.team(team)
.role(Role.ADMIN.getRoleId())
.firstLogin(false);
userService.saveUserCore(builder.build());
log.info("Admin user created: {}", initialUsername);
} else {
createDefaultAdminUser();
@@ -127,8 +134,14 @@ public class InitialSecuritySetup {
if (userService.findByUsernameIgnoreCase(defaultUsername).isEmpty()) {
Team team = teamService.getOrCreateDefaultTeam();
userService.saveUser(
defaultUsername, defaultPassword, team, Role.ADMIN.getRoleId(), true);
SaveUserRequest.Builder builder =
SaveUserRequest.builder()
.username(defaultUsername)
.password(defaultPassword)
.team(team)
.role(Role.ADMIN.getRoleId())
.firstLogin(true);
userService.saveUserCore(builder.build());
log.info("Default admin user created: {}", defaultUsername);
}
}
@@ -137,12 +150,14 @@ public class InitialSecuritySetup {
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId())) {
Team team = teamService.getOrCreateInternalTeam();
userService.saveUser(
Role.INTERNAL_API_USER.getRoleId(),
UUID.randomUUID().toString(),
team,
Role.INTERNAL_API_USER.getRoleId(),
false);
SaveUserRequest.Builder builder =
SaveUserRequest.builder()
.username(Role.INTERNAL_API_USER.getRoleId())
.password(UUID.randomUUID().toString())
.team(team)
.role(Role.INTERNAL_API_USER.getRoleId())
.firstLogin(false);
userService.saveUserCore(builder.build());
userService.addApiKeyToUser(Role.INTERNAL_API_USER.getRoleId());
log.info("Internal API user created: {}", Role.INTERNAL_API_USER.getRoleId());
} else {
@@ -8,11 +8,13 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
@@ -22,8 +24,12 @@ import org.springframework.security.oauth2.client.registration.ClientRegistratio
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;
import org.springframework.security.saml2.provider.service.web.authentication.logout.OpenSaml4LogoutRequestResolver;
import org.springframework.security.saml2.provider.service.web.authentication.logout.Saml2RelyingPartyInitiatedLogoutSuccessHandler;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
@@ -33,7 +39,6 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.CustomAuthenticationFailureHandler;
@@ -56,7 +61,6 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@Slf4j
@Configuration
@@ -72,12 +76,10 @@ public class SecurityConfiguration {
private final ApplicationProperties applicationProperties;
private final ApplicationProperties.Security securityProperties;
private final AppConfig appConfig;
private final UserAuthenticationFilter userAuthenticationFilter;
private final JwtServiceInterface jwtService;
private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final LoginAttemptService loginAttemptService;
private final SessionPersistentRegistry sessionRegistry;
private final PersistentLoginRepository persistentLoginRepository;
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
@@ -92,14 +94,12 @@ public class SecurityConfiguration {
@Lazy UserService userService,
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Qualifier("runningProOrHigher") boolean runningProOrHigher,
AppConfig appConfig,
ApplicationProperties applicationProperties,
ApplicationProperties.Security securityProperties,
UserAuthenticationFilter userAuthenticationFilter,
JwtServiceInterface jwtService,
JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint,
LoginAttemptService loginAttemptService,
SessionPersistentRegistry sessionRegistry,
@Autowired(required = false) GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper,
@Autowired(required = false)
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@@ -112,14 +112,12 @@ public class SecurityConfiguration {
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
this.runningProOrHigher = runningProOrHigher;
this.appConfig = appConfig;
this.applicationProperties = applicationProperties;
this.securityProperties = securityProperties;
this.userAuthenticationFilter = userAuthenticationFilter;
this.jwtService = jwtService;
this.jwtAuthenticationEntryPoint = jwtAuthenticationEntryPoint;
this.loginAttemptService = loginAttemptService;
this.sessionRegistry = sessionRegistry;
this.persistentLoginRepository = persistentLoginRepository;
this.oAuth2userAuthoritiesMapper = oAuth2userAuthoritiesMapper;
this.saml2RelyingPartyRegistrations = saml2RelyingPartyRegistrations;
@@ -196,17 +194,41 @@ public class SecurityConfiguration {
http.cors(cors -> cors.configurationSource(corsSource));
} else {
// Explicitly disable CORS when no origins are configured
http.cors(cors -> cors.disable());
http.cors(AbstractHttpConfigurer::disable);
}
http.csrf(CsrfConfigurer::disable);
// Configure X-Frame-Options based on settings.yml configuration
// When login is disabled, automatically disable X-Frame-Options to allow embedding
if (!loginEnabledValue) {
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.disable()));
} else {
String xFrameOption = securityProperties.getXFrameOptions();
if (xFrameOption != null) {
http.headers(
headers -> {
if ("DISABLED".equalsIgnoreCase(xFrameOption)) {
headers.frameOptions(frameOptions -> frameOptions.disable());
} else if ("SAMEORIGIN".equalsIgnoreCase(xFrameOption)) {
headers.frameOptions(frameOptions -> frameOptions.sameOrigin());
} else {
// Default to DENY
headers.frameOptions(frameOptions -> frameOptions.deny());
}
});
} else {
// If not configured, use default DENY
http.headers(headers -> headers.frameOptions(frameOptions -> frameOptions.deny()));
}
}
if (loginEnabledValue) {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
http.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, LogoutFilter.class)
.addFilterAfter(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
http.sessionManagement(
sessionManagement ->
@@ -226,17 +248,38 @@ public class SecurityConfiguration {
return requestURI.startsWith(contextPath + "/api/");
}));
// Create SAML logout handler if SAML SLO is enabled
final LogoutSuccessHandler samlLogoutHandler;
if (securityProperties.isSaml2Active()
&& Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())
&& saml2RelyingPartyRegistrations != null) {
log.info("Creating SAML2 SLO handler for SP-initiated logout");
OpenSaml4LogoutRequestResolver logoutRequestResolver =
new OpenSaml4LogoutRequestResolver(saml2RelyingPartyRegistrations);
samlLogoutHandler =
new Saml2RelyingPartyInitiatedLogoutSuccessHandler(logoutRequestResolver);
} else {
samlLogoutHandler = null;
}
http.logout(
logout ->
// Require POST to prevent logout CSRF attacks
logout.logoutRequestMatcher(
PathPatternRequestMatcher.withDefaults()
.matcher("/logout"))
.matcher(HttpMethod.POST, "/logout"))
.logoutSuccessHandler(
new CustomLogoutSuccessHandler(
securityProperties, appConfig, jwtService))
securityProperties,
jwtService,
samlLogoutHandler))
.clearAuthentication(true)
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "remember-me", "stirling_jwt"));
.deleteCookies(
"JSESSIONID",
"remember-me",
"stirling_jwt",
"stirling_logout_token"));
http.rememberMe(
rememberMeConfigurer -> // Use the configurator directly
rememberMeConfigurer
@@ -360,9 +403,18 @@ public class SecurityConfiguration {
}
})
.saml2Metadata(metadata -> {});
// Configure SAML2 Single Logout if enabled
// This sets up endpoints for:
// - IdP-initiated logout: IdP sends LogoutRequest to /logout/saml2/slo
// - SP-initiated logout response: IdP sends LogoutResponse to /logout/saml2/slo
if (Boolean.TRUE.equals(securityProperties.getSaml2().getEnableSingleLogout())) {
log.debug("SAML2 Single Logout (SLO) is enabled");
http.saml2Logout(logout -> logout.logoutUrl("/logout/saml2/slo"));
}
}
} else {
log.debug("Login is not enabled.");
log.info("Login is not enabled.");
http.authorizeHttpRequests(authz -> authz.anyRequest().permitAll());
}
return http.build();
@@ -11,7 +11,12 @@ import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -27,10 +32,13 @@ import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
/** REST API Controller for authentication operations. */
@@ -45,6 +53,8 @@ public class AuthController {
private final JwtServiceInterface jwtService;
private final CustomUserDetailsService userDetailsService;
private final LoginAttemptService loginAttemptService;
private final MfaService mfaService;
private final TotpService totpService;
private final ApplicationProperties.Security securityProperties;
/**
@@ -58,7 +68,7 @@ public class AuthController {
@PostMapping("/login")
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
public ResponseEntity<?> login(
@RequestBody UsernameAndPass request,
@RequestBody UsernameAndPassMfa request,
HttpServletRequest httpRequest,
HttpServletResponse response) {
try {
@@ -116,6 +126,47 @@ public class AuthController {
.body(Map.of("error", "User account is disabled"));
}
if (mfaService.isMfaEnabled(user)) {
String code = request.getMfaCode();
if (code == null || code.isBlank()) {
log.warn(
"MFA required but no code provided for user: {} from IP: {}",
username,
ip);
// loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(
Map.of(
"error", "mfa_required",
"message", "Two-factor code required"));
}
String secret = mfaService.getSecret(user);
if (secret == null || secret.isBlank()) {
log.error("MFA enabled but no secret stored for user: {}", username);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "MFA configuration error"));
}
Long timeStep = totpService.getValidTimeStep(secret, code);
if (timeStep == null) {
log.warn("Invalid MFA code for user: {} from IP: {}", username, ip);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(
Map.of(
"error", "invalid_mfa_code",
"message", "Invalid two-factor code"));
}
if (!mfaService.markTotpStepUsed(user, timeStep)) {
log.warn("Replay MFA code detected for user: {} from IP: {}", username, ip);
loginAttemptService.loginFailed(username);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(
Map.of(
"error", "invalid_mfa_code",
"message", "Invalid two-factor code"));
}
}
Map<String, Object> claims = new HashMap<>();
claims.put("authType", AuthenticationType.WEB.toString());
claims.put("role", user.getRolesAsString());
@@ -163,7 +214,7 @@ public class AuthController {
if (auth == null
|| !auth.isAuthenticated()
|| auth.getPrincipal().equals("anonymousUser")) {
|| "anonymousUser".equals(auth.getPrincipal())) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
}
@@ -180,29 +231,6 @@ public class AuthController {
}
}
/**
* Logout endpoint
*
* @param response HTTP response
* @return Success message
*/
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletResponse response) {
try {
SecurityContextHolder.clearContext();
log.debug("User logged out successfully");
return ResponseEntity.ok(Map.of("message", "Logged out successfully"));
} catch (Exception e) {
log.error("Logout error", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Internal server error"));
}
}
/**
* Refresh token
*
@@ -244,6 +272,210 @@ public class AuthController {
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@GetMapping("/mfa/setup")
public ResponseEntity<?> setupMfa(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
}
String username = authentication.getName();
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
if (authTypeResponse != null) {
return authTypeResponse;
}
if (mfaService.isMfaEnabled(user)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "MFA already enabled"));
}
try {
String secret = totpService.generateSecret();
mfaService.setSecret(user, secret);
String otpAuthUri = totpService.buildOtpAuthUri(username, secret);
return ResponseEntity.ok(Map.of("secret", secret, "otpauthUri", otpAuthUri));
} catch (Exception e) {
log.error("Failed to setup MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to setup MFA"));
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/mfa/enable")
public ResponseEntity<?> enableMfa(
@RequestBody MfaCodeRequest request, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
}
String username = authentication.getName();
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
if (authTypeResponse != null) {
return authTypeResponse;
}
String secret = mfaService.getSecret(user);
if (secret == null || secret.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "MFA setup required"));
}
if (request == null || request.getCode() == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "MFA code is required"));
}
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
if (timeStep == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid two-factor code"));
}
try {
if (!mfaService.isTotpStepUsable(user, timeStep)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid two-factor code"));
}
mfaService.enableMfa(user);
mfaService.markTotpStepUsed(user, timeStep);
mfaService.setMfaRequired(user, false);
return ResponseEntity.ok(Map.of("enabled", true));
} catch (Exception e) {
log.error("Failed to enable MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to enable MFA"));
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/mfa/disable")
public ResponseEntity<?> disableMfa(
@RequestBody MfaCodeRequest request, Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
}
String username = authentication.getName();
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
ResponseEntity<?> authTypeResponse = ensureWebAuth(user);
if (authTypeResponse != null) {
return authTypeResponse;
}
if (!mfaService.isMfaEnabled(user)) {
return ResponseEntity.ok(Map.of("enabled", false));
}
String secret = mfaService.getSecret(user);
if (secret == null || secret.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "MFA configuration missing"));
}
if (request == null || request.getCode() == null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "MFA code is required"));
}
Long timeStep = totpService.getValidTimeStep(secret, request.getCode());
if (timeStep == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid two-factor code"));
}
try {
if (!mfaService.isTotpStepUsable(user, timeStep)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Invalid two-factor code"));
}
mfaService.disableMfa(user);
mfaService.markTotpStepUsed(user, timeStep);
return ResponseEntity.ok(Map.of("enabled", false));
} catch (Exception e) {
log.error("Failed to disable MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to disable MFA"));
}
}
@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/mfa/setup/cancel")
public ResponseEntity<?> cancelMfaSetup(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Not authenticated"));
}
String username = authentication.getName();
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (mfaService.isMfaEnabled(user)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "MFA already enabled"));
}
try {
mfaService.clearPendingSecret(user);
return ResponseEntity.ok(Map.of("cleared", true));
} catch (Exception e) {
log.error("Failed to clear MFA setup for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to clear MFA setup"));
}
}
/**
* Admin endpoint to disable MFA for a user
*
* @param username Username of the user to disable MFA for
* @return Response indicating success or failure
*/
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/mfa/disable/admin/{username}")
public ResponseEntity<?> disableMfaByAdmin(@PathVariable String username) {
try {
User user =
userService
.findByUsernameIgnoreCaseWithSettings(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
if (!mfaService.isMfaEnabled(user)) {
return ResponseEntity.ok(Map.of("enabled", false));
}
mfaService.disableMfa(user);
return ResponseEntity.ok(Map.of("enabled", false));
} catch (UsernameNotFoundException e) {
log.warn("User not found for MFA disable: {}", username);
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found"));
} catch (Exception e) {
log.error("Failed to disable MFA for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Failed to disable MFA"));
}
}
/**
* Helper method to build user response object
*
@@ -266,6 +498,19 @@ public class AuthController {
appMetadata.put("provider", user.getAuthenticationType());
userMap.put("app_metadata", appMetadata);
// Add user metadata
Map<String, Object> userMetadata = new HashMap<>();
userMetadata.put("firstLogin", user.isFirstLogin());
userMap.put("user_metadata", userMetadata);
return userMap;
}
private ResponseEntity<?> ensureWebAuth(User user) {
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(Map.of("error", "MFA settings are only available for web accounts"));
}
return null;
}
}
@@ -23,6 +23,7 @@ import stirling.software.proprietary.security.model.InviteToken;
import stirling.software.proprietary.security.repository.InviteTokenRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.SaveUserRequest;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@@ -90,7 +91,8 @@ public class InviteLinkController {
.body(
Map.of(
"error",
"An active invite already exists for this email address"));
"An active invite already exists for this email"
+ " address"));
}
// If sendEmail is requested but no email provided, reject
@@ -123,7 +125,8 @@ public class InviteLinkController {
+ (currentUserCount + activeInvites)
+ "/"
+ maxUsers
+ " users). Contact your administrator to upgrade your license."));
+ " users). Contact your administrator to"
+ " upgrade your license."));
}
}
@@ -457,12 +460,13 @@ public class InviteLinkController {
}
// Create the user account
userService.saveUser(
effectiveEmail,
password,
invite.getTeamId(),
invite.getRole(),
false); // Don't force password change
SaveUserRequest.Builder builder =
SaveUserRequest.builder()
.username(effectiveEmail)
.password(password)
.teamId(invite.getTeamId())
.role(invite.getRole());
userService.saveUserCore(builder.build());
// Mark invite as used
invite.setUsed(true);
@@ -5,6 +5,7 @@ import java.security.Principal;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@@ -17,7 +18,10 @@ import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import jakarta.mail.MessagingException;
import jakarta.servlet.http.HttpServletRequest;
@@ -42,6 +46,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.SaveUserRequest;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -65,26 +70,24 @@ public class UserController {
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody UsernameAndPass usernameAndPass)
throws SQLException, UnsupportedProviderException {
String username = usernameAndPass.getUsername();
String password = usernameAndPass.getPassword();
try {
log.debug("Registration attempt for user: {}", usernameAndPass.getUsername());
log.debug("Registration attempt for user: {}", username);
if (userService.usernameExistsIgnoreCase(usernameAndPass.getUsername())) {
log.warn(
"Registration failed: username already exists: {}",
usernameAndPass.getUsername());
if (userService.usernameExistsIgnoreCase(username)) {
log.warn("Registration failed: username already exists: {}", username);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "User already exists"));
}
if (!userService.isUsernameValid(usernameAndPass.getUsername())) {
log.warn(
"Registration failed: invalid username format: {}",
usernameAndPass.getUsername());
if (!userService.isUsernameValid(username)) {
log.warn("Registration failed: invalid username format: {}", username);
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid username format"));
}
if (usernameAndPass.getPassword() == null || usernameAndPass.getPassword().isEmpty()) {
if (password == null || password.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required"));
}
@@ -101,17 +104,16 @@ public class UserController {
+ ", Available slots: "
+ availableSlots));
}
Team team = teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME).orElse(null);
User user =
userService.saveUser(
usernameAndPass.getUsername(),
usernameAndPass.getPassword(),
team,
Role.USER.getRoleId(),
false);
SaveUserRequest.Builder builder =
SaveUserRequest.builder()
.username(username)
.password(password)
.team(team)
.enabled(false);
User user = userService.saveUserCore(builder.build());
log.info("User registered successfully: {}", usernameAndPass.getUsername());
log.info("User registered successfully: {}", username);
return ResponseEntity.status(HttpStatus.CREATED)
.body(
@@ -126,7 +128,7 @@ public class UserController {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
} catch (Exception e) {
log.error("Registration error for user: {}", usernameAndPass.getUsername(), e);
log.error("Registration error for user: {}", username, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(Map.of("error", "Registration failed: " + e.getMessage()));
}
@@ -221,6 +223,7 @@ public class UserController {
Principal principal,
@RequestParam(name = "currentPassword") String currentPassword,
@RequestParam(name = "newPassword") String newPassword,
@RequestParam(name = "confirmPassword") String confirmPassword,
HttpServletRequest request,
HttpServletResponse response)
throws SQLException, UnsupportedProviderException {
@@ -233,6 +236,43 @@ public class UserController {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "userNotFound", "message", "User not found"));
}
if (currentPassword == null
|| currentPassword.isEmpty()
|| newPassword == null
|| newPassword.isEmpty()
|| confirmPassword == null
|| confirmPassword.isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"missingParameters",
"message",
"Current password, new password, and confirmation are"
+ " required"));
}
if (!newPassword.equals(confirmPassword)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"passwordMismatch",
"message",
"New password and confirmation do not match"));
}
if (newPassword.equals(currentPassword)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"passwordUnchanged",
"message",
"New password must be different from the current password"));
}
User user = userOpt.get();
if (!userService.isPasswordCorrect(user, currentPassword)) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
@@ -325,7 +365,9 @@ public class UserController {
@RequestParam(name = "teamId", required = false) Long teamId,
@RequestParam(name = "authType") String authType,
@RequestParam(name = "forceChange", required = false, defaultValue = "false")
boolean forceChange)
boolean forceChange,
@RequestParam(name = "forceMFA", required = false, defaultValue = "false")
boolean forceMFA)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!userService.isUsernameValid(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
@@ -347,8 +389,9 @@ public class UserController {
+ availableSlots));
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
User user = null;
if (userOpt.isPresent()) {
User user = userOpt.get();
user = userOpt.get();
if (user.getUsername().equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(Map.of("error", "Username already exists."));
@@ -390,9 +433,23 @@ public class UserController {
}
}
if (authType.equalsIgnoreCase(AuthenticationType.SSO.toString())) {
userService.saveUser(username, AuthenticationType.SSO, effectiveTeamId, role);
SaveUserRequest.Builder builder =
SaveUserRequest.builder().username(username).teamId(effectiveTeamId).role(role);
AuthenticationType requestedAuthType;
if ("SSO".equalsIgnoreCase(authType)) {
requestedAuthType = AuthenticationType.OAUTH2;
} else {
try {
requestedAuthType = AuthenticationType.valueOf(authType.toUpperCase(Locale.ROOT));
} catch (IllegalArgumentException e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Invalid authentication type specified."));
}
}
builder.authenticationType(requestedAuthType);
if (requestedAuthType == AuthenticationType.WEB) {
if (password == null || password.isBlank()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password is required."));
@@ -401,8 +458,9 @@ public class UserController {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Password must be at least 6 characters."));
}
userService.saveUser(username, password, effectiveTeamId, role, forceChange);
builder.password(password).firstLogin(forceChange).requireMfa(forceMFA);
}
userService.saveUserCore(builder.build());
return ResponseEntity.ok(Map.of("message", "User created successfully"));
}
@@ -427,7 +485,8 @@ public class UserController {
.body(
Map.of(
"error",
"Email service is not configured. Please configure SMTP settings."));
"Email service is not configured. Please configure SMTP"
+ " settings."));
}
// Parse comma-separated email addresses
@@ -645,7 +704,8 @@ public class UserController {
.body(
Map.of(
"error",
"User's email is not a valid email address. Notifications are disabled."));
"User's email is not a valid email address. Notifications"
+ " are disabled."));
}
String loginUrl = buildLoginUrl(request);
@@ -826,7 +886,14 @@ public class UserController {
String temporaryPassword = java.util.UUID.randomUUID().toString().substring(0, 12);
// Create user with forceChange=true
userService.saveUser(email, temporaryPassword, teamId, role, true);
SaveUserRequest.Builder builder =
SaveUserRequest.builder()
.username(email)
.password(temporaryPassword)
.teamId(teamId)
.role(role)
.firstLogin(true);
userService.saveUserCore(builder.build());
// Send invite email
try {
@@ -12,8 +12,9 @@ public class H2SQLCondition implements Condition {
boolean enableCustomDatabase =
env.getProperty("system.datasource.enableCustomDatabase", Boolean.class, false);
if (enableCustomDatabase) {
return false;
// If custom database is not enabled, H2 is used by default
if (!enableCustomDatabase) {
return true;
}
String dataSourceType = env.getProperty("system.datasource.type", String.class, "");
@@ -4,6 +4,7 @@ import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -18,6 +19,9 @@ public interface UserRepository extends JpaRepository<User, Long> {
@Query("FROM User u LEFT JOIN FETCH u.settings where upper(u.username) = upper(:username)")
Optional<User> findByUsernameIgnoreCaseWithSettings(@Param("username") String username);
@Query("FROM User u LEFT JOIN FETCH u.settings where u.id = :id")
Optional<User> findByIdWithSettings(@Param("id") Long id);
Optional<User> findByUsername(String username);
Optional<User> findByApiKey(String apiKey);
@@ -76,4 +80,16 @@ public interface UserRepository extends JpaRepository<User, Long> {
"SELECT COUNT(u) FROM User u WHERE u.ssoProvider IS NOT NULL "
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
long countSsoUsers();
@Query(
"SELECT COUNT(u) FROM User u JOIN u.settings settings "
+ "WHERE KEY(settings) = :key AND settings = :value")
long countUsersBySetting(@Param("key") String key, @Param("value") String value);
@Modifying
@Query(
value = "DELETE FROM user_settings WHERE user_id = :userId AND setting_key IN (:keys)",
nativeQuery = true)
void deleteSettingsByUserIdAndKeys(
@Param("userId") Long userId, @Param("keys") List<String> keys);
}
@@ -26,8 +26,27 @@ public class EnterpriseEndpointFilter extends OncePerRequestFilter {
throws ServletException, IOException {
if (!runningProOrHigher && isPrometheusEndpointRequest(request)) {
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
// Allow only health checks to pass through for non-pro users
String uri = request.getRequestURI();
// Strip the context path
String contextPath = request.getContextPath();
String trimmedUri =
(contextPath != null && uri.startsWith(contextPath))
? uri.substring(contextPath.length())
: uri;
boolean isHealthCheck =
trimmedUri.startsWith("/actuator/health")
|| "/health".equals(trimmedUri)
|| "/healthz".equals(trimmedUri)
|| "/liveness".equals(trimmedUri)
|| "/readiness".equals(trimmedUri);
if (!isHealthCheck) {
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
}
filterChain.doFilter(request, response);
}
@@ -61,6 +61,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (isPublicAuthEndpoint(requestURI, contextPath)) {
filterChain.doFilter(request, response);
return;
}
if (isStaticResource(contextPath, requestURI)) {
filterChain.doFilter(request, response);
return;
@@ -174,7 +179,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
log.debug("Setting authentication for user: {}", username);
SecurityContextHolder.getContext().setAuthentication(authToken);
log.debug(
"Authentication set successfully: {}",
SecurityContextHolder.getContext().getAuthentication());
} else {
throw new UsernameNotFoundException("User not found: " + username);
}
@@ -0,0 +1,64 @@
package stirling.software.proprietary.security.filter;
import java.io.IOException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Filter that redirects browser requests to /login to the frontend URL when configured. This is
* needed for development mode where frontend runs on a different port, and for SAML logout which
* redirects to /login?logout after SLO completes.
*/
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@RequiredArgsConstructor
public class LoginRedirectFilter extends OncePerRequestFilter {
private final ApplicationProperties applicationProperties;
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String path = request.getRequestURI();
String frontendUrl = applicationProperties.getSystem().getFrontendUrl();
// Only process /login requests when frontendUrl is configured
if (path.equals("/login") && frontendUrl != null && !frontendUrl.isBlank()) {
// Check if this is a browser request (Accept: text/html) vs API request
// (Accept: application/json)
String acceptHeader = request.getHeader("Accept");
boolean isBrowserRequest =
acceptHeader != null
&& acceptHeader.contains("text/html")
&& !acceptHeader.contains("application/json");
if (isBrowserRequest) {
// Preserve query parameters (e.g., ?logout=true, ?error=xxx)
String queryString = request.getQueryString();
String redirectUrl =
frontendUrl + "/login" + (queryString != null ? "?" + queryString : "");
log.debug("Redirecting browser request to frontend: {}", redirectUrl);
response.sendRedirect(redirectUrl);
return;
}
}
filterChain.doFilter(request, response);
}
}
@@ -4,6 +4,7 @@ import static stirling.software.common.util.RequestUriUtils.isPublicAuthEndpoint
import java.io.IOException;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -32,6 +33,7 @@ import stirling.software.common.util.RequestUriUtils;
import stirling.software.proprietary.security.model.ApiKeyAuthenticationToken;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -43,16 +45,19 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
private final UserService userService;
private final SessionPersistentRegistry sessionPersistentRegistry;
private final boolean loginEnabledValue;
private final JwtServiceInterface jwtService;
public UserAuthenticationFilter(
@Lazy ApplicationProperties.Security securityProp,
@Lazy UserService userService,
SessionPersistentRegistry sessionPersistentRegistry,
@Qualifier("loginEnabled") boolean loginEnabledValue) {
@Qualifier("loginEnabled") boolean loginEnabledValue,
@Lazy JwtServiceInterface jwtService) {
this.securityProp = securityProp;
this.userService = userService;
this.sessionPersistentRegistry = sessionPersistentRegistry;
this.loginEnabledValue = loginEnabledValue;
this.jwtService = jwtService;
}
@Override
@@ -135,7 +140,7 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
// Check if the authenticated user is disabled and invalidate their session if so
if (authentication != null && authentication.isAuthenticated()) {
if (authentication.isAuthenticated()) {
UserLoginType loginMethod = UserLoginType.UNKNOWN;
@@ -146,7 +151,14 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
String username = null;
if (principal instanceof UserDetails detailsUser) {
username = detailsUser.getUsername();
loginMethod = UserLoginType.USERDETAILS;
loginMethod = resolveLoginTypeFromJwt(request);
if (loginMethod == UserLoginType.SAML2USER) {
SAML2 saml2 = securityProp.getSaml2();
blockRegistration = saml2 != null && saml2.getBlockRegistration();
} else if (loginMethod == UserLoginType.OAUTH2USER) {
OAUTH2 oAuth = securityProp.getOauth2();
blockRegistration = oAuth != null && oAuth.getBlockRegistration();
}
} else if (principal instanceof OAuth2User oAuth2User) {
username = oAuth2User.getName();
loginMethod = UserLoginType.OAUTH2USER;
@@ -241,6 +253,30 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private UserLoginType resolveLoginTypeFromJwt(HttpServletRequest request) {
if (jwtService == null) {
return UserLoginType.USERDETAILS;
}
try {
String token = jwtService.extractToken(request);
if (token != null && !token.isBlank()) {
Map<String, Object> claims = jwtService.extractClaims(token);
Object authType = claims.get("authType");
if (authType != null) {
String type = authType.toString().toUpperCase();
if ("SAML2".equals(type)) {
return UserLoginType.SAML2USER;
} else if ("OAUTH2".equals(type)) {
return UserLoginType.OAUTH2USER;
}
}
}
} catch (Exception e) {
log.debug("Unable to resolve login type from JWT claims", e);
}
return UserLoginType.USERDETAILS;
}
private enum UserLoginType {
USERDETAILS("UserDetails"),
OAUTH2USER("OAuth2User"),
@@ -87,7 +87,6 @@ public class User implements UserDetails, Serializable {
@ElementCollection
@MapKeyColumn(name = "setting_key")
@Lob
@Column(name = "setting_value", columnDefinition = "text")
@CollectionTable(name = "user_settings", joinColumns = @JoinColumn(name = "user_id"))
@JsonIgnore
@@ -0,0 +1,12 @@
package stirling.software.proprietary.security.model.api.user;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Data
public class MfaCodeRequest {
@Schema(description = "6-digit authentication code from your authenticator app")
private String code;
}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.security.model.api.user;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class UsernameAndPassMfa extends UsernameAndPass {
@Schema(description = "6-digit authentication code from authenticator app", example = "123456")
private String mfaCode;
}
@@ -1,9 +1,9 @@
package stirling.software.proprietary.security.oauth2;
import static stirling.software.proprietary.security.model.AuthenticationType.OAUTH2;
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
import java.io.IOException;
import java.net.URI;
import java.sql.SQLException;
import java.util.Map;
import java.util.Optional;
@@ -114,10 +114,10 @@ public class CustomOAuth2AuthenticationSuccessHandler
.sendRedirect(request, response, "/logout?userIsDisabled=true");
return;
}
boolean isSsoUser = userService.isSsoAuthenticationTypeByUsername(username);
if (userExists
&& userService.hasPassword(username)
&& (!userService.isAuthenticationTypeByUsername(username, SSO)
|| !userService.isAuthenticationTypeByUsername(username, OAUTH2))
&& !isSsoUser
&& oauth2Properties.getAutoCreateUser()) {
response.sendRedirect(contextPath + "/logout?oAuth2AuthenticationErrorWeb=true");
return;
@@ -259,19 +259,23 @@ public class CustomOAuth2AuthenticationSuccessHandler
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
java.net.URL refererUrl = new java.net.URL(referer);
String refererHost = refererUrl.getHost().toLowerCase();
URI refererUri = URI.create(referer);
String host = refererUri.getHost();
if (host == null) {
return Optional.empty();
}
String refererHost = host.toLowerCase();
if (!isOAuthProviderDomain(refererHost)) {
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
if (refererUrl.getPort() != -1
&& refererUrl.getPort() != 80
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
String origin = refererUri.getScheme() + "://" + host;
int port = refererUri.getPort();
if (port != -1 && port != 80 && port != 443) {
origin += ":" + port;
}
return Optional.of(origin);
}
} catch (java.net.MalformedURLException e) {
} catch (IllegalArgumentException e) {
// ignore and fall back
}
}
@@ -53,9 +53,6 @@ public class OAuth2Configuration {
ApplicationProperties applicationProperties, @Lazy UserService userService) {
this.userService = userService;
this.applicationProperties = applicationProperties;
log.info(
"OAuth2Configuration initialized - OAuth2 enabled: {}",
applicationProperties.getSecurity().getOauth2().getEnabled());
}
@Bean
@@ -222,8 +219,6 @@ public class OAuth2Configuration {
name,
oauth.getIssuer(),
REDIRECT_URI_PATH + name);
} else {
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
}
return isValid
@@ -12,9 +12,22 @@ public record CustomSaml2AuthenticatedPrincipal(
String name,
Map<String, List<Object>> attributes,
String nameId,
List<String> sessionIndexes)
List<String> sessionIndexes,
String relyingPartyRegistrationId)
implements Saml2AuthenticatedPrincipal, Serializable {
/**
* Constructor without relyingPartyRegistrationId for backwards compatibility. Sets
* relyingPartyRegistrationId to null.
*/
public CustomSaml2AuthenticatedPrincipal(
String name,
Map<String, List<Object>> attributes,
String nameId,
List<String> sessionIndexes) {
this(name, attributes, nameId, sessionIndexes, null);
}
@Override
public String getName() {
return this.name;
@@ -24,4 +37,14 @@ public record CustomSaml2AuthenticatedPrincipal(
public Map<String, List<Object>> getAttributes() {
return this.attributes;
}
@Override
public List<String> getSessionIndexes() {
return this.sessionIndexes;
}
@Override
public String getRelyingPartyRegistrationId() {
return this.relyingPartyRegistrationId;
}
}
@@ -1,12 +1,14 @@
package stirling.software.proprietary.security.saml2;
import static stirling.software.proprietary.security.model.AuthenticationType.SAML2;
import static stirling.software.proprietary.security.model.AuthenticationType.SSO;
import java.io.IOException;
import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@@ -33,6 +35,7 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@@ -70,8 +73,7 @@ public class CustomSaml2AuthenticationSuccessHandler
// Check if user is eligible for SAML (grandfathered or system has ENTERPRISE license)
if (userExists) {
stirling.software.proprietary.security.model.User user =
userService.findByUsernameIgnoreCase(username).orElse(null);
User user = userService.findByUsernameIgnoreCase(username).orElse(null);
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
// User is not grandfathered and no ENTERPRISE license - block SAML login
@@ -126,8 +128,8 @@ public class CustomSaml2AuthenticationSuccessHandler
}
boolean hasPassword = userExists && userService.hasPassword(username);
boolean isSSOUser =
userExists && userService.isAuthenticationTypeByUsername(username, SSO);
boolean isSsoUser =
userExists && userService.isSsoAuthenticationTypeByUsername(username);
boolean isSAML2User =
userExists && userService.isAuthenticationTypeByUsername(username, SAML2);
@@ -135,15 +137,15 @@ public class CustomSaml2AuthenticationSuccessHandler
"User status - Exists: {}, Has password: {}, Is SSO user: {}, Is SAML2 user: {}",
userExists,
hasPassword,
isSSOUser,
isSsoUser,
isSAML2User);
if (userExists
&& hasPassword
&& (!isSSOUser || !isSAML2User)
&& !isSsoUser
&& saml2Properties.getAutoCreateUser()) {
log.debug(
"User {} exists with password but is not SSO user, redirecting to logout",
"User {} exists with password but is not an SSO user, redirecting to logout",
username);
String origin = resolveOrigin(request);
response.sendRedirect(origin + "/logout?oAuth2AuthenticationErrorWeb=true");
@@ -172,7 +174,11 @@ public class CustomSaml2AuthenticationSuccessHandler
// Extract SSO provider information from SAML2 assertion
String ssoProviderId = saml2Principal.nameId();
String ssoProvider = "saml2"; // fixme
String ssoProvider =
(saml2Properties.getIdpIssuer() != null
&& !saml2Properties.getIdpIssuer().isBlank())
? saml2Properties.getIdpIssuer()
: saml2Properties.getRegistrationId();
log.debug(
"Processing SSO post-login for user: {} (Provider: {}, ProviderId: {})",
@@ -188,12 +194,21 @@ public class CustomSaml2AuthenticationSuccessHandler
SAML2);
log.debug("Successfully processed authentication for user: {}", username);
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication,
Map.of("authType", AuthenticationType.SAML2));
Map<String, Object> claims = new HashMap<>();
claims.put("authType", AuthenticationType.SAML2);
claims.put("samlNameId", saml2Principal.nameId());
List<String> sessionIndexes = saml2Principal.sessionIndexes();
if (sessionIndexes != null && !sessionIndexes.isEmpty()) {
claims.put("samlSessionIndexes", sessionIndexes);
}
if (ssoProvider != null) {
claims.put("samlProvider", ssoProvider);
}
if (saml2Properties.getRegistrationId() != null) {
claims.put("samlRegistrationId", saml2Properties.getRegistrationId());
}
String jwt = jwtService.generateToken(authentication, claims);
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -318,15 +333,18 @@ public class CustomSaml2AuthenticationSuccessHandler
String referer = request.getHeader("Referer");
if (referer != null && !referer.isEmpty()) {
try {
java.net.URL refererUrl = new java.net.URL(referer);
String origin = refererUrl.getProtocol() + "://" + refererUrl.getHost();
if (refererUrl.getPort() != -1
&& refererUrl.getPort() != 80
&& refererUrl.getPort() != 443) {
origin += ":" + refererUrl.getPort();
URI refererUri = URI.create(referer);
String host = refererUri.getHost();
if (host == null) {
return Optional.empty();
}
String origin = refererUri.getScheme() + "://" + host;
int port = refererUri.getPort();
if (port != -1 && port != 80 && port != 443) {
origin += ":" + port;
}
return Optional.of(origin);
} catch (java.net.MalformedURLException e) {
} catch (IllegalArgumentException e) {
log.debug(
"Malformed referer URL: {}, falling back to request-based origin", referer);
}
@@ -94,7 +94,7 @@ public class JwtSaml2AuthenticationRequestRepository
if (token != null) {
tokenStore.remove(authnRequestId);
log.debug("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
log.info("Retrieved SAMLRequest token for RelayState ID: {}", authnRequestId);
return token;
} else {
log.warn("No SAMLRequest token found for RelayState ID: {}", authnRequestId);
@@ -1,9 +1,22 @@
package stirling.software.proprietary.security.saml2;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Base64;
import java.util.Collections;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import org.opensaml.saml.saml2.core.AuthnRequest;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -19,6 +32,10 @@ import org.springframework.security.saml2.provider.service.registration.RelyingP
import org.springframework.security.saml2.provider.service.registration.Saml2MessageBinding;
import org.springframework.security.saml2.provider.service.web.Saml2AuthenticationRequestRepository;
import org.springframework.security.saml2.provider.service.web.authentication.OpenSaml4AuthenticationRequestResolver;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
import jakarta.servlet.http.HttpServletRequest;
@@ -27,6 +44,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Security.SAML2;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
@Configuration
@@ -35,45 +53,34 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
@RequiredArgsConstructor
public class Saml2Configuration {
private static final String SAML_METADATA_NS = "urn:oasis:names:tc:SAML:2.0:metadata";
private static final String XML_DSIG_NS = "http://www.w3.org/2000/09/xmldsig#";
private final ApplicationProperties applicationProperties;
@Bean
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
public RelyingPartyRegistrationRepository relyingPartyRegistrations() throws Exception {
public RelyingPartyRegistrationRepository relyingPartyRegistrations() {
SAML2 samlConf = applicationProperties.getSecurity().getSaml2();
Optional<IdpMetadataInfo> metadataInfo = loadIdpMetadata(samlConf);
log.info(
"Initializing SAML2 configuration with registration ID: {}",
samlConf.getRegistrationId());
// Load IdP certificate
X509Certificate idpCert;
try {
Resource idpCertResource = samlConf.getIdpCert();
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
if (!idpCertResource.exists()) {
log.error(
"SAML2 IdP certificate not found at: {}", idpCertResource.getDescription());
throw new IllegalStateException(
"SAML2 IdP certificate file does not exist: "
+ idpCertResource.getDescription());
}
idpCert = CertificateUtils.readCertificate(idpCertResource);
log.info(
"Successfully loaded IdP certificate. Subject: {}",
idpCert.getSubjectX500Principal().getName());
} catch (Exception e) {
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
}
// Load IdP certificate either from metadata or fallback resource
X509Certificate idpCert =
metadataInfo
.map(IdpMetadataInfo::signingCertificate)
.orElseGet(() -> loadIdpCertificateFromResource(samlConf));
Saml2X509Credential verificationCredential = Saml2X509Credential.verification(idpCert);
// Load SP private key and certificate
Resource privateKeyResource = samlConf.getPrivateKey();
Resource certificateResource = samlConf.getSpCert();
Resource privateKeyResource = samlConf.getSp().getPrivateKeyResource();
Resource certificateResource = samlConf.getSp().getCertResource();
log.info("Loading SP private key from: {}", privateKeyResource.getDescription());
log.debug("Loading SP private key from: {}", privateKeyResource.getDescription());
if (!privateKeyResource.exists()) {
log.error("SAML2 SP private key not found at: {}", privateKeyResource.getDescription());
throw new IllegalStateException(
@@ -81,7 +88,7 @@ public class Saml2Configuration {
+ privateKeyResource.getDescription());
}
log.info("Loading SP certificate from: {}", certificateResource.getDescription());
log.debug("Loading SP certificate from: {}", certificateResource.getDescription());
if (!certificateResource.exists()) {
log.error(
"SAML2 SP certificate not found at: {}", certificateResource.getDescription());
@@ -102,6 +109,43 @@ public class Saml2Configuration {
log.error("Failed to load SAML2 SP credentials: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 SP credentials", e);
}
// Apply metadata overrides - metadata takes precedence over manual config
metadataInfo.ifPresent(info -> applyMetadataOverrides(samlConf, info));
// Get IdP configuration - prefer values from metadata, fall back to manual config
String idpEntityId =
metadataInfo
.map(IdpMetadataInfo::entityId)
.filter(id -> id != null && !id.isBlank())
.orElseGet(() -> samlConf.getProvider().getEntityId());
String idpSingleLoginUrl =
metadataInfo
.map(IdpMetadataInfo::singleSignOnServiceUrl)
.filter(url -> url != null && !url.isBlank())
.orElseGet(() -> samlConf.getProvider().getSingleLoginUrl());
String idpSingleLogoutUrl =
metadataInfo
.map(IdpMetadataInfo::singleLogoutServiceUrl)
.filter(url -> url != null && !url.isBlank())
.orElseGet(() -> samlConf.getProvider().getSingleLogoutUrl());
// Validate required IdP configuration
if (idpEntityId == null || idpEntityId.isBlank()) {
throw new IllegalStateException(
"SAML2 IdP Entity ID is required. Set security.saml2.entityId or provide security.saml2.metadataUri");
}
if (idpSingleLoginUrl == null || idpSingleLoginUrl.isBlank()) {
throw new IllegalStateException(
"SAML2 IdP Single Sign-On URL is required. Set security.saml2.provider.singleLoginUrl or provide security.saml2.metadataUri");
}
log.info(
"SAML2 IdP configuration: entityId={}, ssoUrl={}, sloUrl={}",
idpEntityId,
idpSingleLoginUrl,
idpSingleLogoutUrl);
// Get backend URL from configuration (for SAML endpoints)
String backendUrl = applicationProperties.getSystem().getBackendUrl();
@@ -116,40 +160,38 @@ public class Saml2Configuration {
String entityId =
backendUrl + "/saml2/service-provider-metadata/" + samlConf.getRegistrationId();
String acsLocation = backendUrl + "/login/saml2/sso/{registrationId}";
String sloResponseLocation = backendUrl + "/login";
// SP's Single Logout Service endpoint (where SP receives logout requests/responses from
// IdP)
String spSloLocation = backendUrl + "/logout/saml2/slo";
RelyingPartyRegistration rp =
RelyingPartyRegistration.withRegistrationId(samlConf.getRegistrationId())
.signingX509Credentials(c -> c.add(signingCredential))
.entityId(entityId)
.singleLogoutServiceBinding(Saml2MessageBinding.POST)
.singleLogoutServiceLocation(samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(sloResponseLocation)
.singleLogoutServiceLocation(spSloLocation)
.singleLogoutServiceResponseLocation(spSloLocation)
.assertionConsumerServiceBinding(Saml2MessageBinding.POST)
.assertionConsumerServiceLocation(acsLocation)
.authnRequestsSigned(true)
.assertingPartyMetadata(
metadata ->
metadata.entityId(samlConf.getIdpIssuer())
metadata.entityId(idpEntityId)
.verificationX509Credentials(
c -> c.add(verificationCredential))
.singleSignOnServiceBinding(
Saml2MessageBinding.POST)
.singleSignOnServiceLocation(
samlConf.getIdpSingleLoginUrl())
.singleSignOnServiceLocation(idpSingleLoginUrl)
.singleLogoutServiceBinding(
Saml2MessageBinding.POST)
.singleLogoutServiceLocation(
samlConf.getIdpSingleLogoutUrl())
.singleLogoutServiceResponseLocation(
sloResponseLocation)
.singleLogoutServiceLocation(idpSingleLogoutUrl)
.wantAuthnRequestsSigned(true))
.build();
log.info(
"SAML2 configuration initialized successfully. Registration ID: {}, IdP: {}",
samlConf.getRegistrationId(),
samlConf.getIdpIssuer());
idpEntityId);
return new InMemoryRelyingPartyRegistrationRepository(rp);
}
@@ -206,6 +248,212 @@ public class Saml2Configuration {
return resolver;
}
private X509Certificate loadIdpCertificateFromResource(SAML2 samlConf) {
try {
Resource idpCertResource = samlConf.getProvider().getCertResource();
if (idpCertResource == null) {
throw new IllegalStateException("SAML2 IdP certificate resource is not defined");
}
log.info("Loading IdP certificate from: {}", idpCertResource.getDescription());
if (!idpCertResource.exists()) {
throw new IllegalStateException(
"SAML2 IdP certificate file does not exist: "
+ idpCertResource.getDescription());
}
X509Certificate certificate = CertificateUtils.readCertificate(idpCertResource);
log.info(
"Successfully loaded IdP certificate. Subject: {}",
certificate.getSubjectX500Principal().getName());
return certificate;
} catch (Exception e) {
log.error("Failed to load SAML2 IdP certificate: {}", e.getMessage(), e);
throw new IllegalStateException("Failed to load SAML2 IdP certificate", e);
}
}
private void applyMetadataOverrides(SAML2 samlConf, IdpMetadataInfo metadataInfo) {
log.info(
"Applying IdP metadata overrides for registration: {}",
samlConf.getRegistrationId());
SAML2.Provider provider = samlConf.getProvider();
overrideIfPresent(metadataInfo.entityId(), provider::setEntityId);
overrideIfPresent(metadataInfo.singleSignOnServiceUrl(), provider::setSingleLoginUrl);
overrideIfPresent(metadataInfo.singleLogoutServiceUrl(), provider::setSingleLogoutUrl);
// Persist discovered metadata values to settings.yml
persistMetadataToSettings(metadataInfo);
}
/**
* Persists IdP metadata discovered values to settings.yml. This ensures the discovered
* configuration is saved for future reference and survives restarts even if the metadata
* endpoint becomes unavailable.
*/
private void persistMetadataToSettings(IdpMetadataInfo metadataInfo) {
log.info(
"Migrating discovered IdP metadata to SAML configuration. Existing configuration will be overridden.");
try {
boolean anyPersisted = false;
if (hasText(metadataInfo.entityId())) {
GeneralUtils.saveKeyToSettings(
"security.saml2.provider.entityId", metadataInfo.entityId());
log.info(" -> Persisted provider.entityId: {}", metadataInfo.entityId());
anyPersisted = true;
}
if (hasText(metadataInfo.singleSignOnServiceUrl())) {
GeneralUtils.saveKeyToSettings(
"security.saml2.provider.singleLoginUrl",
metadataInfo.singleSignOnServiceUrl());
log.info(
" -> Persisted provider.singleLoginUrl: {}",
metadataInfo.singleSignOnServiceUrl());
anyPersisted = true;
}
if (hasText(metadataInfo.singleLogoutServiceUrl())) {
GeneralUtils.saveKeyToSettings(
"security.saml2.provider.singleLogoutUrl",
metadataInfo.singleLogoutServiceUrl());
log.info(
" -> Persisted provider.singleLogoutUrl: {}",
metadataInfo.singleLogoutServiceUrl());
anyPersisted = true;
}
if (anyPersisted) {
log.info(
"IdP metadata successfully persisted to settings.yml. These values will be used as fallback if metadataUri becomes unavailable.");
}
} catch (Exception e) {
log.warn(
"Failed to persist IdP metadata to settings.yml: {}. SAML will still work but discovered values won't be saved.",
e.getMessage());
}
}
private Optional<IdpMetadataInfo> loadIdpMetadata(SAML2 samlConf) {
String metadataLocation = samlConf.getEffectiveMetadataUri();
if (metadataLocation == null || metadataLocation.isBlank()) {
return Optional.empty();
}
try (InputStream metadataStream = samlConf.getMetadataUriAsStream()) {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setNamespaceAware(true);
// XXE prevention - disable all external entities and DTD processing
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature(
"http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(metadataStream);
Element entityDescriptor = doc.getDocumentElement();
if (entityDescriptor == null) {
log.warn("No EntityDescriptor found in SAML metadata: {}", metadataLocation);
return Optional.empty();
}
String entityId = entityDescriptor.getAttribute("entityID");
NodeList idpDescriptors =
entityDescriptor.getElementsByTagNameNS(SAML_METADATA_NS, "IDPSSODescriptor");
if (idpDescriptors.getLength() == 0) {
log.warn("No IDPSSODescriptor found in SAML metadata: {}", metadataLocation);
return Optional.empty();
}
Element idpDescriptor = (Element) idpDescriptors.item(0);
String ssoUrl = extractServiceLocation(idpDescriptor, "SingleSignOnService");
String sloUrl = extractServiceLocation(idpDescriptor, "SingleLogoutService");
X509Certificate signingCert = extractSigningCertificate(idpDescriptor);
log.info("Loaded IdP metadata from: {}", metadataLocation);
return Optional.of(new IdpMetadataInfo(entityId, ssoUrl, sloUrl, signingCert));
} catch (IOException
| ParserConfigurationException
| SAXException
| CertificateException e) {
log.warn("Failed to parse SAML metadata from {}: {}", metadataLocation, e.getMessage());
return Optional.empty();
}
}
private String extractServiceLocation(Element descriptor, String tagName) {
NodeList services = descriptor.getElementsByTagNameNS(SAML_METADATA_NS, tagName);
String fallback = null;
for (int i = 0; i < services.getLength(); i++) {
Element service = (Element) services.item(i);
String location = service.getAttribute("Location");
String binding = service.getAttribute("Binding");
if (!hasText(location)) {
continue;
}
if (Saml2MessageBinding.POST.getUrn().equals(binding)) {
return location;
}
if (fallback == null) {
fallback = location;
}
}
return fallback;
}
private X509Certificate extractSigningCertificate(Element descriptor)
throws CertificateException {
NodeList keyDescriptors =
descriptor.getElementsByTagNameNS(SAML_METADATA_NS, "KeyDescriptor");
for (int i = 0; i < keyDescriptors.getLength(); i++) {
Element keyDescriptor = (Element) keyDescriptors.item(i);
String use = keyDescriptor.getAttribute("use");
if (hasText(use) && !"signing".equalsIgnoreCase(use)) {
continue;
}
NodeList certificateNodes =
keyDescriptor.getElementsByTagNameNS(XML_DSIG_NS, "X509Certificate");
if (certificateNodes.getLength() == 0) {
continue;
}
String certificateValue = certificateNodes.item(0).getTextContent();
if (!hasText(certificateValue)) {
continue;
}
byte[] decoded =
Base64.getMimeDecoder().decode(certificateValue.replaceAll("\\s+", ""));
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
return (X509Certificate)
certificateFactory.generateCertificate(new ByteArrayInputStream(decoded));
}
return null;
}
private boolean hasText(String value) {
return value != null && !value.trim().isEmpty();
}
private void overrideIfPresent(String value, Consumer<String> setter) {
if (hasText(value)) {
setter.accept(value.trim());
}
}
private record IdpMetadataInfo(
String entityId,
String singleSignOnServiceUrl,
String singleLogoutServiceUrl,
X509Certificate signingCertificate) {}
private static void logAuthnRequestDetails(AuthnRequest authnRequest) {
String message =
"""
@@ -21,6 +21,7 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;
import javax.sql.DataSource;
@@ -265,14 +266,17 @@ public class DatabaseService implements DatabaseServiceInterface {
String checksum = bytesToHex(digest.digest(content));
log.info("Checksum for {}: {}", backupPath.getFileName(), checksum);
try (Connection conn = DriverManager.getConnection("jdbc:h2:mem:backupVerify");
String verifyDbUrl = "jdbc:h2:mem:backupVerify_" + UUID.randomUUID();
// Use a fresh in-memory database per verification to avoid leftover objects between
// runs.
try (Connection conn = DriverManager.getConnection(verifyDbUrl);
PreparedStatement stmt = conn.prepareStatement("RUNSCRIPT FROM ?")) {
stmt.setString(1, backupPath.toString());
stmt.execute();
}
return true;
} catch (IOException | NoSuchAlgorithmException | SQLException e) {
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage(), e);
log.error("Backup verification failed for {}: {}", backupPath, e.getMessage());
}
return false;
}
@@ -258,6 +258,24 @@ public class JwtService implements JwtServiceInterface {
return token;
}
// Check for logout cookie (set by frontend before redirecting to /logout for SAML SLO)
if (request.getCookies() != null) {
for (jakarta.servlet.http.Cookie cookie : request.getCookies()) {
if ("stirling_logout_token".equals(cookie.getName())) {
String value = cookie.getValue();
if (value != null && !value.isBlank()) {
try {
return java.net.URLDecoder.decode(
value, java.nio.charset.StandardCharsets.UTF_8);
} catch (Exception e) {
log.debug("Failed to decode logout token cookie", e);
return value;
}
}
}
}
}
return null;
}
@@ -0,0 +1,247 @@
package stirling.software.proprietary.security.service;
import java.sql.SQLException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
/**
* Service for managing multi-factor authentication (MFA) settings for users.
*
* <p>This service reads and writes MFA-related settings such as secrets, enablement flags, and
* last-used TOTP steps.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MfaService {
public static final String MFA_ENABLED_KEY = "mfaEnabled";
public static final String MFA_SECRET_KEY = "mfaSecret";
public static final String MFA_LAST_USED_STEP_KEY = "mfaLastUsedStep";
public static final String MFA_REQUIRED_KEY = "mfaRequired";
private final UserRepository userRepository;
private final DatabaseServiceInterface databaseService;
/**
* Determines whether MFA is enabled for the given user.
*
* @param user target user
* @return {@code true} if MFA is enabled
*/
public boolean isMfaEnabled(User user) {
String value = getSetting(user, MFA_ENABLED_KEY);
return Boolean.parseBoolean(value);
}
/**
* Retrieves the MFA secret for the given user.
*
* @param user target user
* @return Base32-encoded secret, or {@code null} if not set
*/
public String getSecret(User user) {
return getSetting(user, MFA_SECRET_KEY);
}
/**
* Stores a new MFA secret and marks MFA as pending (disabled) for the user.
*
* @param user target user
* @param secret Base32-encoded secret to store
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
@Transactional
public void setSecret(User user, String secret)
throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_ENABLED_KEY, "false");
// Clear existing values and flush the removals before inserting a new secret. This keeps
// the (user_id, setting_key) PK satisfied even when the persistence context re-inserts the
// same keys within a single transaction.
settings.remove(MFA_SECRET_KEY);
settings.remove(MFA_LAST_USED_STEP_KEY);
if (managedUser != null && managedUser.getId() != null) {
userRepository.deleteSettingsByUserIdAndKeys(
managedUser.getId(), Arrays.asList(MFA_SECRET_KEY, MFA_LAST_USED_STEP_KEY));
userRepository.flush();
}
settings.put(MFA_SECRET_KEY, secret);
persist(managedUser);
}
/**
* Enables MFA for the given user.
*
* @param user target user
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
public void enableMfa(User user) throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_ENABLED_KEY, "true");
persist(managedUser);
}
/**
* Clears any pending MFA setup data for the user.
*
* @param user target user
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
public void clearPendingSecret(User user) throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_ENABLED_KEY, "false");
settings.remove(MFA_SECRET_KEY);
settings.remove(MFA_LAST_USED_STEP_KEY);
persist(managedUser);
}
/**
* Disables MFA and clears stored secrets for the user.
*
* @param user target user
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
public void disableMfa(User user) throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_ENABLED_KEY, "false");
settings.remove(MFA_SECRET_KEY);
settings.remove(MFA_LAST_USED_STEP_KEY);
persist(managedUser);
}
/**
* Checks whether a TOTP time step has not been used before.
*
* @param user target user
* @param timeStep candidate TOTP time step
* @return {@code true} if the time step is usable
*/
public boolean isTotpStepUsable(User user, long timeStep) {
User managedUser = getUserWithSettings(user);
Map<String, String> settings = managedUser.getSettings();
if (settings == null) {
return true;
}
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
if (lastUsed == null) {
return true;
}
try {
long lastUsedStep = Long.parseLong(lastUsed);
return timeStep > lastUsedStep;
} catch (NumberFormatException ignored) {
return true;
}
}
/**
* Marks a TOTP time step as used, preventing replay.
*
* @param user target user
* @param timeStep time step to mark as used
* @return {@code true} if the time step was marked, {@code false} if it was already used
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
public boolean markTotpStepUsed(User user, long timeStep)
throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
Map<String, String> settings = ensureSettings(managedUser);
String lastUsed = settings.get(MFA_LAST_USED_STEP_KEY);
if (lastUsed != null) {
try {
long lastUsedStep = Long.parseLong(lastUsed);
if (timeStep <= lastUsedStep) {
return false;
}
} catch (NumberFormatException ignored) {
// treat malformed value as unused
}
}
settings.put(MFA_LAST_USED_STEP_KEY, Long.toString(timeStep));
persist(managedUser);
return true;
}
/**
* Determines whether MFA is required for the given user.
*
* @param user target user
* @return {@code true} if MFA is required
*/
public boolean isMfaRequired(User user) {
String value = getSetting(user, MFA_REQUIRED_KEY);
if (value == null) {
value = "false";
}
log.info("MFA required for user {}: {}", user.getUsername(), value);
return Boolean.parseBoolean(value);
}
/**
* Sets whether MFA is required for the given user.
*
* @param user target user
* @param required {@code true} to require MFA
* @throws SQLException when database persistence fails
* @throws UnsupportedProviderException when the database provider is unsupported
*/
public void setMfaRequired(User user, boolean required)
throws SQLException, UnsupportedProviderException {
User managedUser = getUserWithSettings(user);
Map<String, String> settings = ensureSettings(managedUser);
settings.put(MFA_REQUIRED_KEY, Boolean.toString(required));
log.info("Set MFA required={} for user {}", required, managedUser.getUsername());
persist(managedUser);
}
private String getSetting(User user, String key) {
User managedUser = getUserWithSettings(user);
Map<String, String> settings = managedUser.getSettings();
if (settings == null) {
return null;
}
return settings.get(key);
}
private User getUserWithSettings(User user) {
if (user == null || user.getId() == null) {
return user;
}
return userRepository.findByIdWithSettings(user.getId()).orElse(user);
}
private Map<String, String> ensureSettings(User user) {
Map<String, String> settings = user.getSettings();
if (settings == null) {
settings = new HashMap<>();
user.setSettings(settings);
}
return settings;
}
private void persist(User user) throws SQLException, UnsupportedProviderException {
userRepository.save(user);
databaseService.exportDatabase();
}
}
@@ -0,0 +1,50 @@
package stirling.software.proprietary.security.service;
import lombok.Builder;
import lombok.Getter;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.AuthenticationType;
/**
* Carries all attributes required to create or update a user account, including credentials,
* SSO/provider details, team association, role and MFA configuration. Used by the security service
* layer to persist or update users.
*
* <p>Defaults:
*
* <ul>
* <li>password: null
* <li>ssoProviderId: null
* <li>ssoProvider: null
* <li>authenticationType: {@code AuthenticationType.WEB}
* <li>teamId: null
* <li>team: null
* <li>role: {@code Role.USER.getRoleId()}
* <li>firstLogin: false
* <li>enabled: true
* <li>requireMfa: false
* <li>mfaEnabled: false
* <li>mfaSecret: null
* <li>mfaLastUsedStep: null
* </ul>
*/
@Getter
@Builder(builderClassName = "Builder")
public class SaveUserRequest {
private final String username;
@Builder.Default private final String password = null;
@Builder.Default private final String ssoProviderId = null;
@Builder.Default private final String ssoProvider = null;
@Builder.Default private final AuthenticationType authenticationType = AuthenticationType.WEB;
@Builder.Default private final Long teamId = null;
@Builder.Default private final Team team = null;
@Builder.Default private final String role = Role.USER.getRoleId();
@Builder.Default private final boolean firstLogin = false;
@Builder.Default private final boolean enabled = true;
@Builder.Default private final boolean requireMfa = false;
@Builder.Default private final boolean mfaEnabled = false;
@Builder.Default private final String mfaSecret = null;
@Builder.Default private final Long mfaLastUsedStep = null;
}
@@ -0,0 +1,159 @@
package stirling.software.proprietary.security.service;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.util.Base32Codec;
/**
* Service for generating and validating TOTP secrets and codes for MFA authentication.
*
* <p>This service handles secret generation, code validation across time steps, and building
* otpauth:// URIs to provision authenticator apps.
*/
@Service
@RequiredArgsConstructor
public class TotpService {
private static final int SECRET_LENGTH_BYTES = 20;
private static final int CODE_DIGITS = 6;
private static final int PERIOD_SECONDS = 30;
private static final String HMAC_ALGORITHM = "HmacSHA1";
private static final String DEFAULT_ISSUER = "Stirling PDF";
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private final ApplicationProperties applicationProperties;
/**
* Generates a new random TOTP secret encoded in Base32.
*
* @return Base32-encoded secret suitable for provisioning an authenticator app
*/
public String generateSecret() {
byte[] secret = new byte[SECRET_LENGTH_BYTES];
SECURE_RANDOM.nextBytes(secret);
return Base32Codec.encode(secret);
}
/**
* Checks whether a submitted TOTP code is valid for the current time window.
*
* @param secret Base32-encoded shared secret
* @param code six-digit code supplied by the user
* @return {@code true} if the code is valid for the current time window
*/
public boolean isValidCode(String secret, String code) {
return getValidTimeStep(secret, code) != null;
}
/**
* Validates a TOTP code and returns the time step it matches.
*
* @param secret Base32-encoded shared secret
* @param code six-digit code supplied by the user
* @return matching time step, or {@code null} if the code is invalid
*/
public Long getValidTimeStep(String secret, String code) {
if (secret == null || secret.isBlank() || code == null) {
return null;
}
String normalizedCode = code.replace(" ", "");
if (!normalizedCode.matches("\\d{6}")) {
return null;
}
byte[] secretKey;
try {
secretKey = Base32Codec.decode(secret);
} catch (IllegalArgumentException e) {
return null;
}
long timeStep = Instant.now().getEpochSecond() / PERIOD_SECONDS;
byte[] normalizedCodeBytes = normalizedCode.getBytes(StandardCharsets.UTF_8);
for (int offset = -1; offset <= 1; offset++) {
long candidate = timeStep + offset;
String generatedCode = generateCode(secretKey, candidate);
if (MessageDigest.isEqual(
generatedCode.getBytes(StandardCharsets.UTF_8), normalizedCodeBytes)) {
return candidate;
}
}
return null;
}
/**
* Builds an otpauth:// URI for configuring TOTP in authenticator apps.
*
* @param username account identifier to embed in the label
* @param secret Base32-encoded secret to embed in the URI
* @return otpauth URI that can be encoded as a QR code
*/
public String buildOtpAuthUri(String username, String secret) {
String issuer = resolveIssuer();
String label = encodeForOtpAuth(issuer + ":" + username);
String encodedIssuer = encodeForOtpAuth(issuer);
return "otpauth://totp/"
+ label
+ "?secret="
+ secret
+ "&issuer="
+ encodedIssuer
+ "&algorithm=SHA1&digits="
+ CODE_DIGITS
+ "&period="
+ PERIOD_SECONDS;
}
private String encodeForOtpAuth(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8).replace("+", "%20");
}
private String resolveIssuer() {
if (applicationProperties.getUi() != null) {
String appName = applicationProperties.getUi().getAppNameNavbar();
if (appName != null && !appName.isBlank()) {
return appName.trim();
}
}
return DEFAULT_ISSUER;
}
private String generateCode(byte[] secret, long timeStep) {
try {
ByteBuffer buffer = ByteBuffer.allocate(8);
buffer.putLong(timeStep);
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
mac.init(new SecretKeySpec(secret, HMAC_ALGORITHM));
byte[] hash = mac.doFinal(buffer.array());
int offset = hash[hash.length - 1] & 0x0F;
int binary =
((hash[offset] & 0x7F) << 24)
| ((hash[offset + 1] & 0xFF) << 16)
| ((hash[offset + 2] & 0xFF) << 8)
| (hash[offset + 3] & 0xFF);
int otp = binary % (int) Math.pow(10, CODE_DIGITS);
return String.format("%0" + CODE_DIGITS + "d", otp);
} catch (Exception e) {
throw new IllegalStateException("Failed to generate TOTP code", e);
}
}
}
@@ -1,5 +1,10 @@
package stirling.software.proprietary.security.service;
import static stirling.software.proprietary.security.service.MfaService.MFA_ENABLED_KEY;
import static stirling.software.proprietary.security.service.MfaService.MFA_LAST_USED_STEP_KEY;
import static stirling.software.proprietary.security.service.MfaService.MFA_REQUIRED_KEY;
import static stirling.software.proprietary.security.service.MfaService.MFA_SECRET_KEY;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
@@ -101,7 +106,13 @@ public class UserService implements UserServiceInterface {
}
if (autoCreateUser) {
saveUser(username, ssoProviderId, ssoProvider, type);
SaveUserRequest.Builder builder =
SaveUserRequest.builder()
.username(username)
.ssoProviderId(ssoProviderId)
.ssoProvider(ssoProvider)
.authenticationType(type);
saveUserCore(builder.build());
}
}
@@ -141,6 +152,14 @@ public class UserService implements UserServiceInterface {
return user;
}
private User saveUser(Optional<User> user, String apiKey) {
if (user.isPresent()) {
user.get().setApiKey(apiKey);
return userRepository.save(user.get());
}
throw new UsernameNotFoundException("User not found");
}
public User refreshApiKeyForUser(String username) {
// reuse the add API key method for refreshing
return addApiKeyToUser(username);
@@ -179,162 +198,6 @@ public class UserService implements UserServiceInterface {
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
}
public void saveUser(String username, AuthenticationType authenticationType)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUser(username, authenticationType, (Long) null, Role.USER.getRoleId());
}
public void saveUser(
String username,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUser(
username,
ssoProviderId,
ssoProvider,
authenticationType,
(Long) null,
Role.USER.getRoleId());
}
private User saveUser(Optional<User> user, String apiKey) {
if (user.isPresent()) {
user.get().setApiKey(apiKey);
return userRepository.save(user.get());
}
throw new UsernameNotFoundException("User not found");
}
public User saveUser(
String username, AuthenticationType authenticationType, Long teamId, String role)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
return saveUserCore(
username, // username
null, // password
null, // ssoProviderId
null, // ssoProvider
authenticationType, // authenticationType
teamId, // teamId
null, // team
role, // role
false, // firstLogin
true // enabled
);
}
public User saveUser(
String username,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType,
Long teamId,
String role)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
return saveUserCore(
username, // username
null, // password
ssoProviderId, // ssoProviderId
ssoProvider, // ssoProvider
authenticationType, // authenticationType
teamId, // teamId
null, // team
role, // role
false, // firstLogin
true // enabled
);
}
public User saveUser(
String username, AuthenticationType authenticationType, Team team, String role)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
return saveUserCore(
username, // username
null, // password
null, // ssoProviderId
null, // ssoProvider
authenticationType, // authenticationType
null, // teamId
team, // team
role, // role
false, // firstLogin
true // enabled
);
}
public User saveUser(String username, String password, Long teamId)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
return saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
Role.USER.getRoleId(), // role
false, // firstLogin
true // enabled
);
}
public User saveUser(
String username, String password, Team team, String role, boolean firstLogin)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
return saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
null, // teamId
team, // team
role, // role
firstLogin, // firstLogin
true // enabled
);
}
public User saveUser(
String username, String password, Long teamId, String role, boolean firstLogin)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
return saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
role, // role
firstLogin, // firstLogin
true // enabled
);
}
public void saveUser(String username, String password, Long teamId, String role)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUser(username, password, teamId, role, false);
}
public void saveUser(
String username, String password, Long teamId, boolean firstLogin, boolean enabled)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
saveUserCore(
username, // username
password, // password
null, // ssoProviderId
null, // ssoProvider
AuthenticationType.WEB, // authenticationType
teamId, // teamId
null, // team
Role.USER.getRoleId(), // role
firstLogin, // firstLogin
enabled // enabled
);
}
public void deleteUser(String username) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
@@ -485,75 +348,71 @@ public class UserService implements UserServiceInterface {
}
/**
* Core implementation for saving a user with all possible parameters. This method centralizes
* the common logic for all saveUser variants.
* Core method to save a user based on the provided SaveUserRequest.
*
* @param username Username for the new user
* @param password Password for the user (may be null for SSO/OAuth users)
* @param ssoProviderId Unique identifier from SSO provider (may be null for non-SSO users)
* @param ssoProvider Name of the SSO provider (may be null for non-SSO users)
* @param authenticationType Type of authentication (WEB, SSO, etc.)
* @param teamId ID of the team to assign (may be null to use default)
* @param team Team object to assign (takes precedence over teamId if both provided)
* @param role Role to assign to the user
* @param firstLogin Whether this is the user's first login
* @param enabled Whether the user account is enabled
* @param request The SaveUserRequest containing user details
* @return The saved User object
* @throws IllegalArgumentException If username is invalid or team is invalid
* @throws SQLException If database operation fails
* @throws UnsupportedProviderException If provider is not supported
* @throws IllegalArgumentException If the username is invalid
* @throws SQLException If a database error occurs
* @throws UnsupportedProviderException If an unsupported provider is specified
*/
private User saveUserCore(
String username,
String password,
String ssoProviderId,
String ssoProvider,
AuthenticationType authenticationType,
Long teamId,
Team team,
String role,
boolean firstLogin,
boolean enabled)
public User saveUserCore(SaveUserRequest request)
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
if (!isUsernameValid(username)) {
if (!isUsernameValid(request.getUsername())) {
throw new IllegalArgumentException(getInvalidUsernameMessage());
}
User user = new User();
user.setUsername(username);
user.setUsername(request.getUsername());
// Set password if provided
if (password != null && !password.isEmpty()) {
user.setPassword(passwordEncoder.encode(password));
if (request.getPassword() != null && !request.getPassword().isEmpty()) {
user.setPassword(passwordEncoder.encode(request.getPassword()));
}
// Set SSO provider details if provided
if (ssoProviderId != null && ssoProvider != null) {
user.setSsoProviderId(ssoProviderId);
user.setSsoProvider(ssoProvider);
if (request.getSsoProviderId() != null && request.getSsoProvider() != null) {
user.setSsoProviderId(request.getSsoProviderId());
user.setSsoProvider(request.getSsoProvider());
}
// Set authentication type
user.setAuthenticationType(authenticationType);
user.setAuthenticationType(request.getAuthenticationType());
// Set enabled status
user.setEnabled(enabled);
user.setEnabled(request.isEnabled());
// Set first login flag
user.setFirstLogin(firstLogin);
user.setFirstLogin(request.isFirstLogin());
// Set MFA requirement
Map<String, String> settings = user.getSettings();
settings.put(MFA_REQUIRED_KEY, String.valueOf(request.isRequireMfa()));
settings.put(MFA_ENABLED_KEY, String.valueOf(request.isMfaEnabled()));
if (request.getMfaSecret() != null && !request.getMfaSecret().isEmpty()) {
settings.put(MFA_SECRET_KEY, request.getMfaSecret());
} else {
settings.remove(MFA_SECRET_KEY);
}
if (request.getMfaLastUsedStep() != null) {
settings.put(MFA_LAST_USED_STEP_KEY, String.valueOf(request.getMfaLastUsedStep()));
} else {
settings.remove(MFA_LAST_USED_STEP_KEY);
}
log.info(
"MFA required set to true for user {} {}",
request.getUsername(),
user.getSettings().toString());
// Set role (authority)
if (role == null) {
role = Role.USER.getRoleId();
}
user.addAuthority(new Authority(role, user));
user.addAuthority(new Authority(request.getRole(), user));
// Resolve and set team
if (team != null) {
user.setTeam(team);
if (request.getTeam() != null) {
user.setTeam(request.getTeam());
} else {
user.setTeam(resolveTeam(teamId, this::getDefaultTeam));
user.setTeam(resolveTeam(request.getTeamId(), this::getDefaultTeam));
}
// Save user
@@ -600,6 +459,32 @@ public class UserService implements UserServiceInterface {
return user.isPresent() && user.get().hasPassword();
}
public boolean isSsoAuthenticationTypeByUsername(String username) {
Optional<User> user = findByUsernameIgnoreCase(username);
if (user.isEmpty()) {
return false;
}
String authType = user.get().getAuthenticationType();
if (authType == null) {
return false;
}
try {
AuthenticationType authenticationType =
AuthenticationType.valueOf(authType.toUpperCase(Locale.ROOT));
if (authenticationType == AuthenticationType.OAUTH2
|| authenticationType == AuthenticationType.SAML2) {
return true;
}
} catch (IllegalArgumentException ignored) {
// Fall through to legacy string comparison below
}
// Backward compatibility for legacy "SSO" value without relying on the deprecated enum
return "SSO".equalsIgnoreCase(authType);
}
public boolean isAuthenticationTypeByUsername(
String username, AuthenticationType authenticationType) {
Optional<User> user = findByUsernameIgnoreCase(username);
@@ -652,6 +537,7 @@ public class UserService implements UserServiceInterface {
return null;
}
@Override
public boolean isCurrentUserAdmin() {
try {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
@@ -667,6 +553,7 @@ public class UserService implements UserServiceInterface {
return false;
}
@Override
public boolean isCurrentUserFirstLogin() {
try {
String username = getCurrentUsername();
@@ -0,0 +1,96 @@
package stirling.software.proprietary.security.util;
import java.io.ByteArrayOutputStream;
import java.util.Arrays;
import java.util.Locale;
/**
* RFC 4648 Base32 encoder/decoder for handling TOTP secrets.
*
* <p>This implementation is used to encode binary secrets into Base32 strings and decode them back
* into byte arrays for TOTP processing.
*/
public final class Base32Codec {
private static final char[] ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".toCharArray();
private static final int[] LOOKUP_TABLE = new int[128];
static {
Arrays.fill(LOOKUP_TABLE, -1);
for (int i = 0; i < ALPHABET.length; i++) {
LOOKUP_TABLE[ALPHABET[i]] = i;
}
}
private Base32Codec() {}
/**
* Encodes the provided bytes into a Base32 string.
*
* @param data the raw bytes to encode
* @return Base32-encoded representation of the input, or an empty string for null/empty input
*/
public static String encode(byte[] data) {
if (data == null || data.length == 0) {
return "";
}
StringBuilder result = new StringBuilder((data.length * 8 + 4) / 5);
int buffer = data[0] & 0xFF;
int bitsLeft = 8;
int index = 1;
while (bitsLeft > 0 || index < data.length) {
if (bitsLeft < 5) {
if (index < data.length) {
buffer = (buffer << 8) | (data[index++] & 0xFF);
bitsLeft += 8;
} else {
int padding = 5 - bitsLeft;
buffer <<= padding;
bitsLeft += padding;
}
}
int digit = (buffer >> (bitsLeft - 5)) & 0x1F;
bitsLeft -= 5;
result.append(ALPHABET[digit]);
}
return result.toString();
}
/**
* Decodes a Base32 string into raw bytes.
*
* @param value Base32-encoded string (padding and whitespace are ignored)
* @return decoded byte array, or an empty array for null/blank input
* @throws IllegalArgumentException when the input contains invalid Base32 characters
*/
public static byte[] decode(String value) {
if (value == null || value.isBlank()) {
return new byte[0];
}
String normalized = value.replace("=", "").replace(" ", "").toUpperCase(Locale.ROOT);
ByteArrayOutputStream output = new ByteArrayOutputStream(normalized.length());
int buffer = 0;
int bitsLeft = 0;
for (char character : normalized.toCharArray()) {
if (character >= LOOKUP_TABLE.length || LOOKUP_TABLE[character] == -1) {
throw new IllegalArgumentException("Invalid Base32 character: " + character);
}
buffer = (buffer << 5) | LOOKUP_TABLE[character];
bitsLeft += 5;
if (bitsLeft >= 8) {
output.write((buffer >> (bitsLeft - 8)) & 0xFF);
bitsLeft -= 8;
}
}
return output.toByteArray();
}
}
@@ -0,0 +1,137 @@
package stirling.software.proprietary.controller.api;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.when;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.config.AuditConfigurationProperties;
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.AccountData;
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.DatabaseData;
import stirling.software.proprietary.controller.api.ProprietaryUIDataController.LoginData;
import stirling.software.proprietary.repository.PersistentAuditEventRepository;
import stirling.software.proprietary.security.database.repository.SessionRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class ProprietaryUIDataControllerTest {
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
@Mock private UserRepository userRepository;
@Mock private TeamRepository teamRepository;
@Mock private SessionRepository sessionRepository;
@Mock private DatabaseService databaseService;
@Mock private UserLicenseSettingsService licenseSettingsService;
@Mock private PersistentAuditEventRepository auditRepository;
@Mock private MfaService mfaService;
private ApplicationProperties applicationProperties;
private AuditConfigurationProperties auditConfig;
private ObjectMapper objectMapper;
private ProprietaryUIDataController controller;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getUi().setLanguages(List.of("en", "de"));
applicationProperties.getSystem().setDefaultLocale("en");
applicationProperties.getSecurity().setEnableLogin(true);
applicationProperties.getSecurity().getOauth2().setEnabled(false);
applicationProperties.getSecurity().getSaml2().setEnabled(false);
auditConfig = new AuditConfigurationProperties(applicationProperties);
objectMapper = new ObjectMapper();
controller =
new ProprietaryUIDataController(
applicationProperties,
auditConfig,
sessionPersistentRegistry,
userRepository,
teamRepository,
sessionRepository,
databaseService,
objectMapper,
false,
licenseSettingsService,
auditRepository,
mfaService);
}
@Test
void loginDataFlagsFirstTimeSetupWhenNoUsers() {
when(userRepository.findAll()).thenReturn(Collections.emptyList());
ResponseEntity<LoginData> response = controller.getLoginData();
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
LoginData body = response.getBody();
assertThat(body.isFirstTimeSetup()).isTrue();
assertThat(body.isShowDefaultCredentials()).isTrue();
assertThat(body.getLanguages()).containsExactly("en", "de");
assertThat(body.getDefaultLocale()).isEqualTo("en");
}
@Test
void accountDataReturnsUserSettingsAndMfaFlags() {
User user = new User();
user.setUsername("user@example.com");
Authority authority = new Authority();
authority.setAuthority(Role.USER.getRoleId());
user.addAuthority(authority);
user.setSettings(Map.of("theme", "dark"));
when(userRepository.findByUsernameIgnoreCaseWithSettings("user@example.com"))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
when(mfaService.isMfaRequired(user)).thenReturn(false);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
ResponseEntity<AccountData> response = controller.getAccountData(authentication);
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
AccountData data = response.getBody();
assertThat(data.getUsername()).isEqualTo("user@example.com");
assertThat(data.isMfaEnabled()).isTrue();
assertThat(data.isMfaRequired()).isFalse();
assertThat(data.getSettings()).contains("\"theme\":\"dark\"");
}
@Test
void databaseDataMarksUnknownVersion() {
when(databaseService.getBackupList()).thenReturn(List.of());
when(databaseService.getH2Version()).thenReturn("Unknown");
ResponseEntity<DatabaseData> response = controller.getDatabaseData();
assertThat(response.getStatusCode().is2xxSuccessful()).isTrue();
DatabaseData data = response.getBody();
assertThat(data.getBackupFiles()).isEmpty();
assertThat(data.isVersionUnknown()).isTrue();
}
}
@@ -6,9 +6,9 @@ import static org.mockito.Mockito.when;
import java.io.IOException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken;
@@ -26,17 +26,24 @@ class CustomLogoutSuccessHandlerTest {
@Mock private JwtServiceInterface jwtService;
@InjectMocks private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@Mock private ApplicationProperties.Security.SAML2 saml2;
private CustomLogoutSuccessHandler customLogoutSuccessHandler;
@BeforeEach
void setUp() {
customLogoutSuccessHandler = new CustomLogoutSuccessHandler(securityProperties, jwtService);
}
@Test
void testSuccessfulLogout() throws IOException {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String token = "token";
String logoutPath = "/login?logout=true";
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
@@ -50,10 +57,10 @@ class CustomLogoutSuccessHandlerTest {
HttpServletRequest request = mock(HttpServletRequest.class);
HttpServletResponse response = mock(HttpServletResponse.class);
String logoutPath = "/login?logout=true";
String token = "token";
when(response.isCommitted()).thenReturn(false);
when(jwtService.extractToken(request)).thenReturn(token);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getContextPath()).thenReturn("");
when(response.encodeRedirectURL(logoutPath)).thenReturn(logoutPath);
@@ -71,6 +78,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getScheme()).thenReturn("http");
@@ -96,6 +106,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
@@ -128,6 +141,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter(error)).thenReturn("true");
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
@@ -152,6 +168,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn("!!!" + error + "!!!");
when(request.getScheme()).thenReturn("http");
@@ -177,10 +196,12 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter(error)).thenReturn("true");
when(request.getContextPath()).thenReturn(url);
when(request.getScheme()).thenReturn("http");
when(request.getServerName()).thenReturn("localhost");
when(request.getServerPort()).thenReturn(8080);
@@ -204,6 +225,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
@@ -236,6 +260,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
@@ -269,6 +296,9 @@ class CustomLogoutSuccessHandlerTest {
mock(ApplicationProperties.Security.OAUTH2.class);
when(response.isCommitted()).thenReturn(false);
when(request.getParameter("SAMLResponse")).thenReturn(null);
when(securityProperties.getSaml2()).thenReturn(saml2);
when(saml2.getEnableSingleLogout()).thenReturn(false);
when(request.getParameter("oAuth2AuthenticationErrorWeb")).thenReturn(null);
when(request.getParameter("errorOAuth")).thenReturn(null);
when(request.getParameter("oAuth2AutoCreateDisabled")).thenReturn(null);
@@ -0,0 +1,115 @@
package stirling.software.proprietary.security;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.sql.SQLException;
import java.util.Collections;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.DatabaseServiceInterface;
import stirling.software.proprietary.security.service.SaveUserRequest;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class InitialSecuritySetupTest {
@Mock private UserService userService;
@Mock private TeamService teamService;
@Mock private DatabaseServiceInterface databaseService;
@Mock private UserLicenseSettingsService licenseSettingsService;
private ApplicationProperties applicationProperties;
private InitialSecuritySetup initialSecuritySetup;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getSecurity().getInitialLogin().setUsername("admin");
applicationProperties.getSecurity().getInitialLogin().setPassword("password");
Team internalTeam = new Team();
internalTeam.setName(TeamService.INTERNAL_TEAM_NAME);
User internalUser = new User();
internalUser.setUsername(Role.INTERNAL_API_USER.getRoleId());
internalUser.setTeam(internalTeam);
when(userService.findByUsernameIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(Optional.of(internalUser));
when(teamService.getOrCreateInternalTeam()).thenReturn(internalTeam);
initialSecuritySetup =
new InitialSecuritySetup(
userService,
teamService,
applicationProperties,
databaseService,
licenseSettingsService);
}
@Test
void initImportsBackupWhenPresent() throws SQLException, UnsupportedProviderException {
when(userService.hasUsers()).thenReturn(false);
when(databaseService.hasBackup()).thenReturn(true);
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(true);
initialSecuritySetup.init();
verify(databaseService).importDatabase();
verify(userService, never()).saveUserCore(any());
verify(licenseSettingsService).initializeGrandfatheredCount();
verify(licenseSettingsService).updateLicenseMaxUsers();
}
@Test
void initCreatesConfiguredAdminWhenNoBackup()
throws SQLException, UnsupportedProviderException {
when(userService.hasUsers()).thenReturn(false);
when(databaseService.hasBackup()).thenReturn(false);
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.empty());
Team defaultTeam = new Team();
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
when(teamService.getOrCreateDefaultTeam()).thenReturn(defaultTeam);
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
when(userService.usernameExistsIgnoreCase(Role.INTERNAL_API_USER.getRoleId()))
.thenReturn(true);
initialSecuritySetup.init();
ArgumentCaptor<SaveUserRequest> captor = ArgumentCaptor.forClass(SaveUserRequest.class);
verify(userService).saveUserCore(captor.capture());
SaveUserRequest saved = captor.getValue();
assertThat(saved.getUsername()).isEqualTo("admin");
assertThat(saved.getPassword()).isEqualTo("password");
}
@Test
void configureJwtSettingsDisablesKeyCleanupWhenJwtDisabled() {
ReflectionTestUtils.setField(initialSecuritySetup, "v2Enabled", true);
applicationProperties.getSecurity().getJwt().setEnableKeystore(false);
when(userService.hasUsers()).thenReturn(true);
when(userService.getUsersWithoutTeam()).thenReturn(Collections.emptyList());
when(userService.usernameExistsIgnoreCase(any())).thenReturn(true);
initialSecuritySetup.init();
assertThat(applicationProperties.getSecurity().getJwt().isEnableKeyCleanup()).isFalse();
}
}
@@ -0,0 +1,234 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
@ExtendWith(MockitoExtension.class)
class AuthControllerLoginTest {
private final ObjectMapper objectMapper = new ObjectMapper();
private MockMvc mockMvc;
private ApplicationProperties.Security securityProperties;
@Mock private UserService userService;
@Mock private JwtServiceInterface jwtService;
@Mock private CustomUserDetailsService userDetailsService;
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
@BeforeEach
void setUp() {
securityProperties = new ApplicationProperties.Security();
securityProperties.setLoginMethod("all");
AuthController controller =
new AuthController(
userService,
jwtService,
userDetailsService,
loginAttemptService,
mfaService,
totpService,
securityProperties);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void loginRejectsWhenUserPassDisabled() throws Exception {
securityProperties.setLoginMethod(
ApplicationProperties.Security.LoginMethods.OAUTH2.toString());
UsernameAndPassMfa payload = buildPayload(null);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isForbidden())
.andExpect(
jsonPath("$.error")
.value(
"Username/password authentication is not enabled. Please use the configured authentication method."));
verify(userDetailsService, never()).loadUserByUsername(any());
}
@Test
void loginBlockedAccountReturnsUnauthorized() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
when(loginAttemptService.isBlocked("user@example.com")).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(
jsonPath("$.error")
.value("Account is locked due to too many failed attempts"));
verify(loginAttemptService, never()).loginSucceeded(any());
}
@Test
void loginRequiresMfaCodeWhenEnabled() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
User user = buildUser();
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
when(mfaService.isMfaEnabled(user)).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("mfa_required"));
verify(loginAttemptService, never()).loginSucceeded(any());
}
@Test
void loginFailsWhenPasswordIncorrect() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
User user = buildUser();
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(userService.isPasswordCorrect(user, "pw")).thenReturn(false);
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Invalid credentials"));
verify(loginAttemptService).loginFailed("user@example.com");
}
@Test
void loginSucceedsAndGeneratesToken() throws Exception {
UsernameAndPassMfa payload = buildPayload(null);
User user = buildUser();
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(userService.isPasswordCorrect(user, "pw")).thenReturn(true);
when(mfaService.isMfaEnabled(user)).thenReturn(false);
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
.thenReturn("token-123");
mockMvc.perform(
post("/api/v1/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.session.access_token").value("token-123"))
.andExpect(jsonPath("$.user.username").value("user@example.com"));
verify(loginAttemptService).loginSucceeded("user@example.com");
}
@Test
void refreshReturnsUnauthorizedWhenTokenMissing() throws Exception {
when(jwtService.extractToken(any())).thenReturn(null);
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("No token found"));
}
@Test
void refreshReturnsNewTokenWhenValid() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
.thenReturn("new-token");
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.access_token").value("new-token"))
.andExpect(jsonPath("$.expires_in").value(3600));
}
@Test
void getCurrentUserReturnsUnauthorizedWhenAnonymous() throws Exception {
SecurityContextHolder.clearContext();
mockMvc.perform(get("/api/v1/auth/me"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Not authenticated"));
}
@Test
void getCurrentUserReturnsUserDetails() throws Exception {
User user = buildUser();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
mockMvc.perform(get("/api/v1/auth/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.user.username").value("user@example.com"))
.andExpect(
jsonPath("$.user.authenticationType")
.value(AuthenticationType.WEB.name().toLowerCase()));
SecurityContextHolder.clearContext();
}
private User buildUser() {
User user = new User();
user.setUsername("user@example.com");
user.setEnabled(true);
user.setAuthenticationType(AuthenticationType.WEB);
Authority authority = new Authority();
authority.setAuthority(Role.USER.getRoleId());
user.addAuthorities(Set.of(authority));
return user;
}
private UsernameAndPassMfa buildPayload(String mfaCode) {
UsernameAndPassMfa payload = new UsernameAndPassMfa();
payload.setUsername("user@example.com");
payload.setPassword("pw");
payload.setMfaCode(mfaCode);
return payload;
}
}
@@ -0,0 +1,218 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
@ExtendWith(MockitoExtension.class)
class AuthControllerMfaTest {
private static final String USERNAME = "user@example.com";
private final ObjectMapper objectMapper = new ObjectMapper();
private MockMvc mockMvc;
private Authentication authentication;
private User user;
@Mock private UserService userService;
@Mock private JwtServiceInterface jwtService;
@Mock private CustomUserDetailsService userDetailsService;
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
@InjectMocks private AuthController authController;
@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders.standaloneSetup(authController).build();
authentication = new UsernamePasswordAuthenticationToken(USERNAME, "password", List.of());
user = new User();
user.setUsername(USERNAME);
user.setAuthenticationType(AuthenticationType.WEB);
}
@Test
void setupMfaRequiresAuthentication() throws Exception {
mockMvc.perform(get("/api/v1/auth/mfa/setup"))
.andExpect(status().isUnauthorized())
.andExpect(content().json("{\"error\":\"Not authenticated\"}"));
}
@Test
void setupMfaReturnsSecretAndUri() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(false);
when(totpService.generateSecret()).thenReturn("SECRET");
when(totpService.buildOtpAuthUri(USERNAME, "SECRET")).thenReturn("otpauth://test");
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isOk())
.andExpect(jsonPath("$.secret").value("SECRET"))
.andExpect(jsonPath("$.otpauthUri").value("otpauth://test"));
verify(mfaService).setSecret(user, "SECRET");
}
@Test
void setupMfaReturnsConflictWhenAlreadyEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isConflict())
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
verify(totpService, never()).generateSecret();
}
@Test
void setupMfaRejectsNonWebAuthenticationType() throws Exception {
user.setAuthenticationType(AuthenticationType.OAUTH2);
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
mockMvc.perform(get("/api/v1/auth/mfa/setup").principal(authentication))
.andExpect(status().isForbidden())
.andExpect(
content()
.json(
"{\"error\":\"MFA settings are only available for web accounts\"}"));
}
@Test
void enableMfaRejectsMissingCode() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.getSecret(user)).thenReturn("SECRET");
mockMvc.perform(
post("/api/v1/auth/mfa/enable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest())
.andExpect(content().json("{\"error\":\"MFA code is required\"}"));
}
@Test
void enableMfaCompletesWorkflow() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.getSecret(user)).thenReturn("SECRET");
when(totpService.getValidTimeStep("SECRET", "123456")).thenReturn(42L);
when(mfaService.isTotpStepUsable(user, 42L)).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/mfa/enable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("code", "123456"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(true));
verify(mfaService).enableMfa(user);
verify(mfaService).markTotpStepUsed(user, 42L);
verify(mfaService).setMfaRequired(user, false);
}
@Test
void disableMfaCompletesWorkflow() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
when(mfaService.getSecret(user)).thenReturn("SECRET");
when(totpService.getValidTimeStep("SECRET", "654321")).thenReturn(7L);
when(mfaService.isTotpStepUsable(user, 7L)).thenReturn(true);
mockMvc.perform(
post("/api/v1/auth/mfa/disable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(false));
verify(mfaService).disableMfa(user);
verify(mfaService).markTotpStepUsed(user, 7L);
}
@Test
void disableMfaReturnsDisabledWhenNotEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(false);
mockMvc.perform(
post("/api/v1/auth/mfa/disable")
.principal(authentication)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(Map.of("code", "654321"))))
.andExpect(status().isOk())
.andExpect(jsonPath("$.enabled").value(false));
verify(mfaService, never()).getSecret(user);
verifyNoInteractions(totpService);
}
@Test
void cancelMfaSetupClearsPendingSecret() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
.andExpect(status().isOk())
.andExpect(jsonPath("$.cleared").value(true));
verify(mfaService).clearPendingSecret(user);
}
@Test
void cancelMfaSetupReturnsConflictWhenEnabled() throws Exception {
when(userService.findByUsernameIgnoreCaseWithSettings(USERNAME))
.thenReturn(Optional.of(user));
when(mfaService.isMfaEnabled(user)).thenReturn(true);
mockMvc.perform(post("/api/v1/auth/mfa/setup/cancel").principal(authentication))
.andExpect(status().isConflict())
.andExpect(content().json("{\"error\":\"MFA already enabled\"}"));
verify(mfaService, never()).clearPendingSecret(user);
}
}
@@ -0,0 +1,162 @@
package stirling.software.proprietary.security.controller.api;
import static org.hamcrest.Matchers.startsWith;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.security.Principal;
import java.time.LocalDateTime;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.model.InviteToken;
import stirling.software.proprietary.security.repository.InviteTokenRepository;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@ExtendWith(MockitoExtension.class)
class InviteLinkControllerTest {
@Mock private InviteTokenRepository inviteTokenRepository;
@Mock private TeamRepository teamRepository;
@Mock private UserService userService;
@Mock private EmailService emailService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
private Principal adminPrincipal;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getMail().setEnableInvites(true);
applicationProperties.getMail().setInviteLinkExpiryHours(24);
applicationProperties.getSystem().setFrontendUrl("https://frontend.example.com");
adminPrincipal = () -> "admin";
InviteLinkController controller =
new InviteLinkController(
inviteTokenRepository,
teamRepository,
userService,
applicationProperties,
Optional.of(emailService));
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void generateInviteLinkRejectsWhenInvitesDisabled() throws Exception {
applicationProperties.getMail().setEnableInvites(false);
mockMvc.perform(post("/api/v1/invite/generate").principal(adminPrincipal))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Email invites are not enabled"));
verify(inviteTokenRepository, never()).save(any());
}
@Test
void generateInviteLinkRejectsInvalidEmail() throws Exception {
applicationProperties.getMail().setEnableInvites(true);
mockMvc.perform(
post("/api/v1/invite/generate")
.principal(adminPrincipal)
.param("email", "not-an-email"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Invalid email address"));
}
@Test
void generateInviteLinkBlocksOnLicenseLimit() throws Exception {
applicationProperties.getPremium().setEnabled(true);
applicationProperties.getPremium().setMaxUsers(1);
when(userService.getTotalUsersCount()).thenReturn(1L);
when(inviteTokenRepository.countActiveInvites(any(LocalDateTime.class))).thenReturn(0L);
mockMvc.perform(
post("/api/v1/invite/generate")
.principal(adminPrincipal)
.param("email", "new@ex.com"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value(startsWith("License limit reached")));
}
@Test
void generateInviteLinkBuildsFrontendUrl() throws Exception {
Team defaultTeam = new Team();
defaultTeam.setId(5L);
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
.thenReturn(Optional.of(defaultTeam));
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
when(inviteTokenRepository.findByEmail("new@example.com")).thenReturn(Optional.empty());
mockMvc.perform(
post("/api/v1/invite/generate")
.principal(adminPrincipal)
.param("email", "new@example.com"))
.andExpect(status().isOk())
.andExpect(
jsonPath("$.inviteUrl")
.value(startsWith("https://frontend.example.com/invite?token=")))
.andExpect(jsonPath("$.email").value("new@example.com"));
verify(inviteTokenRepository).save(any());
}
@Test
void validateInviteTokenReturnsGoneWhenExpired() throws Exception {
InviteToken expired = new InviteToken();
expired.setToken("abc");
expired.setExpiresAt(LocalDateTime.now().minusHours(1));
expired.setRole(Role.USER.getRoleId());
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(expired));
mockMvc.perform(get("/api/v1/invite/validate/abc"))
.andExpect(status().isGone())
.andExpect(jsonPath("$.error").value("This invite link has expired"));
}
@Test
void acceptInviteCreatesUserWhenEmailProvided() throws Exception {
InviteToken invite = new InviteToken();
invite.setToken("abc");
invite.setExpiresAt(LocalDateTime.now().plusHours(2));
invite.setRole(Role.USER.getRoleId());
invite.setUsed(false);
invite.setEmail(null); // email required from request
when(inviteTokenRepository.findByToken("abc")).thenReturn(Optional.of(invite));
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
mockMvc.perform(
post("/api/v1/invite/accept/abc")
.param("email", "new@example.com")
.param("password", "password123"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("Account created successfully"))
.andExpect(jsonPath("$.username").value("new@example.com"));
verify(userService).saveUserCore(any());
verify(inviteTokenRepository).save(invite);
}
}
@@ -0,0 +1,140 @@
package stirling.software.proprietary.security.controller.api;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@ExtendWith(MockitoExtension.class)
class UserControllerTest {
private final ObjectMapper objectMapper = new ObjectMapper();
@Mock private UserService userService;
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private TeamRepository teamRepository;
@Mock private UserRepository userRepository;
@Mock private EmailService emailService;
@Mock private UserLicenseSettingsService licenseSettingsService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
applicationProperties = new ApplicationProperties();
applicationProperties.getPremium().setMaxUsers(10);
applicationProperties.getMail().setEnabled(true);
UserController controller =
new UserController(
userService,
sessionRegistry,
applicationProperties,
teamRepository,
userRepository,
Optional.of(emailService),
licenseSettingsService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@Test
void registerRejectsExistingUser() throws Exception {
UsernameAndPass payload = new UsernameAndPass();
payload.setUsername("existing@example.com");
payload.setPassword("pw");
when(userService.usernameExistsIgnoreCase("existing@example.com")).thenReturn(true);
mockMvc.perform(
post("/api/v1/user/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("User already exists"));
verify(userService, never()).saveUserCore(any());
}
@Test
void registerCreatesUserWhenValid() throws Exception {
UsernameAndPass payload = new UsernameAndPass();
payload.setUsername("new@example.com");
payload.setPassword("pw");
Team defaultTeam = new Team();
defaultTeam.setName(TeamService.DEFAULT_TEAM_NAME);
when(userService.usernameExistsIgnoreCase("new@example.com")).thenReturn(false);
when(userService.isUsernameValid("new@example.com")).thenReturn(true);
when(licenseSettingsService.wouldExceedLimit(1)).thenReturn(false);
when(teamRepository.findByName(TeamService.DEFAULT_TEAM_NAME))
.thenReturn(Optional.of(defaultTeam));
User savedUser = new User();
savedUser.setUsername("new@example.com");
savedUser.setEnabled(false);
when(userService.saveUserCore(any())).thenReturn(savedUser);
mockMvc.perform(
post("/api/v1/user/register")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(payload)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.user.username").value("new@example.com"));
}
@Test
void changeUserEnabledPreventsSelfDisable() throws Exception {
User user = new User();
user.setUsername("admin");
when(userService.usernameExistsIgnoreCase("admin")).thenReturn(true);
when(userService.findByUsernameIgnoreCase("admin")).thenReturn(Optional.of(user));
Authentication authentication = new UsernamePasswordAuthenticationToken("admin", "pw");
mockMvc.perform(
post("/api/v1/user/admin/changeUserEnabled/admin")
.param("enabled", "false")
.principal(authentication))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("Cannot disable your own account."));
}
@Test
void changePasswordRejectsMissingUser() throws Exception {
Authentication authentication = new UsernamePasswordAuthenticationToken("ghost", "pw");
when(userService.usernameExistsIgnoreCase("ghost")).thenReturn(false);
mockMvc.perform(post("/api/v1/user/admin/deleteUser/ghost").principal(authentication))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("User not found."));
}
}
@@ -36,12 +36,12 @@ class H2SQLConditionTest {
@Test
void returnsFalse_whenEnableCustomDatabase_true_regardlessOfType() {
// Flag=true, Typ=h2 -> false
// Flag=true, Typ=h2 -> true
MockEnvironment envTrueH2 =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "true")
.withProperty("system.datasource.type", "h2");
assertFalse(eval(envTrueH2));
assertTrue(eval(envTrueH2));
// Flag=true, Typ=postgres -> false
MockEnvironment envTrueOther =
@@ -57,20 +57,6 @@ class H2SQLConditionTest {
assertFalse(eval(envTrueMissingType));
}
@Test
void returnsFalse_whenTypeNotH2_orMissing_andFlagNotEnabled() {
// Flag fehlt, Typ=postgres -> false
MockEnvironment envNotH2 =
new MockEnvironment().withProperty("system.datasource.type", "postgresql");
assertFalse(eval(envNotH2));
// Flag=false, Typ fehlt -> false (Default: "")
MockEnvironment envMissingType =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "false");
assertFalse(eval(envMissingType));
}
@Test
void returnsFalse_whenEnabled_but_type_not_h2_or_missing() {
MockEnvironment envNotH2 =
@@ -84,4 +70,18 @@ class H2SQLConditionTest {
.withProperty("system.datasource.enableCustomDatabase", "true");
assertFalse(eval(envMissingType));
}
@Test
void returnsTrue_whenTypeNotH2_andCustomDatabaseDisabled() {
MockEnvironment envNotH2 =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "false")
.withProperty("system.datasource.type", "postgresql");
assertTrue(eval(envNotH2));
MockEnvironment envMissingType =
new MockEnvironment()
.withProperty("system.datasource.enableCustomDatabase", "false");
assertTrue(eval(envMissingType));
}
}
@@ -113,10 +113,10 @@ class UserTest {
// Use an existing value from your AuthenticationType enum (e.g. OAUTH2/SAML2/DATABASE)
// If the name differs, simply adjust below.
AuthenticationType at = AuthenticationType.SSO;
AuthenticationType at = AuthenticationType.OAUTH2;
u.setAuthenticationType(at);
assertEquals("sso", u.getAuthenticationType());
assertEquals("oauth2", u.getAuthenticationType());
}
@Test
@@ -0,0 +1,131 @@
package stirling.software.proprietary.security.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.verify;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import javax.sql.DataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.test.util.ReflectionTestUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.FileInfo;
import stirling.software.proprietary.security.database.DatabaseNotificationServiceInterface;
class DatabaseServiceTest {
@TempDir Path tempDir;
@Mock private DatabaseNotificationServiceInterface notificationService;
private DatabaseService databaseService;
private ApplicationProperties.Datasource datasourceProps;
@BeforeEach
void setUp() {
MockitoAnnotations.openMocks(this);
datasourceProps = new ApplicationProperties.Datasource();
datasourceProps.setType(ApplicationProperties.Driver.H2.name());
datasourceProps.setCustomDatabaseUrl("jdbc:h2:mem:test");
datasourceProps.setEnableCustomDatabase(false);
DataSource dataSource =
new DriverManagerDataSource(
"jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1", "sa", "");
databaseService = new DatabaseService(datasourceProps, dataSource, notificationService);
ReflectionTestUtils.setField(databaseService, "BACKUP_DIR", tempDir);
}
@Test
void hasBackupReturnsFalseWhenEmpty() {
assertThat(databaseService.hasBackup()).isFalse();
assertThat(Files.exists(tempDir)).isTrue();
}
@Test
void getBackupListReturnsEntries() throws IOException {
Path backup =
tempDir.resolve(
"backup_"
+ LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMddHHmm"))
+ ".sql");
Files.writeString(backup, "CREATE TABLE TEST(ID INT);");
List<stirling.software.common.model.FileInfo> backups = databaseService.getBackupList();
assertThat(backups).hasSize(1);
assertThat(backups.get(0).getFileName()).isEqualTo(backup.getFileName().toString());
}
@Test
void importDatabaseFromUICopiesBackupAndDeletesTemp() throws IOException {
Path script = Files.createTempFile("script", ".sql");
Files.writeString(script, "CREATE TABLE SAMPLE(ID INT PRIMARY KEY);\n");
boolean result = databaseService.importDatabaseFromUI(script);
assertThat(result).isTrue();
assertThat(Files.exists(script)).isFalse();
try (var stream = Files.list(tempDir)) {
assertThat(stream.toList()).isNotEmpty();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
@Test
void getBackupFilePathPreventsTraversal() {
assertThatThrownBy(() -> databaseService.getBackupFilePath("../evil.sql"))
.isInstanceOf(SecurityException.class);
}
@Test
void deleteBackupFileRejectsInvalidName() throws Exception {
boolean deleted = databaseService.deleteBackupFile("..bad.sql");
assertThat(deleted).isFalse();
}
@Test
void deleteAllBackupsRemovesFiles() throws Exception {
Path first = tempDir.resolve("backup_first.sql");
Path second = tempDir.resolve("backup_second.sql");
Files.writeString(first, "SELECT 1;");
Files.writeString(second, "SELECT 1;");
List<org.apache.commons.lang3.tuple.Pair<FileInfo, Boolean>> results =
databaseService.deleteAllBackups();
assertThat(results).hasSize(2);
assertThat(results.stream().allMatch(org.apache.commons.lang3.tuple.Pair::getRight))
.isTrue();
assertThat(Files.exists(first)).isFalse();
assertThat(Files.exists(second)).isFalse();
}
@Test
void exportDatabaseCreatesScript() {
databaseService.exportDatabase();
assertThat(tempDir.toFile().list()).isNotEmpty();
verify(notificationService)
.notifyBackupsSuccess(
org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.anyString());
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
@ExtendWith(MockitoExtension.class)
class MfaServiceTest {
@Mock private UserRepository userRepository;
@Mock private DatabaseServiceInterface databaseService;
@InjectMocks private MfaService mfaService;
@Test
void setSecretStoresSecretAndDisablesMfa() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "10");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.setSecret(user, "NEWSECRET");
assertEquals("NEWSECRET", user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void enableMfaSetsEnabledFlag() throws Exception {
User user = new User();
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.enableMfa(user);
assertEquals("true", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
verify(databaseService).exportDatabase();
}
@Test
void disableMfaClearsSecretAndUsage() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "20");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.disableMfa(user);
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void markTotpStepUsedTracksNewestStep() throws Exception {
User user = new User();
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
assertTrue(mfaService.markTotpStepUsed(user, 100L));
assertEquals("100", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void markTotpStepUsedRejectsReplays() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "200");
assertFalse(mfaService.markTotpStepUsed(user, 199L));
assertFalse(mfaService.markTotpStepUsed(user, 200L));
verify(databaseService, never()).exportDatabase();
}
@Test
void markTotpStepUsedIgnoresMalformedStoredValue() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "not-a-number");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
assertTrue(mfaService.markTotpStepUsed(user, 5L));
assertEquals("5", user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService, times(1)).exportDatabase();
}
@Test
void isTotpStepUsableRespectsLastStep() {
User user = new User();
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "50");
assertFalse(mfaService.isTotpStepUsable(user, 50));
assertFalse(mfaService.isTotpStepUsable(user, 40));
assertTrue(mfaService.isTotpStepUsable(user, 51));
}
@Test
void isMfaRequiredDefaultsToFalse() {
User user = new User();
assertFalse(mfaService.isMfaRequired(user));
}
@Test
void setMfaRequiredStoresFlag() throws Exception {
User user = new User();
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.setMfaRequired(user, true);
assertEquals("true", user.getSettings().get(MfaService.MFA_REQUIRED_KEY));
verify(databaseService).exportDatabase();
}
@Test
void clearPendingSecretResetsValues() throws Exception {
User user = new User();
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
user.getSettings().put(MfaService.MFA_LAST_USED_STEP_KEY, "12");
when(userRepository.save(any(User.class))).thenAnswer(i -> i.getArgument(0));
mfaService.clearPendingSecret(user);
assertEquals("false", user.getSettings().get(MfaService.MFA_ENABLED_KEY));
assertNull(user.getSettings().get(MfaService.MFA_SECRET_KEY));
assertNull(user.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY));
verify(databaseService).exportDatabase();
}
@Test
void isMfaEnabledAndGetSecretReadSettings() {
User user = new User();
user.getSettings().put(MfaService.MFA_ENABLED_KEY, "true");
user.getSettings().put(MfaService.MFA_SECRET_KEY, "SECRET");
assertTrue(mfaService.isMfaEnabled(user));
assertEquals("SECRET", mfaService.getSecret(user));
}
}
@@ -0,0 +1,96 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.util.Base32Codec;
class TotpServiceTest {
private TotpService buildService(String appName) {
ApplicationProperties properties = new ApplicationProperties();
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
ui.setAppNameNavbar(appName);
properties.setUi(ui);
return new TotpService(properties);
}
@Test
void generateSecretReturnsBase32String() {
TotpService service = buildService("Test App");
String secret = service.generateSecret();
assertNotNull(secret);
assertEquals(32, secret.length());
assertTrue(secret.matches("[A-Z2-7]+"));
}
@Test
void buildOtpAuthUriIncludesIssuerAndUsername() {
TotpService service = buildService("Stirling Test");
String uri = service.buildOtpAuthUri("user@example.com", "SECRET");
assertTrue(uri.contains("issuer=Stirling%20Test"));
assertTrue(uri.contains("Stirling%20Test%3Auser%40example.com"));
}
@Test
void isValidCodeAcceptsCurrentAndAdjacentTimeSteps() throws Exception {
TotpService service = buildService("Test App");
byte[] secretBytes = "super-secret".getBytes(StandardCharsets.UTF_8);
String secret = Base32Codec.encode(secretBytes);
long timeStep = Instant.now().getEpochSecond() / 30;
String currentCode = generateCode(service, secretBytes, timeStep);
String nextCode = generateCode(service, secretBytes, timeStep + 1);
assertTrue(service.isValidCode(secret, currentCode));
assertEquals(timeStep, service.getValidTimeStep(secret, currentCode));
assertTrue(service.isValidCode(secret, nextCode));
}
@Test
void isValidCodeRejectsInvalidFormats() {
TotpService service = buildService("Test App");
assertFalse(service.isValidCode("SECRET", "ABCDEF"));
assertFalse(service.isValidCode("SECRET", "12345"));
assertFalse(service.isValidCode(null, "123456"));
}
@Test
void isValidCodeRejectsInvalidSecrets() {
TotpService service = buildService("Test App");
assertFalse(service.isValidCode("INVALID*", "123456"));
}
@Test
void buildOtpAuthUriUsesDefaultIssuerWhenMissing() {
ApplicationProperties properties = new ApplicationProperties();
TotpService service = new TotpService(properties);
String uri = service.buildOtpAuthUri("user@example.com", "SECRET");
assertTrue(uri.contains("issuer=Stirling%20PDF"));
}
private String generateCode(TotpService service, byte[] secretBytes, long timeStep)
throws Exception {
Method generateCode =
TotpService.class.getDeclaredMethod("generateCode", byte[].class, long.class);
generateCode.setAccessible(true);
return (String) generateCode.invoke(service, secretBytes, timeStep);
}
}
@@ -1,22 +1,26 @@
package stirling.software.proprietary.security.service;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import java.sql.SQLException;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.context.MessageSource;
import org.springframework.security.crypto.password.PasswordEncoder;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
@@ -29,272 +33,156 @@ import stirling.software.proprietary.security.session.SessionPersistentRegistry;
class UserServiceTest {
@Mock private UserRepository userRepository;
@Mock private TeamRepository teamRepository;
@Mock private AuthorityRepository authorityRepository;
@Mock private PasswordEncoder passwordEncoder;
@Mock private MessageSource messageSource;
@Mock private SessionPersistentRegistry sessionPersistentRegistry;
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private DatabaseServiceInterface databaseService;
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
@Mock private ApplicationProperties.Security.OAUTH2 oauth2Properties;
@InjectMocks private UserService userService;
private Team mockTeam;
private User mockUser;
@BeforeEach
void setUp() {
mockTeam = new Team();
mockTeam.setId(1L);
mockTeam.setName("Test Team");
mockUser = new User();
mockUser.setId(1L);
mockUser.setUsername("testuser");
mockUser.setEnabled(true);
}
@Spy @InjectMocks private UserService userService;
@Test
void testSaveUser_WithUsernameAndAuthenticationType_Success() throws Exception {
// Given
String username = "testuser";
AuthenticationType authType = AuthenticationType.WEB;
void saveUserCore_populatesFieldsAndPersists()
throws SQLException, UnsupportedProviderException {
Long teamId = 42L;
Team team = new Team();
team.setId(teamId);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(team));
when(passwordEncoder.encode("plain")).thenReturn("encoded");
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
SaveUserRequest request =
SaveUserRequest.builder()
.username("validUser")
.password("plain")
.ssoProviderId("sso-id")
.ssoProvider("provider-x")
.authenticationType(AuthenticationType.OAUTH2)
.teamId(teamId)
.role(Role.ADMIN.getRoleId())
.firstLogin(true)
.enabled(false)
.requireMfa(true)
.mfaEnabled(true)
.mfaSecret("top-secret")
.mfaLastUsedStep(5L)
.build();
// When
userService.saveUser(username, authType);
User saved = userService.saveUserCore(request);
// Then
verify(userRepository).save(any(User.class));
ArgumentCaptor<User> userCaptor = ArgumentCaptor.forClass(User.class);
verify(userRepository).save(userCaptor.capture());
verify(databaseService).exportDatabase();
User persisted = userCaptor.getValue();
assertEquals("validUser", persisted.getUsername());
assertEquals("encoded", persisted.getPassword());
assertEquals("provider-x", persisted.getSsoProvider());
assertEquals("sso-id", persisted.getSsoProviderId());
assertEquals(team, persisted.getTeam());
assertEquals("oauth2", persisted.getAuthenticationType());
assertFalse(persisted.isEnabled());
assertTrue(persisted.isFirstLogin());
assertTrue(
persisted.getAuthorities().stream()
.anyMatch(a -> Role.ADMIN.getRoleId().equals(a.getAuthority())));
assertEquals(
"true",
persisted.getSettings().get(MfaService.MFA_REQUIRED_KEY),
"MFA requirement should be stored");
assertEquals(
"true",
persisted.getSettings().get(MfaService.MFA_ENABLED_KEY),
"MFA enabled flag should be stored");
assertEquals(
"top-secret",
persisted.getSettings().get(MfaService.MFA_SECRET_KEY),
"MFA secret should be stored");
assertEquals(
"5",
persisted.getSettings().get(MfaService.MFA_LAST_USED_STEP_KEY),
"MFA last used step should be stored");
assertSame(saved, persisted, "Returned user should be the persisted instance");
}
@Test
void testSaveUser_WithUsernamePasswordAndTeamId_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
void saveUserCore_withoutTeam_usesDefaultTeam()
throws SQLException, UnsupportedProviderException {
Team defaultTeam = new Team();
defaultTeam.setName("Default");
when(teamRepository.findByName("Default")).thenReturn(Optional.of(defaultTeam));
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
SaveUserRequest request = SaveUserRequest.builder().username("anotherUser").build();
// When
User result = userService.saveUser(username, password, teamId);
User saved = userService.saveUserCore(request);
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(teamRepository).findById(teamId);
verify(userRepository).save(any(User.class));
verify(teamRepository).findByName("Default");
verify(teamRepository, never()).findById(anyLong());
verify(databaseService).exportDatabase();
assertEquals(defaultTeam, saved.getTeam(), "Default team should be applied");
}
@Test
void testSaveUser_WithTeamAndRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
String role = Role.ADMIN.getRoleId();
boolean firstLogin = true;
String encodedPassword = "encodedPassword123";
void processSSOPostLogin_autoCreatesUserWhenMissing()
throws IllegalArgumentException, SQLException, UnsupportedProviderException {
String username = "autoUser";
doReturn(true).when(userService).isUsernameValid(username);
when(userRepository.findBySsoProviderAndSsoProviderId("prov", "id"))
.thenReturn(Optional.empty());
when(userRepository.findByUsernameIgnoreCase(username)).thenReturn(Optional.empty());
User created = new User();
doReturn(created).when(userService).saveUserCore(any(SaveUserRequest.class));
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
userService.processSSOPostLogin(username, "id", "prov", true, AuthenticationType.SAML2);
// When
User result = userService.saveUser(username, password, mockTeam, role, firstLogin);
ArgumentCaptor<SaveUserRequest> reqCaptor = ArgumentCaptor.forClass(SaveUserRequest.class);
verify(userService).saveUserCore(reqCaptor.capture());
SaveUserRequest captured = reqCaptor.getValue();
// Then
assertNotNull(result);
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
assertEquals(username, captured.getUsername());
assertEquals("id", captured.getSsoProviderId());
assertEquals("prov", captured.getSsoProvider());
assertEquals(AuthenticationType.SAML2, captured.getAuthenticationType());
}
@Test
void testSaveUser_WithInvalidUsername_ThrowsException() throws Exception {
// Given
String invalidUsername = "ab"; // Too short (less than 3 characters)
AuthenticationType authType = AuthenticationType.WEB;
void addApiKeyToUserGeneratesAndPersists() {
User user = new User();
user.setUsername("user");
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
// When & Then
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(invalidUsername, authType));
User updated = userService.addApiKeyToUser("user");
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
assertNotNull(updated.getApiKey());
verify(userRepository).save(user);
}
@Test
void testSaveUser_WithNullPassword_Success() throws Exception {
// Given
String username = "testuser";
Long teamId = 1L;
void getApiKeyForUserCreatesWhenMissing() {
User user = new User();
user.setUsername("user");
when(userRepository.findByUsernameIgnoreCase("user")).thenReturn(Optional.of(user));
when(userRepository.findByApiKey(any())).thenReturn(Optional.empty());
when(userRepository.save(any(User.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
String apiKey = userService.getApiKeyForUser("user");
// When
User result = userService.saveUser(username, null, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
assertNotNull(apiKey);
verify(userRepository).save(user);
}
@Test
void testSaveUser_WithEmptyPassword_Success() throws Exception {
// Given
String username = "testuser";
String emptyPassword = "";
Long teamId = 1L;
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
User result = userService.saveUser(username, emptyPassword, teamId);
// Then
assertNotNull(result);
verify(passwordEncoder, never()).encode(anyString());
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithValidEmail_Success() throws Exception {
// Given
String emailUsername = "test@example.com";
AuthenticationType authType = AuthenticationType.OAUTH2;
when(teamRepository.findByName("Default")).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(emailUsername, authType);
// Then
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithReservedUsername_ThrowsException() throws Exception {
// Given
String reservedUsername = "all_users";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(reservedUsername, authType));
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_WithAnonymousUser_ThrowsException() throws Exception {
// Given
String anonymousUsername = "anonymoususer";
AuthenticationType authType = AuthenticationType.WEB;
// When & Then
assertThrows(
IllegalArgumentException.class,
() -> userService.saveUser(anonymousUsername, authType));
verify(userRepository, never()).save(any(User.class));
verify(databaseService, never()).exportDatabase();
}
@Test
void testSaveUser_DatabaseExportThrowsException_StillSavesUser() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doThrow(new SQLException("Database export failed")).when(databaseService).exportDatabase();
// When & Then
assertThrows(SQLException.class, () -> userService.saveUser(username, password, teamId));
// Verify user was still saved before the exception
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithFirstLoginFlag_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
boolean firstLogin = true;
boolean enabled = false;
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, firstLogin, enabled);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
}
@Test
void testSaveUser_WithCustomRole_Success() throws Exception {
// Given
String username = "testuser";
String password = "password123";
Long teamId = 1L;
String customRole = Role.LIMITED_API_USER.getRoleId();
String encodedPassword = "encodedPassword123";
when(passwordEncoder.encode(password)).thenReturn(encodedPassword);
when(teamRepository.findById(teamId)).thenReturn(Optional.of(mockTeam));
when(userRepository.save(any(User.class))).thenReturn(mockUser);
doNothing().when(databaseService).exportDatabase();
// When
userService.saveUser(username, password, teamId, customRole);
// Then
verify(passwordEncoder).encode(password);
verify(userRepository).save(any(User.class));
verify(databaseService).exportDatabase();
void isUsernameValidRejectsReservedAndAcceptsEmail() {
assertFalse(userService.isUsernameValid("ALL_USERS"));
assertTrue(userService.isUsernameValid("valid@example.com"));
}
}
@@ -0,0 +1,45 @@
package stirling.software.proprietary.security.util;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class Base32CodecTest {
@Test
void encodeReturnsEmptyStringForEmptyInput() {
assertEquals("", Base32Codec.encode(new byte[0]));
assertEquals("", Base32Codec.encode(null));
}
@Test
void decodeReturnsEmptyArrayForBlankInput() {
assertArrayEquals(new byte[0], Base32Codec.decode(null));
assertArrayEquals(new byte[0], Base32Codec.decode(""));
assertArrayEquals(new byte[0], Base32Codec.decode(" "));
}
@Test
void encodeDecodeRoundTrip() {
byte[] input = "hello world".getBytes(StandardCharsets.UTF_8);
String encoded = Base32Codec.encode(input);
byte[] decoded = Base32Codec.decode(encoded);
assertArrayEquals(input, decoded);
}
@Test
void decodeAcceptsPaddingSpacesAndLowercase() {
byte[] decoded = Base32Codec.decode("mzxw6y tboi====");
assertEquals("foobar", new String(decoded, StandardCharsets.UTF_8));
}
@Test
void decodeRejectsInvalidCharacters() {
assertThrows(IllegalArgumentException.class, () -> Base32Codec.decode("MZXW6$"));
}
}
+16 -12
View File
@@ -2,7 +2,7 @@ plugins {
id "java"
id "jacoco"
id "io.spring.dependency-management" version "1.1.7"
id "org.springframework.boot" version "3.5.7"
id "org.springframework.boot" version "3.5.9"
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "com.diffplug.spotless" version "8.1.0"
@@ -18,7 +18,7 @@ import groovy.xml.XmlSlurper
import org.gradle.api.tasks.testing.Test
ext {
springBootVersion = "3.5.7"
springBootVersion = "3.5.9"
pdfboxVersion = "3.0.6"
imageioVersion = "3.13.0"
lombokVersion = "1.18.42"
@@ -27,7 +27,7 @@ ext {
openSamlVersion = "4.3.2"
commonmarkVersion = "0.27.0"
googleJavaFormatVersion = "1.28.0"
logback = "1.5.23"
logback = "1.5.25"
junitPlatformVersion = "1.12.2"
}
@@ -67,7 +67,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.3.1'
version = '2.4.2'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@@ -102,12 +102,14 @@ def updateSimulationVersion(File fileToUpdate, String version) {
writeIfChanged(fileToUpdate, updatedContent)
}
def rootProjectRef = project
tasks.register('syncAppVersion') {
group = 'versioning'
description = 'Synchronizes app version across desktop and simulation configs.'
doLast {
def appVersion = project.version.toString()
def appVersion = rootProjectRef.version.toString()
println "Synchronizing application version to ${appVersion}"
updateTauriConfigVersion(appVersion)
@@ -149,18 +151,18 @@ subprojects {
repositories {
if (!rootProject.ext.mavenUrl.isEmpty()) {
maven {
url rootProject.ext.mavenUrl + '/releases'
url = rootProject.ext.mavenUrl + '/releases'
credentials(PasswordCredentials) {
username rootProject.ext.username
password rootProject.ext.password
username = rootProject.ext.username
password = rootProject.ext.password
}
authentication {
basic(BasicAuthentication)
}
allowInsecureProtocol true
allowInsecureProtocol = true
}
}
maven { url "https://build.shibboleth.net/maven/releases" }
maven { url = "https://build.shibboleth.net/maven/releases" }
mavenCentral()
}
@@ -397,8 +399,10 @@ tasks.withType(JavaCompile).configureEach {
gradle.taskGraph.whenReady { graph ->
if (project.hasProperty("noSpotless")) {
tasks.matching { it.name.startsWith("spotless") }.configureEach {
enabled = false
allprojects { scopedProject ->
scopedProject.tasks.matching { it.name.startsWith("spotless") }.configureEach {
enabled = false
}
}
}
}
-1
View File
@@ -31,7 +31,6 @@ COPY . .
# Build server-only JAR (no frontend, includes security features controlled by DOCKER_ENABLE_SECURITY at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 3: Final unified image
-1
View File
@@ -30,7 +30,6 @@ WORKDIR /app
COPY . .
RUN DISABLE_ADDITIONAL_FEATURES=true \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 3: Final unified ultra-lite image
-1
View File
@@ -25,7 +25,6 @@ COPY . .
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# ========================================
-1
View File
@@ -25,7 +25,6 @@ COPY . .
# Build the application (server-only JAR - no UI, includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# ========================================
-1
View File
@@ -21,7 +21,6 @@ COPY . .
# Build the application with DISABLE_ADDITIONAL_FEATURES=true
RUN DISABLE_ADDITIONAL_FEATURES=true \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -x spotlessApply -x spotlessCheck -x test -x sonarqube
# ========================================
+1 -2
View File
@@ -32,12 +32,11 @@ COPY . .
# Build JAR with embedded frontend (includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools
FROM debian:stable-slim@sha256:f6681102cd18b4c0c4720a77b602498f4bdcf701c8fc02776dfb0d4c350c381f
FROM debian:stable-slim@sha256:ed542b2d269ff08139fc5ab8c762efe8c8986b564a423d5241a5ce9fb09b6c08
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
+1 -2
View File
@@ -32,12 +32,11 @@ COPY . .
# Build JAR with embedded frontend (includes security features controlled at runtime)
RUN DISABLE_ADDITIONAL_FEATURES=false \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image based on Debian stable-slim
# Contains Java runtime + LibreOffice + Calibre + all PDF tools + extra fonts for air-gapped environments
FROM debian:stable-slim@sha256:f6681102cd18b4c0c4720a77b602498f4bdcf701c8fc02776dfb0d4c350c381f
FROM debian:stable-slim@sha256:ed542b2d269ff08139fc5ab8c762efe8c8986b564a423d5241a5ce9fb09b6c08
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive
-1
View File
@@ -32,7 +32,6 @@ COPY . .
# Build ultra-lite JAR with embedded frontend (minimal features)
RUN DISABLE_ADDITIONAL_FEATURES=true \
STIRLING_PDF_DESKTOP_UI=false \
./gradlew clean build -PbuildWithFrontend=true -x spotlessApply -x spotlessCheck -x test -x sonarqube
# Stage 2: Runtime image
+2 -2
View File
@@ -1,5 +1,5 @@
# Frontend Dockerfile - React/Vite application
FROM node:25-alpine@sha256:f4769ca6eeb6ebbd15eb9c8233afed856e437b75f486f7fccaa81d7c8ad56007 AS build
FROM node:25-alpine@sha256:e80397b81fa93888b5f855e8bef37d9b18d3c5eb38b8731fc23d6d878647340f AS build
WORKDIR /app
@@ -16,7 +16,7 @@ COPY frontend .
RUN npm run build
# Production stage
FROM nginx:alpine@sha256:c083c3799197cfff91fe5c3c558db3d2eea65ccbbfd419fa42a64d2c39a24027
FROM nginx:alpine@sha256:b0f7830b6bfaa1258f45d94c240ab668ced1b3651c8a222aefe6683447c7bf55
# Copy built files from build stage
COPY --from=build /app/dist /usr/share/nginx/html

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