Compare commits

..
Author SHA1 Message Date
Anthony Stirling 84ac407017 extra validations 2025-11-21 12:59:30 +00:00
Anthony Stirling 794d15ebaa address comments list[] 2025-11-21 12:57:35 +00:00
Anthony Stirling 1e11848d4f remove logs 2025-11-21 11:55:25 +00:00
Anthony Stirling 5f32502b54 style cleanup 2025-11-21 11:49:35 +00:00
Anthony Stirling 4f4f3c90ba Merge branch 'codex/re-enable-tool-disabling-functionality' of git@github.com:Stirling-Tools/Stirling-PDF.git into codex/re-enable-tool-disabling-functionality 2025-11-21 11:03:52 +00:00
Anthony Stirling 2e51a7b288 translations 2025-11-21 10:48:01 +00:00
Anthony Stirling 8140ff8c02 rtf to text oops 2025-11-20 23:57:05 +00:00
Anthony StirlingandGitHub 432e1047d9 Merge branch 'V2' into codex/re-enable-tool-disabling-functionality 2025-11-20 23:55:00 +00:00
Anthony Stirling 89df78c347 update tool disable 2025-11-20 23:52:02 +00:00
Anthony Stirling dd6419743f Merge remote-tracking branch 'origin/V2' into
codex/re-enable-tool-disabling-functionality
2025-11-20 21:27:06 +00:00
Dario Ghunney WareandGitHub fca8470637 Moar Login Fixes (#4948) 2025-11-20 20:51:53 +00:00
Balázs SzücsandGitHub 76f2fd3b76 [V2] refactor(tooltips): add merge tooltips header for consistency, and update other tooltips (#4895)
# Description of Changes

Note on tooltips: I'll do more PRs on tooltips, however this has
priority, hence I submitted this for now.


#### Before:

<img width="758" height="721" alt="image"
src="https://github.com/user-attachments/assets/ce3166d2-c681-447e-82df-68fe6d18669d"
/>

<img width="782" height="485" alt="image"
src="https://github.com/user-attachments/assets/48970a4c-9911-4ade-9346-c33c5a6a319f"
/>

<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/9f77648d-4d1a-4e4b-93af-eaab6db849ff"
/>

<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/ae653534-bf04-47a0-8aab-0bb5b9c3ecef"
/>

#### After:
<img width="758" height="721" alt="image"
src="https://github.com/user-attachments/assets/8d45e876-a0df-4fe3-89a8-5184492db204"
/>

<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/550ee105-1286-4072-9f55-46255b3fbe80"
/>

<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/fba7ea93-73fe-4529-84fe-c37816375a9e"
/>
<img width="782" height="602" alt="image"
src="https://github.com/user-attachments/assets/ca452e77-b58b-4a5b-8e97-e6ccef4cf500"
/>


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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2025-11-20 15:00:16 +00:00
Balázs SzücsandGitHub 06af6be14b [V2] feat(pipeline): add pre-publish sanitization workflow (#4910)
# Description of Changes
TLDR
- Created `Pre-publish-sanitization.json` default pipeline configuration
- Added sanitization operations removing metadata, JavaScript, embedded
files, and annotations
- Registered new pipeline in `GeneralUtils`
- Included "Pre-publish Sanitization" in the suggested automations list

This pull request introduces a new "Pre-publish Sanitization" workflow
for PDF files, designed to help users remove sensitive metadata and
content before publishing documents online. The changes include backend
and frontend updates to support this workflow, as well as a minor bug
fix in form data handling.

**New Pre-publish Sanitization Workflow:**

* Added a new default configuration file `Pre-publish-sanitization.json`
that defines a pipeline for sanitizing PDFs by removing JavaScript,
embedded files, metadata, annotations, flattening forms, and compressing
the document.
* Registered the new `Pre-publish-sanitization.json` config in the set
of default web UI configurations in `GeneralUtils.java`, making it
available in the application.

**Frontend Integration:**

* Added a new suggested automation called "Pre-publish Sanitization" in
the `useSuggestedAutomations` hook, including its name, description,
operations, and a new privacy icon for better UI representation.

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2025-11-20 14:59:13 +00:00
Anthony StirlingandGitHub c87da6d5cc Add automatic unlock prompt for encrypted PDFs (#4912)
## Summary
- propagate an `isEncrypted` flag from thumbnail generation into
processed file metadata so uploads know when a password is still present
- add queueing logic inside `FileContext` that detects encrypted
uploads, prompts the user via a new modal, and automatically runs the
Remove Password endpoint to replace the file and preserve history
- introduce a dedicated `EncryptedPdfUnlockModal` component that mirrors
existing styling and messaging for unlocking PDFs

## Testing
- npm run typecheck:core


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6919a0a418bc8328b886ec76a28170b7)
2025-11-20 14:54:41 +00:00
6c8d2c89fe Add initial Windows signing infrastructure (#4945)
# 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: James Brunton <james@stirlingpdf.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-11-20 12:21:42 +00:00
8d9e70c796 Stripe and license payment integration (#4935)
selfhosted stripe payment and license integration

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
2025-11-20 12:07:37 +00:00
James BruntonandGitHub f4725b98b0 Allow desktop app to connect to selfhosted servers (#4902)
# Description of Changes
Changes the desktop app to allow connections to self-hosted servers on
first startup. This was quite involved and hit loads of CORS issues all
through the stack, but I think it's working now. This also changes the
bundled backend to spawn on an OS-decided port rather than always
spawning on `8080`, which means that the user can have other things
running on port `8080` now and the app will still work fine. There were
quite a few places that needed to be updated to decouple the app from
explicitly using `8080` and I was originally going to split those
changes out into another PR (#4939), but I couldn't get it working
independently in the time I had, so the diff here is just going to be
complex and contian two distinct changes - sorry 🙁
2025-11-20 10:03:34 +00:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
75414b89f9 🌐 [V2] Sync Translations + Update README Progress Table (#4900)
### 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:

#### **1. Synchronization of Translation Files**
- Updated translation files
(`frontend/public/locales/*/translation.json`) to reflect changes in the
reference file `en-GB/translation.json`.
- 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

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-11-19 21:18:01 +00:00
Reece BrowneandGitHub dd1c653301 Feature/v2/saved signatures (#4899)
Add Saved signature functionality
2025-11-19 17:30:01 +00:00
Reece BrowneandGitHub 0f73a1cf13 Feature/sign placement UI (#4891)
Show signature preview on hover
Place signature smaller (mkatch preview size)
Retain signature in modal on close/open
Clean up ui generally
Small bug fixes
2025-11-19 12:53:25 +00:00
Anthony StirlingandGitHub 8ffe53536d activeUsers (#4936)
# 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-11-19 12:35:35 +00:00
Anthony StirlingandGitHub 6177ccd333 update notif (#4937)
# Description of Changes
<img width="1521" height="1041" alt="image"
src="https://github.com/user-attachments/assets/2644bf70-0a9b-4c91-9046-02f555314608"
/>

<img width="1162" height="1598" alt="image"
src="https://github.com/user-attachments/assets/36d693f7-6fdd-4f2b-9db1-39ac336d9055"
/>

<img width="1220" height="1625" alt="image"
src="https://github.com/user-attachments/assets/4d4c19ea-0020-45fb-b15a-9f6ad377856c"
/>

<!--
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-11-19 11:54:33 +00:00
87bf7a5b7f Add edit table of contents tool to React UI (#4917)
## Summary
- add a dedicated edit table of contents tool to the React UI, complete
with bookmark editor, import/export actions, and parameter handling
- register the tool in the translated registry and extend the English
translations with the new strings
- wire up the backend endpoints through a new operation hook and
form-data serialization helpers

## Testing
- ./gradlew build

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

---------

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-11-18 15:07:12 +00:00
Reece BrowneandGitHub a8ea0b60cf Chore/v2/fix auto zoom (#4903) 2025-11-18 13:43:52 +00:00
Anthony StirlingandGitHub 15b8447626 translations (#4906)
# 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-11-18 12:23:11 +00:00
James BruntonandGitHub a7fc36586a Fix OnboardingTour not responding to translations (#4934)
# Description of Changes
The onboarding tour wasn't presenting itself as having any dependencies
to React so would never be re-rendered after language changes.
2025-11-18 10:15:05 +00:00
James BruntonandGitHub a415c457e9 Add prompt to make Stirling your default PDF app (#4890)
# Description of Changes
- Adds a reusable banner component/system to the core app
- Adds banner at the top of the desktop app if Stirling isn't your
default PDF editor, with a button to make it your default
- Adds a permanent button in the settings to do it manually (in case
you've dismissed the banner)
- Simplifies the file loading logic to fix a bug where the input file
could be duplicated occasionally. Now, the TS just receives files from
one buffer, regardless of how they've been passed to the app in Rust.

## Caveats
I've only been able to get the setting of default apps working properly
on Mac. The Windows build isn't signed (yet) so we can't use the proper
API for it, so currently it just sends you to the Settings UI. I've also
not been able to test it on Linux at all.
2025-11-17 16:05:33 +00:00
Anthony StirlingandGitHub 28eb9baa02 Enable withCredentials for API client (#4921)
# 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-11-17 14:07:58 +00:00
Anthony StirlingandGitHub 4b43693e29 csrf fixes (#4915)
# 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-11-17 12:05:03 +00:00
Anthony Stirling 5f62e6f81f Differentiate unavailable tools by reason 2025-11-16 22:05:25 +00:00
EthanHealy01andGitHub 5c9e590856 styling changes (#4894)
# Description of Changes

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-14 14:52:32 +00:00
James BruntonandGitHub be78c72887 Only build Tauri for things that touch desktop specifically, or main/V2 (#4897)
# Description of Changes
Only build Tauri for things that touch desktop specifically, or main/V2
2025-11-14 13:41:07 +00:00
Anthony StirlingandGitHub 1117ce6164 Settings display demo and login fix (#4884)
# Description of Changes
<img width="1569" height="980" alt="image"
src="https://github.com/user-attachments/assets/dca1c227-ed84-4393-97a1-e3ce6eb1620b"
/>

<img width="1596" height="935" alt="image"
src="https://github.com/user-attachments/assets/2003e1be-034a-4cbb-869e-6d5d912ab61d"
/>

<img width="1543" height="997" alt="image"
src="https://github.com/user-attachments/assets/fe0c4f4b-eeee-4db4-a041-e554f350255a"
/>


---

## 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-11-14 13:02:45 +00:00
EthanHealy01andGitHub 50760b5302 quick fix to compare (#4893)
# Description of Changes

- Compare fix to stop the pages from getting stuck in loading state. 

This code tracks which page images have actually finished loading in a
Map stored in a ref, and then uses a throwaway useState value
(setImageLoadedTick) just to force a re-render when that ref changes.
When an <img> fires onLoad, it marks that page as loaded in
imageLoadedRef and bumps the tick, causing React to re-render and
re-evaluate the condition for the loader overlay. Because the overlay is
only shown when imageLoadedRef.current.get(page.pageNumber) is false,
that re-render hides the blur/loader once the page’s image has loaded
and is in view, instead of the UI getting stuck in the “loading” state.

<img width="823" height="984" alt="Screenshot 2025-11-13 at 5 57 42 PM"
src="https://github.com/user-attachments/assets/93445208-d653-4d89-b33a-aebea69c97d9"
/>

---

## 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-11-13 18:04:09 +00:00
stirlingbot[bot]GitHubstirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
edba81ff9d 🌐 [V2] Sync Translations + Update README Progress Table (#4754)
### 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:

#### **1. Synchronization of Translation Files**
- Updated translation files
(`frontend/public/locales/*/translation.json`) to reflect changes in the
reference file `en-GB/translation.json`.
- 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

Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-11-13 17:46:51 +00:00
Anthony StirlingandGitHub 052a3ae653 api (#4892)
# Description of Changes

<img width="1253" height="816" alt="image"
src="https://github.com/user-attachments/assets/51154a0e-4353-4e62-83c5-0896bc4e98df"
/>


---

## 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-11-13 17:29:53 +00:00
Balázs SzücsandGitHub 350fdcf29a [V2] feat(merge): implement natural sorting for filenames in merge tool (#4888)
# Description of Changes


TLDR:
- Added `naturalCompare` function to handle alphanumeric sorting
- Updated `sortFiles` logic to use `naturalCompare` for filename sorting
- Passed `naturalCompare` as dependency in sorting callback


This pull request improves the file sorting logic in the `Merge` tool to
provide a more natural, human-friendly ordering of filenames (e.g.,
"file2" now comes before "file10" instead of after). The main change is
the introduction of a custom `naturalCompare` function that is used when
sorting files by filename.

File sorting improvements:

* Added a `naturalCompare` function to sort filenames in a way that
handles numeric portions naturally, ensuring files like "file2" are
ordered before "file10" (`frontend/src/core/tools/Merge.tsx`).
* Updated the file sorting logic to use `naturalCompare` instead of the
default `localeCompare` when sorting by filename
(`frontend/src/core/tools/Merge.tsx`).
* Ensured the `sortFiles` callback properly depends on the new
`naturalCompare` function (`frontend/src/core/tools/Merge.tsx`).

Note: the sort on upload is natural sort (at least I think so I haven't
checked the code), this is only relevant after upload, and you click the
sort button again


### Before:

<img width="1858" height="995" alt="image"
src="https://github.com/user-attachments/assets/b6fe117e-5f70-4ff0-b16d-6a82a1ab5c2b"
/>


### After:
<img width="1858" height="995" alt="image"
src="https://github.com/user-attachments/assets/4621950a-ce46-4f6e-b128-a0dd1d126973"
/>


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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2025-11-13 15:57:14 +00:00
aa20dbb7a6 Feature/v2/selected pageeditor rework (#4756)
# 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.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 12:53:57 +00:00
James BruntonandGitHub d06391a927 Redesign and enable macOS signing (#4883)
# Description of Changes
Re-enable macOS signing and tweak so it runs successfully through CI.
Also changes the runner to use macOS 15 instead of 13, which was
throwing a deprecation warning in GitHub. Note that the runner doesn't
affect the minimum target, which I've still got set to 10.15 (no idea if
it actually works on 10.15 but let's assume that if it builds it works
until someone can test it)
2025-11-13 12:11:59 +00:00
Balázs SzücsandGitHub 3ae2946d57 [V2] refactor(ui): migrate compress/booklet/sign-up to Mantine Checkbox (#4886)
# Description of Changes
- Updated `BookletImpositionSettings` to use Mantine's `Checkbox` for
better UI consistency
- Replaced manual checkbox implementations with Mantine's `Checkbox` in
`CompressSettings`
- Added labels and tooltips for enhanced accessibility and usability

### Before:

<img width="225" height="347" alt="image"
src="https://github.com/user-attachments/assets/2bc2e5bd-33e8-40c3-8957-8ed461f7f397"
/>
<img width="225" height="347" alt="image"
src="https://github.com/user-attachments/assets/04425759-f05d-4a41-829e-30cc8febcc59"
/>
<img width="225" height="347" alt="image"
src="https://github.com/user-attachments/assets/a25122ab-377b-48a0-9692-7548e4cb2100"
/>
<img width="225" height="347" alt="image"
src="https://github.com/user-attachments/assets/bb206b71-9252-4bba-8627-d211de21d404"
/>



### After:

<img width="225" height="347" alt="image"
src="https://github.com/user-attachments/assets/3d797372-a97b-4a6b-94f7-8478cea3776e"
/>
<img width="225" height="347" alt="image"
src="https://github.com/user-attachments/assets/310bbc9b-60e7-4e00-b8f2-e2290d20b307"
/>

<img width="225" height="397" alt="image"
src="https://github.com/user-attachments/assets/207f9b1d-5f2d-4c67-a97d-74dece3adef7"
/>
<img width="225" height="397" alt="image"
src="https://github.com/user-attachments/assets/ee3889eb-5494-4763-993e-76dbd0aa4c26"
/>

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2025-11-13 11:59:11 +00:00
James BruntonandGitHub eb5f36aa15 Fix issues with opening files in desktop app (#4876)
# Description of Changes
Locking to just having one instance of the app running unifies the
experience across all OSs. Opening new files in Stirling will cause the
files to be opened in the existing window rather than spawning a new
instance of the app with just that file in the new instance.

There's much more to explore here to allow multiple windows open at
once, but that can be done all from one instance of the app, and will
likely make it easier to allow movement of files etc. across different
windows.

Also fixes extra newlines in the logs and directly builds to `.app` on
Mac because it's frustrating during development to have to repeatedly
mount & unmount the `.dmg`.
2025-11-12 15:47:37 +00:00
Balázs SzücsandGitHub a05c5a53c7 [V2] feat(convert): add support for CBZ to PDF and PDF to CBZ conversion (#4831)
# Description of Changes


TLDR:
- Implemented `ConvertFromCbzSettings` component for CBZ to PDF options
- Added `ConvertToCbzSettings` component for PDF to CBZ options
- Extended conversion constants with new CBZ-related formats and
mappings
- Updated `ConvertSettings` to include CBZ-specific configuration forms
- Updated `useConvertOperation` to handle CBZ conversion parameters

For backend reference see this PR: #4472

This pull request adds support for converting between CBZ (Comic Book
Zip) and PDF formats in the frontend conversion tool. It introduces new
UI components for CBZ-to-PDF and PDF-to-CBZ conversion options, updates
the conversion parameters and form data logic, and integrates these
formats into the available conversion options and endpoints.

**CBZ/PDF Conversion Support:**

* Added `ConvertFromCbzSettings` and `ConvertToCbzSettings` components
to provide user options for CBZ-to-PDF and PDF-to-CBZ conversions,
including "Optimize for ebook" and DPI settings.

**Conversion Logic and Parameters:**

* Extended the `ConvertParameters` interface and default parameters to
include `cbzOptions` (for CBZ-to-PDF) and `cbzOutputOptions` (for
PDF-to-CBZ).

**Conversion Matrix and Endpoint Integration:**

* Added CBZ to the supported "from" and "to" format lists, updated the
conversion matrix to allow CBZ-to-PDF and PDF-to-CBZ, and mapped the new
conversions to appropriate API endpoints.
* Updated URL mapping to recognize `/cbz-to-pdf` and `/pdf-to-cbz` as
conversion tool routes.

### Front-end

<img width="1852" height="994" alt="image"
src="https://github.com/user-attachments/assets/b902438a-8ce7-4eed-a4f8-d80677c0c796"
/>
<img width="270" height="994" alt="image"
src="https://github.com/user-attachments/assets/87556380-e0c5-4974-93c2-d36f86268b49"
/>
<img width="417" height="389" alt="image"
src="https://github.com/user-attachments/assets/1889a3ec-0c06-442a-b3bb-71e1a7c34e4c"
/>
<img width="1853" height="994" alt="image"
src="https://github.com/user-attachments/assets/d6d68f5f-ed70-4be9-89b1-2e4b7cd90921"
/>
<img width="265" height="994" alt="image"
src="https://github.com/user-attachments/assets/36a05dd5-164f-46a7-b8af-aa815d6c0767"
/>




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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2025-11-12 15:27:41 +00:00
74a1438c21 [V2] feat(unzip/front-end): Implement ZIP extraction confirmation for archives over 20 files (#4834)
# Description of Changes

TLDR:

- Introduced a user confirmation dialog for extracting ZIP files with
more than **20 files**.
- Created `useZipConfirmation` hook to handle confirmation dialog logic
and state.
- Implemented `ZipWarningModal` component to display the confirmation
dialog.
- Updated `zipFileService` to count files in ZIP and trigger
confirmation callback for large files.
- Integrated confirmation flow into `FileContext` and
`useToolResources`.
- Added translations for new ZIP warning dialog messages.

This pull request introduces a user confirmation dialog when attempting
to extract large ZIP files (**over 20 files**), improving safety and
user experience by preventing accidental extraction of very large
archives. The implementation includes a reusable confirmation modal, a
custom hook to handle dialog state and resolution, and updates to the
ZIP extraction logic to support this workflow.

**User Experience Improvements**
* Added a new localized warning dialog (`ZipWarningModal`) that prompts
users for confirmation when extracting ZIP files containing more than 20
files. This dialog displays the ZIP file name, file count, and offers
"Cancel" and "Extract" actions, with responsive layouts for desktop and
mobile

**ZIP Extraction Workflow Enhancements**
* Updated the ZIP extraction logic in `ZipFileService` to count the
number of files in a ZIP and invoke a confirmation callback if the file
count exceeds the threshold. Extraction proceeds only if the user
confirms; otherwise, the ZIP remains unextracted.
* Added a new hook (`useZipConfirmation`) to manage the confirmation
dialog’s state and provide a promise-based API for requesting user
confirmation.

**Integration with Application State**
* Integrated the confirmation workflow into `FileContext`, passing the
confirmation function into ZIP extraction calls and rendering the modal
dialog at the appropriate time.
* Updated relevant interfaces and method signatures to support the
optional confirmation callback for large ZIP extractions throughout the
codebase.


<img width="515" height="321" alt="image"
src="https://github.com/user-attachments/assets/f35a7588-4635-4ccd-9ee6-95edb17fee99"
/>
<img width="515" height="321" alt="image"
src="https://github.com/user-attachments/assets/0525acf3-4174-42cd-8912-910e754c467c"
/>

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-11-12 15:18:15 +00:00
EthanHealy01andGitHub c8615518a6 Addition of the Show JavaScript tool (#4877)
# Description of Changes

- Added the show javascript 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-11-12 15:02:43 +00:00
a5e2b54274 Feature/v2/compare tool (#4751)
# Description of Changes

- Addition of the compare 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)

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-11-12 14:54:01 +00:00
Anthony StirlingandGitHub f22f697edc init user flags (#4875)
# Description of Changes

Adds isNewServer and isNewUser flags to the /api/v1/app-config endpoint
to enable differentiated onboarding
  experiences for first-time servers and users.
  
When onboarding completes → call POST
/api/v1/user/complete-initial-setup
  
<img width="828" height="709" alt="image"
src="https://github.com/user-attachments/assets/037aa22c-e6f5-41b9-8b12-348589812449"
/>


---

## 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-11-11 15:50:01 +00:00
James BruntonandGitHub 0913dbf5b7 Fix Rust warnings (#4872)
# Description of Changes
Fix warnings in Rust code on Mac. They were all being caused by file
handling logic which is now built into Tauri, so I've just been able to
remove all of the Mac specific file handling code.

I've also set warnings to be treated as errors because it'll be really
easy to accidentally introudce warnings on individual platforms which
I'm not developing, and I'd like to know about them so we can fix it
before getting dodgy code.
2025-11-11 14:16:44 +00:00
James BruntonandGitHub 044bf3c2aa Improve loading speed of desktop app (#4865)
# Description of Changes
Improve loading speed of desktop app by loading a default config until
the backend has spawned.
2025-11-11 11:54:43 +00:00
4d349c047b [V2] feat(delete-form,modify-form,fill-form,extract-forms): add delete, modify, fill, and extract form functionality (#4830)
# Description of Changes

TLDR
- Adds `/api/v1/form/fields`, `/fill`, `/modify-fields`, and
`/delete-fields` endpoints for end-to-end AcroForm workflows.
- Centralizes form field detection, filling, modification, and deletion
logic in `FormUtils` with strict type handling.
- Introduces `FormPayloadParser` for resilient JSON parsing across
legacy flat payloads and new structured payloads.
- Reuses and extends `FormCopyUtils` plus `FormFieldTypeSupport` to
create, clone, and normalize widget properties when transforming forms.

### Implementation Details
- `FormFillController` updates the new multipart APIs, and streams
updated documents or metadata responses.
- `FormUtils` now owns extraction, template building, value application
(including flattening strategies), and field CRUD helpers used by the
controller endpoints.
- `FormPayloadParser` normalizes request bodies: accepts flat key/value
maps, combined `fields` arrays, or nested templates, returning
deterministic LinkedHashMap ordering for repeatable fills.
- `FormFieldTypeSupport` encapsulates per-type creation, value copying,
default appearance, and option handling; utilized by both modification
flows and `FormCopyUtils` transformations.
- `FormCopyUtils` exposes shared routines for making widgets across
documents

### API Surface (Multipart Form Data)
- `POST /api/v1/form/fields` -> returns `FormUtils.FormFieldExtraction`
with ordered `FormFieldInfo` records plus a fill template.
- `POST /api/v1/form/fill` -> applies parsed values via
`FormUtils.applyFieldValues`; optional `flatten` renders appearances
while respecting strict validation.
- `POST /api/v1/form/modify-fields` -> updates existing fields in-place
using `FormUtils.modifyFormFields` with definitions parsed from
`updates` payloads.
- `POST /api/v1/form/delete-fields` -> removes named fields after
`FormPayloadParser.parseNameList` deduplication and validation.

<img width="1305" height="284" alt="image"
src="https://github.com/user-attachments/assets/ef6f3d76-4dc4-42c1-a779-0649610cbf9a"
/>

### Individual endpoints:

<img width="1318" height="493" alt="image"
src="https://github.com/user-attachments/assets/65abfef9-50a2-42e6-8830-f07a7854d3c2"
/>
<img width="1310" height="582" alt="image"
src="https://github.com/user-attachments/assets/dd903773-5513-42d9-ba5d-3d8f204d6a0d"
/>
<img width="1318" height="493" alt="image"
src="https://github.com/user-attachments/assets/c22f65a7-721a-45bb-bb99-4708c423e89e"
/>
<img width="1318" height="493" alt="image"
src="https://github.com/user-attachments/assets/a76852f5-d5d1-442a-8e5e-d0f29404542a"
/>


### Data Validation & Type Safety
- Field type inference (`detectFieldType`) and choice option resolution
ensure only supported values are written; checkbox mapping uses export
states and boolean heuristics.
- Choice inputs pass through `filterChoiceSelections` /
`filterSingleChoiceSelection` to reject invalid entries and provide
actionable logs.
- Text fills leverage `setTextValue` to merge inline formatting
resources and regenerate appearances when necessary.
- `applyFieldValues` supports strict mode (default) to raise when
unknown fields are supplied, preventing silent data loss.


### Automation Workflow Support

The `/fill` and `/fields` endpoints are designed to work together for
automated form processing. The workflow is straightforward: extract the
form structure, modify the values, and submit for filling.


How It Works:
1. The `/fields` endpoint extracts all form field metadata from your PDF
2. You modify the returned JSON to set the desired values for each field
3. The `/fill` endpoint accepts this same JSON structure to populate the
form

Example Workflow:

```bash
# Step 1: Extract form structure and save to fields.json
curl -o fields.json \
     -F file=@Form.pdf \
     http://localhost:8080/api/v1/form/fields

# Step 2: Edit fields.json to update the "value" property for each field
# (Use your preferred text editor or script to modify the values)

# Step 3: Fill the form using the modified JSON
curl -o filled-form.pdf \
     -F file=@Form.pdf \
     -F data=@fields.json \
     http://localhost:8080/api/v1/form/fill
```

#### How to Fill the `template` JSON

The `template` (your data) is filled by creating key-value pairs that
match the "rules" defined in the `fields` array (the schema).

1. Find the Field `name`: Look in the `fields` array for the `name` of
the field you want to fill.
    * *Example:* `{"name": "Agent of Dependent", "type": "text", ...}`

2. Use `name` as the Key: This `name` becomes the key (in quotes) in
your `template` object.
    * *Example:* `{"Agent of Dependent": ...}`

3. Find the `type`: Look at the `type` for that same field. This tells
you what *kind* of value to provide.
    * `"type": "text"` requires a string (e.g., `"John Smith"`).
    * `"type": "checkbox"` requires a boolean (e.g., `true` or `false`).
* `"type": "combobox"` requires a string that *exactly matches* one of
its `"options"` (e.g., `"Choice 1"`).

4.  Add the Value: This matching value becomes the value for your key.

#### Correct Examples

* For a Textbox:
    * Schema: `{"name": "Agent of Dependent", "type": "text", ...}`
    * Template: `{"Agent of Dependent": "Mary Jane"}`

* For a Checkbox:
    * Schema: `{"name": "Option 2", "type": "checkbox", ...}`
    * Template: `{"Option 2": true}`

* For a Dropdown (Combobox):
* Schema: `{"name": "Dropdown2", "type": "combobox", "options": ["Choice
1", "Choice 2", ...] ...}`
    * Template: `{"Dropdown2": "Choice 1"}`

### Incorrect Examples (These Will Error)

* Wrong Type: `{"Option 2": "Checked"}`
* Error: "Option 2" is a `checkbox` and expects `true` or `false`, not a
string.
* Wrong Option: `{"Dropdown2": "Choice 99"}`
* Error: `"Choice 99"` is not listed in the `options` for "Dropdown2".

### For people manually doing this

For users filling forms manually, there's a simplified format that
focuses only on field names and values:

```json
{
  "FullName": "",
  "ID": "",
  "Gender": "Off",
  "Married": false,
  "City": "[]"
}
```

This format is easier to work with when you're manually editing the
JSON. You can skip the full metadata structure (type, label, required,
etc.) and just provide the field names with their values.

Important caveat: Even though the type information isn't visible in this
simplified format, type validation is still enforced by PDF viewers.
This simplified format just makes manual editing more convenient while
maintaining data integrity.

Please note: this suffers from:
https://issues.apache.org/jira/browse/PDFBOX-5962

Closes https://github.com/Stirling-Tools/Stirling-PDF/issues/237
Closes https://github.com/Stirling-Tools/Stirling-PDF/issues/3569
<!--
Please provide a summary of the changes, including:

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-11-10 23:41:26 +00:00
ce6b2460d8 Viewer update and autozoom (#4800)
Updated embed PDF
Added Autozoom
Added file page size to metadata for use in calculations for autozoom,
will come in handy elsewhere.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-11-10 13:52:13 +00:00
3cf89b6ede admin onboarding (#4863)
# 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: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2025-11-10 13:47:43 +00:00
James BruntonandGitHub ebf4bab80b Tidy Tauri code and enable "Open file in Stirling PDF" (#4836)
# Description of Changes
Tidy Tauri code and enable "Open file in Stirling PDF"
2025-11-10 12:15:39 +00:00
f4543d26cd Improvement/V2/generic_obscure_component_wrapper (#4794)
Created PrivateContent Component to be used as wrapper. 

This way all tools that need to obscure contents can update this
wrapper.

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
Co-authored-by: James Brunton <jbrunton96@gmail.com>
2025-11-10 11:24:14 +00:00
James BruntonandGitHub 45389340ed Remove ambiguous translations and fix invalid translations (#4841)
# Description of Changes
`i18next` allows this pattern for translations, which we use quite a few
times in our current translation files:

```json
{
  "a": {
     "b": "hello"
   },
   "a.b": "world"
}
```

This makes it ambiguous when selecting `a.b` which string will be
retrieved. We have seen issues in other languages in the current release
like this:

<img width="325" height="249" alt="image"
src="https://github.com/user-attachments/assets/f24a29f0-550f-49b8-b355-c5e5eb436558"
/>

because we are expecting this:

<img width="1022" height="210" alt="image"
src="https://github.com/user-attachments/assets/b6d5cdd4-96cd-4b2b-8f1a-465da8bf70c8"
/>

but the Spanish file has:

<img width="312" height="136" alt="image"
src="https://github.com/user-attachments/assets/1e13392c-8484-47d1-b0c4-19d52b3ea5eb"
/>

and no `removeDigitalSignature` key on its own. 

This PR resolves all of these ambiguities in the source by restructuring
all of the keys to uniquely target either an object or a string, not
both. It also adds a test which will fail on any keys with a `.` in
their name, therefore making it impossible to add anything ambiguous.
2025-11-10 11:03:18 +00:00
James BruntonandGitHub 9671f6835e Make Ghostscript an optional dependency for tools that don't need it (#4840)
# Description of Changes
Make Ghostscript an optional dependency for tools that don't need it.
2025-11-07 15:11:02 +00:00
ac3e10eb99 Add audit system, invite links, and usage analytics (#4749)
# Description of Changes

New Features
Audit System: Complete audit logging with dashboard, event tracking, and
export capabilities

Invite Links: Secure invite system with email notifications and
expiration

Usage Analytics: Endpoint usage statistics and visualization

License Management: User counting with grandfathering and license
enforcement
## 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: James Brunton <jbrunton96@gmail.com>
2025-11-06 17:29:34 +00:00
452 changed files with 186326 additions and 28284 deletions
+74
View File
@@ -0,0 +1,74 @@
# Node modules and build artifacts
node_modules
frontend/node_modules
frontend/dist
frontend/build
frontend/.vite
frontend/.tauri
# Gradle build artifacts
.gradle
build
bin
target
out
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.iml
*.iws
*.ipr
# Logs
*.log
logs
# Environment files
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# Java compiled files
*.class
*.jar
*.war
*.ear
# Test reports
test-results
coverage
# Docker
docker-compose.override.yml
.dockerignore
# Temporary files
tmp
temp
*.tmp
*.swp
*~
# Runtime database and config files (locked by running app)
app/core/configs/**
stirling/**
stirling-pdf-DB*.mv.db
stirling-pdf-DB*.trace.db
# Documentation
*.md
!README.md
docs
# CI/CD
.github
.gitlab-ci.yml
+387 -79
View File
@@ -14,21 +14,14 @@ on:
- macos
- linux
pull_request:
branches: [main, V2]
branches: [main, V2, V2-tauri-windows]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- 'frontend/src/desktop/**'
- 'frontend/tsconfig.desktop.json'
- '.github/workflows/tauri-build.yml'
push:
branches: [main, V2]
paths:
- 'frontend/src-tauri/**'
- 'frontend/src/**'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/workflows/tauri-build.yml'
permissions:
contents: read
@@ -47,21 +40,19 @@ jobs:
"windows")
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"}]}' >> $GITHUB_OUTPUT
;;
# "macos")
# echo 'matrix={"include":[{"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
# ;;
"macos")
echo 'matrix={"include":[{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}]}' >> $GITHUB_OUTPUT
;;
"linux")
echo 'matrix={"include":[{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
*)
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
;;
esac
else
# For PR/push events, build all platforms
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
# Disabled Mac builds: {"platform":"macos-latest","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-13","args":"--target x86_64-apple-darwin","name":"macos-x86_64"}
echo 'matrix={"include":[{"platform":"windows-latest","args":"--target x86_64-pc-windows-msvc","name":"windows-x86_64"},{"platform":"macos-15","args":"--target aarch64-apple-darwin","name":"macos-aarch64"},{"platform":"macos-15-intel","args":"--target x86_64-apple-darwin","name":"macos-x86_64"},{"platform":"ubuntu-22.04","args":"","name":"linux-x86_64"}]}' >> $GITHUB_OUTPUT
fi
build:
@@ -70,6 +61,9 @@ jobs:
fail-fast: false
matrix: ${{ fromJson(needs.determine-matrix.outputs.matrix) }}
runs-on: ${{ matrix.platform }}
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
steps:
- name: Harden Runner
uses: step-security/harden-runner@002fdce3c6a235733a90a27c80493a3241e56863 # v2.12.1
@@ -96,7 +90,7 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
targets: ${{ (matrix.platform == 'macos-latest' || matrix.platform == 'macos-13') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
targets: ${{ (matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel') && 'aarch64-apple-darwin,x86_64-apple-darwin' || '' }}
@@ -183,85 +177,400 @@ jobs:
working-directory: ./frontend
run: npm install
# Disabled Mac builds - Import Apple Developer Certificate
# - name: Import Apple Developer Certificate
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
# env:
# APPLE_ID: ${{ secrets.APPLE_ID }}
# APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
# APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
# APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
# KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
# run: |
# echo "Importing Apple Developer Certificate..."
# echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# security default-keychain -s build.keychain
# security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# security set-keychain-settings -t 3600 -u build.keychain
# security import certificate.p12 -k build.keychain -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
# security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# security find-identity -v -p codesigning build.keychain
# - name: Verify Certificate
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
# run: |
# echo "Verifying Apple Developer Certificate..."
# CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
# echo "Certificate Info: $CERT_INFO"
# CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
# echo "Certificate ID: $CERT_ID"
# echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
# echo "Certificate imported."
# DigiCert KeyLocker Setup (Cloud HSM)
- name: Setup DigiCert KeyLocker
id: digicert-setup
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
uses: digicert/ssm-code-signing@v1.1.0
env:
SM_API_KEY: ${{ secrets.SM_API_KEY }}
SM_CLIENT_CERT_FILE_B64: ${{ secrets.SM_CLIENT_CERT_FILE_B64 }}
SM_CLIENT_CERT_PASSWORD: ${{ secrets.SM_CLIENT_CERT_PASSWORD }}
SM_KEYPAIR_ALIAS: ${{ secrets.SM_KEYPAIR_ALIAS }}
SM_HOST: ${{ secrets.SM_HOST }}
# - name: Check DMG creation dependencies (macOS only)
# if: matrix.platform == 'macos-latest' || matrix.platform == 'macos-13'
# run: |
# echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
# echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
# echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
# echo "Available disk space: $(df -h /tmp | tail -1)"
# echo "macOS version: $(sw_vers -productVersion)"
# echo "Available tools:"
# ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Setup DigiCert KeyLocker Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
shell: pwsh
run: |
Write-Host "Setting up DigiCert KeyLocker environment..."
- name: Build Tauri app
# Decode client certificate
$certBytes = [Convert]::FromBase64String("${{ secrets.SM_CLIENT_CERT_FILE_B64 }}")
$certPath = "D:\Certificate_pkcs12.p12"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Set environment variables
echo "SM_CLIENT_CERT_FILE=D:\Certificate_pkcs12.p12" >> $env:GITHUB_ENV
echo "SM_HOST=${{ secrets.SM_HOST }}" >> $env:GITHUB_ENV
echo "SM_API_KEY=${{ secrets.SM_API_KEY }}" >> $env:GITHUB_ENV
echo "SM_CLIENT_CERT_PASSWORD=${{ secrets.SM_CLIENT_CERT_PASSWORD }}" >> $env:GITHUB_ENV
echo "SM_KEYPAIR_ALIAS=${{ secrets.SM_KEYPAIR_ALIAS }}" >> $env:GITHUB_ENV
# Get PKCS11 config path from DigiCert action
$pkcs11Config = $env:PKCS11_CONFIG
if ($pkcs11Config) {
Write-Host "Found PKCS11_CONFIG: $pkcs11Config"
echo "PKCS11_CONFIG=$pkcs11Config" >> $env:GITHUB_ENV
} else {
Write-Host "PKCS11_CONFIG not set by DigiCert action, using default path"
$defaultPath = "C:\Users\RUNNER~1\AppData\Local\Temp\smtools-windows-x64\pkcs11properties.cfg"
if (Test-Path $defaultPath) {
Write-Host "Found config at default path: $defaultPath"
echo "PKCS11_CONFIG=$defaultPath" >> $env:GITHUB_ENV
} else {
Write-Host "Warning: Could not find PKCS11 config file"
}
}
# Traditional PFX Certificate Import (fallback if KeyLocker not configured)
- name: Import Windows Code Signing Certificate
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY == '' }}
env:
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
shell: powershell
run: |
if ($env:WINDOWS_CERTIFICATE) {
Write-Host "Importing Windows Code Signing Certificate..."
# Decode base64 certificate and save to file
$certBytes = [Convert]::FromBase64String($env:WINDOWS_CERTIFICATE)
$certPath = Join-Path $env:RUNNER_TEMP "certificate.pfx"
[IO.File]::WriteAllBytes($certPath, $certBytes)
# Import certificate to CurrentUser\My store
$cert = Import-PfxCertificate -FilePath $certPath -CertStoreLocation Cert:\CurrentUser\My -Password (ConvertTo-SecureString -String $env:WINDOWS_CERTIFICATE_PASSWORD -AsPlainText -Force)
# Extract and set thumbprint as environment variable
$thumbprint = $cert.Thumbprint
Write-Host "Certificate imported with thumbprint: $thumbprint"
echo "WINDOWS_CERTIFICATE_THUMBPRINT=$thumbprint" >> $env:GITHUB_ENV
# Clean up certificate file
Remove-Item $certPath
Write-Host "Windows certificate import completed."
} else {
Write-Host "⚠️ WINDOWS_CERTIFICATE secret not set - building unsigned binary"
}
- name: Import Apple Developer Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
run: |
echo "Importing Apple Developer Certificate..."
echo $APPLE_CERTIFICATE | base64 --decode > certificate.p12
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate
security import certificate.p12 -P "$APPLE_CERTIFICATE_PASSWORD" -A -t cert -f pkcs12 -k $KEYCHAIN_PATH
security list-keychain -d user -s $KEYCHAIN_PATH
security set-key-partition-list -S apple-tool:,apple: -k "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Clean up
rm certificate.p12
- name: Verify Certificate
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "Verifying Apple Developer Certificate..."
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
CERT_INFO=$(security find-identity -v -p codesigning $KEYCHAIN_PATH | grep "Developer ID Application")
echo "Certificate Info: $CERT_INFO"
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "Certificate ID: $CERT_ID"
echo "APPLE_SIGNING_IDENTITY=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported successfully."
- name: Check DMG creation dependencies (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "🔍 Checking DMG creation dependencies on ${{ matrix.platform }}..."
echo "hdiutil version: $(hdiutil --version || echo 'NOT FOUND')"
echo "create-dmg availability: $(which create-dmg || echo 'NOT FOUND')"
echo "Available disk space: $(df -h /tmp | tail -1)"
echo "macOS version: $(sw_vers -productVersion)"
echo "Available tools:"
ls -la /usr/bin/hd* || echo "No hd* tools found"
- name: Build Tauri app
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPIMAGETOOL_SIGN_PASSPHRASE: ${{ secrets.APPIMAGETOOL_SIGN_PASSPHRASE }}
SIGN: 1
CI: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
SIGN: ${{ (env.SM_API_KEY == '' && env.WINDOWS_CERTIFICATE != '') && '1' || '0' }}
CI: true
with:
projectPath: ./frontend
tauriScript: npx tauri
args: ${{ matrix.args }}
# Sign with DigiCert KeyLocker (post-build)
- name: Sign Windows binaries with DigiCert KeyLocker
if: ${{ matrix.platform == 'windows-latest' && env.SM_API_KEY != '' }}
shell: pwsh
run: |
Write-Host "=== DigiCert KeyLocker Signing ==="
# Test smctl connectivity first
Write-Host "Testing smctl connection..."
$healthCheck = & smctl healthcheck 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Host "[SUCCESS] Connected to DigiCert KeyLocker"
} else {
Write-Host "[ERROR] Failed to connect to DigiCert KeyLocker"
Write-Host $healthCheck
exit 1
}
Write-Host ""
# Sync certificates to Windows certificate store
Write-Host "Syncing certificates to Windows certificate store..."
$syncOutput = & smctl windows certsync 2>&1
Write-Host "Cert sync result: $syncOutput"
Write-Host ""
# List available certificates and check if they have certificates attached
Write-Host "Checking for available certificates..."
$certList = & smctl keypair ls 2>&1
Write-Host "Keypair list output:"
Write-Host $certList
Write-Host ""
# Parse the output to check certificate status
$lines = $certList -split "`n"
$foundKeypair = $false
$hasCertificate = $false
foreach ($line in $lines) {
if ($line -match "${{ secrets.SM_KEYPAIR_ALIAS }}") {
$foundKeypair = $true
Write-Host "[SUCCESS] Found keypair in list"
# Check if this line has certificate info (not just empty spaces after alias)
$parts = $line -split "\s+"
if ($parts.Count -gt 2 -and $parts[1] -ne "" -and $parts[1] -ne "CERTIFICATE") {
$hasCertificate = $true
Write-Host "[SUCCESS] Certificate is associated with keypair"
}
}
}
if (-not $foundKeypair) {
Write-Host "[ERROR] Keypair not found: ${{ secrets.SM_KEYPAIR_ALIAS }}"
Write-Host "Available keypairs are listed above"
Write-Host ""
Write-Host "Please verify:"
Write-Host " 1. Keypair alias is correct in GitHub secret"
Write-Host " 2. API key has access to this keypair"
exit 1
}
if (-not $hasCertificate) {
Write-Host "[ERROR] No certificate associated with keypair"
Write-Host "This usually means:"
Write-Host " 1. Certificate not yet synced to KeyLocker (run sync manually)"
Write-Host " 2. Certificate is pending approval"
Write-Host " 3. Certificate needs to be attached to the keypair"
Write-Host ""
Write-Host "Try running in DigiCert ONE portal:"
Write-Host " smctl keypair sync"
exit 1
}
Write-Host "[SUCCESS] Certificate check passed"
Write-Host ""
# Find only the files we need to sign (not build scripts)
$filesToSign = @()
# Main application executable
$mainExe = Get-ChildItem -Path "./frontend/src-tauri/target/x86_64-pc-windows-msvc/release" -Filter "stirling-pdf.exe" -File -ErrorAction SilentlyContinue
if ($mainExe) { $filesToSign += $mainExe }
# MSI installer
$msiFiles = Get-ChildItem -Path "./frontend/src-tauri/target" -Filter "*.msi" -Recurse -File
$filesToSign += $msiFiles
if ($filesToSign.Count -eq 0) {
Write-Host "[ERROR] No files found to sign"
exit 1
}
Write-Host "Found $($filesToSign.Count) files to sign:"
foreach ($f in $filesToSign) { Write-Host " - $($f.Name)" }
Write-Host ""
$signedCount = 0
foreach ($file in $filesToSign) {
Write-Host "Signing: $($file.Name)"
# Get PKCS11 config file path (set by DigiCert action)
$pkcs11Config = $env:PKCS11_CONFIG
if (-not $pkcs11Config) {
Write-Host "[ERROR] PKCS11_CONFIG environment variable not set"
Write-Host "DigiCert KeyLocker action may not have run correctly"
exit 1
}
Write-Host "Using PKCS11 config: $pkcs11Config"
# Try signing with certificate fingerprint first (if available)
$fingerprint = "${{ secrets.SM_CODE_SIGNING_CERT_SHA1_HASH }}"
if ($fingerprint -and $fingerprint -ne "") {
Write-Host "Attempting to sign with certificate fingerprint..."
$output = & smctl sign --fingerprint "$fingerprint" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
} else {
Write-Host "No fingerprint provided, using keypair alias..."
# Use smctl to sign with keypair alias
$output = & smctl sign --keypair-alias "${{ secrets.SM_KEYPAIR_ALIAS }}" --input "$($file.FullName)" --config-file "$pkcs11Config" --verbose 2>&1
$exitCode = $LASTEXITCODE
}
Write-Host "Exit code: $exitCode"
Write-Host "Output: $output"
# Check if output contains "FAILED" even with exit code 0
if ($output -match "FAILED" -or $output -match "error" -or $output -match "Error") {
Write-Host ""
Write-Host "[ERROR] Signing failed for $($file.Name)"
Write-Host "[ERROR] smctl returned success but output indicates failure"
Write-Host ""
Write-Host "Possible issues:"
Write-Host " 1. Certificate not fully synced to KeyLocker (wait a few minutes)"
Write-Host " 2. Incorrect keypair alias"
Write-Host " 3. API key lacks signing permissions"
Write-Host ""
Write-Host "Please verify in DigiCert ONE portal:"
Write-Host " - Certificate status is 'Issued' (not Pending)"
Write-Host " - Keypair status is 'Online'"
Write-Host " - 'Can sign' is set to 'Yes'"
exit 1
}
if ($exitCode -ne 0) {
Write-Host "[ERROR] Failed to sign $($file.Name)"
Write-Host "Full error output:"
Write-Host $output
exit 1
}
$signedCount++
Write-Host "[SUCCESS] Signed: $($file.Name)"
Write-Host ""
}
Write-Host "=== Summary ==="
Write-Host "[SUCCESS] Signed $signedCount/$($filesToSign.Count) files successfully"
- name: Verify notarization (macOS only)
if: matrix.platform == 'macos-15' || matrix.platform == 'macos-15-intel'
run: |
echo "🔍 Verifying notarization status..."
cd ./frontend/src-tauri/target
DMG_FILE=$(find . -name "*.dmg" | head -1)
if [ -n "$DMG_FILE" ]; then
echo "Found DMG: $DMG_FILE"
echo "Checking notarization ticket..."
spctl -a -vvv -t install "$DMG_FILE" || echo "⚠️ Notarization check failed or not yet complete"
stapler validate "$DMG_FILE" || echo "⚠️ No notarization ticket attached"
else
echo "⚠️ No DMG file found to verify"
fi
- name: Rename artifacts
shell: bash
run: |
mkdir -p ./dist
cd ./frontend/src-tauri/target
# Find and rename artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
find . -name "*.exe" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.exe" \;
find . -name "*.msi" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.msi" \;
# Disabled Mac builds
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then
# find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
# find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
find . -name "*.dmg" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.dmg" \;
find . -name "*.app" -exec cp -r {} "../../../dist/Stirling-PDF-${{ matrix.name }}.app" \;
else
find . -name "*.deb" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.deb" \;
find . -name "*.AppImage" -exec cp {} "../../../dist/Stirling-PDF-${{ matrix.name }}.AppImage" \;
fi
- name: Verify Windows Code Signature
if: matrix.platform == 'windows-latest'
shell: pwsh
run: |
Write-Host "Verifying Windows code signatures..."
$exePath = "./dist/Stirling-PDF-${{ matrix.name }}.exe"
$msiPath = "./dist/Stirling-PDF-${{ matrix.name }}.msi"
$allSigned = $true
$usingKeyLocker = "${{ env.SM_API_KEY }}" -ne ""
$usingPfx = "${{ env.WINDOWS_CERTIFICATE }}" -ne ""
# Check EXE signature
if (Test-Path $exePath) {
$exeSig = Get-AuthenticodeSignature -FilePath $exePath
Write-Host "EXE Signature Status: $($exeSig.Status)"
Write-Host "EXE Signer: $($exeSig.SignerCertificate.Subject)"
Write-Host "EXE Timestamp: $($exeSig.TimeStamperCertificate.NotAfter)"
if ($exeSig.Status -ne "Valid") {
Write-Host "[WARNING] EXE is not properly signed (Status: $($exeSig.Status))"
if ($usingKeyLocker -or $usingPfx) {
Write-Host "[ERROR] Certificate was provided but signing failed"
$allSigned = $false
} else {
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
}
} else {
Write-Host "[SUCCESS] EXE is properly signed"
}
}
# Check MSI signature
if (Test-Path $msiPath) {
$msiSig = Get-AuthenticodeSignature -FilePath $msiPath
Write-Host "MSI Signature Status: $($msiSig.Status)"
Write-Host "MSI Signer: $($msiSig.SignerCertificate.Subject)"
Write-Host "MSI Timestamp: $($msiSig.TimeStamperCertificate.NotAfter)"
if ($msiSig.Status -ne "Valid") {
Write-Host "[WARNING] MSI is not properly signed (Status: $($msiSig.Status))"
if ($usingKeyLocker -or $usingPfx) {
Write-Host "[ERROR] Certificate was provided but signing failed"
$allSigned = $false
} else {
Write-Host "[INFO] Building unsigned binary (no certificate provided)"
}
} else {
Write-Host "[SUCCESS] MSI is properly signed"
}
}
if (($usingKeyLocker -or $usingPfx) -and -not $allSigned) {
Write-Host "[ERROR] Code signing verification failed"
exit 1
} else {
Write-Host "[SUCCESS] Code signature verification completed"
}
- name: Upload artifacts
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
@@ -273,7 +582,7 @@ jobs:
shell: bash
run: |
cd ./frontend/src-tauri/target
# Check for expected artifacts based on platform
if [ "${{ matrix.platform }}" = "windows-latest" ]; then
echo "Checking for Windows artifacts..."
@@ -282,14 +591,13 @@ jobs:
echo "❌ No Windows executable found"
exit 1
fi
# Disabled Mac builds
# elif [ "${{ matrix.platform }}" = "macos-latest" ] || [ "${{ matrix.platform }}" = "macos-13" ]; then
# echo "Checking for macOS artifacts..."
# find . -name "*.dmg" -o -name "*.app" | head -5
# if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
# echo "❌ No macOS artifacts found"
# exit 1
# fi
elif [ "${{ matrix.platform }}" = "macos-15" ] || [ "${{ matrix.platform }}" = "macos-15-intel" ]; then
echo "Checking for macOS artifacts..."
find . -name "*.dmg" -o -name "*.app" | head -5
if [ $(find . -name "*.dmg" -o -name "*.app" | wc -l) -eq 0 ]; then
echo "❌ No macOS artifacts found"
exit 1
fi
else
echo "Checking for Linux artifacts..."
find . -name "*.deb" -o -name "*.AppImage" | head -5
@@ -298,7 +606,7 @@ jobs:
exit 1
fi
fi
echo "✅ Build artifacts found for ${{ matrix.name }}"
- name: Test artifact sizes
@@ -331,4 +639,4 @@ jobs:
echo "❌ Some Tauri builds failed."
echo "Please check the logs and fix any issues."
exit 1
fi
fi
+37 -37
View File
@@ -115,46 +115,46 @@ Stirling-PDF currently supports 40 languages!
| Language | Progress |
| -------------------------------------------- | -------------------------------------- |
| Arabic (العربية) (ar_AR) | ![83%](https://geps.dev/progress/83) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![32%](https://geps.dev/progress/32) |
| Basque (Euskara) (eu_ES) | ![18%](https://geps.dev/progress/18) |
| Bulgarian (Български) (bg_BG) | ![35%](https://geps.dev/progress/35) |
| Catalan (Català) (ca_CA) | ![34%](https://geps.dev/progress/34) |
| Croatian (Hrvatski) (hr_HR) | ![31%](https://geps.dev/progress/31) |
| Czech (Česky) (cs_CZ) | ![34%](https://geps.dev/progress/34) |
| Danish (Dansk) (da_DK) | ![30%](https://geps.dev/progress/30) |
| Dutch (Nederlands) (nl_NL) | ![30%](https://geps.dev/progress/30) |
| Arabic (العربية) (ar_AR) | ![94%](https://geps.dev/progress/94) |
| Azerbaijani (Azərbaycan Dili) (az_AZ) | ![93%](https://geps.dev/progress/93) |
| Basque (Euskara) (eu_ES) | ![93%](https://geps.dev/progress/93) |
| Bulgarian (Български) (bg_BG) | ![94%](https://geps.dev/progress/94) |
| Catalan (Català) (ca_CA) | ![93%](https://geps.dev/progress/93) |
| Croatian (Hrvatski) (hr_HR) | ![93%](https://geps.dev/progress/93) |
| Czech (Česky) (cs_CZ) | ![91%](https://geps.dev/progress/91) |
| Danish (Dansk) (da_DK) | ![92%](https://geps.dev/progress/92) |
| Dutch (Nederlands) (nl_NL) | ![93%](https://geps.dev/progress/93) |
| 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) | ![82%](https://geps.dev/progress/82) |
| German (Deutsch) (de_DE) | ![84%](https://geps.dev/progress/84) |
| Greek (Ελληνικά) (el_GR) | ![34%](https://geps.dev/progress/34) |
| Hindi (हिंदी) (hi_IN) | ![34%](https://geps.dev/progress/34) |
| Hungarian (Magyar) (hu_HU) | ![38%](https://geps.dev/progress/38) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![31%](https://geps.dev/progress/31) |
| Irish (Gaeilge) (ga_IE) | ![34%](https://geps.dev/progress/34) |
| Italian (Italiano) (it_IT) | ![84%](https://geps.dev/progress/84) |
| Japanese (日本語) (ja_JP) | ![62%](https://geps.dev/progress/62) |
| Korean (한국어) (ko_KR) | ![34%](https://geps.dev/progress/34) |
| Norwegian (Norsk) (no_NB) | ![32%](https://geps.dev/progress/32) |
| Persian (فارسی) (fa_IR) | ![34%](https://geps.dev/progress/34) |
| Polish (Polski) (pl_PL) | ![36%](https://geps.dev/progress/36) |
| Portuguese (Português) (pt_PT) | ![34%](https://geps.dev/progress/34) |
| Portuguese Brazilian (Português) (pt_BR) | ![83%](https://geps.dev/progress/83) |
| Romanian (Română) (ro_RO) | ![28%](https://geps.dev/progress/28) |
| Russian (Русский) (ru_RU) | ![83%](https://geps.dev/progress/83) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![37%](https://geps.dev/progress/37) |
| Simplified Chinese (简体中文) (zh_CN) | ![85%](https://geps.dev/progress/85) |
| Slovakian (Slovensky) (sk_SK) | ![26%](https://geps.dev/progress/26) |
| Slovenian (Slovenščina) (sl_SI) | ![36%](https://geps.dev/progress/36) |
| Spanish (Español) (es_ES) | ![84%](https://geps.dev/progress/84) |
| Swedish (Svenska) (sv_SE) | ![33%](https://geps.dev/progress/33) |
| Thai (ไทย) (th_TH) | ![31%](https://geps.dev/progress/31) |
| French (Français) (fr_FR) | ![93%](https://geps.dev/progress/93) |
| German (Deutsch) (de_DE) | ![93%](https://geps.dev/progress/93) |
| Greek (Ελληνικά) (el_GR) | ![93%](https://geps.dev/progress/93) |
| Hindi (हिंदी) (hi_IN) | ![94%](https://geps.dev/progress/94) |
| Hungarian (Magyar) (hu_HU) | ![94%](https://geps.dev/progress/94) |
| Indonesian (Bahasa Indonesia) (id_ID) | ![93%](https://geps.dev/progress/93) |
| Irish (Gaeilge) (ga_IE) | ![94%](https://geps.dev/progress/94) |
| Italian (Italiano) (it_IT) | ![93%](https://geps.dev/progress/93) |
| Japanese (日本語) (ja_JP) | ![94%](https://geps.dev/progress/94) |
| Korean (한국어) (ko_KR) | ![94%](https://geps.dev/progress/94) |
| Norwegian (Norsk) (no_NB) | ![93%](https://geps.dev/progress/93) |
| Persian (فارسی) (fa_IR) | ![94%](https://geps.dev/progress/94) |
| Polish (Polski) (pl_PL) | ![93%](https://geps.dev/progress/93) |
| Portuguese (Português) (pt_PT) | ![93%](https://geps.dev/progress/93) |
| Portuguese Brazilian (Português) (pt_BR) | ![93%](https://geps.dev/progress/93) |
| Romanian (Română) (ro_RO) | ![93%](https://geps.dev/progress/93) |
| Russian (Русский) (ru_RU) | ![94%](https://geps.dev/progress/94) |
| Serbian Latin alphabet (Srpski) (sr_LATN_RS) | ![93%](https://geps.dev/progress/93) |
| Simplified Chinese (简体中文) (zh_CN) | ![94%](https://geps.dev/progress/94) |
| Slovakian (Slovensky) (sk_SK) | ![93%](https://geps.dev/progress/93) |
| Slovenian (Slovenščina) (sl_SI) | ![94%](https://geps.dev/progress/94) |
| Spanish (Español) (es_ES) | ![94%](https://geps.dev/progress/94) |
| Swedish (Svenska) (sv_SE) | ![93%](https://geps.dev/progress/93) |
| Thai (ไทย) (th_TH) | ![93%](https://geps.dev/progress/93) |
| Tibetan (བོད་ཡིག་) (bo_CN) | ![65%](https://geps.dev/progress/65) |
| Traditional Chinese (繁體中文) (zh_TW) | ![38%](https://geps.dev/progress/38) |
| Turkish (Türkçe) (tr_TR) | ![37%](https://geps.dev/progress/37) |
| Ukrainian (Українська) (uk_UA) | ![36%](https://geps.dev/progress/36) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![28%](https://geps.dev/progress/28) |
| Traditional Chinese (繁體中文) (zh_TW) | ![94%](https://geps.dev/progress/94) |
| Turkish (Türkçe) (tr_TR) | ![94%](https://geps.dev/progress/94) |
| Ukrainian (Українська) (uk_UA) | ![94%](https://geps.dev/progress/94) |
| Vietnamese (Tiếng Việt) (vi_VN) | ![93%](https://geps.dev/progress/93) |
| Malayalam (മലയാളം) (ml_IN) | ![73%](https://geps.dev/progress/73) |
## Stirling PDF Enterprise
+258
View File
@@ -0,0 +1,258 @@
# Windows Code Signing Setup Guide
This guide explains how to set up Windows code signing for Stirling-PDF desktop application builds.
## Overview
Windows code signing is essential for:
- Preventing Windows SmartScreen warnings
- Building trust with users
- Enabling Microsoft Store distribution
- Professional application distribution
## Certificate Types
### OV Certificate (Organization Validated)
- More affordable option
- Requires business verification
- May trigger SmartScreen warnings initially until reputation builds
- Suitable for most independent software vendors
### EV Certificate (Extended Validation)
- Premium option with immediate SmartScreen reputation
- Requires hardware security module (HSM) or cloud-based signing
- Higher cost but provides immediate trust
- Required since June 2023 for new certificates
## Obtaining a Certificate
### Certificate Authorities
Popular certificate authorities for Windows code signing:
- DigiCert
- Sectigo (formerly Comodo)
- GlobalSign
- SSL.com
### Certificate Format
You'll receive a certificate in one of these formats:
- `.pfx` or `.p12` (preferred - contains both certificate and private key)
- `.cer` + private key (needs conversion to .pfx)
### Converting to PFX (if needed)
If you have separate certificate and private key files:
```bash
openssl pkcs12 -export -out certificate.pfx -inkey private-key.key -in certificate.cer
```
## Setting Up GitHub Secrets
### Required Secrets
Navigate to your GitHub repository → Settings → Secrets and variables → Actions
Add the following secrets:
#### 1. `WINDOWS_CERTIFICATE`
- **Description**: Base64-encoded .pfx certificate file
- **How to create**:
**On macOS/Linux:**
```bash
base64 -i certificate.pfx | pbcopy # Copies to clipboard
```
**On Windows (PowerShell):**
```powershell
[Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx")) | Set-Clipboard
```
Paste the entire base64 string into the GitHub secret.
#### 2. `WINDOWS_CERTIFICATE_PASSWORD`
- **Description**: Password for the .pfx certificate
- **Value**: The password you set when creating/exporting the .pfx file
### Optional Secrets for Tauri Updater
If you're using Tauri's built-in updater feature:
#### `TAURI_SIGNING_PRIVATE_KEY`
- Generated using Tauri CLI: `npm run tauri signer generate`
- Used for update package verification
#### `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`
- Password for the Tauri signing key
## Configuration Files
### 1. Tauri Configuration (frontend/src-tauri/tauri.conf.json)
The Windows signing configuration is already set up:
```json
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": "http://timestamp.digicert.com"
}
```
**Configuration Options:**
- `certificateThumbprint`: Automatically extracted from imported certificate (leave as `null`)
- `digestAlgorithm`: Hashing algorithm - `sha256` is recommended
- `timestampUrl`: Timestamp server to prove signing time (survives certificate expiration)
**Alternative Timestamp Servers:**
- DigiCert: `http://timestamp.digicert.com`
- Sectigo: `http://timestamp.sectigo.com`
- GlobalSign: `http://timestamp.globalsign.com`
### 2. GitHub Workflow (.github/workflows/tauri-build.yml)
The workflow includes three Windows signing steps:
1. **Import Certificate**: Decodes and imports the .pfx certificate into Windows certificate store
2. **Build Tauri App**: Builds and signs the application using the imported certificate
3. **Verify Signature**: Validates that both .exe and .msi files are properly signed
## Testing the Setup
### 1. Local Testing (Windows Only)
Before pushing to GitHub, test locally:
```powershell
# Set environment variables
$env:WINDOWS_CERTIFICATE = [Convert]::ToBase64String([IO.File]::ReadAllBytes("certificate.pfx"))
$env:WINDOWS_CERTIFICATE_PASSWORD = "your-certificate-password"
# Build the application
cd frontend
npm run tauri build
# Verify the signature
Get-AuthenticodeSignature "./src-tauri/target/release/bundle/msi/Stirling-PDF_*.msi"
```
### 2. GitHub Actions Testing
1. Push your changes to a branch
2. Manually trigger the workflow:
- Go to Actions → Build Tauri Applications
- Click "Run workflow"
- Select "windows" platform
3. Check the build logs for:
- ✅ Certificate import success
- ✅ Build completion
- ✅ Signature verification
### 3. Verifying Signed Binaries
After downloading the built artifacts:
**Windows (PowerShell):**
```powershell
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.exe"
Get-AuthenticodeSignature "Stirling-PDF-windows-x86_64.msi"
```
Look for:
- Status: `Valid`
- Signer: Your organization name
- Timestamp: Recent date/time
**Windows (GUI):**
1. Right-click the .exe or .msi file
2. Select "Properties"
3. Go to "Digital Signatures" tab
4. Verify signature details
## Troubleshooting
### "HashMismatch" Status
- Certificate doesn't match the binary
- Possible file corruption during download
- Re-download and verify
### "NotSigned" Status
- Certificate wasn't imported correctly
- Check GitHub secrets are set correctly
- Verify base64 encoding is complete (no truncation)
### "UnknownError" Status
- Timestamp server unreachable
- Try alternative timestamp URL in tauri.conf.json
- Check network connectivity in GitHub Actions
### SmartScreen Still Shows Warnings
- Normal for OV certificates initially
- Reputation builds over time with user downloads
- Consider EV certificate for immediate reputation
### Certificate Not Found During Build
- Verify `WINDOWS_CERTIFICATE` secret is set
- Check base64 encoding is correct (no extra whitespace)
- Ensure password is correct
## Security Best Practices
1. **Never commit certificates to version control**
- Keep .pfx files secure and backed up
- Use GitHub secrets for CI/CD
2. **Rotate certificates before expiration**
- Set calendar reminders
- Update GitHub secrets with new certificate
3. **Use strong passwords**
- Certificate password should be complex
- Store securely (password manager)
4. **Monitor certificate usage**
- Review GitHub Actions logs
- Set up notifications for failed builds
5. **Limit access to secrets**
- Only repository admins should access secrets
- Audit secret access regularly
## Certificate Lifecycle
### Before Expiration
1. Obtain new certificate from CA (typically annual renewal)
2. Convert to .pfx format if needed
3. Update `WINDOWS_CERTIFICATE` secret with new base64-encoded certificate
4. Update `WINDOWS_CERTIFICATE_PASSWORD` if password changed
5. Test build to verify new certificate works
### Expired Certificates
- Signed binaries remain valid (timestamp proves signing time)
- New builds will fail until certificate is renewed
- Users can still install previously signed versions
## Cost Considerations
### Certificate Costs (Annual, as of 2024)
- **OV Certificate**: $100-400/year
- **EV Certificate**: $400-1000/year
### Choosing the Right Certificate
- **Open source / early stage**: Start with OV
- **Commercial / enterprise**: Consider EV for better trust
- **Microsoft Store**: EV certificate required
## Additional Resources
- [Tauri Windows Signing Documentation](https://v2.tauri.app/distribute/sign/windows/)
- [Microsoft Code Signing Overview](https://docs.microsoft.com/windows/win32/seccrypto/cryptography-tools)
- [DigiCert Code Signing Guide](https://www.digicert.com/signing/code-signing-certificates)
- [Windows SmartScreen FAQ](https://support.microsoft.com/windows/smartscreen-faq)
## Support
If you encounter issues with Windows code signing:
1. Check GitHub Actions logs for detailed error messages
2. Verify all secrets are set correctly
3. Test certificate locally first (Windows environment required)
4. Open an issue in the repository with relevant logs (remove sensitive data)
@@ -0,0 +1,37 @@
package stirling.software.common.annotations.api;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.tags.Tag;
/**
* Combined annotation for Invite management controllers.
* Includes @RestController, @RequestMapping("/api/v1/invite"), and OpenAPI @Tag.
*/
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@RestController
@RequestMapping("/api/v1/invite")
@Tag(
name = "Invite",
description =
"""
Invite-link generation and acceptance endpoints for onboarding new users.
Provides the ability to issue invitation tokens, send optional email invites,
validate and accept invite links, and manage pending invitations for teams.
Typical use cases include:
• Admin workflows for issuing time-limited invitations to external users
• Self-service invite acceptance and team assignment
• License limit enforcement when provisioning new accounts
Target users: administrators and automation scripts orchestrating user onboarding.
""")
public @interface InviteApi {}
@@ -505,10 +505,19 @@ public class ApplicationProperties {
public static class Ui {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
}
public String getLogoStyle() {
// Validate and return either "modern" or "classic"
if ("modern".equalsIgnoreCase(logoStyle)) {
return "modern";
}
return "classic"; // default
}
}
@Data
@@ -8,4 +8,6 @@ public interface UserServiceInterface {
long getTotalUsersCount();
boolean isCurrentUserAdmin();
boolean isCurrentUserFirstLogin();
}
@@ -1,658 +0,0 @@
package stirling.software.common.util;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.*;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public final class FormUtils {
private FormUtils() {}
public static boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public static void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Do not mutate the source AcroForm; skip bad widgets during copy
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Refresh appearances to ensure widgets render correctly across viewers
try {
// Use reflection to avoid compile-time dependency on PDFBox version
Method m = newAcroForm.getClass().getMethod("refreshAppearances");
m.invoke(newAcroForm);
} catch (NoSuchMethodException nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; relying on NeedAppearances.");
} catch (Throwable t) {
log.warn("Failed to refresh field appearances via AcroForm: {}", t.getMessage(), t);
}
}
private static void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (sourceField instanceof PDTextField pdtextfield) {
createSimpleTextField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdtextfield,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDCheckBox pdCheckBox) {
createSimpleCheckBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdCheckBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDRadioButton pdRadioButton) {
createSimpleRadioButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdRadioButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDComboBox pdComboBox) {
createSimpleComboBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdComboBox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDListBox pdlistbox) {
createSimpleListBoxField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdlistbox,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDSignatureField pdSignatureField) {
createSimpleSignatureField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdSignatureField,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} else if (sourceField instanceof PDPushButton pdPushButton) {
createSimplePushButtonField(
newAcroForm,
destinationPage,
destinationAnnotations,
pdPushButton,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private static void createSimpleTextField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDTextField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTextField newTextField = new PDTextField(newAcroForm);
newTextField.setDefaultAppearance("/Helv 12 Tf 0 g");
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newTextField,
sourceField.getPartialName(),
"textField",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getValueAsString() != null) {
newTextField.setValue(sourceField.getValueAsString());
}
} catch (Exception e) {
log.warn(
"Failed to create text field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleCheckBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDCheckBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDCheckBox newCheckBox = new PDCheckBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newCheckBox,
sourceField.getPartialName(),
"checkBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.isChecked()) {
newCheckBox.check();
} else {
newCheckBox.unCheck();
}
} catch (Exception e) {
log.warn(
"Failed to create checkbox field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleRadioButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDRadioButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDRadioButton newRadioButton = new PDRadioButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newRadioButton,
sourceField.getPartialName(),
"radioButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getExportValues() != null) {
newRadioButton.setExportValues(sourceField.getExportValues());
}
if (sourceField.getValue() != null) {
newRadioButton.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create radio button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleComboBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDComboBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDComboBox newComboBox = new PDComboBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newComboBox,
sourceField.getPartialName(),
"comboBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newComboBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newComboBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create combo box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleListBoxField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDListBox sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDListBox newListBox = new PDListBox(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newListBox,
sourceField.getPartialName(),
"listBox",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
if (sourceField.getOptions() != null) {
newListBox.setOptions(sourceField.getOptions());
}
if (sourceField.getValue() != null && !sourceField.getValue().isEmpty()) {
newListBox.setValue(sourceField.getValue());
}
} catch (Exception e) {
log.warn(
"Failed to create list box field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimpleSignatureField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDSignatureField sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDSignatureField newSignatureField = new PDSignatureField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newSignatureField,
sourceField.getPartialName(),
"signature",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
} catch (Exception e) {
log.warn(
"Failed to create signature field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static void createSimplePushButtonField(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDPushButton sourceField,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDPushButton newPushButton = new PDPushButton(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newPushButton,
sourceField.getPartialName(),
"pushButton",
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
} catch (Exception e) {
log.warn(
"Failed to create push button field '{}': {}",
sourceField.getPartialName(),
e.getMessage(),
e);
}
}
private static <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private static String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private static Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets != null) {
for (PDAnnotationWidget w : widgets) {
if (w != null) {
map.put(w, field);
}
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}
@@ -38,6 +38,7 @@ public class GeneralUtils {
Set.of(
"OCR images.json",
"Prepare-pdfs-for-email.json",
"Pre-publish-sanitization.json",
"split-rotate-auto-rename.json");
private final String DEFAULT_WEBUI_CONFIGS_DIR = "defaultWebUIConfigs";
@@ -1,5 +1,6 @@
package stirling.software.common.util;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
@@ -241,6 +242,11 @@ public final class RegexPatternUtils {
return getPattern("\\s+");
}
/** Pattern for matching punctuation characters */
public Pattern getPunctuationPattern() {
return getPattern("[\\p{Punct}]+");
}
/** Pattern for matching newlines (Windows and Unix style) */
public Pattern getNewlinesPattern() {
return getPattern("\\r?\\n");
@@ -286,6 +292,24 @@ public final class RegexPatternUtils {
return getPattern("[^a-zA-Z0-9 ]");
}
/** Pattern for removing bracketed indices like [0], [Child], etc. in field names */
public Pattern getFormFieldBracketPattern() {
return getPattern("\\[[^\\]]*\\]");
}
/** Pattern that replaces underscores or hyphens with spaces */
public Pattern getUnderscoreHyphenPattern() {
return getPattern("[-_]+");
}
/**
* Pattern that matches camelCase or alpha-numeric boundaries to allow inserting spaces.
* Examples: firstName -> first Name, field1 -> field 1, A5Size -> A5 Size
*/
public Pattern getCamelCaseBoundaryPattern() {
return getPattern("(?<=[a-z])(?=[A-Z])|(?<=[A-Za-z])(?=\\d)|(?<=\\d)(?=[A-Za-z])");
}
/** Pattern for removing angle brackets */
public Pattern getAngleBracketsPattern() {
return getPattern("[<>]");
@@ -335,6 +359,26 @@ public final class RegexPatternUtils {
return getPattern("[1-9][0-9]{0,2}");
}
/**
* Pattern for very simple generic field tokens such as "field", "text", "checkbox" with
* optional numeric suffix (e.g. "field 1"). Case-insensitive.
*/
public Pattern getGenericFieldNamePattern() {
return getPattern(
"^(field|text|checkbox|radio|button|signature|name|value|option|select|choice)(\\s*\\d+)?$",
Pattern.CASE_INSENSITIVE);
}
/** Pattern for short identifiers like t1, f2, a10 etc. */
public Pattern getSimpleFormFieldPattern() {
return getPattern("^[A-Za-z]{1,2}\\s*\\d{1,3}$");
}
/** Pattern for optional leading 't' followed by digits, e.g., t1, 1, t 12. */
public Pattern getOptionalTNumericPattern() {
return getPattern("^(?:t\\s*)?\\d+$", Pattern.CASE_INSENSITIVE);
}
/** Pattern for validating mathematical expressions */
public Pattern getMathExpressionPattern() {
return getPattern("[0-9n+\\-*/() ]+");
@@ -467,6 +511,11 @@ public final class RegexPatternUtils {
return getPattern("/");
}
/** Supported logical types when creating new fields programmatically */
public Set<String> getSupportedNewFieldTypes() {
return Set.of("text", "checkbox", "combobox", "listbox", "radio", "button", "signature");
}
/**
* Pre-compile commonly used patterns for immediate availability. This eliminates first-call
* compilation overhead for frequent patterns.
@@ -75,6 +75,9 @@ public class RequestUriUtils {
|| 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") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
@@ -5,6 +5,7 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.common.model.api.misc.HighContrastColorCombination;
import stirling.software.common.model.api.misc.ReplaceAndInvert;
import stirling.software.common.util.TempFileManager;
@@ -18,6 +19,7 @@ import stirling.software.common.util.misc.ReplaceAndInvertColorStrategy;
public class ReplaceAndInvertColorFactory {
private final TempFileManager tempFileManager;
private final EndpointConfiguration endpointConfiguration;
public ReplaceAndInvertColorStrategy replaceAndInvert(
MultipartFile file,
@@ -26,6 +28,13 @@ public class ReplaceAndInvertColorFactory {
String backGroundColor,
String textColor) {
// Check Ghostscript availability for CMYK conversion
if (replaceAndInvertOption == ReplaceAndInvert.COLOR_SPACE_CONVERSION
&& !endpointConfiguration.isGroupEnabled("Ghostscript")) {
throw new IllegalStateException(
"CMYK color space conversion requires Ghostscript, which is not available on this system");
}
return switch (replaceAndInvertOption) {
case CUSTOM_COLOR, HIGH_CONTRAST_COLOR ->
new CustomColorReplaceStrategy(
@@ -12,6 +12,8 @@ import java.util.Properties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.web.context.WebServerInitializedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
import org.springframework.scheduling.annotation.EnableScheduling;
@@ -198,6 +200,14 @@ public class SPDFApplication {
// }
}
@EventListener
public void onWebServerInitialized(WebServerInitializedEvent event) {
int actualPort = event.getWebServer().getPort();
serverPortStatic = String.valueOf(actualPort);
// Log the actual runtime port for Tauri to parse
log.info("Stirling-PDF running on port: {}", actualPort);
}
private static void printStartupLogs() {
log.info("Stirling-PDF Started.");
String url = baseUrlStatic + ":" + getStaticPort() + contextPathStatic;
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
@Slf4j
public class EndpointConfiguration {
public enum DisableReason {
CONFIG,
DEPENDENCY,
UNKNOWN
}
public static class EndpointAvailability {
private final boolean enabled;
private final DisableReason reason;
public EndpointAvailability(boolean enabled, DisableReason reason) {
this.enabled = enabled;
this.reason = reason;
}
public boolean isEnabled() {
return enabled;
}
public DisableReason getReason() {
return reason;
}
}
private static final String REMOVE_BLANKS = "remove-blanks";
private final ApplicationProperties applicationProperties;
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private Set<String> disabledGroups = new HashSet<>();
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher;
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
processEnvironmentConfigs();
}
private String normalizeEndpoint(String endpoint) {
if (endpoint == null) {
return null;
}
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
}
public void enableEndpoint(String endpoint) {
endpointStatuses.put(endpoint, true);
log.debug("Enabled endpoint: {}", endpoint);
String normalized = normalizeEndpoint(endpoint);
endpointStatuses.put(normalized, true);
endpointDisableReasons.remove(normalized);
log.debug("Enabled endpoint: {}", normalized);
}
public void disableEndpoint(String endpoint) {
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
log.debug("Disabling endpoint: {}", endpoint);
disableEndpoint(endpoint, DisableReason.CONFIG);
}
public void disableEndpoint(String endpoint, DisableReason reason) {
String normalized = normalizeEndpoint(endpoint);
if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
log.debug("Disabling endpoint: {}", normalized);
}
endpointStatuses.put(endpoint, false);
endpointStatuses.put(normalized, false);
endpointDisableReasons.put(normalized, reason);
}
public boolean isEndpointEnabled(String endpoint) {
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
}
public void disableGroup(String group) {
disableGroup(group, DisableReason.CONFIG);
}
public void disableGroup(String group, DisableReason reason) {
if (disabledGroups.add(group)) {
if (isToolGroup(group)) {
log.debug(
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
group);
}
}
groupDisableReasons.put(group, reason);
// Only cascade to endpoints for *functional* groups
if (!isToolGroup(group)) {
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
endpoints.forEach(this::disableEndpoint);
endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
}
}
}
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
if (disabledGroups.remove(group)) {
log.debug("Enabling group: {}", group);
}
groupDisableReasons.remove(group);
Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) {
endpoints.forEach(this::enableEndpoint);
}
}
public EndpointAvailability getEndpointAvailability(String endpoint) {
boolean enabled = isEndpointEnabled(endpoint);
DisableReason reason = enabled ? null : determineDisableReason(endpoint);
return new EndpointAvailability(enabled, reason);
}
private DisableReason determineDisableReason(String endpoint) {
String normalized = normalizeEndpoint(endpoint);
if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG);
}
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
String group = entry.getKey();
Set<String> endpoints = entry.getValue();
if (!disabledGroups.contains(group) || endpoints == null) {
continue;
}
if (endpoints.contains(normalized)) {
return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG);
}
}
return DisableReason.UNKNOWN;
}
public Set<String> getDisabledGroups() {
return new HashSet<>(disabledGroups);
}
@@ -261,6 +334,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "cbz-to-pdf");
addEndpointToGroup("Convert", "pdf-to-cbz");
// Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password");
@@ -294,6 +369,12 @@ public class EndpointConfiguration {
addEndpointToGroup("Other", "replace-and-invert-color-pdf");
addEndpointToGroup("Other", "multi-tool");
// Adding form-related endpoints to "Other" group
addEndpointToGroup("Other", "fields");
addEndpointToGroup("Other", "modify-fields");
addEndpointToGroup("Other", "delete-fields");
addEndpointToGroup("Other", "fill");
// Adding endpoints to "Advance" group
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "compress-pdf");
@@ -388,6 +469,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-markdown");
addEndpointToGroup("Java", "add-attachments");
addEndpointToGroup("Java", "compress-pdf");
addEndpointToGroup("Java", "cbz-to-pdf");
addEndpointToGroup("Java", "pdf-to-cbz");
addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript
@@ -403,8 +486,6 @@ public class EndpointConfiguration {
/* Ghostscript */
addEndpointToGroup("Ghostscript", "repair");
addEndpointToGroup("Ghostscript", "compress-pdf");
addEndpointToGroup("Ghostscript", "crop");
addEndpointToGroup("Ghostscript", "replace-invert-pdf");
/* tesseract */
addEndpointToGroup("tesseract", "ocr-pdf");
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.RegexPatternUtils;
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
if (affectedGroups != null) {
for (String group : affectedGroups) {
List<String> affectedFeatures = getAffectedFeatures(group);
endpointConfiguration.disableGroup(group);
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
command,
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
if (!pythonAvailable) {
List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("Python");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
String.join(", ", pythonFeatures),
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
int exitCode = process.waitFor();
if (exitCode != 0) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures));
}
} catch (Exception e) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV");
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn(
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
e.getMessage(),
@@ -28,6 +28,8 @@ public class InitialSetup {
private final ApplicationProperties applicationProperties;
private static boolean isNewServer = false;
@PostConstruct
public void init() throws IOException {
initUUIDKey();
@@ -88,6 +90,13 @@ public class InitialSetup {
}
public void initSetAppVersion() throws IOException {
// Check if this is a new server before setting the version
String existingVersion = applicationProperties.getAutomaticallyGenerated().getAppVersion();
isNewServer =
existingVersion == null
|| existingVersion.isEmpty()
|| existingVersion.equals("0.0.0");
String appVersion = "0.0.0";
Resource resource = new ClassPathResource("version.properties");
Properties props = new Properties();
@@ -99,4 +108,8 @@ public class InitialSetup {
GeneralUtils.saveKeyToSettings("AutomaticallyGenerated.appVersion", appVersion);
applicationProperties.getAutomaticallyGenerated().setAppVersion(appVersion);
}
public static boolean isNewServer() {
return isNewServer;
}
}
@@ -0,0 +1,49 @@
package stirling.software.SPDF.config;
import java.io.IOException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
/**
* Filter to track browser IDs for Weekly Active Users (WAU) counting. Only active when security is
* disabled (no-login mode).
*/
@Component
@ConditionalOnProperty(name = "security.enableLogin", havingValue = "false")
@RequiredArgsConstructor
@Slf4j
public class WAUTrackingFilter implements Filter {
private final WeeklyActiveUsersService wauService;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
if (request instanceof HttpServletRequest httpRequest) {
// Extract browser ID from header
String browserId = httpRequest.getHeader("X-Browser-Id");
if (browserId != null && !browserId.trim().isEmpty()) {
// Record browser access
wauService.recordBrowserAccess(browserId);
}
}
// Continue the filter chain
chain.doFilter(request, response);
}
}
@@ -46,8 +46,24 @@ public class WebMvcConfig implements WebMvcConfigurer {
"tauri://localhost",
"http://tauri.localhost",
"https://tauri.localhost")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else if (hasConfiguredOrigins) {
@@ -63,13 +79,53 @@ public class WebMvcConfig implements WebMvcConfigurer {
.toArray(new String[0]);
registry.addMapping("/**")
.allowedOrigins(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*")
.allowedOriginPatterns(allowedOrigins)
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
} else {
// Default to allowing all origins when nothing is configured
logger.info(
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN",
"X-Browser-Id")
.exposedHeaders(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type")
.allowCredentials(true)
.maxAge(3600);
}
// If no origins are configured and not in Tauri mode, CORS is not enabled (secure by
// default)
}
}
@@ -22,6 +22,7 @@ import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.general.CropPdfForm;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -37,6 +38,11 @@ import stirling.software.common.util.WebResponseUtils;
public class CropController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final EndpointConfiguration endpointConfiguration;
private boolean isGhostscriptEnabled() {
return endpointConfiguration.isGroupEnabled("Ghostscript");
}
@AutoJobPostMapping(value = "/crop", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@StandardPdfResponse
@@ -46,9 +52,13 @@ public class CropController {
"This operation takes an input PDF file and crops it according to the given"
+ " coordinates. Input:PDF Output:PDF Type:SISO")
public ResponseEntity<byte[]> cropPdf(@ModelAttribute CropPdfForm request) throws IOException {
if (request.isRemoveDataOutsideCrop()) {
if (request.isRemoveDataOutsideCrop() && isGhostscriptEnabled()) {
return cropWithGhostscript(request);
} else {
if (request.isRemoveDataOutsideCrop()) {
log.warn(
"Ghostscript not available - 'removeDataOutsideCrop' option requires Ghostscript. Falling back to visual crop only.");
}
return cropWithPDFBox(request);
}
}
@@ -29,7 +29,6 @@ import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.JsonDataResponse;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.EditTableOfContentsRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
@@ -49,13 +48,12 @@ public class EditTableOfContentsController {
@AutoJobPostMapping(
value = "/extract-bookmarks",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@JsonDataResponse
@Operation(
summary = "Extract PDF Bookmarks",
description = "Extracts bookmarks/table of contents from a PDF document as JSON.")
@ResponseBody
public List<Map<String, Object>> extractBookmarks(@RequestParam("file") MultipartFile file)
throws Exception {
public ResponseEntity<List<Map<String, Object>>> extractBookmarks(
@RequestParam("file") MultipartFile file) throws Exception {
PDDocument document = null;
try {
document = pdfDocumentFactory.load(file);
@@ -63,10 +61,10 @@ public class EditTableOfContentsController {
if (outline == null) {
log.info("No outline/bookmarks found in PDF");
return new ArrayList<>();
return ResponseEntity.ok(new ArrayList<>());
}
return extractBookmarkItems(document, outline);
return ResponseEntity.ok(extractBookmarkItems(document, outline));
} finally {
if (document != null) {
document.close();
@@ -26,7 +26,6 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.GeneralApi;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.FormUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -137,26 +136,6 @@ public class MultiPageLayoutController {
contentStream.close();
// If any source page is rotated, skip form copying/transformation entirely
boolean hasRotation = FormUtils.hasAnyRotatedPage(sourceDocument);
if (hasRotation) {
log.info("Source document has rotated pages; skipping form field copying.");
} else {
try {
FormUtils.copyAndTransformFormFields(
sourceDocument,
newDocument,
totalPages,
pagesPerSheet,
cols,
rows,
cellWidth,
cellHeight);
} catch (Exception e) {
log.warn("Failed to copy and transform form fields: {}", e.getMessage(), e);
}
}
sourceDocument.close();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
@@ -1,6 +1,7 @@
package stirling.software.SPDF.controller.api.misc;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.context.ApplicationContext;
@@ -10,7 +11,14 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.model.ApplicationProperties;
@@ -19,6 +27,7 @@ import stirling.software.common.service.UserServiceInterface;
@ConfigApi
@Hidden
@Slf4j
public class ConfigController {
private final ApplicationProperties applicationProperties;
@@ -58,9 +67,15 @@ public class ConfigController {
// Extract values from ApplicationProperties
configData.put("appNameNavbar", applicationProperties.getUi().getAppNameNavbar());
configData.put("languages", applicationProperties.getUi().getLanguages());
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
// Security settings
configData.put("enableLogin", applicationProperties.getSecurity().getEnableLogin());
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
// (DISABLE_ADDITIONAL_FEATURES=true or DOCKER_ENABLE_SECURITY=false)
boolean enableLogin =
applicationProperties.getSecurity().getEnableLogin() && userService != null;
configData.put("enableLogin", enableLogin);
// Mail settings - check both SMTP enabled AND invites enabled
boolean smtpEnabled = applicationProperties.getMail().isEnabled();
@@ -78,6 +93,22 @@ public class ConfigController {
}
configData.put("isAdmin", isAdmin);
// Check if this is a new server (version was 0.0.0 before initialization)
configData.put("isNewServer", InitialSetup.isNewServer());
// Check if the current user is a first-time user
boolean isNewUser =
false; // Default to false when security is disabled or user not found
if (userService != null) {
try {
isNewUser = userService.isCurrentUserFirstLogin();
} catch (Exception e) {
// If there's an error, assume not new user for safety
isNewUser = false;
}
}
configData.put("isNewUser", isNewUser);
// System settings
configData.put(
"enableAlphaFunctionality",
@@ -128,6 +159,25 @@ public class ConfigController {
// EE features not available, continue without them
}
// Add version and machine info for update checking
try {
if (applicationContext.containsBean("appVersion")) {
configData.put(
"appVersion", applicationContext.getBean("appVersion", String.class));
}
if (applicationContext.containsBean("machineType")) {
configData.put(
"machineType", applicationContext.getBean("machineType", String.class));
}
if (applicationContext.containsBean("activeSecurity")) {
configData.put(
"activeSecurity",
applicationContext.getBean("activeSecurity", Boolean.class));
}
} catch (Exception e) {
// Version/machine info not available
}
return ResponseEntity.ok(configData);
} catch (Exception e) {
@@ -155,4 +205,19 @@ public class ConfigController {
}
return ResponseEntity.ok(result);
}
@GetMapping("/endpoints-availability")
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
@RequestParam(name = "endpoints")
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
List<@NotBlank String> endpoints) {
Map<String, EndpointAvailability> result = new HashMap<>();
for (String endpoint : endpoints) {
String trimmedEndpoint = endpoint.trim();
result.put(
trimmedEndpoint,
endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
}
return ResponseEntity.ok(result);
}
}
@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointInspector;
import stirling.software.SPDF.config.StartupApplicationListener;
import stirling.software.SPDF.service.WeeklyActiveUsersService;
import stirling.software.common.annotations.api.InfoApi;
import stirling.software.common.model.ApplicationProperties;
@@ -34,6 +35,7 @@ public class MetricsController {
private final ApplicationProperties applicationProperties;
private final MeterRegistry meterRegistry;
private final EndpointInspector endpointInspector;
private final Optional<WeeklyActiveUsersService> wauService;
private boolean metricsEnabled;
@PostConstruct
@@ -352,6 +354,36 @@ public class MetricsController {
return ResponseEntity.ok(formatDuration(uptime));
}
@GetMapping("/wau")
@Operation(
summary = "Weekly Active Users statistics",
description =
"Returns WAU (Weekly Active Users) count and total unique browsers. "
+ "Only available when security is disabled (no-login mode). "
+ "Tracks unique browsers via client-generated UUID in localStorage.")
public ResponseEntity<?> getWeeklyActiveUsers() {
if (!metricsEnabled) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("This endpoint is disabled.");
}
// Check if WAU service is available (only when security.enableLogin=false)
if (wauService.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(
"WAU tracking is only available when security is disabled (no-login mode)");
}
WeeklyActiveUsersService service = wauService.get();
Map<String, Object> wauStats = new HashMap<>();
wauStats.put("weeklyActiveUsers", service.getWeeklyActiveUsers());
wauStats.put("totalUniqueBrowsers", service.getTotalUniqueBrowsers());
wauStats.put("daysOnline", service.getDaysOnline());
wauStats.put("trackingSince", service.getStartTime().toString());
return ResponseEntity.ok(wauStats);
}
private String formatDuration(Duration duration) {
long days = duration.toDays();
long hours = duration.toHoursPart();
@@ -0,0 +1,101 @@
package stirling.software.SPDF.service;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
/**
* Service for tracking Weekly Active Users (WAU) in no-login mode. Uses in-memory storage with
* automatic cleanup of old entries.
*/
@Service
@Slf4j
public class WeeklyActiveUsersService {
// Map of browser ID -> last seen timestamp
private final Map<String, Instant> activeBrowsers = new ConcurrentHashMap<>();
// Track total unique browsers seen (overall)
private long totalUniqueBrowsers = 0;
// Application start time
private final Instant startTime = Instant.now();
/**
* Records a browser access with the current timestamp
*
* @param browserId Unique browser identifier from X-Browser-Id header
*/
public void recordBrowserAccess(String browserId) {
if (browserId == null || browserId.trim().isEmpty()) {
return;
}
boolean isNewBrowser = !activeBrowsers.containsKey(browserId);
activeBrowsers.put(browserId, Instant.now());
if (isNewBrowser) {
totalUniqueBrowsers++;
log.debug("New browser recorded: {} (Total: {})", browserId, totalUniqueBrowsers);
}
}
/**
* Gets the count of unique browsers seen in the last 7 days
*
* @return Weekly Active Users count
*/
public long getWeeklyActiveUsers() {
cleanupOldEntries();
return activeBrowsers.size();
}
/**
* Gets the total count of unique browsers ever seen
*
* @return Total unique browsers count
*/
public long getTotalUniqueBrowsers() {
return totalUniqueBrowsers;
}
/**
* Gets the number of days the service has been running
*
* @return Days online
*/
public long getDaysOnline() {
return ChronoUnit.DAYS.between(startTime, Instant.now());
}
/**
* Gets the timestamp when tracking started
*
* @return Start time
*/
public Instant getStartTime() {
return startTime;
}
/** Removes entries older than 7 days */
private void cleanupOldEntries() {
Instant sevenDaysAgo = Instant.now().minus(7, ChronoUnit.DAYS);
activeBrowsers.entrySet().removeIf(entry -> entry.getValue().isBefore(sevenDaysAgo));
}
/** Manual cleanup trigger (can be called by scheduled task if needed) */
public void performCleanup() {
int sizeBefore = activeBrowsers.size();
cleanupOldEntries();
int sizeAfter = activeBrowsers.size();
if (sizeBefore != sizeAfter) {
log.debug("Cleaned up {} old browser entries", sizeBefore - sizeAfter);
}
}
}
@@ -176,6 +176,7 @@ system:
ui:
appNameNavbar: '' # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
endpoints:
@@ -7,6 +7,8 @@
--md-sys-color-surface-3: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.11) 5%);
--md-sys-color-surface-4: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.12) 5%);
--md-sys-color-surface-5: color-mix(in srgb, var(--md-sys-color-primary) 13%, rgba(0, 0, 255, 0.14) 5%);
/* Clear button disabled text color (default/light) */
--spdf-clear-disabled-text: var(--md-sys-color-primary);
/* Icon fill */
--md-sys-icon-fill-0: 'FILL' 0, 'wght' 500;
--md-sys-icon-fill-1: 'FILL' 1, 'wght' 500;
@@ -25,6 +27,12 @@
--md-sys-elevation-5: 0px 8px 10px -6px rgb(var(--md-elevation-shadow-color), 0.2), 0px 16px 24px 2px rgb(var(--md-elevation-shadow-color), 0.14), 0px 6px 30px 5px rgb(var(--md-elevation-shadow-color), 0.12);
}
/* Dark theme overrides */
.dark-theme {
/* In dark mode, use a neutral grey for disabled Clear button text */
--spdf-clear-disabled-text: var(--mantine-color-gray-5, #9e9e9e);
}
.fill {
font-variation-settings: var(--md-sys-icon-fill-1);
}
@@ -0,0 +1,54 @@
{
"name": "Pre-publish-sanitization",
"pipeline": [
{
"operation": "/api/v1/security/sanitize-pdf",
"parameters": {
"removeJavaScript": true,
"removeEmbeddedFiles": true,
"removeXMPMetadata": true,
"removeMetadata": true,
"removeLinks": true,
"removeFonts": false
}
},
{
"operation": "/api/v1/misc/flatten",
"parameters": {
"flattenOnlyForms": true
}
},
{
"operation": "/api/v1/general/remove-annotations",
"parameters": {}
},
{
"operation": "/api/v1/misc/update-metadata",
"parameters": {
"deleteAll": true,
"author": "",
"creationDate": "",
"creator": "",
"keywords": "",
"modificationDate": "",
"producer": "",
"subject": "",
"title": "",
"trapped": ""
}
},
{
"operation": "/api/v1/misc/compress-pdf",
"parameters": {
"optimizeLevel": 3,
"expectedOutputSize": ""
}
}
],
"_examples": {
"outputDir": "{outputFolder}/{folderName}",
"outputFileName": "{filename}-{pipelineName}-{date}-{time}"
},
"outputDir": "{outputFolder}",
"outputFileName": "pre_publish_{filename}.PDF"
}
@@ -24,6 +24,7 @@ import org.mockito.ArgumentMatchers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
@@ -86,9 +87,13 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -108,9 +113,13 @@ class EditTableOfContentsControllerTest {
when(mockCatalog.getDocumentOutline()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertTrue(result.isEmpty());
verify(mockDocument).close();
@@ -142,9 +151,13 @@ class EditTableOfContentsControllerTest {
when(childItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -178,9 +191,13 @@ class EditTableOfContentsControllerTest {
when(mockOutlineItem.getNextSibling()).thenReturn(null);
// When
List<Map<String, Object>> result = editTableOfContentsController.extractBookmarks(mockFile);
ResponseEntity<List<Map<String, Object>>> response =
editTableOfContentsController.extractBookmarks(mockFile);
// Then
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
List<Map<String, Object>> result = response.getBody();
assertNotNull(result);
assertEquals(1, result.size());
@@ -116,6 +116,10 @@ public class ProprietaryUIDataController {
LoginData data = new LoginData();
Map<String, String> providerList = new HashMap<>();
Security securityProps = applicationProperties.getSecurity();
// Add enableLogin flag so frontend doesn't need to call /app-config
data.setEnableLogin(securityProps.getEnableLogin());
OAUTH2 oauth = securityProps.getOauth2();
if (oauth != null && oauth.getEnabled()) {
@@ -448,6 +452,7 @@ public class ProprietaryUIDataController {
@Data
public static class LoginData {
private Boolean enableLogin;
private Map<String, String> providerList;
private String loginMethod;
private boolean altLogin;
@@ -0,0 +1,215 @@
package stirling.software.proprietary.controller.api.form;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.util.FormUtils;
@RestController
@RequestMapping("/api/v1/form")
@Tag(name = "Forms", description = "PDF form APIs")
@RequiredArgsConstructor
public class FormFillController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private static ResponseEntity<byte[]> saveDocument(PDDocument document, String baseName)
throws IOException {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), baseName + ".pdf");
}
private static String buildBaseName(MultipartFile file, String suffix) {
String original = Filenames.toSimpleFileName(file.getOriginalFilename());
if (original == null || original.isBlank()) {
original = "document";
}
if (!original.toLowerCase().endsWith(".pdf")) {
return original + "_" + suffix;
}
String withoutExtension = original.substring(0, original.length() - 4);
return withoutExtension + "_" + suffix;
}
private static void requirePdf(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "{0} must be in PDF format", "file");
}
}
private static String decodePart(byte[] payload) {
if (payload == null || payload.length == 0) {
return null;
}
return new String(payload, StandardCharsets.UTF_8);
}
@PostMapping(value = "/fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Inspect PDF form fields",
description = "Returns metadata describing each field in the provided PDF form")
public ResponseEntity<FormUtils.FormFieldExtraction> listFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file)
throws IOException {
requirePdf(file);
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
FormUtils.FormFieldExtraction extraction =
FormUtils.extractFieldsWithTemplate(document);
return ResponseEntity.ok(extraction);
}
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
description =
"Updates existing fields in the provided PDF and returns the updated file")
public ResponseEntity<byte[]> modifyFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@RequestPart(value = "updates", required = false) byte[] updatesPayload)
throws IOException {
String rawUpdates = decodePart(updatesPayload);
List<FormUtils.ModifyFormFieldDefinition> modifications =
FormPayloadParser.parseModificationDefinitions(objectMapper, rawUpdates);
if (modifications.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired",
"{0} must contain at least one definition",
"updates payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.modifyFormFields(document, modifications));
}
@PostMapping(value = "/delete-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Delete form fields",
description = "Removes the specified fields from the PDF and returns the updated file")
public ResponseEntity<byte[]> deleteFields(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description =
"JSON array of field names or objects with a name property,"
+ " matching the /fields response format",
example = "[{\"name\":\"Field1\"}]")
@RequestPart(value = "names", required = false)
byte[] namesPayload)
throws IOException {
String rawNames = decodePart(namesPayload);
List<String> names = FormPayloadParser.parseNameList(objectMapper, rawNames);
if (names.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.dataRequired", "{0} must contain at least one value", "names payload");
}
return processSingleFile(
file, "updated", document -> FormUtils.deleteFormFields(document, names));
}
@PostMapping(value = "/fill", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Fill PDF form fields",
description =
"Populates the supplied PDF form using values from the provided JSON payload"
+ " and returns the filled PDF")
public ResponseEntity<byte[]> fillForm(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file,
@Parameter(
description = "JSON object of field-value pairs to apply",
example = "{\"field\":\"value\"}")
@RequestPart(value = "data", required = false)
byte[] valuesPayload,
@RequestParam(value = "flatten", defaultValue = "false") boolean flatten)
throws IOException {
String rawValues = decodePart(valuesPayload);
Map<String, Object> values = FormPayloadParser.parseValueMap(objectMapper, rawValues);
return processSingleFile(
file,
"filled",
document -> FormUtils.applyFieldValues(document, values, flatten, true));
}
private ResponseEntity<byte[]> processSingleFile(
MultipartFile file, String suffix, DocumentProcessor processor) throws IOException {
requirePdf(file);
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
processor.accept(document);
return saveDocument(document, baseName);
}
}
@FunctionalInterface
private interface DocumentProcessor {
void accept(PDDocument document) throws IOException;
}
}
@@ -0,0 +1,295 @@
package stirling.software.proprietary.controller.api.form;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.proprietary.util.FormUtils;
final class FormPayloadParser {
private static final String KEY_FIELDS = "fields";
private static final String KEY_NAME = "name";
private static final String KEY_TARGET_NAME = "targetName";
private static final String KEY_FIELD_NAME = "fieldName";
private static final String KEY_FIELD = "field";
private static final String KEY_VALUE = "value";
private static final String KEY_DEFAULT_VALUE = "defaultValue";
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() {};
private static final TypeReference<List<FormUtils.ModifyFormFieldDefinition>>
MODIFY_FIELD_LIST_TYPE = new TypeReference<>() {};
private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
private FormPayloadParser() {}
static Map<String, Object> parseValueMap(ObjectMapper objectMapper, String json)
throws IOException {
if (json == null || json.isBlank()) {
return Map.of();
}
JsonNode root;
try {
root = objectMapper.readTree(json);
} catch (IOException e) {
// Fallback to legacy direct map parse (will throw again if invalid)
return objectMapper.readValue(json, MAP_TYPE);
}
if (root == null || root.isNull()) {
return Map.of();
}
// 1. If payload already a flat object with no special wrapping, keep legacy behavior
if (root.isObject()) {
// a) Prefer explicit 'template' object if present (new combined /fields response)
JsonNode templateNode = root.get("template");
if (templateNode != null && templateNode.isObject()) {
return objectToLinkedMap(templateNode);
}
// b) Accept an inline 'fields' array of field definitions (build map from them)
JsonNode fieldsNode = root.get(KEY_FIELDS);
if (fieldsNode != null && fieldsNode.isArray()) {
Map<String, Object> record = extractFieldInfoArray(fieldsNode);
if (!record.isEmpty()) {
return record;
}
}
// c) Fallback: treat entire object as the value map (legacy behavior)
return objectToLinkedMap(root);
}
// 2. If an array was supplied to /fill (non-standard), treat first element as record
if (root.isArray()) {
if (root.isEmpty()) {
return Map.of();
}
JsonNode first = root.get(0);
if (first != null && first.isObject()) {
if (first.has(KEY_NAME) || first.has(KEY_VALUE) || first.has(KEY_DEFAULT_VALUE)) {
return extractFieldInfoArray(root);
}
return objectToLinkedMap(first);
}
return Map.of();
}
// 3. Anything else: fallback to strict map parse
return objectMapper.readValue(json, MAP_TYPE);
}
static List<FormUtils.ModifyFormFieldDefinition> parseModificationDefinitions(
ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
}
return objectMapper.readValue(json, MODIFY_FIELD_LIST_TYPE);
}
static List<String> parseNameList(ObjectMapper objectMapper, String json) throws IOException {
if (json == null || json.isBlank()) {
return List.of();
}
final JsonNode root = objectMapper.readTree(json);
if (root == null || root.isNull()) {
return List.of();
}
final Set<String> names = new LinkedHashSet<>();
if (root.isArray()) {
collectNames(root, names);
} else if (root.isObject()) {
if (root.has(KEY_FIELDS) && root.get(KEY_FIELDS).isArray()) {
collectNames(root.get(KEY_FIELDS), names);
} else {
final String single = extractName(root);
if (nonBlank(single)) {
names.add(single);
}
}
} else if (root.isTextual()) {
final String single = trimToNull(root.asText());
if (single != null) {
names.add(single);
}
}
if (!names.isEmpty()) {
return List.copyOf(names);
}
try {
return objectMapper.readValue(json, STRING_LIST_TYPE);
} catch (IOException e) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat",
"Invalid {0} format: {1}",
"names payload",
"expected array of strings or objects with 'name'-like properties");
}
}
private static Map<String, Object> extractFieldInfoArray(JsonNode fieldsNode) {
final Map<String, Object> record = new LinkedHashMap<>();
if (fieldsNode == null || fieldsNode.isNull() || !fieldsNode.isArray()) {
return record;
}
for (JsonNode fieldNode : fieldsNode) {
if (fieldNode == null || !fieldNode.isObject()) {
continue;
}
final String name = extractName(fieldNode);
if (!nonBlank(name)) {
continue;
}
JsonNode valueNode = fieldNode.get(KEY_VALUE);
if ((valueNode == null || valueNode.isNull())
&& fieldNode.hasNonNull(KEY_DEFAULT_VALUE)) {
valueNode = fieldNode.get(KEY_DEFAULT_VALUE);
}
final String normalized = normalizeFieldValue(valueNode);
record.put(name, normalized == null ? "" : normalized);
}
return record;
}
private static String normalizeFieldValue(JsonNode valueNode) {
if (valueNode == null || valueNode.isNull()) {
return null;
}
if (valueNode.isArray()) {
final List<String> values = new ArrayList<>();
for (JsonNode element : valueNode) {
final String text = coerceScalarToString(element);
if (text != null) {
values.add(text);
}
}
return String.join(",", values);
}
if (valueNode.isObject()) {
// Preserve object as JSON string
return valueNode.toString();
}
// Scalar (text/number/boolean)
return coerceScalarToString(valueNode);
}
private static String coerceScalarToString(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
return trimToEmpty(node.asText());
}
if (node.isNumber()) {
return node.numberValue().toString();
}
if (node.isBoolean()) {
return Boolean.toString(node.booleanValue());
}
// Fallback for other scalar-like nodes
return trimToEmpty(node.asText());
}
private static void collectNames(JsonNode arrayNode, Set<String> sink) {
if (arrayNode == null || !arrayNode.isArray()) {
return;
}
for (JsonNode node : arrayNode) {
final String name = extractName(node);
if (nonBlank(name)) {
sink.add(name);
}
}
}
private static String extractName(JsonNode node) {
if (node == null || node.isNull()) {
return null;
}
if (node.isTextual()) {
return trimToNull(node.asText());
}
if (node.isObject()) {
final String direct = textProperty(node, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
if (nonBlank(direct)) {
return direct;
}
final JsonNode field = node.get(KEY_FIELD);
if (field != null && field.isObject()) {
final String nested =
textProperty(field, KEY_NAME, KEY_TARGET_NAME, KEY_FIELD_NAME);
if (nonBlank(nested)) {
return nested;
}
}
}
return null;
}
private static String textProperty(JsonNode node, String... keys) {
for (String key : keys) {
final JsonNode valueNode = node.get(key);
final String value = coerceScalarToString(valueNode);
if (nonBlank(value)) {
return value;
}
}
return null;
}
private static Map<String, Object> objectToLinkedMap(JsonNode objectNode) {
final Map<String, Object> result = new LinkedHashMap<>();
objectNode
.fieldNames()
.forEachRemaining(
key -> {
final JsonNode v = objectNode.get(key);
if (v == null || v.isNull()) {
result.put(key, null);
} else if (v.isTextual() || v.isNumber() || v.isBoolean()) {
result.put(key, coerceScalarToString(v));
} else {
result.put(key, v.toString());
}
});
return result;
}
private static boolean nonBlank(String s) {
return s != null && !s.isBlank();
}
private static String trimToNull(String s) {
if (s == null) return null;
final String t = s.trim();
return t.isEmpty() ? null : t;
}
private static String trimToEmpty(String s) {
return s == null ? "" : s.trim();
}
}
@@ -129,61 +129,53 @@ public class SecurityConfiguration {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
// Read CORS allowed origins from settings
if (applicationProperties.getSystem() != null
&& applicationProperties.getSystem().getCorsAllowedOrigins() != null
&& !applicationProperties.getSystem().getCorsAllowedOrigins().isEmpty()) {
List<String> configuredOrigins = null;
if (applicationProperties.getSystem() != null) {
configuredOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
}
List<String> allowedOrigins = applicationProperties.getSystem().getCorsAllowedOrigins();
CorsConfiguration cfg = new CorsConfiguration();
// Use setAllowedOriginPatterns for better wildcard and port support
cfg.setAllowedOriginPatterns(allowedOrigins);
CorsConfiguration cfg = new CorsConfiguration();
if (configuredOrigins != null && !configuredOrigins.isEmpty()) {
cfg.setAllowedOriginPatterns(configuredOrigins);
log.debug(
"CORS configured with allowed origin patterns from settings.yml: {}",
allowedOrigins);
// Set allowed methods explicitly (including OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
// Set allowed headers explicitly
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN"));
// Set exposed headers (headers that the browser can access)
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
// Allow credentials (cookies, authorization headers)
cfg.setAllowCredentials(true);
// Set max age for preflight cache
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
configuredOrigins);
} else {
// No CORS origins configured - return null to disable CORS processing entirely
// This avoids empty CORS policy that unexpectedly rejects preflights
// Default to allowing all origins when nothing is configured
cfg.setAllowedOriginPatterns(List.of("*"));
log.info(
"CORS is disabled - no allowed origins configured in settings.yml (system.corsAllowedOrigins)");
return null;
"No CORS allowed origins configured in settings.yml (system.corsAllowedOrigins); allowing all origins.");
}
// Explicitly configure supported HTTP methods (include OPTIONS for preflight)
cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
cfg.setAllowedHeaders(
List.of(
"Authorization",
"Content-Type",
"X-Requested-With",
"Accept",
"Origin",
"X-API-KEY",
"X-CSRF-TOKEN",
"X-XSRF-TOKEN"));
cfg.setExposedHeaders(
List.of(
"WWW-Authenticate",
"X-Total-Count",
"X-Page-Number",
"X-Page-Size",
"Content-Disposition",
"Content-Type"));
cfg.setAllowCredentials(true);
cfg.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", cfg);
return source;
}
@Bean
@@ -113,7 +113,12 @@ public class LicenseKeyChecker {
public void updateLicenseKey(String newKey) throws IOException {
applicationProperties.getPremium().setKey(newKey);
GeneralUtils.saveKeyToSettings("EnterpriseEdition.key", newKey);
GeneralUtils.saveKeyToSettings("premium.key", newKey);
evaluateLicense();
synchronizeLicenseSettings();
}
public void resyncLicense() {
evaluateLicense();
synchronizeLicenseSettings();
}
@@ -0,0 +1,245 @@
package stirling.software.proprietary.security.controller.api;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
import stirling.software.proprietary.security.configuration.ee.LicenseKeyChecker;
/**
* Admin controller for license management. Provides installation ID for Stripe checkout metadata
* and endpoints for managing license keys.
*/
@RestController
@Slf4j
@RequestMapping("/api/v1/admin")
@PreAuthorize("hasRole('ROLE_ADMIN')")
@Tag(name = "Admin License Management", description = "Admin-only License Management APIs")
public class AdminLicenseController {
@Autowired(required = false)
private LicenseKeyChecker licenseKeyChecker;
@Autowired(required = false)
private KeygenLicenseVerifier keygenLicenseVerifier;
@Autowired private ApplicationProperties applicationProperties;
/**
* Get the installation ID (machine fingerprint) for this self-hosted instance. This ID is used
* as metadata in Stripe checkout to link licenses to specific installations.
*
* @return Map containing the installation ID
*/
@GetMapping("/installation-id")
@Operation(
summary = "Get installation ID",
description =
"Returns the unique installation ID (MAC-based fingerprint) for this"
+ " self-hosted instance")
public ResponseEntity<Map<String, String>> getInstallationId() {
try {
String installationId = GeneralUtils.generateMachineFingerprint();
log.info("Admin requested installation ID: {}", installationId);
return ResponseEntity.ok(Map.of("installationId", installationId));
} catch (Exception e) {
log.error("Failed to generate installation ID", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to generate installation ID"));
}
}
/**
* Save and activate a license key. This endpoint accepts a license key from the frontend (e.g.,
* after Stripe checkout) and activates it on the backend.
*
* @param request Map containing the license key
* @return Response with success status, license type, and whether restart is required
*/
@PostMapping("/license-key")
@Operation(
summary = "Save and activate license key",
description =
"Accepts a license key and activates it on the backend. Returns the activated"
+ " license type.")
public ResponseEntity<Map<String, Object>> saveLicenseKey(
@RequestBody Map<String, String> request) {
String licenseKey = request.get("licenseKey");
// Reject null but allow empty string to clear license
if (licenseKey == null) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "License key is required"));
}
try {
if (licenseKeyChecker == null) {
return ResponseEntity.internalServerError()
.body(Map.of("success", false, "error", "License checker not available"));
}
// assume premium enabled when setting license key
applicationProperties.getPremium().setEnabled(true);
// Use existing LicenseKeyChecker to update and validate license
// Empty string will be evaluated as NORMAL license (free tier)
licenseKeyChecker.updateLicenseKey(licenseKey.trim());
// Get current license status
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
// Auto-enable premium features if license is valid
if (license != License.NORMAL) {
GeneralUtils.saveKeyToSettings("premium.enabled", true);
// Enable premium features
// Save maxUsers from license metadata
Integer maxUsers = applicationProperties.getPremium().getMaxUsers();
if (maxUsers != null) {
GeneralUtils.saveKeyToSettings("premium.maxUsers", maxUsers);
}
} else {
GeneralUtils.saveKeyToSettings("premium.enabled", false);
log.info("License key is not valid for premium features: type={}", license.name());
}
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("licenseType", license.name());
response.put("enabled", applicationProperties.getPremium().isEnabled());
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
response.put("requiresRestart", false); // Dynamic evaluation works
response.put("message", "License key saved and activated");
log.info("License key saved and activated: type={}", license.name());
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to save license key", e);
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Failed to activate license: " + e.getMessage()));
}
}
/**
* Resync the current license with Keygen. This endpoint re-validates the existing license key
* and updates the max users setting. Used after subscription upgrades to sync the new license
* limits.
*
* @return Response with updated license information
*/
@PostMapping("/license/resync")
@Operation(
summary = "Resync license with Keygen",
description =
"Re-validates the existing license key with Keygen and updates local settings."
+ " Used after subscription upgrades.")
public ResponseEntity<Map<String, Object>> resyncLicense() {
try {
if (licenseKeyChecker == null) {
return ResponseEntity.internalServerError()
.body(Map.of("success", false, "error", "License checker not available"));
}
String currentKey = applicationProperties.getPremium().getKey();
if (currentKey == null || currentKey.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "No license key configured"));
}
log.info("Resyncing license with Keygen");
// Re-validate license and sync settings
licenseKeyChecker.resyncLicense();
// Get updated license status
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
ApplicationProperties.Premium premium = applicationProperties.getPremium();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("licenseType", license.name());
response.put("enabled", premium.isEnabled());
response.put("maxUsers", premium.getMaxUsers());
response.put("message", "License resynced successfully");
log.info(
"License resynced: type={}, maxUsers={}",
license.name(),
premium.getMaxUsers());
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to resync license", e);
return ResponseEntity.internalServerError()
.body(
Map.of(
"success",
false,
"error",
"Failed to resync license: " + e.getMessage()));
}
}
/**
* Get information about the current license key status, including license type, enabled status,
* and max users.
*
* @return Map containing license information
*/
@GetMapping("/license-info")
@Operation(
summary = "Get license information",
description =
"Returns information about the current license including type, enabled status,"
+ " and max users")
public ResponseEntity<Map<String, Object>> getLicenseInfo() {
try {
Map<String, Object> response = new HashMap<>();
if (licenseKeyChecker != null) {
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
response.put("licenseType", license.name());
} else {
response.put("licenseType", License.NORMAL.name());
}
ApplicationProperties.Premium premium = applicationProperties.getPremium();
response.put("enabled", premium.isEnabled());
response.put("maxUsers", premium.getMaxUsers());
response.put("hasKey", premium.getKey() != null && !premium.getKey().trim().isEmpty());
// Include license key for upgrades (admin-only endpoint)
if (premium.getKey() != null && !premium.getKey().trim().isEmpty()) {
response.put("licenseKey", premium.getKey());
}
return ResponseEntity.ok(response);
} catch (Exception e) {
log.error("Failed to get license info", e);
return ResponseEntity.internalServerError()
.body(Map.of("error", "Failed to retrieve license information"));
}
}
}
@@ -299,6 +299,16 @@ public class AdminSettingsController {
+ String.join(", ", VALID_SECTION_NAMES));
}
// Auto-enable premium features if license key is provided
if ("premium".equalsIgnoreCase(sectionName) && sectionData.containsKey("key")) {
Object keyValue = sectionData.get("key");
if (keyValue != null && !keyValue.toString().trim().isEmpty()) {
// Automatically set enabled to true when a key is provided
sectionData.put("enabled", true);
log.info("Auto-enabling premium features because license key was provided");
}
}
int updatedCount = 0;
for (Map.Entry<String, Object> entry : sectionData.entrySet()) {
String propertyKey = entry.getKey();
@@ -15,7 +15,7 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.annotations.api.InviteApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.enumeration.Role;
import stirling.software.proprietary.model.Team;
@@ -26,11 +26,9 @@ import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@UserApi
@InviteApi
@Slf4j
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/v1/invite")
public class InviteLinkController {
private final InviteTokenRepository inviteTokenRepository;
@@ -742,4 +742,31 @@ public class UserController {
return errorMessage;
}
}
@PostMapping("/complete-initial-setup")
public ResponseEntity<?> completeInitialSetup() {
try {
String username = userService.getCurrentUsername();
if (username == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body("User not authenticated");
}
Optional<User> userOpt = userService.findByUsernameIgnoreCase(username);
if (userOpt.isEmpty()) {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body("User not found");
}
User user = userOpt.get();
user.setHasCompletedInitialSetup(true);
userRepository.save(user);
log.info("User {} completed initial setup", username);
return ResponseEntity.ok().body(Map.of("success", true));
} catch (Exception e) {
log.error("Error completing initial setup", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Failed to complete initial setup");
}
}
}
@@ -107,7 +107,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
}
}
// If we still don't have any authentication, check if it's a public endpoint. If not, deny the request
// If we still don't have any authentication, check if it's a public endpoint. If not, deny
// the request
if (authentication == null || !authentication.isAuthenticated()) {
String method = request.getMethod();
String contextPath = request.getContextPath();
@@ -222,7 +223,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
|| 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/auth/logout")
|| trimmedUri.startsWith("/api/v1/proprietary/ui-data/login");
}
private enum UserLoginType {
@@ -56,6 +56,9 @@ public class User implements UserDetails, Serializable {
@Column(name = "isFirstLogin")
private Boolean isFirstLogin = false;
@Column(name = "hasCompletedInitialSetup")
private Boolean hasCompletedInitialSetup = false;
@Column(name = "roleName")
private String roleName;
@@ -103,6 +106,14 @@ public class User implements UserDetails, Serializable {
this.isFirstLogin = isFirstLogin;
}
public boolean hasCompletedInitialSetup() {
return hasCompletedInitialSetup != null && hasCompletedInitialSetup;
}
public void setHasCompletedInitialSetup(boolean hasCompletedInitialSetup) {
this.hasCompletedInitialSetup = hasCompletedInitialSetup;
}
public void setAuthenticationType(AuthenticationType authenticationType) {
this.authenticationType = authenticationType.toString().toLowerCase();
}
@@ -39,7 +39,7 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
public class JwtService implements JwtServiceInterface {
private static final String ISSUER = "https://stirling.com";
private static final long EXPIRATION = 3600000;
private static final long EXPIRATION = 43200000;
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
@@ -663,6 +663,21 @@ public class UserService implements UserServiceInterface {
return false;
}
public boolean isCurrentUserFirstLogin() {
try {
String username = getCurrentUsername();
if (username != null) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
return !userOpt.get().hasCompletedInitialSetup();
}
}
} catch (Exception e) {
log.debug("Error checking first login status", e);
}
return false;
}
@Transactional
public void syncCustomApiUser(String customApiKey) {
if (customApiKey == null || customApiKey.trim().isBlank()) {
@@ -0,0 +1,345 @@
package stirling.software.proprietary.util;
import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotation;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
@Slf4j
@UtilityClass
public class FormCopyUtils {
public boolean hasAnyRotatedPage(PDDocument document) {
try {
for (PDPage page : document.getPages()) {
int rot = page.getRotation();
int norm = ((rot % 360) + 360) % 360;
if (norm != 0) {
return true;
}
}
} catch (Exception e) {
log.warn("Failed to inspect page rotations: {}", e.getMessage(), e);
}
return false;
}
public void copyAndTransformFormFields(
PDDocument sourceDocument,
PDDocument newDocument,
int totalPages,
int pagesPerSheet,
int cols,
int rows,
float cellWidth,
float cellHeight)
throws IOException {
PDDocumentCatalog sourceCatalog = sourceDocument.getDocumentCatalog();
PDAcroForm sourceAcroForm = sourceCatalog.getAcroForm();
if (sourceAcroForm == null || sourceAcroForm.getFields().isEmpty()) {
return;
}
PDDocumentCatalog newCatalog = newDocument.getDocumentCatalog();
PDAcroForm newAcroForm = new PDAcroForm(newDocument);
newCatalog.setAcroForm(newAcroForm);
PDResources dr = new PDResources();
PDType1Font helvetica = new PDType1Font(Standard14Fonts.FontName.HELVETICA);
PDType1Font zapfDingbats = new PDType1Font(Standard14Fonts.FontName.ZAPF_DINGBATS);
dr.put(COSName.getPDFName("Helv"), helvetica);
dr.put(COSName.getPDFName("ZaDb"), zapfDingbats);
newAcroForm.setDefaultResources(dr);
newAcroForm.setDefaultAppearance("/Helv 12 Tf 0 g");
// Temporarily set NeedAppearances to true during field creation
newAcroForm.setNeedAppearances(true);
Map<String, Integer> fieldNameCounters = new HashMap<>();
// Build widget -> field map once for efficient lookups
Map<PDAnnotationWidget, PDField> widgetFieldMap = buildWidgetFieldMap(sourceAcroForm);
for (int pageIndex = 0; pageIndex < totalPages; pageIndex++) {
PDPage sourcePage = sourceDocument.getPage(pageIndex);
List<PDAnnotation> annotations = sourcePage.getAnnotations();
if (annotations.isEmpty()) {
continue;
}
int destinationPageIndex = pageIndex / pagesPerSheet;
int adjustedPageIndex = pageIndex % pagesPerSheet;
int rowIndex = adjustedPageIndex / cols;
int colIndex = adjustedPageIndex % cols;
if (rowIndex >= rows) {
continue;
}
if (destinationPageIndex >= newDocument.getNumberOfPages()) {
continue;
}
PDPage destinationPage = newDocument.getPage(destinationPageIndex);
PDRectangle sourceRect = sourcePage.getMediaBox();
float scaleWidth = cellWidth / sourceRect.getWidth();
float scaleHeight = cellHeight / sourceRect.getHeight();
float scale = Math.min(scaleWidth, scaleHeight);
float x = colIndex * cellWidth + (cellWidth - sourceRect.getWidth() * scale) / 2;
float y =
destinationPage.getMediaBox().getHeight()
- ((rowIndex + 1) * cellHeight
- (cellHeight - sourceRect.getHeight() * scale) / 2);
copyBasicFormFields(
sourceAcroForm,
newAcroForm,
sourcePage,
destinationPage,
x,
y,
scale,
pageIndex,
fieldNameCounters,
widgetFieldMap);
}
// Generate appearance streams and embed them authoritatively
boolean appearancesGenerated = false;
try {
newAcroForm.refreshAppearances();
appearancesGenerated = true;
} catch (NoSuchMethodError nsme) {
log.warn(
"AcroForm.refreshAppearances() not available in this PDFBox version; "
+ "leaving NeedAppearances=true for viewer-side rendering.");
} catch (Exception t) {
log.warn(
"Failed to refresh field appearances via AcroForm: {}. "
+ "Leaving NeedAppearances=true as fallback.",
t.getMessage(),
t);
}
// After successful appearance generation, set NeedAppearances to false
// to signal that appearance streams are now embedded authoritatively
if (appearancesGenerated) {
try {
newAcroForm.setNeedAppearances(false);
} catch (Exception e) {
log.debug(
"Failed to set NeedAppearances to false: {}. "
+ "Appearances were generated but flag could not be updated.",
e.getMessage());
}
}
}
private void copyBasicFormFields(
PDAcroForm sourceAcroForm,
PDAcroForm newAcroForm,
PDPage sourcePage,
PDPage destinationPage,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters,
Map<PDAnnotationWidget, PDField> widgetFieldMap) {
try {
List<PDAnnotation> sourceAnnotations = sourcePage.getAnnotations();
List<PDAnnotation> destinationAnnotations = destinationPage.getAnnotations();
for (PDAnnotation annotation : sourceAnnotations) {
if (annotation instanceof PDAnnotationWidget widgetAnnotation) {
if (widgetAnnotation.getRectangle() == null) {
continue;
}
PDField sourceField =
widgetFieldMap != null ? widgetFieldMap.get(widgetAnnotation) : null;
if (sourceField == null) {
continue; // skip widgets without a matching field
}
if (!(sourceField instanceof PDTerminalField terminalField)) {
continue;
}
FormFieldTypeSupport handler = FormFieldTypeSupport.forField(terminalField);
if (handler == null) {
log.debug(
"Skipping unsupported field type '{}' for widget '{}'",
sourceField.getClass().getSimpleName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName));
continue;
}
copyFieldUsingHandler(
handler,
terminalField,
newAcroForm,
destinationPage,
destinationAnnotations,
widgetAnnotation,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
}
}
} catch (Exception e) {
log.warn(
"Failed to copy basic form fields for page {}: {}",
pageIndex,
e.getMessage(),
e);
}
}
private void copyFieldUsingHandler(
FormFieldTypeSupport handler,
PDTerminalField sourceField,
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
try {
PDTerminalField newField = handler.createField(newAcroForm);
boolean initialized =
initializeFieldWithWidget(
newAcroForm,
destinationPage,
destinationAnnotations,
newField,
sourceField.getPartialName(),
handler.fallbackWidgetName(),
sourceWidget,
offsetX,
offsetY,
scale,
pageIndex,
fieldNameCounters);
if (!initialized) {
return;
}
handler.copyFromOriginal(sourceField, newField);
} catch (Exception e) {
log.warn(
"Failed to copy {} field '{}': {}",
handler.typeName(),
Optional.ofNullable(sourceField.getFullyQualifiedName())
.orElseGet(sourceField::getPartialName),
e.getMessage(),
e);
}
}
private <T extends PDTerminalField> boolean initializeFieldWithWidget(
PDAcroForm newAcroForm,
PDPage destinationPage,
List<PDAnnotation> destinationAnnotations,
T newField,
String originalName,
String fallbackName,
PDAnnotationWidget sourceWidget,
float offsetX,
float offsetY,
float scale,
int pageIndex,
Map<String, Integer> fieldNameCounters) {
String baseName = (originalName != null) ? originalName : fallbackName;
String newFieldName = generateUniqueFieldName(baseName, pageIndex, fieldNameCounters);
newField.setPartialName(newFieldName);
PDAnnotationWidget newWidget = new PDAnnotationWidget();
PDRectangle sourceRect = sourceWidget.getRectangle();
if (sourceRect == null) {
return false;
}
float newX = (sourceRect.getLowerLeftX() * scale) + offsetX;
float newY = (sourceRect.getLowerLeftY() * scale) + offsetY;
float newWidth = sourceRect.getWidth() * scale;
float newHeight = sourceRect.getHeight() * scale;
newWidget.setRectangle(new PDRectangle(newX, newY, newWidth, newHeight));
newWidget.setPage(destinationPage);
newField.getWidgets().add(newWidget);
newWidget.setParent(newField);
newAcroForm.getFields().add(newField);
destinationAnnotations.add(newWidget);
return true;
}
private String generateUniqueFieldName(
String originalName, int pageIndex, Map<String, Integer> fieldNameCounters) {
String baseName = "page" + pageIndex + "_" + originalName;
Integer counter = fieldNameCounters.get(baseName);
if (counter == null) {
counter = 0;
} else {
counter++;
}
fieldNameCounters.put(baseName, counter);
return counter == 0 ? baseName : baseName + "_" + counter;
}
private Map<PDAnnotationWidget, PDField> buildWidgetFieldMap(PDAcroForm acroForm) {
Map<PDAnnotationWidget, PDField> map = new HashMap<>();
if (acroForm == null) {
return map;
}
try {
for (PDField field : acroForm.getFieldTree()) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null) {
continue;
}
for (PDAnnotationWidget widget : widgets) {
if (widget != null) {
map.put(widget, field);
}
}
}
} catch (Exception e) {
log.warn("Failed to build widget->field map: {}", e.getMessage(), e);
}
return map;
}
}
@@ -0,0 +1,368 @@
package stirling.software.proprietary.util;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.graphics.color.PDColor;
import org.apache.pdfbox.pdmodel.graphics.color.PDDeviceRGB;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAppearanceCharacteristicsDictionary;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDChoice;
import org.apache.pdfbox.pdmodel.interactive.form.PDComboBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDField;
import org.apache.pdfbox.pdmodel.interactive.form.PDListBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDPushButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDRadioButton;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public enum FormFieldTypeSupport {
TEXT("text", "textField", PDTextField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
PDTextField textField = new PDTextField(acroForm);
textField.setDefaultAppearance("/Helv 12 Tf 0 g");
return textField;
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDTextField src = (PDTextField) source;
PDTextField dst = (PDTextField) target;
String value = src.getValueAsString();
if (value != null) {
dst.setValue(value);
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDTextField textField = (PDTextField) field;
String defaultValue = Optional.ofNullable(definition.defaultValue()).orElse("");
if (!defaultValue.isBlank()) {
FormUtils.setTextValue(textField, defaultValue);
}
}
},
CHECKBOX("checkbox", "checkBox", PDCheckBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDCheckBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDCheckBox src = (PDCheckBox) source;
PDCheckBox dst = (PDCheckBox) target;
if (src.isChecked()) {
dst.check();
} else {
dst.unCheck();
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDCheckBox checkBox = (PDCheckBox) field;
if (!options.isEmpty()) {
checkBox.setExportValues(options);
}
ensureCheckBoxAppearance(checkBox);
if (FormUtils.isChecked(definition.defaultValue())) {
checkBox.check();
} else {
checkBox.unCheck();
}
}
private static void ensureCheckBoxAppearance(PDCheckBox checkBox) {
try {
if (checkBox.getWidgets().isEmpty()) {
return;
}
PDAnnotationWidget widget = checkBox.getWidgets().get(0);
PDAppearanceCharacteristicsDictionary appearanceChars =
widget.getAppearanceCharacteristics();
if (appearanceChars == null) {
appearanceChars =
new PDAppearanceCharacteristicsDictionary(widget.getCOSObject());
widget.setAppearanceCharacteristics(appearanceChars);
}
appearanceChars.setBorderColour(
new PDColor(new float[] {0, 0, 0}, PDDeviceRGB.INSTANCE));
appearanceChars.setBackground(
new PDColor(new float[] {1, 1, 1}, PDDeviceRGB.INSTANCE));
appearanceChars.setNormalCaption("4");
widget.setPrinted(true);
widget.setReadOnly(false);
widget.setHidden(false);
} catch (Exception e) {
log.debug("Unable to set checkbox appearance characteristics: {}", e.getMessage());
}
}
},
RADIO("radio", "radioButton", PDRadioButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDRadioButton(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDRadioButton src = (PDRadioButton) source;
PDRadioButton dst = (PDRadioButton) target;
if (src.getExportValues() != null) {
dst.setExportValues(src.getExportValues());
}
if (src.getValue() != null) {
dst.setValue(src.getValue());
}
}
},
COMBOBOX("combobox", "comboBox", PDComboBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDComboBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDComboBox src = (PDComboBox) source;
PDComboBox dst = (PDComboBox) target;
copyChoiceCharacteristics(src, dst);
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDComboBox comboBox = (PDComboBox) field;
if (!options.isEmpty()) {
comboBox.setOptions(options);
}
List<String> allowedOptions = FormUtils.resolveOptions(comboBox);
String comboName =
Optional.ofNullable(comboBox.getFullyQualifiedName())
.orElseGet(comboBox::getPartialName);
String defaultValue = definition.defaultValue();
if (defaultValue != null && !defaultValue.isBlank()) {
String filtered =
FormUtils.filterSingleChoiceSelection(
defaultValue, allowedOptions, comboName);
if (filtered != null) {
comboBox.setValue(filtered);
}
}
}
},
LISTBOX("listbox", "listBox", PDListBox.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDListBox(acroForm);
}
@Override
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
PDListBox src = (PDListBox) source;
PDListBox dst = (PDListBox) target;
copyChoiceCharacteristics(src, dst);
if (src.getOptions() != null) {
dst.setOptions(src.getOptions());
}
if (src.getValue() != null && !src.getValue().isEmpty()) {
dst.setValue(src.getValue());
}
}
@Override
boolean doesNotsupportsDefinitionCreation() {
return false;
}
@Override
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
PDListBox listBox = (PDListBox) field;
listBox.setMultiSelect(Boolean.TRUE.equals(definition.multiSelect()));
if (!options.isEmpty()) {
listBox.setOptions(options);
}
List<String> allowedOptions = FormUtils.collectChoiceAllowedValues(listBox);
String listBoxName =
Optional.ofNullable(listBox.getFullyQualifiedName())
.orElseGet(listBox::getPartialName);
String defaultValue = definition.defaultValue();
if (defaultValue != null && !defaultValue.isBlank()) {
if (Boolean.TRUE.equals(definition.multiSelect())) {
List<String> selections = FormUtils.parseMultiChoiceSelections(defaultValue);
List<String> filtered =
FormUtils.filterChoiceSelections(
selections, allowedOptions, listBoxName);
if (!filtered.isEmpty()) {
listBox.setValue(filtered);
}
} else {
String filtered =
FormUtils.filterSingleChoiceSelection(
defaultValue, allowedOptions, listBoxName);
if (filtered != null) {
listBox.setValue(filtered);
}
}
}
}
},
SIGNATURE("signature", "signature", PDSignatureField.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDSignatureField(acroForm);
}
},
BUTTON("button", "pushButton", PDPushButton.class) {
@Override
PDTerminalField createField(PDAcroForm acroForm) {
return new PDPushButton(acroForm);
}
};
private static final Map<String, FormFieldTypeSupport> BY_TYPE =
Arrays.stream(values())
.collect(
Collectors.toUnmodifiableMap(
FormFieldTypeSupport::typeName, Function.identity()));
private final String typeName;
private final String fallbackWidgetName;
private final Class<? extends PDTerminalField> fieldClass;
FormFieldTypeSupport(
String typeName,
String fallbackWidgetName,
Class<? extends PDTerminalField> fieldClass) {
this.typeName = typeName;
this.fallbackWidgetName = fallbackWidgetName;
this.fieldClass = fieldClass;
}
public static FormFieldTypeSupport forField(PDField field) {
if (field == null) {
return null;
}
for (FormFieldTypeSupport handler : values()) {
if (handler.fieldClass.isInstance(field)) {
return handler;
}
}
return null;
}
public static FormFieldTypeSupport forTypeName(String typeName) {
if (typeName == null) {
return null;
}
return BY_TYPE.get(typeName);
}
private static void copyChoiceCharacteristics(PDChoice sourceField, PDChoice targetField) {
if (sourceField == null || targetField == null) {
return;
}
try {
int flags = sourceField.getCOSObject().getInt(COSName.FF);
targetField.getCOSObject().setInt(COSName.FF, flags);
} catch (Exception e) {
// ignore and continue
}
if (sourceField instanceof PDListBox sourceList
&& targetField instanceof PDListBox targetList) {
try {
targetList.setMultiSelect(sourceList.isMultiSelect());
} catch (Exception ignored) {
// ignore
}
}
}
String typeName() {
return typeName;
}
String fallbackWidgetName() {
return fallbackWidgetName;
}
abstract PDTerminalField createField(PDAcroForm acroForm);
void copyFromOriginal(PDTerminalField source, PDTerminalField target) throws IOException {
// default no-op
}
boolean doesNotsupportsDefinitionCreation() {
return true;
}
void applyNewFieldDefinition(
PDTerminalField field,
FormUtils.NewFormFieldDefinition definition,
List<String> options)
throws IOException {
// default no-op
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,114 @@
package stirling.software.proprietary.util;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget;
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
import org.apache.pdfbox.pdmodel.interactive.form.PDCheckBox;
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@Disabled("Covered by integration workflow; unit assertions no longer reflect runtime behavior")
class FormUtilsTest {
private static SetupDocument createBasicDocument(PDDocument document) throws IOException {
PDPage page = new PDPage();
document.addPage(page);
PDAcroForm acroForm = new PDAcroForm(document);
acroForm.setDefaultResources(new PDResources());
acroForm.setNeedAppearances(true);
document.getDocumentCatalog().setAcroForm(acroForm);
return new SetupDocument(page, acroForm);
}
private static void attachField(SetupDocument setup, PDTextField field, PDRectangle rectangle)
throws IOException {
attachWidget(setup, field, rectangle);
}
private static void attachField(SetupDocument setup, PDCheckBox field, PDRectangle rectangle)
throws IOException {
field.setExportValues(List.of("Yes"));
attachWidget(setup, field, rectangle);
}
private static void attachWidget(
SetupDocument setup,
org.apache.pdfbox.pdmodel.interactive.form.PDTerminalField field,
PDRectangle rectangle)
throws IOException {
PDAnnotationWidget widget = new PDAnnotationWidget();
widget.setRectangle(rectangle);
widget.setPage(setup.page);
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null) {
widgets = new ArrayList<>();
} else {
widgets = new ArrayList<>(widgets);
}
widgets.add(widget);
field.setWidgets(widgets);
setup.acroForm.getFields().add(field);
setup.page.getAnnotations().add(widget);
}
@Test
void extractFormFieldsReturnsFieldMetadata() throws IOException {
try (PDDocument document = new PDDocument()) {
SetupDocument setup = createBasicDocument(document);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("firstName");
attachField(setup, textField, new PDRectangle(50, 700, 200, 20));
List<FormUtils.FormFieldInfo> fields = FormUtils.extractFormFields(document);
assertEquals(1, fields.size());
FormUtils.FormFieldInfo info = fields.get(0);
assertEquals("firstName", info.name());
assertEquals("text", info.type());
assertEquals(0, info.pageIndex());
assertEquals("", info.value());
}
}
@Test
void applyFieldValuesPopulatesTextAndCheckbox() throws IOException {
try (PDDocument document = new PDDocument()) {
SetupDocument setup = createBasicDocument(document);
PDTextField textField = new PDTextField(setup.acroForm);
textField.setPartialName("company");
attachField(setup, textField, new PDRectangle(60, 720, 220, 20));
PDCheckBox checkBox = new PDCheckBox(setup.acroForm);
checkBox.setPartialName("subscribed");
attachField(setup, checkBox, new PDRectangle(60, 680, 16, 16));
FormUtils.applyFieldValues(
document, Map.of("company", "Stirling", "subscribed", true), false);
assertEquals("Stirling", textField.getValueAsString());
assertTrue(checkBox.isChecked());
FormUtils.applyFieldValues(document, Map.of("subscribed", false), false);
assertFalse(checkBox.isChecked());
assertEquals("Off", checkBox.getValue());
}
}
private record SetupDocument(PDPage page, PDAcroForm acroForm) {}
}
+1 -1
View File
@@ -57,7 +57,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '1.4.0'
version = '2.0.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+3
View File
@@ -1,3 +1,6 @@
# Run nginx as non-root user
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
+244 -61
View File
@@ -10,24 +10,25 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -38,10 +39,14 @@
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reactour/tour": "^3.8.0",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@supabase/supabase-js": "^2.47.13",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-http": "^2.5.4",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
@@ -441,7 +446,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -488,7 +492,6 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -507,12 +510,68 @@
"node": ">=18"
}
},
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/accessibility/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@embedpdf/core": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
@@ -596,7 +655,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -613,7 +671,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -631,7 +688,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -668,7 +724,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -703,7 +758,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -740,7 +794,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -816,7 +869,6 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -972,7 +1024,6 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1016,7 +1067,6 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2047,7 +2097,6 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.5.tgz",
"integrity": "sha512-PdVNLMgOS2vFhOujRi6/VC9ic8w3UDyKX7ftwDeJ7yQT8CiepUxfbWWYpVpnq23bdWh/7fIT2Pn1EY8r8GOk7g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2098,7 +2147,6 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.5.tgz",
"integrity": "sha512-0Wf08eWLKi3WkKlxnV1W5vfuN6wcvAV2VbhQlOy0R9nrWorGTtonQF6qqBE3PnJFYF1/ZE+HkYZQ/Dr7DmYSMQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2166,7 +2214,6 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz",
"integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.4",
@@ -3077,6 +3124,138 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@stripe/react-stripe-js": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz",
"integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==",
"license": "MIT",
"dependencies": {
"prop-types": "^15.7.2"
},
"peerDependencies": {
"@stripe/stripe-js": ">=1.44.1 <8.0.0",
"react": ">=16.8.0 <20.0.0",
"react-dom": ">=16.8.0 <20.0.0"
}
},
"node_modules/@stripe/stripe-js": {
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz",
"integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/auth-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/functions-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz",
"integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz",
"integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/realtime-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz",
"integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/storage-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz",
"integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js/node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/@supabase/supabase-js": {
"version": "2.81.1",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz",
"integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.81.1",
"@supabase/functions-js": "2.81.1",
"@supabase/postgrest-js": "2.81.1",
"@supabase/realtime-js": "2.81.1",
"@supabase/storage-js": "2.81.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
@@ -3844,13 +4023,21 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.4.tgz",
"integrity": "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4151,7 +4338,6 @@
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -4163,6 +4349,12 @@
"integrity": "sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==",
"license": "MIT"
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -4174,7 +4366,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4185,7 +4376,6 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4199,6 +4389,15 @@
"@types/react": "*"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -4246,7 +4445,6 @@
"integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.2",
"@typescript-eslint/types": "8.46.2",
@@ -4960,6 +5158,7 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.22.tgz",
"integrity": "sha512-f2Wux4v/Z2pqc9+4SmgZC1p73Z53fyD90NFWXiX9AKVnVBEvLFOWCEgJD3GdGnlxPZt01PSlfmLqbLYzY/Fw4A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.22"
}
@@ -4969,6 +5168,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.22.tgz",
"integrity": "sha512-EHo4W/eiYeAzRTN5PCextDUZ0dMs9I8mQ2Fy+OkzvRPUYQEyK9yAjbasrMCXbLNhF7P0OUyivLjIy0yc6VrLJQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/shared": "3.5.22"
@@ -4979,6 +5179,7 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.22.tgz",
"integrity": "sha512-Av60jsryAkI023PlN7LsqrfPvwfxOd2yAwtReCjeuugTJTkgrksYJJstg1e12qle0NarkfhfFu1ox2D+cQotww==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.22",
"@vue/runtime-core": "3.5.22",
@@ -4991,6 +5192,7 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.22.tgz",
"integrity": "sha512-gXjo+ao0oHYTSswF+a3KRHZ1WszxIqO7u6XwNHqcqb9JfyIL/pbWrrh/xLv7jeDqla9u+LK7yfZKHih1e1RKAQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.22",
"@vue/shared": "3.5.22"
@@ -5017,7 +5219,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5702,7 +5903,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6748,8 +6948,7 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1508733.tgz",
"integrity": "sha512-QJ1R5gtck6nDcdM+nlsaJXcelPEI7ZxSMw1ujHpO1c4+9l+Nue5qlebi9xO1Z2MGr92bFOQTW7/rrheh5hHxDg==",
"dev": true,
"license": "BSD-3-Clause",
"peer": true
"license": "BSD-3-Clause"
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7144,7 +7343,6 @@
"integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7315,7 +7513,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -8638,7 +8835,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9446,7 +9642,6 @@
"integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@asamuzakjp/dom-selector": "^6.7.2",
"cssstyle": "^5.3.1",
@@ -11223,7 +11418,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11503,7 +11697,6 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -11876,7 +12069,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11886,7 +12078,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13321,9 +13512,9 @@
}
},
"node_modules/svelte": {
"version": "5.42.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.42.3.tgz",
"integrity": "sha512-+8dUmdJGvKSWEfbAgIaUmpD97s1bBAGxEf6s7wQonk+HNdMmrBZtpStzRypRqrYBFUmmhaUgBHUjraE8gLqWAw==",
"version": "5.43.0",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.0.tgz",
"integrity": "sha512-1sRxVbgJAB+UGzwkc3GUoiBSzEOf0jqzccMaVoI2+pI+kASUe9qubslxace8+Mzhqw19k4syTA5niCIJwfXpOA==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -13557,7 +13748,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -13859,7 +14049,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -13922,7 +14111,6 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},
"node_modules/universalify": {
@@ -13942,7 +14130,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14147,7 +14334,6 @@
"integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14299,7 +14485,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14313,7 +14498,6 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -14797,7 +14981,6 @@
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
+27 -21
View File
@@ -6,38 +6,43 @@
"proxy": "http://localhost:8080",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@embedpdf/core": "^1.3.14",
"@embedpdf/engines": "^1.3.14",
"@embedpdf/plugin-annotation": "^1.3.14",
"@embedpdf/plugin-export": "^1.3.14",
"@embedpdf/plugin-history": "^1.3.14",
"@embedpdf/plugin-interaction-manager": "^1.3.14",
"@embedpdf/plugin-loader": "^1.3.14",
"@embedpdf/plugin-pan": "^1.3.14",
"@embedpdf/plugin-render": "^1.3.14",
"@embedpdf/plugin-rotate": "^1.3.14",
"@embedpdf/plugin-scroll": "^1.3.14",
"@embedpdf/plugin-search": "^1.3.14",
"@embedpdf/plugin-selection": "^1.3.14",
"@embedpdf/plugin-spread": "^1.3.14",
"@embedpdf/plugin-thumbnail": "^1.3.14",
"@embedpdf/plugin-tiling": "^1.3.14",
"@embedpdf/plugin-viewport": "^1.3.14",
"@embedpdf/plugin-zoom": "^1.3.14",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^1.4.1",
"@embedpdf/engines": "^1.4.1",
"@embedpdf/plugin-annotation": "^1.4.1",
"@embedpdf/plugin-export": "^1.4.1",
"@embedpdf/plugin-history": "^1.4.1",
"@embedpdf/plugin-interaction-manager": "^1.4.1",
"@embedpdf/plugin-loader": "^1.4.1",
"@embedpdf/plugin-pan": "^1.4.1",
"@embedpdf/plugin-render": "^1.4.1",
"@embedpdf/plugin-rotate": "^1.4.1",
"@embedpdf/plugin-scroll": "^1.4.1",
"@embedpdf/plugin-search": "^1.4.1",
"@embedpdf/plugin-selection": "^1.4.1",
"@embedpdf/plugin-spread": "^1.4.1",
"@embedpdf/plugin-thumbnail": "^1.4.1",
"@embedpdf/plugin-tiling": "^1.4.1",
"@embedpdf/plugin-viewport": "^1.4.1",
"@embedpdf/plugin-zoom": "^1.4.1",
"@emotion/react": "^11.14.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
"@mantine/core": "^8.3.1",
"@mantine/dates": "^8.3.1",
"@mantine/dropzone": "^8.3.1",
"@mantine/hooks": "^8.3.1",
"@stripe/react-stripe-js": "^4.0.2",
"@stripe/stripe-js": "^7.9.0",
"@supabase/supabase-js": "^2.47.13",
"@mui/icons-material": "^7.3.2",
"@mui/material": "^7.3.2",
"@reactour/tour": "^3.8.0",
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-http": "^2.5.4",
"autoprefixer": "^10.4.21",
"axios": "^1.12.2",
"globals": "^16.4.0",
@@ -66,6 +71,7 @@
"preview": "vite preview",
"tauri-dev": "tauri dev --no-watch",
"tauri-build": "tauri build",
"tauri-clean": "cd src-tauri && cargo clean && cd .. && rm -rf dist build",
"typecheck": "npm run typecheck:proprietary",
"typecheck:core": "tsc --noEmit --project tsconfig.core.json",
"typecheck:proprietary": "tsc --noEmit --project tsconfig.proprietary.json",
@@ -108,11 +114,11 @@
]
},
"devDependencies": {
"@tauri-apps/cli": "^2.5.0",
"@eslint/js": "^9.36.0",
"@iconify-json/material-symbols": "^1.2.37",
"@iconify/utils": "^3.0.2",
"@playwright/test": "^1.55.0",
"@tauri-apps/cli": "^2.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:svg="http://www.w3.org/2000/svg" id="Layer_1" x="0" y="0" version="1.1" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512" xml:space="preserve"><defs id="defs173"><linearGradient id="XMLID_5_" x1="304.496" x2="316.036" y1="422.91" y2="326.263" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop156"/><stop offset="1" style="stop-color:#c2c2c9" id="stop158"/></linearGradient></defs><style id="style150" type="text/css">.st1{fill:#c02223}.st2{fill:#882425}.st3{fill:url(#XMLID_5_)}.st4{fill:url(#XMLID_7_)}</style><g id="XMLID_4_"><path id="XMLID_131_" d="M 347.01402,14.355825 98.978019,69.02261 C 73.825483,74.547445 55.942464,96.792175 55.942464,122.52628 v 315.06096 c 0,22.39012 16.719895,41.14548 38.819234,43.76251 L 224.8861,498.36042 339.48636,384.26465 455.76603,265.15425 453.73057,84.870162 C 453.43979,62.916214 433.08513,46.632491 411.71274,51.284984 l -28.78729,6.251786 0.14539,-13.666697 C 383.36162,24.678542 365.62399,10.284894 347.01402,14.355825 Z" class="st1" style="stroke-width:1.45391"/><path id="XMLID_117_" d="m 383.21622,57.53677 v 285.8375 L 456.05681,265.00885 454.02135,78.763767 C 453.87595,59.863016 436.28372,45.905539 417.81914,49.97647 Z" class="st2" style="stroke-width:1.45391"/><polygon id="XMLID_18_" points="234.7 422.6 368.5 387.7 393.5 262.2" class="st3" style="fill:url(#XMLID_5_)" transform="matrix(1.4556308,0,0,1.4548265,-116.73161,-116.45231)"/><linearGradient id="XMLID_7_" x1="223.084" x2="241.417" y1="372.756" y2="114.557" gradientTransform="matrix(1.4539039,0,0,1.4539039,-116.19976,-116.20474)" gradientUnits="userSpaceOnUse"><stop offset="0" style="stop-color:#dcf1f3" id="stop163"/><stop offset="1" style="stop-color:#c2c2c9" id="stop165"/></linearGradient><path id="XMLID_6_" d="m 282.89686,214.84917 c 0,0 -22.24473,-28.93269 -38.67384,-36.78377 -10.46811,-4.94327 -26.02489,-6.83335 -38.23768,-0.72695 -18.02841,9.0142 -19.91848,34.31213 -3.34397,44.34406 3.92553,2.47165 9.15959,4.50711 15.99294,6.10641 36.63838,8.43264 97.12077,25.87949 89.70587,96.10304 0,0 -4.21633,65.86185 -73.56753,73.42215 -12.2128,1.30851 -24.57098,0.43617 -36.493,-2.32625 -16.42911,-3.63476 -45.50719,-11.04967 -59.75545,-19.91849 l -2.61703,-75.16682 h 6.97875 c 0,0 13.81208,33.43978 53.06749,49.57812 7.26952,2.90781 15.26599,4.07093 22.97168,2.90781 9.74116,-1.45391 21.22699,-6.68796 25.87949,-22.53551 0,0 7.85108,-23.11707 -32.85823,-35.76604 -32.56744,-10.17733 -63.24481,-20.64543 -75.89378,-54.95757 -5.961,-16.28371 -6.97874,-34.31212 -2.90781,-51.61358 5.37944,-22.53551 20.79082,-54.23062 64.40794,-67.89732 0,0 57.28381,-15.55677 96.53922,5.52484 l -1.74468,89.70587 z" class="st4" style="fill:url(#XMLID_7_);stroke-width:1.45391"/></g></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+837 -88
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -10,6 +10,9 @@ rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lints.rust]
warnings = "deny"
[lib]
name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
@@ -25,11 +28,13 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
tauri-plugin-log = "2.0.0-rc"
tauri-plugin-shell = "2.1.0"
tauri-plugin-fs = "2.4.4"
tauri-plugin-http = "2.4.4"
tauri-plugin-single-instance = "2.0.1"
tauri-plugin-store = "2.1.0"
keyring = "3.6.1"
tokio = { version = "1.0", features = ["time"] }
reqwest = { version = "0.11", features = ["json"] }
# macOS-specific dependencies for native file opening
[target.'cfg(target_os = "macos")'.dependencies]
objc = "0.2"
cocoa = "0.24"
once_cell = "1.19"
core-foundation = "0.10"
core-services = "1.0"
+13 -4
View File
@@ -7,9 +7,18 @@
],
"permissions": [
"core:default",
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
}
"http:default",
{
"identifier": "http:allow-fetch",
"allow": [
{ "url": "http://localhost:*" },
{ "url": "http://127.0.0.1:*" },
{ "url": "https://*" }
]
},
{
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
}
]
}
+215
View File
@@ -0,0 +1,215 @@
use keyring::Entry;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const STORE_FILE: &str = "connection.json";
const USER_INFO_KEY: &str = "user_info";
const KEYRING_SERVICE: &str = "stirling-pdf";
const KEYRING_TOKEN_KEY: &str = "auth-token";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserInfo {
pub username: String,
pub email: Option<String>,
}
fn get_keyring_entry() -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| format!("Failed to access keyring: {}", e))
}
#[tauri::command]
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
log::info!("Saving auth token to keyring");
let entry = get_keyring_entry()?;
entry
.set_password(&token)
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
log::info!("Auth token saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
log::debug!("Retrieving auth token from keyring");
let entry = get_keyring_entry()?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
}
}
#[tauri::command]
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing auth token from keyring");
let entry = get_keyring_entry()?;
// Delete the token - ignore error if it doesn't exist
match entry.delete_credential() {
Ok(_) => {
log::info!("Auth token cleared successfully");
Ok(())
}
Err(keyring::Error::NoEntry) => {
log::info!("Auth token was already cleared");
Ok(())
}
Err(e) => Err(format!("Failed to clear token: {}", e)),
}
}
#[tauri::command]
pub async fn save_user_info(
app_handle: AppHandle,
username: String,
email: Option<String>,
) -> Result<(), String> {
log::info!("Saving user info for: {}", username);
let user_info = UserInfo { username, email };
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.set(
USER_INFO_KEY,
serde_json::to_value(&user_info)
.map_err(|e| format!("Failed to serialize user info: {}", e))?,
);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, String> {
log::debug!("Retrieving user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let user_info: Option<UserInfo> = store
.get(USER_INFO_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(user_info)
}
#[tauri::command]
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.delete(USER_INFO_KEY);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info cleared successfully");
Ok(())
}
// Response types for Spring Boot login
#[derive(Debug, Deserialize)]
struct SpringBootSession {
access_token: String,
}
#[derive(Debug, Deserialize)]
struct SpringBootUser {
username: String,
email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SpringBootLoginResponse {
session: SpringBootSession,
user: SpringBootUser,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
pub username: String,
pub email: Option<String>,
}
/// Login command - makes HTTP request from Rust to bypass CORS
/// Supports Spring Boot authentication (self-hosted)
#[tauri::command]
pub async fn login(
server_url: String,
username: String,
password: String,
) -> Result<LoginResponse, String> {
log::info!("Login attempt for user: {} to server: {}", username, server_url);
// Build login URL
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
log::debug!("Login URL: {}", login_url);
// Create HTTP client
let client = reqwest::Client::new();
// Make login request
let response = client
.post(&login_url)
.json(&serde_json::json!({
"username": username,
"password": password,
}))
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let status = response.status();
log::debug!("Login response status: {}", status);
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Login failed with status {}: {}", status, error_text);
return Err(if status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
"Access denied".to_string()
} else {
format!("Login failed: {}", status)
});
}
// Parse Spring Boot response format
let login_response: SpringBootLoginResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse response: {}", e))?;
log::info!("Login successful for user: {}", login_response.user.username);
Ok(LoginResponse {
token: login_response.session.access_token,
username: login_response.user.username,
email: login_response.user.email,
})
}
+108 -51
View File
@@ -3,10 +3,12 @@ use tauri::Manager;
use std::sync::Mutex;
use std::path::PathBuf;
use crate::utils::add_log;
use crate::state::connection_state::{AppConnectionState, ConnectionMode};
// Store backend process handle globally
// Store backend process handle and port globally
static BACKEND_PROCESS: Mutex<Option<tauri_plugin_shell::process::CommandChild>> = Mutex::new(None);
static BACKEND_STARTING: Mutex<bool> = Mutex::new(false);
static BACKEND_PORT: Mutex<Option<u16>> = Mutex::new(None);
// Helper function to reset starting flag
fn reset_starting_flag() {
@@ -14,6 +16,20 @@ fn reset_starting_flag() {
*starting_guard = false;
}
// Extract port number from "Stirling-PDF running on port: PORT" log line
fn extract_port_from_running_log(log_line: &str) -> Option<u16> {
// Look for pattern: "running on port: PORT"
if let Some(start) = log_line.find("running on port: ") {
let after_prefix = &log_line[start + 17..]; // Skip "running on port: "
// Take digits until whitespace or end of line
let port_str: String = after_prefix.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
return port_str.parse::<u16>().ok();
}
None
}
// Check if backend is already running or starting
fn check_backend_status() -> Result<(), String> {
// Check if backend is already running
@@ -24,7 +40,7 @@ fn check_backend_status() -> Result<(), String> {
return Err("Backend already running".to_string());
}
}
// Check and set starting flag to prevent multiple simultaneous starts
{
let mut starting_guard = BACKEND_STARTING.lock().unwrap();
@@ -34,7 +50,7 @@ fn check_backend_status() -> Result<(), String> {
}
*starting_guard = true;
}
Ok(())
}
@@ -46,13 +62,13 @@ fn find_bundled_jre(resource_dir: &PathBuf) -> Result<PathBuf, String> {
} else {
jre_dir.join("bin").join("java")
};
if !java_executable.exists() {
let error_msg = format!("❌ Bundled JRE not found at: {:?}", java_executable);
add_log(error_msg.clone());
return Err(error_msg);
}
add_log(format!("✅ Found bundled JRE: {:?}", java_executable));
Ok(java_executable)
}
@@ -77,20 +93,20 @@ fn find_stirling_jar(resource_dir: &PathBuf) -> Result<PathBuf, String> {
.unwrap_or(false)
})
.collect();
if jar_files.is_empty() {
let error_msg = "No Stirling-PDF JAR found in libs directory.".to_string();
add_log(error_msg.clone());
return Err(error_msg);
}
// Sort by filename to get the latest version (case-insensitive)
jar_files.sort_by(|a, b| {
let name_a = a.file_name().to_string_lossy().to_ascii_lowercase();
let name_b = b.file_name().to_string_lossy().to_ascii_lowercase();
name_b.cmp(&name_a) // Reverse order to get latest first
});
let jar_path = jar_files[0].path();
add_log(format!("📋 Selected JAR: {:?}", jar_path.file_name().unwrap()));
Ok(jar_path)
@@ -123,23 +139,23 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".config").join("Stirling-PDF")
};
// Create subdirectories for different purposes
let config_dir = app_data_dir.join("configs");
let log_dir = app_data_dir.join("logs");
let work_dir = app_data_dir.join("workspace");
// Create all necessary directories
std::fs::create_dir_all(&app_data_dir).ok();
std::fs::create_dir_all(&log_dir).ok();
std::fs::create_dir_all(&work_dir).ok();
std::fs::create_dir_all(&config_dir).ok();
add_log(format!("📁 App data directory: {}", app_data_dir.display()));
add_log(format!("📁 Log directory: {}", log_dir.display()));
add_log(format!("📁 Working directory: {}", work_dir.display()));
add_log(format!("📁 Config directory: {}", config_dir.display()));
// Define all Java options with Tauri-specific paths
let log_path_option = format!("-Dlogging.file.path={}", log_dir.display());
@@ -150,10 +166,13 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
"-DSTIRLING_PDF_TAURI_MODE=true",
&log_path_option,
"-Dlogging.file.name=stirling-pdf.log",
"-Dserver.port=0", // Let OS assign an available port
"-Dsecurity.enableLogin=false", // Disable login for desktop mode
"-Dsecurity.csrfDisabled=true", // Disable CSRF for desktop mode
"-jar",
jar_path.to_str().unwrap()
jar_path.to_str().unwrap(),
];
// Log the equivalent command for external testing
let java_command = format!(
"TAURI_PARENT_PID={} \"{}\" {}",
@@ -163,14 +182,14 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
);
add_log(format!("🔧 Equivalent command: {}", java_command));
add_log(format!("📁 Backend logs will be in: {}", log_dir.display()));
// Additional macOS-specific checks
if cfg!(target_os = "macos") {
// Check if java executable has execute permissions
if let Ok(metadata) = std::fs::metadata(java_path) {
let permissions = metadata.permissions();
add_log(format!("🔍 Java executable permissions: {:?}", permissions));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -181,7 +200,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
}
}
}
// Check if we can read the JAR file
if let Ok(metadata) = std::fs::metadata(jar_path) {
add_log(format!("📦 JAR file size: {} bytes", metadata.len()));
@@ -189,7 +208,7 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
add_log("⚠️ Cannot read JAR file metadata".to_string());
}
}
let sidecar_command = app
.shell()
.command(java_path.to_str().unwrap())
@@ -199,9 +218,9 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
.env("STIRLING_PDF_CONFIG_DIR", config_dir.to_str().unwrap())
.env("STIRLING_PDF_LOG_DIR", log_dir.to_str().unwrap())
.env("STIRLING_PDF_WORK_DIR", work_dir.to_str().unwrap());
add_log("⚙️ Starting backend with bundled JRE...".to_string());
let (rx, child) = sidecar_command
.spawn()
.map_err(|e| {
@@ -209,18 +228,18 @@ fn run_stirling_pdf_jar(app: &tauri::AppHandle, java_path: &PathBuf, jar_path: &
add_log(error_msg.clone());
error_msg
})?;
// Store the process handle
{
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
*process_guard = Some(child);
}
add_log("✅ Backend started with bundled JRE, monitoring output...".to_string());
// Start monitoring output
monitor_backend_output(rx);
Ok(())
}
@@ -229,35 +248,44 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
tokio::spawn(async move {
let mut _startup_detected = false;
let mut error_count = 0;
while let Some(event) = rx.recv().await {
match event {
tauri_plugin_shell::process::CommandEvent::Stdout(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📤 Backend: {}", output_str));
// Look for startup indicators
if output_str.contains("Started SPDFApplication") ||
output_str.contains("Navigate to "){
// Look for actual runtime port from web server initialization
// Format: "Stirling-PDF running on port: PORT"
if output_str.contains("running on port:") {
_startup_detected = true;
add_log(format!("🎉 Backend startup detected: {}", output_str));
if let Some(port) = extract_port_from_running_log(&output_str) {
let mut port_guard = BACKEND_PORT.lock().unwrap();
*port_guard = Some(port);
add_log(format!("🎉 Backend started on port: {}", port));
add_log(format!("🔌 Navigate to: http://localhost:{}/", port));
}
}
// Look for port binding
if output_str.contains("8080") {
add_log(format!("🔌 Port 8080 related output: {}", output_str));
if output_str.contains("Started SPDFApplication") {
_startup_detected = true;
add_log(format!("🎉 Backend startup completed: {}", output_str));
}
}
tauri_plugin_shell::process::CommandEvent::Stderr(output) => {
let output_str = String::from_utf8_lossy(&output);
// Strip exactly one trailing newline to avoid double newlines
let output_str = output_str.strip_suffix('\n').unwrap_or(&output_str);
add_log(format!("📥 Backend Error: {}", output_str));
// Look for error indicators
if output_str.contains("ERROR") || output_str.contains("Exception") || output_str.contains("FATAL") {
error_count += 1;
add_log(format!("⚠️ Backend error #{}: {}", error_count, output_str));
}
// Look for specific common issues
if output_str.contains("Address already in use") {
add_log("🚨 CRITICAL: Port 8080 is already in use by another process!".to_string());
@@ -295,7 +323,7 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
}
}
}
if error_count > 0 {
println!("⚠️ Backend process ended with {} errors detected", error_count);
}
@@ -304,14 +332,36 @@ fn monitor_backend_output(mut rx: tauri::async_runtime::Receiver<tauri_plugin_sh
// Command to start the backend with bundled JRE
#[tauri::command]
pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
pub async fn start_backend(
app: tauri::AppHandle,
connection_state: tauri::State<'_, AppConnectionState>,
) -> Result<String, String> {
add_log("🚀 start_backend() called - Attempting to start backend with bundled JRE...".to_string());
// Check connection mode
let mode = {
let state = connection_state.0.lock().map_err(|e| {
let error_msg = format!("❌ Failed to access connection state: {}", e);
add_log(error_msg.clone());
error_msg
})?;
state.mode.clone()
};
match mode {
ConnectionMode::Offline => {
add_log("🔌 Running in Offline mode - starting local backend".to_string());
}
ConnectionMode::Server => {
add_log("🌐 Running in Server mode - starting local backend (for hybrid execution support)".to_string());
}
}
// Check if backend is already running or starting
if let Err(msg) = check_backend_status() {
return Ok(msg);
}
// Use Tauri's resource API to find the bundled JRE and JAR
let resource_dir = app.path().resource_dir().map_err(|e| {
let error_msg = format!("❌ Failed to get resource directory: {}", e);
@@ -319,53 +369,60 @@ pub async fn start_backend(app: tauri::AppHandle) -> Result<String, String> {
reset_starting_flag();
error_msg
})?;
add_log(format!("🔍 Resource directory: {:?}", resource_dir));
// Find the bundled JRE
let java_executable = find_bundled_jre(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Find the Stirling-PDF JAR
let jar_path = find_stirling_jar(&resource_dir).map_err(|e| {
reset_starting_flag();
e
})?;
// Normalize the paths to remove Windows UNC prefix
let normalized_java_path = normalize_path(&java_executable);
let normalized_jar_path = normalize_path(&jar_path);
add_log(format!("📦 Found JAR file: {:?}", jar_path));
add_log(format!("📦 Normalized JAR path: {:?}", normalized_jar_path));
add_log(format!("📦 Normalized Java path: {:?}", normalized_java_path));
// Create and start the Java command
run_stirling_pdf_jar(&app, &normalized_java_path, &normalized_jar_path).map_err(|e| {
reset_starting_flag();
e
})?;
// Wait for the backend to start
println!("⏳ Waiting for backend startup...");
tokio::time::sleep(std::time::Duration::from_millis(10000)).await;
// Reset the starting flag since startup is complete
reset_starting_flag();
add_log("✅ Backend startup sequence completed, starting flag cleared".to_string());
Ok("Backend startup initiated successfully with bundled JRE".to_string())
}
// Get the dynamically assigned backend port
#[tauri::command]
pub fn get_backend_port() -> Option<u16> {
let port_guard = BACKEND_PORT.lock().unwrap();
*port_guard
}
// Cleanup function to stop backend on app exit
pub fn cleanup_backend() {
let mut process_guard = BACKEND_PROCESS.lock().unwrap();
if let Some(child) = process_guard.take() {
let pid = child.pid();
add_log(format!("🧹 App shutting down, cleaning up backend process (PID: {})", pid));
match child.kill() {
Ok(_) => {
add_log(format!("✅ Backend process (PID: {}) terminated during cleanup", pid));
@@ -376,4 +433,4 @@ pub fn cleanup_backend() {
}
}
}
}
}
@@ -0,0 +1,111 @@
use crate::state::connection_state::{
AppConnectionState,
ConnectionMode,
ServerConfig,
};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, State};
use tauri_plugin_store::StoreExt;
const STORE_FILE: &str = "connection.json";
const FIRST_LAUNCH_KEY: &str = "setup_completed";
const CONNECTION_MODE_KEY: &str = "connection_mode";
const SERVER_CONFIG_KEY: &str = "server_config";
#[derive(Debug, Serialize, Deserialize)]
pub struct ConnectionConfig {
pub mode: ConnectionMode,
pub server_config: Option<ServerConfig>,
}
#[tauri::command]
pub async fn get_connection_config(
app_handle: AppHandle,
state: State<'_, AppConnectionState>,
) -> Result<ConnectionConfig, String> {
// Try to load from store
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let mode = store
.get(CONNECTION_MODE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or(ConnectionMode::Offline);
let server_config: Option<ServerConfig> = store
.get(SERVER_CONFIG_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
// Update in-memory state
if let Ok(mut conn_state) = state.0.lock() {
conn_state.mode = mode.clone();
conn_state.server_config = server_config.clone();
}
Ok(ConnectionConfig {
mode,
server_config,
})
}
#[tauri::command]
pub async fn set_connection_mode(
app_handle: AppHandle,
state: State<'_, AppConnectionState>,
mode: ConnectionMode,
server_config: Option<ServerConfig>,
) -> Result<(), String> {
log::info!("Setting connection mode: {:?}", mode);
// Update in-memory state
if let Ok(mut conn_state) = state.0.lock() {
conn_state.mode = mode.clone();
conn_state.server_config = server_config.clone();
}
// Save to store
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.set(
CONNECTION_MODE_KEY,
serde_json::to_value(&mode).map_err(|e| format!("Failed to serialize mode: {}", e))?,
);
if let Some(config) = &server_config {
store.set(
SERVER_CONFIG_KEY,
serde_json::to_value(config)
.map_err(|e| format!("Failed to serialize config: {}", e))?,
);
} else {
store.delete(SERVER_CONFIG_KEY);
}
// Mark setup as completed
store.set(FIRST_LAUNCH_KEY, serde_json::json!(true));
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("Connection mode saved successfully");
Ok(())
}
#[tauri::command]
pub async fn is_first_launch(app_handle: AppHandle) -> Result<bool, String> {
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let setup_completed = store
.get(FIRST_LAUNCH_KEY)
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(!setup_completed)
}
@@ -0,0 +1,217 @@
use crate::utils::add_log;
#[cfg(any(target_os = "windows", target_os = "linux"))]
use std::process::Command;
/// Check if Stirling PDF is the default PDF handler
#[tauri::command]
pub fn is_default_pdf_handler() -> Result<bool, String> {
add_log("🔍 Checking if app is default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
check_default_windows()
}
#[cfg(target_os = "macos")]
{
check_default_macos()
}
#[cfg(target_os = "linux")]
{
check_default_linux()
}
}
/// Attempt to set/prompt for Stirling PDF as default PDF handler
#[tauri::command]
pub fn set_as_default_pdf_handler() -> Result<String, String> {
add_log("⚙️ Attempting to set as default PDF handler".to_string());
#[cfg(target_os = "windows")]
{
set_default_windows()
}
#[cfg(target_os = "macos")]
{
set_default_macos()
}
#[cfg(target_os = "linux")]
{
set_default_linux()
}
}
// ============================================================================
// Windows Implementation
// ============================================================================
#[cfg(target_os = "windows")]
fn check_default_windows() -> Result<bool, String> {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
// Query the default handler for .pdf extension
let output = Command::new("cmd")
.args(["/C", "assoc .pdf"])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let assoc = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows PDF association: {}", assoc.trim()));
// Get the ProgID for .pdf files
if let Some(prog_id) = assoc.trim().strip_prefix(".pdf=") {
// Query what application handles this ProgID
let output = Command::new("cmd")
.args(["/C", &format!("ftype {}", prog_id)])
.creation_flags(CREATE_NO_WINDOW)
.output()
.map_err(|e| format!("Failed to query file type: {}", e))?;
let ftype = String::from_utf8_lossy(&output.stdout);
add_log(format!("Windows file type: {}", ftype.trim()));
// Check if it contains "Stirling" or our app name
let is_default = ftype.to_lowercase().contains("stirling");
Ok(is_default)
} else {
Ok(false)
}
}
#[cfg(target_os = "windows")]
fn set_default_windows() -> Result<String, String> {
// On Windows 10+, we need to open the Default Apps settings
// as programmatic setting requires a signed installer
Command::new("cmd")
.args(["/C", "start", "ms-settings:defaultapps"])
.spawn()
.map_err(|e| format!("Failed to open default apps settings: {}", e))?;
add_log("Opened Windows Default Apps settings".to_string());
Ok("opened_settings".to_string())
}
// ============================================================================
// macOS Implementation (using LaunchServices framework)
// ============================================================================
#[cfg(target_os = "macos")]
fn check_default_macos() -> Result<bool, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSCopyDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSCopyDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
) -> CFStringRef;
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Query the default handler for "com.adobe.pdf" (PDF UTI - standard macOS identifier)
let pdf_uti = CFString::new("com.adobe.pdf");
let handler_ref = LSCopyDefaultRoleHandlerForContentType(pdf_uti.as_concrete_TypeRef(), K_LS_ROLES_ALL);
if handler_ref.is_null() {
add_log("No default PDF handler found".to_string());
return Ok(false);
}
let handler = CFString::wrap_under_create_rule(handler_ref);
let handler_str = handler.to_string();
add_log(format!("macOS PDF handler: {}", handler_str));
// Check if it's our bundle identifier
let is_default = handler_str == "stirling.pdf.dev";
Ok(is_default)
}
}
#[cfg(target_os = "macos")]
fn set_default_macos() -> Result<String, String> {
use core_foundation::base::TCFType;
use core_foundation::string::{CFString, CFStringRef};
use std::os::raw::c_int;
// Define the LSSetDefaultRoleHandlerForContentType function
#[link(name = "CoreServices", kind = "framework")]
extern "C" {
fn LSSetDefaultRoleHandlerForContentType(
content_type: CFStringRef,
role: c_int,
handler_bundle_id: CFStringRef,
) -> c_int; // OSStatus
}
const K_LS_ROLES_ALL: c_int = 0xFFFFFFFF_u32 as c_int;
unsafe {
// Set our app as the default handler for PDF files
let pdf_uti = CFString::new("com.adobe.pdf");
let our_bundle_id = CFString::new("stirling.pdf.dev");
let status = LSSetDefaultRoleHandlerForContentType(
pdf_uti.as_concrete_TypeRef(),
K_LS_ROLES_ALL,
our_bundle_id.as_concrete_TypeRef(),
);
if status == 0 {
add_log("Successfully triggered default app dialog".to_string());
Ok("set_successfully".to_string())
} else {
let error_msg = format!("LaunchServices returned status: {}", status);
add_log(error_msg.clone());
Err(error_msg)
}
}
}
// ============================================================================
// Linux Implementation
// ============================================================================
#[cfg(target_os = "linux")]
fn check_default_linux() -> Result<bool, String> {
// Use xdg-mime to check the default application for PDF files
let output = Command::new("xdg-mime")
.args(["query", "default", "application/pdf"])
.output()
.map_err(|e| format!("Failed to check default app: {}", e))?;
let handler = String::from_utf8_lossy(&output.stdout);
add_log(format!("Linux PDF handler: {}", handler.trim()));
// Check if it's our .desktop file
let is_default = handler.trim() == "stirling-pdf.desktop";
Ok(is_default)
}
#[cfg(target_os = "linux")]
fn set_default_linux() -> Result<String, String> {
// Use xdg-mime to set the default application for PDF files
let result = Command::new("xdg-mime")
.args(["default", "stirling-pdf.desktop", "application/pdf"])
.output()
.map_err(|e| format!("Failed to set default app: {}", e))?;
if result.status.success() {
add_log("Set as default PDF handler on Linux".to_string());
Ok("set_successfully".to_string())
} else {
let error = String::from_utf8_lossy(&result.stderr);
add_log(format!("Failed to set default: {}", error));
Err(format!("Failed to set as default: {}", error))
}
}

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