Compare commits

...
Author SHA1 Message Date
Reece Browne 6a53ba3a6d Merge branch 'main' into chore/v2/ctrlf 2025-12-11 12:33:49 +00:00
Reece fdd7399f95 Fix bug on search when leaving and returning to viewer 2025-12-11 12:33:17 +00:00
Reece cd048299f4 lint 2025-12-11 11:49:19 +00:00
Reece Browne ae72344317 Offline pdfium (#5213)
# 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)

### 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.
2025-12-11 11:23:20 +00:00
Reece Browne 6565a6ce18 Bug/v2/improved cache busting (#5107)
# 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.
2025-12-11 11:19:55 +00:00
Ludy e26035c3b3 build(versioning): synchronize app version across Tauri and simulation configs (#5120)
# Description of Changes

- **What was changed**
- Added `groovy.json.JsonOutput` and `groovy.json.JsonSlurper` imports
to `build.gradle`.
- Introduced a reusable `writeIfChanged(File targetFile, String
newContent)` helper to avoid unnecessary file writes when content is
unchanged.
  - Added `updateTauriConfigVersion(String version)` to:
    - Parse `frontend/src-tauri/tauri.conf.json`.
    - Set the `version` field from `project.version`.
- Re-write the file as pretty-printed JSON (with a trailing line
separator) only if content actually changed.
- Added `updateSimulationVersion(File fileToUpdate, String version)` to:
- Locate the `appVersion: '<value>'` assignment via regex in simulation
files.
    - Replace the existing version with `project.version`.
- Fail the build with a clear `GradleException` if `appVersion` cannot
be found.
- Registered a new Gradle task `syncAppVersion` (group: `versioning`)
which:
    - Reads `project.version` as the canonical app version.
    - Updates `frontend/src-tauri/tauri.conf.json`.
- Updates `frontend/src/core/testing/serverExperienceSimulations.ts`.
- Updates
`frontend/src/proprietary/testing/serverExperienceSimulations.ts`.
- Updated the main `build` task so it now depends on `syncAppVersion` in
addition to `:stirling-pdf:bootJar` and `buildRestartHelper`.

- **Why the change was made**
- To ensure the desktop Tauri configuration and server experience
simulation configs consistently use the same application version as
defined in `project.version`.
- To remove manual version bumps in multiple files and eliminate the
risk of version mismatches between backend, desktop app, and
simulation/testing tooling.
- To minimize noise in commits and CI by only touching versioned files
when their content actually changes (using `writeIfChanged`).


---

## 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.
2025-12-11 11:13:54 +00:00
ConnorYoh e474cc76ad Improved static upgrade flow (#5214)
<img width="996" height="621" alt="image"
src="https://github.com/user-attachments/assets/1ac87414-09ed-4307-8f7c-25984e0c89d1"
/>
<img width="608" height="351" alt="image"
src="https://github.com/user-attachments/assets/c271f75e-4844-4034-8905-007cc7ab1265"
/>
<img width="660" height="355" alt="image"
src="https://github.com/user-attachments/assets/34913b74-d4fa-418a-b098-fda48b41f0dd"
/>
<img width="1371" height="906" alt="image"
src="https://github.com/user-attachments/assets/35b61389-fd67-41b3-9969-e5409e53b362"
/>
<img width="639" height="450" alt="image"
src="https://github.com/user-attachments/assets/ae018bf3-0fcf-4221-892f-440d7325540a"
/>
<img width="963" height="599" alt="image"
src="https://github.com/user-attachments/assets/f6f67682-f43c-46f3-8632-16b209780b15"
/>
<img width="982" height="628" alt="image"
src="https://github.com/user-attachments/assets/45a7c171-3eb4-4271-a299-f3a6e78c1a52"
/>
2025-12-11 11:13:20 +00:00
Reece 4b94166317 Merge branch 'chore/v2/ctrlf' of https://github.com/Stirling-Tools/Stirling-PDF into chore/v2/ctrlf 2025-12-11 10:57:44 +00:00
Reece Browne 43eaa84a8f fix tooltips on tab (#5219)
# 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)

### 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.
2025-12-11 09:20:39 +00:00
James Brunton 2cd4175689 Fix Mac app not being able to open files with spaces in their name (#5218)
# Description of Changes
Fix #5189.

Fix Mac app not being able to open files with spaces in their name,
which was happening because the URL was not being decoded on input.
2025-12-11 08:55:37 +00:00
Dario Ghunney Ware d6a83fe6a1 Fix: SSO Login Page (#5220)
Users logging in via OAuth2 were redirected to Spring's default login
form instead of the React frontend login page. This happened because the
OAuth2 configuration used `.loginPage("/oauth2")` which pointed to the
old Thymeleaf template.

### 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.
2025-12-10 19:46:48 +00:00
Reece 1911d046a2 enter to down arrow 2025-12-10 18:26:41 +00:00
Reece 08c1382680 Make better good 2025-12-10 18:22:56 +00:00
James Brunton 3c92cb7c2b Improve styling of quick access bar (#5197)
# Description of Changes

Currently, the Quick Access Bar only renders well in Chrome. It's got
all sorts of layout issues in Firefox and Safari. This PR attempts to
retain the recent changes to make the bar thinner etc. but make it work
better in all browsers.
2025-12-10 17:21:07 +00:00
Reece Browne dacaf94ceb Merge branch 'main' into chore/v2/ctrlf 2025-12-10 16:48:02 +00:00
Reece 19e611bbcf lint and search improvents 2025-12-10 16:38:46 +00:00
Reece 6eb6af9445 Search on ctrl f 2025-12-10 16:23:15 +00:00
James Brunton b83888c74a Make lite version of CI (#5188)
# Description of Changes
Add lite mode for CI which just runs the most important jobs for
deployment. This won't be used in this repo, but allows other repos
containing Stirling to easily disable jobs like desktop builds etc. if
they're unnecessary, without needing to deal with conflicts in the
files. They'll just need to set the repo variable `CI_PROFILE` to
`lite`. We have an upstream repo that we'd like these changes for.
2025-12-10 13:54:57 +00:00
Anthony Stirling 787d0d21c9 lang updates plus --include-existing flag (#5212)
# 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)

### 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.
2025-12-10 11:41:11 +00:00
Dario Ghunney Ware 7b26b184d1 Fix: Access to Swagger UI when login enabled (#5194)
Fixes for /swagger-ui/index.html & /v1/api-docs endpoints not being
accessible when login was enabled.

- `UserAuthenticationFilter.isPublicAuthEndpoint()` had gaps in its
check, missing `/v1/api-docs`
- Refactored `UserAuthenticationFilter` to use
`RequestUriUtils.isPublicAuthEndpoint()` instead of its own incorrect
method

Closes #5125 & #5028

---

### 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.
2025-12-10 11:39:29 +00:00
Anthony Stirling f17ad56def Handle restricted language configuration fallback V2 (#5154)
## Summary
- restrict supported languages to the validated list from app-config
instead of always adding an extra fallback
- set the effective fallback locale to the preferred configured language
and switch away from disallowed selections automatically

## Testing
- ./gradlew build


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6930529bc6c08328a1ce05f7d1316e27)
2025-12-10 11:12:18 +00:00
Reece Browne de438d00e1 Fiix colours (#5211)
<img width="638" height="471" alt="image"
src="https://github.com/user-attachments/assets/c2352e25-6ee0-4726-9ce4-059870347ea6"
/>
<img width="638" height="450" alt="image"
src="https://github.com/user-attachments/assets/a17931ca-ffea-4dc3-a17a-629d98003da6"
/>
<img width="985" height="774" alt="image"
src="https://github.com/user-attachments/assets/b16203c6-a136-4fee-ba57-495f7b6e8c75"
/>
<img width="635" height="348" alt="image"
src="https://github.com/user-attachments/assets/4b4ab328-b2e4-442f-84a5-d947e97653ec"
/>
2025-12-10 11:10:34 +00:00
Anthony Stirling 6787169583 Handle composition input in PDF text editor (#5192)
## Summary
- track IME composition state in the PDF text editor to avoid
interrupting phonetic input methods
- update text syncing to occur after composition completes and skip
redundant updates mid-composition

## Testing
- npm run lint -- --max-warnings 0


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693744be74148328bd3bda9150de6e56)
2025-12-10 10:11:45 +00:00
Anthony Stirling 291e1a392b extra font support in text editor (#5208)
# 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)

### 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.
2025-12-10 10:11:16 +00:00
Anthony Stirling 7c5aa3685f Add configurable SMTP TLS/SSL options (#5204)
## Summary
- add optional STARTTLS and SSL-related fields to mail settings with
defaults matching prior behavior
- apply the new mail properties in the SMTP JavaMail configuration,
including trust and hostname verification overrides
- update mail settings template and tests to cover default behavior and
explicit TLS/SSL overrides
- clarify STARTTLS naming and sslTrust usage with examples in property
comments and the settings template
- default sslTrust to a wildcard when unset so TLS connections accept
any host by default unless tightened

## Testing
- ./gradlew :proprietary:test --tests
stirling.software.proprietary.security.service.MailConfigTest --console
plain

------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693864b2a6648328ae75c7e88a726a65)
2025-12-10 10:10:54 +00:00
Anthony StirlingandCopilot Autofix powered by AI 9c03914edd Add admin password reset option for users (#5180)
## Summary
- add backend support for admins to reset user passwords and optionally
email notifications when SMTP is enabled
- surface mail capability in admin settings data for the UI
- add a shared change-password modal hooked into People and Team user
actions with random password generation and email options

## Testing
- not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6934b978fe3c83289b5b95dec79b3d38)

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-12-10 10:10:40 +00:00
James Brunton c980ee10c0 Backport fixes from SaaS (#5187)
# Description of Changes
- Add new skeleton loader style type (block - nothing's currently using
it but it might as well be available)
- Make Dev API overridable (and set to the new docs that actually work
while Swagger docs don't work properly)
2025-12-09 11:45:01 +00:00
James Brunton fa4d2bc09a Fix path to sample file in tour (#5186)
# Description of Changes
Fix path to sample file in tour
2025-12-09 11:43:18 +00:00
Anthony Stirling 7faf7e50fa Chang etext on intro (#5160)
# 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)

### 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.
2025-12-06 00:06:11 +00:00
EthanHealy01 bb201ef9c1 Chore/bump gradle version number (#5176)
bump version number
2025-12-05 23:22:32 +00:00
82dbcfbb9b SSO login fix (#5167)
Fixes bug where SSO login with custom providers caused an
`InvalidClientRegistrationIdException: Invalid Client Registration with
Id: oidc` errors.

  Root Cause:
- Backend: Redirect URI was hardcoded to `/login/oauth2/code/oidc`
regardless of provider registration ID
- Frontend: Unknown providers were mapped back to 'oidc' instead of
using actual provider ID

Closes #5141

---------

Co-authored-by: Anthony Stirling <77850077+frooodle@users.noreply.github.com>
Co-authored-by: Keon Chen <66115421+keonchennl@users.noreply.github.com>
2025-12-05 23:19:41 +00:00
EthanHealy01 9fd8fd89ed add enum SERVER to list of valid licenses (#5172) 2025-12-05 13:18:23 +00:00
Keon Chen 3a2370ea1f Update OCR setup guide link in LanguagePicker (#5162)
# Description of Changes

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

- What was changed
Update the link to the doc page for language picker in OCR config
- Why the change was made
Old link doesn't work anymore
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

- [x ] 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)
- [x ] 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.
2025-12-04 21:35:11 +00:00
James BruntonandConnorYoh e7db714091 More fixes for automate (#5168)
# Description of Changes
Fix file missed in #5127 to use `apiClient` instead of `axios` directly

Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2025-12-04 17:53:08 +00:00
ConnorYohandJames Brunton c6b4a2b141 Desktop to match normal login screens (#5122)1
Also fixed issue with csrf
Also fixed issue with rust keychain

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-12-04 17:48:19 +00:00
Anthony Stirling 7459463a3c V2 sso in server plan (#5158)
# 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)

### 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.
2025-12-03 21:12:29 +00:00
EthanHealy01andAnthony Stirling c9bf436895 couple of small fixes for text editor (#5155)
# Description of Changes

- Workbench.tsx: Allow PDF text editor to handle it's own custom state
(this is a bit of a hotfix, we're going to handle the PDF text editors
file upload flow better in a future PR).
- PdfTextEditorView.tsx: Added a dropzone and some helper text to upload
the first file.
- PdfTextEditorView.tsx: Hide document view when isConverting is true.
Prevents showing stale content from previous file during conversion.
- useTranslatedToolRegistry.tsx: Moved PDF Text Editor to top of
Recommended tools list. Increased visibility of the new feature.
- PdfTextEditor.tsx: Removed auto-navigation to PDF Editor workbench on
file selection. Stops the "jumpy" behavior when selecting files.
- HomePage.tsx: Check specifically for pdfTextEditor tool instead of any
custom workbench. Prevents auto-switch to viewer when uploading files
while in PDF Text Editor.

<img width="2056" height="1073" alt="Screenshot 2025-12-03 at 6 01
14 PM"
src="https://github.com/user-attachments/assets/dfc63a46-7991-486c-ba00-0ce7637502f5"
/>


---

## Checklist

### General

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

### Documentation

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-12-03 20:37:23 +00:00
EthanHealy01 f8dbf171e1 Feature/v2/get all info on pdf (#5105)
# Description of Changes

- Addition of the get all info on PDF tool

---

## 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.
2025-12-03 20:02:42 +00:00
ConnorYoh e59c717dc0 Fixes state management loops around getting results V2 (#5153)
Makes sure settings step collapses in results step

Makes sure result step doesn't always reset even in development for
baseTool

Make sure result step doesn't reset for convert
2025-12-03 17:42:04 +00:00
ConnorYoh f2bffe2dc6 Fix-convert-V2 (#5147)
Custom processors can now return consume all inputs flag. This allows to
have many inputs to single output consumption

Fixed multi call conversion logic
2025-12-03 17:39:49 +00:00
Anthony Stirling 5d827df08c Add onboarding bypass flag V2 version2 version 2 (#5151)
## Summary
- add a shared hook that honors a `bypassOnboarding` query parameter and
marks onboarding steps as completed for the session
- block onboarding orchestrator and UI elements when the bypass flag is
present so tours and popups stay hidden

## Testing
- ./gradlew build


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_693059f866a8832891dd97f3d52ca5a0)
2025-12-03 17:17:22 +00:00
Anthony Stirling bdb3c887f3 opensource text editor (#5146)
# 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)

### 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.
2025-12-03 12:55:34 +00:00
f902e8aca9 OAuth Provider Buttons (#5103)
PR to allow other OAuth providers to be displayed on the login screen.
Will show a generic icon when the provider is not in the known set of
providers.
<img width="424" height="205" alt="47ab288dadbc889fd84cc83c9ded0829"
src="https://github.com/user-attachments/assets/2877eb3d-2ade-406f-a2bf-dc404793e30f"
/>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-12-03 10:54:53 +00:00
Anthony Stirling 65a3eeca76 Toml (#5115)
# 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.
2025-12-03 09:57:00 +00:00
Anthony Stirling f72538d30f Add files via upload 2025-12-03 09:11:05 +00:00
Anthony Stirling 88c5fb46ad Bump version from 2.0.2 to 2.0.3 2025-12-02 23:39:27 +00:00
Anthony Stirling 8e2f9546a5 fixes for automate (#5127)
# 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.
2025-12-02 23:38:19 +00:00
f2f4bd5230 Bug/v2/signature fixes (#5104)
Co-authored-by: Dario Ghunney Ware <dariogware@gmail.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-12-02 22:48:29 +00:00
Anthony Stirling f3cc30d0c2 Update wording for third-party services reference 2025-12-02 19:14:03 +00:00
Reece Browne ba7c75aff4 Update embed and allow form rendering (#5124)
You can now see the values of forms filled elsewhre (still no filling
forms ourselves)
2025-12-02 18:56:22 +00:00
Anthony Stirling a53d73ef51 Revise README for improved structure and clarity (#5121)
# 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.
2025-12-02 17:25:59 +00:00
c2a63cf425 java frontend (#5097)
# 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: Reece <reece@stirlingpdf.com>
Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-12-02 17:15:29 +00:00
Reece Browne c3456adc2b Print with embed (#5109) 2025-12-02 13:56:28 +00:00
EthanHealy01 179b569769 Chore/v2/onboarding flow cleanup (#5065) 2025-12-02 12:40:20 +00:00
341adaa07d Grandpa Fix (#5030)
PR to address inactive accounts (invited/pending activation) not being
grandfathered during migration

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-12-02 12:34:38 +00:00
feebfe82fa Reduce JWT Logs (#5108)
Removed logging in some areas and changed level from `WARN` -> `DEBUG`
to reduce verbosity

Closes #5089

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-12-02 12:34:17 +00:00
347 changed files with 17829 additions and 6581 deletions
+1
View File
@@ -5,6 +5,7 @@ frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
frontend/src-tauri/target
# Gradle build artifacts
.gradle
+12
View File
@@ -0,0 +1,12 @@
# CI Configuration
## CI Lite Mode
Skip non-essential CI workflows by setting a repository variable:
**Settings → Secrets and variables → Actions → Variables → New repository variable**
- Name: `CI_PROFILE`
- Value: `lite`
Skips resource-intensive builds, releases, and OSS-specific workflows. Useful for deployment-only forks or faster CI runs.
@@ -1,403 +0,0 @@
"""
Author: Ludy87
Description: This script processes .properties files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of lines (including comments and empty lines) in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
The script also provides functionality to update the translation files to match the reference file by adding missing keys and
adjusting the format.
Usage:
python check_language_properties.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_properties.py --reference-file src\main\resources\messages_en_GB.properties --branch "" --files src\main\resources\messages_de_DE.properties src\main\resources\messages_uk_UA.properties
import copy
import glob
import os
import argparse
import re
def find_duplicate_keys(file_path):
"""
Identifies duplicate keys in a .properties file.
:param file_path: Path to the .properties file.
:return: List of tuples (key, first_occurrence_line, duplicate_line).
"""
keys = {}
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
# Skip empty lines and comments
if not stripped_line or stripped_line.startswith("#"):
continue
# Split the line into key and value
if "=" in stripped_line:
key, _ = stripped_line.split("=", 1)
key = key.strip()
# Check if the key already exists
if key in keys:
duplicates.append((key, keys[key], line_number))
else:
keys[key] = line_number
return duplicates
# Maximum size for properties files (e.g., 200 KB)
MAX_FILE_SIZE = 200 * 1024
def parse_properties_file(file_path):
"""
Parses a .properties file and returns a structured list of its contents.
:param file_path: Path to the .properties file.
:return: List of dictionaries representing each line in the file.
"""
properties_list = []
with open(file_path, "r", encoding="utf-8") as file:
for line_number, line in enumerate(file, start=1):
stripped_line = line.strip()
# Handle empty lines
if not stripped_line:
properties_list.append(
{"line_number": line_number, "type": "empty", "content": ""}
)
continue
# Handle comments
if stripped_line.startswith("#"):
properties_list.append(
{
"line_number": line_number,
"type": "comment",
"content": stripped_line,
}
)
continue
# Handle key-value pairs
match = re.match(r"^([^=]+)=(.*)$", line)
if match:
key, value = match.groups()
properties_list.append(
{
"line_number": line_number,
"type": "entry",
"key": key.strip(),
"value": value.strip(),
}
)
return properties_list
def write_json_file(file_path, updated_properties):
"""
Writes updated properties back to the file in their original format.
:param file_path: Path to the .properties file.
:param updated_properties: List of updated properties to write.
"""
updated_lines = {entry["line_number"]: entry for entry in updated_properties}
# Sort lines by their numbers and retain comments and empty lines
all_lines = sorted(set(updated_lines.keys()))
original_format = []
for line in all_lines:
if line in updated_lines:
entry = updated_lines[line]
else:
entry = None
ref_entry = updated_lines[line]
if ref_entry["type"] in ["comment", "empty"]:
original_format.append(ref_entry)
elif entry is None:
# Add missing entries from the reference file
original_format.append(ref_entry)
elif entry["type"] == "entry":
# Replace entries with those from the current JSON
original_format.append(entry)
# Write the updated content back to the file
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
for entry in original_format:
if entry["type"] == "comment":
file.write(f"{entry['content']}\n")
elif entry["type"] == "empty":
file.write(f"{entry['content']}\n")
elif entry["type"] == "entry":
file.write(f"{entry['key']}={entry['value']}\n")
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference .properties file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_properties_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".properties")
or not basename_current_file.startswith("messages_")
):
continue
current_properties = parse_properties_file(os.path.join(branch, file_path))
updated_properties = []
for ref_entry in reference_properties:
ref_entry_copy = copy.deepcopy(ref_entry)
for current_entry in current_properties:
if current_entry["type"] == "entry":
if ref_entry_copy["type"] != "entry":
continue
if ref_entry_copy["key"].lower() == current_entry["key"].lower():
ref_entry_copy["value"] = current_entry["value"]
updated_properties.append(ref_entry_copy)
write_json_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_properties(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as file:
return file.read().splitlines()
return [""]
def check_for_differences(reference_file, file_list, branch, actor):
reference_branch = reference_file.split("/")[0]
basename_reference_file = os.path.basename(reference_file)
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_lines = read_properties(reference_file)
has_differences = False
only_reference_file = True
file_arr = file_list
if len(file_list) == 1:
file_arr = file_list[0].split()
base_dir = os.path.abspath(
os.path.join(os.getcwd(), "app", "core", "src", "main", "resources")
)
for file_path in file_arr:
file_normpath = os.path.normpath(file_path)
absolute_path = os.path.abspath(file_normpath)
# Verify that file is within the expected directory
if not absolute_path.startswith(base_dir):
raise ValueError(f"Unsafe file found: {file_normpath}")
# Verify file size before processing
if os.path.getsize(os.path.join(branch, file_normpath)) > MAX_FILE_SIZE:
raise ValueError(
f"The file {file_normpath} is too large and could pose a security risk."
)
basename_current_file = os.path.basename(os.path.join(branch, file_normpath))
if (
basename_current_file == basename_reference_file
or (
# only local windows command
not file_normpath.startswith(
os.path.join(
"", "app", "core", "src", "main", "resources", "messages_"
)
)
and not file_normpath.startswith(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_",
)
)
)
or not file_normpath.endswith(".properties")
or not basename_current_file.startswith("messages_")
):
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{basename_current_file}`")
current_lines = read_properties(os.path.join(branch, file_path))
reference_line_count = len(reference_lines)
current_line_count = len(current_lines)
if reference_line_count != current_line_count:
report.append("")
report.append("1. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
has_differences = True
if reference_line_count > current_line_count:
report.append(
f" - **_Mismatched line count_**: {reference_line_count} (reference) vs {current_line_count} (current). Comments, empty lines, or translation strings are missing."
)
elif reference_line_count < current_line_count:
report.append(
f" - **_Too many lines_**: {reference_line_count} (reference) vs {current_line_count} (current). Please verify if there is an additional line that needs to be removed."
)
else:
report.append("1. **Test Status:** ✅ **_Passed_**")
# Check for missing or extra keys
current_keys = []
reference_keys = []
for line in current_lines:
if not line.startswith("#") and line != "" and "=" in line:
key, _ = line.split("=", 1)
current_keys.append(key)
for line in reference_lines:
if not line.startswith("#") and line != "" and "=" in line:
key, _ = line.split("=", 1)
reference_keys.append(key)
current_keys_set = set(current_keys)
reference_keys_set = set(reference_keys)
missing_keys = current_keys_set.difference(reference_keys_set)
extra_keys = reference_keys_set.difference(current_keys_set)
missing_keys_list = list(missing_keys)
extra_keys_list = list(extra_keys)
if missing_keys_list or extra_keys_list:
has_differences = True
missing_keys_str = "`, `".join(missing_keys_list)
extra_keys_str = "`, `".join(extra_keys_list)
report.append("2. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
if missing_keys_list:
spaces_keys_list = []
for key in missing_keys_list:
if " " in key:
spaces_keys_list.append(key)
if spaces_keys_list:
spaces_keys_str = "`, `".join(spaces_keys_list)
report.append(
f" - **_Keys containing unnecessary spaces_**: `{spaces_keys_str}`!"
)
report.append(
f" - **_Extra keys in `{basename_current_file}`_**: `{missing_keys_str}` that are not present in **_`{basename_reference_file}`_**."
)
if extra_keys_list:
report.append(
f" - **_Missing keys in `{basename_reference_file}`_**: `{extra_keys_str}` that are not present in **_`{basename_current_file}`_**."
)
else:
report.append("2. **Test Status:** ✅ **_Passed_**")
if find_duplicate_keys(os.path.join(branch, file_normpath)):
has_differences = True
output = "\n".join(
[
f" - `{key}`: first at line {first}, duplicate at `line {duplicate}`"
for key, first, duplicate in find_duplicate_keys(
os.path.join(branch, file_normpath)
)
]
)
report.append("3. **Test Status:** ❌ **_Failed_**")
report.append(" - **Issue:**")
report.append(" - duplicate entries were found:")
report.append(output)
else:
report.append("3. **Test Status:** ✅ **_Passed_**")
report.append("")
report.append("---")
report.append("")
if has_differences:
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [messages_en_GB.properties](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/app/core/src/main/resources/messages_en_GB.properties)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
report.append("")
report.append(
f"Thanks @{actor} for your help in keeping the translations up to date."
)
if not only_reference_file:
print("\n".join(report))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser.add_argument(
"--actor",
required=False,
help="Actor from PR.",
)
parser.add_argument(
"--reference-file",
required=True,
help="Path to the reference file.",
)
parser.add_argument(
"--branch",
type=str,
required=True,
help="Branch name.",
)
parser.add_argument(
"--check-file",
type=str,
required=False,
help="List of changed files, separated by spaces.",
)
parser.add_argument(
"--files",
nargs="+",
required=False,
help="List of changed files, separated by spaces.",
)
args = parser.parse_args()
# Sanitize --actor input to avoid injection attacks
if args.actor:
args.actor = re.sub(r"[^a-zA-Z0-9_\\-]", "", args.actor)
# Sanitize --branch input to avoid injection attacks
if args.branch:
args.branch = re.sub(r"[^a-zA-Z0-9\\-]", "", args.branch)
file_list = args.files
if file_list is None:
if args.check_file:
file_list = [args.check_file]
else:
file_list = glob.glob(
os.path.join(
os.getcwd(),
"app",
"core",
"src",
"main",
"resources",
"messages_*.properties",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
@@ -1,6 +1,6 @@
"""
Author: Ludy87
Description: This script processes JSON translation files for localization checks. It compares translation files in a branch with
Description: This script processes TOML translation files for localization checks. It compares translation files in a branch with
a reference file to ensure consistency. The script performs two main checks:
1. Verifies that the number of translation keys in the translation files matches the reference file.
2. Ensures that all keys in the translation files are present in the reference file and vice versa.
@@ -9,10 +9,10 @@ The script also provides functionality to update the translation files to match
adjusting the format.
Usage:
python check_language_json.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
python check_language_toml.py --reference-file <path_to_reference_file> --branch <branch_name> [--actor <actor_name>] [--files <list_of_changed_files>]
"""
# Sample for Windows:
# python .github/scripts/check_language_json.py --reference-file frontend/public/locales/en-GB/translation.json --branch "" --files frontend/public/locales/de-DE/translation.json frontend/public/locales/fr-FR/translation.json
# python .github/scripts/check_language_toml.py --reference-file frontend/public/locales/en-GB/translation.toml --branch "" --files frontend/public/locales/de-DE/translation.toml frontend/public/locales/fr-FR/translation.toml
import copy
import glob
@@ -20,12 +20,14 @@ import os
import argparse
import re
import json
import tomllib # Python 3.11+ (stdlib)
import tomli_w # For writing TOML files
def find_duplicate_keys(file_path, keys=None, prefix=""):
"""
Identifies duplicate keys in a JSON file (including nested keys).
:param file_path: Path to the JSON file.
Identifies duplicate keys in a TOML file (including nested keys).
:param file_path: Path to the TOML file.
:param keys: Dictionary to track keys (used for recursion).
:param prefix: Prefix for nested keys.
:return: List of tuples (key, first_occurrence_path, duplicate_path).
@@ -35,8 +37,9 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
duplicates = []
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
# Load TOML file
with open(file_path, 'rb') as file:
data = tomllib.load(file)
def process_dict(obj, current_prefix=""):
for key, value in obj.items():
@@ -54,18 +57,18 @@ def find_duplicate_keys(file_path, keys=None, prefix=""):
return duplicates
# Maximum size for JSON files (e.g., 500 KB)
# Maximum size for TOML files (e.g., 500 KB)
MAX_FILE_SIZE = 500 * 1024
def parse_json_file(file_path):
def parse_toml_file(file_path):
"""
Parses a JSON translation file and returns a flat dictionary of all keys.
:param file_path: Path to the JSON file.
Parses a TOML translation file and returns a flat dictionary of all keys.
:param file_path: Path to the TOML file.
:return: Dictionary with flattened keys.
"""
with open(file_path, "r", encoding="utf-8") as file:
data = json.load(file)
with open(file_path, 'rb') as file:
data = tomllib.load(file)
def flatten_dict(d, parent_key="", sep="."):
items = {}
@@ -99,38 +102,37 @@ def unflatten_dict(d, sep="."):
return result
def write_json_file(file_path, updated_properties):
def write_toml_file(file_path, updated_properties):
"""
Writes updated properties back to the JSON file.
:param file_path: Path to the JSON file.
Writes updated properties back to the TOML file.
:param file_path: Path to the TOML file.
:param updated_properties: Dictionary of updated properties to write.
"""
nested_data = unflatten_dict(updated_properties)
with open(file_path, "w", encoding="utf-8", newline="\n") as file:
json.dump(nested_data, file, ensure_ascii=False, indent=2)
file.write("\n") # Add trailing newline
with open(file_path, "wb") as file:
tomli_w.dump(nested_data, file)
def update_missing_keys(reference_file, file_list, branch=""):
"""
Updates missing keys in the translation files based on the reference file.
:param reference_file: Path to the reference JSON file.
:param reference_file: Path to the reference TOML file.
:param file_list: List of translation files to update.
:param branch: Branch where the files are located.
"""
reference_properties = parse_json_file(reference_file)
reference_properties = parse_toml_file(reference_file)
for file_path in file_list:
basename_current_file = os.path.basename(os.path.join(branch, file_path))
if (
basename_current_file == os.path.basename(reference_file)
or not file_path.endswith(".json")
or not file_path.endswith(".toml")
or not os.path.dirname(file_path).endswith("locales")
):
continue
current_properties = parse_json_file(os.path.join(branch, file_path))
current_properties = parse_toml_file(os.path.join(branch, file_path))
updated_properties = {}
for ref_key, ref_value in reference_properties.items():
@@ -141,16 +143,16 @@ def update_missing_keys(reference_file, file_list, branch=""):
# Add missing key with reference value
updated_properties[ref_key] = ref_value
write_json_file(os.path.join(branch, file_path), updated_properties)
write_toml_file(os.path.join(branch, file_path), updated_properties)
def check_for_missing_keys(reference_file, file_list, branch):
update_missing_keys(reference_file, file_list, branch)
def read_json_keys(file_path):
def read_toml_keys(file_path):
if os.path.isfile(file_path) and os.path.exists(file_path):
return parse_json_file(file_path)
return parse_toml_file(file_path)
return {}
@@ -160,7 +162,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report = []
report.append(f"#### 🔄 Reference Branch: `{reference_branch}`")
reference_keys = read_json_keys(reference_file)
reference_keys = read_toml_keys(reference_file)
has_differences = False
only_reference_file = True
@@ -197,12 +199,12 @@ def check_for_differences(reference_file, file_list, branch, actor):
):
continue
if not file_normpath.endswith(".json") or basename_current_file != "translation.json":
if not file_normpath.endswith(".toml") or basename_current_file != "translation.toml":
continue
only_reference_file = False
report.append(f"#### 📃 **File Check:** `{locale_dir}/{basename_current_file}`")
current_keys = read_json_keys(os.path.join(branch, file_path))
current_keys = read_toml_keys(os.path.join(branch, file_path))
reference_key_count = len(reference_keys)
current_key_count = len(current_keys)
@@ -272,7 +274,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
report.append("## ❌ Overall Check Status: **_Failed_**")
report.append("")
report.append(
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.json](https://github.com/Stirling-Tools/Stirling-PDF/blob/V2/frontend/public/locales/en-GB/translation.json)"
f"@{actor} please check your translation if it conforms to the standard. Follow the format of [en-GB/translation.toml](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/frontend/public/locales/en-GB/translation.toml)"
)
else:
report.append("## ✅ Overall Check Status: **_Success_**")
@@ -286,7 +288,7 @@ def check_for_differences(reference_file, file_list, branch, actor):
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Find missing keys")
parser = argparse.ArgumentParser(description="Find missing keys in TOML translation files")
parser.add_argument(
"--actor",
required=False,
@@ -337,9 +339,9 @@ if __name__ == "__main__":
"public",
"locales",
"*",
"translation.json",
"translation.toml",
)
)
update_missing_keys(args.reference_file, file_list)
else:
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
check_for_differences(args.reference_file, file_list, args.branch, args.actor)
@@ -14,6 +14,7 @@ jobs:
permissions:
issues: write
if: |
vars.CI_PROFILE != 'lite' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, 'prdeploy') ||
@@ -180,7 +181,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:pr-${{ needs.check-comment.outputs.pr_number }}
build-args: VERSION_TAG=alpha
+16 -3
View File
@@ -262,7 +262,13 @@ jobs:
strategy:
fail-fast: false
matrix:
docker-rev: ["Dockerfile", "Dockerfile.ultra-lite", "Dockerfile.fat"]
include:
- docker-rev: docker/embedded/Dockerfile
artifact-suffix: Dockerfile
- docker-rev: docker/embedded/Dockerfile.ultra-lite
artifact-suffix: Dockerfile.ultra-lite
- docker-rev: docker/embedded/Dockerfile.fat
artifact-suffix: Dockerfile.fat
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
@@ -272,6 +278,13 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Free disk space on runner
run: |
echo "Disk space before cleanup:" && df -h
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/share/boost
docker system prune -af || true
echo "Disk space after cleanup:" && df -h
- name: Set up JDK 17
uses: actions/setup-java@dded0888837ed1f317902acf8a20df0ad188d165 # v5.0.0
with:
@@ -301,7 +314,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/backend/${{ matrix.docker-rev }}
file: ./${{ matrix.docker-rev }}
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -313,7 +326,7 @@ jobs:
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: reports-docker-${{ matrix.docker-rev }}
name: reports-docker-${{ matrix.artifact-suffix }}
path: |
build/reports/tests/
build/test-results/
@@ -1,19 +1,14 @@
name: Check Properties Files on PR
name: Check TOML Translation Files on PR
# This workflow validates TOML translation files
on:
pull_request_target:
types: [opened, synchronize, reopened]
paths:
- "app/core/src/main/resources/messages_*.properties"
- "frontend/public/locales/*/translation.toml"
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref_name || github.ref }}
cancel-in-progress: true
@@ -73,22 +68,22 @@ jobs:
run: |
echo "Fetching PR changed files..."
echo "Getting list of changed files from PR..."
# Check if PR number exists
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
echo "Error: PR number is empty"
exit 1
fi
# Get changed files and filter for properties files, handle case where no matches are found
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^app/core/src/main/resources/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$' > changed_files.txt || echo "No matching properties files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No properties files changed in this PR"
echo "Workflow will exit early as no relevant files to check"
exit 0
fi
echo "Found $(wc -l < changed_files.txt) matching properties files"
# Check if PR number exists
if [ -z "${{ steps.get-pr-data.outputs.pr_number }}" ]; then
echo "Error: PR number is empty"
exit 1
fi
# Get changed files and filter for TOML translation files
gh pr view ${{ steps.get-pr-data.outputs.pr_number }} --json files -q ".files[].path" | grep -E '^frontend/public/locales/[a-zA-Z-]+/translation\.toml$' > changed_files.txt || echo "No matching TOML files found in PR"
# Check if any files were found
if [ ! -s changed_files.txt ]; then
echo "No TOML translation files changed in this PR"
echo "Workflow will exit early as no relevant files to check"
exit 0
fi
echo "Found $(wc -l < changed_files.txt) matching TOML files"
- name: Determine reference file test
- name: Determine reference file
id: determine-file
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
@@ -125,11 +120,11 @@ jobs:
pull_number: prNumber,
});
// Filter for relevant files based on the PR changes
// Filter for relevant TOML files based on the PR changes
const changedFiles = files
.filter(file =>
file.status !== "removed" &&
/^app\/core\/src\/main\/resources\/messages_[a-zA-Z_]{2}_[a-zA-Z_]{2,7}\.properties$/.test(file.filename)
/^frontend\/public\/locales\/[a-zA-Z-]+\/translation\.toml$/.test(file.filename)
)
.map(file => file.filename);
@@ -169,16 +164,16 @@ jobs:
// Determine reference file
let referenceFilePath;
if (changedFiles.includes("app/core/src/main/resources/messages_en_GB.properties")) {
if (changedFiles.includes("frontend/public/locales/en-GB/translation.toml")) {
console.log("Using PR branch reference file.");
const { data: fileContent } = await github.rest.repos.getContent({
owner: prRepoOwner,
repo: prRepoName,
path: "app/core/src/main/resources/messages_en_GB.properties",
path: "frontend/public/locales/en-GB/translation.toml",
ref: branch,
});
referenceFilePath = "pr-branch-messages_en_GB.properties";
referenceFilePath = "pr-branch-translation-en-GB.toml";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
} else {
@@ -186,11 +181,11 @@ jobs:
const { data: fileContent } = await github.rest.repos.getContent({
owner: repoOwner,
repo: repoName,
path: "app/core/src/main/resources/messages_en_GB.properties",
path: "frontend/public/locales/en-GB/translation.toml",
ref: "main",
});
referenceFilePath = "main-branch-messages_en_GB.properties";
referenceFilePath = "main-branch-translation-en-GB.toml";
const content = Buffer.from(fileContent.content, "base64").toString("utf-8");
fs.writeFileSync(referenceFilePath, content);
}
@@ -198,11 +193,20 @@ jobs:
console.log(`Reference file path: ${referenceFilePath}`);
core.exportVariable("REFERENCE_FILE", referenceFilePath);
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
- name: Install Python dependencies
run: |
pip install tomli-w
- name: Run Python script to check files
id: run-check
run: |
echo "Running Python script to check files..."
python .github/scripts/check_language_properties.py \
echo "Running Python script to check TOML files..."
python .github/scripts/check_language_toml.py \
--actor ${{ github.event.pull_request.user.login }} \
--reference-file "${REFERENCE_FILE}" \
--branch "pr-branch" \
@@ -213,7 +217,7 @@ jobs:
id: capture-output
run: |
if [ -f result.txt ] && [ -s result.txt ]; then
echo "Test, capturing output..."
echo "Capturing output..."
SCRIPT_OUTPUT=$(cat result.txt)
echo "SCRIPT_OUTPUT<<EOF" >> $GITHUB_ENV
echo "$SCRIPT_OUTPUT" >> $GITHUB_ENV
@@ -227,7 +231,7 @@ jobs:
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
else
echo "No update found."
echo "No output found."
echo "SCRIPT_OUTPUT=" >> $GITHUB_ENV
echo "FAIL_JOB=false" >> $GITHUB_ENV
fi
@@ -249,7 +253,7 @@ jobs:
issue_number: issueNumber
});
const comment = comments.data.find(c => c.body.includes("## 🚀 Translation Verification Summary"));
const comment = comments.data.find(c => c.body.includes("## 🌐 TOML Translation Verification Summary"));
// Only update or create comments by the action user
const expectedActor = "${{ steps.setup-bot.outputs.app-slug }}[bot]";
@@ -260,7 +264,7 @@ jobs:
owner: repoOwner,
repo: repoName,
comment_id: comment.id,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Updated existing comment.");
} else if (!comment) {
@@ -269,7 +273,7 @@ jobs:
owner: repoOwner,
repo: repoName,
issue_number: issueNumber,
body: `## 🚀 Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
body: `## 🌐 TOML Translation Verification Summary\n\n\n${SCRIPT_OUTPUT}\n`
});
console.log("Created new comment.");
} else {
@@ -287,6 +291,6 @@ jobs:
run: |
echo "Cleaning up temporary files..."
rm -rf pr-branch
rm -f pr-branch-messages_en_GB.properties main-branch-messages_en_GB.properties changed_files.txt result.txt
rm -f pr-branch-translation-en-GB.toml main-branch-translation-en-GB.toml changed_files.txt result.txt
echo "Cleanup complete."
continue-on-error: true # Ensure cleanup runs even if previous steps fail
+1
View File
@@ -31,6 +31,7 @@ permissions:
jobs:
determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
+11 -9
View File
@@ -5,6 +5,7 @@ on:
push:
branches:
- V2-master
- alljavadocker
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
@@ -23,6 +24,7 @@ permissions:
jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-24.04-8core
permissions:
packages: write
@@ -93,10 +95,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}
type=raw,value=latest
- name: Generate tags for latest (V2-demo branch - test)
- name: Generate tags for latest (alljavadocker branch - test)
id: meta-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -110,7 +112,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -149,10 +151,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-fat
type=raw,value=latest-fat
- name: Generate tags for latest-fat (V2-demo branch - test)
- name: Generate tags for latest-fat (alljavadocker branch - test)
id: meta-fat-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -166,7 +168,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified
file: ./docker/embedded/Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -203,10 +205,10 @@ jobs:
type=raw,value=${{ steps.versionNumber.outputs.versionNumber }}-ultra-lite
type=raw,value=latest-ultra-lite
- name: Generate tags for ultra-lite (V2-demo branch - test)
- name: Generate tags for ultra-lite (alljavadocker branch - test)
id: meta-lite-test
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f # v5.8.0
if: github.ref == 'refs/heads/V2-demo'
if: github.ref == 'refs/heads/alljavadocker'
with:
images: |
ghcr.io/stirling-tools/stirling-pdf-test
@@ -220,7 +222,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./docker/Dockerfile.unified-lite
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
+4 -3
View File
@@ -24,6 +24,7 @@ permissions:
jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
permissions:
packages: write
@@ -107,7 +108,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile
file: ./docker/embedded/Dockerfile
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -152,7 +153,7 @@ jobs:
if: github.ref != 'refs/heads/main'
with:
context: .
file: ./Dockerfile.ultra-lite
file: ./docker/embedded/Dockerfile.ultra-lite
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
@@ -183,7 +184,7 @@ jobs:
with:
builder: ${{ steps.buildx.outputs.name }}
context: .
file: ./Dockerfile.fat
file: ./docker/embedded/Dockerfile.fat
push: true
cache-from: type=gha
cache-to: type=gha,mode=max
+1
View File
@@ -17,6 +17,7 @@ permissions: read-all
jobs:
analysis:
if: ${{ vars.CI_PROFILE != 'lite' }}
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:
+1
View File
@@ -27,6 +27,7 @@ permissions:
jobs:
sonarqube:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
+1
View File
@@ -10,6 +10,7 @@ permissions:
jobs:
stale:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
permissions:
issues: write
+1
View File
@@ -23,6 +23,7 @@ permissions:
jobs:
push:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
-122
View File
@@ -1,122 +0,0 @@
name: Sync Files
on:
workflow_dispatch:
push:
branches:
- main
paths:
- "build.gradle"
- "README.md"
- "app/core/src/main/resources/messages_*.properties"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
# cancel in-progress jobs if a new job is triggered
# This is useful to avoid running multiple builds for the same branch if a new commit is pushed
# or a pull request is updated.
# It helps to save resources and time by ensuring that only the latest commit is built and tested
# This is particularly useful for long-running jobs that may take a while to complete.
# The `group` is set to a combination of the workflow name, event name, and branch name.
# This ensures that jobs are grouped by the workflow and branch, allowing for cancellation of
# in-progress jobs when a new commit is pushed to the same branch or a new pull request is opened.
concurrency:
group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref_name || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
sync-files:
runs-on: ubuntu-latest
env:
# Prevents sdist builds → no tar extraction
PIP_ONLY_BINARY: ":all:"
PIP_DISABLE_PIP_VERSION_CHECK: "1"
steps:
- name: Harden Runner
uses: step-security/harden-runner@f4a75cfd619ee5ce8d5b864b0d183aff3c69b55a # v2.13.1
with:
egress-policy: audit
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Setup GitHub App Bot
id: setup-bot
uses: ./.github/actions/setup-bot
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Set up Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
with:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation property files
run: |
python .github/scripts/check_language_properties.py --reference-file "app/core/src/main/resources/messages_en_GB.properties" --branch main
- name: Commit translation files
run: |
git add app/core/src/main/resources/messages_*.properties
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
- name: Install dependencies
# Wheels-only + Hash-Pinning
run: |
pip install --require-hashes --only-binary=:all: -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation.py
- name: Run git add
run: |
git add README.md scripts/ignore_translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync README.md & scripts/ignore_translation.toml" || echo "No changes detected"
- name: Create Pull Request
if: always()
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ steps.setup-bot.outputs.token }}
commit-message: Update files
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
body: |
### 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 (`messages_*.properties`) to reflect changes in the reference file `messages_en_GB.properties`.
- Ensured consistency and synchronization across all supported language files.
- Highlighted any missing or incomplete translations.
#### **2. Update README.md**
- Generated the translation progress table in `README.md`.
- 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
draft: false
delete-branch: true
labels: github-actions
sign-commits: true
add-paths: |
README.md
app/core/src/main/resources/messages_*.properties
+22 -16
View File
@@ -1,15 +1,15 @@
name: Sync Files V2
name: Sync Files (TOML)
on:
workflow_dispatch:
push:
branches:
- V2
- main
- syncLangTest
paths:
- "build.gradle"
- "README.md"
- "frontend/public/locales/*/translation.json"
- "frontend/public/locales/*/translation.toml"
- "app/core/src/main/resources/static/3rdPartyLicenses.json"
- "scripts/ignore_translation.toml"
@@ -52,21 +52,25 @@ jobs:
python-version: "3.12"
cache: "pip" # caching pip dependencies
- name: Sync translation JSON files
- name: Install Python dependencies
run: |
python .github/scripts/check_language_json.py --reference-file "frontend/public/locales/en-GB/translation.json" --branch V2
pip install tomli-w
- name: Sync translation TOML files
run: |
python .github/scripts/check_language_toml.py --reference-file "frontend/public/locales/en-GB/translation.toml" --branch main
- name: Commit translation files
run: |
git add frontend/public/locales/*/translation.json
git diff --staged --quiet || git commit -m ":memo: Sync translation files" || echo "No changes detected"
git add frontend/public/locales/*/translation.toml
git diff --staged --quiet || git commit -m ":memo: Sync translation files (TOML)" || echo "No changes detected"
- name: Install dependencies
- name: Install README dependencies
run: pip install --require-hashes -r ./.github/scripts/requirements_sync_readme.txt
- name: Sync README.md
run: |
python scripts/counter_translation_v2.py
python scripts/counter_translation_v3.py
- name: Run git add
run: |
@@ -82,21 +86,22 @@ jobs:
committer: ${{ steps.setup-bot.outputs.committer }}
author: ${{ steps.setup-bot.outputs.committer }}
signoff: true
branch: sync_readme_v2
base: V2
title: ":globe_with_meridians: [V2] Sync Translations + Update README Progress Table"
branch: sync_readme_v3
base: main
title: ":globe_with_meridians: Sync Translations + Update README Progress Table"
body: |
### Description of Changes
This Pull Request was automatically generated to synchronize updates to translation files and documentation for the **V2 branch**. Below are the details of the changes made:
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.json`) to reflect changes in the reference file `en-GB/translation.json`.
- 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`.
- 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.
@@ -115,4 +120,5 @@ jobs:
sign-commits: true
add-paths: |
README.md
frontend/public/locales/*/translation.json
frontend/public/locales/*/translation.toml
scripts/ignore_translation.toml
+3
View File
@@ -28,6 +28,7 @@ permissions:
jobs:
determine-matrix:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
@@ -636,6 +637,8 @@ jobs:
if [ "${{ needs.build.result }}" = "success" ]; then
echo "✅ All Tauri builds completed successfully!"
echo "Artifacts are ready for distribution."
elif [ "${{ needs.build.result }}" = "skipped" ]; then
echo "⏭️ Tauri builds skipped (CI lite mode enabled)"
else
echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues."
+2 -1
View File
@@ -21,6 +21,7 @@ permissions:
jobs:
deploy:
if: ${{ vars.CI_PROFILE != 'lite' }}
runs-on: ubuntu-latest
steps:
- name: Harden Runner
@@ -66,7 +67,7 @@ jobs:
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
file: ./Dockerfile
file: ./docker/embedded/Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/test:test-${{ github.sha }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
+3 -3
View File
@@ -202,10 +202,10 @@ const [ToolName] = (props: BaseToolProps) => {
## 5. Add Translations
Update translation files. **Important: Only update `en-GB` files** - other languages are handled separately.
**File to update:** `frontend/public/locales/en-GB/translation.json`
**File to update:** `frontend/public/locales/en-GB/translation.toml`
**Required Translation Keys**:
```json
```toml
{
"home": {
"[toolName]": {
@@ -251,7 +251,7 @@ Update translation files. **Important: Only update `en-GB` files** - other langu
```
**Translation Notes:**
- **Only update `en-GB/translation.json`** - other locale files are managed separately
- **Only update `en-GB/translation.toml`** - other locale files are managed separately
- Use descriptive keys that match your component's `t()` calls
- Include tooltip translations if you created tooltip hooks
- Add `options.*` keys if your tool has settings with descriptions
+48 -152
View File
@@ -1,173 +1,69 @@
<p align="center"><img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80"></p>
<h1 align="center">Stirling-PDF</h1>
<p align="center">
<img src="https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling.png" width="80" alt="Stirling PDF logo">
</p>
[![Docker Pulls](https://img.shields.io/docker/pulls/frooodle/s-pdf)](https://hub.docker.com/r/frooodle/s-pdf)
[![Discord](https://img.shields.io/discord/1068636748814483718?label=Discord)](https://discord.gg/HYmhKj45pU)
[![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge)](https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF)
[![GitHub Repo stars](https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social)](https://github.com/Stirling-Tools/stirling-pdf)
<h1 align="center">Stirling PDF - The Open-Source PDF Platform</h1>
<a href="https://www.producthunt.com/posts/stirling-pdf?embed=true&utm_source=badge-featured&utm_medium=badge&utm_souce=badge-stirling&#0045;pdf" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=641239&theme=light" alt="Stirling&#0032;PDF - Open&#0032;source&#0032;locally&#0032;hosted&#0032;web&#0032;PDF&#0032;editor | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
[![Deploy to DO](https://www.deploytodo.com/do-btn-blue.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/Stirling-Tools/Stirling-PDF/tree/digitalOcean&refcode=c3210994b1af)
Stirling PDF is a powerful, open-source PDF editing platform. Run it as a personal desktop app, in the browser, or deploy it on your own servers with a private API. Edit, sign, redact, convert, and automate PDFs without sending documents to external services.
[Stirling-PDF](https://www.stirlingpdf.com) is a robust, locally hosted web-based PDF manipulation tool using Docker. It enables you to carry out various operations on PDF files, including splitting, merging, converting, reorganizing, adding images, rotating, compressing, and more. This locally hosted web application has evolved to encompass a comprehensive set of features, addressing all your PDF requirements.
<p align="center">
<a href="https://hub.docker.com/r/stirlingtools/stirling-pdf">
<img src="https://img.shields.io/docker/pulls/frooodle/s-pdf" alt="Docker Pulls">
</a>
<a href="https://discord.gg/HYmhKj45pU">
<img src="https://img.shields.io/discord/1068636748814483718?label=Discord" alt="Discord">
</a>
<a href="https://scorecard.dev/viewer/?uri=github.com/Stirling-Tools/Stirling-PDF">
<img src="https://api.scorecard.dev/projects/github.com/Stirling-Tools/Stirling-PDF/badge" alt="OpenSSF Scorecard">
</a>
<a href="https://github.com/Stirling-Tools/stirling-pdf">
<img src="https://img.shields.io/github/stars/stirling-tools/stirling-pdf?style=social" alt="GitHub Repo stars">
</a>
</p>
All files and PDFs exist either exclusively on the client side, reside in server memory only during task execution, or temporarily reside in a file solely for the execution of the task. Any file downloaded by the user will have been deleted from the server by that point.
![Stirling PDF - Dashboard](images/home-light.png)
Homepage: [https://stirlingpdf.com](https://stirlingpdf.com)
## Key Capabilities
All documentation available at [https://docs.stirlingpdf.com/](https://docs.stirlingpdf.com/)
- **Everywhere you work** - Desktop client, browser UI, and self-hosted server with a private API.
- **50+ PDF tools** - Edit, merge, split, sign, redact, convert, OCR, compress, and more.
- **Automation & workflows** - No-code pipelines direct in UI with APIs to process millions of PDFs.
- **Enterprisegrade** - SSO, auditing, and flexible onprem deployments.
- **Developer platform** - REST APIs available for nearly all tools to integrate into your existing systems.
- **Global UI** - Interface available in 40+ languages.
![stirling-home](images/stirling-home.jpg)
For a full feature list, see the docs: **https://docs.stirlingpdf.com**
## Features
## Quick Start
- 50+ PDF Operations
- Parallel file processing and downloads
- Dark mode support
- Custom download options
- Custom 'Pipelines' to run multiple features in a automated queue
- API for integration with external scripts
- Optional Login and Authentication support (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/System%20and%20Security) for documentation)
- Database Backup and Import (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/DATABASE) for documentation)
- Enterprise features like SSO (see [here](https://docs.stirlingpdf.com/Advanced%20Configuration/Single%20Sign-On%20Configuration) for documentation)
```bash
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf
```
## PDF Features
Then open: http://localhost:8080
### Page Operations
For full installation options (including desktop and Kubernetes), see our [Documentation Guide](https://docs.stirlingpdf.com/#documentation-guide).
- View and modify PDFs - View multi-page PDFs with custom viewing, sorting, and searching. Plus, on-page edit features like annotating, drawing, and adding text and images. (Using PDF.js with Joxit and Liberation fonts)
- Full interactive GUI for merging/splitting/rotating/moving PDFs and their pages
- Merge multiple PDFs into a single resultant file
- Split PDFs into multiple files at specified page numbers or extract all pages as individual files
- Reorganize PDF pages into different orders
- Rotate PDFs in 90-degree increments
- Remove pages
- Multi-page layout (format PDFs into a multi-paged page)
- Scale page contents size by set percentage
- Adjust contrast
- Crop PDF
- Auto-split PDF (with physically scanned page dividers)
- Extract page(s)
- Convert PDF to a single page
- Overlay PDFs on top of each other
- PDF to a single page
- Split PDF by sections
## Resources
### Conversion Operations
- [**Documentation**](https://docs.stirlingpdf.com)
- [**Homepage**](https://stirling.com)
- [**API Docs**](https://registry.scalar.com/@stirlingpdf/apis/stirling-pdf-processing-api/)
- [**Server Plan & Enterprise**](https://docs.stirlingpdf.com/Paid-Offerings)
- Convert PDFs to and from images
- Convert any common file to PDF (using LibreOffice)
- Convert PDF to Word/PowerPoint/others (using LibreOffice)
- Convert HTML to PDF
- Convert PDF to XML
- Convert PDF to CSV
- URL to PDF
- Markdown to PDF
## Support
### Security & Permissions
- **Community** [Discord](https://discord.gg/HYmhKj45pU)
- **Bug Reports**: [Github issues](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- Add and remove passwords
- Change/set PDF permissions
- Add watermark(s)
- Certify/sign PDFs
- Sanitize PDFs
- Auto-redact text
## Contributing
### Other Operations
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
- Add/generate/write signatures
- Split by Size or PDF
- Repair PDFs
- Detect and remove blank pages
- Compare two PDFs and show differences in text
- Add images to PDFs
- Compress PDFs to decrease their filesize (using qpdf)
- Extract images from PDF
- Remove images from PDF
- Extract images from scans
- Remove annotations
- Add page numbers
- Auto-rename files by detecting PDF header text
- OCR on PDF (using Tesseract OCR)
- PDF/A conversion (using LibreOffice)
- Edit metadata
- Flatten PDFs
- Get all information on a PDF to view or export as JSON
- Show/detect embedded JavaScript
For development setup, see the [Developer Guide](DeveloperGuide.md).
For adding translations, see the [Translation Guide](devGuide/HowToAddNewLanguage.md).
## License
# 📖 Get Started
Visit our comprehensive documentation at [docs.stirlingpdf.com](https://docs.stirlingpdf.com) for:
- Installation guides for all platforms
- Configuration options
- Feature documentation
- API reference
- Security setup
- Enterprise features
## Supported Languages
Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![87%](https://geps.dev/progress/87) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![86%](https://geps.dev/progress/86) |
| Basque (Euskara) (eu_ES) | ![86%](https://geps.dev/progress/86) |
| Bulgarian (Български) (bg_BG) | ![86%](https://geps.dev/progress/86) |
| Catalan (Català) (ca_CA) | ![85%](https://geps.dev/progress/85) |
| Croatian (Hrvatski) (hr_HR) | ![86%](https://geps.dev/progress/86) |
| Czech (Česky) (cs_CZ) | ![84%](https://geps.dev/progress/84) |
| Danish (Dansk) (da_DK) | ![85%](https://geps.dev/progress/85) |
| Dutch (Nederlands) (nl_NL) | ![85%](https://geps.dev/progress/85) |
| English (English) (en_GB) | ![100%](https://geps.dev/progress/100) |
| English (US) (en_US) | ![100%](https://geps.dev/progress/100) |
| French (Français) (fr_FR) | ![85%](https://geps.dev/progress/85) |
| German (Deutsch) (de_DE) | ![86%](https://geps.dev/progress/86) |
| Greek (Ελληνικά) (el_GR) | ![86%](https://geps.dev/progress/86) |
| Hindi (हिंदी) (hi_IN) | ![86%](https://geps.dev/progress/86) |
| Hungarian (Magyar) (hu_HU) | ![86%](https://geps.dev/progress/86) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![85%](https://geps.dev/progress/85) |
| Irish (Gaeilge) (ga_IE) | ![86%](https://geps.dev/progress/86) |
| Italian (Italiano) (it_IT) | ![85%](https://geps.dev/progress/85) |
| Japanese (日本語) (ja_JP) | ![86%](https://geps.dev/progress/86) |
| Korean (한국어) (ko_KR) | ![86%](https://geps.dev/progress/86) |
| Norwegian (Norsk) (no_NB) | ![86%](https://geps.dev/progress/86) |
| Persian (فارسی) (fa_IR) | ![86%](https://geps.dev/progress/86) |
| Polish (Polski) (pl_PL) | ![86%](https://geps.dev/progress/86) |
| Portuguese (Português) (pt_PT) | ![86%](https://geps.dev/progress/86) |
| Portuguese Brazilian (Português) (pt_BR) | ![86%](https://geps.dev/progress/86) |
| Romanian (Română) (ro_RO) | ![85%](https://geps.dev/progress/85) |
| Russian (Русский) (ru_RU) | ![86%](https://geps.dev/progress/86) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![86%](https://geps.dev/progress/86) |
| Simplified Chinese (简体中文) (zh_CN) | ![87%](https://geps.dev/progress/87) |
| Slovakian (Slovensky) (sk_SK) | ![86%](https://geps.dev/progress/86) |
| Slovenian (Slovenščina) (sl_SI) | ![86%](https://geps.dev/progress/86) |
| Spanish (Español) (es_ES) | ![86%](https://geps.dev/progress/86) |
| Swedish (Svenska) (sv_SE) | ![86%](https://geps.dev/progress/86) |
| Thai (ไทย) (th_TH) | ![86%](https://geps.dev/progress/86) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![87%](https://geps.dev/progress/87) |
| Turkish (Türkçe) (tr_TR) | ![86%](https://geps.dev/progress/86) |
| Ukrainian (Українська) (uk_UA) | ![86%](https://geps.dev/progress/86) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![86%](https://geps.dev/progress/86) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
Stirling PDF offers an Enterprise edition of its software. This is the same great software but with added features, support and comforts.
Check out our [Enterprise docs](https://docs.stirlingpdf.com/Pro)
## 🤝 Looking to contribute?
Join our community:
- [Contribution Guidelines](CONTRIBUTING.md)
- [Translation Guide (How to add custom languages)](devGuide/HowToAddNewLanguage.md)
- [Developer Guide](devGuide/DeveloperGuide.md)
- [Issue Tracker](https://github.com/Stirling-Tools/Stirling-PDF/issues)
- [Discord Community](https://discord.gg/HYmhKj45pU)
Stirling PDF is open-core. See [LICENSE](LICENSE) for details.
@@ -112,7 +112,6 @@ public class ApplicationProperties {
@Data
public static class Security {
private Boolean enableLogin;
private Boolean csrfDisabled;
private InitialLogin initialLogin = new InitialLogin();
private OAUTH2 oauth2 = new OAUTH2();
private SAML2 saml2 = new SAML2();
@@ -575,6 +574,16 @@ public class ApplicationProperties {
private String username;
@ToString.Exclude private String password;
private String from;
// STARTTLS upgrades a plain SMTP connection to TLS after connecting (RFC 3207)
private Boolean startTlsEnable = true;
private Boolean startTlsRequired;
// SSL/TLS wrapper for implicit TLS (typically port 465)
private Boolean sslEnable;
// Hostnames or patterns (e.g., "smtp.example.com" or "*") to trust for TLS certificates;
// defaults to "*" (trust all) when not set
private String sslTrust;
// Enables hostname verification for TLS connections
private Boolean sslCheckServerIdentity;
}
@Data
@@ -254,10 +254,7 @@ public class PostHogService {
properties,
"security_enableLogin",
applicationProperties.getSecurity().getEnableLogin());
addIfNotEmpty(
properties,
"security_csrfDisabled",
applicationProperties.getSecurity().getCsrfDisabled());
addIfNotEmpty(properties, "security_csrfDisabled", true);
addIfNotEmpty(
properties,
"security_loginAttemptCount",
@@ -39,6 +39,7 @@ 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")) {
@@ -161,10 +162,9 @@ public class RequestUriUtils {
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
|| trimmedUri.contains("/v1/api-docs");
|| trimmedUri.startsWith("/v1/api-docs");
}
private static String stripContextPath(String contextPath, String requestURI) {
@@ -34,7 +34,6 @@ public class InitialSetup {
public void init() throws IOException {
initUUIDKey();
initSecretKey();
initEnableCSRFSecurity();
initLegalUrls();
initSetAppVersion();
GeneralUtils.extractPipeline();
@@ -60,18 +59,6 @@ public class InitialSetup {
}
}
public void initEnableCSRFSecurity() throws IOException {
if (GeneralUtils.isVersionHigher(
"0.46.0", applicationProperties.getAutomaticallyGenerated().getAppVersion())) {
Boolean csrf = applicationProperties.getSecurity().getCsrfDisabled();
if (!csrf) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", false);
GeneralUtils.saveKeyToSettings("system.enableAnalytics", true);
applicationProperties.getSecurity().setCsrfDisabled(false);
}
}
}
public void initLegalUrls() throws IOException {
// Initialize Terms and Conditions
String termsUrl = applicationProperties.getLegal().getTermsAndConditions();
@@ -95,7 +82,7 @@ public class InitialSetup {
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
|| "0.0.0".equals(existingVersion);
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
@@ -62,10 +62,15 @@ public class OpenApiConfig {
// Add server configuration from environment variable
String swaggerServerUrl = System.getenv("SWAGGER_SERVER_URL");
Server server;
if (swaggerServerUrl != null && !swaggerServerUrl.trim().isEmpty()) {
Server server = new Server().url(swaggerServerUrl).description("API Server");
openAPI.addServersItem(server);
server = new Server().url(swaggerServerUrl).description("API Server");
} else {
// Use relative path so Swagger uses the current browser origin to avoid CORS issues
// when accessing via different ports
server = new Server().url("/").description("Current Server");
}
openAPI.addServersItem(server);
// Add ErrorResponse schema to components
Schema<?> errorResponseSchema =
@@ -1,10 +1,14 @@
package stirling.software.SPDF.config;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.CacheControl;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
@@ -25,6 +29,20 @@ public class WebMvcConfig implements WebMvcConfigurer {
registry.addInterceptor(endpointInterceptor);
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
// Cache hashed assets (JS/CSS with content hashes) for 1 year
// These files have names like index-ChAS4tCC.js that change when content changes
registry.addResourceHandler("/assets/**")
.addResourceLocations("classpath:/static/assets/")
.setCacheControl(CacheControl.maxAge(365, TimeUnit.DAYS).cachePublic());
// Don't cache index.html - it needs to be fresh to reference latest hashed assets
registry.addResourceHandler("/index.html")
.addResourceLocations("classpath:/static/")
.setCacheControl(CacheControl.noCache().mustRevalidate());
}
@Override
public void addCorsMappings(CorsRegistry registry) {
// Check if running in Tauri mode
@@ -124,7 +124,6 @@ public class SettingsController {
ApplicationProperties.Security security = applicationProperties.getSecurity();
settings.put("enableLogin", security.getEnableLogin());
settings.put("csrfDisabled", security.getCsrfDisabled());
settings.put("loginMethod", security.getLoginMethod());
settings.put("loginAttemptCount", security.getLoginAttemptCount());
settings.put("loginResetTimeMinutes", security.getLoginResetTimeMinutes());
@@ -159,12 +158,6 @@ public class SettingsController {
.getSecurity()
.setEnableLogin((Boolean) settings.get("enableLogin"));
}
if (settings.containsKey("csrfDisabled")) {
GeneralUtils.saveKeyToSettings("security.csrfDisabled", settings.get("csrfDisabled"));
applicationProperties
.getSecurity()
.setCsrfDisabled((Boolean) settings.get("csrfDisabled"));
}
if (settings.containsKey("loginMethod")) {
GeneralUtils.saveKeyToSettings("security.loginMethod", settings.get("loginMethod"));
applicationProperties
@@ -31,12 +31,10 @@ import stirling.software.common.model.api.PDFFile;
import stirling.software.common.service.JobOwnershipService;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.security.config.PremiumEndpoint;
@Slf4j
@ConvertApi
@RequiredArgsConstructor
@PremiumEndpoint
public class ConvertPdfJsonController {
private final PdfJsonConversionService pdfJsonConversionService;
@@ -74,6 +74,7 @@ public class ConfigController {
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
@@ -1,20 +1,88 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import jakarta.annotation.PostConstruct;
import jakarta.servlet.http.HttpServletRequest;
@Controller
public class ReactRoutingController {
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
@Value("${server.servlet.context-path:/}")
private String contextPath;
private String cachedIndexHtml;
private boolean indexHtmlExists = false;
@PostConstruct
public void init() {
// Only cache if index.html exists (production builds)
ClassPathResource resource = new ClassPathResource("static/index.html");
if (resource.exists()) {
try {
this.cachedIndexHtml = processIndexHtml();
this.indexHtmlExists = true;
} catch (IOException e) {
// Failed to cache, will process on each request
this.indexHtmlExists = false;
}
}
}
private String processIndexHtml() throws IOException {
ClassPathResource resource = new ClassPathResource("static/index.html");
try (InputStream inputStream = resource.getInputStream()) {
String html = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
// Replace %BASE_URL% with the actual context path for base href
String baseUrl = contextPath.endsWith("/") ? contextPath : contextPath + "/";
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
html.replaceFirst(
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
"<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
"<script>window.STIRLING_PDF_API_BASE_URL = '" + baseUrl + "';</script>";
html = html.replace("</head>", contextPathScript + "</head>");
return html;
}
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
value = {"/", "/index.html"},
produces = MediaType.TEXT_HTML_VALUE)
public ResponseEntity<String> serveIndexHtml(HttpServletRequest request) throws IOException {
if (indexHtmlExists && cachedIndexHtml != null) {
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(cachedIndexHtml);
}
// Fallback: process on each request (dev mode or cache failed)
return ResponseEntity.ok().contentType(MediaType.TEXT_HTML).body(processIndexHtml());
}
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|manifest.*\\.json|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*$}")
public ResponseEntity<String> forwardRootPaths(HttpServletRequest request) throws IOException {
return serveIndexHtml(request);
}
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js|assets|locales|modern-logo|classic-logo|Login|og_images|samples)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public ResponseEntity<String> forwardNestedPaths(HttpServletRequest request)
throws IOException {
return serveIndexHtml(request);
}
}
@@ -19,9 +19,9 @@ import stirling.software.common.service.UserServiceInterface;
/**
* Unified signature image controller that works for both authenticated and unauthenticated users.
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared signatures -
* PersonalSignatureService (proprietary, optional): reads personal signatures For authenticated
* signature management (save/delete), see proprietary SignatureController.
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
* authenticated signature management (save/delete), see proprietary SignatureController.
*/
@Slf4j
@RestController
@@ -33,8 +33,12 @@ public class PdfJsonFallbackFontService {
public static final String FALLBACK_FONT_CJK_ID = "fallback-noto-cjk";
public static final String FALLBACK_FONT_JP_ID = "fallback-noto-jp";
public static final String FALLBACK_FONT_KR_ID = "fallback-noto-korean";
public static final String FALLBACK_FONT_TC_ID = "fallback-noto-tc";
public static final String FALLBACK_FONT_AR_ID = "fallback-noto-arabic";
public static final String FALLBACK_FONT_TH_ID = "fallback-noto-thai";
public static final String FALLBACK_FONT_DEVANAGARI_ID = "fallback-noto-devanagari";
public static final String FALLBACK_FONT_MALAYALAM_ID = "fallback-noto-malayalam";
public static final String FALLBACK_FONT_TIBETAN_ID = "fallback-noto-tibetan";
// Font name aliases map PDF font names to available fallback fonts
// This provides better visual consistency when editing PDFs
@@ -59,6 +63,22 @@ public class PdfJsonFallbackFontService {
Map.entry("dejavuserif", "fallback-dejavu-serif"),
Map.entry("dejavumono", "fallback-dejavu-mono"),
Map.entry("dejavusansmono", "fallback-dejavu-mono"),
// Traditional Chinese fonts (Taiwan, Hong Kong, Macau)
Map.entry("mingliu", "fallback-noto-tc"),
Map.entry("pmingliu", "fallback-noto-tc"),
Map.entry("microsoftjhenghei", "fallback-noto-tc"),
Map.entry("jhenghei", "fallback-noto-tc"),
Map.entry("kaiti", "fallback-noto-tc"),
Map.entry("kaiu", "fallback-noto-tc"),
Map.entry("dfkaib5", "fallback-noto-tc"),
Map.entry("dfkai", "fallback-noto-tc"),
// Simplified Chinese fonts (Mainland China) - more common
Map.entry("simsun", "fallback-noto-cjk"),
Map.entry("simhei", "fallback-noto-cjk"),
Map.entry("microsoftyahei", "fallback-noto-cjk"),
Map.entry("yahei", "fallback-noto-cjk"),
Map.entry("songti", "fallback-noto-cjk"),
Map.entry("heiti", "fallback-noto-cjk"),
// Noto Sans - Google's universal font (use as last resort generic fallback)
Map.entry("noto", "fallback-noto-sans"),
Map.entry("notosans", "fallback-noto-sans"));
@@ -83,6 +103,12 @@ public class PdfJsonFallbackFontService {
"classpath:/static/fonts/NotoSansKR-Regular.ttf",
"NotoSansKR-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_TC_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansTC-Regular.ttf",
"NotoSansTC-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_AR_ID,
new FallbackFontSpec(
@@ -95,6 +121,24 @@ public class PdfJsonFallbackFontService {
"classpath:/static/fonts/NotoSansThai-Regular.ttf",
"NotoSansThai-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_DEVANAGARI_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansDevanagari-Regular.ttf",
"NotoSansDevanagari-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_MALAYALAM_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSansMalayalam-Regular.ttf",
"NotoSansMalayalam-Regular",
"ttf")),
Map.entry(
FALLBACK_FONT_TIBETAN_ID,
new FallbackFontSpec(
"classpath:/static/fonts/NotoSerifTibetan-Regular.ttf",
"NotoSerifTibetan-Regular",
"ttf")),
// Liberation Sans family
Map.entry(
"fallback-liberation-sans",
@@ -484,6 +528,20 @@ public class PdfJsonFallbackFontService {
*/
public String resolveFallbackFontId(int codePoint) {
Character.UnicodeBlock block = Character.UnicodeBlock.of(codePoint);
// Bopomofo is primarily used in Taiwan for Traditional Chinese phonetic annotation
if (block == Character.UnicodeBlock.BOPOMOFO
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED) {
return FALLBACK_FONT_TC_ID;
}
// Compatibility ideographs are primarily used by Traditional Chinese encodings (e.g., Big5,
// HKSCS) so prefer the Traditional Chinese fallback here.
if (block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT) {
return FALLBACK_FONT_TC_ID;
}
if (block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B
@@ -492,19 +550,23 @@ public class PdfJsonFallbackFontService {
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E
|| block == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F
|| block == Character.UnicodeBlock.CJK_SYMBOLS_AND_PUNCTUATION
|| block == Character.UnicodeBlock.BOPOMOFO
|| block == Character.UnicodeBlock.BOPOMOFO_EXTENDED
|| block == Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) {
return FALLBACK_FONT_CJK_ID;
}
Character.UnicodeScript script = Character.UnicodeScript.of(codePoint);
return switch (script) {
// HAN script is used by both Simplified and Traditional Chinese
// Default to Simplified (mainland China, 1.4B speakers) as it's more common
// Traditional Chinese PDFs are detected via font name aliases (MingLiU, PMingLiU, etc.)
case HAN -> FALLBACK_FONT_CJK_ID;
case HIRAGANA, KATAKANA -> FALLBACK_FONT_JP_ID;
case HANGUL -> FALLBACK_FONT_KR_ID;
case ARABIC -> FALLBACK_FONT_AR_ID;
case THAI -> FALLBACK_FONT_TH_ID;
case DEVANAGARI -> FALLBACK_FONT_DEVANAGARI_ID;
case MALAYALAM -> FALLBACK_FONT_MALAYALAM_ID;
case TIBETAN -> FALLBACK_FONT_TIBETAN_ID;
default -> FALLBACK_FONT_ID;
};
}
@@ -179,7 +179,7 @@ public class SharedSignatureService {
StandardOpenOption.TRUNCATE_EXISTING);
// Store reference to image file
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
}
log.info("Saved signature {} for user {}", request.getId(), username);
@@ -207,7 +207,7 @@ public class SharedSignatureService {
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("personal");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
@@ -238,7 +238,7 @@ public class SharedSignatureService {
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("shared");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
@@ -12,7 +12,6 @@
security:
enableLogin: true # set to 'true' to enable login
csrfDisabled: false # set to 'true' to disable CSRF protection (not recommended for production)
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
@@ -105,6 +104,11 @@ mail:
username: '' # SMTP server username
password: '' # SMTP server password
from: '' # sender email address
startTlsEnable: true # enable STARTTLS (explicit TLS upgrade after connecting) when supported by the SMTP server
startTlsRequired: false # require STARTTLS; connection fails if the upgrade command is not supported
sslEnable: false # enable SSL/TLS wrapper for implicit TLS (typically used with port 465)
sslTrust: '' # optional trusted host override, e.g. "smtp.example.com" or "*"; defaults to "*" (trust all) when empty
sslCheckServerIdentity: false # enable hostname verification when using SSL/TLS
legal:
termsAndConditions: https://www.stirling.com/legal/terms-of-service # URL to the terms and conditions of your application (e.g. https://example.com/terms). Empty string to disable or filename to load from local file in static folder
@@ -205,6 +205,10 @@ public class ProprietaryUIDataController {
data.setLoginMethod(securityProps.getLoginMethod());
data.setAltLogin(!providerList.isEmpty() && securityProps.isAltLogin());
// Add language configuration for login page
data.setLanguages(applicationProperties.getUi().getLanguages());
data.setDefaultLocale(applicationProperties.getSystem().getDefaultLocale());
return ResponseEntity.ok(data);
}
@@ -328,6 +332,7 @@ public class ProprietaryUIDataController {
data.setGrandfatheredUserCount(grandfatheredCount);
data.setLicenseMaxUsers(licenseMaxUsers);
data.setPremiumEnabled(premiumEnabled);
data.setMailEnabled(applicationProperties.getMail().isEnabled());
return ResponseEntity.ok(data);
}
@@ -376,7 +381,7 @@ public class ProprietaryUIDataController {
data.setUsername(username);
data.setRole(user.get().getRolesAsString());
data.setSettings(settingsJson);
data.setChangeCredsFlag(user.get().isFirstLogin());
data.setChangeCredsFlag(user.get().isFirstLogin() || user.get().isForcePasswordChange());
data.setOAuth2Login(isOAuth2Login);
data.setSaml2Login(isSaml2Login);
@@ -491,6 +496,8 @@ public class ProprietaryUIDataController {
private boolean altLogin;
private boolean firstTimeSetup;
private boolean showDefaultCredentials;
private List<String> languages;
private String defaultLocale;
}
@Data
@@ -510,6 +517,7 @@ public class ProprietaryUIDataController {
private int grandfatheredUserCount;
private int licenseMaxUsers;
private boolean premiumEnabled;
private boolean mailEnabled;
}
@Data
@@ -1,7 +1,12 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -18,6 +23,7 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
import stirling.software.proprietary.security.service.UserService;
@@ -38,6 +44,7 @@ public class SignatureController {
private final SignatureService signatureService;
private final UserService userService;
private static final String ALL_USERS_FOLDER = "ALL_USERS";
/**
* Save a new signature for the authenticated user. Enforces storage limits and authentication
@@ -84,19 +91,105 @@ public class SignatureController {
}
/**
* Delete a signature owned by the authenticated user. Users can only delete their own personal
* signatures, not shared ones.
* Update a signature label. Users can update labels for their own personal signatures and for
* shared signatures.
*/
@PostMapping("/{signatureId}/label")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> updateSignatureLabel(
@PathVariable String signatureId, @RequestBody Map<String, String> body) {
try {
String username = userService.getCurrentUsername();
String newLabel = body.get("label");
if (newLabel == null || newLabel.trim().isEmpty()) {
log.warn("Invalid label update request");
return ResponseEntity.badRequest().build();
}
signatureService.updateSignatureLabel(username, signatureId, newLabel);
log.info("User {} updated label for signature {}", username, signatureId);
return ResponseEntity.noContent().build();
} catch (IOException e) {
log.warn("Failed to update signature label: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
/**
* Delete a signature owned by the authenticated user. Users can delete their own personal
* signatures. Admins can also delete shared signatures.
*/
@DeleteMapping("/{signatureId}")
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
try {
String username = userService.getCurrentUsername();
signatureService.deleteSignature(username, signatureId);
log.info("User {} deleted signature {}", username, signatureId);
return ResponseEntity.noContent().build();
boolean isAdmin = userService.isCurrentUserAdmin();
// Validate filename to prevent path traversal
if (signatureId.contains("..")
|| signatureId.contains("/")
|| signatureId.contains("\\")) {
log.warn("Invalid signature ID: {}", signatureId);
return ResponseEntity.badRequest().build();
}
// Try to delete from personal folder first
try {
signatureService.deleteSignature(username, signatureId);
log.info("User {} deleted personal signature {}", username, signatureId);
return ResponseEntity.noContent().build();
} catch (IOException e) {
// If not found in personal folder, check if it's in shared folder
if (isAdmin) {
// Admin can delete from shared folder
if (deleteFromSharedFolder(signatureId)) {
log.info("Admin {} deleted shared signature {}", username, signatureId);
return ResponseEntity.noContent().build();
}
}
// If not admin or not found in shared folder either, return 404
throw e;
}
} catch (IOException e) {
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
/**
* Delete a signature from the shared (ALL_USERS) folder. Only admins should call this method.
*/
private boolean deleteFromSharedFolder(String signatureId) throws IOException {
String signatureBasePath = InstallationPathConfig.getSignaturesPath();
Path sharedFolder = Paths.get(signatureBasePath, ALL_USERS_FOLDER);
boolean deleted = false;
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
log.info("Deleted shared signature file: {}", file);
}
}
// Also delete metadata file if it exists
Path metadataPath = sharedFolder.resolve(signatureId + ".json");
if (Files.exists(metadataPath)) {
Files.delete(metadataPath);
log.info("Deleted shared signature metadata: {}", metadataPath);
}
}
return deleted;
}
}
@@ -33,7 +33,8 @@ public class MailConfig {
// Creates a new instance of JavaMailSenderImpl, which is a Spring implementation
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailProperties.getHost());
String host = mailProperties.getHost();
mailSender.setHost(host);
mailSender.setPort(mailProperties.getPort());
mailSender.setDefaultEncoding("UTF-8");
@@ -70,8 +71,32 @@ public class MailConfig {
log.info("SMTP authentication disabled - no credentials provided");
}
boolean startTlsEnabled =
mailProperties.getStartTlsEnable() == null || mailProperties.getStartTlsEnable();
// Enables STARTTLS to encrypt the connection if supported by the SMTP server
props.put("mail.smtp.starttls.enable", "true");
props.put("mail.smtp.starttls.enable", Boolean.toString(startTlsEnabled));
if (mailProperties.getStartTlsRequired() != null) {
props.put(
"mail.smtp.starttls.required", mailProperties.getStartTlsRequired().toString());
}
if (mailProperties.getSslEnable() != null) {
props.put("mail.smtp.ssl.enable", mailProperties.getSslEnable().toString());
}
// Trust the configured host to allow STARTTLS with self-signed certificates
String sslTrust = mailProperties.getSslTrust();
if (sslTrust == null || sslTrust.trim().isEmpty()) {
sslTrust = "*";
}
if (sslTrust != null && !sslTrust.trim().isEmpty()) {
props.put("mail.smtp.ssl.trust", sslTrust);
}
if (mailProperties.getSslCheckServerIdentity() != null) {
props.put(
"mail.smtp.ssl.checkserveridentity",
mailProperties.getSslCheckServerIdentity().toString());
}
// Returns the configured mail sender, ready to send emails
return mailSender;
@@ -1,7 +1,6 @@
package stirling.software.proprietary.security.configuration;
import java.util.List;
import java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -25,8 +24,6 @@ import org.springframework.security.saml2.provider.service.web.authentication.Op
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
import org.springframework.security.web.savedrequest.NullRequestCache;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.web.cors.CorsConfiguration;
@@ -47,7 +44,6 @@ import stirling.software.proprietary.security.database.repository.PersistentLogi
import stirling.software.proprietary.security.filter.IPRateLimitingFilter;
import stirling.software.proprietary.security.filter.JwtAuthenticationFilter;
import stirling.software.proprietary.security.filter.UserAuthenticationFilter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationFailureHandler;
import stirling.software.proprietary.security.oauth2.CustomOAuth2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
@@ -198,74 +194,19 @@ public class SecurityConfiguration {
http.cors(cors -> cors.disable());
}
if (securityProperties.getCsrfDisabled() || !loginEnabledValue) {
http.csrf(CsrfConfigurer::disable);
}
http.csrf(CsrfConfigurer::disable);
if (loginEnabledValue) {
boolean v2Enabled = appConfig.v2Enabled();
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
if (!securityProperties.getCsrfDisabled()) {
CookieCsrfTokenRepository cookieRepo =
CookieCsrfTokenRepository.withHttpOnlyFalse();
CsrfTokenRequestAttributeHandler requestHandler =
new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName(null);
http.csrf(
csrf ->
csrf.ignoringRequestMatchers(
request -> {
String uri = request.getRequestURI();
// Ignore CSRF for auth endpoints
if (uri.startsWith("/api/v1/auth/")) {
return true;
}
String apiKey = request.getHeader("X-API-KEY");
// If there's no API key, don't ignore CSRF
// (return false)
if (apiKey == null || apiKey.trim().isEmpty()) {
return false;
}
// Validate API key using existing UserService
try {
Optional<User> user =
userService.getUserByApiKey(apiKey);
// If API key is valid, ignore CSRF (return
// true)
// If API key is invalid, don't ignore CSRF
// (return false)
return user.isPresent();
} catch (Exception e) {
// If there's any error validating the API
// key, don't ignore CSRF
return false;
}
})
.csrfTokenRepository(cookieRepo)
.csrfTokenRequestHandler(requestHandler));
}
http.sessionManagement(
sessionManagement -> {
if (v2Enabled) {
sessionManagement ->
sessionManagement.sessionCreationPolicy(
SessionCreationPolicy.STATELESS);
} else {
sessionManagement
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(10)
.maxSessionsPreventsLogin(false)
.sessionRegistry(sessionRegistry)
.expiredUrl("/login?logout=true");
}
});
SessionCreationPolicy.STATELESS));
http.authenticationProvider(daoAuthenticationProvider());
http.requestCache(requestCache -> requestCache.requestCache(new NullRequestCache()));
@@ -324,10 +265,16 @@ public class SecurityConfiguration {
.authenticated());
// Handle User/Password Logins
if (securityProperties.isUserPass()) {
// v2: Authentication is handled via API (/api/v1/auth/login), not form login
// We configure form login to handle Spring Security redirects,
// but use /perform_login as the processing URL so /login remains a React route
http.formLogin(
formLogin ->
formLogin
.loginPage("/login")
.loginPage("/login") // Redirect here when unauthenticated
.loginProcessingUrl(
"/perform_login") // Process form posts here (not
// /login)
.successHandler(
new CustomAuthenticationSuccessHandler(
loginAttemptService,
@@ -342,18 +289,7 @@ public class SecurityConfiguration {
if (securityProperties.isOauth2Active()) {
http.oauth2Login(
oauth2 -> {
// v1: Use /oauth2 as login page for Thymeleaf templates
if (!v2Enabled) {
oauth2.loginPage("/oauth2");
}
// v2: Don't set loginPage, let default OAuth2 flow handle it
oauth2
/*
This Custom handler is used to check if the OAUTH2 user trying to log in, already exists in the database.
If user exists, login proceeds as usual. If user does not exist, then it is auto-created but only if 'OAUTH2AutoCreateUser'
is set as true, else login fails with an error message advising the same.
*/
oauth2.loginPage("/login")
.successHandler(
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
@@ -387,12 +323,8 @@ public class SecurityConfiguration {
.saml2Login(
saml2 -> {
try {
// Only set login page for v1/Thymeleaf mode
if (!v2Enabled) {
saml2.loginPage("/saml2");
}
saml2.relyingPartyRegistrationRepository(
saml2.loginPage("/login")
.relyingPartyRegistrationRepository(
saml2RelyingPartyRegistrations)
.authenticationManager(
new ProviderManager(authenticationProvider))
@@ -283,7 +283,12 @@ public class AdminLicenseController {
// Prevent path traversal and enforce single filename component
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Filename must not contain path separators or '..'"));
.body(
Map.of(
"success",
false,
"error",
"Filename must not contain path separators or '..'"));
}
// Validate file extension
@@ -7,6 +7,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -18,6 +19,7 @@ import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.*;
import jakarta.mail.MessagingException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.transaction.Transactional;
@@ -236,6 +238,8 @@ public class UserController {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "incorrectPassword", "message", "Incorrect password"));
}
// Set flags before changing password so they're saved together
user.setForcePasswordChange(false);
userService.changePassword(user, newPassword);
userService.changeFirstUse(user, false);
// Logout using Spring's utility
@@ -584,6 +588,79 @@ public class UserController {
return ResponseEntity.ok(Map.of("message", "User role updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changePasswordForUser")
public ResponseEntity<?> changePasswordForUser(
@RequestParam(name = "username") String username,
@RequestParam(name = "newPassword", required = false) String newPassword,
@RequestParam(name = "generateRandom", defaultValue = "false") boolean generateRandom,
@RequestParam(name = "sendEmail", defaultValue = "false") boolean sendEmail,
@RequestParam(name = "includePassword", defaultValue = "false") boolean includePassword,
@RequestParam(name = "forcePasswordChange", defaultValue = "false")
boolean forcePasswordChange,
HttpServletRequest request,
Authentication authentication)
throws SQLException, UnsupportedProviderException, MessagingException {
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("error", "User not found."));
}
String currentUsername = authentication.getName();
if (currentUsername.equalsIgnoreCase(username)) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Cannot change your own password."));
}
User user = userOpt.get();
String finalPassword = newPassword;
if (generateRandom) {
finalPassword = UUID.randomUUID().toString().replace("-", "").substring(0, 12);
}
if (finalPassword == null || finalPassword.trim().isEmpty()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "New password is required."));
}
// Set force password change flag before changing password so both are saved together
user.setForcePasswordChange(forcePasswordChange);
userService.changePassword(user, finalPassword);
// Invalidate all active sessions to force reauthentication
userService.invalidateUserSessions(username);
if (sendEmail) {
if (emailService.isEmpty() || !applicationProperties.getMail().isEnabled()) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", "Email is not configured."));
}
String userEmail = user.getUsername();
// Check if username is a valid email format
if (userEmail == null || userEmail.isBlank() || !userEmail.contains("@")) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(
Map.of(
"error",
"User's email is not a valid email address. Notifications are disabled."));
}
String loginUrl = buildLoginUrl(request);
emailService
.get()
.sendPasswordChangedNotification(
userEmail,
user.getUsername(),
includePassword ? finalPassword : null,
loginUrl);
}
return ResponseEntity.ok(Map.of("message", "User password updated successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/changeUserEnabled/{username}")
public ResponseEntity<?> changeUserEnabled(
@@ -56,6 +56,19 @@ public interface UserRepository extends JpaRepository<User, Long> {
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')")
List<User> findAllSsoUsers();
/**
* Finds SSO users who have never created a session (pending activation) and are not yet
* grandfathered.
*/
@Query(
"SELECT u FROM User u "
+ "LEFT JOIN SessionEntity s ON u.username = s.principalName "
+ "WHERE (u.ssoProvider IS NOT NULL "
+ "OR LOWER(u.authenticationType) IN ('sso', 'oauth2', 'saml2')) "
+ "AND (u.oauthGrandfathered IS NULL OR u.oauthGrandfathered = false) "
+ "AND s.sessionId IS NULL")
List<User> findPendingSsoUsersWithoutSession();
/**
* Counts all SSO users - those with sso_provider set OR authenticationType is sso/oauth2/saml2.
*/
@@ -26,6 +26,7 @@ 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;
@@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtServiceInterface jwtService;
@@ -47,19 +49,6 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final AuthenticationEntryPoint authenticationEntryPoint;
private final ApplicationProperties.Security securityProperties;
public JwtAuthenticationFilter(
JwtServiceInterface jwtService,
UserService userService,
CustomUserDetailsService userDetailsService,
AuthenticationEntryPoint authenticationEntryPoint,
ApplicationProperties.Security securityProperties) {
this.jwtService = jwtService;
this.userService = userService;
this.userDetailsService = userDetailsService;
this.authenticationEntryPoint = authenticationEntryPoint;
this.securityProperties = securityProperties;
}
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
@@ -68,7 +57,11 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
return;
}
if (isStaticResource(request.getContextPath(), request.getRequestURI())) {
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
if (isStaticResource(contextPath, requestURI)) {
filterChain.doFilter(request, response);
return;
}
@@ -77,10 +70,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Allow specific auth endpoints to pass through without JWT
String requestURI = request.getRequestURI();
String contextPath = request.getContextPath();
// Allow auth endpoints to pass through without JWT
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
@@ -105,22 +95,18 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
}
try {
log.debug("Validating JWT token");
jwtService.validateToken(jwtToken);
log.debug("JWT token validated successfully");
} catch (AuthenticationFailureException e) {
log.warn("JWT validation failed: {}", e.getMessage());
log.debug("JWT validation failed: {}", e.getMessage());
handleAuthenticationFailure(request, response, e);
return;
}
Map<String, Object> claims = jwtService.extractClaims(jwtToken);
String tokenUsername = claims.get("sub").toString();
log.debug("JWT token username: {}", tokenUsername);
try {
authenticate(request, claims);
log.debug("Authentication successful for user: {}", tokenUsername);
} catch (SQLException | UnsupportedProviderException e) {
log.error("Error processing user authentication for user: {}", tokenUsername, e);
handleAuthenticationFailure(
@@ -241,24 +241,6 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
filterChain.doFilter(request, response);
}
private static boolean isPublicAuthEndpoint(String requestURI, String contextPath) {
// Remove context path from URI to normalize path matching
String trimmedUri =
requestURI.startsWith(contextPath)
? requestURI.substring(contextPath.length())
: requestURI;
// Public auth endpoints that don't require authentication
return trimmedUri.startsWith("/login")
|| trimmedUri.startsWith("/auth/")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith("/api/v1/auth/login")
|| trimmedUri.startsWith("/api/v1/auth/refresh")
|| trimmedUri.startsWith("/api/v1/auth/logout")
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
}
private enum UserLoginType {
USERDETAILS("UserDetails"),
OAUTH2USER("OAuth2User"),
@@ -59,6 +59,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "forcePasswordChange")
private Boolean forcePasswordChange = false;
@Column(name = "roleName")
private String roleName;
@@ -117,6 +120,14 @@ public class User implements UserDetails, Serializable {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public boolean isForcePasswordChange() {
return forcePasswordChange != null && forcePasswordChange;
}
public void setForcePasswordChange(boolean forcePasswordChange) {
this.forcePasswordChange = forcePasswordChange;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}
@@ -27,6 +27,7 @@ import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.exception.UnsupportedProviderException;
@@ -39,6 +40,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
@Slf4j
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler
extends SavedRequestAwareAuthenticationSuccessHandler {
@@ -77,12 +79,18 @@ public class CustomOAuth2AuthenticationSuccessHandler
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block OAuth login
log.warn(
"OAuth login blocked for existing user '{}' - not eligible (not grandfathered and no paid license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
log.warn(
"OAuth login blocked for new user '{}' - not eligible (no paid license for auto-creation)",
username);
response.sendRedirect(request.getContextPath() + "/logout?oAuth2RequiresLicense=true");
return;
}
@@ -67,10 +67,15 @@ public class OAuth2Configuration {
keycloakClientRegistration().ifPresent(registrations::add);
if (registrations.isEmpty()) {
log.error("No OAuth2 provider registered");
log.error("No OAuth2 provider registered - check your OAuth2 configuration");
throw new NoProviderFoundException("At least one OAuth2 provider must be configured.");
}
log.info(
"OAuth2 ClientRegistrationRepository created with {} provider(s): {}",
registrations.size(),
registrations.stream().map(ClientRegistration::getRegistrationId).toList());
return new InMemoryClientRegistrationRepository(registrations);
}
@@ -165,7 +170,6 @@ public class OAuth2Configuration {
githubClient.getUseAsUsername());
boolean isValid = validateProvider(github);
log.info("Initialised GitHub OAuth2 provider");
return isValid
? Optional.of(
@@ -208,7 +212,19 @@ public class OAuth2Configuration {
null,
null);
return !isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider)
boolean isValid =
!isStringEmpty(oidcProvider.getIssuer()) || validateProvider(oidcProvider);
if (isValid) {
log.info(
"Initialised OIDC OAuth2 provider: registrationId='{}', issuer='{}', redirectUri='{}'",
name,
oauth.getIssuer(),
REDIRECT_URI_PATH + name);
} else {
log.warn("OIDC OAuth2 provider validation failed - provider will not be registered");
}
return isValid
? Optional.of(
ClientRegistrations.fromIssuerLocation(oauth.getIssuer())
.registrationId(name)
@@ -217,7 +233,7 @@ public class OAuth2Configuration {
.scope(oidcProvider.getScopes())
.userNameAttributeName(oidcProvider.getUseAsUsername().getName())
.clientName(clientName)
.redirectUri(REDIRECT_URI_PATH + "oidc")
.redirectUri(REDIRECT_URI_PATH + name)
.authorizationGrantType(AUTHORIZATION_CODE)
.build())
: Optional.empty();
@@ -67,19 +67,25 @@ public class CustomSaml2AuthenticationSuccessHandler
boolean userExists = userService.usernameExistsIgnoreCase(username);
// Check if user is eligible for SAML (grandfathered or system has paid license)
// 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);
if (user != null && !licenseSettingsService.isOAuthEligible(user)) {
// User is not grandfathered and no paid license - block SAML login
if (user != null && !licenseSettingsService.isSamlEligible(user)) {
// User is not grandfathered and no ENTERPRISE license - block SAML login
log.warn(
"SAML2 login blocked for existing user '{}' - not eligible (not grandfathered and no ENTERPRISE license)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
}
} else if (!licenseSettingsService.isOAuthEligible(null)) {
// No existing user and no paid license -> block auto creation
} else if (!licenseSettingsService.isSamlEligible(null)) {
// No existing user and no ENTERPRISE license -> block auto creation
log.warn(
"SAML2 login blocked for new user '{}' - not eligible (no ENTERPRISE license for auto-creation)",
username);
response.sendRedirect(
request.getContextPath() + "/logout?saml2RequiresLicense=true");
return;
@@ -223,4 +223,54 @@ public class EmailService {
sendPlainEmail(to, subject, body, true);
}
@Async
public void sendPasswordChangedNotification(
String to, String username, String newPassword, String loginUrl)
throws MessagingException {
String subject = "Your Stirling PDF password has been updated";
String passwordSection =
newPassword == null
? ""
: """
<div style=\"background-color: #f8f9fa; border-left: 4px solid #007bff; padding: 15px; margin: 20px 0; border-radius: 4px;\">
<p style=\"margin: 0;\"><strong>Temporary Password:</strong> %s</p>
</div>
"""
.formatted(newPassword);
String body =
"""
<html><body style=\"margin: 0; padding: 0;\">
<div style=\"font-family: Arial, sans-serif; background-color: #f8f9fa; padding: 20px;\">
<div style=\"max-width: 600px; margin: auto; background-color: #ffffff; border-radius: 8px; overflow: hidden; border: 1px solid #e0e0e0;\">
<div style=\"text-align: center; padding: 20px; background-color: #222;\">
<img src=\"https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/main/docs/stirling-transparent.svg\" alt=\"Stirling PDF\" style=\"max-height: 60px;\">
</div>
<div style=\"padding: 30px; color: #333;\">
<h2 style=\"color: #222; margin-top: 0;\">Your password was changed</h2>
<p>Hello %s,</p>
<p>An administrator has updated the password for your Stirling PDF account.</p>
%s
<p>If you did not expect this change, please contact your administrator immediately.</p>
<div style=\"text-align: center; margin: 30px 0;\">
<a href=\"%s\" style=\"display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;\">Go to Stirling PDF</a>
</div>
<p style=\"font-size: 14px; color: #666;\">Or copy and paste this link in your browser:</p>
<div style=\"background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;\">
%s
</div>
</div>
<div style=\"text-align: center; padding: 15px; font-size: 12px; color: #777; background-color: #f0f0f0;\">
&copy; 2025 Stirling PDF. All rights reserved.
</div>
</div>
</div>
</body></html>
"""
.formatted(username, passwordSection, loginUrl, loginUrl);
sendPlainEmail(to, subject, body, true);
}
}
@@ -50,7 +50,6 @@ public class JwtService implements JwtServiceInterface {
KeyPersistenceServiceInterface keyPersistenceService) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
log.info("JwtService initialized");
}
@Override
@@ -256,11 +255,9 @@ public class JwtService implements JwtServiceInterface {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7); // Remove "Bearer " prefix
log.debug("JWT token extracted from Authorization header");
return token;
}
log.debug("No JWT token found in Authorization header");
return null;
}
@@ -283,10 +280,9 @@ public class JwtService implements JwtServiceInterface {
.parse(token)
.getHeader()
.get("kid");
log.debug("Extracted key ID from token: {}", keyId);
return keyId;
} catch (Exception e) {
log.warn("Failed to extract key ID from token header: {}", e.getMessage());
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
return null;
}
}
@@ -55,7 +55,6 @@ public class KeyPairCleanupService {
return;
}
log.info("Removing keys older than retention period");
removeKeys(eligibleKeys);
keyPersistenceService.refreshActiveKeyPair();
}
@@ -778,4 +778,30 @@ public class UserService implements UserServiceInterface {
return updated;
}
/**
* Grandfathers SSO users who have never created a session (invited/pending accounts). These
* users would otherwise be blocked when SSO requires a paid license despite existing before the
* policy change.
*
* @return Number of pending users updated
*/
@Transactional
public int grandfatherPendingSsoUsersWithoutSession() {
List<User> pendingUsers = userRepository.findPendingSsoUsersWithoutSession();
int updated = 0;
for (User user : pendingUsers) {
if (!user.isOauthGrandfathered()) {
user.setOauthGrandfathered(true);
updated++;
}
}
if (updated > 0) {
userRepository.saveAll(pendingUsers);
}
return updated;
}
}
@@ -2,6 +2,7 @@ package stirling.software.proprietary.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
@@ -13,6 +14,8 @@ import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
@@ -31,6 +34,7 @@ public class SignatureService implements PersonalSignatureServiceInterface {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper = new ObjectMapper();
// Storage limits per user
private static final int MAX_SIGNATURES_PER_USER = 20;
@@ -88,6 +92,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
response.setCreatedAt(timestamp);
response.setUpdatedAt(timestamp);
// Copy text signature properties if present
if ("text".equals(request.getType())) {
response.setSignerName(request.getSignerName());
response.setFontFamily(request.getFontFamily());
response.setFontSize(request.getFontSize());
response.setTextColor(request.getTextColor());
}
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
@@ -133,6 +145,19 @@ public class SignatureService implements PersonalSignatureServiceInterface {
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
}
// Save metadata JSON file
String metadataFileName = request.getId() + ".json";
Path metadataPath = targetFolder.resolve(metadataFileName);
verifyPathWithinDirectory(metadataPath, targetFolder);
String metadataJson = objectMapper.writeValueAsString(response);
Files.writeString(
metadataPath,
metadataJson,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope);
return response;
}
@@ -179,6 +204,13 @@ public class SignatureService implements PersonalSignatureServiceInterface {
log.info("Deleted signature file: {}", file);
}
}
// Also delete metadata file if it exists
Path metadataPath = personalFolder.resolve(signatureId + ".json");
if (Files.exists(metadataPath)) {
Files.delete(metadataPath);
log.info("Deleted signature metadata: {}", metadataPath);
}
}
if (!deleted) {
@@ -186,6 +218,50 @@ public class SignatureService implements PersonalSignatureServiceInterface {
}
}
/** Update a signature label. */
public void updateSignatureLabel(String username, String signatureId, String newLabel)
throws IOException {
validateFileName(signatureId);
// Try personal folder first
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
Path metadataPath = personalFolder.resolve(signatureId + ".json");
if (Files.exists(metadataPath)) {
updateMetadataLabel(metadataPath, newLabel);
log.info("Updated label for personal signature {} (user: {})", signatureId, username);
return;
}
// If not found in personal, try shared folder
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
Path sharedMetadataPath = sharedFolder.resolve(signatureId + ".json");
if (Files.exists(sharedMetadataPath)) {
updateMetadataLabel(sharedMetadataPath, newLabel);
log.info("Updated label for shared signature {}", signatureId);
return;
}
throw new FileNotFoundException("Signature metadata not found");
}
private void updateMetadataLabel(Path metadataPath, String newLabel) throws IOException {
String metadataJson = Files.readString(metadataPath, StandardCharsets.UTF_8);
SavedSignatureResponse sig =
objectMapper.readValue(metadataJson, SavedSignatureResponse.class);
sig.setLabel(newLabel);
sig.setUpdatedAt(System.currentTimeMillis());
String updatedJson = objectMapper.writeValueAsString(sig);
Files.writeString(
metadataPath,
updatedJson,
StandardCharsets.UTF_8,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
}
// Private helper methods
private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
@@ -245,16 +321,31 @@ public class SignatureService implements PersonalSignatureServiceInterface {
String fileName = path.getFileName().toString();
String id = fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id);
sig.setType("image");
sig.setScope(scope);
sig.setCreatedAt(Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(Files.getLastModifiedTime(path).toMillis());
// Try to load metadata from JSON file
Path metadataPath = folder.resolve(id + ".json");
SavedSignatureResponse sig;
// Set unified URL path (works for both personal and shared)
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
if (Files.exists(metadataPath)) {
// Load from metadata file
String metadataJson =
Files.readString(
metadataPath, StandardCharsets.UTF_8);
sig =
objectMapper.readValue(
metadataJson, SavedSignatureResponse.class);
} else {
// Fallback for old signatures without metadata
sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id);
sig.setType("image");
sig.setScope(scope);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
}
signatures.add(sig);
} catch (IOException e) {
@@ -21,6 +21,7 @@ import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.UserLicenseSettings;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.UserLicenseSettingsRepository;
import stirling.software.proprietary.security.service.UserService;
@@ -192,10 +193,18 @@ public class UserLicenseSettingsService {
+ "They will retain OAuth access even without a paid license. "
+ "New users will require a paid license for OAuth.",
updated);
} else if (grandfatheredCount > 0) {
log.debug(
"OAuth grandfathering already completed: {} users grandfathered",
grandfatheredCount);
}
// Grandfather pending users (invited but never logged in)
// The query filters to non-grandfathered users only, so this is idempotent
if (grandfatheredCount > 0 || oauthUsersCount > 0) {
int pendingUpdated = userService.grandfatherPendingSsoUsersWithoutSession();
if (pendingUpdated > 0) {
log.warn(
"OAuth GRANDFATHERING: Marked {} pending SSO users (no prior sessions) as"
+ " grandfathered.",
pendingUpdated);
}
}
}
}
@@ -335,17 +344,76 @@ public class UserLicenseSettingsService {
* @param user The user to check
* @return true if the user can use OAuth/SAML
*/
public boolean isOAuthEligible(stirling.software.proprietary.security.model.User user) {
public boolean isOAuthEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("OAuth eligibility check for user: {}", username);
// Grandfathered users always have OAuth access
if (user != null && user.isOauthGrandfathered()) {
log.debug("User {} is grandfathered for OAuth", user.getUsername());
return true;
}
// Users can use OAuth/SAML only if system has ENTERPRISE license
boolean hasEnterpriseLicense = hasEnterpriseLicense();
log.debug("OAuth eligibility check: hasEnterpriseLicense={}", hasEnterpriseLicense);
return hasEnterpriseLicense;
// todo: remove
if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting OAuth login - checking license requirement");
}
// Users can use OAuth with SERVER or ENTERPRISE license
boolean hasPaid = hasPaidLicense();
log.info(
"OAuth eligibility result: hasPaidLicense={}, user={}, eligible={}",
hasPaid,
username,
hasPaid);
return hasPaid;
}
/**
* Checks if a user is eligible to use SAML authentication.
*
* <p>A user is eligible if:
*
* <ul>
* <li>They are grandfathered for OAuth (existing user before policy change), OR
* <li>The system has an ENTERPRISE license (SAML is enterprise-only)
* </ul>
*
* @param user The user to check
* @return true if the user can use SAML
*/
public boolean isSamlEligible(User user) {
String username = (user != null) ? user.getUsername() : "<new user>";
log.info("SAML2 eligibility check for user: {}", username);
// Grandfathered users always have SAML access
if (user != null && user.isOauthGrandfathered()) {
log.info("User {} is grandfathered for SAML2 - ELIGIBLE", username);
return true;
}
if (user != null) {
log.info(
"User {} is NOT grandfathered (isOauthGrandfathered={})",
username,
user.isOauthGrandfathered());
} else {
log.info("New user attempting SAML2 login - checking license requirement");
}
// Users can use SAML only with ENTERPRISE license
boolean hasEnterprise = hasEnterpriseLicense();
log.info(
"SAML2 eligibility result: hasEnterpriseLicense={}, user={}, eligible={}",
hasEnterprise,
username,
hasEnterprise);
return hasEnterprise;
}
/**
@@ -487,8 +555,12 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
return license == License.SERVER || license == License.ENTERPRISE;
boolean hasPaid = (license == License.SERVER || license == License.ENTERPRISE);
log.info("License check result: type={}, requiresPaid=true, hasPaid={}", license, hasPaid);
return hasPaid;
}
/**
@@ -502,7 +574,19 @@ public class UserLicenseSettingsService {
if (checker == null) {
return false;
}
License license = checker.getPremiumLicenseEnabledResult();
log.info(
"License check result: type={}, requiresEnterprise=true, hasEnterprise={}",
license,
(license == License.ENTERPRISE));
if (license != License.ENTERPRISE) {
log.warn(
"SAML2 requires ENTERPRISE license but found: {}. SAML2 login will be blocked.",
license);
}
return license == License.ENTERPRISE;
}
}
@@ -0,0 +1,162 @@
package stirling.software.proprietary.security.oauth2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
/**
* Unit tests for OAuth2Configuration redirect URI logic.
*
* <p>These tests validate the critical fix for GitHub issue #5141: The redirect URI path segment
* MUST match the registration ID. Previously, the redirect URI was hardcoded to 'oidc', causing
* InvalidClientRegistrationIdException when custom provider names were used.
*
* <p>Note: These are conceptual tests documenting the expected behavior. Full integration testing
* with actual OIDC discovery would require: 1. Mock HTTP server for OIDC discovery endpoints 2.
* Valid OIDC configuration responses 3. Network mocking infrastructure
*/
class OAuth2ConfigurationTest {
/**
* Tests the redirect URI pattern for OIDC provider configurations.
*
* <p>Critical behavior (GitHub issue #5141 fix): The redirect URI path segment MUST match the
* registration ID. For example: - Provider name: "authentik" Redirect URI:
* "/login/oauth2/code/authentik" - Provider name: "mycompany" Redirect URI:
* "/login/oauth2/code/mycompany" - Provider name: "oidc" Redirect URI:
* "/login/oauth2/code/oidc"
*
* <p>Previously, the redirect URI was hardcoded to 'oidc', causing Spring Security to look for
* a registration with ID 'oidc' when the provider redirected back. This caused
* InvalidClientRegistrationIdException when custom provider names were used.
*/
@Test
void testRedirectUriPattern_usesProviderNameNotHardcodedOidc() {
// Verify the redirect URI pattern constant
String redirectUriBase = "{baseUrl}/login/oauth2/code/";
// Test cases: provider name expected redirect URI
String[][] testCases = {
{"authentik", redirectUriBase + "authentik"},
{"mycompany", redirectUriBase + "mycompany"},
{"oidc", redirectUriBase + "oidc"},
{"okta", redirectUriBase + "okta"},
{"auth0", redirectUriBase + "auth0"}
};
for (String[] testCase : testCases) {
String providerName = testCase[0];
String expectedRedirectUri = testCase[1];
// The fix ensures: .redirectUri(REDIRECT_URI_PATH + name)
// instead of: .redirectUri(REDIRECT_URI_PATH + "oidc")
String actualRedirectUri = redirectUriBase + providerName;
assertEquals(
expectedRedirectUri,
actualRedirectUri,
String.format(
"Redirect URI for provider '%s' must use provider name, not hardcoded 'oidc'",
providerName));
}
}
/**
* Documents the critical fix for OAuth2 redirect URI mismatch.
*
* <p>This test validates the logic that was changed in OAuth2Configuration.java line 220:
*
* <pre>
* // BEFORE (bug):
* .redirectUri(REDIRECT_URI_PATH + "oidc") // Always "oidc"
*
* // AFTER (fix):
* .redirectUri(REDIRECT_URI_PATH + name) // Dynamic provider name
* </pre>
*/
@Test
void testCriticalFix_redirectUriMatchesRegistrationId() {
// The redirect URI path segment extraction by Spring Security
String callbackUrl = "http://localhost:8080/login/oauth2/code/authentik?code=abc123";
// Spring extracts the path segment between "code/" and "?"
String extractedRegistrationId = extractRegistrationIdFromCallback(callbackUrl);
// The extracted ID MUST match an actual registration ID
assertEquals("authentik", extractedRegistrationId);
// If we had used hardcoded "oidc", the callback would be:
String buggyCallbackUrl = "http://localhost:8080/login/oauth2/code/oidc?code=abc123";
String buggyExtractedId = extractRegistrationIdFromCallback(buggyCallbackUrl);
// This would look for registration with ID "oidc" but we registered "authentik"
assertEquals("oidc", buggyExtractedId);
// The mismatch: registrationId="authentik", but Spring looks for "oidc"
// Result: InvalidClientRegistrationIdException
assertNotNull(buggyExtractedId, "This demonstrates the bug that was fixed");
}
/** Helper method simulating Spring's extraction of registration ID from callback URL */
private String extractRegistrationIdFromCallback(String callbackUrl) {
// Simplified version of what Spring Security does
// Actual: OAuth2AuthorizationRequestRedirectFilter extracts from path
String path = callbackUrl.split("\\?")[0];
String[] parts = path.split("/");
return parts[parts.length - 1]; // Last path segment
}
/**
* Validates the frontend-backend flow for custom provider names.
*
* <p>Complete flow: 1. Backend: Provider configured as "authentik" in settings.yml 2. Backend:
* ClientRegistration created with registrationId="authentik" 3. Backend: Redirect URI set to
* "{baseUrl}/login/oauth2/code/authentik" 4. Backend: Login endpoint returns providerList with
* "/oauth2/authorization/authentik" 5. Frontend: Extracts "authentik" from path and uses it for
* OAuth login 6. Frontend: Redirects to "/oauth2/authorization/authentik" 7. Backend: Spring
* Security redirects to provider with redirect_uri containing "authentik" 8. Provider:
* Redirects back to "/login/oauth2/code/authentik?code=..." 9. Backend: Spring Security
* extracts "authentik" from callback URL 10. Backend: Looks up ClientRegistration with ID
* "authentik" SUCCESS
*
* <p>If redirect URI was hardcoded to "oidc" (the bug): Step 7: Provider redirects to
* "/login/oauth2/code/oidc?code=..." Step 9: Spring Security looks for registration ID "oidc"
* Step 10: FAIL - No registration found with ID "oidc" (we registered "authentik") Result:
* InvalidClientRegistrationIdException
*/
@Test
void testEndToEndFlow_registrationIdConsistency() {
String providerName = "authentik";
// Step 2: Registration ID
String registrationId = providerName;
assertEquals("authentik", registrationId);
// Step 3: Redirect URI (MUST use same name)
String redirectUri = "{baseUrl}/login/oauth2/code/" + providerName;
assertEquals("{baseUrl}/login/oauth2/code/authentik", redirectUri);
// Step 4: Provider list endpoint
String authorizationPath = "/oauth2/authorization/" + providerName;
assertEquals("/oauth2/authorization/authentik", authorizationPath);
// Step 5: Frontend extracts provider ID
String frontendProviderId =
authorizationPath.substring(authorizationPath.lastIndexOf('/') + 1);
assertEquals("authentik", frontendProviderId);
// Step 6-8: OAuth flow (external)
// Step 9: Callback URL from provider
String callbackUrl =
"http://localhost:8080/login/oauth2/code/" + providerName + "?code=abc123";
String extractedId = extractRegistrationIdFromCallback(callbackUrl);
// Step 10: Registration lookup
assertEquals(
registrationId,
extractedId,
"Registration ID from callback MUST match original registration ID");
}
}
@@ -27,6 +27,11 @@ class MailConfigTest {
when(mailProps.getPort()).thenReturn(587);
when(mailProps.getUsername()).thenReturn("user@example.com");
when(mailProps.getPassword()).thenReturn("password");
when(mailProps.getStartTlsEnable()).thenReturn(null);
when(mailProps.getStartTlsRequired()).thenReturn(null);
when(mailProps.getSslEnable()).thenReturn(null);
when(mailProps.getSslTrust()).thenReturn(null);
when(mailProps.getSslCheckServerIdentity()).thenReturn(null);
}
@Test
@@ -50,6 +55,32 @@ class MailConfigTest {
() -> assertEquals("password", impl.getPassword()),
() -> assertEquals("UTF-8", impl.getDefaultEncoding()),
() -> assertEquals("true", props.getProperty("mail.smtp.auth")),
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.enable")));
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.enable")),
() -> assertEquals(null, props.getProperty("mail.smtp.starttls.required")),
() -> assertEquals(null, props.getProperty("mail.smtp.ssl.enable")),
() -> assertEquals("*", props.getProperty("mail.smtp.ssl.trust")));
}
@Test
void shouldRespectExplicitTlsOverrides() {
ApplicationProperties appProps = mock(ApplicationProperties.class);
when(mailProps.getStartTlsEnable()).thenReturn(false);
when(mailProps.getStartTlsRequired()).thenReturn(true);
when(mailProps.getSslEnable()).thenReturn(true);
when(mailProps.getSslTrust()).thenReturn("*");
when(mailProps.getSslCheckServerIdentity()).thenReturn(true);
when(appProps.getMail()).thenReturn(mailProps);
MailConfig config = new MailConfig(appProps);
JavaMailSenderImpl impl = (JavaMailSenderImpl) config.javaMailSender();
Properties props = impl.getJavaMailProperties();
assertAll(
() -> assertEquals("false", props.getProperty("mail.smtp.starttls.enable")),
() -> assertEquals("true", props.getProperty("mail.smtp.starttls.required")),
() -> assertEquals("true", props.getProperty("mail.smtp.ssl.enable")),
() -> assertEquals("*", props.getProperty("mail.smtp.ssl.trust")),
() -> assertEquals("true", props.getProperty("mail.smtp.ssl.checkserveridentity")));
}
}
@@ -2,6 +2,9 @@ package stirling.software.proprietary.service;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Optional;
@@ -198,4 +201,288 @@ class UserLicenseSettingsServiceTest {
assertEquals(5, result, "Should fall back to default 5 users if grandfathered is 0");
}
@Test
void grandfatherExistingOAuthUsers_runsOnlyWhenNoneGrandfathered() {
// With grandfatheredCount == 0, should run grandfathering for all users
when(userService.countOAuthUsers()).thenReturn(10L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
when(userService.grandfatherAllOAuthUsers()).thenReturn(10);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
service.grandfatherExistingOAuthUsers();
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_skipsMainButRunsPendingWhenSomeAlreadyGrandfathered() {
// V2V2.1 upgrade: some users already grandfathered, but pending users need to be checked
when(userService.countOAuthUsers()).thenReturn(10L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(4L);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(2);
service.grandfatherExistingOAuthUsers();
verify(userService, never()).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_stillChecksPendingWhenAllUsersGrandfathered() {
// All active users grandfathered, but still check for pending users
when(userService.countOAuthUsers()).thenReturn(10L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(10L);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(0);
service.grandfatherExistingOAuthUsers();
verify(userService, never()).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_skipsWhenNoOAuthUsers() {
when(userService.countOAuthUsers()).thenReturn(0L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
service.grandfatherExistingOAuthUsers();
verify(userService, never()).grandfatherAllOAuthUsers();
verify(userService, never()).grandfatherPendingSsoUsersWithoutSession();
}
@Test
void grandfatherExistingOAuthUsers_grandfathersPendingUsersOnFirstRun() {
// Pending users (invited but never logged in) should be grandfathered
// during the initial grandfathering run (when grandfatheredCount == 0)
when(userService.countOAuthUsers()).thenReturn(5L);
when(userService.countGrandfatheredOAuthUsers()).thenReturn(0L);
when(userService.grandfatherAllOAuthUsers()).thenReturn(5);
when(userService.grandfatherPendingSsoUsersWithoutSession()).thenReturn(3);
service.grandfatherExistingOAuthUsers();
verify(userService, times(1)).grandfatherAllOAuthUsers();
verify(userService, times(1)).grandfatherPendingSsoUsersWithoutSession();
}
// ===== OAuth Eligibility Tests =====
@Test
void isOAuthEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for OAuth");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithServerLicense_returnsTrue() {
// Non-grandfathered user with SERVER license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(user);
assertEquals(true, result, "Non-grandfathered user with SERVER license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isOAuthEligible(user);
assertEquals(
true, result, "Non-grandfathered user with ENTERPRISE license should be eligible");
}
@Test
void isOAuthEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without paid license should NOT be eligible");
}
@Test
void isOAuthEligible_newUserWithServerLicense_returnsTrue() {
// New user (null) with SERVER license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isOAuthEligible(null);
assertEquals(
true, result, "New user with SERVER license should be eligible for auto-creation");
}
@Test
void isOAuthEligible_newUserWithNoLicense_returnsFalse() {
// New user (null) without license should NOT be eligible
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isOAuthEligible(null);
assertEquals(
false,
result,
"New user without paid license should NOT be eligible for auto-creation");
}
@Test
void isOAuthEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, OAuth should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isOAuthEligible(user);
assertEquals(
false, result, "OAuth should be blocked when LicenseKeyChecker is unavailable");
}
// ===== SAML Eligibility Tests =====
@Test
void isSamlEligible_grandfatheredUser_returnsTrue() {
// Grandfathered user should be eligible for SAML regardless of license
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("grandfathered-user");
user.setOauthGrandfathered(true);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(true, result, "Grandfathered user should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithEnterpriseLicense_returnsTrue() {
// Non-grandfathered user with ENTERPRISE license should be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(user);
assertEquals(
true,
result,
"Non-grandfathered user with ENTERPRISE license should be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithServerLicense_returnsFalse() {
// Non-grandfathered user with SERVER license should NOT be eligible for SAML
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user with SERVER license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_nonGrandfatheredUserWithNoLicense_returnsFalse() {
// Non-grandfathered user without license should NOT be eligible
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.NORMAL);
boolean result = service.isSamlEligible(user);
assertEquals(
false,
result,
"Non-grandfathered user without ENTERPRISE license should NOT be eligible for SAML");
}
@Test
void isSamlEligible_newUserWithEnterpriseLicense_returnsTrue() {
// New user (null) with ENTERPRISE license should be eligible for auto-creation
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.ENTERPRISE);
boolean result = service.isSamlEligible(null);
assertEquals(
true,
result,
"New user with ENTERPRISE license should be eligible for SAML auto-creation");
}
@Test
void isSamlEligible_newUserWithServerLicense_returnsFalse() {
// New user (null) with SERVER license should NOT be eligible for SAML
when(licenseKeyChecker.getPremiumLicenseEnabledResult()).thenReturn(License.SERVER);
boolean result = service.isSamlEligible(null);
assertEquals(
false,
result,
"New user with SERVER license should NOT be eligible for SAML (requires ENTERPRISE)");
}
@Test
void isSamlEligible_licenseCheckerUnavailable_returnsFalse() {
// If LicenseKeyChecker is unavailable, SAML should be blocked
when(licenseKeyCheckerProvider.getIfAvailable()).thenReturn(null);
stirling.software.proprietary.security.model.User user =
new stirling.software.proprietary.security.model.User();
user.setUsername("test-user");
user.setOauthGrandfathered(false);
boolean result = service.isSamlEligible(user);
assertEquals(false, result, "SAML should be blocked when LicenseKeyChecker is unavailable");
}
}

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