Compare 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
94 changed files with 6736 additions and 1105 deletions
@@ -606,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
@@ -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);
}
}
}
@@ -13,6 +13,7 @@ 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;
@@ -28,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
@@ -59,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 {
@@ -111,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
@@ -1,5 +1,6 @@
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;
@@ -58,6 +59,7 @@ public class ConvertPdfJsonController {
}
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
logJsonResponse("pdf/text-editor", jsonBytes);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
@@ -114,10 +116,11 @@ 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())
@@ -185,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 =
@@ -219,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;
}
/**
@@ -290,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) {
@@ -299,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) {
@@ -320,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,
@@ -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));
}
}
}
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()) {
@@ -426,7 +426,8 @@ public class PdfJsonFallbackFontService {
String normalized =
WHITESPACE_PATTERN
.matcher(
PATTERN.matcher(originalFontName).replaceAll("") // Remove subset prefix
PATTERN.matcher(originalFontName)
.replaceAll("") // Remove subset prefix
.toLowerCase())
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
// "timesnewroman")
@@ -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)
@@ -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)
@@ -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);
}
}
@@ -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,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);
+1 -1
View File
@@ -67,7 +67,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.4.5'
version = '2.5.1'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+96 -53
View File
@@ -1236,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"
@@ -1427,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"
@@ -1463,6 +1478,7 @@ notesStamps = "Notes & Stamps"
opacity = "Opacity"
pen = "Pen"
polygon = "Polygon"
properties = "Properties"
rectangle = "Rectangle"
redo = "Redo"
saveChanges = "Save Changes"
@@ -1488,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)"
@@ -4218,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)"
@@ -4844,6 +4912,7 @@ account = "Account"
activity = "Activity"
adminSettings = "Admin Settings"
allTools = "Tools"
plugins = "Plugins"
automate = "Automate"
config = "Config"
files = "Files"
@@ -5298,38 +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"
formFill = "Fill Form"
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"
toggleAttachments = "Toggle Attachments"
toggleSidebar = "Toggle Sidebar"
toggleTheme = "Toggle Theme"
[rotate]
rotateLeft = "Rotate Anticlockwise"
rotateRight = "Rotate Clockwise"
@@ -5589,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"
@@ -6167,11 +6219,6 @@ title = "API Documentation"
[tableExtraxt]
tags = "CSV,Table Extraction,extract,convert"
[textAlign]
center = "Center"
left = "Left"
right = "Right"
[theme]
toggle = "Toggle Theme"
@@ -6237,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"
@@ -6447,19 +6503,6 @@ 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"
+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>
+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={
@@ -2,6 +2,7 @@ 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";
@@ -108,7 +109,8 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />
<ToolRegistryProvider>
<PluginRegistryProvider>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
@@ -139,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>
);
}
@@ -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 (
<>
@@ -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
);
}
@@ -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);
@@ -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>
@@ -43,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 (
@@ -63,8 +73,6 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
bookmarks,
selectedFileName,
disabled,
files,
thumbnails,
downloadUrl,
downloadFilename,
errorMessage,
@@ -78,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
);
@@ -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
@@ -12,6 +12,7 @@ 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;
@@ -151,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 (
@@ -0,0 +1,620 @@
import { ActionIcon, Tooltip, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { useEffect, useState, useRef, useCallback } from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import { useAnnotation } from '@embedpdf/plugin-annotation/react';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
import { WidthControl } from '@app/components/annotation/shared/WidthControl';
import { PropertiesPopover } from '@app/components/annotation/shared/PropertiesPopover';
import { ColorControl } from '@app/components/annotation/shared/ColorControl';
/**
* Props interface matching EmbedPDF's annotation selection menu pattern
* This matches the type from @embedpdf/plugin-annotation
*/
export interface AnnotationSelectionMenuProps {
documentId?: string;
context?: {
type: 'annotation';
annotation: any;
pageIndex: number;
};
selected: boolean;
menuWrapperProps?: {
ref?: (node: HTMLDivElement | null) => void;
style?: React.CSSProperties;
};
}
export function AnnotationSelectionMenu(props: AnnotationSelectionMenuProps) {
const activeDocumentId = useActiveDocumentId();
// Don't render until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return (
<AnnotationSelectionMenuInner
documentId={activeDocumentId}
{...props}
/>
);
}
type AnnotationType = 'textMarkup' | 'ink' | 'inkHighlighter' | 'text' | 'note' | 'shape' | 'line' | 'stamp' | 'unknown';
function AnnotationSelectionMenuInner({
documentId,
context,
selected,
menuWrapperProps,
}: AnnotationSelectionMenuProps & { documentId: string }) {
const annotation = context?.annotation;
const pageIndex = context?.pageIndex;
const { t } = useTranslation();
const { provides } = useAnnotation(documentId);
const wrapperRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [isTextEditorOpen, setIsTextEditorOpen] = useState(false);
const [textDraft, setTextDraft] = useState('');
const [textBoxPosition, setTextBoxPosition] = useState<{ top: number; left: number; width: number; height: number; fontSize: number; fontFamily: string } | null>(null);
// Merge refs - menuWrapperProps.ref is a callback ref
const setRef = useCallback((node: HTMLDivElement | null) => {
wrapperRef.current = node;
// Call the EmbedPDF ref callback
menuWrapperProps?.ref?.(node);
}, [menuWrapperProps]);
// Type detection
const getAnnotationType = useCallback((): AnnotationType => {
const type = annotation?.object?.type;
const toolId = annotation?.object?.customData?.toolId;
// Map type numbers to categories
if ([9, 10, 11, 12].includes(type)) return 'textMarkup';
if (type === 15) {
return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
}
if (type === 3) {
return toolId === 'note' ? 'note' : 'text';
}
if ([5, 6, 7].includes(type)) return 'shape';
if ([4, 8].includes(type)) return 'line';
if (type === 13) return 'stamp';
return 'unknown';
}, [annotation]);
// Calculate menu width based on annotation type
const calculateWidth = (annotationType: AnnotationType): number => {
switch (annotationType) {
case 'stamp':
return 80;
case 'inkHighlighter':
return 220;
case 'shape':
return 200;
default:
return 180;
}
};
// Get annotation properties
const obj = annotation?.object;
const annotationType = getAnnotationType();
const annotationId = obj?.id;
// Get current colors
const getCurrentColor = (): string => {
if (!obj) return '#000000';
const type = obj.type;
// Text annotations use textColor
if (type === 3) return obj.textColor || obj.color || '#000000';
// Shape annotations use strokeColor
if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
// Default to color property
return obj.color || obj.strokeColor || '#000000';
};
const getStrokeColor = (): string => {
return obj?.strokeColor || obj?.color || '#000000';
};
const getFillColor = (): string => {
return obj?.color || obj?.fillColor || '#0000ff';
};
const getBackgroundColor = (): string => {
// Check multiple possible properties for background color
return obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff';
};
const getTextColor = (): string => {
return obj?.textColor || obj?.color || '#000000';
};
const getOpacity = (): number => {
return Math.round((obj?.opacity ?? 1) * 100);
};
const getWidth = (): number => {
return obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2;
};
// Handlers
const handleDelete = useCallback(() => {
if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) {
provides.deleteAnnotation(pageIndex, annotationId);
}
}, [provides, annotationId, pageIndex]);
const handleOpenTextEditor = useCallback(() => {
if (!annotation) return;
// Try to find the annotation element in the DOM
const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement;
let fontSize = (obj?.fontSize || 14) * 1.33;
let fontFamily = 'Helvetica';
if (annotationElement) {
const rect = annotationElement.getBoundingClientRect();
// Try multiple selectors to find the text element
const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement;
if (textElement) {
const computedStyle = window.getComputedStyle(textElement);
const computedSize = parseFloat(computedStyle.fontSize);
if (computedSize && computedSize > 0) {
fontSize = computedSize;
}
fontFamily = computedStyle.fontFamily || fontFamily;
}
setTextBoxPosition({
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
fontSize: fontSize,
fontFamily: fontFamily,
});
} else if (wrapperRef.current) {
// Fallback to wrapper position
const rect = wrapperRef.current.getBoundingClientRect();
setTextBoxPosition({
top: rect.top,
left: rect.left,
width: Math.max(rect.width, 200),
height: Math.max(rect.height, 50),
fontSize: fontSize,
fontFamily: fontFamily,
});
} else {
return;
}
setTextDraft(obj?.contents || '');
setIsTextEditorOpen(true);
// Focus the textarea after it renders
setTimeout(() => {
textareaRef.current?.focus();
textareaRef.current?.select();
}, 0);
}, [obj, annotation, annotationId]);
const handleSaveText = useCallback(() => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
contents: textDraft,
});
setIsTextEditorOpen(false);
setTextBoxPosition(null);
}, [provides, annotationId, pageIndex, textDraft]);
const handleCloseTextEdit = useCallback(() => {
setIsTextEditorOpen(false);
setTextBoxPosition(null);
}, []);
const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
const type = obj?.type;
const patch: any = {};
if (target === 'stroke') {
// Shape stroke - preserve fill color
patch.strokeColor = color;
patch.color = obj?.color || '#0000ff'; // Preserve fill
patch.strokeWidth = getWidth();
} else if (target === 'fill') {
// Shape fill - preserve stroke color
patch.color = color;
patch.strokeColor = obj?.strokeColor || '#000000'; // Preserve stroke
patch.strokeWidth = getWidth();
} else if (target === 'background') {
// Background color for text/note - set multiple properties for compatibility
patch.backgroundColor = color;
patch.fillColor = color;
patch.color = color;
} else if (target === 'text') {
// Text color for text/note - TRY PROPERTY COMBINATIONS
patch.textColor = color;
patch.fontColor = color; // EmbedPDF might expect this instead
// Include font metadata (EmbedPDF might require these together)
patch.fontSize = obj?.fontSize ?? 14;
patch.fontFamily = obj?.fontFamily ?? 'Helvetica';
// Re-submit text content
patch.contents = obj?.contents ?? '';
} else {
// Main color - for highlights, ink, etc.
patch.color = color;
// For text markup annotations (highlight, underline, strikeout, squiggly)
if ([9, 10, 11, 12].includes(type)) {
patch.strokeColor = color;
patch.fillColor = color;
patch.opacity = obj?.opacity ?? 1;
}
// For line annotations (type 4, 8), include stroke properties
if ([4, 8].includes(type)) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
}
// For ink annotations (type 15), include all stroke-related properties
if (type === 15) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2;
patch.opacity = obj?.opacity ?? 1;
}
}
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex, obj]);
const handleOpacityChange = useCallback((opacity: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
opacity: opacity / 100,
});
}, [provides, annotationId, pageIndex]);
const handleWidthChange = useCallback((width: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
strokeWidth: width,
});
}, [provides, annotationId, pageIndex]);
const handlePropertiesUpdate = useCallback((patch: Record<string, any>) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex]);
// Render button groups based on annotation type
const renderButtons = () => {
const commonButtonStyles = {
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)',
},
},
};
const EditTextButton = () => (
<Tooltip label={t('annotation.editText', 'Edit Text')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={handleOpenTextEditor}
styles={commonButtonStyles}
>
<EditIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
const DeleteButton = () => (
<Tooltip label={t('annotation.delete', 'Delete')}>
<ActionIcon
variant="subtle"
color="red"
size="md"
onClick={handleDelete}
styles={{
root: {
...commonButtonStyles.root,
'&:hover': {
backgroundColor: 'var(--mantine-color-red-1)',
borderColor: 'var(--mantine-color-red-4)',
color: 'var(--mantine-color-red-7)',
},
},
}}
>
<DeleteIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
switch (annotationType) {
case 'textMarkup':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
<DeleteButton />
</>
);
case 'ink':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
<DeleteButton />
</>
);
case 'inkHighlighter':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={20} />
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
<DeleteButton />
</>
);
case 'text':
case 'note':
return (
<>
<ColorControl
value={getTextColor()}
onChange={(color) => handleColorChange(color, 'text')}
label={t('annotation.color', 'Color')}
/>
<ColorControl
value={getBackgroundColor()}
onChange={(color) => handleColorChange(color, 'background')}
label={t('annotation.backgroundColor', 'Background color')}
/>
<EditTextButton />
<PropertiesPopover
annotationType={annotationType}
annotation={annotation}
onUpdate={handlePropertiesUpdate}
/>
<DeleteButton />
</>
);
case 'shape':
return (
<>
<ColorControl
value={getStrokeColor()}
onChange={(color) => handleColorChange(color, 'stroke')}
label={t('annotation.strokeColor', 'Stroke Colour')}
/>
<ColorControl
value={getFillColor()}
onChange={(color) => handleColorChange(color, 'fill')}
label={t('annotation.fillColor', 'Fill Colour')}
/>
<PropertiesPopover
annotationType="shape"
annotation={annotation}
onUpdate={handlePropertiesUpdate}
/>
<DeleteButton />
</>
);
case 'line':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
<DeleteButton />
</>
);
case 'stamp':
return <DeleteButton />;
default:
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<DeleteButton />
</>
);
}
};
// Calculate position for portal based on wrapper element
useEffect(() => {
if (!selected || !annotation || !wrapperRef.current) {
setMenuPosition(null);
return;
}
const updatePosition = () => {
const wrapper = wrapperRef.current;
if (!wrapper) {
setMenuPosition(null);
return;
}
const wrapperRect = wrapper.getBoundingClientRect();
// Position menu below the wrapper, centered
// Use getBoundingClientRect which gives viewport-relative coordinates
// Since we're using fixed positioning in the portal, we don't need to add scroll offsets
setMenuPosition({
top: wrapperRect.bottom + 8,
left: wrapperRect.left + wrapperRect.width / 2,
});
};
updatePosition();
// Update position on scroll/resize
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [selected, annotation]);
// Early return AFTER all hooks have been called
if (!selected || !annotation) return null;
const menuContent = menuPosition ? (
<div
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
transform: 'translateX(-50%)',
pointerEvents: 'auto',
zIndex: 10000, // Very high z-index to appear above everything
backgroundColor: 'var(--mantine-color-body)',
borderRadius: 8,
padding: '8px 12px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.25)',
border: '1px solid var(--mantine-color-default-border)',
fontSize: '14px',
minWidth: `${calculateWidth(annotationType)}px`,
transition: 'min-width 0.2s ease',
}}
>
<Group gap="sm" wrap="nowrap" justify="center">
{renderButtons()}
</Group>
</div>
) : null;
const textEditorOverlay = isTextEditorOpen && textBoxPosition ? (
<div
style={{
position: 'fixed',
top: `${textBoxPosition.top}px`,
left: `${textBoxPosition.left}px`,
width: `${textBoxPosition.width}px`,
height: `${textBoxPosition.height}px`,
zIndex: 10001,
pointerEvents: 'auto',
}}
>
<textarea
ref={textareaRef}
value={textDraft}
onChange={(e) => setTextDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
handleCloseTextEdit();
} else if (e.key === 'Enter' && e.ctrlKey) {
handleSaveText();
}
}}
onBlur={handleSaveText}
style={{
width: '100%',
height: '100%',
minHeight: '0',
minWidth: '0',
maxWidth: '100%',
maxHeight: '100%',
fontSize: `${textBoxPosition.fontSize}px`,
fontFamily: textBoxPosition.fontFamily,
lineHeight: '1.2',
color: '#000000',
backgroundColor: '#ffffff',
border: '2px solid var(--mantine-color-blue-5)',
borderRadius: '0',
padding: '0',
margin: '0',
resize: 'none',
boxSizing: 'border-box',
outline: 'none',
overflow: 'hidden',
wordWrap: 'break-word',
overflowWrap: 'break-word',
}}
/>
</div>
) : null;
const canClickToEdit = selected && (annotationType === 'text' || annotationType === 'note') && !isTextEditorOpen;
return (
<>
{/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps */}
<div
ref={setRef}
onClick={canClickToEdit ? handleOpenTextEditor : undefined}
style={{
// Use EmbedPDF's positioning styles
...menuWrapperProps?.style,
// Keep the wrapper invisible but still occupying space for positioning
opacity: 0,
pointerEvents: canClickToEdit ? 'auto' : 'none',
}}
/>
{typeof document !== 'undefined' && menuContent
? createPortal(menuContent, document.body)
: null}
{typeof document !== 'undefined' && textEditorOverlay
? createPortal(textEditorOverlay, document.body)
: null}
</>
);
}
@@ -19,10 +19,87 @@ import NavigationWarningModal from '@app/components/shared/NavigationWarningModa
import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
// ─── Measure dictionary extraction ────────────────────────────────────────────
async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales | null> {
try {
const { PDFDocument, PDFDict, PDFName, PDFArray, PDFNumber, PDFString, PDFHexString } = await import('@cantoo/pdf-lib');
const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { ignoreEncryption: true });
// Parse a Measure dict into a MeasureScale, or return null if malformed.
const parseScale = (measureObj: unknown) => {
if (!(measureObj instanceof PDFDict)) return null;
const rObj = measureObj.lookup(PDFName.of('R'));
const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString)
? rObj.decodeText() : '';
// D = distance array, X = x-axis fallback
let fmtArray = measureObj.lookup(PDFName.of('D'));
if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of('X'));
if (!(fmtArray instanceof PDFArray)) return null;
const firstFmt = fmtArray.lookup(0);
if (!(firstFmt instanceof PDFDict)) return null;
const cObj = firstFmt.lookup(PDFName.of('C'));
const uObj = firstFmt.lookup(PDFName.of('U'));
if (!(cObj instanceof PDFNumber) || cObj.asNumber() <= 0) return null;
const unit = (uObj instanceof PDFString || uObj instanceof PDFHexString)
? uObj.decodeText() : 'units';
return { factor: cObj.asNumber(), unit, ratioLabel };
};
const result: PageMeasureScales = new Map();
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
const page = pdfDoc.getPage(i);
const pageHeight = page.getHeight();
const pageNode = page.node as unknown as PDFDict;
const viewports: ViewportScale[] = [];
// Spec-conformant: /VP array — each viewport can have its own scale and BBox
const vpObj = pageNode.lookup(PDFName.of('VP'));
if (vpObj instanceof PDFArray) {
for (let j = 0; j < vpObj.size(); j++) {
const vpEntry = vpObj.lookup(j);
if (!(vpEntry instanceof PDFDict)) continue;
const scale = parseScale(vpEntry.lookup(PDFName.of('Measure')));
if (!scale) continue;
let bbox: ViewportScale['bbox'] = null;
const bboxObj = vpEntry.lookup(PDFName.of('BBox'));
if (bboxObj instanceof PDFArray && bboxObj.size() >= 4) {
bbox = [
(bboxObj.lookup(0) as PDFNumber).asNumber(),
(bboxObj.lookup(1) as PDFNumber).asNumber(),
(bboxObj.lookup(2) as PDFNumber).asNumber(),
(bboxObj.lookup(3) as PDFNumber).asNumber(),
];
}
viewports.push({ bbox, scale });
}
}
// Fallback: /Measure directly on page (non-conforming but seen in the wild)
if (viewports.length === 0) {
const scale = parseScale(pageNode.lookup(PDFName.of('Measure')));
if (scale) viewports.push({ bbox: null, scale });
}
if (viewports.length > 0) result.set(i, { viewports, pageHeight } satisfies PageScaleInfo);
}
return result.size > 0 ? result : null;
} catch {
return null;
}
}
// ──────────────────────────────────────────────────────────────────────────────
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
@@ -688,8 +765,20 @@ const EmbedPdfViewerContent = ({
};
}, [applyChanges, setApplyChanges]);
// Ruler / measurement tool state
const [isRulerActive, setIsRulerActive] = useState(false);
const [pageMeasureScales, setPageMeasureScales] = useState<PageMeasureScales | null>(null);
useEffect(() => {
const file = effectiveFile?.file;
if (!file) { setPageMeasureScales(null); return; }
let cancelled = false;
extractPageMeasureScales(file).then(scales => { if (!cancelled) setPageMeasureScales(scales); });
return () => { cancelled = true; };
}, [effectiveFile]);
// Register viewer right-rail buttons
useViewerRightRailButtons();
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
// Auto-fetch form fields when a PDF is loaded in the viewer.
// In normal viewer mode, this uses pdf-lib (frontend-only).
@@ -819,6 +908,11 @@ const EmbedPdfViewerContent = ({
isActive={isPlacementOverlayActive}
signatureConfig={signatureConfig}
/>
<RulerOverlay
containerRef={pdfContainerRef}
isActive={isRulerActive}
pageMeasureScales={pageMeasureScales}
/>
</Box>
</>
)}
@@ -50,6 +50,7 @@ import { useTranslation } from 'react-i18next';
import { LinkLayer } from '@app/components/viewer/LinkLayer';
import { TextSelectionHandler } from '@app/components/viewer/TextSelectionHandler';
import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu';
import { AnnotationSelectionMenu } from '@app/components/viewer/AnnotationSelectionMenu';
import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge';
import { DocumentPermissionsAPIBridge } from '@app/components/viewer/DocumentPermissionsAPIBridge';
@@ -125,7 +126,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
}),
createPluginRegistration(ScrollPluginPackage),
createPluginRegistration(RenderPluginPackage, {
withForms: true,
withForms: !enableFormFill,
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
}),
@@ -752,7 +753,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
documentId={documentId}
pageIndex={pageIndex}
selectionOutlineColor="#007ACC"
selectionMenu={(props) => <RedactionSelectionMenu {...props} />}
selectionMenu={(props) => <AnnotationSelectionMenu {...props} />}
/>
)}
@@ -0,0 +1,747 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewer } from '@app/contexts/ViewerContext';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Point {
x: number;
y: number;
}
/**
* A point anchored to a specific PDF page in PDF-unit space.
* x and y are in PDF points (1/72 inch) relative to the page's top-left corner.
*
* This is the only truly zoom-invariant representation. Screen positions are
* recovered at render time via getBoundingClientRect on the page element, so
* scroll, zoom, and fixed page margins are all handled by the browser — we never
* have to track them ourselves.
*/
interface PagePoint {
pageIndex: number;
x: number;
y: number;
}
interface Measurement {
id: string;
start: PagePoint;
end: PagePoint;
}
export interface RulerOverlayHandle {
clearAll: () => void;
}
interface RulerOverlayProps {
containerRef: React.RefObject<HTMLElement | null>;
isActive: boolean;
pageMeasureScales?: PageMeasureScales | null;
}
// ─── Math ─────────────────────────────────────────────────────────────────────
function dist(a: Point, b: Point): number {
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
}
function midpoint(a: Point, b: Point): Point {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function perpUnit(a: Point, b: Point): { nx: number; ny: number } {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
return { nx: -dy / len, ny: dx / len };
}
/** Angle from horizontal 0°–90°. Computed from screen-space points (same angle as PDF space). */
function angleDeg(a: Point, b: Point): number {
return Math.atan2(Math.abs(b.y - a.y), Math.abs(b.x - a.x)) * (180 / Math.PI);
}
function formatDist(pts: number): string {
const mm = (pts / 72) * 25.4;
if (mm < 100) return `${mm.toFixed(1)} mm`;
if (mm < 1000) return `${(mm / 10).toFixed(1)} cm`;
return `${(mm / 1000).toFixed(2)} m`;
}
function formatInches(pts: number): string {
const inches = pts / 72;
if (inches < 12) return `${inches.toFixed(2)} in`;
return `${(inches / 12).toFixed(2)} ft`;
}
export interface MeasureScale {
/** real_world_value = pdf_points * factor */
factor: number;
/** e.g. "ft", "m" */
unit: string;
/** Human-readable ratio from PDF, e.g. "1 in = 10 ft" */
ratioLabel: string;
}
export interface ViewportScale {
/** BBox in PDF user space (bottom-left origin). null = entire page. */
bbox: [number, number, number, number] | null;
scale: MeasureScale;
}
export interface PageScaleInfo {
viewports: ViewportScale[];
/** Page height in PDF points — used to flip screen-y (top=0) to PDF-y (bottom=0). */
pageHeight: number;
}
export type PageMeasureScales = Map<number, PageScaleInfo>;
/**
* Given the start/end PagePoints of a measurement, find the scale from the
* viewport whose BBox contains the midpoint. Falls back to the first viewport
* if none contains it (handles whole-page viewports with bbox=null).
*/
function pickScale(
start: PagePoint,
end: PagePoint,
pageMeasureScales: PageMeasureScales,
): MeasureScale | null {
if (start.pageIndex !== end.pageIndex) return null;
const info = pageMeasureScales.get(start.pageIndex);
if (!info?.viewports.length) return null;
// Midpoint in screen-space page coords (x left→right, y top→bottom, PDF points)
const mx = (start.x + end.x) / 2;
// Flip y: screen y=0 is page top; PDF user space y=0 is page bottom
const my = info.pageHeight - (start.y + end.y) / 2;
for (const { bbox, scale } of info.viewports) {
if (!bbox) return scale; // whole-page viewport
const [x0, y0, x1, y1] = bbox;
if (mx >= Math.min(x0, x1) && mx <= Math.max(x0, x1) &&
my >= Math.min(y0, y1) && my <= Math.max(y0, y1)) {
return scale;
}
}
return null;
}
function formatScaled(pts: number, scale: MeasureScale): string {
const val = pts * scale.factor;
if (val >= 1000) return `${val.toFixed(0)} ${scale.unit}`;
if (val >= 100) return `${val.toFixed(1)} ${scale.unit}`;
if (val >= 10) return `${val.toFixed(2)} ${scale.unit}`;
return `${val.toFixed(3)} ${scale.unit}`;
}
// Conversion factors to metres for known units
const TO_METRES: Record<string, number> = {
m: 1, cm: 0.01, mm: 0.001, km: 1000,
ft: 0.3048, in: 0.0254, yd: 0.9144, mi: 1609.344,
};
function isImperialUnit(unit: string): boolean {
return ['ft', 'in', 'yd', 'mi'].includes(unit.toLowerCase().trim());
}
function formatMetricFromMetres(m: number): string {
if (m >= 1000) return `${(m / 1000).toFixed(2)} km`;
if (m >= 1) return `${m.toFixed(1)} m`;
if (m >= 0.1) return `${(m * 100).toFixed(1)} cm`;
return `${(m * 1000).toFixed(1)} mm`;
}
function formatImperialFromFeet(ft: number): string {
if (ft >= 1) return `${ft.toFixed(2)} ft`;
return `${(ft * 12).toFixed(2)} in`;
}
/**
* Returns the scaled real-world value in the *other* unit system, or null if
* the unit is not a recognised metric/imperial unit.
* e.g. 72 pts, scale {factor:0.138889, unit:"ft"} → "3.048 m"
* 72 pts, scale {factor:0.352778, unit:"m"} → "1.157 ft" (approx)
*/
function scaledCross(pts: number, scale: MeasureScale): string | null {
const toM = TO_METRES[scale.unit.toLowerCase().trim()];
if (!toM) return null;
const metres = pts * scale.factor * toM;
return isImperialUnit(scale.unit)
? formatMetricFromMetres(metres)
: formatImperialFromFeet(metres / 0.3048);
}
// ─── DOM helpers ──────────────────────────────────────────────────────────────
function findScrollEl(root: HTMLElement): HTMLElement | null {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node: Node | null;
while ((node = walker.nextNode())) {
const el = node as HTMLElement;
if (el === root) continue;
const { overflow, overflowY, overflowX } = window.getComputedStyle(el);
if ([overflow, overflowY, overflowX].some(v => v === 'auto' || v === 'scroll')) {
return el;
}
}
return null;
}
function isOverPage(e: MouseEvent): boolean {
return !!(e.target as Element).closest?.('[data-page-index]');
}
/**
* Find the nearest point on any page boundary and return it as both
* an SVG screen coordinate and a PagePoint (page-relative PDF units).
* Used to clamp the live line when the cursor drifts off the page.
*/
function nearestPageDocPt(
cursor: Point,
container: HTMLElement,
zoom: number,
): { screenPt: Point; docPt: PagePoint } | null {
const pages = container.querySelectorAll('[data-page-index]');
if (!pages.length) return null;
const cr = container.getBoundingClientRect();
let bestDist = Infinity;
let best: { screenPt: Point; docPt: PagePoint } | null = null;
pages.forEach(pageNode => {
const pageEl = pageNode as HTMLElement;
const r = pageEl.getBoundingClientRect();
const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10);
// Page bounds in SVG (container-relative) space
const left = r.left - cr.left;
const top = r.top - cr.top;
const right = r.right - cr.left;
const bottom = r.bottom - cr.top;
// Nearest point on this rect to the cursor (SVG space)
const cx = Math.max(left, Math.min(right, cursor.x));
const cy = Math.max(top, Math.min(bottom, cursor.y));
const d = Math.sqrt((cursor.x - cx) ** 2 + (cursor.y - cy) ** 2);
if (d < bestDist) {
bestDist = d;
// Convert SVG-space point (cx, cy) → page-relative viewport → PDF points:
// viewport position of cx = cr.left + cx
// page-relative position = (cr.left + cx) - r.left
// PDF units = page-relative / zoom
best = {
screenPt: { x: cx, y: cy },
docPt: {
pageIndex,
x: (cr.left + cx - r.left) / zoom,
y: (cr.top + cy - r.top ) / zoom,
},
};
}
});
return best;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
const TICK = 10;
const DOT_R = 5;
const LH = 26; // label height (normal — 1 line)
const LH2 = 44; // label height (hovered, no scale — 2 lines)
const LH3 = 62; // label height (hovered, with scale — 3 lines)
const LP = 10; // label horizontal padding
const DEL_R = 8;
interface MeasurementLineProps {
id: string;
startS: Point;
endS: Point;
/** Physical distance in PDF points (= screen pixel distance / zoom). */
distPts: number;
hovered: boolean;
onDelete: (id: string) => void;
onHover: (id: string | null) => void;
measureScale?: MeasureScale | null;
}
function MeasurementLine({ id, startS, endS, distPts, hovered, onDelete, onHover, measureScale }: MeasurementLineProps) {
const mid = midpoint(startS, endS);
const { nx, ny } = perpUnit(startS, endS);
const ang = angleDeg(startS, endS);
const angLabel = `${ang.toFixed(1)}°`;
// Whether the PDF's unit is imperial — determines display order (imperial-first vs metric-first)
const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit);
// Idle: scaled primary if scale present, else physical metric
const distLabel = measureScale ? formatScaled(distPts, measureScale) : formatDist(distPts);
// Hover line 1 — both real-world values ordered by PDF unit system:
// imperial PDF: "10.000 ft / 3.048 m"
// metric PDF: "142.5 m / 467.5 ft"
// no scale: "25.4 mm / 1.00 in" (metric first, default)
const hoverLine1 = measureScale
? (() => {
const primary = formatScaled(distPts, measureScale);
const cross = scaledCross(distPts, measureScale);
return cross ? `${primary} / ${cross}` : primary;
})()
: `${formatDist(distPts)} / ${formatInches(distPts)}`;
// Hover line 2 — both physical paper values, same order as line 1:
// imperial PDF: "1.00 in / 25.4 mm"
// metric PDF or no scale: "25.4 mm / 1.00 in"
const hoverLine2 = measureScale
? (imperialFirst
? `${formatInches(distPts)} / ${formatDist(distPts)}`
: `${formatDist(distPts)} / ${formatInches(distPts)}`)
: null;
// Hover line 3 (scaled) / line 2 (no scale) — ratio label + angle
const contextLabel = measureScale?.ratioLabel
? `${measureScale.ratioLabel} ${angLabel}`
: angLabel;
const maxHoverLh = measureScale ? LH3 : LH2;
const lh = hovered ? maxHoverLh : LH;
const lwNormal = Math.max(distLabel.length * 8 + LP * 2, 80);
const lwHover = Math.max(
hoverLine1.length * 8 + LP * 2,
(hoverLine2?.length ?? 0) * 8 + LP * 2,
contextLabel.length * 8 + LP * 2,
80,
);
const lw = hovered ? lwHover : lwNormal;
const sw = hovered ? 3 : 2;
const delX = mid.x + lwHover / 2 + DEL_R + 4;
const delY = mid.y;
const hitLeft = mid.x - lwHover / 2 - 4;
const hitTop = mid.y - maxHoverLh / 2 - 4;
const hitWidth = (delX + DEL_R + 4) - hitLeft;
const hitHeight = maxHoverLh + 8;
const mono = "'Roboto Mono','Consolas',monospace";
return (
<g
onMouseEnter={() => onHover(id)}
onMouseLeave={() => onHover(null)}
style={{ pointerEvents: 'all' }}
>
<rect x={hitLeft} y={hitTop} width={hitWidth} height={hitHeight}
fill="transparent" stroke="none" style={{ pointerEvents: 'all' }} />
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
<line x1={endS.x + nx * TICK / 2} y1={endS.y + ny * TICK / 2}
x2={endS.x - nx * TICK / 2} y2={endS.y - ny * TICK / 2}
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
<circle cx={startS.x} cy={startS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
<circle cx={endS.x} cy={endS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
<g style={{ pointerEvents: 'all', cursor: 'default' }}>
<rect x={mid.x - lw / 2} y={mid.y - lh / 2} width={lw} height={lh}
rx={5} fill="white" stroke="#1e88e5" strokeWidth={1.5} filter="url(#ruler-shadow)" />
{hovered && measureScale ? (
// 3-line scaled hover
<>
<text x={mid.x} y={mid.y - 17} textAnchor="middle" dominantBaseline="middle"
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
style={{ userSelect: 'none' }}>{hoverLine1}</text>
<text x={mid.x} y={mid.y} textAnchor="middle" dominantBaseline="middle"
fill="#546e7a" fontSize={11} fontFamily={mono} fontWeight={500}
style={{ userSelect: 'none' }}>{hoverLine2}</text>
<text x={mid.x} y={mid.y + 17} textAnchor="middle" dominantBaseline="middle"
fill="#5c6bc0" fontSize={10} fontFamily={mono} fontWeight={500}
style={{ userSelect: 'none' }}>{contextLabel}</text>
</>
) : hovered ? (
// 2-line no-scale hover
<>
<text x={mid.x} y={mid.y - 6} textAnchor="middle" dominantBaseline="middle"
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
style={{ userSelect: 'none' }}>{hoverLine1}</text>
<text x={mid.x} y={mid.y + 13} textAnchor="middle" dominantBaseline="middle"
fill="#5c6bc0" fontSize={11} fontFamily={mono} fontWeight={500}
style={{ userSelect: 'none' }}>{contextLabel}</text>
</>
) : (
// Idle — single line
<text x={mid.x} y={mid.y + 1} textAnchor="middle" dominantBaseline="middle"
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
style={{ userSelect: 'none' }}>{distLabel}</text>
)}
<g style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); onDelete(id); }}>
<circle cx={delX} cy={delY} r={DEL_R} fill="#ef5350" stroke="white" strokeWidth={1.5} />
<text x={delX} y={delY} textAnchor="middle" dominantBaseline="middle"
fill="white" fontSize={12} fontWeight={700} style={{ userSelect: 'none' }}>×</text>
</g>
</g>
</g>
);
}
interface LiveLineProps {
startS: Point;
endS: Point;
zoom: number;
measureScale?: MeasureScale | null;
}
function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) {
const d = dist(startS, endS) / zoom; // PDF points from screen distance
const mid = midpoint(startS, endS);
const { nx, ny } = perpUnit(startS, endS);
const ang = angleDeg(startS, endS);
const distLabel = measureScale ? formatScaled(d, measureScale) : formatDist(d);
const lw = Math.max(distLabel.length * 8 + LP * 2, 80);
return (
<g>
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
stroke="#1e88e5" strokeWidth={2} strokeDasharray="7 4"
strokeLinecap="round" opacity={0.85} />
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
stroke="#1e88e5" strokeWidth={2} strokeLinecap="round" />
{d > 4 && (
<g>
<rect x={mid.x - lw / 2} y={mid.y - LH2 / 2} width={lw} height={LH2}
rx={5} fill="#1e88e5" stroke="white" strokeWidth={1} />
<text x={mid.x} y={mid.y - 6}
textAnchor="middle" dominantBaseline="middle"
fill="white" fontSize={12}
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={600}
style={{ userSelect: 'none' }}>
{distLabel}
</text>
<text x={mid.x} y={mid.y + 13}
textAnchor="middle" dominantBaseline="middle"
fill="rgba(255,255,255,0.85)" fontSize={11}
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={500}
style={{ userSelect: 'none' }}>
{`${ang.toFixed(1)}°`}
</text>
</g>
)}
</g>
);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
export const RulerOverlay = React.forwardRef<RulerOverlayHandle, RulerOverlayProps>(
({ containerRef, isActive, pageMeasureScales }, ref) => {
const [measurements, setMeasurements] = useState<Measurement[]>([]);
const [firstPt, setFirstPt] = useState<PagePoint | null>(null);
/** Current cursor in SVG screen-space — for live crosshair and live line rendering. */
const [cursorS, setCursorS] = useState<Point | null>(null);
/** Current cursor in page-relative PDF units — for finalising off-page clicks. */
const [cursorDoc, setCursorDoc] = useState<PagePoint | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
/**
* Incremented on scroll to trigger re-renders.
* We no longer store the scroll value — getBoundingClientRect handles that
* automatically and is always accurate regardless of scroll position.
*/
const [, setScrollVersion] = useState(0);
const scrollElRef = useRef<HTMLElement | null>(null);
const scrollCleanupRef = useRef<(() => void) | null>(null);
const idCounter = useRef(0);
const firstPtRef = useRef<PagePoint | null>(null);
useEffect(() => { firstPtRef.current = firstPt; }, [firstPt]);
const cursorDocRef = useRef<PagePoint | null>(null);
// ── Zoom ──────────────────────────────────────────────────────────────────
const viewer = useViewer();
const { registerImmediateZoomUpdate } = viewer;
const [zoom, setZoom] = useState<number>(() => {
try { return ((viewer.getZoomState() as any)?.zoomPercent ?? 140) / 100; }
catch { return 1.4; }
});
const zoomRef = useRef(zoom);
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
useEffect(() => {
return registerImmediateZoomUpdate((pct) => {
const newZoom = pct / 100;
zoomRef.current = newZoom; // immediate for event-listener closures
setZoom(newZoom); // re-render #1: zoom updated, but PDF.js DOM may not be yet
// re-render #2: after PDF.js has updated page element dimensions in the DOM,
// so getBoundingClientRect returns the correct positions for the new zoom level.
requestAnimationFrame(() => setScrollVersion(n => n + 1));
});
}, [registerImmediateZoomUpdate]);
// ── Scroll tracking ────────────────────────────────────────────────────────
// We only need re-renders on scroll; getBoundingClientRect gives us accurate
// positions without needing to know the scroll offset ourselves.
const attachScrollEl = useCallback((el: HTMLElement) => {
scrollCleanupRef.current?.();
scrollElRef.current = el;
const handler = () => setScrollVersion(n => n + 1);
el.addEventListener('scroll', handler, { passive: true });
scrollCleanupRef.current = () => el.removeEventListener('scroll', handler);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const tryAttach = () => {
const el = findScrollEl(container);
if (el) { attachScrollEl(el); return true; }
return false;
};
if (!tryAttach()) {
const timer = setTimeout(() => tryAttach(), 600);
return () => { clearTimeout(timer); scrollCleanupRef.current?.(); };
}
return () => scrollCleanupRef.current?.();
}, [containerRef, attachScrollEl]);
// Re-find scroll element when zoom changes (PDF.js may recreate the scroll DOM).
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const el = findScrollEl(container);
if (el && el !== scrollElRef.current) attachScrollEl(el);
}, [zoom, containerRef, attachScrollEl]);
// ── Imperative handle ──────────────────────────────────────────────────────
React.useImperativeHandle(ref, () => ({
clearAll: () => { setMeasurements([]); setFirstPt(null); setCursorS(null); setCursorDoc(null); },
}));
// ── Reset when deactivated ─────────────────────────────────────────────────
useEffect(() => {
if (!isActive) { setFirstPt(null); setCursorS(null); setCursorDoc(null); }
}, [isActive]);
// ── Mouse events ───────────────────────────────────────────────────────────
useEffect(() => {
const el = containerRef.current;
if (!isActive || !el) return;
const toScreenPt = (e: MouseEvent): Point => {
const r = el.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
/**
* Convert a mouse event to a page-relative PagePoint.
* Returns null if the cursor is not directly over a page element.
*/
const toDocPagePt = (e: MouseEvent): PagePoint | null => {
const pageEl = (e.target as Element).closest?.('[data-page-index]') as HTMLElement | null;
if (!pageEl) return null;
const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10);
const r = pageEl.getBoundingClientRect();
const z = zoomRef.current;
return { pageIndex, x: (e.clientX - r.left) / z, y: (e.clientY - r.top) / z };
};
const clearCursor = () => {
setCursorS(null);
setCursorDoc(null);
cursorDocRef.current = null;
};
const onMove = (e: MouseEvent) => {
const screenPt = toScreenPt(e);
if (isOverPage(e)) {
el.style.cursor = 'crosshair';
const docPt = toDocPagePt(e);
setCursorS(screenPt);
setCursorDoc(docPt);
cursorDocRef.current = docPt;
} else if (firstPtRef.current !== null) {
// First point placed, cursor wandered off page — clamp to nearest edge
el.style.cursor = 'crosshair';
const result = nearestPageDocPt(screenPt, el, zoomRef.current);
if (result) {
setCursorS(result.screenPt);
setCursorDoc(result.docPt);
cursorDocRef.current = result.docPt;
}
} else {
el.style.cursor = 'default';
clearCursor();
}
};
const onClick = (e: MouseEvent) => {
if (e.button !== 0) return;
if ((e.target as Element).closest?.('[data-ruler-interactive]')) return;
const overPage = isOverPage(e);
if (!overPage && firstPtRef.current === null) return;
e.preventDefault();
const dp = overPage ? toDocPagePt(e) : cursorDocRef.current;
if (!dp) return;
setFirstPt(prev => {
if (!prev) { firstPtRef.current = dp; return dp; }
firstPtRef.current = null;
const id = `ruler-${++idCounter.current}`;
setMeasurements(m => [...m, { id, start: prev, end: dp }]);
return null;
});
};
const onLeave = () => {
el.style.cursor = '';
if (firstPtRef.current === null) clearCursor();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { setFirstPt(null); setCursorS(null); setCursorDoc(null); }
};
el.addEventListener('mousemove', onMove);
el.addEventListener('click', onClick);
el.addEventListener('mouseleave', onLeave);
document.addEventListener('keydown', onKey);
return () => {
el.removeEventListener('mousemove', onMove);
el.removeEventListener('click', onClick);
el.removeEventListener('mouseleave', onLeave);
document.removeEventListener('keydown', onKey);
el.style.cursor = '';
};
}, [containerRef, isActive]);
const deleteMeasurement = useCallback((id: string) => {
setMeasurements(prev => prev.filter(m => m.id !== id));
}, []);
if (!isActive && measurements.length === 0) return null;
// ── PagePoint → SVG screen coordinates ────────────────────────────────────
/**
* Convert a page-anchored point to SVG screen coordinates.
*
* Uses getBoundingClientRect so the browser computes the exact screen position
* accounting for scroll, zoom, page margins, centering — everything. This is
* why we no longer need to track scroll offsets.
*
* Returns null if the page element isn't in the DOM (shouldn't happen with
* PDF.js placeholder divs, but guard anyway).
*/
const pagePointToScreen = (pt: PagePoint): Point | null => {
const container = containerRef.current;
if (!container) return null;
const pageEl = container.querySelector(`[data-page-index="${pt.pageIndex}"]`) as HTMLElement | null;
if (!pageEl) return null;
const pageRect = pageEl.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return {
x: pageRect.left - containerRect.left + pt.x * zoom,
y: pageRect.top - containerRect.top + pt.y * zoom,
};
};
const firstPtS = firstPt ? pagePointToScreen(firstPt) : null;
return (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'visible',
zIndex: 100,
}}
>
<defs>
<filter id="ruler-shadow" x="-20%" y="-50%" width="140%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="rgba(0,0,0,0.22)" />
</filter>
</defs>
{/* Completed measurements */}
{measurements.map(m => {
const startS = pagePointToScreen(m.start);
const endS = pagePointToScreen(m.end);
if (!startS || !endS) return null;
const mScale = pageMeasureScales ? pickScale(m.start, m.end, pageMeasureScales) : null;
return (
<MeasurementLine
key={m.id}
id={m.id}
startS={startS}
endS={endS}
distPts={dist(startS, endS) / zoom}
hovered={hoveredId === m.id}
onDelete={deleteMeasurement}
onHover={setHoveredId}
measureScale={mScale}
/>
);
})}
{/* Live line while drawing */}
{isActive && firstPtS && cursorS && (
<LiveLine
startS={firstPtS} endS={cursorS} zoom={zoom}
measureScale={pageMeasureScales && firstPt && cursorDoc
? pickScale(firstPt, cursorDoc, pageMeasureScales)
: null}
/>
)}
{/* First-point anchor dot */}
{isActive && firstPtS && (
<circle cx={firstPtS.x} cy={firstPtS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
)}
{/* Crosshair */}
{isActive && cursorS && (
<g opacity={0.75}>
<line x1={cursorS.x - 12} y1={cursorS.y} x2={cursorS.x + 12} y2={cursorS.y} stroke="#1e88e5" strokeWidth={1.5} />
<line x1={cursorS.x} y1={cursorS.y - 12} x2={cursorS.x} y2={cursorS.y + 12} stroke="#1e88e5" strokeWidth={1.5} />
<circle cx={cursorS.x} cy={cursorS.y} r={2} fill="#1e88e5" />
</g>
)}
{/* Clear all */}
{measurements.length > 0 && (
<g data-ruler-interactive="true" style={{ pointerEvents: 'all', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setMeasurements([]); }}>
<rect x={8} y={8} width={88} height={26} rx={5}
fill="rgba(239,83,80,0.9)" stroke="white" strokeWidth={1} />
<text x={52} y={25} textAnchor="middle" fill="white" fontSize={12}
fontFamily="sans-serif" fontWeight={600} style={{ userSelect: 'none' }}>
Clear all
</text>
</g>
)}
</svg>
);
}
);
RulerOverlay.displayName = 'RulerOverlay';
@@ -14,8 +14,12 @@ import { useNavigationState, useNavigationGuard } from '@app/contexts/Navigation
import { BASE_PATH, withBasePath } from '@app/constants/app';
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import StraightenIcon from '@mui/icons-material/Straighten';
export function useViewerRightRailButtons() {
export function useViewerRightRailButtons(
isRulerActive?: boolean,
setIsRulerActive?: (v: boolean) => void,
) {
const { t, i18n } = useTranslation();
const viewer = useViewer();
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
@@ -82,6 +86,8 @@ export function useViewerRightRailButtons() {
const isFormFillActive = (selectedTool as string) === 'formFill';
const rulerLabel = t('rightRail.ruler', 'Ruler / Measure');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
const buttons: RightRailButtonWithAction[] = [
{
@@ -137,6 +143,24 @@ export function useViewerRightRailButtons() {
setIsPanning(prev => !prev);
},
},
{
id: 'viewer-ruler',
icon: <StraightenIcon sx={{ fontSize: '1.5rem' }} />,
tooltip: rulerLabel,
ariaLabel: rulerLabel,
section: 'top' as const,
order: 25,
active: Boolean(isRulerActive),
onClick: () => {
const next = !isRulerActive;
setIsRulerActive?.(next);
// Disable pan when activating ruler — they conflict
if (next && viewer.getPanState()?.isPanning) {
viewer.panActions.togglePan();
setIsPanning(false);
}
},
},
{
id: 'viewer-rotate-left',
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
@@ -317,6 +341,9 @@ export function useViewerRightRailButtons() {
redactionActiveType,
formFillLabel,
isFormFillActive,
rulerLabel,
isRulerActive,
setIsRulerActive,
]);
useRightRailButtons(viewerButtons);
@@ -60,6 +60,8 @@ export interface AppConfig {
isNewUser?: boolean;
defaultHideUnavailableTools?: boolean;
defaultHideUnavailableConversions?: boolean;
pluginsPath?: string;
basePath?: string;
}
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
@@ -0,0 +1,163 @@
import React, { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
import apiClient from "@app/services/apiClient";
import { useAppConfig } from "@app/contexts/AppConfigContext";
interface PluginResponse {
id: string;
icon: string;
name: string;
description?: string;
version?: string;
author?: string;
frontendUrl?: string;
frontendLabel?: string;
iconPath?: string;
hasFrontend?: boolean;
backendEndpoints?: string[];
minHostVersion: string;
iconUrl?: string;
jarCreatedAt?: string;
}
export interface PluginInfo {
icon: ReactNode;
id: string;
name: string;
description: string;
version?: string;
author?: string;
hasFrontend: boolean;
frontendUrl?: string;
frontendLabel?: string;
iconPath?: string;
backendEndpoints: string[];
minHostVersion: string;
iconUrl?: string;
jarCreatedAt?: string;
}
interface PluginRegistryState {
plugins: PluginInfo[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
}
const PluginRegistryContext = createContext<PluginRegistryState | undefined>(undefined);
const buildFrontendUrl = (response: PluginResponse): string | undefined => {
if (!response.frontendUrl) {
return undefined;
}
return response.frontendUrl;
};
const buildIconUrl = (path?: string, baseUrl?: string): string | undefined => {
if (!path) return undefined;
if (path.startsWith("http")) return path;
const normalizedBase =
baseUrl?.replace(/\/+$/, "") || (typeof window !== "undefined" ? window.location.origin.replace(/\/+$/, "") : "");
if (!normalizedBase) return path;
return `${normalizedBase}${path.startsWith("/") ? path : `/${path}`}`;
};
const buildPluginInfo = (response: PluginResponse, baseUrl?: string): PluginInfo => {
const frontendBase = response.frontendUrl
? new URL(response.frontendUrl).origin
: baseUrl;
return {
id: response.id,
icon: response.icon,
name: response.name,
description: response.description || "",
version: response.version,
author: response.author,
hasFrontend: Boolean(response.hasFrontend),
frontendUrl: buildFrontendUrl(response),
frontendLabel: response.frontendLabel,
iconPath: response.iconPath,
backendEndpoints: response.backendEndpoints ?? [],
minHostVersion: response.minHostVersion,
iconUrl: buildIconUrl(response.iconPath, frontendBase),
jarCreatedAt: response.jarCreatedAt,
};
};
export const PluginRegistryProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { loading: configLoading, config } = useAppConfig();
const [plugins, setPlugins] = useState<PluginInfo[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchPlugins = useCallback(async () => {
setLoading(true);
setError(null);
try {
const response = await apiClient.get<PluginResponse[]>("/api/v1/config/plugins");
const normalized = response.data
.map((plugin) => buildPluginInfo(plugin, config?.baseUrl))
.sort((a, b) => a.name.localeCompare(b.name));
setPlugins(normalized);
} catch (err: any) {
if (err?.response?.status === 401) {
setPlugins([]);
setError(null);
} else {
const message = err?.response?.data?.message || err?.message || "Unable to load plugins";
setError(message);
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!configLoading) {
if (typeof window === "undefined") {
void fetchPlugins();
return;
}
const path = window.location.pathname;
const isAuthPage =
path.includes("/login") || path.includes("/signup") || path.includes("/auth/callback") || path.includes("/invite/");
if (!isAuthPage) {
void fetchPlugins();
}
}
}, [configLoading, fetchPlugins]);
const value = useMemo(
() => ({
plugins,
loading,
error,
refresh: fetchPlugins,
}),
[plugins, loading, error, fetchPlugins],
);
return <PluginRegistryContext.Provider value={value}>{children}</PluginRegistryContext.Provider>;
};
export const usePluginRegistry = () => {
const context = useContext(PluginRegistryContext);
if (context) {
return context;
}
if (process.env.NODE_ENV !== "production") {
console.warn("[PluginRegistryContext] usePluginRegistry called outside PluginRegistryProvider - returning fallback state");
}
return {
plugins: [],
loading: false,
error: null,
refresh: async () => {},
};
};
+22 -47
View File
@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
// Track globally fetched endpoint sets to prevent duplicate fetches across components
const globalFetchedSets = new Set<string>();
// Track whether we've done the global fetch to prevent duplicate requests
let globalFetchDone = false;
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
/**
@@ -72,17 +72,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const [error, setError] = useState<string | null>(null);
const fetchAllEndpointStatuses = async (force = false) => {
const endpointsKey = [...endpoints].sort().join(',');
// Skip if we already fetched these exact endpoints globally
if (!force && globalFetchedSets.has(endpointsKey)) {
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
// Skip if already fetched globally and not forced
if (!force && globalFetchDone) {
console.debug('[useEndpointConfig] Using global cache');
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
} else {
acc.status[endpoint] = true;
}
return acc;
},
@@ -93,6 +93,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setLoading(false);
return;
}
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setEndpointDetails({});
@@ -103,45 +104,21 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
try {
setLoading(true);
setError(null);
console.debug('[useEndpointConfig] Fetching endpoint statuses', { count: endpoints.length, force });
console.debug('[useEndpointConfig] Fetching all endpoint statuses from server');
// Check which endpoints we haven't fetched yet
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
if (newEndpoints.length === 0) {
console.debug('[useEndpointConfig] All endpoints already in global cache');
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
globalFetchedSets.add(endpointsKey);
setLoading(false);
return;
}
// Fetch all endpoints at once - no query params needed
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability`);
// Use batch API for efficiency - only fetch new endpoints
const endpointsParam = newEndpoints.join(',');
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
const statusMap = response.data;
// Update global cache with new results
Object.entries(statusMap).forEach(([endpoint, details]) => {
// Populate global cache with all results
Object.entries(response.data).forEach(([endpoint, details]) => {
globalEndpointCache[endpoint] = {
enabled: details?.enabled ?? true,
reason: details?.reason ?? null,
};
});
globalFetchDone = true;
// Get all requested endpoints from cache (including previously cached ones)
// Return status for the requested endpoints
const fullStatus = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
@@ -158,17 +135,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setEndpointStatus(fullStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
globalFetchedSets.add(endpointsKey);
} catch (err: any) {
// On 401 (auth error), use optimistic fallback instead of disabling
if (err.response?.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
endpoints.forEach(endpoint => {
globalEndpointCache[endpoint] = { enabled: true, reason: null };
});
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = optimisticDetails;
globalEndpointCache[endpoint] = optimisticDetails;
acc.details[endpoint] = { enabled: true, reason: null };
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
@@ -181,14 +158,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
console.error('[EndpointConfig] Failed to check endpoints:', err);
// Fallback: assume all endpoints are enabled on error (optimistic)
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = optimisticDetails;
acc.details[endpoint] = { enabled: true, reason: null };
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
@@ -208,8 +184,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[useEndpointConfig] JWT available event - clearing cache for refetch with auth');
// Clear the global cache to allow refetch with JWT
globalFetchedSets.clear();
globalFetchDone = false;
Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]);
fetchAllEndpointStatuses(true);
};
+27 -11
View File
@@ -22,6 +22,7 @@ import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
import "@app/pages/HomePage.css";
@@ -60,22 +61,37 @@ export default function HomePage() {
const { setActiveFileIndex } = useViewer();
const prevFileCountRef = useRef(activeFiles.length);
// Auto-switch to viewer when going from 0 to 1 file
// Skip this if PDF Text Editor is active - it handles its own empty state
// Startup/open transition behavior:
// - opening exactly 1 file from empty -> viewer (unless already in fileEditor)
// - opening 2+ files from empty -> fileEditor
useEffect(() => {
const prevCount = prevFileCountRef.current;
const currentCount = activeFiles.length;
if (
navigationState.workbench !== 'fileEditor' &&
prevCount === 0 &&
currentCount === 1
) {
// PDF Text Editor handles its own empty state with a dropzone
if (selectedToolKey !== 'pdfTextEditor') {
actions.setWorkbench('viewer');
setActiveFileIndex(0);
console.log('[HomePage] Navigation effect triggered:', {
prevCount,
currentCount,
currentWorkbench: navigationState.workbench,
selectedToolKey,
});
const action = getStartupNavigationAction(
prevCount,
currentCount,
selectedToolKey,
navigationState.workbench
);
console.log('[HomePage] Navigation action returned:', action);
if (action) {
console.log('[HomePage] Applying navigation:', action);
actions.setWorkbench(action.workbench);
if (typeof action.activeFileIndex === 'number') {
setActiveFileIndex(action.activeFileIndex);
}
} else {
console.log('[HomePage] No navigation - staying in current workbench');
}
prevFileCountRef.current = currentCount;
+351
View File
@@ -0,0 +1,351 @@
import { Box, Button, Group, Text } from "@mantine/core";
import { useNavigate, useParams, useLocation } from "react-router-dom";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { PluginInfo, usePluginRegistry } from "@app/contexts/PluginRegistryContext";
const URL_ATTRIBUTES_TO_NORMALIZE = new Set([
"src",
"href",
"action",
"poster",
"formaction",
"data-src",
"data-href",
"data-url",
"data-background",
"xlink:href",
"srcset",
]);
const ABSOLUTE_URL_PATTERN = /^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|\/\/|#)/;
const resolveRelativeUrl = (value: string, base: string | null): string => {
if (!value || !base) {
return value;
}
const trimmed = value.trim();
if (!trimmed || ABSOLUTE_URL_PATTERN.test(trimmed)) {
return value;
}
try {
return new URL(trimmed, base).toString();
} catch {
return value;
}
};
const normalizeSrcset = (value: string, base: string | null): string => {
if (!base) {
return value;
}
return value
.split(",")
.map((segment) => {
const trimmed = segment.trim();
if (!trimmed) {
return "";
}
const [url, descriptor] = trimmed.split(/\s+/, 2);
if (!url) {
return "";
}
const resolved = resolveRelativeUrl(url, base);
return descriptor ? `${resolved} ${descriptor}` : resolved;
})
.filter(Boolean)
.join(", ");
};
const normalizeStyleUrls = (value: string, base: string | null): string => {
if (!base) {
return value;
}
return value.replace(/url\(([^)]+)\)/g, (_, rawUrl) => {
const trimmed = rawUrl.trim();
const cleaned = trimmed.replace(/^["']|["']$/g, "");
const resolved = resolveRelativeUrl(cleaned, base);
if (!resolved) {
return `url(${rawUrl})`;
}
return `url("${resolved}")`;
});
};
const copyAttributes = (source: Element, target: Element, base: string | null) => {
Array.from(source.attributes).forEach((attribute) => {
if (attribute.name === "style") {
return;
}
const lowerName = attribute.name.toLowerCase();
if (URL_ATTRIBUTES_TO_NORMALIZE.has(lowerName)) {
if (lowerName === "srcset") {
target.setAttribute(attribute.name, normalizeSrcset(attribute.value, base));
} else {
target.setAttribute(attribute.name, resolveRelativeUrl(attribute.value, base));
}
return;
}
target.setAttribute(attribute.name, attribute.value);
});
const styleValue = source.getAttribute("style");
if (styleValue) {
target.setAttribute("style", normalizeStyleUrls(styleValue, base));
}
};
const DOMTextConstructor = typeof globalThis.Text === "function" ? globalThis.Text : null;
const DOMCommentConstructor = typeof globalThis.Comment === "function" ? globalThis.Comment : null;
const cloneNodeWithResolvedUrls = (node: ChildNode, base: string | null): Node | null => {
if (DOMTextConstructor ? node instanceof DOMTextConstructor : node.nodeType === Node.TEXT_NODE) {
return document.createTextNode(node.textContent ?? "");
}
if (DOMCommentConstructor ? node instanceof DOMCommentConstructor : node.nodeType === Node.COMMENT_NODE) {
return document.createComment(node.textContent ?? "");
}
if (!(node instanceof Element)) {
return node.cloneNode(true);
}
const tagName = node.tagName.toLowerCase();
if (tagName === "script") {
const script = document.createElement("script");
copyAttributes(node, script, base);
script.text = node.textContent ?? "";
return script;
}
const clone = node.cloneNode(false) as Element;
copyAttributes(node, clone, base);
node.childNodes.forEach((child) => {
const normalizedChild = cloneNodeWithResolvedUrls(child, base);
if (normalizedChild) {
clone.appendChild(normalizedChild);
}
});
return clone;
};
export default function PluginPage() {
const { id } = useParams<{ id: string }>();
const pluginRegistry = usePluginRegistry();
const plugins = pluginRegistry?.plugins ?? [];
const loading = pluginRegistry?.loading ?? false;
const navigate = useNavigate();
const location = useLocation();
const navigationPlugin = location.state?.plugin as PluginInfo | undefined;
const plugin = useMemo(() => navigationPlugin ?? plugins.find((p: { id: any; }) => p.id === id), [navigationPlugin, plugins, id]);
const pluginBaseUrl = useMemo(() => {
if (!plugin?.frontendUrl) {
return null;
}
try {
const url = new URL(plugin.frontendUrl);
url.hash = "";
url.search = "";
url.pathname = url.pathname.replace(/\/[^/]*$/, "/");
return url.toString();
} catch {
return null;
}
}, [plugin?.frontendUrl]);
const containerRef = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
const [loadingHtml, setLoadingHtml] = useState(true);
useEffect(() => {
if (!plugin?.frontendUrl) {
console.log("[PluginPage] Plugin missing frontendUrl, skipping fetch");
setLoadingHtml(false);
containerRef.current?.replaceChildren();
return;
}
let cancelled = false;
const fetchHtml = async () => {
try {
setLoadingHtml(true);
setError(null);
console.log(`[PluginPage] Fetching plugin HTML from ${plugin.frontendUrl}`);
const response = await fetch(plugin.frontendUrl!, { credentials: "include" });
if (!response.ok) {
throw new Error(response.statusText || "Failed to load plugin");
}
const html = await response.text();
if (cancelled) return;
console.debug(`[PluginPage] Successfully loaded plugin HTML (${html.length} chars)`);
if (containerRef.current) {
containerRef.current.innerHTML = "";
const dom = new DOMParser().parseFromString(html, "text/html");
const nodes = [...Array.from(dom.head.childNodes), ...Array.from(dom.body.childNodes)];
const fragment = document.createDocumentFragment();
nodes.forEach((node) => {
const normalizedNode = cloneNodeWithResolvedUrls(node, pluginBaseUrl);
if (normalizedNode) {
fragment.appendChild(normalizedNode);
}
});
if (pluginBaseUrl) {
const backendPrefix = pluginBaseUrl
.replace(/\/$/, "")
.replace(/\/plugins\/[^/]+\/?$/, "");
const bridgeScript = document.createElement("script");
bridgeScript.textContent = `
window.STIRLING_PDF_PLUGIN_API_BASE = ${JSON.stringify(backendPrefix)};
window.STIRLING_PDF_PLUGIN_AUTH_TOKEN =
localStorage.getItem("stirling_jwt") ||
sessionStorage.getItem("stirling_jwt") ||
"";
`;
containerRef.current.appendChild(bridgeScript);
}
containerRef.current.appendChild(fragment);
}
} catch (err: any) {
if (!cancelled) {
console.error("[PluginPage] Failed to load plugin HTML", err);
setError(err?.message || "Unable to load plugin HTML");
}
} finally {
if (!cancelled) {
setLoadingHtml(false);
}
}
};
fetchHtml();
return () => {
cancelled = true;
};
}, [plugin, pluginBaseUrl]);
if (loading) {
return (
<Box
style={{
height: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text>Loading plugin</Text>
</Box>
);
}
if (!plugin) {
return (
<Box
style={{
padding: "1.5rem",
height: "100vh",
background: "var(--bg-app, #05070a)",
color: "white",
}}
>
<Group justify="space-between" mb="md">
<Text fw={700}>Plugin not found</Text>
<Button variant="outline" size="xs" onClick={() => navigate("/")}>
Back home
</Button>
</Group>
<Text>The requested plugin cannot be loaded right now.</Text>
</Box>
);
}
return (
<Box
component="main"
style={{
height: "100vh",
background: "#05070a",
color: "white",
display: "flex",
flexDirection: "column",
}}
>
<Group justify="space-between" align="center" style={{ padding: "1rem 1.5rem", borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
<div>
<Text fw={600} size="lg">
{plugin.name}
</Text>
<Text size="sm" color="dimmed">
{plugin.description}
</Text>
</div>
<Button size="xs" variant="outline" onClick={() => navigate("/")}>
Back to Stirling PDF
</Button>
</Group>
<Box
style={{
flex: 1,
overflow: "hidden",
position: "relative",
}}
>
{loadingHtml && (
<Box
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "rgba(5, 7, 10, 0.9)",
zIndex: 1,
}}
>
<Text>Loading plugin UI</Text>
</Box>
)}
{error && (
<Box
style={{
position: "absolute",
inset: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "tomato",
zIndex: 2,
}}
>
<Text>{error}</Text>
</Box>
)}
<Box
ref={containerRef}
style={{
height: "100%",
width: "100%",
overflow: "auto",
}}
/>
</Box>
</Box>
);
}
@@ -38,7 +38,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.4.6',
appVersion: '2.5.1',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
serverPort: 8080,
-1
View File
@@ -372,7 +372,6 @@ const Annotate = (_props: BaseToolProps) => {
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
@@ -1,7 +1,7 @@
import { useMemo, useRef, useState } from 'react';
import { useMemo, useState } from 'react';
import type React from 'react';
import { useTranslation } from 'react-i18next';
import { Text, Group, ActionIcon, Stack, Slider, Box, Tooltip as MantineTooltip, Button, Textarea, Tooltip, Paper } from '@mantine/core';
import { Text, Group, ActionIcon, Stack, Slider, Box, Tooltip as MantineTooltip, Button, Tooltip, Paper } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { ColorPicker, ColorSwatchButton } from '@app/components/annotation/shared/ColorPicker';
import { ImageUploader } from '@app/components/annotation/shared/ImageUploader';
@@ -111,7 +111,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
const { t } = useTranslation();
const [colorPickerTarget, setColorPickerTarget] = useState<ColorTarget>(null);
const [isColorPickerOpen, setIsColorPickerOpen] = useState(false);
const selectedUpdateTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const {
activeTool,
@@ -122,10 +121,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
buildToolOptions,
deriveToolFromAnnotation,
selectedAnn,
selectedTextDraft,
setSelectedTextDraft,
selectedFontSize,
setSelectedFontSize,
annotationApiRef,
viewerContext,
setPlacementMode,
@@ -549,512 +544,6 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
</Paper>
);
const selectedDerivedTool = selectedAnn?.object ? deriveToolFromAnnotation(selectedAnn.object) : undefined;
const selectedAnnotationControls = selectedAnn && (() => {
const rawType = selectedAnn.object?.type;
const toolId = selectedDerivedTool ?? deriveToolFromAnnotation(selectedAnn.object);
const derivedType =
toolId === 'highlight' ? 9
: toolId === 'underline' ? 10
: toolId === 'squiggly' ? 11
: toolId === 'strikeout' ? 12
: toolId === 'line' ? 4
: toolId === 'square' ? 5
: toolId === 'circle' ? 6
: toolId === 'polygon' ? 7
: toolId === 'polyline' ? 8
: toolId === 'text' ? 3
: toolId === 'note' ? 3
: toolId === 'stamp' ? 13
: toolId === 'ink' ? 15
: undefined;
const type = typeof rawType === 'number' ? rawType : derivedType;
if (toolId && ['highlight', 'underline', 'strikeout', 'squiggly'].includes(toolId)) {
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t('annotation.editTextMarkup', 'Edit Text Markup')}</Text>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
<ColorSwatchButton
color={selectedAnn.object?.color ?? highlightColor}
size={28}
onClick={() => {
setColorPickerTarget('highlight');
setIsColorPickerOpen(true);
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
onChange={(value) => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ opacity: value / 100 }
);
}}
/>
</Box>
</Stack>
</Paper>
);
}
if (type === 15 || toolId === 'inkHighlighter' || toolId === 'ink') {
const isHighlighter = toolId === 'inkHighlighter';
const thicknessValue =
selectedAnn.object?.strokeWidth ??
selectedAnn.object?.borderWidth ??
selectedAnn.object?.lineWidth ??
selectedAnn.object?.thickness ??
(isHighlighter ? freehandHighlighterWidth : inkWidth);
const colorValue = selectedAnn.object?.color ?? (isHighlighter ? highlightColor : inkColor);
const opacityValue = Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || (isHighlighter ? highlightOpacity : 100));
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>
{isHighlighter ? t('annotation.freehandHighlighter', 'Freehand Highlighter') : t('annotation.editInk', 'Edit Pen')}
</Text>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
<ColorSwatchButton
color={colorValue}
size={28}
onClick={() => {
setColorPickerTarget(isHighlighter ? 'highlight' : 'ink');
setIsColorPickerOpen(true);
}}
/>
</Box>
{isHighlighter && (
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={opacityValue}
onChange={(value) => {
setHighlightOpacity(value);
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ opacity: value / 100 }
);
}}
/>
</Box>
)}
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
<Slider
min={1}
max={isHighlighter ? 20 : 12}
value={thicknessValue}
onChange={(value) => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{
strokeWidth: value,
borderWidth: value,
lineWidth: value,
thickness: value,
}
);
if (isHighlighter) {
setFreehandHighlighterWidth?.(value);
} else {
setInkWidth(value);
}
}}
/>
</Box>
</Stack>
</Paper>
);
}
if (type === 3 || toolId === 'text' || toolId === 'note') {
const isNote = toolId === 'note';
const selectedBackground =
selectedAnn.object?.backgroundColor ??
(isNote ? noteBackgroundColor || '#ffffff' : textBackgroundColor || '#ffffff');
const alignValue = selectedAnn.object?.textAlign;
const currentAlign =
typeof alignValue === 'number'
? alignValue === 1
? 'center'
: alignValue === 2
? 'right'
: 'left'
: alignValue === 'center'
? 'center'
: alignValue === 'right'
? 'right'
: 'left';
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{isNote ? t('annotation.editNote', 'Edit Sticky Note') : t('annotation.editText', 'Edit Text Box')}</Text>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
<ColorSwatchButton
color={selectedAnn.object?.textColor ?? selectedAnn.object?.color ?? textColor}
size={28}
onClick={() => {
setColorPickerTarget('text');
setIsColorPickerOpen(true);
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.backgroundColor', 'Background color')}</Text>
<Group gap="xs" align="center">
<ColorSwatchButton
color={selectedBackground}
size={28}
onClick={() => {
setColorPickerTarget(isNote ? 'noteBackground' : 'textBackground');
setIsColorPickerOpen(true);
}}
/>
<Button
size="xs"
variant={selectedAnn.object?.backgroundColor ? 'light' : 'default'}
onClick={() => {
if (isNote) {
setNoteBackgroundColor('');
} else {
setTextBackgroundColor('');
}
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ backgroundColor: 'transparent', fillColor: 'transparent' }
);
}}
>
{t('annotation.clearBackground', 'Remove background')}
</Button>
</Group>
</Box>
<Textarea
label={t('annotation.text', 'Text')}
value={selectedTextDraft}
minRows={3}
maxRows={8}
autosize
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
const target = e.currentTarget;
const start = target.selectionStart;
const end = target.selectionEnd;
const val = selectedTextDraft;
const newVal = val.substring(0, start) + '\r\n' + val.substring(end);
setSelectedTextDraft(newVal);
setTimeout(() => {
target.selectionStart = target.selectionEnd = start + 2;
}, 0);
if (selectedUpdateTimer.current) {
clearTimeout(selectedUpdateTimer.current);
}
selectedUpdateTimer.current = setTimeout(() => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ contents: newVal, textColor: selectedAnn.object?.textColor ?? textColor }
);
}, 120);
}
}}
onChange={(e) => {
const val = e.currentTarget.value;
setSelectedTextDraft(val);
if (selectedUpdateTimer.current) {
clearTimeout(selectedUpdateTimer.current);
}
selectedUpdateTimer.current = setTimeout(() => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ contents: val, textColor: selectedAnn.object?.textColor ?? textColor }
);
}, 120);
}}
/>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.fontSize', 'Font size')}</Text>
<Slider
min={8}
max={32}
value={selectedFontSize}
onChange={(size) => {
setSelectedFontSize(size);
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ fontSize: size }
);
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.textAlignment', 'Text Alignment')}</Text>
<Group gap="xs">
<ActionIcon
variant={currentAlign === 'left' ? 'filled' : 'default'}
onClick={() => {
setTextAlignment('left');
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ textAlign: 0 }
);
}}
size="md"
>
<LocalIcon icon="format-align-left" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'center' ? 'filled' : 'default'}
onClick={() => {
setTextAlignment('center');
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ textAlign: 1 }
);
}}
size="md"
>
<LocalIcon icon="format-align-center" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'right' ? 'filled' : 'default'}
onClick={() => {
setTextAlignment('right');
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ textAlign: 2 }
);
}}
size="md"
>
<LocalIcon icon="format-align-right" width={18} height={18} />
</ActionIcon>
</Group>
</Box>
</Stack>
</Paper>
);
}
if (type === 13 || toolId === 'stamp') {
const imageSrc = selectedAnn.object?.imageSrc || selectedAnn.object?.data || selectedAnn.object?.url;
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t('annotation.stamp', 'Add Image')}</Text>
{imageSrc ? (
<Stack gap="xs">
<Text size="xs" c="dimmed">{t('annotation.imagePreview', 'Preview')}</Text>
<img
src={imageSrc}
alt={t('annotation.stamp', 'Add Image')}
style={{ maxWidth: '100%', maxHeight: '180px', objectFit: 'contain', border: '1px solid #ccc', borderRadius: '4px' }}
/>
</Stack>
) : (
<Text size="xs" c="dimmed">
{t('annotation.unsupportedType', 'This annotation type is not fully supported for editing.')}
</Text>
)}
<Text size="xs" c="dimmed">
{t('annotation.editStampHint', 'To change the image, delete this stamp and add a new one.')}
</Text>
</Stack>
</Paper>
);
}
if ((type !== undefined && [4, 8].includes(type)) || toolId === 'line' || toolId === 'polyline') {
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t('annotation.editLine', 'Edit Line')}</Text>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.color', 'Color')}</Text>
<ColorSwatchButton
color={selectedAnn.object?.strokeColor ?? shapeStrokeColor}
size={28}
onClick={() => {
setColorPickerTarget('shapeStroke');
setIsColorPickerOpen(true);
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={Math.round(((selectedAnn.object?.opacity ?? 1) * 100) || 100)}
onChange={(value) => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{ opacity: value / 100 }
);
}}
/>
</Box>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Width')}</Text>
<Slider
min={1}
max={12}
value={selectedAnn.object?.borderWidth ?? shapeThickness}
onChange={(value) => {
annotationApiRef?.current?.updateAnnotation?.(
selectedAnn.object?.pageIndex ?? 0,
selectedAnn.object?.id,
{
borderWidth: value,
strokeWidth: value,
lineWidth: value,
}
);
setShapeThickness(value);
}}
/>
</Box>
</Stack>
</Paper>
);
}
if ((type !== undefined && [5, 6, 7].includes(type)) || toolId === 'square' || toolId === 'circle' || toolId === 'polygon') {
const shapeName = type === 5 ? 'Square' : type === 6 ? 'Circle' : 'Polygon';
const strokeColorValue = selectedAnn.object?.strokeColor ?? shapeStrokeColor;
const fillColorValue = selectedAnn.object?.color ?? shapeFillColor;
const opacityValue = Math.round(((selectedAnn.object?.opacity ?? shapeOpacity / 100) * 100) || 100);
const pageIndex = selectedAnn.object?.pageIndex ?? 0;
const annId = selectedAnn.object?.id;
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t(`annotation.edit${shapeName}`, `Edit ${shapeName}`)}</Text>
<Group gap="md">
<Stack gap={4} align="center">
<Text size="xs" c="dimmed">{t('annotation.strokeColor', 'Stroke Color')}</Text>
<ColorSwatchButton
color={strokeColorValue}
size={28}
onClick={() => {
setColorPickerTarget('shapeStroke');
setIsColorPickerOpen(true);
}}
/>
</Stack>
<Stack gap={4} align="center">
<Text size="xs" c="dimmed">{t('annotation.fillColor', 'Fill Color')}</Text>
<ColorSwatchButton
color={fillColorValue}
size={28}
onClick={() => {
setColorPickerTarget('shapeFill');
setIsColorPickerOpen(true);
}}
/>
</Stack>
</Group>
<Box>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.opacity', 'Opacity')}</Text>
<Slider
min={10}
max={100}
value={opacityValue}
onChange={(value) => {
setShapeOpacity(value);
setShapeStrokeOpacity(value);
setShapeFillOpacity(value);
if (annId) {
annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
opacity: value / 100,
strokeOpacity: value / 100,
fillOpacity: value / 100,
});
}
}}
/>
</Box>
<Group gap="xs" align="flex-end">
<Box style={{ flex: 1 }}>
<Text size="xs" c="dimmed" mb={4}>{t('annotation.strokeWidth', 'Stroke')}</Text>
<Slider
min={0}
max={12}
value={selectedAnn.object?.borderWidth ?? shapeThickness}
onChange={(value) => {
if (annId) {
annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
borderWidth: value,
strokeWidth: value,
lineWidth: value,
});
}
setShapeThickness(value);
}}
/>
</Box>
<Button
size="xs"
variant={(selectedAnn.object?.borderWidth ?? shapeThickness) === 0 ? 'filled' : 'light'}
onClick={() => {
const newValue = (selectedAnn.object?.borderWidth ?? shapeThickness) === 0 ? 1 : 0;
if (annId) {
annotationApiRef?.current?.updateAnnotation?.(pageIndex, annId, {
borderWidth: newValue,
strokeWidth: newValue,
lineWidth: newValue,
});
}
setShapeThickness(newValue);
}}
>
{(selectedAnn.object?.borderWidth ?? shapeThickness) === 0
? t('annotation.borderOff', 'Border: Off')
: t('annotation.borderOn', 'Border: On')
}
</Button>
</Group>
</Stack>
</Paper>
);
}
return (
<Paper withBorder p="sm" radius="md">
<Stack gap="sm">
<Text size="sm" fw={600}>{t('annotation.editSelected', 'Edit Annotation')}</Text>
<Text size="xs" c="dimmed">{t('annotation.unsupportedType', 'This annotation type is not fully supported for editing.')}</Text>
</Stack>
</Paper>
);
})();
const colorPickerComponent = (
<ColorPicker
isOpen={isColorPickerOpen}
@@ -1287,11 +776,7 @@ export function AnnotationPanel(props: AnnotationPanelProps) {
{renderToolButtons(otherTools)}
</Box>
{activeTool !== 'select' && defaultStyleControls}
{activeTool === 'select' && selectedAnn && selectedAnnotationControls}
{activeTool === 'select' && !selectedAnn && defaultStyleControls}
{activeTool === 'stamp' && defaultStyleControls}
{colorPickerComponent}
@@ -5,7 +5,6 @@ interface UseAnnotationSelectionParams {
annotationApiRef: React.RefObject<AnnotationAPI | null>;
deriveToolFromAnnotation: (annotation: any) => AnnotationToolId | undefined;
activeToolRef: React.MutableRefObject<AnnotationToolId>;
manualToolSwitch: React.MutableRefObject<boolean>;
setActiveTool: (toolId: AnnotationToolId) => void;
setSelectedTextDraft: (text: string) => void;
setSelectedFontSize: (size: number) => void;
@@ -34,6 +33,7 @@ interface UseAnnotationSelectionParams {
const MARKUP_TOOL_IDS = ['highlight', 'underline', 'strikeout', 'squiggly'] as const;
const DRAWING_TOOL_IDS = ['ink', 'inkHighlighter'] as const;
const STAY_ACTIVE_TOOL_IDS = [...MARKUP_TOOL_IDS, ...DRAWING_TOOL_IDS] as const;
const isTextMarkupAnnotation = (annotation: any): boolean => {
const toolId =
@@ -55,6 +55,9 @@ const isTextMarkupAnnotation = (annotation: any): boolean => {
};
const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null | undefined): boolean => {
// Text markup tools (highlight, underline, strikeout, squiggly) and drawing tools (ink, inkHighlighter) stay active
// All other tools switch to select mode after placement
const toolId =
derivedTool ||
annotation?.customData?.annotationToolId ||
@@ -62,12 +65,17 @@ const shouldStayOnPlacementTool = (annotation: any, derivedTool?: string | null
annotation?.object?.customData?.annotationToolId ||
annotation?.object?.customData?.toolId;
if (toolId && (MARKUP_TOOL_IDS.includes(toolId as any) || DRAWING_TOOL_IDS.includes(toolId as any))) {
// Check if it's a tool that should stay active
if (toolId && STAY_ACTIVE_TOOL_IDS.includes(toolId as any)) {
return true;
}
const type = annotation?.type ?? annotation?.object?.type;
if (typeof type === 'number' && type === 15) return true; // ink family
if (isTextMarkupAnnotation(annotation)) return true;
// Check if it's a markup annotation by type/subtype
if (isTextMarkupAnnotation(annotation)) {
return true;
}
// All other tools (text, note, shapes, lines, stamps) switch to select
return false;
};
@@ -75,7 +83,6 @@ export function useAnnotationSelection({
annotationApiRef,
deriveToolFromAnnotation,
activeToolRef,
manualToolSwitch,
setActiveTool,
setSelectedTextDraft,
setSelectedFontSize,
@@ -226,8 +233,8 @@ export function useAnnotationSelection({
},
[
activeToolRef,
annotationApiRef,
deriveToolFromAnnotation,
manualToolSwitch,
setActiveTool,
setInkWidth,
setNoteBackgroundColor,
@@ -252,7 +259,6 @@ export function useAnnotationSelection({
setShapeFillOpacity,
setTextAlignment,
setFreehandHighlighterWidth,
shouldStayOnPlacementTool,
]
);
@@ -304,9 +310,7 @@ export function useAnnotationSelection({
const tool =
deriveToolFromAnnotation((eventAnn as any)?.object ?? eventAnn ?? api.getSelectedAnnotation?.()) ||
currentTool;
const stayOnPlacement =
shouldStayOnPlacementTool(eventAnn, tool) ||
(tool ? DRAWING_TOOL_IDS.includes(tool as any) : false);
const stayOnPlacement = shouldStayOnPlacementTool(eventAnn, tool);
if (activeToolRef.current !== 'select' && !stayOnPlacement) {
activeToolRef.current = 'select';
setActiveTool('select');
@@ -318,9 +322,7 @@ export function useAnnotationSelection({
applySelectionFromAnnotation(selected ?? eventAnn ?? null);
const derivedAfter =
deriveToolFromAnnotation((selected as any)?.object ?? selected ?? eventAnn ?? null) || activeToolRef.current;
const stayOnPlacementAfter =
shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter) ||
(derivedAfter ? DRAWING_TOOL_IDS.includes(derivedAfter as any) : false);
const stayOnPlacementAfter = shouldStayOnPlacementTool(selected ?? eventAnn ?? null, derivedAfter);
if (activeToolRef.current !== 'select' && !stayOnPlacementAfter) {
activeToolRef.current = 'select';
setActiveTool('select');
@@ -16,6 +16,7 @@ import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import { Util } from 'pdfjs-dist/legacy/build/pdf.mjs';
import {
PdfJsonDocument,
PdfJsonFont,
PdfJsonImageElement,
PdfJsonPage,
TextGroup,
@@ -450,14 +451,25 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const start = performance.now();
try {
const response = await apiClient.get(
`/api/v1/convert/pdf/text-editor/page/${cachedJobId}/${pageNumber}`,
{
responseType: 'json',
},
);
const [pageResponse, pageFontsResponse] = await Promise.all([
apiClient.get(
`/api/v1/convert/pdf/text-editor/page/${cachedJobId}/${pageNumber}`,
{
responseType: 'json',
},
),
apiClient.get(
`/api/v1/convert/pdf/text-editor/fonts/${cachedJobId}/${pageNumber}`,
{
responseType: 'json',
},
),
]);
const pageData = response.data as PdfJsonPage;
const pageData = pageResponse.data as PdfJsonPage;
const pageFonts = Array.isArray(pageFontsResponse.data)
? (pageFontsResponse.data as PdfJsonFont[])
: [];
const normalizedImages = (pageData.imageElements ?? []).map(cloneImageElement);
if (imagesByPageRef.current.length <= pageIndex) {
@@ -471,12 +483,31 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
}
const nextPages = [...prevDoc.pages];
const existingPage = nextPages[pageIndex] ?? {};
const fontMap = new Map<string, PdfJsonFont>();
for (const existingFont of prevDoc.fonts ?? []) {
if (!existingFont) {
continue;
}
const existingKey = existingFont.uid || `${existingFont.pageNumber ?? -1}:${existingFont.id ?? ''}`;
fontMap.set(existingKey, existingFont);
}
if (pageFonts.length > 0) {
for (const font of pageFonts) {
if (!font) {
continue;
}
const key = font.uid || `${font.pageNumber ?? -1}:${font.id ?? ''}`;
fontMap.set(key, font);
}
}
const nextFonts = Array.from(fontMap.values());
nextPages[pageIndex] = {
...existingPage,
imageElements: normalizedImages.map(cloneImageElement),
};
return {
...prevDoc,
fonts: nextFonts,
pages: nextPages,
};
});
@@ -1087,8 +1118,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const canUseIncremental =
isLazyMode &&
cachedJobId &&
dirtyPageIndices.length > 0 &&
dirtyPageIndices.length < totalPages;
dirtyPageIndices.length > 0;
if (canUseIncremental) {
await ensureImagesForPages(dirtyPageIndices);
@@ -1105,10 +1135,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? [];
const partialDocument: PdfJsonDocument = {
metadata: document.metadata,
xmpMetadata: document.xmpMetadata,
fonts: document.fonts,
lazyImages: true,
// Incremental export only needs changed pages.
// Fonts/resources/content streams are resolved from server-side cache.
pages: partialPages,
};
@@ -1135,11 +1163,13 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
setErrorMessage(null);
return;
} catch (incrementalError) {
if (isLazyMode && cachedJobIdRef.current) {
throw new Error('Incremental export failed for cached document. Please reload and retry.');
}
console.warn(
'[handleGeneratePdf] Incremental export failed, falling back to full export',
incrementalError,
);
// Fall through to full export below
}
}
@@ -1272,8 +1302,7 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
const canUseIncremental =
isLazyMode &&
cachedJobId &&
dirtyPageIndices.length > 0 &&
dirtyPageIndices.length < totalPages;
dirtyPageIndices.length > 0;
if (canUseIncremental) {
await ensureImagesForPages(dirtyPageIndices);
@@ -1290,10 +1319,8 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
document.pages?.filter((_, index) => dirtyPageSet.has(index)) ?? [];
const partialDocument: PdfJsonDocument = {
metadata: document.metadata,
xmpMetadata: document.xmpMetadata,
fonts: document.fonts,
lazyImages: true,
// Incremental export only needs changed pages.
// Fonts/resources/content streams are resolved from server-side cache.
pages: partialPages,
};
@@ -1312,6 +1339,9 @@ const PdfTextEditor = ({ onComplete, onError }: BaseToolProps) => {
downloadName = detectedName || expectedName;
pdfBlob = response.data;
} catch (incrementalError) {
if (isLazyMode && cachedJobId) {
throw new Error('Incremental export failed for cached document. Please reload and retry.');
}
console.warn(
'[handleSaveToWorkbench] Incremental export failed, falling back to full export',
incrementalError,
@@ -1209,7 +1209,7 @@ export const buildUpdatedDocument = (
...page,
textElements: updatedElements,
imageElements: images.map(cloneImageElement),
contentStreams: page.contentStreams ?? [],
contentStreams: page.contentStreams ?? null,
};
});
@@ -1282,7 +1282,7 @@ export const restoreGlyphElements = (
...page,
textElements: rebuiltElements,
imageElements: images.map(cloneImageElement),
contentStreams: page.contentStreams ?? [],
contentStreams: page.contentStreams ?? null,
};
});
+1 -1
View File
@@ -6,7 +6,7 @@ export type BaseWorkbenchType = typeof BASE_WORKBENCH_TYPES[number];
// Workbench types including custom views
export type WorkbenchType = BaseWorkbenchType | `custom:${string}`;
export const getDefaultWorkbench = (): WorkbenchType => 'fileEditor';
export const getDefaultWorkbench = (): WorkbenchType => 'viewer';
// Type guard using the same source of truth
export const isValidWorkbench = (value: string): value is WorkbenchType => {
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { getStartupNavigationAction } from '@app/utils/homePageNavigation';
import type { WorkbenchType } from '@app/types/workbench';
describe('getStartupNavigationAction', () => {
it('returns viewer + active index for 0->1 transition when not in fileEditor', () => {
expect(getStartupNavigationAction(0, 1, null, 'viewer' as WorkbenchType)).toEqual({
workbench: 'viewer',
activeFileIndex: 0,
});
expect(getStartupNavigationAction(0, 1, null, 'pageEditor' as WorkbenchType)).toEqual({
workbench: 'viewer',
activeFileIndex: 0,
});
});
it('returns fileEditor for 0->2+ transition when not in fileEditor', () => {
expect(getStartupNavigationAction(0, 2, null, 'viewer' as WorkbenchType)).toEqual({
workbench: 'fileEditor',
});
});
it('does not force navigation for pdfTextEditor', () => {
expect(getStartupNavigationAction(0, 1, 'pdfTextEditor', 'viewer' as WorkbenchType)).toBeNull();
expect(getStartupNavigationAction(0, 3, 'pdfTextEditor', 'viewer' as WorkbenchType)).toBeNull();
});
it('does not navigate on non-startup transitions', () => {
expect(getStartupNavigationAction(1, 2, null, 'viewer' as WorkbenchType)).toBeNull();
expect(getStartupNavigationAction(2, 1, null, 'viewer' as WorkbenchType)).toBeNull();
});
it('does not navigate when user already has files (N→M transitions)', () => {
// User has 1 file, adds another -> no navigation (stay in current workbench)
expect(getStartupNavigationAction(1, 2, null, 'viewer' as WorkbenchType)).toBeNull();
expect(getStartupNavigationAction(1, 2, null, 'fileEditor' as WorkbenchType)).toBeNull();
// User has 3 files, adds more -> no navigation
expect(getStartupNavigationAction(3, 4, null, 'fileEditor' as WorkbenchType)).toBeNull();
// User has 2 files, deletes 1 -> no navigation
expect(getStartupNavigationAction(2, 1, null, 'viewer' as WorkbenchType)).toBeNull();
});
it('handles all workbench types consistently for 0→N transitions', () => {
// 0→1 always goes to viewer regardless of current workbench (since default is viewer)
expect(getStartupNavigationAction(0, 1, null, 'viewer' as WorkbenchType)).toEqual({
workbench: 'viewer',
activeFileIndex: 0,
});
expect(getStartupNavigationAction(0, 1, null, 'fileEditor' as WorkbenchType)).toEqual({
workbench: 'viewer',
activeFileIndex: 0,
});
expect(getStartupNavigationAction(0, 1, null, 'pageEditor' as WorkbenchType)).toEqual({
workbench: 'viewer',
activeFileIndex: 0,
});
expect(getStartupNavigationAction(0, 1, null, 'custom:formFill' as WorkbenchType)).toEqual({
workbench: 'viewer',
activeFileIndex: 0,
});
// 0→N (N>1) always goes to fileEditor
expect(getStartupNavigationAction(0, 3, null, 'viewer' as WorkbenchType)).toEqual({
workbench: 'fileEditor',
});
expect(getStartupNavigationAction(0, 3, null, 'custom:myTool' as WorkbenchType)).toEqual({
workbench: 'fileEditor',
});
});
});
@@ -0,0 +1,49 @@
import type { WorkbenchType } from '@app/types/workbench';
export type StartupWorkbench = 'viewer' | 'fileEditor';
export interface StartupNavigationAction {
workbench: StartupWorkbench;
activeFileIndex?: number;
}
export function getStartupNavigationAction(
previousFileCount: number,
currentFileCount: number,
selectedToolKey: string | null,
currentWorkbench: WorkbenchType
): StartupNavigationAction | null {
console.log('[homePageNavigation] Called with:', {
previousFileCount,
currentFileCount,
selectedToolKey,
currentWorkbench,
});
// pdfTextEditor handles its own empty state
if (selectedToolKey === 'pdfTextEditor') {
console.log('[homePageNavigation] pdfTextEditor detected, returning null');
return null;
}
// Only handle transitions from empty (0 files) to some files
if (previousFileCount !== 0) {
console.log('[homePageNavigation] Not a 0→N transition, returning null');
return null;
}
// 0→1: Go to viewer to view the single file
if (currentFileCount === 1) {
console.log('[homePageNavigation] 0→1 transition, returning viewer');
return { workbench: 'viewer', activeFileIndex: 0 };
}
// 0→N (N>1): Go to fileEditor to manage multiple files
if (currentFileCount > 1) {
console.log('[homePageNavigation] 0→N transition, returning fileEditor');
return { workbench: 'fileEditor' };
}
console.log('[homePageNavigation] Still at 0 files, returning null');
return null;
}
@@ -227,6 +227,10 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
disabled={testing || loading}
onClick={() => {
setCustomUrl(serverUrl);
// Auto-submit the form after setting the URL
setTimeout(() => {
handleSubmit(new Event('submit') as any);
}, 0);
}}
>
{t('setup.server.useLast', 'Last used server: {{serverUrl}}', { serverUrl: serverUrl })}
@@ -213,12 +213,16 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const type = params.get('type') || parsed.searchParams.get('type');
const accessTokenFromHash = params.get('access_token');
const accessTokenFromQuery = parsed.searchParams.get('access_token');
const serverFromQuery = parsed.searchParams.get('server');
// Handle self-hosted SSO deep link
// Self-hosted SSO deep links are normally handled by authService.loginWithSelfHostedOAuth.
// Fallback here only if no in-flight auth listener exists (e.g. renderer reload mid-flow).
if (type === 'sso' || type === 'sso-selfhosted') {
if (authService.isSelfHostedDeepLinkFlowActive()) {
return;
}
const accessTokenFromHash = params.get('access_token');
const accessTokenFromQuery = parsed.searchParams.get('access_token');
const serverFromQuery = parsed.searchParams.get('server');
const token = accessTokenFromHash || accessTokenFromQuery;
const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL;
if (!token || !serverUrl) {
@@ -13,7 +13,17 @@ export async function clearPlatformAuthAfterSignOut(): Promise<void> {
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
try {
await authService.localClearAuth();
// Only clear if there's NO token in storage
// If token exists, user just logged in and we should keep it
const token = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
console.log('[AuthCleanup] Login init check - token exists:', !!token, 'length:', token?.length || 0);
if (!token) {
console.log('[AuthCleanup] No token found on login init, clearing stale auth data');
await authService.localClearAuth();
} else {
console.log('[AuthCleanup] Token present on login init (length:', token.length, '), skipping cleanup (fresh login)');
}
} catch (err) {
console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err);
}
@@ -0,0 +1,62 @@
import { STIRLING_SAAS_URL } from '@app/constants/connection';
import { connectionModeService } from '@app/services/connectionModeService';
import { authService } from '@app/services/authService';
import type { PlatformSessionUser } from '@proprietary/extensions/platformSessionBridge';
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
try {
const mode = await connectionModeService.getCurrentMode();
// Return true for ANY desktop auth mode (SaaS or self-hosted with desktop authService)
// This skips redundant backend validation in springAuthClient since desktop authService
// already manages the token lifecycle
return mode === 'saas' || mode === 'selfhosted';
} catch {
return false;
}
}
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
try {
const userInfo = await authService.getUserInfo();
if (!userInfo) {
return null;
}
return {
username: userInfo.username,
email: userInfo.email,
};
} catch {
return null;
}
}
export async function refreshPlatformSession(): Promise<boolean> {
try {
const mode = await connectionModeService.getCurrentMode();
if (mode === 'saas') {
return await authService.refreshSupabaseToken(STIRLING_SAAS_URL);
} else if (mode === 'selfhosted') {
const serverConfig = await connectionModeService.getServerConfig();
if (!serverConfig) {
return false;
}
return await authService.refreshToken(serverConfig.url);
}
return false;
} catch {
return false;
}
}
/**
* Save token to platform-specific secure storage (Tauri store + localStorage)
* Called after token refresh to ensure token is synced across all storage locations
*/
export async function savePlatformToken(token: string): Promise<void> {
try {
await authService.saveToken(token);
} catch (error) {
console.error('[PlatformBridge] Failed to save token:', error);
throw error;
}
}
@@ -190,10 +190,8 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
try {
setError(null);
const endpointsParam = endpoints.join(',');
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(
`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`,
`/api/v1/config/endpoints-availability`,
{
suppressErrorToast: true,
}
@@ -16,6 +16,7 @@ let lastBackendToast = 0;
interface ExtendedRequestConfig extends InternalAxiosRequestConfig {
operationName?: string;
skipBackendReadyCheck?: boolean;
skipAuthRedirect?: boolean;
_retry?: boolean;
}
@@ -55,7 +56,10 @@ export function setupApiInterceptors(client: AxiosInstance): void {
// Self-hosted mode: enable credentials for session management
extendedConfig.withCredentials = true;
// If another request is already refreshing, wait before attaching token.
await authService.awaitRefreshIfInProgress();
const token = await authService.getAuthToken();
if (token) {
extendedConfig.headers.Authorization = `Bearer ${token}`;
} else {
@@ -104,9 +108,16 @@ export function setupApiInterceptors(client: AxiosInstance): void {
},
async (error) => {
const originalRequest = error.config as ExtendedRequestConfig;
const requestUrl = String(originalRequest?.url || '');
const isAuthProbeRequest = requestUrl.includes('/api/v1/auth/me');
// Handle 401 Unauthorized - try to refresh token
if (error.response?.status === 401 && !originalRequest._retry) {
// `/auth/me` is used as a probe by session bootstrap; refreshing here can
// create recursion (refresh -> save token -> jwt-available -> /auth/me).
if (isAuthProbeRequest) {
return Promise.reject(error);
}
if (typeof window !== 'undefined') {
console.warn('[apiClientSetup] 401 on path:', window.location.pathname, 'url:', originalRequest.url);
}
+180 -92
View File
@@ -39,8 +39,10 @@ export class AuthService {
private authStatus: AuthStatus = 'unauthenticated';
private userInfo: UserInfo | null = null;
private cachedToken: string | null = null;
private lastTokenSaveTime: number = 0;
private authListeners = new Set<(status: AuthStatus, userInfo: UserInfo | null) => void>();
private refreshPromise: Promise<boolean> | null = null;
private selfHostedDeepLinkFlowActive = false;
static getInstance(): AuthService {
if (!AuthService.instance) {
@@ -52,52 +54,51 @@ export class AuthService {
/**
* Save token to all storage locations and notify listeners
*/
private async saveTokenEverywhere(token: string, refreshToken?: string | null): Promise<void> {
private async saveTokenEverywhere(
token: string,
refreshToken?: string | null,
emitJwtAvailable = true
): Promise<void> {
// Validate token before caching
if (!token || token.trim().length === 0) {
console.warn('[Desktop AuthService] Attempted to save invalid/empty token');
throw new Error('Invalid token');
}
console.log(`[Desktop AuthService] Saving token (length: ${token.length})`);
// Save access token to Tauri secure store (primary)
try {
await invoke('save_auth_token', { token });
console.log('[Desktop AuthService] ✅ Token saved to Tauri store');
} catch (error) {
console.error('[Desktop AuthService] Failed to save token to Tauri store:', error);
console.error('[Desktop AuthService] Failed to save token to Tauri store:', error);
// Don't throw - we can still use localStorage
}
// Sync to localStorage for web layer (fallback)
try {
localStorage.setItem('stirling_jwt', token);
console.log('[Desktop AuthService] ✅ Token saved to localStorage');
} catch (error) {
console.error('[Desktop AuthService] Failed to save token to localStorage:', error);
console.error('[Desktop AuthService] Failed to save token to localStorage:', error);
}
// Cache the valid token in memory
this.cachedToken = token;
console.log('[Desktop AuthService] ✅ Token cached in memory');
this.lastTokenSaveTime = Date.now();
// Save refresh token if provided (keyring with Tauri Store fallback)
if (refreshToken) {
console.log('[Desktop AuthService] Saving refresh token to secure storage...');
try {
await invoke('save_refresh_token', { token: refreshToken });
console.log('[Desktop AuthService] ✅ Refresh token saved to secure storage');
// Only remove from localStorage after successful save
localStorage.removeItem('stirling_refresh_token');
} catch (error) {
console.error('[Desktop AuthService] Failed to save refresh token:', error);
console.error('[Desktop AuthService] Failed to save refresh token:', error);
}
}
// Notify other parts of the system
window.dispatchEvent(new CustomEvent('jwt-available'));
console.log('[Desktop AuthService] Dispatched jwt-available event');
if (emitJwtAvailable) {
// Notify other parts of the system when a brand-new auth session is established.
window.dispatchEvent(new CustomEvent('jwt-available'));
}
}
/**
@@ -108,37 +109,21 @@ export class AuthService {
try {
const token = await invoke<string | null>('get_auth_token');
if (token) {
console.log(`[Desktop AuthService] ✅ Token found in Tauri store (length: ${token.length})`);
return token;
}
console.log('[Desktop AuthService] ️ No token in Tauri store, checking localStorage...');
} catch (error) {
console.error('[Desktop AuthService] Failed to read from Tauri store:', error);
console.error('[Desktop AuthService] Failed to read from Tauri store:', error);
}
// Fallback to localStorage
const localStorageToken = localStorage.getItem('stirling_jwt');
if (localStorageToken) {
console.log(`[Desktop AuthService] ✅ Token found in localStorage (length: ${localStorageToken.length})`);
} else {
console.log('[Desktop AuthService] ❌ No token found in any storage');
}
return localStorageToken;
return localStorage.getItem('stirling_jwt');
}
/**
* Get refresh token from secure storage (keyring or Tauri Store fallback)
*/
private async getRefreshToken(): Promise<string | null> {
const token = await invoke<string | null>('get_refresh_token');
if (token) {
console.log('[Desktop AuthService] ✅ Refresh token retrieved from secure storage');
} else {
console.log('[Desktop AuthService] No refresh token in secure storage');
}
return token;
return await invoke<string | null>('get_refresh_token');
}
/**
@@ -147,19 +132,16 @@ export class AuthService {
private async clearTokenEverywhere(): Promise<void> {
// Invalidate cache
this.cachedToken = null;
console.log('[Desktop AuthService] Cache invalidated');
// Best effort: clear Tauri keyring (both access and refresh tokens)
try {
await invoke('clear_auth_token');
console.log('[Desktop AuthService] Cleared Tauri keyring access token');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear Tauri keyring access token', error);
}
try {
await invoke('clear_refresh_token');
console.log('[Desktop AuthService] Cleared Tauri keyring refresh token');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear Tauri keyring refresh token', error);
}
@@ -168,7 +150,6 @@ export class AuthService {
try {
localStorage.removeItem('stirling_jwt');
localStorage.removeItem('stirling_refresh_token');
console.log('[Desktop AuthService] Cleared localStorage tokens');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear localStorage tokens', error);
}
@@ -196,6 +177,10 @@ export class AuthService {
};
}
isSelfHostedDeepLinkFlowActive(): boolean {
return this.selfHostedDeepLinkFlowActive;
}
private notifyListeners() {
this.authListeners.forEach(listener => listener(this.authStatus, this.userInfo));
}
@@ -268,24 +253,17 @@ export class AuthService {
}
async login(serverUrl: string, username: string, password: string, mfaCode?: string): Promise<UserInfo> {
console.log(`[Desktop AuthService] 🔐 Starting login to: ${serverUrl}`);
console.log(`[Desktop AuthService] Username: ${username}`);
try {
// Validate SaaS configuration if connecting to SaaS
if (serverUrl === STIRLING_SAAS_URL) {
if (!STIRLING_SAAS_URL) {
console.error('[Desktop AuthService] ❌ VITE_SAAS_SERVER_URL is not configured');
throw new Error('VITE_SAAS_SERVER_URL is not configured');
}
if (!SUPABASE_KEY) {
console.error('[Desktop AuthService] ❌ VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured');
}
}
console.log('[Desktop AuthService] Invoking Rust login command...');
// Call Rust login command (bypasses CORS)
const response = await invoke<LoginResponse>('login', {
serverUrl,
@@ -298,26 +276,19 @@ export class AuthService {
const { token, username: returnedUsername, email } = response;
console.log('[Desktop AuthService] ✅ Login response received');
console.log(`[Desktop AuthService] Username from response: ${returnedUsername || username}`);
// Save token to all storage locations
try {
console.log('[Desktop AuthService] Saving token to storage...');
await this.saveTokenEverywhere(token);
console.log('[Desktop AuthService] ✅ Token saved successfully');
} catch (error) {
console.error('[Desktop AuthService] Failed to save token:', error);
console.error('[Desktop AuthService] Failed to save token:', error);
throw new Error('Failed to save authentication token');
}
// Save user info to store
console.log('[Desktop AuthService] Saving user info...');
await invoke('save_user_info', {
username: returnedUsername || username,
email,
});
console.log('[Desktop AuthService] ✅ User info saved');
const userInfo: UserInfo = {
username: returnedUsername || username,
@@ -326,10 +297,9 @@ export class AuthService {
this.setAuthStatus('authenticated', userInfo);
console.log('[Desktop AuthService] ✅ Login completed successfully');
return userInfo;
} catch (error) {
console.error('[Desktop AuthService] Login failed:', error);
console.error('[Desktop AuthService] Login failed:', error);
// Provide more detailed error messages based on the error type
if (error instanceof Error || typeof error === 'string') {
@@ -338,55 +308,46 @@ export class AuthService {
if (errMsg.includes('mfa_required')) {
this.setAuthStatus('unauthenticated', null);
console.error('[Desktop AuthService] Two-factor authentication required');
throw new AuthServiceError('Two-factor code required.', 'mfa_required');
}
if (errMsg.includes('invalid_mfa_code')) {
this.setAuthStatus('unauthenticated', null);
console.error('[Desktop AuthService] Invalid two-factor code provided');
throw new AuthServiceError('Invalid two-factor code.', 'invalid_mfa_code');
}
// Authentication errors
if (errMsg.includes('401') || errMsg.includes('unauthorized') || errMsg.includes('invalid credentials')) {
console.error('[Desktop AuthService] Authentication failed - invalid credentials');
this.setAuthStatus('unauthenticated', null);
throw new Error('Invalid username or password. Please check your credentials and try again.');
}
// Server not found or unreachable
else if (errMsg.includes('connection refused') || errMsg.includes('econnrefused')) {
console.error('[Desktop AuthService] Server connection refused');
this.setAuthStatus('unauthenticated', null);
throw new Error('Cannot connect to server. Please check the server URL and ensure the server is running.');
}
// Timeout
else if (errMsg.includes('timeout') || errMsg.includes('timed out')) {
console.error('[Desktop AuthService] Login request timed out');
this.setAuthStatus('unauthenticated', null);
throw new Error('Login request timed out. Please check your network connection and try again.');
}
// DNS failure
else if (errMsg.includes('getaddrinfo') || errMsg.includes('dns') || errMsg.includes('not found') || errMsg.includes('enotfound')) {
console.error('[Desktop AuthService] DNS resolution failed');
this.setAuthStatus('unauthenticated', null);
throw new Error('Cannot resolve server address. Please check the server URL is correct.');
}
// SSL/TLS errors
else if (errMsg.includes('ssl') || errMsg.includes('tls') || errMsg.includes('certificate') || errMsg.includes('cert')) {
console.error('[Desktop AuthService] SSL/TLS error');
this.setAuthStatus('unauthenticated', null);
throw new Error('SSL/TLS certificate error. Server may have an invalid or self-signed certificate.');
}
// 404 - endpoint not found
else if (errMsg.includes('404') || errMsg.includes('not found')) {
console.error('[Desktop AuthService] Login endpoint not found');
this.setAuthStatus('unauthenticated', null);
throw new Error('Login endpoint not found. Please ensure you are connecting to a valid Stirling PDF server.');
}
// 403 - security disabled
else if (errMsg.includes('403') || errMsg.includes('forbidden')) {
console.error('[Desktop AuthService] Login disabled on server');
this.setAuthStatus('unauthenticated', null);
throw new Error('Login is not enabled on this server. Please enable security mode (DOCKER_ENABLE_SECURITY=true).');
}
@@ -398,10 +359,16 @@ export class AuthService {
}
}
/**
* Public method to save token to all storage locations
* Called by springAuthClient after token refresh to sync Tauri store
*/
async saveToken(token: string): Promise<void> {
await this.saveTokenEverywhere(token, undefined, false);
}
async logout(): Promise<void> {
try {
console.log('Logging out');
// Best-effort backend logout so any server-side session/cookies are cleared
try {
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
@@ -444,8 +411,6 @@ export class AuthService {
await invoke('clear_user_info');
this.setAuthStatus('unauthenticated', null);
console.log('Logged out successfully');
} catch (error) {
console.error('Error during logout:', error);
// Still set status to unauthenticated even if clear fails
@@ -457,21 +422,31 @@ export class AuthService {
async getAuthToken(): Promise<string | null> {
try {
// Return cached token if available
// Check cached token validity before returning
if (this.cachedToken) {
console.debug('[Desktop AuthService] ✅ Returning cached token');
return this.cachedToken;
// Use minimal leeway (5s) for cache validation to avoid excessive invalidation
// Health checks run every 5s, so 30s leeway would cause 5-6 unnecessary cache clears
// The 30s leeway is used elsewhere for proactive refresh before user operations
if (this.isTokenExpiringSoon(this.cachedToken, 5)) {
console.warn('[Desktop AuthService] ⚠️ Cached token is expired or expiring soon, invalidating cache');
this.cachedToken = null;
// Fall through to fetch from storage
} else {
console.debug('[Desktop AuthService] ✅ Returning cached token');
return this.cachedToken;
}
}
console.debug('[Desktop AuthService] Cache miss, fetching from storage...');
const token = await this.getTokenFromAnySource();
// Cache the token if valid
// Cache token if found (backend will validate expiry)
if (token && token.trim().length > 0) {
this.cachedToken = token;
console.log('[Desktop AuthService] ✅ Token cached in memory after retrieval');
return token;
}
return token;
return null;
} catch (error) {
console.error('[Desktop AuthService] Failed to get auth token:', error);
return null;
@@ -505,6 +480,59 @@ export class AuthService {
}
}
async awaitRefreshIfInProgress(): Promise<boolean> {
if (!this.refreshPromise) {
return false;
}
try {
console.debug('[Desktop AuthService] Waiting for in-flight refresh to complete');
return await this.refreshPromise;
} catch (error) {
console.warn('[Desktop AuthService] In-flight refresh failed while waiting', error);
return false;
}
}
isTokenExpiringSoon(token: string, leewaySeconds = 30): boolean {
try {
const parts = token.split('.');
if (parts.length < 2) {
console.warn('[Desktop AuthService] Token malformed - less than 2 parts');
return true;
}
const base64Url = parts[1];
const base64 = base64Url
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
const payload = JSON.parse(atob(base64));
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
if (!expSeconds) {
console.warn('[Desktop AuthService] Token has no exp claim');
return true;
}
const nowSeconds = Math.floor(Date.now() / 1000);
const nowWithLeeway = nowSeconds + Math.max(0, leewaySeconds);
const timeUntilExpiry = expSeconds - nowSeconds;
const isExpiring = expSeconds <= nowWithLeeway;
console.debug('[Desktop AuthService] Token expiry check:', {
expiresIn: timeUntilExpiry + 's',
leeway: leewaySeconds + 's',
isExpiring
});
return isExpiring;
} catch (err) {
// If parsing fails, treat token as unsafe/stale and force refresh path.
console.warn('[Desktop AuthService] Token parsing failed:', err);
return true;
}
}
async refreshToken(serverUrl: string): Promise<boolean> {
// Prevent concurrent refresh attempts - reuse in-flight refresh
if (this.refreshPromise) {
@@ -542,10 +570,20 @@ export class AuthService {
}
);
const { token } = response.data;
const token =
response.data?.session?.access_token ??
response.data?.access_token ??
response.data?.token;
if (!token) {
console.error('[Desktop AuthService] Refresh response missing token payload');
this.setAuthStatus('unauthenticated', null);
await this.logout();
return false;
}
// Save token to all storage locations
await this.saveTokenEverywhere(token);
await this.saveTokenEverywhere(token, undefined, false);
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
@@ -607,7 +645,7 @@ export class AuthService {
const { access_token, refresh_token: newRefreshToken } = response.data;
// Save new tokens
await this.saveTokenEverywhere(access_token, newRefreshToken);
await this.saveTokenEverywhere(access_token, newRefreshToken, false);
const userInfo = await this.getUserInfo();
this.setAuthStatus('authenticated', userInfo);
@@ -630,16 +668,28 @@ export class AuthService {
// If we are on the login/setup screen, don't auto-restore a previous session; clear instead
const path = typeof window !== 'undefined' ? window.location.pathname : '';
if (path.startsWith('/login') || path.startsWith('/setup')) {
console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
// Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
await this.clearTokenEverywhere().catch(() => {});
try {
await invoke('clear_user_info');
} catch (err) {
console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
// Check if token exists in storage (user just logged in via web flow)
const tokenInStorage = typeof window !== 'undefined' ? localStorage.getItem('stirling_jwt') : null;
if (tokenInStorage) {
console.log('[Desktop AuthService] On login/setup path with token present - skipping validation');
console.log('[Desktop AuthService] Login flow will handle authentication state');
// Return early to avoid clearing partial state during login completion
// The login completion handler (completeSelfHostedSession) will:
// 1. Fetch and save user info
// 2. Set auth status to authenticated
return;
} else {
console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
// Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
await this.clearTokenEverywhere().catch(() => {});
try {
await invoke('clear_user_info');
} catch (err) {
console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
}
this.setAuthStatus('unauthenticated', null);
return;
}
this.setAuthStatus('unauthenticated', null);
return;
}
const token = await this.getAuthToken();
@@ -737,22 +787,27 @@ export class AuthService {
// ignore URL parsing failures
}
// Open in system browser and wait for deep link callback
if (await this.openInSystemBrowser(authUrl)) {
return this.waitForDeepLinkCompletion(trimmedServer);
}
throw new Error('Unable to open system browser for SSO. Please check your system settings.');
// Register deep-link listener before opening browser to avoid callback races on first launch.
return this.waitForDeepLinkCompletion(trimmedServer, async () => {
if (!(await this.openInSystemBrowser(authUrl))) {
throw new Error('Unable to open system browser for SSO. Please check your system settings.');
}
});
}
/**
* Wait for a deep-link event to complete self-hosted SSO after system browser OAuth
*/
private async waitForDeepLinkCompletion(serverUrl: string): Promise<UserInfo> {
private async waitForDeepLinkCompletion(
serverUrl: string,
startFlow?: () => Promise<void>
): Promise<UserInfo> {
if (!isTauri()) {
throw new Error('Deep link authentication is only supported in Tauri desktop app.');
}
this.selfHostedDeepLinkFlowActive = true;
return new Promise<UserInfo>((resolve, reject) => {
let completed = false;
let unlisten: (() => void) | null = null;
@@ -762,6 +817,7 @@ export class AuthService {
completed = true;
if (unlisten) unlisten();
sessionStorage.removeItem('oauth_nonce');
this.selfHostedDeepLinkFlowActive = false;
reject(new Error('SSO login timed out. Please try again.'));
}
}, 120_000);
@@ -780,6 +836,7 @@ export class AuthService {
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
this.selfHostedDeepLinkFlowActive = false;
reject(new Error(error || 'Authentication was not successful.'));
return;
}
@@ -800,6 +857,7 @@ export class AuthService {
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
this.selfHostedDeepLinkFlowActive = false;
console.error('[Desktop AuthService] Nonce validation failed - potential CSRF attack');
reject(new Error('Invalid authentication state. Nonce validation failed.'));
return;
@@ -809,6 +867,7 @@ export class AuthService {
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
this.selfHostedDeepLinkFlowActive = false;
console.log('[Desktop AuthService] Nonce validated successfully');
const userInfo = await this.completeSelfHostedSession(serverUrl, token);
@@ -825,10 +884,39 @@ export class AuthService {
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
this.selfHostedDeepLinkFlowActive = false;
reject(err instanceof Error ? err : new Error('Failed to complete SSO'));
}
}).then((fn) => {
}).then(async (fn) => {
unlisten = fn;
if (!startFlow || completed) {
return;
}
try {
await startFlow();
} catch (err) {
if (completed) {
return;
}
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
this.selfHostedDeepLinkFlowActive = false;
reject(err instanceof Error ? err : new Error('Failed to start SSO login'));
}
}).catch((err) => {
if (completed) {
return;
}
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
this.selfHostedDeepLinkFlowActive = false;
reject(err instanceof Error ? err : new Error('Failed to listen for deep link events'));
});
});
}
@@ -117,20 +117,15 @@ export class TauriBackendService {
}
/**
* Get auth token from any available source (localStorage or Tauri store)
* Get auth token with expiry validation
* Delegates to authService which handles caching and expiry checking
*/
private async getAuthToken(): Promise<string | null> {
// Check localStorage first (web layer token)
const localStorageToken = localStorage.getItem('stirling_jwt');
if (localStorageToken) {
return localStorageToken;
}
// Fallback to Tauri store
try {
return await invoke<string | null>('get_auth_token');
} catch {
console.debug('[TauriBackendService] No auth token available');
const { authService } = await import('./authService');
return await authService.getAuthToken();
} catch (error) {
console.debug('[TauriBackendService] Failed to get auth token:', error);
return null;
}
}
+3
View File
@@ -11,6 +11,7 @@ import Signup from "@app/routes/Signup";
import AuthCallback from "@app/routes/AuthCallback";
import InviteAccept from "@app/routes/InviteAccept";
import MobileScannerPage from "@app/pages/MobileScannerPage";
import PluginPage from "@app/pages/PluginPage";
import Onboarding from "@app/components/onboarding/Onboarding";
// Import global styles
@@ -60,6 +61,8 @@ export default function App() {
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/invite/:token" element={<InviteAccept />} />
{/* Plugin route must win before the catch-all Landing */}
<Route path="/plugins/:id" element={<PluginPage />} />
{/* Main app routes - Landing handles auth logic */}
<Route path="/*" element={<Landing />} />
</Routes>
@@ -52,6 +52,7 @@ describe('SpringAuthClient', () => {
expect(apiClient.get).toHaveBeenCalledWith('/api/v1/auth/me', {
headers: { Authorization: `Bearer ${mockToken}` },
suppressErrorToast: true,
skipAuthRedirect: true,
});
expect(result.data.session).toBeTruthy();
expect(result.data.session?.user).toEqual(mockUser);
@@ -309,14 +310,10 @@ describe('SpringAuthClient', () => {
},
} as any);
const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent');
const result = await springAuth.refreshSession();
expect(localStorage.getItem('stirling_jwt')).toBe(newToken);
expect(dispatchEventSpy).toHaveBeenCalledWith(
expect.objectContaining({ type: 'jwt-available' })
);
// Note: refreshSession does not dispatch jwt-available event, only notifies listeners
expect(result.data.session?.access_token).toBe(newToken);
expect(result.error).toBeNull();
});
+221 -16
View File
@@ -13,8 +13,29 @@ import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes';
import { resetOAuthState } from '@app/auth/oauthStorage';
import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup';
import {
getPlatformSessionUser,
isDesktopSaaSAuthMode,
refreshPlatformSession,
savePlatformToken,
} from '@app/extensions/platformSessionBridge';
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
function getHttpStatus(error: unknown): number | undefined {
if (error instanceof AxiosError) {
return error.response?.status;
}
if (error && typeof error === 'object' && 'response' in error) {
const response = (error as { response?: { status?: unknown } }).response;
if (response && typeof response.status === 'number') {
return response.status;
}
}
return undefined;
}
// Helper to extract error message from axios error
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof AxiosError) {
@@ -98,14 +119,100 @@ type AuthChangeCallback = (event: AuthChangeEvent, session: Session | null) => v
class SpringAuthClient {
private listeners: AuthChangeCallback[] = [];
private sessionCheckInterval: NodeJS.Timeout | null = null;
private readonly SESSION_CHECK_INTERVAL = 60000; // 1 minute
private readonly TOKEN_REFRESH_THRESHOLD = 300000; // 5 minutes before expiry
// Adaptive intervals - calculated based on actual JWT token lifetime
// Defaults for initial startup (will be recalculated on first token)
private sessionCheckIntervalMs = 10000; // 10 seconds default
private tokenRefreshThresholdMs = 30000; // 30 seconds default
private readonly DESKTOP_SAAS_REFRESH_EARLY_SECONDS = 60;
constructor() {
// Start periodic session validation
this.startSessionMonitoring();
}
/**
* Calculate optimal check interval and refresh threshold based on token lifetime.
* - Check interval: token lifetime / 6 (check 6 times during token life)
* - Refresh threshold: token lifetime / 4 (refresh when 25% remaining)
* - Applies min/max bounds for sanity
*/
private calculateAdaptiveIntervals(token: string): void {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
console.warn('[SpringAuth] Cannot decode token for adaptive intervals, using defaults');
return;
}
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
const iatSeconds = typeof payload?.iat === 'number' ? payload.iat : 0;
if (expSeconds <= 0 || iatSeconds <= 0) {
console.warn('[SpringAuth] Token missing exp/iat claims, using default intervals');
return;
}
const tokenLifetimeMs = (expSeconds - iatSeconds) * 1000;
// Check interval: check 6 times during token lifetime
// Min: 5 seconds (for very short tokens)
// Max: 60 seconds (don't check too infrequently)
this.sessionCheckIntervalMs = Math.max(5000, Math.min(60000, tokenLifetimeMs / 6));
// Refresh threshold: refresh when 25% of lifetime remaining
// Min: 30 seconds (give buffer for refresh to complete)
// Max: 5 minutes (don't wait too long for long-lived tokens)
this.tokenRefreshThresholdMs = Math.max(30000, Math.min(300000, tokenLifetimeMs / 4));
console.log('[SpringAuth] 📊 Adaptive intervals calculated:', {
tokenLifetime: Math.floor(tokenLifetimeMs / 1000) + 's',
checkInterval: Math.floor(this.sessionCheckIntervalMs / 1000) + 's',
refreshThreshold: Math.floor(this.tokenRefreshThresholdMs / 1000) + 's',
});
// Restart monitoring with new interval
this.restartSessionMonitoring();
} catch (error) {
console.warn('[SpringAuth] Failed to calculate adaptive intervals:', error);
}
}
private decodeJwtPayload(token: string): Record<string, unknown> | null {
const parts = token.split('.');
if (parts.length < 2) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url
.replace(/-/g, '+')
.replace(/_/g, '/')
.padEnd(Math.ceil(base64Url.length / 4) * 4, '=');
return JSON.parse(atob(base64));
}
private getTokenExpiry(token: string): { expiresIn: number; expiresAt: number } {
try {
const payload = this.decodeJwtPayload(token);
if (!payload) {
throw new Error('Token payload missing');
}
const expSeconds = typeof payload?.exp === 'number' ? payload.exp : 0;
const expiresAt = expSeconds > 0 ? expSeconds * 1000 : Date.now() + 3600 * 1000;
const expiresIn = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
return { expiresIn, expiresAt };
} catch {
// Fallback for non-JWT or malformed tokens.
const expiresAt = Date.now() + 3600 * 1000;
return { expiresIn: 3600, expiresAt };
}
}
/**
* Helper to get CSRF token from cookie
*/
@@ -127,13 +234,54 @@ class SpringAuthClient {
async getSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
// Get JWT from localStorage
const token = localStorage.getItem('stirling_jwt');
let token = localStorage.getItem('stirling_jwt');
if (!token) {
// console.debug('[SpringAuth] getSession: No JWT in localStorage');
return { data: { session: null }, error: null };
}
if (await isDesktopSaaSAuthMode()) {
let tokenExpiry = this.getTokenExpiry(token);
if (tokenExpiry.expiresIn <= this.DESKTOP_SAAS_REFRESH_EARLY_SECONDS) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem('stirling_jwt');
return { data: { session: null }, error: null };
}
const refreshedToken = localStorage.getItem('stirling_jwt');
if (!refreshedToken) {
localStorage.removeItem('stirling_jwt');
return { data: { session: null }, error: null };
}
token = refreshedToken;
tokenExpiry = this.getTokenExpiry(token);
}
if (tokenExpiry.expiresIn <= 0) {
localStorage.removeItem('stirling_jwt');
return { data: { session: null }, error: null };
}
const platformUser = await getPlatformSessionUser();
const session: Session = {
user: {
id: platformUser?.email || platformUser?.username || 'desktop-saas-user',
email: platformUser?.email || '',
username: platformUser?.username || platformUser?.email || 'User',
role: 'USER',
},
access_token: token,
expires_in: tokenExpiry.expiresIn,
expires_at: tokenExpiry.expiresAt,
};
return { data: { session }, error: null };
}
// Verify with backend
// Note: We pass the token explicitly here, overriding the interceptor's default
// console.debug('[SpringAuth] getSession: Verifying JWT with /api/v1/auth/me');
@@ -142,6 +290,8 @@ class SpringAuthClient {
'Authorization': `Bearer ${token}`,
},
suppressErrorToast: true, // Suppress global error handler (we handle errors locally)
// Session bootstrap should not trigger global 401 refresh/redirect loops.
skipAuthRedirect: true,
});
// console.debug('[SpringAuth] /me response status:', response.status);
@@ -149,11 +299,12 @@ class SpringAuthClient {
// console.debug('[SpringAuth] /me response data:', data);
// Create session object
const tokenExpiry = this.getTokenExpiry(token);
const session: Session = {
user: data.user,
access_token: token,
expires_in: 3600,
expires_at: Date.now() + 3600 * 1000,
expires_in: tokenExpiry.expiresIn,
expires_at: tokenExpiry.expiresAt,
};
// console.debug('[SpringAuth] getSession: Session retrieved successfully');
@@ -161,8 +312,15 @@ class SpringAuthClient {
} catch (error: unknown) {
console.error('[SpringAuth] getSession error:', error);
// If 401/403, token is invalid - clear it
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
// If 401/403, token is invalid - try explicit refresh
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
// A 401 during startup can be a race with a concurrent refresh. Try one
// explicit refresh before treating the session as invalid.
const refreshResult = await this.refreshSession();
if (!refreshResult.error && refreshResult.data.session) {
return refreshResult;
}
localStorage.removeItem('stirling_jwt');
console.debug('[SpringAuth] getSession: Not authenticated');
return { data: { session: null }, error: null };
@@ -201,6 +359,12 @@ class SpringAuthClient {
localStorage.setItem('stirling_jwt', token);
// console.log('[SpringAuth] JWT stored in localStorage');
// Sync token to platform-specific storage (Tauri store for desktop)
await savePlatformToken(token);
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
@@ -382,6 +546,34 @@ class SpringAuthClient {
*/
async refreshSession(): Promise<{ data: { session: Session | null }; error: AuthError | null }> {
try {
if (await isDesktopSaaSAuthMode()) {
const refreshed = await refreshPlatformSession();
if (!refreshed) {
localStorage.removeItem('stirling_jwt');
return {
data: { session: null },
error: { message: 'Token refresh failed - please log in again' },
};
}
const { data, error } = await this.getSession();
if (error || !data.session) {
return {
data: { session: null },
error: error || { message: 'Token refresh failed - please log in again' },
};
}
// Calculate adaptive intervals for desktop SaaS mode
const token = localStorage.getItem('stirling_jwt');
if (token) {
this.calculateAdaptiveIntervals(token);
}
this.notifyListeners('TOKEN_REFRESHED', data.session);
return { data, error: null };
}
const response = await apiClient.post('/api/v1/auth/refresh', null, {
headers: {
'X-XSRF-TOKEN': this.getCsrfToken() || '',
@@ -396,8 +588,11 @@ class SpringAuthClient {
// Update local storage with new token
localStorage.setItem('stirling_jwt', token);
// Dispatch custom event for other components to react to JWT availability
window.dispatchEvent(new CustomEvent('jwt-available'));
// Sync token to platform-specific storage (Tauri store for desktop)
await savePlatformToken(token);
// Calculate adaptive monitoring intervals based on token lifetime
this.calculateAdaptiveIntervals(token);
const session: Session = {
user: data.user,
@@ -417,7 +612,8 @@ class SpringAuthClient {
localStorage.removeItem('stirling_jwt');
// Handle different error statuses
if (error instanceof AxiosError && (error.response?.status === 401 || error.response?.status === 403)) {
const status = getHttpStatus(error);
if (status === 401 || status === 403) {
return { data: { session: null }, error: { message: 'Token refresh failed - please log in again' } };
}
@@ -462,27 +658,36 @@ class SpringAuthClient {
private startSessionMonitoring() {
// Periodically check session validity
// Since we use HttpOnly cookies, we just need to check with the server
// Interval is adaptive based on token lifetime (calculated when token is received)
this.sessionCheckInterval = setInterval(async () => {
try {
// Try to get current session
const { data } = await this.getSession();
// If we have a session, proactively refresh if needed
// (The server will handle token expiry, but we can be proactive)
if (data.session) {
const timeUntilExpiry = (data.session.expires_at || 0) - Date.now();
// Refresh if token expires soon
if (timeUntilExpiry > 0 && timeUntilExpiry < this.TOKEN_REFRESH_THRESHOLD) {
// console.log('[SpringAuth] Proactively refreshing token');
// Refresh if token expires soon (threshold is adaptive)
if (timeUntilExpiry > 0 && timeUntilExpiry < this.tokenRefreshThresholdMs) {
console.log('[SpringAuth] 🔄 Proactively refreshing token (expires in ' + Math.floor(timeUntilExpiry / 1000) + 's)');
await this.refreshSession();
}
}
} catch (error) {
console.error('[SpringAuth] Session monitoring error:', error);
}
}, this.SESSION_CHECK_INTERVAL);
}, this.sessionCheckIntervalMs);
}
private restartSessionMonitoring() {
// Stop existing interval
if (this.sessionCheckInterval) {
clearInterval(this.sessionCheckInterval);
this.sessionCheckInterval = null;
}
// Start with new interval
this.startSessionMonitoring();
}
public destroy() {
@@ -21,7 +21,10 @@ interface SecuritySettingsData {
persistence?: boolean;
enableKeyRotation?: boolean;
enableKeyCleanup?: boolean;
keyRetentionDays?: number;
tokenExpiryMinutes?: number;
desktopTokenExpiryMinutes?: number;
allowedClockSkewSeconds?: number;
refreshGraceMinutes?: number;
secureCookie?: boolean;
};
audit?: {
@@ -131,7 +134,10 @@ export default function AdminSecuritySection() {
'security.jwt.persistence': securitySettings.jwt?.persistence,
'security.jwt.enableKeyRotation': securitySettings.jwt?.enableKeyRotation,
'security.jwt.enableKeyCleanup': securitySettings.jwt?.enableKeyCleanup,
'security.jwt.keyRetentionDays': securitySettings.jwt?.keyRetentionDays,
'security.jwt.tokenExpiryMinutes': securitySettings.jwt?.tokenExpiryMinutes,
'security.jwt.desktopTokenExpiryMinutes': securitySettings.jwt?.desktopTokenExpiryMinutes,
'security.jwt.allowedClockSkewSeconds': securitySettings.jwt?.allowedClockSkewSeconds,
'security.jwt.refreshGraceMinutes': securitySettings.jwt?.refreshGraceMinutes,
'security.jwt.secureCookie': securitySettings.jwt?.secureCookie,
// Premium audit settings
'premium.enterpriseFeatures.audit.enabled': audit?.enabled,
@@ -382,20 +388,75 @@ export default function AdminSecuritySection() {
</Group>
</div>
<div>
<NumberInput
name="jwt_keyRetentionDays"
name="jwt_tokenExpiryMinutes"
label={
<Group component="span" gap="xs">
<span>{t('admin.settings.security.jwt.keyRetentionDays.label', 'Key Retention Days')}</span>
<PendingBadge show={isFieldPending('jwt.keyRetentionDays')} />
<span>{t('admin.settings.security.jwt.tokenExpiryMinutes.label', 'Web Token Expiry (minutes)')}</span>
<PendingBadge show={isFieldPending('jwt.tokenExpiryMinutes')} />
</Group>
}
description={t('admin.settings.security.jwt.keyRetentionDays.description', 'Number of days to retain old JWT keys for verification')}
value={settings?.jwt?.keyRetentionDays || 7}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, keyRetentionDays: Number(value) } })}
description={t('admin.settings.security.jwt.tokenExpiryMinutes.description', 'Access token lifetime in minutes for web clients (default: 1440 = 24 hours)')}
value={settings?.jwt?.tokenExpiryMinutes || 1440}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, tokenExpiryMinutes: Number(value) } })}
min={1}
max={365}
max={43200}
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
name="jwt_desktopTokenExpiryMinutes"
label={
<Group component="span" gap="xs">
<span>{t('admin.settings.security.jwt.desktopTokenExpiryMinutes.label', 'Desktop Token Expiry (minutes)')}</span>
<PendingBadge show={isFieldPending('jwt.desktopTokenExpiryMinutes')} />
</Group>
}
description={t('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)')}
value={settings?.jwt?.desktopTokenExpiryMinutes || 43200}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, desktopTokenExpiryMinutes: Number(value) } })}
min={1}
max={525600}
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
name="jwt_allowedClockSkewSeconds"
label={
<Group component="span" gap="xs">
<span>{t('admin.settings.security.jwt.allowedClockSkewSeconds.label', 'Clock Skew Tolerance (seconds)')}</span>
<PendingBadge show={isFieldPending('jwt.allowedClockSkewSeconds')} />
</Group>
}
description={t('admin.settings.security.jwt.allowedClockSkewSeconds.description', 'Tolerance for client/server time drift during token validation (default: 60 seconds)')}
value={settings?.jwt?.allowedClockSkewSeconds ?? 60}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, allowedClockSkewSeconds: Number(value) } })}
min={0}
max={300}
disabled={!loginEnabled}
/>
</div>
<div>
<NumberInput
name="jwt_refreshGraceMinutes"
label={
<Group component="span" gap="xs">
<span>{t('admin.settings.security.jwt.refreshGraceMinutes.label', 'Refresh Grace Period (minutes)')}</span>
<PendingBadge show={isFieldPending('jwt.refreshGraceMinutes')} />
</Group>
}
description={t('admin.settings.security.jwt.refreshGraceMinutes.description', 'Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)')}
value={settings?.jwt?.refreshGraceMinutes ?? 15}
onChange={(value) => setSettings({ ...settings, jwt: { ...settings?.jwt, refreshGraceMinutes: Number(value) } })}
min={0}
max={120}
disabled={!loginEnabled}
/>
</div>
@@ -0,0 +1,33 @@
export interface PlatformSessionUser {
username: string;
email?: string;
}
/**
* Proprietary/web default: no desktop SaaS auth bridge.
*/
export async function isDesktopSaaSAuthMode(): Promise<boolean> {
return false;
}
/**
* Proprietary/web default: no platform user store.
*/
export async function getPlatformSessionUser(): Promise<PlatformSessionUser | null> {
return null;
}
/**
* Proprietary/web default: no platform refresh path.
*/
export async function refreshPlatformSession(): Promise<boolean> {
return false;
}
/**
* Proprietary/web default: no platform-specific token storage (uses localStorage only).
*/
export async function savePlatformToken(_token: string): Promise<void> {
// Web mode: token already saved to localStorage in springAuthClient
// No additional platform storage needed
}
@@ -1,4 +1,10 @@
import { AxiosInstance } from 'axios';
import { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios';
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (error: Error) => void;
}> = [];
function getJwtTokenFromStorage(): string | null {
try {
@@ -9,6 +15,24 @@ function getJwtTokenFromStorage(): string | null {
}
}
function setJwtTokenInStorage(token: string): void {
try {
localStorage.setItem('stirling_jwt', token);
console.debug('[API Client] Stored new JWT token in localStorage');
} catch (error) {
console.error('[API Client] Failed to store JWT in localStorage:', error);
}
}
function clearJwtTokenFromStorage(): void {
try {
localStorage.removeItem('stirling_jwt');
console.debug('[API Client] Cleared JWT token from localStorage');
} catch (error) {
console.error('[API Client] Failed to clear JWT from localStorage:', error);
}
}
function getXsrfToken(): string | null {
try {
const cookies = document.cookie.split(';');
@@ -25,6 +49,48 @@ function getXsrfToken(): string | null {
}
}
function processQueue(error: Error | null, token: string | null = null): void {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else if (token) {
prom.resolve(token);
}
});
failedQueue = [];
}
async function refreshAuthToken(client: AxiosInstance): Promise<string> {
console.log('[API Client] Refreshing expired JWT token...');
try {
const response = await client.post('/api/v1/auth/refresh', {}, {
// Don't retry refresh requests to avoid infinite loops
headers: { 'X-Skip-Auth-Refresh': 'true' }
});
const newToken = response.data?.session?.access_token;
if (!newToken) {
throw new Error('No access token in refresh response');
}
setJwtTokenInStorage(newToken);
console.log('[API Client] ✅ Token refreshed successfully');
return newToken;
} catch (error) {
console.error('[API Client] ❌ Token refresh failed:', error);
clearJwtTokenFromStorage();
// Redirect to login
if (window.location.pathname !== '/login') {
console.log('[API Client] Redirecting to login page...');
window.location.href = '/login';
}
throw error;
}
}
export function setupApiInterceptors(client: AxiosInstance): void {
// Install request interceptor to add JWT token
client.interceptors.request.use(
@@ -47,4 +113,61 @@ export function setupApiInterceptors(client: AxiosInstance): void {
return Promise.reject(error);
}
);
// Install response interceptor to handle 401 and auto-refresh token
client.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Skip refresh for auth endpoints or if explicitly disabled
// Exception: /auth/me should trigger refresh (used by getSession)
if (
!originalRequest ||
(originalRequest.url?.includes('/api/v1/auth/') && !originalRequest.url?.includes('/api/v1/auth/me')) ||
originalRequest.headers?.['X-Skip-Auth-Refresh'] ||
originalRequest._retry
) {
return Promise.reject(error);
}
// Handle 401 errors by attempting token refresh
if (error.response?.status === 401 && getJwtTokenFromStorage()) {
console.warn('[API Client] Received 401 error, attempting token refresh...');
if (isRefreshing) {
// Already refreshing - queue this request
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return client(originalRequest);
})
.catch((err) => {
return Promise.reject(err);
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
const newToken = await refreshAuthToken(client);
processQueue(null, newToken);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return client(originalRequest);
} catch (refreshError) {
processQueue(refreshError as Error, null);
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
}
@@ -48,7 +48,7 @@ const FREE_LICENSE_INFO: LicenseInfo = {
const BASE_NO_LOGIN_CONFIG: AppConfig = {
enableAnalytics: true,
appVersion: '2.4.6',
appVersion: '2.5.1',
serverCertificateEnabled: false,
enableAlphaFunctionality: false,
enableDesktopInstallSlide: true,
@@ -71,13 +71,14 @@ function WidgetInputInner({
height,
zIndex: 10,
boxSizing: 'border-box',
border: `2px solid ${borderColor}`,
borderRadius: 2,
background: bgColor,
border: `1px solid ${borderColor}`,
borderRadius: 1,
background: isActive ? bgColor : 'transparent',
transition: 'border-color 0.15s, background 0.15s, box-shadow 0.15s',
boxShadow: isActive
? `0 0 0 2px ${error ? 'rgba(244, 67, 54, 0.25)' : 'rgba(33, 150, 243, 0.25)'}`
: 'none',
boxShadow:
isActive && field.type !== 'radio' && field.type !== 'checkbox'
? `0 0 0 2px ${error ? 'rgba(244, 67, 54, 0.25)' : 'rgba(33, 150, 243, 0.25)'}`
: 'none',
cursor: field.readOnly ? 'default' : 'text',
pointerEvents: 'auto',
display: 'flex',
@@ -122,8 +123,8 @@ function WidgetInputInner({
const fontSize = widget.fontSize
? widget.fontSize * scaleY
: field.multiline
? Math.max(8, Math.min(height * 0.65, 14))
: Math.max(8, height * 0.7);
? Math.max(6, Math.min(height * 0.60, 14))
: Math.max(6, height * 0.65);
const inputBaseStyle: React.CSSProperties = {
width: '100%',
@@ -192,9 +193,11 @@ function WidgetInputInner({
{...commonProps}
style={{
...commonStyle,
border: isActive ? commonStyle.border : '1px solid rgba(0,0,0,0.15)',
background: isActive ? bgColor : 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
justifyContent: 'center', // Keep center for checkboxes as they are usually square hitboxes
cursor: field.readOnly ? 'default' : 'pointer',
}}
title={error || field.tooltip || field.label}
@@ -207,11 +210,22 @@ function WidgetInputInner({
>
<span
style={{
fontSize: `${Math.max(12, height * 0.7)}px`,
width: '85%',
height: '85%',
maxWidth: height * 0.9, // Prevent it from getting too wide in rectangular boxes
maxHeight: width * 0.9,
fontSize: `${Math.max(10, height * 0.75)}px`,
lineHeight: 1,
color: isChecked ? '#2196F3' : 'transparent',
background: '#FFF',
border: isChecked || isActive ? '1px solid #2196F3' : '1.5px solid #666',
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 700,
userSelect: 'none',
boxShadow: isActive ? '0 0 0 2px rgba(33, 150, 243, 0.2)' : 'none',
}}
>
@@ -282,9 +296,12 @@ function WidgetInputInner({
{...commonProps}
style={{
...commonStyle,
border: isActive ? commonStyle.border : 'none',
background: 'transparent',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
justifyContent: 'flex-start', // Align to start (left) instead of center for radio buttons
paddingLeft: Math.max(1, (height - Math.min(width, height) * 0.8) / 2), // Slight offset
cursor: field.readOnly ? 'default' : 'pointer',
}}
title={error || field.tooltip || `${field.label}: ${optionValue}`}
@@ -297,12 +314,16 @@ function WidgetInputInner({
>
<span
style={{
width: Math.max(8, height * 0.5),
height: Math.max(8, height * 0.5),
width: Math.min(width, height) * 0.8,
height: Math.min(width, height) * 0.8,
borderRadius: '50%',
border: '2px solid #666',
background: isSelected ? '#2196F3' : 'transparent',
display: 'block',
border: `1.5px solid ${isSelected || isActive ? '#2196F3' : '#666'}`,
background: isSelected ? '#2196F3' : '#FFF',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: isSelected ? 'inset 0 0 0 2px white' : 'none',
transition: 'background 0.15s, border-color 0.15s',
}}
/>
</div>
@@ -16,10 +16,7 @@ export async function fetchFormFieldsWithCoordinates(
const response = await apiClient.post<FormField[]>(
'/api/v1/form/fields-with-coordinates',
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
}
formData
);
return response.data;
}
@@ -42,7 +39,6 @@ export async function fillFormFields(
formData.append('flatten', String(flatten));
const response = await apiClient.post('/api/v1/form/fill', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
responseType: 'blob',
});
return response.data;
+11 -5
View File
@@ -1,15 +1,21 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import react from '@vitejs/plugin-react-swc';
import tsconfigPaths from 'vite-tsconfig-paths';
import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig(({ mode }) => {
// Load env file based on `mode` in the current working directory.
// Set the third parameter to '' to load all env regardless of the
// `VITE_` prefix.
const env = loadEnv(mode, process.cwd(), '')
// When DISABLE_ADDITIONAL_FEATURES is false (or unset), enable proprietary features
const isProprietary = process.env.DISABLE_ADDITIONAL_FEATURES !== 'true';
const isDesktopMode =
mode === 'desktop' ||
process.env.STIRLING_DESKTOP === 'true' ||
process.env.VITE_DESKTOP === 'true';
env.STIRLING_DESKTOP === 'true' ||
env.VITE_DESKTOP === 'true';
// Validate required environment variables for desktop builds
if (isDesktopMode) {
@@ -18,7 +24,7 @@ export default defineConfig(({ mode }) => {
'VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY',
];
const missingVars = requiredEnvVars.filter(varName => !process.env[varName]);
const missingVars = requiredEnvVars.filter(varName => !env[varName]);
if (missingVars.length > 0) {
throw new Error(
@@ -108,6 +114,6 @@ export default defineConfig(({ mode }) => {
},
},
},
base: process.env.RUN_SUBPATH ? `/${process.env.RUN_SUBPATH}` : './',
base: env.RUN_SUBPATH ? `/${env.RUN_SUBPATH}` : './',
};
});
-1
View File
@@ -62,7 +62,6 @@ 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.
validation: # PDF signature validation settings
trust:
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
@@ -74,7 +74,7 @@ services:
DOCKER_ENABLE_SECURITY: "true"
SECURITY_ENABLELOGIN: "true"
SECURITY_LOGINMETHOD: "${SECURITY_LOGINMETHOD:-all}"
SYSTEM_DEFAULTLOCALE: en-US
SYSTEM_DEFAULTLOCALE: "${SYSTEM_DEFAULTLOCALE:-en-US}"
SYSTEM_BACKENDURL: "http://localhost:8080"
# Enterprise License (required for SAML)
+32 -4
View File
@@ -13,24 +13,48 @@ echo -e "${BLUE}╚════════════════════
echo ""
AUTO_LOGIN=false
DEFAULT_LANGUAGE="en-US"
COMPOSE_UP_ARGS=(-d --build)
for arg in "$@"; do
case "$arg" in
while [[ $# -gt 0 ]]; do
case "$1" in
--auto)
AUTO_LOGIN=true
shift
;;
--nobuild)
COMPOSE_UP_ARGS=(-d)
shift
;;
--language)
if [[ -z "${2:-}" ]]; then
echo -e "${RED}Missing value for --language${NC}"
exit 1
fi
DEFAULT_LANGUAGE="$2"
shift 2
;;
--language=*)
DEFAULT_LANGUAGE="${1#*=}"
shift
;;
-l)
if [[ -z "${2:-}" ]]; then
echo -e "${RED}Missing value for -l${NC}"
exit 1
fi
DEFAULT_LANGUAGE="$2"
shift 2
;;
-h|--help)
echo "Usage: $0 [--auto] [--nobuild]"
echo "Usage: $0 [--auto] [--nobuild] [--language <locale>]"
echo ""
echo " --auto Enable SSO auto-login and force SAML-only login method"
echo " --nobuild Skip building images (use existing images)"
echo " --language Set system default locale (e.g. de-DE, sv-SE)"
exit 0
;;
*)
echo -e "${RED}Unknown option: $arg${NC}"
echo -e "${RED}Unknown option: $1${NC}"
exit 1
;;
esac
@@ -65,6 +89,10 @@ if [ "$AUTO_LOGIN" = true ]; then
echo ""
fi
export SYSTEM_DEFAULTLOCALE="$DEFAULT_LANGUAGE"
echo -e "${GREEN}✓ Default locale set to: ${SYSTEM_DEFAULTLOCALE}${NC}"
echo ""
echo -e "${YELLOW}▶ Starting Keycloak (SAML) containers...${NC}"
docker-compose -f docker-compose-keycloak-saml.yml up "${COMPOSE_UP_ARGS[@]}" keycloak-saml-db keycloak-saml