Compare commits

...
38 Commits
Author SHA1 Message Date
Ludy87 db8e46c451 Update ConfigController.java 2026-02-20 10:23:22 +01:00
Ludy d57c3ddeb7 Merge branch 'main' into add_java_plugin 2026-02-20 09:14:40 +01:00
Anthony Stirling 031477b7b5 fix publishing for tauri author (#5757)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-19 16:11:10 +00:00
Anthony Stirling 8725ba66bb ruler support (#5758)
# Description of Changes

<img width="266" height="228" alt="image"
src="https://github.com/user-attachments/assets/bcc6ec11-bd9e-4b83-a081-62149dd92f2a"
/>

<img width="882" height="335" alt="image"
src="https://github.com/user-attachments/assets/b86dbf13-6bcf-4b28-81a6-8c405358a58e"
/>

<img width="1050" height="399" alt="image"
src="https://github.com/user-attachments/assets/6a4468ed-d0f9-44ab-978a-c640d490da8b"
/>

on hover
<img width="380" height="196" alt="image"
src="https://github.com/user-attachments/assets/ba3755b3-4823-48dc-b6aa-3a0f9b0517a3"
/>

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-19 15:58:03 +00:00
James Brunton 115a24b16d Add plist file for Mac permissions (#5756)
# Description of Changes
There's speculation that the Mac app failing to connect to self-hosted
servers (#5264) is because of missing OS-permissions
[NSLocalNetworkUsageDescription](https://developer.apple.com/documentation/bundleresources/information-property-list/nslocalnetworkusagedescription).
This PR adds it in a `Info.plist` file which ships with the Tauri app
which should throw an OS permissions dialog if required.
2026-02-19 10:26:01 +00:00
Balázs Szücs c775fed17d fix(form-fill): fix hardcoded response (#5755)
# Description of Changes

Closes: #5752

<!--
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>
2026-02-18 11:01:58 +00:00
Anthony Stirling ae9d29abf0 large query reduction (#5754)
# Description of Changes

Reduce endpoint-availability call so that an empty param to it returns
all endpoints to avoid pointlessly large http headers


Before:
GET
/api/v1/config/endpoints-availability?endpoints=compress-pdf%2Crotate-pdf%2Cmerge-pdfs%2Csplit-pages%2Cocr-pdf
  for all 74 tools

  After:
  GET /api/v1/config/endpoints-availability
---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-18 10:52:59 +00:00
Anthony Stirling ddf93d2b1a fixes for desktop SSO (#5751)
# Description of Changes



Race condition: The browser was opened before the deep-link listener was
registered. On slow first launches the OAuth
callback could arrive before the listener was ready. Fixed by
registering the listener first, then opening the browser
   inside .then() once the listener is confirmed active.

Double-handling: Both SetupWizard and
authService.waitForDeepLinkCompletion processed the same
sso/sso-selfhosted deep
links, calling completeSelfHostedSession and onComplete() independently.
Fixed by moving all SSO completion into
  authService and having SetupWizard defer to it.

Hardening: Removing SetupWizard's handler entirely left no fallback if
the webview reloads while the auth listener's
Promise is in flight. A selfHostedDeepLinkFlowActive flag tracks whether
authService has an active listener.
SetupWizard now acts as a fallback only when the flag is false (i.e.
after a JS context reset), preventing duplicate
  handling in the normal path while preserving resilience on reload.
---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-18 10:46:15 +00:00
Balázs Szücs e97f93924e fix(forms): Update form field UI and behavior for better interactivity and alignment (#5747)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-02-18 08:49:52 +00:00
Anthony Stirling 8d5b3eb36b Fix SAML login "something went wrong" when language list = 1 (#5750)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-18 08:49:08 +00:00
Ludy 9f26dc4112 Merge branch 'main' into add_java_plugin 2026-02-17 13:04:53 +01:00
Reece Browne 757a666f5e Chore/v2/improve annotation UI (#5724) 2026-02-16 22:01:15 +00:00
Anthony Stirling 558c75a2b1 JWT enhancements for desktop (#5742)
# Description of Changes

This is temporary solution which will be enhanced in future

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-16 21:57:42 +00:00
ConnorYoh da2eb54fe8 fix_env_files_for_tauri (#5741)
https://vite.dev/config/#using-environment-variables-in-config
2026-02-16 20:49:23 +00:00
Ludy a23c252af5 Merge branch 'main' into add_java_plugin 2026-02-16 21:11:04 +01:00
Anthony Stirling 772dd4632e PDF Text editor changes (#5726)
# Description of Changes

 - Reduced lightweight editor JSON size:
- Omit heavy page resources and contentStreams in lazy/lightweight
flows.
      - Omit form fields in lazy metadata/editor bootstrapping flows.
      - Strip inline font program blobs from lazy initial payloads.
  - Added page-based font loading:
      - New endpoint to fetch fonts for a specific cached page:
        GET /api/v1/convert/pdf/text-editor/fonts/{jobId}/{pageNumber}
- Frontend now loads page fonts alongside page data and merges into
local doc state.
  - Reduced save payload duplication:
- Partial export now sends only changed pages (no repeated full-document
font/metadata payload each save).
  - Preserved round-trip/export safety:
- Missing lightweight fields (resources/contentStreams) are interpreted
as “preserve existing from cached PDF.”
- Annotation semantics fixed so explicit empty annotation lists can
clear annotations.
- Fixed a regression where lazy mode could fall back to full export and
lose overlays; lazy now stays on cached
        partial export path when dirty pages exist.
  - Logging/noise reduction
  - Transport optimization:
- Enabled HTTP compression for JSON/problem responses. (might remove
later tho in testing)
      
      
      ### Outcome

  - Much smaller JSON payloads for giant PDFs.
  - Fewer duplicated bytes over the wire.
  - Page-scoped loading of heavy font data.
- Better reliability for preserving overlays/vector/background content
during export.


## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-16 17:36:13 +00:00
Anthony Stirling d5cf77cf50 refactor: fix homepage file upload path (#5738)
Extracts file-based navigation logic from HomePage into pure function
with comprehensive test coverage.

New behavior:
- Opening 1 file from empty → switch to viewer (activeFileIndex: 0)
- Opening 2+ files from empty → switch to fileEditor
- pdfTextEditor tool → no auto-navigation (handles own empty state)
- Non-startup transitions (N→M files) → no navigation

Benefits:
- Pure function → easy to test and reason about
- Clear separation of concerns
- Preserves all existing behavior including pdfTextEditor special case
- Adds new multi-file startup behavior

Changes:
- HomePage.tsx: use getStartupNavigationAction() utility
- homePageNavigation.ts: pure navigation logic
- homePageNavigation.test.ts: comprehensive unit tests

Note: prevFileCountRef initialization kept as useRef(activeFiles.length)
to correctly handle files restored from IndexedDB on app startup.

# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-16 12:40:50 +00:00
Ludy87 f25b308e46 Add new plugin translation keys
Add three English translation entries to support plugin UI updates: a top-level minHost label ("min. v{{version}}") and, under [plugins], closeViewer ("Close plugin viewer") and noDescription ("No description provided."). These provide a host-version hint, a viewer close label, and a fallback description for plugins.
2026-02-15 21:40:08 +01:00
Ludy87 d3e13967e9 Improve plugin loader, frontend and i18n
Refactor and harden plugin subsystem: PluginLoader now validates and filters jar files more robustly (mime/probe fallback, jar readability check), normalises sorting, and resolves jar timestamps via creation/modified times. PluginFrontendController refactored to use helpers for suffix resolution and resource streaming, adds path traversal checks and clearer logging, and serves static assets from META-INF/resources/plugins. PluginService gains logging, duplicate-plugin-id detection (keeps first jar) and safer descriptor loading. Added Javadoc/comments to PluginDescriptorResponse, PluginMetadata and PluginResourceConfig. Frontend: add English plugin translations and migrate PluginSection defaults to English; remove openPluginWindow util. These changes improve security, stability and developer observability when loading and serving plugin assets.
2026-02-15 21:29:50 +01:00
Ludy87 0e94ea156f Remove unused imports in Plugin & ToolPicker
Clean up unused imports and variables: remove useMemo import from PluginSection, drop unused Badge and Text imports from ToolPicker, and remove unused workflow setters (setLeftPanelView, setReaderMode, setSearchQuery) from the ToolPicker destructure. These are housekeeping changes to address linter warnings and reduce dead code.
2026-02-15 20:44:43 +01:00
Ludy87 46049a0a4a Update AppConfigContext.tsx 2026-02-15 18:22:40 +01:00
Ludy87 3d3c5f79a5 Remove unused pluginExplorerOpen state
Delete the unused pluginExplorerOpen useState declaration from QuickAccessBar.tsx to clean up dead state and reduce component clutter. No behavior changes intended.
2026-02-15 18:14:38 +01:00
Ludy87 330a987faf Add plugin icons and jar creation metadata
Add icon and jarCreatedAt fields to plugin descriptors and responses; capture JAR creation time in PluginLoader (with content-type probe and improved logging) and include it in built PluginDescriptor. Update PluginMetadata to accept an icon. Improve URLClassLoader and toArray usages. Expose basePath and pluginsPath in ConfigController. Frontend: remove PluginExplorerModal, add a Plugins section in settings (PluginSection) and wire it into nav; enhance PluginRegistry to build icon URLs, include minHostVersion and jarCreatedAt, and normalize plugin data using app baseUrl; update ToolPicker to render plugin icons and UI tweaks. Misc: small UI/refactor changes and debug logging adjustments.
2026-02-15 18:11:44 +01:00
Ludy87 5806dfecf6 Update openPluginWindow.ts 2026-02-15 00:22:22 +01:00
Ludy87 b653e09c16 Update SPDFApplication.java 2026-02-15 00:08:40 +01:00
Ludy87 61f3000cea Add plugin system with loader and UI 2026-02-14 23:57:55 +01:00
Balázs Szücs e310493966 refactor(api): replace regex string literals with Pattern instances for improved performance and readability (#5680)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-02-14 21:01:19 +00:00
Balázs Szücs 0a1d2effdc feat(frontend): Upgrade embedPDF to v2.6.0 and migrate to pdf-lib fork, fix attachment/bookmark panel (#5723)
# Description of Changes

Upgrades embedPDF from v2.5.0 to v2.6.0 and migrates from unmaintained
pdf-lib to @cantoo/pdf-lib fork. Adds defensive error handling for
malformed PDFs and improves bridge lifecycle management.

### Changes

**Dependencies**
- Upgrade all @embedpdf/* packages from ^2.5.0 to ^2.6.0
- Replace pdf-lib with @cantoo/pdf-lib (maintained fork with better
TypeScript support)

**PDF Viewer Infrastructure (attachment/bookmark fix)**
- Add useDocumentReady hook to track document lifecycle across bridges
- Implement defensive bridge cleanup to prevent stale registrations
- Fix race condition in document ready state detection by subscribing to
events before checking state

**Link Extraction (updated to cantoo/pdf-lib)**
- Add graceful error handling for PDFs with invalid catalog structures
- Extract enhanced link metadata (tooltips, colors, border styles,
highlight modes)
- Return empty results instead of throwing on malformed PDFs
- Add validation for link creation (destination page bounds, rect
dimensions, color values)

**Signature Flattening  (updated to cantoo/pdf-lib)**
- Improve SVG embedding with three-tier fallback strategy (native
vector, rasterized PNG, placeholder)
- Add proper Unicode handling for PDF form tooltips via
PDFString.decodeText()
- Extract SVG utilities into cleaner strategy pattern

**Form Field Processing  (updated to cantoo/pdf-lib)**
- Add support for display labels vs export values in dropdown/list
fields per PDF spec 12.7.4.4
- Implement caching for expensive field property lookups
- Add proper handling of malformed /Opt arrays


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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-02-14 20:55:27 +00:00
b8ce4e47c1 Preserve local paths for desktop saves (#5543)
# Summary

- Adds desktop file tracking: local paths are preserved and save buttons
now work as expcted (doing Save/Save As as appropriate)
- Adds logic to track whether files are 'dirty' (they've been modified
by some tool, and not saved to disk yet).
- Improves file state UX (dirty vs saved) and close warnings
- Web behaviour should be unaffected by these changes

## Indicators
Files now have indicators in desktop mode to tell you their state.

### File up-to-date with disk

<img width="318" height="393" alt="image"
src="https://github.com/user-attachments/assets/06325f9a-afd7-4c2f-8a5b-6d11e3093115"
/>

### File modified by a tool but not saved to disk yet

<img width="357" height="385" alt="image"
src="https://github.com/user-attachments/assets/1a7716d9-c6f7-4d13-be0d-c1de6493954b"
/>

### File not tracked on disk

<img width="312" height="379" alt="image"
src="https://github.com/user-attachments/assets/9cffe300-bd9a-4e19-97c7-9b98bebefacc"
/>

# Limitations
- It's a bit weird that we still have files stored in indexeddb in the
app, which are still loadable. We might want to change this behaviour in
the future
- Viewer's Save doesn't persist to disk. I've left that out here because
it'd need a lot of testing to make sure the logic's right with making
sure you can leave the Viewer with applying the changes to the PDF
_without_ saving to disk
- There's no current way to do Save As on a file that has already been
persisted to disk - it's only ever Save. Similarly, there's no way to
duplicate a file.

---------

Co-authored-by: James Brunton <jbrunton96@gmail.com>
Co-authored-by: James Brunton <james@stirlingpdf.com>
2026-02-13 23:15:28 +00:00
Anthony Stirling 946196de43 fix tool disabling for docs and others (#5722)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-13 23:15:06 +00:00
Balázs Szücs 27bd34c29b feat(form-fill): FormFill tool with context and UI components for PDF form filling (#5711) 2026-02-13 15:10:48 +00:00
Balázs Szücs 5a1ed50e2b feat(attachments): add attachment support with sidebar and API integration (#5673) 2026-02-13 12:41:15 +00:00
Reece Browne e01734fb7d Fix viewer export (#5713) 2026-02-13 12:16:52 +00:00
Reece Browne 7c3c7937b3 various viewer pill fixes (#5714) 2026-02-13 12:16:30 +00:00
Balázs Szücs b1d44d5661 feat(linklayer): improve link handling with pdf-lib integration and add link toolbar, add delete link functionality (#5715) 2026-02-13 12:16:13 +00:00
Balázs Szücs 71c845bcd8 feat(text-selection): implement text selection enhancement for double and triple-click actions (#5712) 2026-02-13 12:16:01 +00:00
albanobattistellaandLudy c62277a8e5 Update translation (#5670)
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
2026-02-12 20:04:47 +00:00
Balázs Szücs f3a4dbc903 feat(redaction): improve manual redaction with color selection and updated UI elements (#5679)
# Description of Changes

<img width="1920" height="977" alt="image"
src="https://github.com/user-attachments/assets/17e451b7-df2b-4097-b8aa-66954d89b935"
/>


<!--
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>
2026-02-12 19:26:26 +00:00
241 changed files with 16314 additions and 2335 deletions
+115 -3
View File
@@ -30,6 +30,91 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
- **Web Server**: `npm run build` then serve dist/ folder
- **Development**: `npm run tauri-dev` for desktop dev mode
#### Import Paths - CRITICAL
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
```typescript
// ✅ CORRECT - Use @app/* for all imports
import { AppLayout } from "@app/components/AppLayout";
import { useFileContext } from "@app/contexts/FileContext";
import { FileContext } from "@app/contexts/FileContext";
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
import { AppLayout } from "@core/components/AppLayout";
import { useFileContext } from "@proprietary/contexts/FileContext";
```
**Only use explicit aliases when:**
- Building layer-specific override that wraps a lower layer's component
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
#### Component Override Pattern (Stub/Shadow)
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
**How it works:**
1. Core defines stub component (returns null or no-op)
2. Desktop/proprietary overrides with same path/name
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
**Example - Desktop-specific footer:**
```typescript
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
return null; // Stub - does nothing in web builds
}
```
```typescript
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
import { Box } from '@mantine/core';
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
interface RightRailFooterExtensionsProps {
className?: string;
}
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
return (
<Box className={className}>
<BackendHealthIndicator />
</Box>
);
}
```
```typescript
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
export function RightRail() {
return (
<div>
{/* In web builds: renders nothing (stub returns null) */}
{/* In desktop builds: renders BackendHealthIndicator */}
<RightRailFooterExtensions className="right-rail-footer" />
</div>
);
}
```
**Build resolution:**
- **Core build**: `@app/*``core/*` → Gets stub (returns null)
- **Desktop build**: `@app/*``desktop/*` → Gets real implementation (shadows core)
**Benefits:**
- No runtime checks or feature flags
- Type-safe across all builds
- Clean, readable code
- Build-time optimization (dead code elimination)
#### Multi-Tool Workflow Architecture
Frontend designed for **stateful document processing**:
- Users upload PDFs once, then chain tools (split → merge → compress → view)
@@ -37,7 +122,7 @@ Frontend designed for **stateful document processing**:
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
#### FileContext - Central State Management
**Location**: `src/contexts/FileContext.tsx`
**Location**: `frontend/src/core/contexts/FileContext.tsx`
- **Active files**: Currently loaded PDFs and their variants
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
@@ -62,7 +147,7 @@ Without cleanup: browser crashes with memory leaks.
**Architecture**: Modular hook-based system with clear separation of concerns:
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
- Coordinates all tool operations with consistent interface
- Integrates with FileContext for operation tracking
- Handles validation, error handling, and UI state management
@@ -147,8 +232,34 @@ return useToolOperation({
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
- **Security Layer**: Authentication, authorization, and user management (when enabled)
### Frontend Directory Structure
The frontend is organized with a clear separation of concerns:
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
- **`core/components/`**: React components organized by feature
- `core/components/tools/`: Individual PDF tool implementations
- `core/components/viewer/`: PDF viewer components
- `core/components/pageEditor/`: Page manipulation UI
- `core/components/tooltips/`: Help tooltips for tools
- `core/components/shared/`: Reusable UI components
- **`core/contexts/`**: React Context providers
- `FileContext.tsx`: Central file state management
- `file/`: File reducer and selectors
- `toolWorkflow/`: Tool workflow state
- **`core/hooks/`**: Custom React hooks
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
- **`core/constants/`**: Application constants and configuration
- **`core/data/`**: Static data (tool taxonomy, etc.)
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
- **`frontend/src/proprietary/`**: Proprietary/licensed features
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
- **`frontend/public/`**: Static assets served directly
- `public/locales/`: Translation JSON files
### Component Architecture
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
- **Internationalization**:
- Backend: `messages_*.properties` files
@@ -203,6 +314,7 @@ return useToolOperation({
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
@@ -391,13 +391,24 @@ public class EndpointConfiguration {
addEndpointToGroup("Advance", "extract-image-scans");
addEndpointToGroup("Advance", "repair");
addEndpointToGroup("Advance", "auto-rename");
addEndpointToGroup("Advance", "handleData");
addEndpointToGroup("Advance", "scanner-effect");
addEndpointToGroup("Advance", "show-javascript");
addEndpointToGroup("Advance", "overlay-pdf");
// Backend-only endpoints
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "pipeline");
// Adding endpoints to "Automation" group
addEndpointToGroup("Automation", "handleData");
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
addEndpointToGroup("Automation", "pipeline");
// Adding endpoints to "DeveloperTools" group
addEndpointToGroup("DeveloperTools", "show-javascript");
// Adding endpoints to "DeveloperDocs" group (fake endpoints for link-only tools)
addEndpointToGroup("DeveloperDocs", "dev-api-docs");
addEndpointToGroup("DeveloperDocs", "dev-folder-scanning-docs");
addEndpointToGroup("DeveloperDocs", "dev-sso-guide-docs");
addEndpointToGroup("DeveloperDocs", "dev-airgapped-docs");
// CLI
addEndpointToGroup("CLI", "compress-pdf");
@@ -595,6 +606,12 @@ public class EndpointConfiguration {
return endpointGroups.getOrDefault(group, new HashSet<>());
}
public Set<String> getAllEndpoints() {
return endpointGroups.values().stream()
.flatMap(Set::stream)
.collect(java.util.stream.Collectors.toSet());
}
private boolean isToolGroup(String group) {
return "qpdf".equals(group)
|| "OCRmyPDF".equals(group)
@@ -14,6 +14,7 @@ public class InstallationPathConfig {
private static final String CUSTOM_FILES_PATH;
private static final String CLIENT_WEBUI_PATH;
private static final String PIPELINE_PATH;
private static final String PLUGINS_PATH;
// Config paths
private static final String SETTINGS_PATH;
@@ -40,6 +41,7 @@ public class InstallationPathConfig {
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator;
PLUGINS_PATH = CUSTOM_FILES_PATH + "plugins" + File.separator;
// Initialize config paths
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
@@ -110,6 +112,10 @@ public class InstallationPathConfig {
return SIGNATURES_PATH;
}
public static String getPluginsPath() {
return PLUGINS_PATH;
}
public static String getPrivateKeyPath() {
return BACKUP_PRIVATE_KEY_PATH;
}
@@ -0,0 +1,49 @@
package stirling.software.common.constants;
/**
* Centralized constants for JWT token management.
*
* <p>These defaults are used when configuration values are not explicitly set.
*/
public final class JwtConstants {
private JwtConstants() {
throw new UnsupportedOperationException("Utility class");
}
/** Default JWT access token lifetime in minutes (24 hours). */
public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440;
/** Default desktop client token lifetime in minutes (30 days). */
public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200;
/**
* Default refresh grace period in minutes.
*
* <p>Allows refresh of expired tokens within this window after expiration.
*/
public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
/**
* Default allowed clock skew in seconds.
*
* <p>Tolerates small time drift between client and server clocks during validation.
*/
public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60;
/** Milliseconds per minute. */
public static final long MILLIS_PER_MINUTE = 60_000L;
/** Seconds per minute. */
public static final long SECONDS_PER_MINUTE = 60L;
/** JWT issuer identifier. */
public static final String ISSUER = "https://stirling.com";
/**
* Maximum refresh attempts allowed within the grace period window.
*
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
*/
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
}
@@ -39,6 +39,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.YamlPropertySourceFactory;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
@@ -393,12 +394,107 @@ public class ApplicationProperties {
}
}
/**
* JWT token configuration.
*
* <p><b>BREAKING CHANGE (v2.0):</b> Default token expiry increased from 12 hours (720
* minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set
* {@code tokenExpiryMinutes: 720} in your configuration.
*/
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
private int keyRetentionDays = 7;
/**
* JWT access token lifetime in minutes for web clients.
*
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
*
* <p><b>BREAKING CHANGE:</b> Previously hardcoded to 720 minutes (12 hours). Now
* defaults to 1440 minutes (24 hours).
*/
private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
/**
* JWT access token lifetime in minutes for desktop clients (Tauri app).
*
* <p>Desktop clients are automatically detected via User-Agent header and receive
* longer-lived tokens because they run on personal devices with OS-level encrypted
* storage (macOS Keychain, Windows Credential Manager, Linux Secret Service).
*
* <p>This provides better UX (login once per month) while maintaining security through
* device encryption and secure storage, matching the behavior of popular desktop apps
* like Slack, Discord, VS Code, etc.
*
* <p>Default: 43200 minutes (30 days).
*/
private int desktopTokenExpiryMinutes = 43200;
/**
* Allowed clock skew in seconds for JWT validation.
*
* <p>Tolerates small time drift between client and server clocks. Tokens that are
* slightly expired or slightly in the future (within this window) will still be
* accepted.
*
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
*/
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
/**
* Grace period in minutes for refreshing expired tokens.
*
* <p>Allows token refresh using an expired access token if the token expired within
* this many minutes. This provides better UX by allowing users to refresh slightly
* expired tokens without re-authentication.
*
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
*
* <p>Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes.
*/
private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
/**
* Calculate number of days to retain old JWT signing keys.
*
* <p>Automatically calculated based on the longest token lifetime plus a proportional
* safety buffer. Keys must be retained for at least as long as the tokens they signed
* remain valid, otherwise token verification will fail.
*
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
*
* <p>The buffer includes:
*
* <ul>
* <li>10% of token lifetime (scales with token duration)
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
* </ul>
*
* @return calculated key retention period in days
*/
public int getKeyRetentionDays() {
final int MINUTES_PER_DAY = 1440;
final double BUFFER_PERCENTAGE = 0.10; // 10% buffer
int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes);
// Add 10% buffer (scales with token lifetime)
int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE);
// Add refresh grace period
bufferMinutes += refreshGraceMinutes;
// Add clock skew (convert seconds to minutes, round up)
bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0);
// Total retention in minutes, convert to days (round up)
int totalMinutes = maxTokenExpiryMinutes + bufferMinutes;
return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY);
}
}
@Data
@@ -616,6 +712,8 @@ public class ApplicationProperties {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
private boolean defaultHideUnavailableTools = false;
private boolean defaultHideUnavailableConversions = false;
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
@@ -0,0 +1,98 @@
package stirling.software.common.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Form field information with coordinates for interactive form viewer. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Form field with coordinates and metadata")
public class FormFieldWithCoordinates {
@Schema(description = "Fully qualified field name", example = "form1.firstName")
private String name;
@Schema(description = "Display label for the field", example = "First Name")
private String label;
@Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature")
private String type;
@Schema(description = "Current field value")
private String value;
@Schema(
description =
"Available options (export values) for choice fields"
+ " (dropdown, radio, listbox)")
private List<String> options;
@Schema(
description =
"Human-readable display labels for choice field options,"
+ " parallel to the 'options' list. Null when identical to options.")
private List<String> displayOptions;
@Schema(description = "Whether the field is required")
private boolean required;
@Schema(description = "Whether the field is read-only")
private boolean readOnly;
@Schema(description = "Whether this is a multi-select list box")
private boolean multiSelect;
@Schema(description = "Whether this is a multi-line text field")
private boolean multiline;
@Schema(description = "Tooltip/alternate name for the field")
private String tooltip;
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
private List<WidgetCoordinates> widgets;
/**
* Coordinates for a single widget annotation (visual representation of the field). A field can
* have multiple widgets if it appears on multiple pages.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Widget coordinates in PDF space")
public static class WidgetCoordinates {
@Schema(description = "Page index (0-based)", example = "0")
private int pageIndex;
@Schema(description = "X coordinate in PDF points (lower-left origin)")
private float x;
@Schema(description = "Y coordinate in PDF points (lower-left origin)")
private float y;
@Schema(description = "Width in PDF points")
private float width;
@Schema(description = "Height in PDF points")
private float height;
@Schema(description = "Export value for this widget (radio/checkbox buttons only)")
private String exportValue;
@Schema(description = "Font size in PDF points")
private Float fontSize;
}
}
@@ -0,0 +1,34 @@
package stirling.software.common.plugins;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Builder;
import lombok.Value;
/** Immutable descriptor that represents a loaded plugin. */
@Value
@Builder
@JsonInclude(JsonInclude.Include.NON_EMPTY)
public class PluginDescriptor {
String id;
String icon;
String name;
String description;
String version;
String author;
String frontendLabel;
String frontendPath;
String iconPath;
String minHostVersion;
String jarCreatedAt;
@Builder.Default boolean hasFrontend = false;
@Builder.Default List<String> backendEndpoints = Collections.emptyList();
@Builder.Default Map<String, String> metadata = Collections.emptyMap();
}
@@ -0,0 +1,65 @@
package stirling.software.common.plugins;
import java.util.List;
import java.util.Map;
import lombok.Builder;
import lombok.Value;
@Value
@Builder
/**
* API-facing representation of a plugin descriptor with a fully resolved frontend URL.
*
* <p>This DTO is returned to clients so they can render plugin metadata and open plugin UIs.
*/
public class PluginDescriptorResponse {
String id;
String icon;
String name;
String description;
String version;
String author;
String frontendUrl;
String frontendLabel;
String iconPath;
String minHostVersion;
String jarCreatedAt;
boolean hasFrontend;
List<String> backendEndpoints;
Map<String, String> metadata;
/**
* Creates a response object from an internal {@link PluginDescriptor}.
*
* @param descriptor loaded plugin descriptor
* @param baseUrl optional API base URL used to build an absolute frontend URL
* @return normalized response payload for API clients
*/
public static PluginDescriptorResponse from(PluginDescriptor descriptor, String baseUrl) {
String frontendPath = descriptor.getFrontendPath();
String normalizedBase = baseUrl != null ? baseUrl.replaceAll("/+$", "") : "";
String normalizedPath = frontendPath != null ? frontendPath.replaceAll("^/+", "/") : "";
String frontendUrl =
(normalizedBase.isEmpty() || normalizedPath.isEmpty())
? (normalizedPath.isEmpty() ? null : normalizedPath)
: normalizedBase + normalizedPath;
return PluginDescriptorResponse.builder()
.id(descriptor.getId())
.icon(descriptor.getIcon())
.name(descriptor.getName())
.description(descriptor.getDescription())
.version(descriptor.getVersion())
.author(descriptor.getAuthor())
.frontendUrl(frontendUrl)
.frontendLabel(descriptor.getFrontendLabel())
.iconPath(descriptor.getIconPath())
.hasFrontend(descriptor.isHasFrontend())
.backendEndpoints(descriptor.getBackendEndpoints())
.metadata(descriptor.getMetadata())
.minHostVersion(descriptor.getMinHostVersion())
.jarCreatedAt(descriptor.getJarCreatedAt())
.build();
}
}
@@ -0,0 +1,261 @@
package stirling.software.common.plugins;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
/**
* Utility responsible for discovering plugin jars, parsing their metadata, and integrating them
* into the Stirling-PDF runtime.
*/
@Slf4j
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class PluginLoader {
private static final String JAR_EXTENSION = ".jar";
private static final String JAR_MIME_TYPE = "application/java-archive";
private static final String METADATA_RESOURCE = "META-INF/stirling-plugin.json";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* Discovers plugin jar files in the configured plugins directory.
*
* @return sorted list of valid plugin jar paths
*/
public static List<Path> listPluginJars() {
Path pluginDir = ensurePluginDirectory();
if (!Files.isDirectory(pluginDir)) {
return Collections.emptyList();
}
try (Stream<Path> stream = Files.list(pluginDir)) {
return stream.filter(Files::isRegularFile)
.filter(PluginLoader::looksLikeJarFile)
.filter(PluginLoader::isReadableJarArchive)
.sorted(
Comparator.comparing(
path -> path.getFileName().toString().toLowerCase()))
.collect(Collectors.toList());
} catch (IOException e) {
log.warn("Failed to list plugin directory {}: {}", pluginDir, e.getMessage());
return Collections.emptyList();
}
}
/**
* Converts discovered plugin jar paths into URL entries suitable for class/resource loading.
*
* @return immutable-style list of valid jar URLs
*/
public static List<URL> pluginJarUrls() {
List<Path> jars = listPluginJars();
if (jars.isEmpty()) {
return Collections.emptyList();
}
List<URL> urls = new ArrayList<>(jars.size());
for (Path jar : jars) {
try {
urls.add(jar.toUri().toURL());
} catch (MalformedURLException e) {
log.warn("Skipping plugin jar with invalid URL {}: {}", jar, e.getMessage());
}
}
return urls;
}
/**
* Creates a class loader that can load classes/resources from installed plugins.
*
* @param parent parent class loader
* @return plugin-aware class loader or parent when no plugin jars exist
*/
public static ClassLoader buildPluginClassLoader(ClassLoader parent) {
List<URL> urls = pluginJarUrls();
if (urls.isEmpty()) {
return parent;
}
log.info(
"Scanning {} plugin jars in {}",
urls.size(),
InstallationPathConfig.getPluginsPath());
return new URLClassLoader(urls.toArray(URL[]::new), parent);
}
/**
* Loads descriptors for all discovered plugin jars.
*
* @return immutable list of successfully parsed descriptors
*/
public static List<PluginDescriptor> loadDescriptors() {
List<Path> jars = listPluginJars();
if (jars.isEmpty()) {
return Collections.emptyList();
}
List<PluginDescriptor> descriptors = new ArrayList<>();
for (Path jar : jars) {
PluginDescriptor descriptor = readDescriptorFromJar(jar);
if (descriptor != null) {
descriptors.add(descriptor);
}
}
return Collections.unmodifiableList(descriptors);
}
/**
* Loads metadata for one plugin jar and maps it to a descriptor.
*
* @param jarPath plugin jar path
* @return descriptor when valid metadata exists, otherwise {@code null}
*/
public static PluginDescriptor loadDescriptor(Path jarPath) {
return readDescriptorFromJar(jarPath);
}
private static Path ensurePluginDirectory() {
Path pluginDir = Path.of(InstallationPathConfig.getPluginsPath());
try {
return Files.createDirectories(pluginDir);
} catch (IOException e) {
log.error("Unable to create plugin directory {}", pluginDir, e);
return pluginDir;
}
}
private static PluginDescriptor readDescriptorFromJar(Path jarPath) {
if (!Files.isRegularFile(jarPath)) {
log.warn("Plugin jar {} is not a regular file, skipping", jarPath);
return null;
}
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
JarEntry entry = jarFile.getJarEntry(METADATA_RESOURCE);
if (entry == null) {
log.info("Plugin jar {} does not include {}, skipping", jarPath, METADATA_RESOURCE);
return null;
}
PluginMetadata metadata;
try (InputStream inputStream = jarFile.getInputStream(entry)) {
metadata = OBJECT_MAPPER.readValue(inputStream, PluginMetadata.class);
}
String createdAt = resolveJarTimestamp(jarPath);
if (metadata.getId() == null || metadata.getId().isBlank()) {
log.warn("Plugin metadata in {} is missing required id, ignoring", jarPath);
return null;
}
String pluginId = metadata.getId();
log.info(
"Loaded metadata for plugin '{}': name='{}' version='{}'",
pluginId,
metadata.getName(),
metadata.getVersion());
return buildDescriptor(metadata, createdAt);
} catch (IOException e) {
log.warn("Failed to inspect plugin jar {}: {}", jarPath, e.getMessage());
return null;
}
}
private static PluginDescriptor buildDescriptor(PluginMetadata metadata, String jarCreatedAt) {
PluginMetadata.PluginFrontend frontend = metadata.getFrontend();
String id = metadata.getId();
String icon = metadata.getIcon();
String frontendPath =
(frontend != null
&& frontend.getEntrypoint() != null
&& !frontend.getEntrypoint().isBlank())
? ensureLeadingSlash(frontend.getEntrypoint())
: "/plugins/" + id + "/index.html";
return PluginDescriptor.builder()
.id(id)
.icon(defaultIfEmpty(icon, null))
.name(defaultIfEmpty(metadata.getName(), id))
.description(defaultIfEmpty(metadata.getDescription(), ""))
.version(defaultIfEmpty(metadata.getVersion(), "0.0.0"))
.author(metadata.getAuthor())
.frontendLabel(frontend != null ? frontend.getLabel() : null)
.frontendPath(frontendPath)
.iconPath(frontend != null ? frontend.getIconPath() : null)
.hasFrontend(frontend != null)
.backendEndpoints(
metadata.getBackendEndpoints() == null
? Collections.emptyList()
: metadata.getBackendEndpoints())
.metadata(
metadata.getMetadata() == null
? Collections.emptyMap()
: metadata.getMetadata())
.minHostVersion(defaultIfEmpty(metadata.getMinHostVersion(), null))
.jarCreatedAt(jarCreatedAt)
.build();
}
private static String defaultIfEmpty(String value, String fallback) {
return (value == null || value.isBlank()) ? fallback : value;
}
private static String ensureLeadingSlash(String path) {
if (path == null || path.isBlank()) {
return "/";
}
return path.startsWith("/") ? path : "/" + path;
}
private static boolean looksLikeJarFile(Path path) {
return path.getFileName().toString().toLowerCase().endsWith(JAR_EXTENSION);
}
private static boolean isReadableJarArchive(Path path) {
try {
String mimeType = Files.probeContentType(path);
if (mimeType != null && !JAR_MIME_TYPE.equals(mimeType)) {
log.debug("Ignoring non-jar mime type {} for {}", mimeType, path);
return false;
}
} catch (IOException e) {
log.debug("Unable to probe mime type for {}: {}", path, e.getMessage());
}
try (JarFile ignored = new JarFile(path.toFile())) {
return true;
} catch (IOException e) {
log.warn("Skipping invalid jar archive {}: {}", path, e.getMessage());
return false;
}
}
private static String resolveJarTimestamp(Path jarPath) throws IOException {
BasicFileAttributes attrs = Files.readAttributes(jarPath, BasicFileAttributes.class);
FileTime creationTime = attrs.creationTime();
FileTime lastModifiedTime = attrs.lastModifiedTime();
FileTime preferredTime =
creationTime == null || creationTime.toMillis() <= 0
? lastModifiedTime
: creationTime;
return preferredTime.toInstant().toString();
}
}
@@ -0,0 +1,36 @@
package stirling.software.common.plugins;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
/** Model for deserializing {@code META-INF/stirling-plugin.json} from a plugin jar. */
public class PluginMetadata {
private String id;
private String icon;
private String name;
private String description;
private String version;
private String author;
private String minHostVersion;
private PluginFrontend frontend;
private List<String> backendEndpoints;
private Map<String, String> metadata;
/** Frontend-specific metadata block declared inside plugin metadata JSON. */
@Getter
@Setter
@JsonIgnoreProperties(ignoreUnknown = true)
public static class PluginFrontend {
private String entrypoint;
private String label;
private String iconPath;
}
}
@@ -0,0 +1,28 @@
package stirling.software.common.plugins;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
/** Registers MVC resource handlers that expose static assets from plugin jars. */
public class PluginResourceConfig implements WebMvcConfigurer {
/** Adds {@code /plugins/**} static resource mappings for every discovered plugin jar. */
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
List<String> locations =
PluginLoader.pluginJarUrls().stream()
.map(url -> "jar:" + url + "!/META-INF/resources/plugins/")
.collect(Collectors.toList());
if (!locations.isEmpty()) {
registry.addResourceHandler("/plugins/**")
.addResourceLocations(locations.toArray(String[]::new))
.setCachePeriod(0);
}
}
}
@@ -9,6 +9,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -25,6 +26,9 @@ import lombok.extern.slf4j.Slf4j;
public class MobileScannerService {
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
private final Path tempDirectory;
@@ -121,7 +125,8 @@ public class MobileScannerService {
// Handle duplicate filenames
int counter = 1;
while (Files.exists(filePath)) {
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
String nameWithoutExt =
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
String ext =
safeFilename.contains(".")
? safeFilename.substring(safeFilename.lastIndexOf("."))
@@ -271,14 +276,14 @@ public class MobileScannerService {
throw new IllegalArgumentException("Session ID cannot be empty");
}
// Basic validation: alphanumeric and hyphens only
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
if (!SESSION_ID_VALIDATION_PATTERN.matcher(sessionId).matches()) {
throw new IllegalArgumentException("Invalid session ID format");
}
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
// Ensure we have a non-empty, safe filename
if (sanitized.isBlank()) {
sanitized = "upload-" + System.currentTimeMillis();
@@ -47,6 +47,7 @@ public class SvgSanitizer {
private static final Pattern DATA_SCRIPT_PATTERN =
Pattern.compile(
"^\\s*data\\s*:[^,]*(?:script|javascript|vbscript)", Pattern.CASE_INSENSITIVE);
private static final Pattern NULL_BYTE_PATTERN = Pattern.compile("\u0000");
private final SsrfProtectionService ssrfProtectionService;
private final ApplicationProperties applicationProperties;
@@ -210,7 +211,7 @@ public class SvgSanitizer {
String result = url.trim();
result = result.replaceAll("\u0000", "");
result = NULL_BYTE_PATTERN.matcher(result).replaceAll("");
for (int i = 0; i < 3; i++) {
try {
@@ -9,9 +9,11 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.context.WebServerInitializedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.env.Environment;
@@ -27,6 +29,7 @@ import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.ConfigInitializer;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.plugins.PluginLoader;
@Slf4j
@EnableScheduling
@@ -38,6 +41,10 @@ import stirling.software.common.model.ApplicationProperties;
})
public class SPDFApplication {
private static final Pattern PORT_SUFFIX_PATTERN = Pattern.compile(".+:\\d+$");
private static final Pattern URL_SCHEME_PATTERN =
Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/+$");
private static String serverPortStatic;
private static String baseUrlStatic;
private static String contextPathStatic;
@@ -54,11 +61,11 @@ public class SPDFApplication {
}
public static void main(String[] args) throws IOException, InterruptedException {
SpringApplication app = new SpringApplication(SPDFApplication.class);
SpringApplicationBuilder builder = new SpringApplicationBuilder(SPDFApplication.class);
Properties props = new Properties();
app.setAdditionalProfiles(getActiveProfile(args));
builder.profiles(getActiveProfile(args));
ConfigInitializer initializer = new ConfigInitializer();
try {
@@ -106,8 +113,13 @@ public class SPDFApplication {
if (!props.isEmpty()) {
finalProps.putAll(props);
}
ClassLoader pluginClassLoader =
PluginLoader.buildPluginClassLoader(SPDFApplication.class.getClassLoader());
if (pluginClassLoader != SPDFApplication.class.getClassLoader()) {
Thread.currentThread().setContextClassLoader(pluginClassLoader);
}
SpringApplication app = builder.build();
app.setDefaultProperties(finalProps);
app.run(args);
// Ensure directories are created
@@ -244,8 +256,8 @@ public class SPDFApplication {
String trimmedBase =
(backendUrl == null || backendUrl.isBlank())
? "http://localhost"
: backendUrl.trim().replaceAll("/+$", "");
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
: TRAILING_SLASH_PATTERN.matcher(backendUrl.trim()).replaceAll("");
boolean hasScheme = URL_SCHEME_PATTERN.matcher(trimmedBase).matches();
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
Integer parsedPort = parsePort(port);
@@ -298,7 +310,7 @@ public class SPDFApplication {
if (port == null) {
return trimmedBase;
}
if (trimmedBase.matches(".+:\\d+$")) {
if (PORT_SUFFIX_PATTERN.matcher(trimmedBase).matches()) {
return trimmedBase;
}
return trimmedBase + ":" + port;
@@ -8,6 +8,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -51,6 +52,7 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class MergeController {
private static final Pattern QUOTE_WRAP_PATTERN = Pattern.compile("^\"|\"$");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@@ -173,7 +175,7 @@ public class MergeController {
String[] parts = inside.split(",");
String[] result = new String[parts.length];
for (int i = 0; i < parts.length; i++) {
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
result[i] = QUOTE_WRAP_PATTERN.matcher(parts[i].trim()).replaceAll("");
}
return result;
}
@@ -1,7 +1,9 @@
package stirling.software.SPDF.controller.api.converters;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.UUID;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
@@ -36,6 +38,7 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class ConvertPdfJsonController {
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
private final PdfJsonConversionService pdfJsonConversionService;
@Autowired(required = false)
@@ -56,10 +59,13 @@ public class ConvertPdfJsonController {
}
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
logJsonResponse("pdf/text-editor", jsonBytes);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
: "document";
String docName = baseName + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
@@ -82,7 +88,9 @@ public class ConvertPdfJsonController {
String originalName = jsonFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
: "document";
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
@@ -108,14 +116,17 @@ public class ConvertPdfJsonController {
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
log.debug("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
byte[] jsonBytes =
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
logJsonResponse("pdf/text-editor/metadata", jsonBytes);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
: "document";
String docName = baseName + "_metadata.json";
@@ -152,7 +163,9 @@ public class ConvertPdfJsonController {
String baseName =
(filename != null && !filename.isBlank())
? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "")
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(filename))
.replaceFirst("")
: Optional.ofNullable(document.getMetadata())
.map(PdfJsonMetadata::getTitle)
.filter(title -> title != null && !title.isBlank())
@@ -175,11 +188,33 @@ public class ConvertPdfJsonController {
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
logJsonResponse("pdf/text-editor/page", jsonBytes);
String docName = "page_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
@GetMapping(value = "/pdf/text-editor/fonts/{jobId}/{pageNumber}")
@Operation(
summary = "Extract fonts used by a single cached page for text editor",
description =
"Retrieves the font payloads used by a single page from a previously cached PDF document."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user. Output:JSON")
public ResponseEntity<byte[]> extractPageFonts(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
// Validate job ownership
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractPageFonts(jobId, pageNumber);
logJsonResponse("pdf/text-editor/fonts/page", jsonBytes);
String docName = "page_fonts_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@AutoJobPostMapping(
value = "/pdf/text-editor/clear-cache/{jobId}",
consumes = MediaType.ALL_VALUE)
@Operation(
summary = "Clear cached PDF document for text editor",
description =
@@ -209,6 +244,188 @@ public class ConvertPdfJsonController {
return baseJobId;
}
private void logJsonResponse(String label, byte[] jsonBytes) {
if (jsonBytes == null) {
log.warn("Returning {} JSON response: null bytes", label);
return;
}
// Only perform expensive tail extraction if debug logging is enabled
if (log.isDebugEnabled()) {
int length = jsonBytes.length;
boolean endsWithJson =
length > 0 && (jsonBytes[length - 1] == '}' || jsonBytes[length - 1] == ']');
String tail = "";
if (length > 0) {
int start = Math.max(0, length - 64);
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
tail = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
}
log.debug(
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
label,
length,
endsWithJson,
tail);
}
if (isPdfJsonDebugDumpEnabled()) {
try {
String tmpDir = System.getProperty("java.io.tmpdir");
String customDir = System.getenv("SPDF_PDFJSON_DUMP_DIR");
java.nio.file.Path dumpDir =
customDir != null && !customDir.isBlank()
? java.nio.file.Path.of(customDir)
: java.nio.file.Path.of(tmpDir);
java.nio.file.Path dumpPath =
java.nio.file.Files.createTempFile(dumpDir, "pdfjson_", ".json");
java.nio.file.Files.write(dumpPath, jsonBytes);
log.debug("PDF JSON debug dump ({}): {}", label, dumpPath);
} catch (Exception ex) {
log.warn("Failed to write PDF JSON debug dump ({}): {}", label, ex.getMessage());
}
}
if (isPdfJsonRepeatScanEnabled()) {
logRepeatedJsonStrings(label, jsonBytes);
}
}
private boolean isPdfJsonDebugDumpEnabled() {
String env = System.getenv("SPDF_PDFJSON_DUMP");
if (env != null && env.equalsIgnoreCase("true")) {
return true;
}
return Boolean.getBoolean("spdf.pdfjson.dump");
}
private boolean isPdfJsonRepeatScanEnabled() {
String env = System.getenv("SPDF_PDFJSON_REPEAT_SCAN");
if (env != null && env.equalsIgnoreCase("true")) {
return true;
}
return Boolean.getBoolean("spdf.pdfjson.repeatScan");
}
private void logRepeatedJsonStrings(String label, byte[] jsonBytes) {
final int minLen = 12;
final int maxLen = 200;
final int maxUnique = 50000;
java.util.Map<String, Integer> counts = new java.util.HashMap<>();
boolean inString = false;
boolean escape = false;
boolean tooLong = false;
StringBuilder current = new StringBuilder(64);
boolean capped = false;
for (byte b : jsonBytes) {
char ch = (char) (b & 0xFF);
if (!inString) {
if (ch == '"') {
inString = true;
escape = false;
tooLong = false;
current.setLength(0);
}
continue;
}
if (escape) {
escape = false;
if (!tooLong && current.length() < maxLen) {
current.append(ch);
}
continue;
}
if (ch == '\\') {
escape = true;
continue;
}
if (ch == '"') {
inString = false;
if (!tooLong) {
int len = current.length();
if (len >= minLen && len <= maxLen) {
String value = current.toString();
if (!looksLikeBase64(value)) {
if (!capped || counts.containsKey(value)) {
counts.merge(value, 1, Integer::sum);
if (!capped && counts.size() >= maxUnique) {
capped = true;
}
}
}
}
}
continue;
}
if (!tooLong) {
if (current.length() < maxLen) {
current.append(ch);
} else {
tooLong = true;
}
}
}
java.util.List<java.util.Map.Entry<String, Integer>> top =
counts.entrySet().stream()
.filter(e -> e.getValue() > 1)
.sorted((a, b) -> Integer.compare(b.getValue(), a.getValue()))
.limit(20)
.toList();
if (!top.isEmpty()) {
String summary =
top.stream()
.map(
e ->
String.format(
"\"%s\"(len=%d,count=%d)",
truncateForLog(e.getKey()),
e.getKey().length(),
e.getValue()))
.collect(java.util.stream.Collectors.joining("; "));
log.debug(
"PDF JSON repeat scan ({}): top strings -> {}{}",
label,
summary,
capped ? " (capped)" : "");
} else {
log.debug(
"PDF JSON repeat scan ({}): no repeated strings found{}",
label,
capped ? " (capped)" : "");
}
}
private boolean looksLikeBase64(String value) {
if (value.length() < 32) {
return false;
}
int base64Chars = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if ((c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '+'
|| c == '/'
|| c == '=') {
base64Chars++;
}
}
return base64Chars >= value.length() * 0.9;
}
private String truncateForLog(String value) {
int max = 64;
if (value.length() <= max) {
return value.replaceAll("[\\r\\n\\t]+", " ");
}
return value.substring(0, max).replaceAll("[\\r\\n\\t]+", " ") + "...";
}
/**
* Validate that the current user has access to the given job.
*
@@ -1,5 +1,6 @@
package stirling.software.SPDF.controller.api.misc;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -8,20 +9,23 @@ import org.springframework.context.ApplicationContext;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.SPDF.config.InitialSetup;
import stirling.software.SPDF.service.plugin.PluginService;
import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.plugins.PluginDescriptorResponse;
import stirling.software.common.service.ServerCertificateServiceInterface;
import stirling.software.common.service.UserServiceInterface;
@@ -37,6 +41,7 @@ public class ConfigController {
private final UserServiceInterface userService;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
private final PluginService pluginService;
public ConfigController(
ApplicationProperties applicationProperties,
@@ -48,7 +53,8 @@ public class ConfigController {
UserServiceInterface userService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig,
PluginService pluginService) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
@@ -56,6 +62,7 @@ public class ConfigController {
this.userService = userService;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
this.pluginService = pluginService;
}
/**
@@ -153,6 +160,14 @@ public class ConfigController {
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
// User preference defaults
configData.put(
"defaultHideUnavailableTools",
applicationProperties.getUi().isDefaultHideUnavailableTools());
configData.put(
"defaultHideUnavailableConversions",
applicationProperties.getUi().isDefaultHideUnavailableConversions());
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
@@ -282,6 +297,10 @@ public class ConfigController {
// Version/machine info not available
}
// config directory path
configData.put("basePath", InstallationPathConfig.getPath());
configData.put("pluginsPath", InstallationPathConfig.getPluginsPath());
return ResponseEntity.ok(configData);
} catch (Exception e) {
@@ -291,6 +310,23 @@ public class ConfigController {
}
}
@GetMapping("/plugins")
public ResponseEntity<List<PluginDescriptorResponse>> getPlugins(HttpServletRequest request) {
String baseUrl =
ServletUriComponentsBuilder.fromRequestUri(request)
.replacePath(null)
.replaceQuery(null)
.build()
.toUriString();
List<PluginDescriptorResponse> mapped =
pluginService.getPlugins().stream()
.map(descriptor -> PluginDescriptorResponse.from(descriptor, baseUrl))
.toList();
return ResponseEntity.ok(mapped);
}
@GetMapping("/endpoint-enabled")
public ResponseEntity<Boolean> isEndpointEnabled(
@RequestParam(name = "endpoint") String endpoint) {
@@ -312,11 +348,13 @@ public class ConfigController {
@GetMapping("/endpoints-availability")
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
@RequestParam(name = "endpoints")
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
List<@NotBlank String> endpoints) {
@RequestParam(name = "endpoints", required = false) List<String> endpoints) {
Collection<String> toCheck =
(endpoints == null || endpoints.isEmpty())
? endpointConfiguration.getAllEndpoints()
: endpoints;
Map<String, EndpointAvailability> result = new HashMap<>();
for (String endpoint : endpoints) {
for (String endpoint : toCheck) {
String trimmedEndpoint = endpoint.trim();
result.put(
trimmedEndpoint,
@@ -57,6 +57,7 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class StampController {
private static final Pattern NEWLINE_PATTERN = Pattern.compile("\\r?\\n");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@@ -266,7 +267,7 @@ public class StampController {
.getEscapedNewlinePattern()
.matcher(processedStampText)
.replaceAll("\n");
String[] lines = normalizedText.split("\\r?\\n");
String[] lines = NEWLINE_PATTERN.split(normalizedText);
PDRectangle pageSize = page.getMediaBox();
@@ -21,6 +21,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.core.io.Resource;
@@ -44,6 +45,7 @@ import stirling.software.common.util.FileMonitor;
public class PipelineDirectoryProcessor {
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("\\\\?watchedFolders");
private final ObjectMapper objectMapper;
private final ApiDocService apiDocService;
@@ -433,10 +435,12 @@ public class PipelineDirectoryProcessor {
private Path determineOutputPath(PipelineConfig config, Path dir) {
String outputDir =
config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString())
.replaceAll("\\\\?watchedFolders", "");
WATCHED_FOLDERS_PATTERN
.matcher(
config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString()))
.replaceAll("");
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
}
@@ -0,0 +1,133 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.plugin.PluginService;
@Controller
@Slf4j
/**
* Serves static frontend assets embedded in plugin jars under {@code META-INF/resources/plugins}.
*/
public class PluginFrontendController {
private static final String PLUGIN_RESOURCE_ROOT = "META-INF/resources/plugins/";
private final PluginService pluginService;
/**
* @param pluginService service used to resolve plugin jar locations
*/
public PluginFrontendController(PluginService pluginService) {
this.pluginService = pluginService;
}
/**
* Redirects plugin root requests to the conventional {@code index.html} entrypoint.
*
* @param pluginId requested plugin identifier
* @return permanent redirect to plugin index page
*/
@GetMapping("/plugins/{pluginId}")
public ResponseEntity<Void> redirectToIndex(@PathVariable String pluginId) {
return ResponseEntity.status(301)
.location(URI.create("/plugins/" + pluginId + "/index.html"))
.build();
}
/**
* Streams an asset from the requested plugin jar, while validating path boundaries to avoid
* traversal outside the plugin resource root.
*
* @param request incoming servlet request used to extract suffix path
* @param pluginId requested plugin identifier
* @return asset content when found; suitable HTTP error otherwise
*/
@GetMapping("/plugins/{pluginId}/**")
public ResponseEntity<ByteArrayResource> servePluginAsset(
HttpServletRequest request, @PathVariable String pluginId) {
try {
String suffix = resolveSuffix(request, pluginId);
if (suffix == null) {
return ResponseEntity.notFound().build();
}
if (suffix.contains("..")) {
log.warn(
"[PluginFrontend] Blocked path traversal attempt for {}: {}",
pluginId,
suffix);
return ResponseEntity.badRequest().build();
}
Optional<Path> jarPath = pluginService.getPluginJarPath(pluginId);
if (jarPath.isEmpty() || !Files.isRegularFile(jarPath.get())) {
return ResponseEntity.notFound().build();
}
String resourcePath = PLUGIN_RESOURCE_ROOT + pluginId + suffix;
return serveResourceFromJar(jarPath.get(), resourcePath);
} catch (IOException e) {
log.error("[PluginFrontend] Failed to stream plugin asset for {}", pluginId, e);
return ResponseEntity.status(500).build();
}
}
private static String resolveSuffix(HttpServletRequest request, String pluginId) {
String contextPath = Optional.ofNullable(request.getContextPath()).orElse("");
String requestUri = Optional.ofNullable(request.getRequestURI()).orElse("");
String prefix = contextPath + "/plugins/" + pluginId;
if (!requestUri.startsWith(prefix)) {
return null;
}
String suffix = requestUri.substring(prefix.length());
if (suffix.isEmpty() || "/".equals(suffix)) {
return "/index.html";
}
return suffix;
}
private static ResponseEntity<ByteArrayResource> serveResourceFromJar(
Path jarPath, String resourcePath) throws IOException {
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
JarEntry entry = jarFile.getJarEntry(resourcePath);
if (entry == null || entry.isDirectory()) {
return ResponseEntity.notFound().build();
}
byte[] content;
try (InputStream stream = jarFile.getInputStream(entry)) {
content = stream.readAllBytes();
}
MediaType mediaType =
MediaTypeFactory.getMediaType(entry.getName())
.orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok()
.contentType(mediaType)
.cacheControl(CacheControl.noStore())
.body(new ByteArrayResource(content));
}
}
}
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
@@ -28,6 +29,8 @@ public class ReactRoutingController {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
private static final Pattern BASE_HREF_PATTERN =
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -94,9 +97,9 @@ public class ReactRoutingController {
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
html.replaceFirst(
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
"<base href=\\\"" + baseUrl + "\\\" />");
BASE_HREF_PATTERN
.matcher(html)
.replaceFirst("<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
File diff suppressed because it is too large Load Diff
@@ -37,23 +37,68 @@ import stirling.software.SPDF.model.json.PdfJsonStream;
@Component
public class PdfJsonCosMapper {
public enum SerializationContext {
DEFAULT,
ANNOTATION_RAW_DATA,
FORM_FIELD_RAW_DATA,
CONTENT_STREAMS_LIGHTWEIGHT,
RESOURCES_LIGHTWEIGHT;
public boolean omitStreamData() {
return this == CONTENT_STREAMS_LIGHTWEIGHT || this == RESOURCES_LIGHTWEIGHT;
}
}
public PdfJsonStream serializeStream(PDStream stream) throws IOException {
if (stream == null) {
return null;
}
return serializeStream(
stream.getCOSObject(), Collections.newSetFromMap(new IdentityHashMap<>()));
stream.getCOSObject(),
Collections.newSetFromMap(new IdentityHashMap<>()),
SerializationContext.DEFAULT);
}
public PdfJsonStream serializeStream(COSStream cosStream) throws IOException {
if (cosStream == null) {
return null;
}
return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>()));
return serializeStream(
cosStream,
Collections.newSetFromMap(new IdentityHashMap<>()),
SerializationContext.DEFAULT);
}
public PdfJsonStream serializeStream(COSStream cosStream, SerializationContext context)
throws IOException {
if (cosStream == null) {
return null;
}
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
return serializeStream(
cosStream, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
}
public PdfJsonStream serializeStream(PDStream stream, SerializationContext context)
throws IOException {
if (stream == null) {
return null;
}
return serializeStream(stream.getCOSObject(), context);
}
public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException {
return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>()));
return serializeCosValue(
base,
Collections.newSetFromMap(new IdentityHashMap<>()),
SerializationContext.DEFAULT);
}
public PdfJsonCosValue serializeCosValue(COSBase base, SerializationContext context)
throws IOException {
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
return serializeCosValue(
base, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
}
public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document)
@@ -165,8 +210,8 @@ public class PdfJsonCosMapper {
return cosStream;
}
private PdfJsonCosValue serializeCosValue(COSBase base, Set<COSBase> visited)
throws IOException {
private PdfJsonCosValue serializeCosValue(
COSBase base, Set<COSBase> visited, SerializationContext context) throws IOException {
if (base == null) {
return null;
}
@@ -220,21 +265,23 @@ public class PdfJsonCosMapper {
if (base instanceof COSArray array) {
List<PdfJsonCosValue> items = new ArrayList<>(array.size());
for (COSBase item : array) {
PdfJsonCosValue serialized = serializeCosValue(item, visited);
PdfJsonCosValue serialized = serializeCosValue(item, visited, context);
items.add(serialized);
}
builder.type(PdfJsonCosValue.Type.ARRAY).items(items);
return builder.build();
}
if (base instanceof COSStream stream) {
builder.type(PdfJsonCosValue.Type.STREAM).stream(serializeStream(stream, visited));
builder.type(PdfJsonCosValue.Type.STREAM).stream(
serializeStream(stream, visited, context));
return builder.build();
}
if (base instanceof COSDictionary dictionary) {
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
for (COSName key : dictionary.keySet()) {
PdfJsonCosValue serialized =
serializeCosValue(dictionary.getDictionaryObject(key), visited);
serializeCosValue(
dictionary.getDictionaryObject(key), visited, context);
entries.put(key.getName(), serialized);
}
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
@@ -248,16 +295,23 @@ public class PdfJsonCosMapper {
}
}
private PdfJsonStream serializeStream(COSStream cosStream, Set<COSBase> visited)
private PdfJsonStream serializeStream(
COSStream cosStream, Set<COSBase> visited, SerializationContext context)
throws IOException {
Map<String, PdfJsonCosValue> dictionary = new LinkedHashMap<>();
for (COSName key : cosStream.keySet()) {
COSBase value = cosStream.getDictionaryObject(key);
PdfJsonCosValue serialized = serializeCosValue(value, visited);
PdfJsonCosValue serialized = serializeCosValue(value, visited, context);
if (serialized != null) {
dictionary.put(key.getName(), serialized);
}
}
if (context != null && context.omitStreamData()) {
log.debug("Omitting stream rawData during {} serialization", context);
return PdfJsonStream.builder().dictionary(dictionary).rawData(null).build();
}
String rawData = null;
try (InputStream inputStream = cosStream.createRawInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
@@ -7,6 +7,7 @@ import java.io.InputStream;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDFont;
@@ -310,6 +311,11 @@ public class PdfJsonFallbackFontService {
"classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf",
"DejaVuSansMono-BoldOblique",
"ttf")));
private static final Pattern BOLD_FONT_WEIGHT_PATTERN =
Pattern.compile(".*[_-]?[6-9]00(wght)?.*");
private static final Pattern FONT_NAME_DELIMITER_PATTERN = Pattern.compile("[-_,+]");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern PATTERN = Pattern.compile("^[A-Z]{6}\\+");
private final ResourceLoader resourceLoader;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@@ -418,16 +424,18 @@ public class PdfJsonFallbackFontService {
// Normalize font name: remove subset prefix (e.g. "PXAAAC+"), convert to lowercase,
// remove spaces
String normalized =
originalFontName
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
.toLowerCase()
.replaceAll("\\s+", ""); // Remove spaces (e.g. "Times New Roman" ->
WHITESPACE_PATTERN
.matcher(
PATTERN.matcher(originalFontName)
.replaceAll("") // Remove subset prefix
.toLowerCase())
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
// "timesnewroman")
// Extract base name without weight/style suffixes
// Split on common delimiters: hyphen, underscore, comma, plus
// Handles: "Arimo_700wght" -> "arimo", "Arial-Bold" -> "arial", "Arial,Bold" -> "arial"
String baseName = normalized.split("[-_,+]")[0];
String baseName = FONT_NAME_DELIMITER_PATTERN.split(normalized)[0];
String aliasedFontId = FONT_NAME_ALIASES.get(baseName);
if (aliasedFontId != null) {
@@ -470,7 +478,7 @@ public class PdfJsonFallbackFontService {
// Check for numeric weight indicators (600-900 = bold)
// Handles: "Arimo_700wght", "Arial-700", "Font-w700"
if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) {
if (BOLD_FONT_WEIGHT_PATTERN.matcher(normalizedFontName).matches()) {
return true;
}
@@ -514,7 +522,7 @@ public class PdfJsonFallbackFontService {
// Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono)
boolean isSupported =
baseFontId.startsWith("fallback-liberation-")
|| baseFontId.equals("fallback-noto-sans")
|| "fallback-noto-sans".equals(baseFontId)
|| baseFontId.startsWith("fallback-dejavu-");
if (!isSupported) {
@@ -523,8 +531,8 @@ public class PdfJsonFallbackFontService {
// DejaVu Sans and Mono use "oblique" instead of "italic"
boolean useOblique =
baseFontId.equals("fallback-dejavu-sans")
|| baseFontId.equals("fallback-dejavu-mono");
"fallback-dejavu-sans".equals(baseFontId)
|| "fallback-dejavu-mono".equals(baseFontId);
if (isBold && isItalic) {
return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic");
@@ -9,6 +9,7 @@ import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
@@ -27,6 +28,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
public class SharedSignatureService {
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
@@ -105,7 +107,7 @@ public class SharedSignatureService {
throw new IllegalArgumentException("Invalid filename");
}
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
@@ -113,7 +115,7 @@ public class SharedSignatureService {
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
// Whitelist only safe image extensions
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
@@ -0,0 +1,59 @@
package stirling.software.SPDF.service.plugin;
import java.nio.file.Path;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.plugins.PluginDescriptor;
import stirling.software.common.plugins.PluginLoader;
@Service
@Slf4j
public class PluginService {
private final List<PluginDescriptor> plugins;
private final Map<String, Path> pluginJarPaths;
public PluginService() {
List<Path> jars = PluginLoader.listPluginJars();
Map<String, Path> jarMap = new LinkedHashMap<>();
List<PluginDescriptor> descriptors = new java.util.ArrayList<>();
for (Path jar : jars) {
PluginDescriptor descriptor = PluginLoader.loadDescriptor(jar);
if (descriptor == null) {
continue;
}
String pluginId = descriptor.getId();
if (jarMap.containsKey(pluginId)) {
log.warn(
"Duplicate plugin id '{}' detected in {}. Keeping first jar at {}",
pluginId,
jar,
jarMap.get(pluginId));
continue;
}
descriptors.add(descriptor);
jarMap.put(pluginId, jar);
}
this.plugins = Collections.unmodifiableList(descriptors);
this.pluginJarPaths = Collections.unmodifiableMap(jarMap);
}
public List<PluginDescriptor> getPlugins() {
return plugins;
}
public Optional<Path> getPluginJarPath(String pluginId) {
return Optional.ofNullable(pluginJarPaths.get(pluginId));
}
}
@@ -38,6 +38,7 @@ spring.devtools.livereload.enabled=true
spring.devtools.restart.exclude=stirling.software.proprietary.security/**
spring.web.resources.mime-mappings.webmanifest=application/manifest+json
spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
server.tomcat.max-http-header-size=32768
spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL
spring.datasource.driver-class-name=org.h2.Driver
@@ -64,7 +64,10 @@ security:
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
validation: # PDF signature validation settings
trust:
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
@@ -230,10 +233,12 @@ ui:
appNameNavbar: "" # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
endpoints:
toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice'])
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice', 'DeveloperTools', 'DeveloperDocs', 'Automation'])
metrics:
enabled: true # 'true' to enable Info APIs (`/api/*`) endpoints, 'false' to disable
@@ -18,6 +18,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.AfterEach;
@@ -46,6 +47,7 @@ import stirling.software.common.util.WebResponseUtils;
public class ConvertWebsiteToPdfTest {
private static final Pattern PDF_FILENAME_PATTERN = Pattern.compile("[A-Za-z0-9_]+\\.pdf");
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@@ -142,7 +144,7 @@ public class ConvertWebsiteToPdfTest {
assertTrue(out.endsWith(".pdf"));
// Only AZ, az, 09, underscore and dot allowed
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
// no truncation here (source not that long)
assertTrue(out.length() <= 54);
}
@@ -159,7 +161,7 @@ public class ConvertWebsiteToPdfTest {
String out = (String) m.invoke(sut, longUrl);
assertTrue(out.endsWith(".pdf"));
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
// safeName limited to 50 -> total max 54 including '.pdf'
assertTrue(out.length() <= 54, "Filename should be truncated to 50 + '.pdf'");
}
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
@@ -26,6 +27,18 @@ import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class StampControllerTest {
private static final Pattern UUID_HEX_PATTERN = Pattern.compile("[0-9a-f]{8}");
private static final Pattern DATE_LITERAL_REGEX =
Pattern.compile("@date is \\d{4}-\\d{2}-\\d{2}");
private static final Pattern DATE_TIME_MIN_PATTERN =
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}");
private static final Pattern DATE_SLASH_PATTERN = Pattern.compile("\\d{2}/\\d{2}/\\d{4}");
private static final Pattern DAY_LABEL_PATTERN = Pattern.compile("Day: \\d{2}");
private static final Pattern MONTH_LABEL_PATTERN = Pattern.compile("Month: \\d{2}");
private static final Pattern DATE_TIME_FULL_PATTERN =
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}");
private static final Pattern TIME_LABEL_PATTERN = Pattern.compile("Time: \\d{2}:\\d{2}:\\d{2}");
private static final Pattern DATE_LABEL_PATTERN = Pattern.compile("Date: \\d{4}-\\d{2}-\\d{2}");
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@@ -173,7 +186,7 @@ class StampControllerTest {
void testDateReplacement() throws Exception {
String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
assertTrue(
result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
DATE_LABEL_PATTERN.matcher(result).matches(),
"Date should match YYYY-MM-DD format");
}
@@ -182,7 +195,7 @@ class StampControllerTest {
void testTimeReplacement() throws Exception {
String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
assertTrue(
result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
TIME_LABEL_PATTERN.matcher(result).matches(),
"Time should match HH:mm:ss format");
}
@@ -192,7 +205,7 @@ class StampControllerTest {
String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
// DateTime format: YYYY-MM-DD HH:mm:ss
assertTrue(
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
DATE_TIME_FULL_PATTERN.matcher(result).matches(),
"DateTime should match YYYY-MM-DD HH:mm:ss format");
}
@@ -208,14 +221,15 @@ class StampControllerTest {
@DisplayName("Should replace @month with zero-padded month")
void testMonthReplacement() throws Exception {
String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
assertTrue(
MONTH_LABEL_PATTERN.matcher(result).matches(), "Month should be zero-padded");
}
@Test
@DisplayName("Should replace @day with zero-padded day")
void testDayReplacement() throws Exception {
String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
assertTrue(DAY_LABEL_PATTERN.matcher(result).matches(), "Day should be zero-padded");
}
}
@@ -228,7 +242,7 @@ class StampControllerTest {
void testCustomDateFormatSlash() throws Exception {
String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
assertTrue(
result.matches("\\d{2}/\\d{2}/\\d{4}"),
DATE_SLASH_PATTERN.matcher(result).matches(),
"Should match dd/MM/yyyy format: " + result);
}
@@ -238,7 +252,7 @@ class StampControllerTest {
String result =
invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
assertTrue(
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
DATE_TIME_MIN_PATTERN.matcher(result).matches(),
"Should match yyyy-MM-dd HH:mm format: " + result);
}
@@ -345,7 +359,7 @@ class StampControllerTest {
// @@date should become @date, and @date should be replaced with actual date
assertTrue(result.startsWith("@date is "), "Should start with literal @date");
assertTrue(
result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
DATE_LITERAL_REGEX.matcher(result).matches(),
"Should have date after: " + result);
}
@@ -463,7 +477,9 @@ class StampControllerTest {
@DisplayName("UUID should contain only hex characters")
void testUuidFormat() throws Exception {
String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
assertTrue(
UUID_HEX_PATTERN.matcher(result).matches(),
"UUID should be 8 hex characters: " + result);
}
}
@@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -104,12 +105,40 @@ public class FormFillController {
requirePdf(file);
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
FormUtils.repairMissingWidgetPageReferences(document);
FormUtils.FormFieldExtraction extraction =
FormUtils.extractFieldsWithTemplate(document);
return ResponseEntity.ok(extraction);
}
}
@PostMapping(value = "/fields-with-coordinates", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Inspect PDF form fields with widget coordinates",
description =
"Returns metadata describing each field in the provided PDF form, "
+ "including precise widget coordinates for interactive rendering")
public ResponseEntity<List<FormFieldWithCoordinates>> listFieldsWithCoordinates(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file)
throws IOException {
requirePdf(file);
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
FormUtils.repairMissingWidgetPageReferences(document);
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(document);
return ResponseEntity.ok(fields);
}
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
@@ -215,6 +244,7 @@ public class FormFillController {
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
FormUtils.repairMissingWidgetPageReferences(document);
processor.accept(document);
return saveDocument(document, baseName);
}
@@ -2,7 +2,7 @@ package stirling.software.proprietary.security.configuration;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
@@ -11,15 +11,22 @@ import org.springframework.context.annotation.Configuration;
import com.github.benmanes.caffeine.cache.Caffeine;
import stirling.software.common.model.ApplicationProperties;
@Configuration
@EnableCaching
public class CacheConfig {
@Value("${security.jwt.keyRetentionDays}")
private int keyRetentionDays;
private final ApplicationProperties applicationProperties;
@Autowired
public CacheConfig(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@Bean
public CacheManager cacheManager() {
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(
Caffeine.newBuilder()
@@ -361,7 +361,8 @@ public class SecurityConfiguration {
securityProperties.getOauth2(),
userService,
jwtService,
licenseSettingsService))
licenseSettingsService,
applicationProperties))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
@@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
@@ -34,12 +35,15 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
/** REST API Controller for authentication operations. */
@RestController
@@ -55,7 +59,9 @@ public class AuthController {
private final LoginAttemptService loginAttemptService;
private final MfaService mfaService;
private final TotpService totpService;
private final RefreshRateLimitService refreshRateLimitService;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties applicationProperties;
/**
* Login endpoint - replaces Supabase signInWithPassword
@@ -171,16 +177,52 @@ public class AuthController {
claims.put("authType", AuthenticationType.WEB.toString());
claims.put("role", user.getRolesAsString());
String token = jwtService.generateToken(user.getUsername(), claims);
// Detect desktop client and issue longer-lived tokens for better UX
// Desktop apps run on personal devices with OS-level encryption (secure storage)
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(httpRequest);
String token;
int keyRetentionDays = securityProperties.getJwt().getKeyRetentionDays();
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
token = jwtService.generateToken(user.getUsername(), claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP token for user '{}': expiry={}min ({}d), keyRetention={}d",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440,
keyRetentionDays);
} else {
// Web: Use configured web expiry (default 24 hours)
token = jwtService.generateToken(user.getUsername(), claims);
int webExpiryMinutes =
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
log.info(
"Issued WEB token for user '{}': expiry={}min ({}d), keyRetention={}d",
username,
webExpiryMinutes,
webExpiryMinutes / 1440,
keyRetentionDays);
}
// Record successful login
loginAttemptService.loginSucceeded(username);
log.info("Login successful for user: {} from IP: {}", username, ip);
log.info(
"Login successful for user: {} from IP: {} (desktop: {})",
username,
ip,
isDesktopClient);
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session", Map.of("access_token", token, "expires_in", 3600)));
"session",
Map.of(
"access_token",
token,
"expires_in",
getTokenExpirySeconds(isDesktopClient))));
} catch (UsernameNotFoundException e) {
String username = request.getUsername();
@@ -272,25 +314,92 @@ public class AuthController {
.body(Map.of("error", "No token found"));
}
jwtService.validateToken(token);
String username = jwtService.extractUsername(token);
// Generate token hash for rate limiting (avoid storing actual tokens)
String tokenHash = generateTokenHash(token);
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
if (!isRefreshWithinGrace(claims)) {
log.warn("Token refresh rejected: token expired beyond configured grace window");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
}
// Only apply rate limiting if token is actually expired (not for valid tokens)
// This prevents false-positive 429 errors with multiple tabs, retries, etc.
long expMillis = extractEpochMillis(claims.get("exp"));
boolean isExpired = expMillis > 0 && expMillis < System.currentTimeMillis();
if (isExpired
&& !refreshRateLimitService.isRefreshAllowed(
tokenHash, getRefreshGraceMillis())) {
log.warn(
"Token refresh rejected: rate limit exceeded (max {} attempts allowed)",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(
Map.of(
"error",
"Too many refresh attempts",
"max_attempts",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE));
}
Object usernameClaim = claims.get("sub");
String username = usernameClaim != null ? usernameClaim.toString() : null;
if (username == null || username.isBlank()) {
log.warn("Token refresh rejected: missing subject claim");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
}
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
User user = (User) userDetails;
Map<String, Object> claims = new HashMap<>();
claims.put("authType", user.getAuthenticationType());
claims.put("role", user.getRolesAsString());
Map<String, Object> newClaims = new HashMap<>();
newClaims.put("authType", user.getAuthenticationType());
newClaims.put("role", user.getRolesAsString());
String newToken = jwtService.generateToken(username, claims);
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String newToken;
if (isDesktopClient) {
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
newToken = jwtService.generateToken(username, newClaims, desktopExpiryMinutes);
log.info(
"Refreshed DESKTOP token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
newToken = jwtService.generateToken(username, newClaims);
int webExpiryMinutes =
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
log.info(
"Refreshed WEB token for user '{}': expiry={}min ({}d)",
username,
webExpiryMinutes,
webExpiryMinutes / 1440);
}
// Don't clear rate limit tracking - let it expire naturally after grace period
// This prevents reusing the same expired token indefinitely
log.debug("Token refreshed for user: {}", username);
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session", Map.of("access_token", newToken, "expires_in", 3600)));
"session",
Map.of(
"access_token",
newToken,
"expires_in",
getTokenExpirySeconds(isDesktopClient))));
} catch (AuthenticationFailureException e) {
log.warn("Token refresh failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
} catch (Exception e) {
log.error("Token refresh error", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
@@ -532,6 +641,95 @@ public class AuthController {
return userMap;
}
private long getTokenExpirySeconds() {
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
int expiryMinutes =
configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
return expiryMinutes * JwtConstants.SECONDS_PER_MINUTE;
}
private long getTokenExpirySeconds(boolean isDesktop) {
if (isDesktop) {
// Desktop: use configured desktop token expiry
return DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties)
* JwtConstants.SECONDS_PER_MINUTE;
}
// Web: use configured web value
return getTokenExpirySeconds();
}
private boolean isRefreshWithinGrace(Map<String, Object> claims) {
long expMillis = extractEpochMillis(claims.get("exp"));
if (expMillis <= 0) {
return false;
}
long now = System.currentTimeMillis();
if (expMillis >= now) {
return true;
}
long expiredForMillis = now - expMillis;
return expiredForMillis <= getRefreshGraceMillis();
}
private long getRefreshGraceMillis() {
int configuredMinutes = securityProperties.getJwt().getRefreshGraceMinutes();
int graceMinutes =
configuredMinutes >= 0
? configuredMinutes
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
return graceMinutes * JwtConstants.MILLIS_PER_MINUTE;
}
private long extractEpochMillis(Object claimValue) {
if (claimValue == null) {
return -1L;
}
if (claimValue instanceof java.util.Date date) {
return date.getTime();
}
if (claimValue instanceof Number number) {
long epochSeconds = number.longValue();
return epochSeconds * 1000L;
}
return -1L;
}
/**
* Generate a hash of the token for rate limiting purposes.
*
* <p>Uses SHA-256 to avoid storing actual token values in memory.
*
* @param token the JWT token
* @return hex-encoded SHA-256 hash of the token
*/
private String generateTokenHash(String token) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] hashBytes =
digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
} catch (java.security.NoSuchAlgorithmException e) {
// Fallback to hashCode if SHA-256 is not available (should never happen)
log.warn("SHA-256 not available, using hashCode for token tracking", e);
return String.valueOf(token.hashCode());
}
}
private ResponseEntity<?> ensureWebAuth(User user) {
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
@@ -9,6 +9,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -36,6 +37,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
@RequiredArgsConstructor
public class UIDataTessdataController {
private static final Pattern INVALID_LANG_CHARS_PATTERN = Pattern.compile("[^A-Za-z0-9_+\\-]");
private final RuntimePathConfig runtimePathConfig;
private static volatile List<String> cachedRemoteTessdata = null;
private static volatile long cachedRemoteTessdataExpiry = 0L;
@@ -88,7 +90,7 @@ public class UIDataTessdataController {
failed.add(language);
continue;
}
String safeLang = language.replaceAll("[^A-Za-z0-9_+\\-]", "");
String safeLang = INVALID_LANG_CHARS_PATTERN.matcher(language).replaceAll("");
if (!safeLang.equals(language)) {
failed.add(language);
continue;
@@ -36,6 +36,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
@Slf4j
@RequiredArgsConstructor
@@ -48,6 +49,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final ApplicationProperties applicationProperties;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -150,9 +152,27 @@ public class CustomOAuth2AuthenticationSuccessHandler
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
Map<String, Object> claims = Map.of("authType", AuthenticationType.OAUTH2);
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String jwt;
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(
applicationProperties);
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP OAuth2 token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
// Web: Use default expiry
jwt = jwtService.generateToken(authentication, claims);
log.debug("Issued WEB OAuth2 token for user '{}'", username);
}
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -37,6 +37,7 @@ import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
@AllArgsConstructor
@Slf4j
@@ -191,10 +192,27 @@ public class CustomSaml2AuthenticationSuccessHandler
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication,
Map.of("authType", AuthenticationType.SAML2));
Map<String, Object> claims = Map.of("authType", AuthenticationType.SAML2);
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String jwt;
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(
applicationProperties);
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
// Web: Use default expiry
jwt = jwtService.generateToken(authentication, claims);
log.debug("Issued WEB SAML token for user '{}'", username);
}
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
@@ -19,6 +20,9 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
@@ -30,6 +34,8 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
@@ -38,18 +44,20 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
@Service
public class JwtService implements JwtServiceInterface {
private static final String ISSUER = "https://stirling.com";
private static final long EXPIRATION = 43200000;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
private final ApplicationProperties.Security securityProperties;
@Autowired
public JwtService(
@Qualifier("v2Enabled") boolean v2Enabled,
KeyPersistenceServiceInterface keyPersistenceService) {
KeyPersistenceServiceInterface keyPersistenceService,
ApplicationProperties applicationProperties) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
this.securityProperties = applicationProperties.getSecurity();
}
@Override
@@ -84,9 +92,10 @@ public class JwtService implements JwtServiceInterface {
Jwts.builder()
.claims(claims)
.subject(username)
.issuer(ISSUER)
.issuer(JwtConstants.ISSUER)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
.expiration(
new Date(System.currentTimeMillis() + getExpirationMillis()))
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
String keyId = activeKey.getKeyId();
@@ -100,6 +109,40 @@ public class JwtService implements JwtServiceInterface {
}
}
@Override
public String generateToken(String username, Map<String, Object> claims, int expiryMinutes) {
try {
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
Optional<KeyPair> keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId());
if (keyPairOpt.isEmpty()) {
throw new RuntimeException("Unable to retrieve key pair for active key");
}
KeyPair keyPair = keyPairOpt.get();
long customExpirationMillis = expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
var builder =
Jwts.builder()
.claims(claims)
.subject(username)
.issuer(JwtConstants.ISSUER)
.issuedAt(new Date())
.expiration(
new Date(System.currentTimeMillis() + customExpirationMillis))
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
String keyId = activeKey.getKeyId();
if (keyId != null) {
builder.header().keyId(keyId);
}
return builder.compact();
} catch (Exception e) {
throw new RuntimeException("Failed to generate token with custom expiry", e);
}
}
@Override
public void validateToken(String token) throws AuthenticationFailureException {
extractAllClaims(token);
@@ -114,12 +157,23 @@ public class JwtService implements JwtServiceInterface {
return extractClaim(token, Claims::getSubject);
}
@Override
public String extractUsernameAllowExpired(String token) {
return extractClaim(token, Claims::getSubject, true);
}
@Override
public Map<String, Object> extractClaims(String token) {
Claims claims = extractAllClaims(token);
return new HashMap<>(claims);
}
@Override
public Map<String, Object> extractClaimsAllowExpired(String token) {
Claims claims = extractAllClaims(token, true);
return new HashMap<>(claims);
}
@Override
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
@@ -130,11 +184,21 @@ public class JwtService implements JwtServiceInterface {
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
final Claims claims = extractAllClaims(token, false);
return claimsResolver.apply(claims);
}
private <T> T extractClaim(
String token, Function<Claims, T> claimsResolver, boolean allowExpired) {
final Claims claims = extractAllClaims(token, allowExpired);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return extractAllClaims(token, false);
}
private Claims extractAllClaims(String token, boolean allowExpired) {
try {
String keyId = extractKeyId(token);
KeyPair keyPair;
@@ -176,11 +240,12 @@ public class JwtService implements JwtServiceInterface {
} else {
log.debug("No key ID in token header, trying all available keys");
// Try all available keys when no keyId is present
return tryAllKeys(token);
return tryAllKeys(token, allowExpired);
}
return Jwts.parser()
.verifyWith(keyPair.getPublic())
.clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
@@ -191,7 +256,13 @@ public class JwtService implements JwtServiceInterface {
log.warn("Invalid token: {}", e.getMessage());
throw new AuthenticationFailureException("Invalid token", e);
} catch (ExpiredJwtException e) {
log.warn("The token has expired: {}", e.getMessage());
if (allowExpired) {
log.debug(
"Extracting claims from expired token (allowed for refresh grace period): {}",
e.getMessage());
return e.getClaims();
}
log.warn("Token validation failed - token has expired: {}", e.getMessage());
throw new AuthenticationFailureException("The token has expired", e);
} catch (UnsupportedJwtException e) {
log.warn("The token is unsupported: {}", e.getMessage());
@@ -202,7 +273,8 @@ public class JwtService implements JwtServiceInterface {
}
}
private Claims tryAllKeys(String token) throws AuthenticationFailureException {
private Claims tryAllKeys(String token, boolean allowExpired)
throws AuthenticationFailureException {
// First try the active key
try {
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
@@ -210,9 +282,18 @@ public class JwtService implements JwtServiceInterface {
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
return Jwts.parser()
.verifyWith(publicKey)
.clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
if (allowExpired) {
log.debug(
"Extracting claims from expired token (allowed for refresh grace period)");
return e.getClaims();
}
log.warn("Token validation failed - token has expired");
throw new AuthenticationFailureException("The token has expired", e);
} catch (SignatureException
| NoSuchAlgorithmException
| InvalidKeySpecException activeKeyException) {
@@ -230,9 +311,15 @@ public class JwtService implements JwtServiceInterface {
verificationKey.getVerifyingKey());
return Jwts.parser()
.verifyWith(publicKey)
.clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
if (allowExpired) {
return e.getClaims();
}
throw new AuthenticationFailureException("The token has expired", e);
} catch (SignatureException
| NoSuchAlgorithmException
| InvalidKeySpecException e) {
@@ -266,24 +353,51 @@ public class JwtService implements JwtServiceInterface {
return v2Enabled;
}
/**
* Extract key ID from JWT header without validating the token.
*
* <p>Parses the Base64-encoded JWT header to retrieve the "kid" (key ID) claim. Returns null if
* the header cannot be parsed or does not contain a key ID.
*
* @param token the JWT token
* @return the key ID, or null if not found or parsing fails
*/
private String extractKeyId(String token) {
try {
PublicKey signingKey =
keyPersistenceService.decodePublicKey(
keyPersistenceService.getActiveKey().getVerifyingKey());
String[] tokenParts = token.split("\\.");
if (tokenParts.length < 2) {
log.debug(
"Token does not have enough parts (expected at least 2, got {})",
tokenParts.length);
return null;
}
String keyId =
(String)
Jwts.parser()
.verifyWith(signingKey)
.build()
.parse(token)
.getHeader()
.get("kid");
return keyId;
} catch (Exception e) {
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]);
Map<String, Object> header =
OBJECT_MAPPER.readValue(
headerBytes, new TypeReference<Map<String, Object>>() {});
Object keyId = header.get("kid");
return keyId instanceof String ? (String) keyId : null;
} catch (IllegalArgumentException e) {
log.debug("Failed to decode Base64 JWT header: {}", e.getMessage());
return null;
} catch (java.io.IOException e) {
log.debug("Failed to parse JWT header as JSON: {}", e.getMessage());
return null;
}
}
private long getExpirationMillis() {
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
int expiryMinutes =
configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
return expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
}
private long getAllowedClockSkewSeconds() {
int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds();
return configuredSeconds >= 0 ? configuredSeconds : JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
}
}
@@ -25,6 +25,16 @@ public interface JwtServiceInterface {
*/
String generateToken(String username, Map<String, Object> claims);
/**
* Generate a JWT token for a specific username with custom expiry
*
* @param username the username for which to generate the token
* @param claims additional claims to include in the token
* @param expiryMinutes custom token lifetime in minutes
* @return JWT token as a string
*/
String generateToken(String username, Map<String, Object> claims, int expiryMinutes);
/**
* Validate a JWT token
*
@@ -41,6 +51,15 @@ public interface JwtServiceInterface {
*/
String extractUsername(String token);
/**
* Extract username from JWT token while allowing expired tokens. Signature and token structure
* must still be valid.
*
* @param token the JWT token
* @return username extracted from token
*/
String extractUsernameAllowExpired(String token);
/**
* Extract all claims from JWT token
*
@@ -49,6 +68,15 @@ public interface JwtServiceInterface {
*/
Map<String, Object> extractClaims(String token);
/**
* Extract all claims from JWT token while allowing expired tokens. Signature and token
* structure must still be valid.
*
* @param token the JWT token
* @return map of claims
*/
Map<String, Object> extractClaimsAllowExpired(String token);
/**
* Check if token is expired
*
@@ -10,8 +10,10 @@ import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -41,6 +43,7 @@ import stirling.software.proprietary.security.model.JwtVerificationKey;
public class KeyPersistenceService implements KeyPersistenceServiceInterface {
public static final String KEY_SUFFIX = ".key";
public static final String PUB_KEY_SUFFIX = ".pub";
private final ApplicationProperties.Security.Jwt jwtProperties;
private final CacheManager cacheManager;
@@ -59,19 +62,119 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
@PostConstruct
public void initializeKeystore() {
if (!isKeystoreEnabled()) {
log.info("JWT keystore is disabled - keys will be generated in memory");
return;
}
try {
ensurePrivateKeyDirectoryExists();
loadKeyPair();
loadExistingKeysFromDisk();
} catch (Exception e) {
log.error("Failed to initialize keystore, using in-memory generation", e);
}
}
private void loadKeyPair() {
if (activeKey == null) {
/**
* Load all existing JWT keys from disk into memory on startup.
*
* <p>This ensures tokens signed with previous keys remain valid after server restart. If no
* keys exist on disk, generates a new keypair.
*/
private void loadExistingKeysFromDisk() {
try {
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
if (!Files.exists(keyDirectory)) {
log.info("No existing keys found, generating new keypair");
generateAndStoreKeypair();
return;
}
List<Path> keyFiles;
try (var stream = Files.list(keyDirectory)) {
keyFiles =
stream.filter(path -> path.toString().endsWith(KEY_SUFFIX))
.sorted(
(a, b) ->
b.getFileName().compareTo(a.getFileName())) // Most
// recent
// first
.collect(Collectors.toList());
}
if (keyFiles.isEmpty()) {
log.info("No existing keys found in directory, generating new keypair");
generateAndStoreKeypair();
return;
}
log.info("Loading {} existing JWT keys from disk", keyFiles.size());
int loadedCount = 0;
for (Path keyFile : keyFiles) {
try {
String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, "");
// Load private key first
PrivateKey privateKey = loadPrivateKey(keyId);
// Try to load public key, or generate it from private key if missing
// (migration)
String encodedPublicKey;
try {
encodedPublicKey = loadPublicKey(keyId);
} catch (IOException e) {
// Public key file doesn't exist - generate it from private key (migration)
log.info("Migrating legacy key: generating public key file for {}", keyId);
KeyPair keyPair = reconstructKeyPair(privateKey);
// Save the public key file
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
encodedPublicKey = encodePublicKey(keyPair.getPublic());
Files.writeString(publicKeyFile, encodedPublicKey);
publicKeyFile.toFile().setReadable(true, true);
publicKeyFile.toFile().setWritable(true, true);
publicKeyFile.toFile().setExecutable(false, false);
log.info("Successfully migrated key: {}", keyId);
}
// Create verification key and add to cache
JwtVerificationKey verifyingKey =
new JwtVerificationKey(keyId, encodedPublicKey);
verifyingKeyCache.put(keyId, verifyingKey);
loadedCount++;
// Set the most recent key as active (first in sorted list)
if (activeKey == null) {
activeKey = verifyingKey;
log.info("Set active JWT signing key: {}", keyId);
} else {
log.debug(
"Loaded historical JWT key: {} (created: {})",
keyId,
verifyingKey.getCreatedAt());
}
} catch (Exception e) {
log.warn(
"Failed to load key: {}, skipping. Error: {}",
keyFile.getFileName(),
e.getMessage());
}
}
if (loadedCount == 0) {
log.warn("No valid keys could be loaded from disk, generating new keypair");
generateAndStoreKeypair();
} else {
log.info(
"Successfully loaded {} JWT keys, active key: {}",
loadedCount,
activeKey.getKeyId());
}
} catch (IOException e) {
log.error("Failed to load keys from disk, generating new keypair", e);
generateAndStoreKeypair();
}
}
@@ -84,10 +187,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
KeyPair keyPair = generateRSAKeypair();
String keyId = generateKeyId();
storePrivateKey(keyId, keyPair.getPrivate());
storeKeyPair(keyId, keyPair);
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
verifyingKeyCache.put(keyId, verifyingKey);
activeKey = verifyingKey;
log.info("Generated and stored new JWT keypair: {}", keyId);
} catch (IOException e) {
log.error("Failed to generate and store keypair", e);
}
@@ -200,16 +304,43 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
}
}
private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException {
Path keyFile =
Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX);
String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded());
Files.writeString(keyFile, encodedKey);
/**
* Store both private and public keys to disk.
*
* <p>Private key stored as: keyId.key
*
* <p>Public key stored as: keyId.pub
*/
private void storeKeyPair(String keyId, KeyPair keyPair) throws IOException {
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
// Set read/write to only the owner
keyFile.toFile().setReadable(true, true);
keyFile.toFile().setWritable(true, true);
keyFile.toFile().setExecutable(false, false);
// Store private key
Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX);
String encodedPrivateKey =
Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
Files.writeString(privateKeyFile, encodedPrivateKey);
// Set read/write to only the owner (security)
privateKeyFile.toFile().setReadable(true, true);
privateKeyFile.toFile().setWritable(true, true);
privateKeyFile.toFile().setExecutable(false, false);
// Store public key
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
String encodedPublicKey =
Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
Files.writeString(publicKeyFile, encodedPublicKey);
// Public key can be more permissive but still restrict to owner
publicKeyFile.toFile().setReadable(true, true);
publicKeyFile.toFile().setWritable(true, true);
publicKeyFile.toFile().setExecutable(false, false);
log.debug(
"Stored keypair to disk: {} (private: {}, public: {})",
keyId,
privateKeyFile.getFileName(),
publicKeyFile.getFileName());
}
private PrivateKey loadPrivateKey(String keyId)
@@ -229,6 +360,53 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
return keyFactory.generatePrivate(keySpec);
}
/**
* Load public key from disk.
*
* @param keyId the key identifier
* @return Base64-encoded public key string
* @throws IOException if the public key file is not found
*/
private String loadPublicKey(String keyId) throws IOException {
Path publicKeyFile =
Paths.get(InstallationPathConfig.getPrivateKeyPath())
.resolve(keyId + PUB_KEY_SUFFIX);
if (!Files.exists(publicKeyFile)) {
throw new IOException("Public key not found: " + publicKeyFile);
}
return Files.readString(publicKeyFile).trim();
}
/**
* Reconstruct a KeyPair from a PrivateKey.
*
* <p>For RSA keys, derives the public key from the private key.
*
* @param privateKey the RSA private key
* @return reconstructed KeyPair
* @throws NoSuchAlgorithmException if RSA algorithm is not available
* @throws InvalidKeySpecException if the key specification is invalid
*/
private KeyPair reconstructKeyPair(PrivateKey privateKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
// For RSA, we can derive the public key from the private key
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
// Get the private key spec
RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privateKey;
// Create public key spec from private key parameters
RSAPublicKeySpec publicKeySpec =
new RSAPublicKeySpec(rsaPrivateKey.getModulus(), rsaPrivateKey.getPublicExponent());
// Generate public key
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
return new KeyPair(publicKey, privateKey);
}
private String encodePublicKey(PublicKey publicKey) {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
@@ -0,0 +1,124 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
/**
* Service to rate limit token refresh attempts within the grace period.
*
* <p>Prevents abuse of expired tokens by tracking and limiting refresh attempts per token. Tokens
* are identified by a hash to avoid storing actual token values.
*/
@Service
@Slf4j
public class RefreshRateLimitService {
private final ApplicationProperties.Security.Jwt jwtProperties;
@Autowired
public RefreshRateLimitService(ApplicationProperties applicationProperties) {
this.jwtProperties = applicationProperties.getSecurity().getJwt();
}
private static class RefreshAttempt {
private final AtomicInteger count = new AtomicInteger(0);
private final Instant firstAttempt = Instant.now();
int incrementAndGet() {
return count.incrementAndGet();
}
Instant getFirstAttempt() {
return firstAttempt;
}
int getCount() {
return count.get();
}
}
private final Map<String, RefreshAttempt> attempts = new ConcurrentHashMap<>();
/**
* Check if a refresh attempt is allowed for the given token.
*
* @param tokenHash hash of the token attempting refresh
* @param graceWindowMillis the configured grace window in milliseconds
* @return true if refresh is allowed, false if rate limit exceeded
*/
public boolean isRefreshAllowed(String tokenHash, long graceWindowMillis) {
RefreshAttempt attempt = attempts.computeIfAbsent(tokenHash, k -> new RefreshAttempt());
int attemptCount = attempt.incrementAndGet();
if (attemptCount > JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE) {
log.warn(
"Refresh rate limit exceeded for token (attempt {}). Token hash: {}",
attemptCount,
tokenHash.substring(0, Math.min(8, tokenHash.length())));
return false;
}
// Clean up if outside grace window
Instant cutoff = Instant.now().minusMillis(graceWindowMillis);
if (attempt.getFirstAttempt().isBefore(cutoff)) {
attempts.remove(tokenHash);
}
return true;
}
/**
* Remove tracking for a token after successful refresh.
*
* @param tokenHash hash of the refreshed token
*/
public void clearRefreshAttempts(String tokenHash) {
attempts.remove(tokenHash);
}
/** Clean up expired tracking entries every 5 minutes. */
@Scheduled(fixedRate = 300000)
public void cleanupExpiredEntries() {
// Use configured grace period with same normalization as runtime checks
int configuredMinutes = jwtProperties.getRefreshGraceMinutes();
int graceMinutes =
configuredMinutes >= 0
? configuredMinutes
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
Instant cutoff = Instant.now().minusMillis(graceMinutes * 60000L);
int removed =
attempts.entrySet().stream()
.filter(entry -> entry.getValue().getFirstAttempt().isBefore(cutoff))
.mapToInt(
entry -> {
attempts.remove(entry.getKey());
return 1;
})
.sum();
if (removed > 0) {
log.debug("Cleaned up {} expired refresh tracking entries", removed);
}
}
/** Get current tracking statistics for monitoring. */
public Map<String, Object> getStats() {
return Map.of(
"tracked_tokens",
attempts.size(),
"max_attempts_allowed",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
}
}
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
@@ -33,6 +34,7 @@ public class TotpService {
private static final String HMAC_ALGORITHM = "HmacSHA1";
private static final String DEFAULT_ISSUER = "Stirling PDF";
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final Pattern TOTP_CODE_PATTERN = Pattern.compile("\\d{6}");
private final ApplicationProperties applicationProperties;
@@ -71,7 +73,7 @@ public class TotpService {
}
String normalizedCode = code.replace(" ", "");
if (!normalizedCode.matches("\\d{6}")) {
if (!TOTP_CODE_PATTERN.matcher(normalizedCode).matches()) {
return null;
}
@@ -0,0 +1,82 @@
package stirling.software.proprietary.security.util;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
/**
* Utility class for detecting desktop clients and determining appropriate token expiry times.
*
* <p>Desktop clients (Tauri, Electron) receive longer-lived tokens because:
*
* <ul>
* <li>They run on personal devices (not shared computers)
* <li>Tokens stored in OS-level encrypted keychain (not browser localStorage)
* <li>Better UX (users expect desktop apps to stay logged in)
* </ul>
*/
@Slf4j
public class DesktopClientUtils {
private DesktopClientUtils() {
// Utility class - prevent instantiation
}
/**
* Detect if the request is from a desktop client (Tauri app).
*
* @param request the HTTP request
* @return true if desktop client, false if web browser
*/
public static boolean isDesktopClient(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
if (userAgent == null) {
return false;
}
// Tauri desktop app includes "Tauri" or "tauri-plugin" in User-Agent
// Also check for common desktop app identifiers
String userAgentLower = userAgent.toLowerCase();
boolean hasTauri = userAgentLower.contains("tauri");
boolean hasStirling = userAgentLower.contains("stirlingpdf-desktop");
boolean hasElectron = userAgentLower.contains("electron");
boolean isDesktop = hasTauri || hasStirling || hasElectron;
log.debug("Desktop client detection: {} (User-Agent: {})", isDesktop, userAgent);
return isDesktop;
}
/**
* Get the configured desktop token expiry time in minutes.
*
* @param applicationProperties the application properties
* @return desktop token expiry in minutes (defaults to 30 days if not configured)
*/
public static int getDesktopTokenExpiryMinutes(ApplicationProperties applicationProperties) {
int configuredMinutes =
applicationProperties.getSecurity().getJwt().getDesktopTokenExpiryMinutes();
// If not configured or invalid, default to 30 days (43200 minutes)
return configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES;
}
/**
* Get the configured web token expiry time in minutes.
*
* @param applicationProperties the application properties
* @return web token expiry in minutes
*/
public static int getWebTokenExpiryMinutes(ApplicationProperties applicationProperties) {
int configuredMinutes =
applicationProperties.getSecurity().getJwt().getTokenExpiryMinutes();
return configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
}
}
@@ -10,6 +10,7 @@ import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
@@ -32,6 +33,7 @@ import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
@Slf4j
public class SignatureService implements PersonalSignatureServiceInterface {
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -366,14 +368,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
@@ -5,6 +5,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
@@ -18,6 +19,7 @@ import java.util.Optional;
import java.util.Set;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -46,6 +48,7 @@ import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.RegexPatternUtils;
@@ -67,6 +70,13 @@ public class FormUtils {
public final Set<String> CHOICE_FIELD_TYPES =
Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO);
/**
* Threshold in PDF points for considering two widgets to be on the same line. Fields whose
* y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead
* of top-to-bottom.
*/
private static final float SAME_LINE_THRESHOLD_PT = 10.0f;
/**
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
* so all callers share identical mapping logic.
@@ -109,6 +119,8 @@ public class FormUtils {
List<FormFieldInfo> fields = new ArrayList<>();
Map<String, Integer> typeCounters = new HashMap<>();
Map<Integer, Integer> pageOrderCounters = new HashMap<>();
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
for (PDField field : acroForm.getFieldTree()) {
if (!(field instanceof PDTerminalField terminalField)) {
continue;
@@ -125,7 +137,7 @@ public class FormUtils {
String currentValue = safeValue(terminalField);
boolean required = field.isRequired();
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField);
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField, annotationPageMap);
List<String> options = resolveOptions(terminalField);
String tooltip = resolveTooltip(terminalField);
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
@@ -164,6 +176,396 @@ public class FormUtils {
return Collections.unmodifiableList(fields);
}
/**
* Extract form fields with widget coordinates for the interactive form viewer.
*
* @param document PDF document
* @return List of form fields with coordinates and metadata
*/
public List<FormFieldWithCoordinates> extractFormFieldsWithCoordinates(PDDocument document) {
if (document == null) return List.of();
PDAcroForm acroForm = getAcroFormSafely(document);
if (acroForm == null) return List.of();
List<FormFieldWithCoordinates> fields = new ArrayList<>();
Map<String, Integer> typeCounters = new HashMap<>();
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
for (PDField field : acroForm.getFieldTree()) {
if (!(field instanceof PDTerminalField terminalField)) {
continue;
}
String type = detectFieldType(terminalField);
String name =
Optional.ofNullable(field.getFullyQualifiedName())
.orElseGet(field::getPartialName);
if (name == null || name.isBlank()) {
continue;
}
String currentValue = safeValue(terminalField);
boolean required = field.isRequired();
boolean readOnly = field.isReadOnly();
List<String> options = resolveOptions(terminalField);
List<String> displayOptions = resolveDisplayOptions(terminalField);
String tooltip = resolveTooltip(terminalField);
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
String displayLabel =
deriveDisplayLabel(field, name, tooltip, type, typeIndex, options);
boolean multiSelect = resolveMultiSelect(terminalField);
boolean multiline =
terminalField instanceof PDTextField
&& ((PDTextField) terminalField).isMultiline();
// Extract widget coordinates
List<FormFieldWithCoordinates.WidgetCoordinates> widgets =
extractWidgetCoordinates(document, terminalField, annotationPageMap);
// Only include displayOptions when they differ from export options
List<String> displayOptsToSend = null;
if (displayOptions != null
&& !displayOptions.isEmpty()
&& !displayOptions.equals(options)) {
displayOptsToSend = displayOptions;
}
fields.add(
FormFieldWithCoordinates.builder()
.name(name)
.label(displayLabel)
.type(type)
.value(currentValue)
.options(options.isEmpty() ? null : options)
.displayOptions(displayOptsToSend)
.required(required)
.readOnly(readOnly)
.multiSelect(multiSelect)
.multiline(multiline)
.tooltip(tooltip)
.widgets(widgets.isEmpty() ? null : widgets)
.build());
}
// Sort by page and position
fields.sort(new FieldCoordinateComparator());
log.debug("Total fields processed: {}", fields.size());
log.debug(
"Fields WITH widgets: {}",
fields.stream()
.filter(f -> f.getWidgets() != null && !f.getWidgets().isEmpty())
.count());
log.debug(
"Fields WITHOUT widgets: {}",
fields.stream()
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
.count());
fields.stream()
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
.forEach(
f ->
log.debug(
"Field '{}' type={} has NO widget coordinates",
f.getName(),
f.getType()));
return Collections.unmodifiableList(fields);
}
/**
* Extract widget coordinates for a form field.
*
* @param document PDF document
* @param field Terminal field
* @return List of widget coordinates
*/
private List<FormFieldWithCoordinates.WidgetCoordinates> extractWidgetCoordinates(
PDDocument document,
PDTerminalField field,
Map<COSDictionary, Integer> annotationPageMap) {
List<FormFieldWithCoordinates.WidgetCoordinates> result = new ArrayList<>();
List<PDAnnotationWidget> widgets = field.getWidgets();
log.debug(
"Field '{}' type={} has {} widgets",
field.getFullyQualifiedName(),
field.getClass().getSimpleName(),
widgets != null ? widgets.size() : 0);
if (widgets == null || widgets.isEmpty()) {
// Some fields (especially text fields) might be their own widget annotation
log.trace(
"Field '{}' has no widgets, checking if field acts as its own annotation",
field.getFullyQualifiedName());
try {
COSDictionary fieldDict = field.getCOSObject();
COSBase rectBase = fieldDict.getDictionaryObject(COSName.RECT);
if (rectBase instanceof COSArray rectArray) {
int pageIndex =
findPageIndexForAnnotation(document, fieldDict, annotationPageMap);
if (pageIndex >= 0) {
PDRectangle rectangle = new PDRectangle(rectArray);
result.add(
createWidgetCoordinates(
document, rectangle, pageIndex, null, field));
} else {
log.warn(
"Found rectangle for field '{}' but could not resolve page index",
field.getFullyQualifiedName());
}
}
} catch (Exception e) {
log.debug(
"Could not extract direct rectangle for field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
return result;
}
// For radio buttons, pre-resolve export values per widget
List<String> exportValues = null;
if (field instanceof PDRadioButton radio) {
exportValues = radio.getExportValues();
}
for (int i = 0; i < widgets.size(); i++) {
PDAnnotationWidget widget = widgets.get(i);
try {
PDRectangle rectangle = widget.getRectangle();
if (rectangle == null) {
log.warn(
"Field '{}' widget {} has NULL rectangle",
field.getFullyQualifiedName(),
i);
continue;
}
int pageIndex = resolveWidgetPageIndex(document, widget, annotationPageMap);
if (pageIndex < 0) {
log.warn(
"Field '{}' widget {} could not resolve page index",
field.getFullyQualifiedName(),
i);
continue;
}
// Resolve export value for radio/checkbox widgets
String exportValue = null;
if (exportValues != null && i < exportValues.size()) {
exportValue = exportValues.get(i);
} else if (field instanceof PDButton) {
// Fall back to appearance state name from the widget's normal appearance
try {
var ap = widget.getAppearance();
if (ap != null && ap.getNormalAppearance() != null) {
var normalAp = ap.getNormalAppearance();
if (normalAp.isSubDictionary()) {
for (var cosName : normalAp.getSubDictionary().keySet()) {
String key = cosName.getName();
if (!"Off".equals(key)) {
exportValue = key;
break;
}
}
}
}
} catch (Exception e) {
log.trace(
"Could not extract export value for widget in '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
}
result.add(
createWidgetCoordinates(
document, rectangle, pageIndex, exportValue, field));
} catch (Exception e) {
log.debug(
"Failed to extract coordinates for widget in field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
}
return result;
}
private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates(
PDDocument document,
PDRectangle rectangle,
int pageIndex,
String exportValue,
PDTerminalField field) {
if (pageIndex < 0 || pageIndex >= document.getNumberOfPages()) {
return null;
}
PDPage page = document.getPage(pageIndex);
PDRectangle cropBox = page.getCropBox();
// Use CropBox dimensions for the y-flip.
// Note: getWidth() and getHeight() return dimensions BEFORE rotation.
float cropHeight = cropBox.getHeight();
// Get absolute widget coordinates (in MediaBox space, un-rotated)
float pdfX = rectangle.getLowerLeftX();
float pdfY = rectangle.getLowerLeftY();
float width = rectangle.getWidth();
float height = rectangle.getHeight();
// Adjust relative to CropBox origin
float relativeX = pdfX - cropBox.getLowerLeftX();
float relativeY = pdfY - cropBox.getLowerLeftY();
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
// Widget /Rect coordinates are always in un-rotated PDF user space.
// The embedpdf viewer wraps all page content inside a <Rotate> CSS
// component that handles visual rotation — we must NOT apply any
// rotation transform here, or widgets would be double-rotated.
float finalX = relativeX;
float finalY = cropHeight - relativeY - height;
float finalW = width;
float finalH = height;
// Validate coordinates are within reasonable bounds
if (finalX < -1.0f
|| finalY < -1.0f
|| finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow
|| finalY > cropHeight + 1.0f) {
log.warn(
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}",
field.getFullyQualifiedName(),
pageIndex,
finalX,
finalY,
finalW,
finalH);
return null;
}
return FormFieldWithCoordinates.WidgetCoordinates.builder()
.pageIndex(pageIndex)
.x(finalX)
.y(finalY)
.width(finalW)
.height(finalH)
.exportValue(exportValue)
.fontSize(extractFontSize(field))
.build();
}
/**
* Repairs widgets with missing page references by scanning all pages and setting the /P entry
* for orphan widgets.
*
* <p>This should be called BEFORE extracting form field coordinates.
*
* @param document PDF document to repair
*/
public void repairMissingWidgetPageReferences(PDDocument document) {
try {
PDAcroForm acroForm = getAcroFormSafely(document);
if (acroForm == null) {
return;
}
log.debug("Checking for widgets with missing page references...");
int repairedCount = 0;
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
for (PDField field : acroForm.getFieldTree()) {
if (!(field instanceof PDTerminalField terminalField)) {
continue;
}
List<PDAnnotationWidget> widgets = terminalField.getWidgets();
if (widgets == null || widgets.isEmpty()) {
continue;
}
for (PDAnnotationWidget widget : widgets) {
if (widget.getPage() == null) {
Integer pageIndex = annotationPageMap.get(widget.getCOSObject());
if (pageIndex != null && pageIndex >= 0) {
PDPage foundPage = document.getPage(pageIndex);
widget.setPage(foundPage);
repairedCount++;
log.debug(
"Repaired widget for field '{}' - set page reference via map",
field.getFullyQualifiedName());
} else {
log.warn(
"Could not find page for widget in field '{}'",
field.getFullyQualifiedName());
}
}
}
}
if (repairedCount > 0) {
log.debug(
"Successfully repaired {} widgets with missing page references",
repairedCount);
} else {
log.debug("No widgets needed repair");
}
} catch (Exception e) {
log.error("Error repairing widget page references: {}", e.getMessage(), e);
}
}
private int findPageIndexForAnnotation(
PDDocument document,
COSDictionary annotDict,
Map<COSDictionary, Integer> annotationPageMap) {
try {
// Method 0: Check the pre-built lookup map (fastest)
if (annotationPageMap != null) {
Integer idx = annotationPageMap.get(annotDict);
if (idx != null) {
return idx;
}
}
// Method 1: Check the /P entry if it points to a page
COSBase base = annotDict.getDictionaryObject(COSName.P);
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
if (pageDict != null) {
for (int i = 0; i < document.getNumberOfPages(); i++) {
if (document.getPage(i).getCOSObject() == pageDict) {
return i;
}
}
}
// Method 2: Fallback search through all pages' annotations
for (int i = 0; i < document.getNumberOfPages(); i++) {
PDPage page = document.getPage(i);
List<PDAnnotation> annotations = page.getAnnotations();
if (annotations != null) {
for (PDAnnotation annot : annotations) {
if (annot != null && annot.getCOSObject() == annotDict) {
return i;
}
}
}
}
} catch (Exception e) {
log.trace("Error finding page for annotation: {}", e.getMessage());
}
return -1;
}
/**
* Build a single record object (field-name -> value placeholder) that can be directly submitted
* to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless
@@ -312,7 +714,24 @@ public class FormUtils {
return;
}
flattenViaRendering(document, acroForm);
if (acroForm == null) {
return;
}
// Use PDFBox's built-in field flattening which bakes form field values
// into the page content stream as static text/graphics, removing the
// interactive form structure but preserving all other document content
// (images, text, annotations, etc.) at full quality.
try {
ensureAppearances(acroForm);
acroForm.flatten();
} catch (Exception e) {
log.warn(
"PDFBox acroForm.flatten() failed, falling back to rendering: {}",
e.getMessage(),
e);
flattenViaRendering(document, acroForm);
}
}
private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi)
@@ -385,7 +804,7 @@ public class FormUtils {
PDPage page = widget.getPage();
if (page == null) {
page = resolveWidgetPage(document, widget);
page = resolveWidgetPage(document, widget, null);
if (page != null) {
widget.setPage(page);
}
@@ -820,6 +1239,16 @@ public class FormUtils {
private String safeValue(PDTerminalField field) {
try {
// PDChoice.getValueAsString() returns a raw COS string representation
// that doesn't reliably reflect the selected value. Use getValue()
// which returns the proper List<String> of selected options.
if (field instanceof PDChoice choiceField) {
List<String> selected = choiceField.getValue();
if (selected == null || selected.isEmpty()) {
return null;
}
return String.join(",", selected);
}
return field.getValueAsString();
} catch (Exception e) {
log.debug(
@@ -833,14 +1262,25 @@ public class FormUtils {
List<String> resolveOptions(PDTerminalField field) {
try {
if (field instanceof PDChoice choice) {
List<String> display = choice.getOptionsDisplayValues();
if (display != null && !display.isEmpty()) {
return new ArrayList<>(display);
}
LinkedHashSet<String> allowed = new LinkedHashSet<>();
List<String> exportValues = choice.getOptionsExportValues();
if (exportValues != null && !exportValues.isEmpty()) {
return new ArrayList<>(exportValues);
List<String> displayValues = choice.getOptionsDisplayValues();
if (exportValues != null) {
exportValues.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.forEach(allowed::add);
}
if (displayValues != null) {
displayValues.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.forEach(allowed::add);
}
return new ArrayList<>(allowed);
} else if (field instanceof PDRadioButton radio) {
List<String> exports = radio.getExportValues();
if (exports != null && !exports.isEmpty()) {
@@ -861,6 +1301,29 @@ public class FormUtils {
return Collections.emptyList();
}
/**
* Returns the display-value labels for a choice field's options. For radio / checkbox this
* returns an empty list (no separate display values). For PDChoice fields, if the PDF provides
* distinct display values, those are returned; otherwise an empty list (indicating that the
* export values from {@link #resolveOptions} should be shown directly).
*/
List<String> resolveDisplayOptions(PDTerminalField field) {
try {
if (field instanceof PDChoice choice) {
List<String> display = choice.getOptionsDisplayValues();
if (display != null && !display.isEmpty()) {
return new ArrayList<>(display);
}
}
} catch (Exception e) {
log.debug(
"Failed to resolve display options for field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
return Collections.emptyList();
}
private boolean resolveMultiSelect(PDTerminalField field) {
if (field instanceof PDListBox listBox) {
try {
@@ -875,6 +1338,44 @@ public class FormUtils {
return false;
}
private Float extractFontSize(PDTerminalField field) {
try {
String da = null;
if (field instanceof PDVariableText vt) {
da = vt.getDefaultAppearance();
}
if (da == null || da.isBlank()) {
// Check parent/acroform default appearance if field's is missing
PDAcroForm form = field.getAcroForm();
if (form != null) {
da = form.getDefaultAppearance();
}
}
if (da != null && !da.isBlank()) {
// Standard DA looks like: /Helv 12 Tf 0 g
// We want the number before 'Tf'
String[] tokens = da.split("\\s+");
for (int i = 0; i < tokens.length; i++) {
if ("Tf".equals(tokens[i]) && i > 0) {
try {
float size = Float.parseFloat(tokens[i - 1]);
return size > 0 ? size : null;
} catch (NumberFormatException ignored) {
}
}
}
}
} catch (Exception e) {
log.trace(
"Could not extract font size for field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
return null;
}
private boolean isSettableCheckBoxState(String state) {
if (state == null) return false;
String trimmed = state.trim();
@@ -952,6 +1453,13 @@ public class FormUtils {
if (simplified.isEmpty()) return true;
// Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7")
// Standard UUIDs are 32 hex characters; require at least that to avoid
// false positives on short hex-like field names.
String nospaces = simplified.replaceAll("\\s+", "");
if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"))
return true;
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
|| patterns.getOptionalTNumericPattern().matcher(simplified).matches();
@@ -1007,7 +1515,7 @@ public class FormUtils {
PDAnnotationWidget widget = widgets.get(0);
PDRectangle originalRectangle = cloneRectangle(widget.getRectangle());
PDPage page = resolveWidgetPage(document, widget);
PDPage page = resolveWidgetPage(document, widget, null);
if (page == null || originalRectangle == null) {
log.warn(
"Unable to resolve widget page or rectangle for '{}'; skipping",
@@ -1064,7 +1572,7 @@ public class FormUtils {
desiredName,
modification.label(),
resolvedType,
determineWidgetPageIndex(document, widget),
determineWidgetPageIndex(document, widget, null),
originalRectangle.getLowerLeftX(),
originalRectangle.getLowerLeftY(),
originalRectangle.getWidth(),
@@ -1205,59 +1713,43 @@ public class FormUtils {
return null;
}
private int resolveFirstWidgetPageIndex(PDDocument document, PDTerminalField field) {
private int resolveFirstWidgetPageIndex(
PDDocument document,
PDTerminalField field,
Map<COSDictionary, Integer> annotationPageMap) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null || widgets.isEmpty()) {
return -1;
}
Map<PDAnnotationWidget, Integer> widgetPageFallbacks = null;
for (PDAnnotationWidget widget : widgets) {
int idx = resolveWidgetPageIndex(document, widget);
int idx = resolveWidgetPageIndex(document, widget, annotationPageMap);
if (idx >= 0) {
return idx;
}
try {
COSDictionary widgetDictionary = widget.getCOSObject();
if (widgetDictionary != null
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
if (widgetPageFallbacks == null) {
widgetPageFallbacks = buildWidgetPageFallbackMap(document);
}
Integer fallbackIndex = widgetPageFallbacks.get(widget);
if (fallbackIndex != null && fallbackIndex >= 0) {
return fallbackIndex;
}
}
} catch (Exception e) {
log.debug(
"Failed to inspect widget page reference for field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
}
return -1;
}
private int resolveWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
private int resolveWidgetPageIndex(
PDDocument document,
PDAnnotationWidget widget,
Map<COSDictionary, Integer> annotationPageMap) {
if (document == null || widget == null) {
return -1;
}
try {
COSDictionary widgetDictionary = widget.getCOSObject();
if (widgetDictionary != null
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
Map<PDAnnotationWidget, Integer> fallback = buildWidgetPageFallbackMap(document);
Integer index = fallback.get(widget);
if (index != null) {
return index;
}
// Method 0: Check the pre-built lookup map (fastest)
if (annotationPageMap != null) {
Integer idx = annotationPageMap.get(widget.getCOSObject());
if (idx != null) {
return idx;
}
} catch (Exception e) {
log.debug("Widget page lookup via fallback map failed: {}", e.getMessage());
}
try {
PDPage page = widget.getPage();
if (page != null) {
// indexOf is O(N), still slower than map but better than scanning annotations
int idx = document.getPages().indexOf(page);
if (idx >= 0) {
return idx;
@@ -1267,14 +1759,36 @@ public class FormUtils {
log.debug("Widget page lookup failed: {}", e.getMessage());
}
// Method 1: Check the /P entry if it points to a page
try {
COSDictionary widgetDictionary = widget.getCOSObject();
if (widgetDictionary != null) {
COSBase base = widgetDictionary.getDictionaryObject(COSName.P);
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
if (pageDict != null) {
for (int i = 0; i < document.getNumberOfPages(); i++) {
if (document.getPage(i).getCOSObject() == pageDict) {
return i;
}
}
}
}
} catch (Exception e) {
log.debug("Widget page lookup via /P entry failed: {}", e.getMessage());
}
// Method 2: Fallback search through all pages' annotations
int pageCount = document.getNumberOfPages();
COSDictionary widgetDict = widget.getCOSObject();
for (int i = 0; i < pageCount; i++) {
try {
PDPage candidate = document.getPage(i);
List<PDAnnotation> annotations = candidate.getAnnotations();
for (PDAnnotation annotation : annotations) {
if (annotation == widget) {
return i;
if (annotations != null) {
for (PDAnnotation annot : annotations) {
if (annot != null && annot.getCOSObject() == widgetDict) {
return i;
}
}
}
} catch (IOException e) {
@@ -1317,7 +1831,7 @@ public class FormUtils {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets != null) {
for (PDAnnotationWidget widget : widgets) {
PDPage page = resolveWidgetPage(document, widget);
PDPage page = resolveWidgetPage(document, widget, null);
if (page != null) {
page.getAnnotations().remove(widget);
}
@@ -1437,7 +1951,10 @@ public class FormUtils {
rectangle.getHeight());
}
private PDPage resolveWidgetPage(PDDocument document, PDAnnotationWidget widget) {
private PDPage resolveWidgetPage(
PDDocument document,
PDAnnotationWidget widget,
Map<COSDictionary, Integer> annotationPageMap) {
if (widget == null) {
return null;
}
@@ -1445,7 +1962,7 @@ public class FormUtils {
if (page != null) {
return page;
}
int pageIndex = determineWidgetPageIndex(document, widget);
int pageIndex = determineWidgetPageIndex(document, widget, annotationPageMap);
if (pageIndex >= 0) {
try {
return document.getPage(pageIndex);
@@ -1456,11 +1973,21 @@ public class FormUtils {
return null;
}
private int determineWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
private int determineWidgetPageIndex(
PDDocument document,
PDAnnotationWidget widget,
Map<COSDictionary, Integer> annotationPageMap) {
if (document == null || widget == null) {
return -1;
}
if (annotationPageMap != null) {
Integer idx = annotationPageMap.get(widget.getCOSObject());
if (idx != null) {
return idx;
}
}
PDPage directPage = widget.getPage();
if (directPage != null) {
int index = 0;
@@ -1488,6 +2015,33 @@ public class FormUtils {
return -1;
}
/**
* Build a map of annotation COS dictionaries to their respective page index. Scan once
* per-document to avoid O(N^2) lookups during field extraction.
*/
public Map<COSDictionary, Integer> buildAnnotationPageMap(PDDocument document) {
if (document == null) {
return Collections.emptyMap();
}
Map<COSDictionary, Integer> map = new HashMap<>();
int pageCount = document.getNumberOfPages();
for (int i = 0; i < pageCount; i++) {
try {
PDPage page = document.getPage(i);
List<PDAnnotation> annotations = page.getAnnotations();
for (PDAnnotation annot : annotations) {
if (annot != null) {
map.putIfAbsent(annot.getCOSObject(), i);
}
}
} catch (Exception e) {
log.debug("Failed to index annotations for page {}: {}", i, e.getMessage());
}
}
return map;
}
private Map<PDAnnotationWidget, Integer> buildWidgetPageFallbackMap(PDDocument document) {
if (document == null) {
return Collections.emptyMap();
@@ -1760,4 +2314,46 @@ public class FormUtils {
boolean multiSelect,
String tooltip,
int pageOrder) {}
/**
* Comparator for sorting form fields by page, then vertically (top-to-bottom), then
* horizontally (left-to-right) for fields on approximately the same line.
*/
static final class FieldCoordinateComparator implements Comparator<FormFieldWithCoordinates> {
private static int firstWidgetPageIndex(FormFieldWithCoordinates f) {
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
? f.getWidgets().get(0).getPageIndex()
: -1;
}
private static float firstWidgetY(FormFieldWithCoordinates f) {
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
? f.getWidgets().get(0).getY()
: 0;
}
private static float firstWidgetX(FormFieldWithCoordinates f) {
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
? f.getWidgets().get(0).getX()
: 0;
}
@Override
public int compare(FormFieldWithCoordinates a, FormFieldWithCoordinates b) {
int pageA = firstWidgetPageIndex(a);
int pageB = firstWidgetPageIndex(b);
int pageCompare = Integer.compare(pageA, pageB);
if (pageCompare != 0) return pageCompare;
float yA = firstWidgetY(a);
float yB = firstWidgetY(b);
// Fields on approximately the same line should be sorted left-to-right
if (Math.abs(yA - yB) < SAME_LINE_THRESHOLD_PT) {
return Float.compare(firstWidgetX(a), firstWidgetX(b));
}
return Float.compare(yA, yB);
}
}
}
@@ -10,6 +10,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@@ -36,6 +38,7 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
@@ -53,11 +56,17 @@ class AuthControllerLoginTest {
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
@Mock private RefreshRateLimitService refreshRateLimitService;
@BeforeEach
void setUp() {
securityProperties = new ApplicationProperties.Security();
securityProperties.setLoginMethod("all");
securityProperties.getJwt().setTokenExpiryMinutes(60);
securityProperties.getJwt().setRefreshGraceMinutes(5);
ApplicationProperties applicationProperties = new ApplicationProperties();
applicationProperties.setSecurity(securityProperties);
AuthController controller =
new AuthController(
@@ -67,7 +76,9 @@ class AuthControllerLoginTest {
loginAttemptService,
mfaService,
totpService,
securityProperties);
refreshRateLimitService,
securityProperties,
applicationProperties);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -175,7 +186,11 @@ class AuthControllerLoginTest {
void refreshReturnsNewTokenWhenValid() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put("exp", new Date(System.currentTimeMillis() + 60_000));
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
// Rate limiting is not checked for valid tokens, so no stub needed
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
.thenReturn("new-token");
@@ -184,7 +199,75 @@ class AuthControllerLoginTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.user").exists())
.andExpect(jsonPath("$.session.access_token").value("new-token"))
.andExpect(jsonPath("$.session.expires_in").value(3600));
.andExpect(
jsonPath("$.session.expires_in")
.value(3600)); // 60 minutes * 60 = 3600 seconds
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
// period
}
@Test
void refreshRejectsTokenExpiredBeyondGrace() throws Exception {
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put(
"exp",
new Date(
System.currentTimeMillis()
- (10 * 60_000))); // 10 minutes ago, beyond 5 minute grace
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Token refresh failed"));
verify(userDetailsService, never()).loadUserByUsername(any());
verify(refreshRateLimitService, never()).isRefreshAllowed(any(), any(Long.class));
}
@Test
void refreshAcceptsTokenExpiredWithinGrace() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put(
"exp",
new Date(
System.currentTimeMillis()
- 60_000)); // 1 minute ago, within 5 minute grace
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(true);
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
.thenReturn("new-token");
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.session.access_token").value("new-token"));
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
// period
}
@Test
void refreshRejectsWhenRateLimitExceeded() throws Exception {
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put("exp", new Date(System.currentTimeMillis() - 60_000)); // 1 minute ago
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(false);
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.error").value("Too many refresh attempts"))
.andExpect(jsonPath("$.max_attempts").exists());
verify(userDetailsService, never()).loadUserByUsername(any());
verify(refreshRateLimitService, never()).clearRefreshAttempts(any());
}
@Test
@@ -37,13 +37,19 @@ class CustomOAuth2AuthenticationSuccessHandlerTest {
oauth2Props.setAutoCreateUser(true);
oauth2Props.setBlockRegistration(false);
ApplicationProperties applicationProperties = new ApplicationProperties();
ApplicationProperties.Security securityProperties = new ApplicationProperties.Security();
securityProperties.setOauth2(oauth2Props);
applicationProperties.setSecurity(securityProperties);
CustomOAuth2AuthenticationSuccessHandler handler =
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
oauth2Props,
userService,
jwtService,
licenseSettingsService);
licenseSettingsService,
applicationProperties);
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
@@ -31,6 +31,7 @@ import org.springframework.security.core.Authentication;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
@@ -64,7 +65,8 @@ class JwtServiceTest {
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
jwtService = new JwtService(true, keystoreService);
ApplicationProperties applicationProperties = new ApplicationProperties();
jwtService = new JwtService(true, keystoreService, applicationProperties);
}
@Test
@@ -73,8 +75,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -94,8 +94,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -114,8 +112,6 @@ class JwtServiceTest {
void testValidateTokenSuccess() throws Exception {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn("testuser");
@@ -179,8 +175,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(user);
when(user.getUsername()).thenReturn(username);
@@ -207,8 +201,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -281,8 +273,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -307,8 +297,6 @@ class JwtServiceTest {
// First, generate a token successfully
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
@@ -16,6 +17,8 @@ import stirling.software.proprietary.security.util.Base32Codec;
class TotpServiceTest {
private static final Pattern PATTERN = Pattern.compile("[A-Z2-7]+");
private TotpService buildService(String appName) {
ApplicationProperties properties = new ApplicationProperties();
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
@@ -32,7 +35,7 @@ class TotpServiceTest {
assertNotNull(secret);
assertEquals(32, secret.length());
assertTrue(secret.matches("[A-Z2-7]+"));
assertTrue(PATTERN.matcher(secret).matches());
}
@Test
+2 -2
View File
@@ -5,7 +5,7 @@ plugins {
id "org.springframework.boot" version "3.5.9"
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
id "io.swagger.swaggerhub" version "1.3.2"
id "com.diffplug.spotless" version "8.2.1"
id "com.diffplug.spotless" version "8.1.0"
id "com.github.jk1.dependency-license-report" version "3.0.1"
//id "nebula.lint" version "19.0.3"
id "org.sonarqube" version "7.2.2.6593"
@@ -67,7 +67,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.4.6'
version = '2.5.1'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+269 -177
View File
@@ -10,29 +10,31 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.5.0",
"@embedpdf/engines": "^2.5.0",
"@embedpdf/models": "^2.5.0",
"@embedpdf/plugin-annotation": "^2.5.0",
"@embedpdf/plugin-bookmark": "^2.5.0",
"@embedpdf/plugin-document-manager": "^2.5.0",
"@embedpdf/plugin-export": "^2.5.0",
"@embedpdf/plugin-history": "^2.5.0",
"@embedpdf/plugin-interaction-manager": "^2.5.0",
"@embedpdf/plugin-pan": "^2.5.0",
"@embedpdf/plugin-print": "^2.5.0",
"@embedpdf/plugin-redaction": "^2.5.0",
"@embedpdf/plugin-render": "^2.5.0",
"@embedpdf/plugin-rotate": "^2.5.0",
"@embedpdf/plugin-scroll": "^2.5.0",
"@embedpdf/plugin-search": "^2.5.0",
"@embedpdf/plugin-selection": "^2.5.0",
"@embedpdf/plugin-spread": "^2.5.0",
"@embedpdf/plugin-thumbnail": "^2.5.0",
"@embedpdf/plugin-tiling": "^2.5.0",
"@embedpdf/plugin-viewport": "^2.5.0",
"@embedpdf/plugin-zoom": "^2.5.0",
"@embedpdf/core": "^2.6.0",
"@embedpdf/engines": "^2.6.0",
"@embedpdf/models": "^2.6.0",
"@embedpdf/plugin-annotation": "^2.6.0",
"@embedpdf/plugin-attachment": "^2.6.0",
"@embedpdf/plugin-bookmark": "^2.6.0",
"@embedpdf/plugin-document-manager": "^2.6.0",
"@embedpdf/plugin-export": "^2.6.0",
"@embedpdf/plugin-history": "^2.6.0",
"@embedpdf/plugin-interaction-manager": "^2.6.0",
"@embedpdf/plugin-pan": "^2.6.0",
"@embedpdf/plugin-print": "^2.6.0",
"@embedpdf/plugin-redaction": "^2.6.0",
"@embedpdf/plugin-render": "^2.6.0",
"@embedpdf/plugin-rotate": "^2.6.0",
"@embedpdf/plugin-scroll": "^2.6.0",
"@embedpdf/plugin-search": "^2.6.0",
"@embedpdf/plugin-selection": "^2.6.0",
"@embedpdf/plugin-spread": "^2.6.0",
"@embedpdf/plugin-thumbnail": "^2.6.0",
"@embedpdf/plugin-tiling": "^2.6.0",
"@embedpdf/plugin-viewport": "^2.6.0",
"@embedpdf/plugin-zoom": "^2.6.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -49,6 +51,7 @@
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "^2.5.6",
"@tauri-apps/plugin-shell": "^2.3.4",
@@ -59,7 +62,6 @@
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
@@ -364,6 +366,21 @@
"node": ">=18"
}
},
"node_modules/@cantoo/pdf-lib": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.5.3.tgz",
"integrity": "sha512-SBQp8i/XdWNUhLutn5P67Pwj4X9vU046BRpfOMODJZuYVrgChtsTfgdnlW2O7x8gdXs8j7NoTaWI/b78E2oVmQ==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"color": "^4.2.3",
"crypto-js": "^4.2.0",
"node-html-better-parser": ">=1.4.0",
"pako": "^1.0.11",
"tslib": ">=2"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -553,13 +570,13 @@
}
},
"node_modules/@embedpdf/core": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.5.0.tgz",
"integrity": "sha512-nI7GnA5xCNtJHAdKBLPKJVvi4+yAKjy1sysaDf+qp+z3D81Hy8oAcl///QTaZ9ob0SL2jyqi3x//hKl0Rwmgrw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.6.0.tgz",
"integrity": "sha512-859GUvZ3BLpJuKTiwcPPMNn9CSlMaPjQ4yXnyQRngfbvDAiijIIpVLaC98B08Nx6QsUcD3cs/6+wkB888lNsDw==",
"license": "MIT",
"dependencies": {
"@embedpdf/engines": "2.5.0",
"@embedpdf/models": "2.5.0"
"@embedpdf/engines": "2.6.0",
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -570,9 +587,9 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.5.0.tgz",
"integrity": "sha512-SEknNmQrYvkAZgJllRKXuvXSrHSndDQsr7b3mrIVa9bzV6TeZua0a/YUlvI3/jf74Sdajru3XKPe22iHEOH4Zg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.6.0.tgz",
"integrity": "sha512-zW3927u0wbFBD2tQLWbE45DEBIMkZyN7n5O2p70er6u7mP1XYEz7Ud9NxcPL/3b5MzDfPBTSyxM3T12e+ZeAxw==",
"license": "MIT",
"dependencies": {
"@embedpdf/fonts-arabic": "1.0.0",
@@ -582,8 +599,8 @@
"@embedpdf/fonts-latin": "1.0.0",
"@embedpdf/fonts-sc": "1.0.0",
"@embedpdf/fonts-tc": "1.0.0",
"@embedpdf/models": "2.5.0",
"@embedpdf/pdfium": "2.5.0"
"@embedpdf/models": "2.6.0",
"@embedpdf/pdfium": "2.6.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -636,31 +653,48 @@
"license": "OFL-1.1"
},
"node_modules/@embedpdf/models": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.5.0.tgz",
"integrity": "sha512-wu7XgargYBQEh46hVnfsmkTF6TvuoP9nAkTASR60s5ourjlT12qL9RiFLpwGkOBfs8E58h8V5hkgKsra5t03Lw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.6.0.tgz",
"integrity": "sha512-6zuoJE79WXyRXKhJXhl+8p4njuC1nxPpKYRIs54PRLgTkHOLaou+G+ZunEd99XOoVssHLCjxWBUpg46ihQwXDw==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.5.0.tgz",
"integrity": "sha512-2VEO4cNZsV8ig9upS+C+x3Tb58aqNxiAdaUMlD2ZZT8FgszhsV9xMyEuM2maFRdjeT7EO37FtzYBdXc/K67ivA==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.6.0.tgz",
"integrity": "sha512-eYXU1VvVI0e9OqOzvsTcsU6YSLq9F7jcAiIbtMB+NxApvvH3kHz3FPEcf8ha2ZiLftF5OAD8K89SSE5GLE6t1A==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.5.0.tgz",
"integrity": "sha512-S5zCeWU3hM9jrnaGuW5RAXt+AzXXvQbFtAdCtxHW1hFADiZ97FKr8KS9MGCkkj6C9madtZP6iUJikvnhoLCABQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.6.0.tgz",
"integrity": "sha512-FJgGy6lhKrWsiJjh7jZ92NwMBob5GOwfYejQl28JFk6muEQORLtysz5gaeyMpMIyxnfjlf9Eqv8Z6LBBfGLGOA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0",
"@embedpdf/utils": "2.5.0"
"@embedpdf/models": "2.6.0",
"@embedpdf/utils": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-history": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/plugin-selection": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-history": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/plugin-selection": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"svelte": ">=5 <6",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-attachment": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.6.0.tgz",
"integrity": "sha512-6UZkj7jFWCruR69OPQFMqbJTgwdra4rnJSBfLA8yLxgz2zTsgt3owjfQDmlJvAQ7G1/rZM2T+EJeuozulj4NoQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -669,15 +703,15 @@
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.5.0.tgz",
"integrity": "sha512-2N5kGoamUrQqWZC5SMWIhdyBHqZN/CdcGf8GVH71FFw3AU6rmZ1AD/AkLzgqoYGIuZFE8ACckdrhtbpsZMmSDQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.6.0.tgz",
"integrity": "sha512-4JmaFD+gFaLj8Bayi6Fm5qxMoRH+JUy+L3S6xk1KM8YWjJyzsoz9C2mHSXKJ0GBMgiOkjaBuJSWqLgMe/oz7OQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -686,15 +720,15 @@
}
},
"node_modules/@embedpdf/plugin-document-manager": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.5.0.tgz",
"integrity": "sha512-I8Z/0B7R/YhtVaJFruwFO+QBLIDmQfHx9WVlrDXWZs68YiGwEbjSyizEIEqtulUJxcXfPs2Tf7oIBbdSuPG2NQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.6.0.tgz",
"integrity": "sha512-fcx0JKDboEV8eQ4r++ksDHPDuUz40oOmtHDqxYLw6cpos0fqW0p55OP+fKp6LfC/bY7ULVDrmcEQf1cD9Qho4w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -703,15 +737,15 @@
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.5.0.tgz",
"integrity": "sha512-KC9jXqwcxe76QqfxLx0tnrSdFoApTFOpT+dwrvox186uxYKSmSt1JHFWe4THB/A63hCNr8uMwyswYdFO8fWNHw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.6.0.tgz",
"integrity": "sha512-i1Xy7qUipVVLDPnnY22hm3RNMx33lvuNbCuPggql5Ws6WBLG9YhDsK+v0JVe2sDSlifa5SJwuBlMHZWPRTZyxg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -720,15 +754,15 @@
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.5.0.tgz",
"integrity": "sha512-Av9NBSE9Or1Y6cXcNWpx0bBZN3yI4vywa6kSNjhaqOrgpQDWMaTO57eApJpyHzBodqEztY+klE9YJ7MH88zm6w==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.6.0.tgz",
"integrity": "sha512-cfVoBjkIbFiRsQu/cwPEi0rrTAF7jriAGzABWawnSTKYEPFrU3LDHO7TewgBz45kHl9pSwvRexaIdTR8ECIKbQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -737,15 +771,15 @@
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.5.0.tgz",
"integrity": "sha512-QrmowLVvC5FNZdvVr2kczSDdnHHOuhf+So0VG5Ythts/OL1bIR/0OOpuyJsScTyo5boYnRkXv8yPf8htL57YKQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.6.0.tgz",
"integrity": "sha512-9bruF6M6GKVdABRTinHsZ+izf2tDQwDEcNI0CHVc5gurrz3CQfAGP2sJkv8uQrXyYTK3zV2Oq6zGknk7Hdx9mA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -754,17 +788,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.5.0.tgz",
"integrity": "sha512-DfdA+hBm9kGYYy7OuJym6azk2h2U/Geirud+tmVzFSL7+OZ3tZ3K9fqj07w66zx0msyUVlYrXzkYSU9NEmwpLA==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.6.0.tgz",
"integrity": "sha512-r8AXcXUy6NMYDaQeixScbeFfmZIvWpUUjx3gxjP4J90xfxXnuz/g/lnh4D2DBaiK4mt6crIVBpXU9IUwMIcUMQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -773,15 +807,15 @@
}
},
"node_modules/@embedpdf/plugin-print": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.5.0.tgz",
"integrity": "sha512-qejq7/0K9hh3hzop+u+Qmn7ijTqGcDhxaiXoPkyl91CZVOyAD8qMBzWnhC7vRNOB7hcYgBP81uegE3se+EIlcA==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.6.0.tgz",
"integrity": "sha512-cgWRqVtRgCCLCn1ViuZEFr+ZJ3QI61/5s9tl3T9x81rwkBN4HT582BYzyRnLBzTMYKxkQZDr1WxfS8ctdlHEUQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
@@ -790,20 +824,20 @@
}
},
"node_modules/@embedpdf/plugin-redaction": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.5.0.tgz",
"integrity": "sha512-G0cm1hLWi09gU8WV+IShq2XHkmLtEbk+EvD3dIiyJV2kbOjgwGSC2Ezt8br3DzH6R/0bF6RAbDIpFyS2Q0oMfg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.6.0.tgz",
"integrity": "sha512-DdDnmOl9K0N4dpTeUohavxQyrfollhkjT+zdfkna3Fc7F4jfl3Vg6uKoGmT71A+Vp4uTGNLt6cNCscbJW9E9kQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0",
"@embedpdf/utils": "2.5.0"
"@embedpdf/models": "2.6.0",
"@embedpdf/utils": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-annotation": "2.5.0",
"@embedpdf/plugin-history": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/plugin-selection": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-annotation": "2.6.0",
"@embedpdf/plugin-history": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/plugin-selection": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -812,15 +846,15 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.5.0.tgz",
"integrity": "sha512-nrTmg8cVMohcKYiQ/7erErsaWlyaq20OtXbVjmnPNnqz4amJLAjlPyudTJRlWWPyIiri9SF4A0ue5ICDY2sypg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.6.0.tgz",
"integrity": "sha512-Rk4QCxDOzhQrvKPt/G3G+p5ELwnKFkC5ljHMd7ND23atR9E3wm5W3+Nx3FaAYYPrpfqQ7BrbKnfQ7SkUbDxS3w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -829,15 +863,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.5.0.tgz",
"integrity": "sha512-crFsXduaxNZJmVRfgklBpO4x4i9cRxPmfFBvdIoyJ1ea6AGOCL0rQKQcfqHTFdgtPzlVUiIg6Hi2v+033jdLUg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.6.0.tgz",
"integrity": "sha512-zgF2S5cfkOxkOWrwoLQLN8scJgKBEhyhVOv/RNdeAKP6qE3h28AGRmDeMsekBDbiInlIxIHzynE5vVcTNf5EnQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -846,16 +880,16 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.5.0.tgz",
"integrity": "sha512-AdLuSgvAaukLl1uQ0FbswcAIPFaR3Jk2ZbEJpWLd9E6iQ+66Cta0Sz8d5J6ndx7VBlRYAZwoqiXF85utJxpQ5g==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.6.0.tgz",
"integrity": "sha512-BEgSy6cs9+MLCS0Z3/FYMdA4Ygt6ddYIAg28XlF20kN3tLj8BQUo5qx6adI+SlwrFFGY52VAjjK7VSBuGfn19g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -864,15 +898,15 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.5.0.tgz",
"integrity": "sha512-ycHJh05vBZ1PTSdEMgdx6K1py0oklwbwY2eXO4nD54EN9EVZgWlYC4Q+u8nyGOiNL6VmJYcqJ0HmjSWBdmGWBw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.6.0.tgz",
"integrity": "sha512-GSzJkmuK9LE7LmlTwnDl71KdD9prHlCjgFs5Tm0K8qjELOSH+oduFXusIuf654+UQveDYczpzBVUcqb4yBf1xA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -881,17 +915,17 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.5.0.tgz",
"integrity": "sha512-M3WDjahig/6KE83SZGvTaJWhqEOIzH002k2fpJVuks926UBnfgYCH8uqV7SOUQTneQDmIa0PlyFiuEXDw1Ocrw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.6.0.tgz",
"integrity": "sha512-VrW0duVxLwaquInwmuNDMz8o0tfCDwe3j81fvTUDW/s7KqnzFbxK7vEuq5TEtxWuSng2DXxyX3r1ntCm4X/NCg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0",
"@embedpdf/utils": "2.5.0"
"@embedpdf/models": "2.6.0",
"@embedpdf/utils": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -900,15 +934,15 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.5.0.tgz",
"integrity": "sha512-kG8HZMZmbpUVDxCOEyQzIiMPW+VjjebOl93V+quAH+GAI5Tkg6exPyyQ2+/DOJPCtYX4Kh2z2aeoyK2b7NRgIQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.6.0.tgz",
"integrity": "sha512-0mzPCJlw1X7jWeDg5JssU6/HCFtyOP7scEdbIaASYzofGXa2Rj8/+L+UDBrb+KTF6CR4X6fEfeNmxWmAatOAWQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -917,16 +951,16 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.5.0.tgz",
"integrity": "sha512-iWofJSXKbWrgvS2fe8v3U1+e2wjBRXD2i1DUcJKnTrqyfjZ8YzUomc5EzdG2RT7uUjtqrcu7463TZ9JHXUkASQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.6.0.tgz",
"integrity": "sha512-Sj4jCV1MNk+19zKWX4KfSl5c0YHrqVG83pEYfqexjSkSX7y7HRwAOtMBtNd3uLInPPSBnzDxj+KlJlIe8RPPJw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-render": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-render": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -935,18 +969,18 @@
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.5.0.tgz",
"integrity": "sha512-oih0GyGOJvfaXPLSEY+qfC05UUU1ZkADEbr6uCwRMmdHIXu/0ZTJnAToegfWXtfE+Sw0J5wscVkipXXIX5azlw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.6.0.tgz",
"integrity": "sha512-qyiHWljryHWQ7uzip2WDg4x28o/1QM0wh9oIyz5WlBnrDaK6bLJGsWUym5P6WfLp0Y8h6GFAslNNcgjBv6E3qw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-render": "2.5.0",
"@embedpdf/plugin-scroll": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-render": "2.6.0",
"@embedpdf/plugin-scroll": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -955,15 +989,15 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.5.0.tgz",
"integrity": "sha512-z0AXHA9Z3rZdCLje7P2NsQbxKLJ4b/l8lgzXOVn5Ow/pIPE0D2P3fn9WzImHTNI1RNrZMdkW9OH3lfkEXFTqHw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.6.0.tgz",
"integrity": "sha512-Ea7s+LivQ4ph01mVngU2tu2Ni/zulxzIyiifCpMaBMHmvjGFQjJcNNYHR90YuM8keto82KCszxdNDuAEEzT6Wg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -972,17 +1006,17 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.5.0.tgz",
"integrity": "sha512-HWJlqXOXdv/kttV+XWCCStUZAeLl66AuaO8BsnPlAPwEADLLCH4tR4XqJQoWr7/r5watKP7UeQ00FsWu0oGclw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.6.0.tgz",
"integrity": "sha512-2XUgasN2ZQm2MgpB6ls/re/SKhsREvt2D1gIcvJgXvGkene0NcpxGNIRi/+JN7W0fw4x3QtwLQtyF+/0uMgPmg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-scroll": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-scroll": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -991,9 +1025,9 @@
}
},
"node_modules/@embedpdf/utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.5.0.tgz",
"integrity": "sha512-JjYj6BRzu9oesA1JOqKPFMEWKinjvJIjziWu1j6lDXxLsE59bkShjUKbaEG+lkXRspuZRWNP++rzE2p2Ht4veg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.6.0.tgz",
"integrity": "sha512-FT6U6L3Et688urUTyISpYH05w4sG+WzoWxaI7aPU4ieh4c/vVgadUtjZj/QCC8v+DebPYRAX1gpUY7e0Y0HlTQ==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
@@ -4180,6 +4214,15 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
@@ -6452,11 +6495,23 @@
"node": ">=6"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -6469,9 +6524,18 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -6590,6 +6654,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
@@ -8839,6 +8909,22 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -10685,6 +10771,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/node-html-better-parser": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
"integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
"license": "MIT",
"dependencies": {
"html-entities": "^2.3.2"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -11240,24 +11335,6 @@
"node": ">= 14.16"
}
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pdfjs-dist": {
"version": "5.4.530",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz",
@@ -12965,6 +13042,21 @@
"integrity": "sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==",
"license": "MIT"
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
+25 -23
View File
@@ -7,28 +7,29 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.5.0",
"@embedpdf/engines": "^2.5.0",
"@embedpdf/models": "^2.5.0",
"@embedpdf/plugin-annotation": "^2.5.0",
"@embedpdf/plugin-bookmark": "^2.5.0",
"@embedpdf/plugin-export": "^2.5.0",
"@embedpdf/plugin-history": "^2.5.0",
"@embedpdf/plugin-document-manager": "^2.5.0",
"@embedpdf/plugin-interaction-manager": "^2.5.0",
"@embedpdf/plugin-pan": "^2.5.0",
"@embedpdf/plugin-print": "^2.5.0",
"@embedpdf/plugin-redaction": "^2.5.0",
"@embedpdf/plugin-render": "^2.5.0",
"@embedpdf/plugin-rotate": "^2.5.0",
"@embedpdf/plugin-scroll": "^2.5.0",
"@embedpdf/plugin-search": "^2.5.0",
"@embedpdf/plugin-selection": "^2.5.0",
"@embedpdf/plugin-spread": "^2.5.0",
"@embedpdf/plugin-thumbnail": "^2.5.0",
"@embedpdf/plugin-tiling": "^2.5.0",
"@embedpdf/plugin-viewport": "^2.5.0",
"@embedpdf/plugin-zoom": "^2.5.0",
"@embedpdf/core": "^2.6.0",
"@embedpdf/engines": "^2.6.0",
"@embedpdf/models": "^2.6.0",
"@embedpdf/plugin-annotation": "^2.6.0",
"@embedpdf/plugin-attachment": "^2.6.0",
"@embedpdf/plugin-bookmark": "^2.6.0",
"@embedpdf/plugin-export": "^2.6.0",
"@embedpdf/plugin-history": "^2.6.0",
"@embedpdf/plugin-document-manager": "^2.6.0",
"@embedpdf/plugin-interaction-manager": "^2.6.0",
"@embedpdf/plugin-pan": "^2.6.0",
"@embedpdf/plugin-print": "^2.6.0",
"@embedpdf/plugin-redaction": "^2.6.0",
"@embedpdf/plugin-render": "^2.6.0",
"@embedpdf/plugin-rotate": "^2.6.0",
"@embedpdf/plugin-scroll": "^2.6.0",
"@embedpdf/plugin-search": "^2.6.0",
"@embedpdf/plugin-selection": "^2.6.0",
"@embedpdf/plugin-spread": "^2.6.0",
"@embedpdf/plugin-thumbnail": "^2.6.0",
"@embedpdf/plugin-tiling": "^2.6.0",
"@embedpdf/plugin-viewport": "^2.6.0",
"@embedpdf/plugin-zoom": "^2.6.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -45,6 +46,7 @@
"@tailwindcss/postcss": "^4.1.13",
"@tanstack/react-virtual": "^3.13.12",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-fs": "^2.4.5",
"@tauri-apps/plugin-http": "^2.5.6",
"@tauri-apps/plugin-shell": "^2.3.4",
@@ -55,7 +57,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"@cantoo/pdf-lib": "^2.5.3",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
+138 -51
View File
@@ -17,6 +17,12 @@ confirmClose = "Confirm Close"
confirmCloseCancel = "Cancel"
confirmCloseConfirm = "Close File"
confirmCloseMessage = "Are you sure you want to close this file?"
confirmCloseDiscard = "Discard changes and close"
confirmCloseSave = "Save and close"
confirmCloseUnsaved = "This file has unsaved changes."
confirmCloseUnsavedList = "You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}"
confirmCloseSaveFailedTitle = "Save Failed"
confirmCloseSaveFailed = "Saved with errors. {{count}} file{{plural}} could not be saved."
confirmPasswordErrorMessage = "New Password and Confirm New Password must match."
custom = "Custom..."
customPosition = "Custom Position"
@@ -39,6 +45,8 @@ edit = "Edit"
editYourNewFiles = "Edit your new file(s)"
exportAndContinue = "Export & Continue"
false = "False"
fileSavedToDisk = "File saved to disk"
fileNotSavedToDisk = "Not saved to disk"
fileSelected = "Selected: {{filename}}"
filesSelected = "{{count}} files selected"
font = "Font"
@@ -829,8 +837,9 @@ label = "Username"
[admin.settings.endpoints]
description = "Control which API endpoints and endpoint groups are available."
management = "Endpoint Management"
note = "Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect."
title = "API Endpoints"
userDefaults = "User Preference Defaults"
userDefaultsDescription = "Set default values for user preferences. Users can override these in their personal settings."
[admin.settings.endpoints.groupsToRemove]
description = "Select endpoint groups to disable"
@@ -840,6 +849,14 @@ label = "Disabled Endpoint Groups"
description = "Select individual endpoints to disable"
label = "Disabled Endpoints"
[admin.settings.endpoints.defaultHideUnavailableTools]
description = "Remove disabled tools instead of showing them greyed out"
label = "Hide unavailable tools by default"
[admin.settings.endpoints.defaultHideUnavailableConversions]
description = "Remove disabled conversion options instead of showing them greyed out"
label = "Hide unavailable conversions by default"
[admin.settings.enterpriseRequired]
message = "An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference."
title = "Enterprise License Required"
@@ -1219,9 +1236,21 @@ label = "Enable Key Cleanup"
description = "Automatically rotate JWT signing keys periodically"
label = "Enable Key Rotation"
[admin.settings.security.jwt.keyRetentionDays]
description = "Number of days to retain old JWT keys for verification"
label = "Key Retention Days"
[admin.settings.security.jwt.tokenExpiryMinutes]
description = "Access token lifetime in minutes for web clients (default: 1440 = 24 hours)"
label = "Web Token Expiry (minutes)"
[admin.settings.security.jwt.desktopTokenExpiryMinutes]
description = "Access token lifetime in minutes for desktop clients. Desktop apps automatically detected via User-Agent and receive longer sessions for better UX (default: 43200 = 30 days)"
label = "Desktop Token Expiry (minutes)"
[admin.settings.security.jwt.allowedClockSkewSeconds]
description = "Tolerance for client/server time drift during token validation (default: 60 seconds)"
label = "Clock Skew Tolerance (seconds)"
[admin.settings.security.jwt.refreshGraceMinutes]
description = "Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)"
label = "Refresh Grace Period (minutes)"
[admin.settings.security.jwt.persistence]
description = "Store JWT keys persistently to survive server restarts"
@@ -1410,13 +1439,16 @@ applyChanges = "Apply Changes"
backgroundColor = "Background colour"
borderOff = "Border: Off"
borderOn = "Border: On"
changeColor = "Change Colour"
chooseColor = "Choose colour"
circle = "Circle"
clearBackground = "Remove background"
color = "Colour"
contents = "Text"
delete = "Delete"
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
drawing = "Drawing"
duplicate = "Duplicate"
editCircle = "Edit Circle"
editInk = "Edit Pen"
editLine = "Edit Line"
@@ -1446,6 +1478,7 @@ notesStamps = "Notes & Stamps"
opacity = "Opacity"
pen = "Pen"
polygon = "Polygon"
properties = "Properties"
rectangle = "Rectangle"
redo = "Redo"
saveChanges = "Save Changes"
@@ -1471,6 +1504,7 @@ title = "Annotate"
underline = "Underline"
undo = "Undo"
unsupportedType = "This annotation type is not fully supported for editing."
width = "Width"
[app]
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
@@ -3300,6 +3334,10 @@ desc = "Build multi-step workflows by chaining together PDF actions. Ideal for r
tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations"
title = "Automate"
[home.formFill]
desc = "Fill PDF form fields interactively with a visual editor"
title = "Fill Form"
[home.autoRename]
desc = "Auto renames a PDF file based on its detected header"
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
@@ -4197,6 +4235,57 @@ title = "Page Editor"
zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[viewer]
cannotPreviewFile = "Cannot Preview File"
dualPageView = "Dual Page View"
firstPage = "First Page"
lastPage = "Last Page"
nextPage = "Next Page"
onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format."
previousPage = "Previous Page"
singlePageView = "Single Page View"
unknownFile = "Unknown file"
zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[rightRail]
closeSelected = "Close Selected Files"
selectAll = "Select All"
deselectAll = "Deselect All"
selectByNumber = "Select by Page Numbers"
deleteSelected = "Delete Selected Pages"
closePdf = "Close PDF"
exportAll = "Export PDF"
downloadSelected = "Download Selected Files"
annotations = "Annotations"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
saveChanges = "Save Changes"
toggleAttachments = "Toggle Attachments"
toggleTheme = "Toggle Theme"
language = "Language"
toggleAnnotations = "Toggle Annotations Visibility"
search = "Search PDF"
panMode = "Pan Mode"
applyRedactionsFirst = "Apply redactions first"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
toggleSidebar = "Toggle Sidebar"
toggleBookmarks = "Toggle Bookmarks"
print = "Print PDF"
ruler = "Ruler / Measure"
draw = "Draw"
redact = "Redact"
exitRedaction = "Exit Redaction Mode"
save = "Save"
downloadAll = "Download All"
saveAll = "Save All"
[textAlign]
left = "Left"
center = "Center"
right = "Right"
[pageExtracter]
header = "Extract Pages"
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
@@ -4823,6 +4912,7 @@ account = "Account"
activity = "Activity"
adminSettings = "Admin Settings"
allTools = "Tools"
plugins = "Plugins"
automate = "Automate"
config = "Config"
files = "Files"
@@ -4879,6 +4969,9 @@ applyRedactions = "Apply Redactions"
applyWarning = "⚠️ Permanent application, cannot be undone and the data underneath will be deleted"
boxRedaction = "Box draw redaction"
colourPicker = "Colour Picker"
colorLabel = "Redaction Colour"
active = "Redaction Mode Active"
activate = "Activate Redaction Tool"
controlsTitle = "Manual Redaction Controls"
convertPDFToImageLabel = "Convert PDF to PDF-Image (Used to remove text behind the box)"
export = "Export"
@@ -5274,36 +5367,6 @@ title = "High Contrast"
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
title = "Invert All Colours"
[rightRail]
annotations = "Annotations"
applyRedactionsFirst = "Apply redactions first"
closePdf = "Close PDF"
closeSelected = "Close Selected Files"
deleteSelected = "Delete Selected Pages"
deselectAll = "Deselect All"
downloadAll = "Download All"
downloadSelected = "Download Selected Files"
draw = "Draw"
exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
language = "Language"
panMode = "Pan Mode"
print = "Print PDF"
redact = "Redact"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
save = "Save"
saveAll = "Save All"
saveChanges = "Save Changes"
search = "Search PDF"
selectAll = "Select All"
selectByNumber = "Select by Page Numbers"
toggleAnnotations = "Toggle Annotations Visibility"
toggleBookmarks = "Toggle Bookmarks"
toggleSidebar = "Toggle Sidebar"
toggleTheme = "Toggle Theme"
[rotate]
rotateLeft = "Rotate Anticlockwise"
rotateRight = "Rotate Clockwise"
@@ -5563,6 +5626,21 @@ title = "Policies & Privacy"
[settings.preferences]
title = "Preferences"
[settings.plugins]
author = "Author: {{author}}"
count = "Installed plugins {{count}}"
createdAt = "Created on {{date}}"
description = "Browse, install, and configure extensions."
empty = "No plugins found. Drop a plugin JAR in {{path}}."
error = "Failed to load plugins"
label = "Plugins"
loading = "Loading plugins..."
minHost = "min. v{{version}}"
noDescription = "No description"
sectionTitle = "Extensions"
title = "Plugins"
unknownAuthor = "unknown"
[settings.security]
description = "Update your password to keep your account secure."
title = "Security"
@@ -6141,11 +6219,6 @@ title = "API Documentation"
[tableExtraxt]
tags = "CSV,Table Extraction,extract,convert"
[textAlign]
center = "Center"
left = "Left"
right = "Right"
[theme]
toggle = "Toggle Theme"
@@ -6211,6 +6284,15 @@ verification = "Verification"
noSearchResults = "No tools found"
noTools = "No tools available"
[plugins]
closeViewer = "Close plugin viewer"
noDescription = "No description provided."
open = "Open UI"
refresh = "Refresh"
shortTitle = "Plugins"
title = "Installed plugins"
version = "v{{version}}"
[unlockPDFForms]
description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable."
filenamePrefix = "unlocked_forms"
@@ -6421,18 +6503,23 @@ fileManager = "File Manager"
pageEditor = "Page Editor"
viewer = "Viewer"
[viewer]
cannotPreviewFile = "Cannot Preview File"
dualPageView = "Dual Page View"
firstPage = "First Page"
lastPage = "Last Page"
nextPage = "Next Page"
onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format."
previousPage = "Previous Page"
singlePageView = "Single Page View"
unknownFile = "Unknown file"
zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[viewer.attachments]
title = "Attachments"
searchPlaceholder = "Search attachments"
noSupport = "Attachment support is unavailable for this viewer."
noDocument = "Open a PDF to view its attachments."
loading = "Loading attachments..."
empty = "No attachments in this document"
noMatch = "No attachments match your search"
[viewer.formBar]
title = "Form Fields"
unsavedBadge = "Unsaved"
unsavedDesc = "You have unsaved changes"
hasFieldsDesc = "This PDF contains fillable fields"
dismiss = "Dismiss"
apply = "Apply Changes"
download = "Download PDF"
[viewPdf]
header = "View PDF"
+17 -17
View File
@@ -264,8 +264,8 @@ preview = "Selezione posizione"
previewDisclaimer = "L'anteprima è approssimativa. Il risultato finale può variare a causa delle metriche dei font PDF."
submit = "Aggiungi numeri di pagina"
title = "Aggiungi numeri di pagina"
zeroPad = "Zeropad Width (Bates Stamping)"
zeroPadTooltip = "Zeropad (Bates Stamp) page numbers to this width (e.g., 3 ⇒ 001). Set 0 to disable."
zeroPad = "Larghezza del riempimento con zeri (Timbro Bates)"
zeroPadTooltip = "Riempire con zeri (timbro Bates) i numeri di pagina fino a questa larghezza (ad esempio, 3 ⇒ 001). Impostare a 0 per disabilitare."
[addPageNumbers.error]
failed = "Operazione di aggiunta dei numeri di pagina non riuscita"
@@ -391,13 +391,13 @@ title = "Risultati timbro"
[AddStampRequest.template]
custom = "Personalizzato"
dateHeader = "Data intestazione"
doc-info = "Document Info"
draft = "Draft Watermark"
doc-info = "Informazioni sul documento"
draft = "Filigrana di bozza"
draftWatermark = "Bozza Filigrana"
european-date = "European Date (DD/MM/YYYY)"
european-date = "Note legali (DD/MM/YYYY)"
europeanDate = "Data europea"
legal-footer = "Legal Footer"
page-numbers = "Page Numbers"
legal-footer = "Note legali"
page-numbers = "Numeri di pagina"
pageNumberFooter = "Piè di pagina con numero di pagina"
timestamp = "Timestamp"
@@ -921,23 +921,23 @@ description = "Percorso dell'eseguibile WeasyPrint per conversione da HTML a PDF
label = "Eseguibile WeasyPrint"
[admin.settings.general.customPaths.pipeline]
label = "Directory pipeline"
label = "Pipeline della directory"
[admin.settings.general.customPaths.pipeline.finishedFoldersDir]
description = "Directory di output dei PDF elaborati (lascia vuoto per default: /pipeline/finishedFolders)"
label = "Directory cartelle completate"
[admin.settings.general.customPaths.pipeline.pipelineDir]
description = "Base directory for pipeline resources (leave empty for default: /pipeline)"
label = "Pipeline Directory"
description = "Directory di base per le risorse della pipeline (lascia vuoto per il valore predefinito: /pipeline)"
label = "Pipeline della directory"
[admin.settings.general.customPaths.pipeline.watchedFoldersDir]
description = "Directory in cui la pipeline monitora i PDF in arrivo (lascia vuoto per predefinito: /pipeline/watchedFolders)"
label = "Directory cartelle monitorate"
[admin.settings.general.customPaths.pipeline.watchedFoldersDirs]
description = "Directories where pipeline monitors for incoming PDFs (one per line or comma-separated; leave empty for default: /pipeline/watchedFolders)"
label = "Watched Folders Directories"
description = "Directory in cui la pipeline monitora i PDF in arrivo (una per riga o separate da virgola; lascia vuoto per il valore predefinito: /pipeline/watchedFolders)"
label = "Cartelle e directory controllate"
[admin.settings.general.defaultLocale]
description = "La lingua predefinita per i nuovi utenti (es. en_US, es_ES)"
@@ -2285,7 +2285,7 @@ title = "Questi PDF sembrano molto diversi"
[compare.edited]
label = "PDF modificato"
placeholder = "Select the edited PDF"
placeholder = "Seleziona il PDF modificato"
[compare.error]
filesMissing = "Impossibile trovare i file selezionati. Selezionali di nuovo."
@@ -2560,8 +2560,8 @@ selectSourceFormatFirst = "Seleziona prima un formato sorgente"
settings = "Impostazioni"
single = "Singolo"
sourceFormatPlaceholder = "Formato sorgente"
strictMode = "Strict Mode"
strictModeDesc = "Error if conversion is not perfect (uses VeraPDF verification)"
strictMode = "Modalità Stretta"
strictModeDesc = "Errore se la conversione non è perfetta (utilizza la verifica VeraPDF)"
svgPdfOptions = "Opzioni da SVG a PDF"
svgVectorNote = "I file SVG vengono renderizzati come grafica vettoriale per un output nitido a qualsiasi risoluzione. Le dimensioni dell'SVG determinano le dimensioni della pagina PDF."
targetFormatPlaceholder = "Formato di destinazione"
@@ -4887,7 +4887,6 @@ failed = "Si è verificato un errore durante la redazione del PDF."
[redact.manual]
apply = "Applica"
applyChanges = "Applica modifiche"
applyRedactions = "Apply Redactions"
applyWarning = "⚠️ Applicazione permanente, non può essere annullata e i dati sottostanti verranno eliminati"
boxRedaction = "Redazione con riquadro"
colourPicker = "Selettore colore"
@@ -4915,6 +4914,7 @@ upload = "Carica"
zoom = "Zoom"
zoomIn = "Ingrandisci"
zoomOut = "Riduci"
applyRedactions = "Apply Redactions"
[redact.manual.pageRedactionNumbers]
placeholder = "(es. 1,2,8 o 4,7,12-16 o 2n-1)"
@@ -5669,7 +5669,7 @@ title = "Accedi al server"
subtitle = "Inserisci l'URL del tuo server self-hosted"
testing = "Verifica connessione..."
title = "Connetti al server"
useLast = "Last used server: {{serverUrl}}"
useLast = "Ultimo server utilizzato: {{serverUrl}}"
[setup.server.error]
configFetch = "Impossibile recuperare la configurazione del server. Controlla l'URL e riprova."
+45
View File
@@ -907,6 +907,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [
"bitflags 2.10.0",
"block2",
"libc",
"objc2",
]
@@ -3531,6 +3533,30 @@ dependencies = [
"webpki-roots 1.0.5",
]
[[package]]
name = "rfd"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
dependencies = [
"block2",
"dispatch2",
"glib-sys",
"gobject-sys",
"gtk-sys",
"js-sys",
"log",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"raw-window-handle",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.60.2",
]
[[package]]
name = "ring"
version = "0.17.14"
@@ -4229,6 +4255,7 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-deep-link",
"tauri-plugin-dialog",
"tauri-plugin-fs",
"tauri-plugin-http",
"tauri-plugin-log",
@@ -4609,6 +4636,24 @@ dependencies = [
"windows-result 0.3.4",
]
[[package]]
name = "tauri-plugin-dialog"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
dependencies = [
"log",
"raw-window-handle",
"rfd",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"tauri-plugin-fs",
"thiserror 2.0.18",
"url",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.4.5"
+1
View File
@@ -28,6 +28,7 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
tauri-plugin-log = "2.8.0"
tauri-plugin-shell = "2.3.4"
tauri-plugin-fs = "2.4.5"
tauri-plugin-dialog = "2.4.2"
tauri-plugin-http = { version = "2.5.6", features = ["dangerous-settings"] }
tauri-plugin-single-instance = { version = "2.3.7", features = ["deep-link"] }
tauri-plugin-store = "2.4.2"
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Stirling-PDF needs access to your local network to connect to self-hosted servers.</string>
</dict>
</plist>
@@ -7,6 +7,7 @@
],
"permissions": [
"core:default",
"core:window:allow-destroy",
"http:default",
{
"identifier": "http:allow-fetch",
@@ -40,6 +41,18 @@
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-remove",
"allow": [{ "path": "**" }]
},
"dialog:default",
"dialog:allow-message",
"dialog:allow-open",
"dialog:allow-save",
"opener:default",
"shell:allow-open"
]
+9
View File
@@ -33,3 +33,12 @@ pub async fn clear_opened_files() -> Result<(), String> {
Ok(())
}
// Command to atomically get and clear opened file paths
#[tauri::command]
pub async fn pop_opened_files() -> Result<Vec<String>, String> {
let mut opened_files = OPENED_FILES.lock().unwrap();
let all_files = opened_files.clone();
opened_files.clear();
add_log(format!("📂 Returning and clearing {} opened file(s)", all_files.len()));
Ok(all_files)
}
+1 -1
View File
@@ -5,7 +5,7 @@ pub mod auth;
pub mod default_app;
pub use backend::{cleanup_backend, get_backend_port, start_backend};
pub use files::{add_opened_file, clear_opened_files, get_opened_files};
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
pub use connection::{
get_connection_config,
is_first_launch,
+6 -3
View File
@@ -16,6 +16,7 @@ use commands::{
get_backend_port,
get_connection_config,
get_opened_files,
pop_opened_files,
get_refresh_token,
get_user_info,
is_first_launch,
@@ -55,6 +56,7 @@ pub fn run() {
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_http::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_deep_link::init())
@@ -139,6 +141,7 @@ pub fn run() {
start_backend,
get_backend_port,
get_opened_files,
pop_opened_files,
clear_opened_files,
get_tauri_logs,
get_connection_config,
@@ -170,9 +173,9 @@ pub fn run() {
app_handle.cleanup_before_exit();
}
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => {
add_log("🔄 Window close requested, cleaning up...".to_string());
cleanup_backend();
// Allow the window to close
add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string());
// Don't cleanup here - let JavaScript handler prevent close if needed
// Backend cleanup happens in ExitRequested when window actually closes
}
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
use tauri::DragDropEvent;
+4 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.4.6",
"version": "2.5.1",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -23,6 +23,7 @@
},
"bundle": {
"active": true,
"publisher": "Stirling PDF Inc.",
"targets": [
"deb",
"rpm",
@@ -76,7 +77,8 @@
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null
"providerShortName": null,
"infoPlist": "Info.plist"
}
},
"plugins": {
+10
View File
@@ -7,6 +7,7 @@ import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvide
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import HomePage from "@app/pages/HomePage";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import PluginPage from "@app/pages/PluginPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -43,6 +44,15 @@ export default function App() {
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="/plugins/:id"
element={
<AppProviders>
<PluginPage />
</AppProviders>
}
/>
<Route
path="*"
element={
+28 -3
View File
@@ -2,13 +2,14 @@ import { ReactNode, useEffect } from "react";
import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvider";
import { FileContextProvider } from "@app/contexts/FileContext";
import { NavigationProvider } from "@app/contexts/NavigationContext";
import { PluginRegistryProvider } from "@app/contexts/PluginRegistryContext";
import { ToolRegistryProvider } from "@app/contexts/ToolRegistryProvider";
import { FilesModalProvider } from "@app/contexts/FilesModalContext";
import { ToolWorkflowProvider } from "@app/contexts/ToolWorkflowContext";
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
import { SidebarProvider } from "@app/contexts/SidebarContext";
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext";
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions, useAppConfig } from "@app/contexts/AppConfigContext";
import { RightRailProvider } from "@app/contexts/RightRailContext";
import { ViewerProvider } from "@app/contexts/ViewerContext";
import { SignatureProvider } from "@app/contexts/SignatureContext";
@@ -23,6 +24,7 @@ import { useAppInitialization } from "@app/hooks/useAppInitialization";
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
import { RedactionProvider } from "@app/contexts/RedactionContext";
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
@@ -69,6 +71,24 @@ export interface AppProvidersProps {
appConfigProviderProps?: Partial<AppConfigProviderOverrides>;
}
// Component to sync server defaults to preferences when AppConfig loads
function ServerDefaultsSync() {
const { config } = useAppConfig();
const { updateServerDefaults } = usePreferences();
useEffect(() => {
if (config) {
const serverDefaults = {
hideUnavailableTools: config.defaultHideUnavailableTools ?? false,
hideUnavailableConversions: config.defaultHideUnavailableConversions ?? false,
};
updateServerDefaults(serverDefaults);
}
}, [config, updateServerDefaults]);
return null;
}
/**
* Core application providers
* Contains all providers needed for the core
@@ -85,10 +105,12 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
>
<ScarfTrackingInitializer />
<AppConfigLoader />
<ServerDefaultsSync />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<PluginRegistryProvider>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
@@ -98,6 +120,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<PageEditorProvider>
<SignatureProvider>
<RedactionProvider>
<FormFillProvider>
<AnnotationProvider>
<RightRailProvider>
<TourOrchestrationProvider>
@@ -107,6 +130,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</TourOrchestrationProvider>
</RightRailProvider>
</AnnotationProvider>
</FormFillProvider>
</RedactionProvider>
</SignatureProvider>
</PageEditorProvider>
@@ -117,6 +141,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</PluginRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
@@ -0,0 +1,59 @@
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker } from '@mantine/core';
import { useState } from 'react';
interface ColorControlProps {
value: string;
onChange: (color: string) => void;
label: string;
disabled?: boolean;
}
export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) {
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal>
<Popover.Target>
<Tooltip label={label}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<ColorSwatch color={value} size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<MantineColorPicker
format="hex"
value={value}
onChange={onChange}
swatches={[
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
]}
swatchesPerRow={5}
size="sm"
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,60 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import OpacityIcon from '@mui/icons-material/Opacity';
interface OpacityControlProps {
value: number; // 0-100
onChange: (value: number) => void;
disabled?: boolean;
}
export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.opacity', 'Opacity')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<OpacityIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={value}
onChange={onChange}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,211 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import TuneIcon from '@mui/icons-material/Tune';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
type AnnotationType = 'text' | 'note' | 'shape';
interface PropertiesPopoverProps {
annotationType: AnnotationType;
annotation: any;
onUpdate: (patch: Record<string, any>) => void;
disabled?: boolean;
}
export function PropertiesPopover({
annotationType,
annotation,
onUpdate,
disabled = false,
}: PropertiesPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const obj = annotation?.object;
// Get current values
const fontSize = obj?.fontSize ?? 14;
const textAlign = obj?.textAlign;
const currentAlign =
typeof textAlign === 'number'
? textAlign === 1
? 'center'
: textAlign === 2
? 'right'
: 'left'
: textAlign === 'center'
? 'center'
: textAlign === 'right'
? 'right'
: 'left';
// For shapes
const opacity = Math.round((obj?.opacity ?? 1) * 100);
const strokeWidth = obj?.borderWidth ?? obj?.strokeWidth ?? 2;
const borderVisible = strokeWidth > 0;
const renderTextNoteControls = () => (
<Stack gap="md" style={{ minWidth: 280 }}>
{/* Font Size */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.fontSize', 'Font size')}
</Text>
<Slider
value={fontSize}
onChange={(val) => onUpdate({ fontSize: val })}
min={8}
max={32}
label={(val) => `${val}pt`}
/>
</div>
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={Math.round((obj?.opacity ?? 1) * 100)}
onChange={(val) => onUpdate({ opacity: val / 100 })}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</div>
{/* Text Alignment */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.textAlignment', 'Text Alignment')}
</Text>
<Group gap="xs">
<ActionIcon
variant={currentAlign === 'left' ? 'filled' : 'default'}
onClick={() => onUpdate({ textAlign: 0 })}
size="md"
>
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'center' ? 'filled' : 'default'}
onClick={() => onUpdate({ textAlign: 1 })}
size="md"
>
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'right' ? 'filled' : 'default'}
onClick={() => onUpdate({ textAlign: 2 })}
size="md"
>
<FormatAlignRightIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Group>
</div>
</Stack>
);
const renderShapeControls = () => (
<Stack gap="md" style={{ minWidth: 250 }}>
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={opacity}
onChange={(val) => {
const newOpacity = val / 100;
onUpdate({
opacity: newOpacity,
strokeOpacity: newOpacity,
fillOpacity: newOpacity,
});
}}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</div>
{/* Stroke Width */}
<div>
<Group gap="xs" align="flex-end">
<div style={{ flex: 1 }}>
<Text size="xs" fw={500} mb={4}>
{t('annotation.strokeWidth', 'Stroke')}
</Text>
<Slider
value={strokeWidth}
onChange={(val) => {
onUpdate({
borderWidth: val,
strokeWidth: val,
lineWidth: val,
});
}}
min={0}
max={12}
label={(val) => `${val}pt`}
/>
</div>
<Button
size="xs"
variant={!borderVisible ? 'filled' : 'light'}
onClick={() => {
const newValue = borderVisible ? 0 : 1;
onUpdate({
borderWidth: newValue,
strokeWidth: newValue,
lineWidth: newValue,
});
}}
>
{borderVisible
? t('annotation.borderOn', 'Border: On')
: t('annotation.borderOff', 'Border: Off')}
</Button>
</Group>
</div>
</Stack>
);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.properties', 'Properties')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<TuneIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
{(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()}
{annotationType === 'shape' && renderShapeControls()}
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,62 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import LineWeightIcon from '@mui/icons-material/LineWeight';
interface WidthControlProps {
value: number;
onChange: (value: number) => void;
min: number; // 1 for ink, 0 for shapes
max: number; // 12 for ink, 20 for highlighter
disabled?: boolean;
}
export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.width', 'Width')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<LineWeightIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.width', 'Width')}
</Text>
<Slider
value={value}
onChange={onChange}
min={min}
max={max}
label={(val) => `${val}pt`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -8,6 +8,7 @@ import { useLogoAssets } from '@app/hooks/useLogoAssets';
import styles from '@app/components/fileEditor/FileEditor.module.css';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
interface AddFileCardProps {
onFileSelect: (files: File[]) => void;
@@ -33,9 +34,15 @@ const AddFileCard = ({
openFilesModal();
};
const handleNativeUploadClick = (e: React.MouseEvent) => {
const handleNativeUploadClick = async (e: React.MouseEvent) => {
e.stopPropagation();
fileInputRef.current?.click();
const files = await openFilesFromDisk({
multiple,
onFallbackOpen: () => fileInputRef.current?.click()
});
if (files.length > 0) {
onFileSelect(files);
}
};
const handleOpenFilesModal = (e: React.MouseEvent) => {
@@ -179,4 +186,4 @@ const AddFileCard = ({
);
};
export default AddFileCard;
export default AddFileCard;
@@ -12,7 +12,7 @@ import AddFileCard from '@app/components/fileEditor/AddFileCard';
import FilePickerModal from '@app/components/shared/FilePickerModal';
import { FileId, StirlingFile } from '@app/types/fileContext';
import { alert } from '@app/components/toast';
import { downloadBlob } from '@app/utils/downloadUtils';
import { downloadFile } from '@app/services/downloadService';
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
@@ -278,13 +278,29 @@ const FileEditor = ({
}
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
const handleDownloadFile = useCallback((fileId: FileId) => {
const handleDownloadFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
const file = record ? selectors.getFile(record.id) : null;
console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty });
if (record && file) {
downloadBlob(file, file.name);
const result = await downloadFile({
data: file,
filename: file.name,
localPath: record.localFilePath
});
console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath });
// Mark file as clean after successful save to disk
if (result.savedPath) {
console.log('[FileEditor] Marking file as clean:', fileId);
fileActions.updateStirlingFileStub(fileId, {
localFilePath: record.localFilePath ?? result.savedPath,
isDirty: false
});
} else {
console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty });
}
}
}, [activeStirlingFileStubs, selectors, _setStatus]);
}, [activeStirlingFileStubs, selectors, fileActions]);
const handleUnzipFile = useCallback(async (fileId: FileId) => {
const record = activeStirlingFileStubs.find(r => r.id === fileId);
@@ -0,0 +1,13 @@
import React from 'react';
import { StirlingFileStub } from '@app/types/fileContext';
import { PrivateContent } from '@app/components/shared/PrivateContent';
interface FileEditorFileNameProps {
file: StirlingFileStub;
}
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
<PrivateContent>{file.name}</PrivateContent>
);
export default FileEditorFileName;
@@ -23,6 +23,8 @@ import { FileId } from '@app/types/file';
import { formatFileSize } from '@app/utils/fileUtils';
import ToolChain from '@app/components/shared/ToolChain';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
import { downloadFile } from '@app/services/downloadService';
import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName';
import { PrivateContent } from '@app/components/shared/PrivateContent';
@@ -69,7 +71,7 @@ const FileEditorThumbnail = ({
actions: fileActions,
openEncryptedUnlockPrompt,
} = useFileContext();
const { state } = useFileState();
const { state, selectors } = useFileState();
const hasError = state.ui.errorFileIds.includes(file.id);
// ---- Drag state ----
@@ -191,6 +193,37 @@ const FileEditorThumbnail = ({
setShowCloseModal(false);
}, [file.id, file.name, onCloseFile]);
const handleSaveAndClose = useCallback(async () => {
const fileToSave = selectors.getFile(file.id);
if (fileToSave) {
try {
const result = await downloadFile({
data: fileToSave,
filename: file.name,
localPath: file.localFilePath
});
if (!result.cancelled && result.savedPath) {
fileActions.updateStirlingFileStub(file.id, {
localFilePath: file.localFilePath ?? result.savedPath,
isDirty: false
});
} else if (result.cancelled) {
setShowCloseModal(false);
return;
}
} catch (error) {
console.error(`Failed to save ${file.name}:`, error);
alert({ alertType: 'error', title: 'Save failed', body: `Could not save ${file.name}`, expandable: true });
setShowCloseModal(false);
return;
}
}
// Then close
onCloseFile(file.id);
alert({ alertType: 'success', title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
setShowCloseModal(false);
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
const handleCancelClose = useCallback(() => {
setShowCloseModal(false);
}, []);
@@ -213,7 +246,6 @@ const FileEditorThumbnail = ({
onClick: (e) => {
e.stopPropagation();
onDownloadFile(file.id);
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
},
},
{
@@ -365,8 +397,8 @@ const FileEditorThumbnail = ({
marginTop: '0.5rem',
marginBottom: '0.5rem',
}}>
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
<PrivateContent>{file.name}</PrivateContent>
<Text size="lg" fw={700} className={styles.title} lineClamp={2} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem' }}>
<FileEditorFileName file={file} />
</Text>
<Text
size="sm"
@@ -469,18 +501,40 @@ const FileEditorThumbnail = ({
size="auto"
>
<Stack gap="md">
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseConfirm', 'Close File')}
</Button>
</Group>
{file.isDirty && file.localFilePath ? (
<>
<Text size="md">{t('confirmCloseUnsaved', 'This file has unsaved changes.')}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseDiscard', 'Discard changes and close')}
</Button>
<Button variant="filled" onClick={handleSaveAndClose}>
{t('confirmCloseSave', 'Save and close')}
</Button>
</Group>
</>
) : (
<>
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
<Text size="sm" c="dimmed" fw={500}>
{file.name}
</Text>
<Group justify="flex-end" gap="sm">
<Button variant="light" onClick={handleCancelClose}>
{t('confirmCloseCancel', 'Cancel')}
</Button>
<Button variant="filled" color="red" onClick={handleConfirmClose}>
{t('confirmCloseConfirm', 'Close File')}
</Button>
</Group>
</>
)}
</Stack>
</Modal>
</div>
@@ -12,8 +12,14 @@ const FileActions: React.FC = () => {
const terminology = useFileActionTerminology();
const icons = useFileActionIcons();
const DownloadIcon = icons.download;
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
useFileManagerContext();
const {
recentFiles,
selectedFileIds,
filteredFiles,
onSelectAll,
onDeleteSelected,
onDownloadSelected
} = useFileManagerContext();
const handleSelectAll = () => {
onSelectAll();
@@ -31,6 +37,7 @@ const FileActions: React.FC = () => {
}
};
// Only show actions if there are files
if (recentFiles.length === 0) {
return null;
@@ -46,7 +46,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
const [isHovered, setIsHovered] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { t } = useTranslation();
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
const { removeFiles } = useFileManagement();
// Check if this is a ZIP file
@@ -269,6 +269,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
>
{t('fileManager.delete', 'Delete')}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
@@ -3,11 +3,12 @@ import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useFileHandler } from '@app/hooks/useFileHandler';
import { useFileState } from '@app/contexts/FileContext';
import { useFileState, useFileActions } from '@app/contexts/FileContext';
import { useNavigationState, useNavigationActions, useNavigationGuard } from '@app/contexts/NavigationContext';
import { isBaseWorkbench } from '@app/types/workbench';
import { useViewer } from '@app/contexts/ViewerContext';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { FileId } from '@app/types/file';
import styles from '@app/components/layout/Workbench.module.css';
import TopControls from '@app/components/shared/TopControls';
@@ -26,6 +27,7 @@ export default function Workbench() {
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
const { actions: fileActions } = useFileActions();
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
@@ -61,13 +63,17 @@ export default function Workbench() {
const handleFileSelect = useCallback((index: number) => {
// Don't do anything if selecting the same file
if (index === activeFileIndex) return;
// requestNavigation handles the unsaved changes check internally
requestNavigation(() => {
setActiveFileIndex(index);
});
}, [activeFileIndex, requestNavigation, setActiveFileIndex]);
const handleFileRemove = useCallback(async (fileId: FileId) => {
await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context
}, [fileActions]);
const handlePreviewClose = () => {
setPreviewFile(null);
const previousMode = sessionStorage.getItem('previousMode');
@@ -201,6 +207,7 @@ export default function Workbench() {
})}
currentFileIndex={activeFileIndex}
onFileSelect={handleFileSelect}
onFileRemove={handleFileRemove}
/>
)}
@@ -14,6 +14,7 @@ import { FileId } from '@app/types/file';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { downloadFile } from '@app/services/downloadService';
interface FileItem {
id: FileId;
@@ -79,13 +80,7 @@ const FileThumbnail = ({
// Fallback: attempt to download using the File object if provided
const maybeFile = (file as unknown as { file?: File }).file;
if (maybeFile instanceof File) {
const link = document.createElement('a');
link.href = URL.createObjectURL(maybeFile);
link.download = maybeFile.name || file.name || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || 'download' });
return;
}
@@ -295,6 +295,21 @@ export const usePageEditorExport = ({
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
}
if (sourceFileIds.length === 1 && newStirlingFiles.length === 1) {
const sourceStub = selectors.getStirlingFileStub(sourceFileIds[0]);
if (sourceStub?.localFilePath) {
actions.updateStirlingFileStub(newStirlingFiles[0].fileId, {
localFilePath: sourceStub.localFilePath,
isDirty: true
});
}
}
// Remove source files from context
if (sourceFileIds.length > 0) {
await actions.removeFiles(sourceFileIds, true);
}
setHasUnsavedChanges(false);
setSplitPositions(new Set());
setExportLoading(false);
@@ -1,15 +1,28 @@
import React from 'react';
import { Menu, Loader, Group, Text } from '@mantine/core';
import VisibilityIcon from '@mui/icons-material/Visibility';
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from '@mantine/core';
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import CloseIcon from '@mui/icons-material/Close';
import FitText from '@app/components/shared/FitText';
import { PrivateContent } from '@app/components/shared/PrivateContent';
import { FileId } from '@app/types/file';
// Truncate text from the center: "very-long-filename.pdf" -> "very-lo...ame.pdf"
function truncateCenter(text: string, maxLength: number = 25): string {
if (text.length <= maxLength) return text;
const ellipsis = '...';
const charsToShow = maxLength - ellipsis.length;
const frontChars = Math.ceil(charsToShow / 2);
const backChars = Math.floor(charsToShow / 2);
return text.substring(0, frontChars) + ellipsis + text.substring(text.length - backChars);
}
interface FileDropdownMenuProps {
displayName: string;
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>;
currentFileIndex: number;
onFileSelect?: (index: number) => void;
onFileRemove?: (fileId: FileId) => void;
switchingTo?: string | null;
viewOptionStyle: React.CSSProperties;
pillRef?: React.RefObject<HTMLDivElement>;
@@ -20,22 +33,27 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
activeFiles,
currentFileIndex,
onFileSelect,
onFileRemove,
switchingTo,
viewOptionStyle,
}) => {
return (
<Menu trigger="click" position="bottom" width="30rem">
<Menu.Target>
<div style={{...viewOptionStyle, cursor: 'pointer'}}>
<div style={{...viewOptionStyle, cursor: 'pointer', maxWidth: '100%'}}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
<InsertDriveFileIcon fontSize="small" style={{ flexShrink: 0 }} />
)}
<PrivateContent>
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} />
<FitText
text={truncateCenter(displayName, 30)}
minimumFontScale={0.6}
style={{ maxWidth: '12rem', display: 'inline-block' }}
/>
</PrivateContent>
<KeyboardArrowDownIcon fontSize="small" />
<KeyboardArrowDownIcon fontSize="small" style={{ flexShrink: 0 }} />
</div>
</Menu.Target>
<Menu.Dropdown style={{
@@ -65,14 +83,36 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<PrivateContent>
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
<FitText
text={truncateCenter(itemName, 50)}
minimumFontScale={0.7}
style={{ display: 'block', width: '100%' }}
/>
</PrivateContent>
</div>
{file.versionNumber && file.versionNumber > 1 && (
<Text size="xs" c="dimmed">
v{file.versionNumber}
</Text>
)}
<Group gap="xs" style={{ flexShrink: 0 }}>
{file.versionNumber && file.versionNumber > 1 && (
<Text size="xs" c="dimmed">
v{file.versionNumber}
</Text>
)}
{onFileRemove && (
<Tooltip label="Close file" withArrow>
<ActionIcon
size="xs"
variant="subtle"
color="red"
onClick={(e) => {
e.stopPropagation();
onFileRemove(file.fileId as FileId);
}}
style={{ flexShrink: 0 }}
>
<CloseIcon style={{ fontSize: 14 }} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
</Menu.Item>
);
@@ -14,6 +14,7 @@ import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
import { useAppConfig } from '@app/contexts/AppConfigContext';
import { useIsMobile } from '@app/hooks/useIsMobile';
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
const LandingPage = () => {
const { addFiles } = useFileHandler();
@@ -41,8 +42,14 @@ const LandingPage = () => {
openFilesModal();
};
const handleNativeUploadClick = () => {
fileInputRef.current?.click();
const handleNativeUploadClick = async () => {
const files = await openFilesFromDisk({
multiple: true,
onFallbackOpen: () => fileInputRef.current?.click()
});
if (files.length > 0) {
await addFiles(files);
}
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -52,12 +52,15 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
}) => {
const { t } = useTranslation();
const labelText = option.label;
const comingSoonText = t('comingSoon', 'Coming soon');
const label = disabled ? (
<Tooltip content={t('comingSoon', 'Coming soon')} position="left" arrow>
<p>{option.label}</p>
<Tooltip content={comingSoonText} position="left" arrow>
<p>{labelText}</p>
</Tooltip>
) : (
<p>{option.label}</p>
<p>{labelText}</p>
);
return (
@@ -157,12 +160,27 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
compact = false,
tooltip
}) => {
const { i18n } = useTranslation();
const { i18n, ready } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
// Trigger animation when dropdown opens
useEffect(() => {
if (opened) {
setAnimationTriggered(false);
// Small delay to ensure DOM is ready
setTimeout(() => setAnimationTriggered(true), 20);
}
}, [opened]);
// Don't render until i18n is ready to prevent race condition
// during SAML auth where components render before i18n initializes
if (!ready || !i18n.language) {
return null;
}
// Get the filtered list of supported languages from i18n
// This respects server config (ui.languages) applied by AppConfigLoader
const allowedLanguages = (i18n.options.supportedLngs as string[] || [])
@@ -176,12 +194,6 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
label: name,
}));
// Hide the language selector if there's only one language option
// (no point showing a selector when there's nothing to select)
if (languageOptions.length <= 1) {
return null;
}
// Calculate dropdown width and grid columns based on number of languages
// 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols
const dropdownWidth = languageOptions.length <= 4 ? 300
@@ -225,16 +237,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB'];
supportedLanguages['en-GB'] ||
'English'; // Fallback if supportedLanguages lookup fails
// Trigger animation when dropdown opens
useEffect(() => {
if (opened) {
setAnimationTriggered(false);
// Small delay to ensure DOM is ready
setTimeout(() => setAnimationTriggered(true), 20);
}
}, [opened]);
// Hide the language selector if there's only one language option
// (no point showing a selector when there's nothing to select)
if (languageOptions.length <= 1) {
return null;
}
return (
<>
@@ -127,7 +127,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
/>
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
<PrivateContent>
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
<FitText text={itemName} minimumFontScale={0.7} />
</PrivateContent>
</div>
{file.versionNumber && file.versionNumber > 1 && (
@@ -0,0 +1,91 @@
import { useMemo } from "react";
import { ActionIcon } from "@mantine/core";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { PluginInfo } from "@app/contexts/PluginRegistryContext";
interface PluginViewerOverlayProps {
plugin: PluginInfo;
onClose: () => void;
}
export default function PluginViewerOverlay({
plugin,
onClose,
}: PluginViewerOverlayProps) {
const { t } = useTranslation();
const iframeSrc = useMemo(() => plugin.frontendUrl ?? "", [plugin.frontendUrl]);
if (!iframeSrc) {
return null;
}
const overlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
background: "rgba(6, 8, 12, 0.92)",
padding: "1.5rem",
display: "flex",
flexDirection: "column",
zIndex: 5000,
};
const headerStyle: React.CSSProperties = {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
color: "var(--text-on-dark, white)",
};
const titleStyle: React.CSSProperties = {
fontWeight: 600,
fontSize: "1.25rem",
};
const subtitleStyle: React.CSSProperties = {
opacity: 0.85,
fontSize: "0.9rem",
};
const iframeStyle: React.CSSProperties = {
flex: 1,
width: "100%",
border: "none",
borderRadius: "0.75rem",
background: "#05070a",
};
return createPortal(
<div style={overlayStyle}>
<div style={headerStyle}>
<div>
<div style={titleStyle}>{plugin.name}</div>
<div style={subtitleStyle}>
{plugin.description || t("plugins.noDescription", "No description provided.")}
</div>
</div>
<ActionIcon
variant="light"
size="lg"
onClick={onClose}
aria-label={t("plugins.closeViewer", "Close plugin viewer")}
>
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
</ActionIcon>
</div>
<iframe
src={iframeSrc}
title={plugin.name}
style={iframeStyle}
allowFullScreen
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
/>
</div>,
document.body
);
}
@@ -1,4 +1,4 @@
import React, { useState, useRef, forwardRef, useEffect } from "react";
import React, { useState, useRef, forwardRef, useEffect, useMemo } from "react";
import { Stack, Divider, Menu, Indicator } from "@mantine/core";
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
@@ -34,7 +34,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
const location = useLocation();
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool, toolAvailability } = useToolWorkflow();
const { hasUnsavedChanges } = useNavigationState();
const { actions: navigationActions } = useNavigationActions();
const { getToolNavigation } = useSidebarNavigation();
@@ -119,14 +119,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
);
};
const mainButtons: ButtonConfig[] = [
const mainButtons: ButtonConfig[] = useMemo(() => [
{
id: 'read',
name: t("quickAccess.reader", "Reader"),
icon: <LocalIcon icon="menu-book-rounded" width="1.25rem" height="1.25rem" />,
size: 'md',
size: 'md' as const,
isRound: false,
type: 'navigation',
type: 'navigation' as const,
onClick: () => {
setActiveButton('read');
handleReaderToggle();
@@ -136,9 +136,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
id: 'automate',
name: t("quickAccess.automate", "Automate"),
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
size: 'md',
size: 'md' as const,
isRound: false,
type: 'navigation',
type: 'navigation' as const,
onClick: () => {
setActiveButton('automate');
// If already on automate tool, reset it directly
@@ -149,7 +149,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
}
}
},
];
].filter(button => {
// Filter out buttons for disabled tools
// 'read' is always available (viewer mode)
if (button.id === 'read') return true;
// Check if tool is actually available (not just present in registry)
const availability = toolAvailability[button.id as keyof typeof toolAvailability];
return availability?.available !== false;
}), [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability]);
const middleButtons: ButtonConfig[] = [
{
@@ -3,7 +3,7 @@ import { ActionIcon, Divider } from '@mantine/core';
import '@app/components/shared/rightRail/RightRail.css';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { useRightRail } from '@app/contexts/RightRailContext';
import { useFileState, useFileSelection } from '@app/contexts/FileContext';
import { useFileState, useFileSelection, useFileActions } from '@app/contexts/FileContext';
import { useNavigationState } from '@app/contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
@@ -22,6 +22,7 @@ import LightModeIcon from '@mui/icons-material/LightMode';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
import { downloadFile } from '@app/services/downloadService';
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
@@ -59,6 +60,7 @@ export default function RightRail() {
const { selectors } = useFileState();
const { selectedFiles, selectedFileIds } = useFileSelection();
const { actions: fileActions } = useFileActions();
const { signaturesApplied } = useSignature();
const activeFiles = selectors.getFiles();
@@ -141,7 +143,7 @@ export default function RightRail() {
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
return;
}
viewerContext?.exportActions?.download();
viewerContext?.exportActions?.download?.();
return;
}
@@ -150,34 +152,57 @@ export default function RightRail() {
return;
}
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => {
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
});
const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles;
const stubsToExport = selectedFiles.length > 0
? selectors.getSelectedStirlingFileStubs()
: selectors.getStirlingFileStubs();
if (filesToExport.length > 0) {
for (let i = 0; i < filesToExport.length; i++) {
const file = filesToExport[i];
const stub = stubsToExport[i];
console.log('[RightRail] Exporting file:', { fileName: file.name, stubId: stub?.id, localFilePath: stub?.localFilePath, isDirty: stub?.isDirty });
const result = await downloadFile({
data: file,
filename: file.name,
localPath: stub?.localFilePath
});
console.log('[RightRail] Export complete, checking dirty state:', { localFilePath: stub?.localFilePath, isDirty: stub?.isDirty, savedPath: result.savedPath });
// Mark file as clean after successful save to disk
if (stub && result.savedPath) {
console.log('[RightRail] Marking file as clean:', stub.id);
fileActions.updateStirlingFileStub(stub.id, {
localFilePath: stub.localFilePath ?? result.savedPath,
isDirty: false
});
} else {
console.log('[RightRail] Skipping clean mark:', { savedPath: result.savedPath, isDirty: stub?.isDirty });
}
}
}
}, [
currentView,
selectedFiles,
activeFiles,
pageEditorFunctions,
viewerContext,
signaturesApplied
signaturesApplied,
selectors,
fileActions,
]);
const downloadTooltip = useMemo(() => {
if (currentView === 'pageEditor') {
return t('rightRail.exportAll', 'Export PDF');
}
if (currentView === 'viewer') {
return terminology.download;
}
if (selectedCount > 0) {
return terminology.downloadSelected;
}
return terminology.downloadAll;
}, [currentView, selectedCount, t]);
}, [currentView, selectedCount, t, terminology]);
return (
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
@@ -24,9 +24,8 @@ const ToolChain: React.FC<ToolChainProps> = ({
size = 'xs',
color = 'var(--mantine-color-blue-7)'
}) => {
if (!toolChain || toolChain.length === 0) return null;
const { t } = useTranslation();
if (!toolChain || toolChain.length === 0) return null;
const toolIds = toolChain.map(tool => tool.toolId);
@@ -11,6 +11,7 @@ import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDro
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
import { usePageEditorDropdownState, PageEditorDropdownState } from '@app/components/pageEditor/hooks/usePageEditorDropdownState';
import { FileId } from '@app/types/file';
const viewOptionStyle: React.CSSProperties = {
@@ -29,6 +30,7 @@ const createViewOptions = (
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
currentFileIndex: number,
onFileSelect?: (index: number) => void,
onFileRemove?: (fileId: FileId) => void,
pageEditorState?: PageEditorDropdownState,
customViews?: CustomWorkbenchViewInstance[]
) => {
@@ -37,8 +39,7 @@ const createViewOptions = (
const isInViewer = currentView === 'viewer';
const fileName = currentFile?.name || '';
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
const hasMultipleFiles = activeFiles.length > 1;
const showViewerDropdown = isInViewer && hasMultipleFiles;
const showViewerDropdown = isInViewer;
const viewerOption = {
label: showViewerDropdown ? (
@@ -47,6 +48,7 @@ const createViewOptions = (
activeFiles={activeFiles}
currentFileIndex={currentFileIndex}
onFileSelect={onFileSelect}
onFileRemove={onFileRemove}
switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle}
/>
@@ -132,6 +134,7 @@ interface TopControlsProps {
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
currentFileIndex?: number;
onFileSelect?: (index: number) => void;
onFileRemove?: (fileId: FileId) => void;
}
const TopControls = ({
@@ -141,6 +144,7 @@ const TopControls = ({
activeFiles = [],
currentFileIndex = 0,
onFileSelect,
onFileRemove,
}: TopControlsProps) => {
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
@@ -176,9 +180,10 @@ const TopControls = ({
activeFiles,
currentFileIndex,
onFileSelect,
onFileRemove,
pageEditorState,
customViews
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState, customViews]);
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, onFileRemove, pageEditorState, customViews]);
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { NavKey } from '@app/components/shared/config/types';
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
import PluginSection from '@app/components/shared/config/configSections/PluginSection';
export interface ConfigNavItem {
key: NavKey;
@@ -53,6 +54,17 @@ export const useConfigNavSections = (
},
],
},
{
title: t('settings.plugins.sectionTitle', 'Extensions'),
items: [
{
key: 'plugins',
label: t('settings.plugins.label', 'Plugins'),
icon: 'extension-rounded',
component: <PluginSection />
},
],
},
];
return sections;
@@ -0,0 +1,146 @@
import React, { useEffect, useState } from "react";
import { Paper, Text, Group, Stack, Badge, Divider, Avatar, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { usePluginRegistry } from "@app/contexts/PluginRegistryContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import apiClient from "@app/services/apiClient";
const PluginSection: React.FC = () => {
const { t } = useTranslation();
const { plugins, loading, error } = usePluginRegistry();
const { config } = useAppConfig();
const pluginPath =
config?.pluginsPath ?? (config?.basePath ? `${config.basePath}/customFiles/plugins/` : "customFiles/plugins/");
const [iconStatus, setIconStatus] = useState<Record<string, boolean>>({});
useEffect(() => {
let active = true;
const toCheck = plugins.filter((plugin) => plugin.iconUrl && iconStatus[plugin.id] === undefined);
toCheck.forEach((plugin) => {
const iconUrl = plugin.iconUrl!;
apiClient
.get(iconUrl, { responseType: "blob", suppressErrorToast: true })
.then(() => {
if (!active) return;
setIconStatus((prev) => ({ ...prev, [plugin.id]: true }));
})
.catch(() => {
if (!active) return;
setIconStatus((prev) => ({ ...prev, [plugin.id]: false }));
});
});
return () => {
active = false;
};
}, [plugins, iconStatus]);
const renderIcon = (plugin: ReturnType<typeof usePluginRegistry> extends { plugins: (infer T)[] } ? T : never) => {
const isValid = iconStatus[plugin.id];
if (plugin.iconUrl && isValid) {
return <Avatar radius="md" w="36px" h="36px" src={plugin.iconUrl} alt="Plugin Icon" />;
}
return <LocalIcon icon="extension-outline" width="1.5rem" height="1.5rem" />;
};
return (
<Stack gap="md">
<Paper radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
<Group justify="space-between">
<Text size="lg" fw={600}>
{t("settings.plugins.title", "Plugins")}
</Text>
<Badge variant="outline" color="gray">
{t("settings.plugins.count", "Installed plugins {{count}}", { count: plugins.length })}
</Badge>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t("settings.plugins.description", "Browse, install, and configure extensions.")}
</Text>
</Paper>
{loading && (
<Text size="sm" c="dimmed">
{t("settings.plugins.loading", "Loading plugins...")}
</Text>
)}
{error && plugins.length > 0 && (
<Text size="sm" c="red">
{t("settings.plugins.error", "Failed to load plugins")}
</Text>
)}
{!loading && plugins.length === 0 && (
<Text size="sm" c="dimmed">
{t("settings.plugins.empty", "No plugins found. Drop a plugin JAR in {{path}}.", { path: pluginPath })}
</Text>
)}
{plugins.map((plugin) => (
<Paper key={plugin.id} radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
<Stack gap="sm">
<Group align="center" gap="sm">
{renderIcon(plugin)}
<Stack gap="0">
<Group gap="xs">
{plugin.frontendLabel && (
<Badge color="teal" variant="light">
{plugin.frontendLabel}
</Badge>
)}
{plugin.minHostVersion && (
<Badge color="blue" variant="light">
{t("settings.plugins.minHost", { defaultValue: "min. v{{version}}", version: plugin.minHostVersion })}
</Badge>
)}
{plugin.version && (
<Badge variant="outline" color="gray">
{t("plugins.version", { defaultValue: "v{{version}}", version: plugin.version })}
</Badge>
)}
</Group>
<Text fw={600} c={plugin.hasFrontend ? "var(--mantine-color-blue-3)" : undefined}>
{plugin.name}
</Text>
</Stack>
</Group>
<Text size="sm" c="dimmed">
{plugin.description || t("settings.plugins.noDescription", "No description")}
</Text>
{plugin.backendEndpoints.length > 0 && (
<Group gap="xs">
{plugin.backendEndpoints.map((endpoint) => (
<Tooltip key={endpoint} label={endpoint} position="bottom" withArrow>
<Badge variant="outline" color="cyan" style={{ fontSize: "0.7rem", letterSpacing: 0.4 }}>
{endpoint.replace(/^\//, "").toUpperCase()}
</Badge>
</Tooltip>
))}
</Group>
)}
<Divider />
<Group gap="xs" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("settings.plugins.author", "Author: {{author}}", {
author: plugin.author || t("settings.plugins.unknownAuthor", "unknown"),
})}
</Text>
</Group>
{plugin.jarCreatedAt && (
<Text size="xs" c="dimmed">
{t("settings.plugins.createdAt", "Created on {{date}}", {
date: new Date(plugin.jarCreatedAt).toLocaleString(),
})}
</Text>
)}
</Stack>
</Paper>
))}
</Stack>
);
};
export default PluginSection;
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
'adminAudit',
'adminUsage',
'adminEndpoints',
'plugins',
] as const;
// Derive the type from the array
@@ -1,5 +1,5 @@
import React, { useMemo, useRef } from "react";
import { Box, Stack } from "@mantine/core";
import React, { useEffect, useMemo, useRef } from "react";
import { Box, Button, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import "@app/components/tools/toolPicker/ToolPicker.css";
@@ -12,6 +12,11 @@ import ToolButton from "@app/components/tools/toolPicker/ToolButton";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId";
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
import { usePluginRegistry } from "@app/contexts/PluginRegistryContext";
import { Tooltip } from "@app/components/shared/Tooltip";
import { useNavigate } from "react-router-dom";
import FitText from "@app/components/shared/FitText";
import { LocalIcon } from '@app/components/shared/LocalIcon';
interface ToolPickerProps {
selectedToolKey: string | null;
@@ -26,7 +31,10 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
const scrollableRef = useRef<HTMLDivElement>(null);
const { sections: visibleSections } = useToolSections(filteredTools);
const { favoriteTools, toolRegistry } = useToolWorkflow();
const {
favoriteTools,
toolRegistry,
} = useToolWorkflow();
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
@@ -47,6 +55,19 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
[visibleSections]
);
const { plugins } = usePluginRegistry();
const navigate = useNavigate();
const pluginItems = useMemo(
() => plugins.filter((plugin) => plugin.hasFrontend && plugin.frontendUrl),
[plugins],
);
useEffect(() => {
pluginItems.forEach((plugin) => {
console.debug(`[ToolPicker] Rendering icon for plugin ${plugin.id}:`, plugin.icon);
});
}, [pluginItems]);
// Build flat list by subcategory for search mode
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
@@ -133,6 +154,68 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
</div>
</Box>
)}
{pluginItems.length > 0 && (
<Box w="100%">
<div style={headerTextStyle}>{t("plugins.shortTitle", "Plugins")}</div>
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
{pluginItems.map((plugin) => (
<div key={`plugin-${plugin.id}`} className="tool-button-container">
<Tooltip content={plugin.description} position="right" arrow={true} delay={500}>
<Button
component="a"
key={`plugin-${plugin.id}`}
variant="subtle"
size="sm"
radius="md"
fullWidth
className="tool-button"
justify="flex-start"
onClick={() => {
console.debug(`[ToolPicker] Navigating to plugin ${plugin.id}`);
navigate(`/plugins/${plugin.id}`, { state: { plugin } });
}}
data-tour={`plugin-button-${plugin.id}`}
styles={{
root: {
borderRadius: 0,
color: "var(--tools-text-and-icon-color)",
overflow: 'visible'
},
label: { overflow: 'visible' }
}}
>
<>
<div
className="tool-button-icon"
style={{
transform: "scale(0.8)",
transformOrigin: "center",
opacity: 1,
color: "var(--tools-text-and-icon-color)",
marginRight: "0.5rem"
}}
>
<LocalIcon icon={typeof plugin.icon === 'string' ? plugin.icon : 'extension'} width="24" height="24" />
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
<FitText
text={plugin.name}
lines={1}
minimumFontScale={0.8}
as="span"
style={{ display: 'inline-block', maxWidth: '100%', opacity: 1 }}
/>
</div>
</div>
</>
</Button>
</Tooltip>
</div>
))}
</div>
</Box>
)}
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
<Box key={sc.subcategoryId} w="100%">
<div style={headerTextStyle}>
@@ -74,6 +74,11 @@ const CompareDocumentPane = ({
}
}, [zoom]);
const renderedPageNumbers = useMemo(
() => new Set(pages.map((p) => p.pageNumber)),
[pages]
);
return (
<div className="compare-pane">
<div className="compare-header">
@@ -88,7 +93,7 @@ const CompareDocumentPane = ({
placeholder={dropdownPlaceholder ?? null}
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
onNavigate={onNavigateChange}
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
renderedPageNumbers={renderedPageNumbers}
/>
)}
</Group>
@@ -15,6 +15,7 @@ import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
import { downloadFromUrl } from '@app/services/downloadService';
export interface EditTableOfContentsWorkbenchViewData {
bookmarks: BookmarkNode[];
@@ -42,6 +43,16 @@ interface EditTableOfContentsWorkbenchViewProps {
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
const { t } = useTranslation();
const terminology = useFileActionTerminology();
const files = data?.files ?? [];
const thumbnails = data?.thumbnails ?? [];
const previewFiles = useMemo(
() =>
files.map((file, index) => ({
file,
thumbnail: thumbnails[index],
})),
[files, thumbnails]
);
if (!data) {
return (
@@ -62,8 +73,6 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
bookmarks,
selectedFileName,
disabled,
files,
thumbnails,
downloadUrl,
downloadFilename,
errorMessage,
@@ -77,15 +86,6 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
onFileClick,
} = data;
const previewFiles = useMemo(
() =>
files?.map((file, index) => ({
file,
thumbnail: thumbnails[index],
})) ?? [],
[files, thumbnails]
);
const showResults = Boolean(
previewFiles.length > 0 || downloadUrl || errorMessage
);
@@ -177,10 +177,8 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
<Group justify="flex-end" gap="sm">
{downloadUrl && (
<Button
component="a"
href={downloadUrl}
download={downloadFilename ?? undefined}
leftSection={<LocalIcon icon='download-rounded' />}
onClick={() => downloadFromUrl(downloadUrl, downloadFilename ?? "download")}
>
{terminology.download}
</Button>
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
import { downloadFile } from '@app/services/downloadService';
interface GetPdfInfoResultsProps {
operation: GetPdfInfoOperationHook;
@@ -21,14 +22,7 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]);
const handleDownload = useCallback((file: File) => {
const blobUrl = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
void downloadFile({ data: file, filename: file.name });
}, []);
if (isLoading && operation.results.length === 0) {
@@ -76,4 +70,3 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
export default GetPdfInfoResults;
@@ -1,8 +1,7 @@
import { useTranslation } from 'react-i18next';
import { useEffect, useRef, useCallback } from 'react';
import { Button, Stack, Text, Group, Divider } from '@mantine/core';
import HighlightAltIcon from '@mui/icons-material/HighlightAlt';
import CropFreeIcon from '@mui/icons-material/CropFree';
import { Button, Stack, Text, Divider, ColorInput } from '@mantine/core';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
import { useViewer } from '@app/contexts/ViewerContext';
import { useSignature } from '@app/contexts/SignatureContext';
@@ -19,8 +18,8 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
const { t } = useTranslation();
// Use our RedactionContext which bridges to EmbedPDF
const { activateTextSelection, activateMarquee, redactionsApplied, setActiveType } = useRedaction();
const { pendingCount, activeType, isBridgeReady } = useRedactionMode();
const { activateManualRedact, redactionsApplied, setActiveType, setManualRedactColor } = useRedaction();
const { pendingCount, activeType, isBridgeReady, isRedacting, manualRedactColor } = useRedactionMode();
// Get viewer context to manage annotation mode and save changes
const { isAnnotationMode, setAnnotationMode, applyChanges, activeFileIndex } = useViewer();
@@ -28,9 +27,8 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
// Get signature context to deactivate annotation tools when switching to redaction
const { signatureApiRef } = useSignature();
// Check which tool is active based on activeType
const isSelectionActive = activeType === 'redactSelection';
const isMarqueeActive = activeType === 'marqueeRedact';
// Check if redaction mode is active
const isRedactActive = isRedacting;
// Track if we've auto-activated for the current bridge session
const hasAutoActivated = useRef(false);
@@ -47,12 +45,12 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
const timer = setTimeout(() => {
// Deactivate annotation mode to show redaction layer
setAnnotationMode(false);
// Pre-select the Mark Text tool
activateTextSelection();
// Pre-select the Redaction tool
activateManualRedact();
}, 150);
return () => clearTimeout(timer);
}
}, [isBridgeReady, disabled, activateTextSelection, setAnnotationMode]);
}, [isBridgeReady, disabled, activateManualRedact, setAnnotationMode]);
// Reset auto-activation flag when disabled changes or bridge becomes not ready
useEffect(() => {
@@ -68,18 +66,16 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
prevFileIndexRef.current = activeFileIndex;
// Reset active type to null when switching files
// This makes both buttons appear unselected, requiring the user to re-click
// which ensures proper activation on the new PDF
if (isSelectionActive || isMarqueeActive) {
if (activeType) {
setActiveType(null);
}
// Reset auto-activation flag so new file can auto-activate
hasAutoActivated.current = false;
}
}, [activeFileIndex, isSelectionActive, isMarqueeActive, setActiveType]);
}, [activeFileIndex, activeType, setActiveType]);
const handleSelectionClick = () => {
const handleRedactClick = () => {
// Deactivate annotation mode and tools to switch to redaction layer
if (isAnnotationMode) {
setAnnotationMode(false);
@@ -93,34 +89,7 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
}
}
if (isSelectionActive && !isAnnotationMode) {
// If already active and not coming from annotation mode, switch to marquee
activateMarquee();
} else {
activateTextSelection();
}
};
const handleMarqueeClick = () => {
// Deactivate annotation mode and tools to switch to redaction layer
if (isAnnotationMode) {
setAnnotationMode(false);
// Deactivate any active annotation tools (like draw)
if (signatureApiRef?.current) {
try {
signatureApiRef.current.deactivateTools();
} catch (error) {
console.log('Unable to deactivate annotation tools:', error);
}
}
}
if (isMarqueeActive && !isAnnotationMode) {
// If already active and not coming from annotation mode, switch to selection
activateTextSelection();
} else {
activateMarquee();
}
activateManualRedact();
};
// Handle saving changes - this will apply pending redactions and save to file
@@ -149,43 +118,27 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
{t('redact.manual.instructions', 'Select text or draw areas on the PDF to mark content for redaction.')}
</Text>
<Group gap="sm" grow wrap="nowrap">
{/* Mark Text Selection Tool */}
<Button
variant={isSelectionActive && !isAnnotationMode ? 'filled' : 'outline'}
color={isSelectionActive && !isAnnotationMode ? 'blue' : 'gray'}
leftSection={<HighlightAltIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleSelectionClick}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: {
minWidth: 0,
},
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}}
>
{t('redact.manual.markText', 'Mark Text')}
</Button>
<ColorInput
label={t('redact.manual.colorLabel', 'Redaction Colour')}
value={manualRedactColor}
onChange={setManualRedactColor}
disabled={disabled || !isApiReady}
size="sm"
format="hex"
popoverProps={{ withinPortal: true }}
/>
{/* Mark Area (Marquee) Tool */}
<Button
variant={isMarqueeActive && !isAnnotationMode ? 'filled' : 'outline'}
color={isMarqueeActive && !isAnnotationMode ? 'blue' : 'gray'}
leftSection={<CropFreeIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleMarqueeClick}
disabled={disabled || !isApiReady}
size="sm"
styles={{
root: {
minWidth: 0,
},
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
}}
>
{t('redact.manual.markArea', 'Mark Area')}
</Button>
</Group>
<Button
variant={isRedactActive && !isAnnotationMode ? 'filled' : 'outline'}
color={isRedactActive && !isAnnotationMode ? 'blue' : 'gray'}
leftSection={<AutoFixHighIcon style={{ fontSize: 18, flexShrink: 0 }} />}
onClick={handleRedactClick}
disabled={disabled || !isApiReady}
fullWidth
size="sm"
>
{isRedactActive && !isAnnotationMode ? t('redact.manual.active', 'Redaction Mode Active') : t('redact.manual.activate', 'Activate Redaction Tool')}
</Button>
{/* Save Changes Button - applies pending redactions and saves to file */}
<Button
@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileStatusIndicator from '@app/components/tools/shared/FileStatusIndicator';
import { StirlingFile } from '@app/types/fileContext';
import i18n from '@app/i18n';
export interface FilesToolStepProps {
selectedFiles: StirlingFile[];
@@ -14,9 +14,7 @@ export function createFilesToolStep(
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
props: FilesToolStepProps
): React.ReactElement {
const { t } = useTranslation();
return createStep(t("files.title", "Files"), {
return createStep(i18n.t("files.title", "Files"), {
isVisible: true,
isCollapsed: props.isCollapsed,
onCollapsedClick: props.onCollapsedClick
@@ -9,6 +9,10 @@ import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
import { Tooltip } from "@app/components/shared/Tooltip";
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { saveOperationResults } from "@app/services/operationResultsSaveService";
import { useFileActions, useFileState } from "@app/contexts/FileContext";
import { FileId } from "@app/types/fileContext";
import i18n from "@app/i18n";
export interface ReviewToolStepProps<TParams = unknown> {
isVisible: boolean;
@@ -34,6 +38,8 @@ function ReviewStepContent<TParams = unknown>({
const icons = useFileActionIcons();
const DownloadIcon = icons.download;
const stepRef = useRef<HTMLDivElement>(null);
const { actions: fileActions } = useFileActions();
const { selectors } = useFileState();
const handleUndo = async () => {
try {
@@ -50,6 +56,31 @@ function ReviewStepContent<TParams = unknown>({
thumbnail: operation.thumbnails[index],
})) || [];
const handleDownload = async () => {
if (!operation.downloadUrl) return;
try {
await saveOperationResults({
downloadUrl: operation.downloadUrl,
downloadFilename: operation.downloadFilename || "download",
downloadLocalPath: operation.downloadLocalPath,
outputFileIds: operation.outputFileIds,
getFile: (fileId) => selectors.getFile(fileId as FileId),
getStub: (fileId) => selectors.getStirlingFileStub(fileId as FileId),
markSaved: (fileId, savedPath) => {
const stub = selectors.getStirlingFileStub(fileId as FileId);
fileActions.updateStirlingFileStub(fileId as FileId, {
localFilePath: stub?.localFilePath ?? savedPath,
isDirty: false
});
}
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error("[ReviewToolStep] Failed to download file:", message);
alert(`Failed to download file: ${message}`);
}
};
// Auto-scroll to bottom when content appears
useEffect(() => {
if (stepRef.current && (previewFiles.length > 0 || operation.downloadUrl || operation.errorMessage)) {
@@ -92,13 +123,11 @@ function ReviewStepContent<TParams = unknown>({
)}
{operation.downloadUrl && (
<Button
component="a"
href={operation.downloadUrl}
download={operation.downloadFilename}
leftSection={<DownloadIcon />}
color="blue"
fullWidth
mb="md"
onClick={handleDownload}
>
{terminology.download}
</Button>
@@ -123,10 +152,8 @@ export function createReviewToolStep<TParams = unknown>(
) => React.ReactElement,
props: ReviewToolStepProps<TParams>
): React.ReactElement {
const { t } = useTranslation();
return createStep(
t("review", "Review"),
i18n.t("review", "Review"),
{
isVisible: props.isVisible,
isCollapsed: props.isCollapsed,
@@ -80,8 +80,6 @@ const ToolStep = ({
alwaysShowTooltip = false,
tooltip
}: ToolStepProps) => {
if (!isVisible) return null;
const parent = useContext(ToolStepContext);
// Auto-detect if we should show numbers based on sibling count or force option
@@ -91,6 +89,8 @@ const ToolStep = ({
return parent ? parent.visibleStepCount >= 3 : false; // Auto-detect
}, [showNumber, parent]);
if (!isVisible) return null;
const stepNumber = _stepNumber;
return (
@@ -1,3 +1,5 @@
import { downloadFromUrl } from "@app/services/downloadService";
export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain";
export type ShowJsToken = { type: ShowJsTokenType; text: string };
@@ -372,11 +374,6 @@ export async function copyTextToClipboard(text: string, fallbackElement?: HTMLEl
}
}
export function triggerDownload(url: string, filename: string): void {
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
export async function triggerDownload(url: string, filename: string): Promise<void> {
await downloadFromUrl(url, filename);
}
@@ -6,6 +6,7 @@ import type { ValidateSignatureOperationHook } from '@app/hooks/tools/validateSi
import '@app/components/tools/validateSignature/reportView/styles.css';
import FitText from '@app/components/shared/FitText';
import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection';
import { downloadFile } from '@app/services/downloadService';
interface ValidateSignatureResultsProps {
operation: ValidateSignatureOperationHook;
@@ -80,14 +81,7 @@ const ValidateSignatureResults = ({
];
const handleDownload = useCallback((file: File) => {
const blobUrl = URL.createObjectURL(file);
const link = document.createElement('a');
link.href = blobUrl;
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
void downloadFile({ data: file, filename: file.name });
}, []);
// Show the big loader only while we're still waiting for the first results.
@@ -8,6 +8,7 @@ import type {
AnnotationEvent,
AnnotationPatch,
} from '@app/components/viewer/viewerTypes';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
@@ -290,6 +291,7 @@ const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
// Use the provided annotation API just like SignatureAPIBridge/HistoryAPIBridge
const { provides: annotationApi } = useAnnotationCapability();
const documentReady = useDocumentReady();
const buildAnnotationDefaults = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
@@ -323,6 +325,7 @@ export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function Annotation
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
configureAnnotationTool(toolId, options);
},
isReady: () => !!annotationApi && documentReady,
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const defaults = buildAnnotationDefaults(toolId, options);
const api = annotationApi as AnnotationApiSurface | undefined;

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