Compare commits

...
Author SHA1 Message Date
Dario Ghunney Ware 78a4c0363a resolving conflicts 2025-12-02 13:25:38 +00:00
Dario Ghunney Ware d3f4a40f68 Optimising caching and retrieval 2025-12-02 12:57:20 +00:00
DarioGii c9cd6404ae unify chat across docs 2 2025-12-02 12:56:50 +00:00
DarioGii 9e9179015e unify chat across docs 2025-12-02 12:56:50 +00:00
DarioGii 8139987919 pre-process docs on upload 2025-12-02 12:56:48 +00:00
Dario Ghunney Ware f4b39101ac improving context 2025-12-02 12:56:43 +00:00
Dario Ghunney Ware f82020a3b7 wip - implementing RAG system 2025-12-02 12:55:01 +00:00
DarioGii cb0f7c7720 UI elements 2025-12-02 12:24:14 +00:00
Dario Ghunney Ware 3695a9a70a wip - working on timeout issue 2025-12-02 12:22:39 +00:00
DarioGii 98d4949930 wip 2025-12-02 12:21:02 +00:00
Dario Ghunney Ware 6dcf20b9c9 wip - trying to connect to OpenAi 2025-12-02 12:20:59 +00:00
Dario Ghunney Ware 949e8eb2c3 Updating version, removing deprecated classes 2025-12-02 12:18:08 +00:00
Dario Ghunney Ware e0ad76eeab chatbot UI
# Conflicts:
#	frontend/public/locales/en-US/translation.json
#	frontend/src/core/components/AppProviders.tsx
#	frontend/src/core/components/viewer/useViewerRightRailButtons.tsx
2025-12-02 12:18:04 +00:00
Dario Ghunney Ware d502e90f11 wip - resolving dependency issues 2025-12-02 12:13:53 +00:00
Dario Ghunney Ware 1325196d75 wip - building skeleton
# Conflicts:
#	app/proprietary/build.gradle
#	build.gradle
2025-12-02 12:13:53 +00:00
stirlingbot[bot]Dario Ghunney Warestirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
c5030a543a 🤖 format everything with pre-commit by stirlingbot (#4175)
Auto-generated by [create-pull-request][1] with **stirlingbot**

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

Signed-off-by: stirlingbot[bot] <stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2025-12-01 13:04:30 +00:00
Dario Ghunney Ware 58bfd8bee0 JWT Authentication (#3921)
This PR introduces JWT (JSON Web Token) authentication for Stirling-PDF,
allowing for stateless authentication capabilities alongside the
existing session-based authentication system.

### Key Features & Changes

  JWT Authentication System
- Core Service: JwtService.java - Token generation, validation, and
cookie management
- Authentication Filter: JwtAuthenticationFilter.java - Request
interceptor for JWT validation
- Key Management: KeyPersistenceService.java +
KeyPairCleanupService.java - RSA key rotation and persistence
  - Frontend: jwt-init.js - Client-side JWT handling and URL cleanup

  Security Integration
- SAML2: JwtSaml2AuthenticationRequestRepository.java - JWT-backed SAML
request storage
- OAuth2: Updated CustomAuthenticationSuccessHandler. java,
CustomOAuth2AuthenticationSuccessHandler.java &
CustomSaml2AuthenticationSuccessHandler.java for JWT integration
- Configuration: Enhanced SecurityConfiguration.java with JWT filter
chain

  Infrastructure
  - Caching: CacheConfig.java - Caffeine cache for JWT keys
  - Database: New JwtVerificationKey.java entity for key storage
- Error Handling: JwtAuthenticationEntryPoint.java for unauthorized
access

### Challenges Encountered

- Configured SecurityConfiguration to use either
`UsernamePasswordAuthenticationFilter` or `JWTAuthenticationFilter`
based on whether JWTs are enabled to prevent the former intercepting
requests while in stateless mode.
- Removed the `.defaultSuccessUrl("/")` from login configuration as its
inclusion was preventing overriding the use of the
`CustomAuthenticationSuccessHandler` and preventing proper
authentication flows.
---

## Checklist

### General

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

### Documentation

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

### UI Changes (if applicable)

- [x] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
<img width="599" height="515" alt="Screenshot 2025-07-10 at 13 35 56"
src="https://github.com/user-attachments/assets/4126b752-ad0d-4ffa-b295-6714c43381e1"
/>

<img width="392" height="376" alt="Screenshot 2025-07-10 at 13 36 10"
src="https://github.com/user-attachments/assets/c681bc43-68ff-4934-8245-d544e2ad7b9c"
/>

<img width="1870" height="986" alt="eb750e8c3954fc47b2dd2e6e76ddb7d5"
src="https://github.com/user-attachments/assets/fca9b23d-b0b6-4884-8a26-98a441b641ef"
/>

<img width="1299" height="702" alt="Screenshot 2025-07-10 at 13 30 57"
src="https://github.com/user-attachments/assets/9415d8bf-fac4-4d38-8c3a-985d043d1076"
/>

### Testing (if applicable)

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

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Ludy <Ludy87@users.noreply.github.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Ethan <ethan@MacBook-Pro.local>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
# Conflicts:
#	.claude/settings.local.json
#	app/common/src/main/java/stirling/software/common/configuration/AppConfig.java
#	app/core/src/main/resources/static/js/fetch-utils.js
#	app/core/src/main/resources/static/js/jwt-init.js
#	app/proprietary/src/main/java/stirling/software/proprietary/security/model/Authority.java
#	app/proprietary/src/main/java/stirling/software/proprietary/security/model/User.java
2025-12-01 13:04:30 +00:00
ConnorYohGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
1e72416d55 Added file endpoint for license files and easy upload in admin UI (#5055)
<img width="698" height="240" alt="image"
src="https://github.com/user-attachments/assets/f0161e5f-e2ed-44c1-bdd1-93fab46f756b"
/>

---------

Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-11-29 19:35:50 +00:00
Anthony StirlingandGitHub 959d14f075 Bump version from 2.0.1 to 2.0.2 2025-11-29 19:29:58 +00:00
8f6fcee428 Fix: Only block export when signatures are placed but not applied (#5084)
Previously, activating signature placement mode would immediately set
signaturesApplied=false, blocking export even when no signatures were
actually placed on the PDF. This caused the "unapplied signatures"
warning to appear incorrectly after clicking "Apply Signatures".

Changes:
- Remove signaturesApplied=false from activateDrawMode and
activateSignaturePlacementMode in SignatureContext
- Add signaturesApplied=false to onAnnotationEvent handler in
SignatureAPIBridge when event.type === 'create'
- Now signatures are only marked as unapplied when actually placed

This ensures:
- Users can activate placement mode without triggering export warning
- Export is only blocked when signatures are actually placed but not
applied
- After applying signatures, users can immediately export without
warning

🤖 Generated with [Claude Code](https://claude.com/claude-code)

# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-29 19:29:30 +00:00
651f17f1c6 Save signatures to server (#5080)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2025-11-29 19:29:06 +00:00
Anthony StirlingandGitHub fde449e738 Use frontend translations for OCR language picker (#5051)
## Summary
- revert OCR ui-data endpoint to only expose language codes without
backend labels
- translate OCR language options on the frontend using existing lang.*
keys with locale-aware fallbacks

## Testing
- Not run (not requested)


------
[Codex
Task](https://chatgpt.com/codex/tasks/task_b_6928ae7c85448328a0d2660a0c021b22)
2025-11-29 16:05:15 +00:00
Anthony StirlingandGitHub 85e9121745 desktop fix debian (#5068)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-29 16:04:43 +00:00
Anthony StirlingandGitHub 12f1fd485e Audit viewer making api call when shouldnt (#5069)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-29 16:04:29 +00:00
Anthony StirlingandGitHub b49e8a2355 tauri remote connection fix (#5070)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-29 16:04:19 +00:00
d908bc6785 Swagger fixes (#5071)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2025-11-29 16:03:57 +00:00
Anthony StirlingandGitHub 4ae79d92ae Fix email invite/ allow non auth and table refresh issues (#5076)
# Description of Changes

- Show warning when email invite fails but user is created
  - Auto-refresh user/team tables after modifications
  - Fix invite email URLs to use frontend URL instead of backend
  - Support anonymous SMTP for local development
---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2025-11-29 16:03:44 +00:00
Balázs SzücsandGitHub 85d9b5b83d feat(viewer): Add interactive link layer with (basic) internal/external navigation support (#5077)
# Description of Changes

Added a new `LinkLayer` component to the PDF viewer that renders
clickable overlays for PDF link annotations, enabling both internal page
navigation and external URL links.

- Created `LinkLayer.tsx` component that extracts link annotations using
the EmbedPDF annotation API with fallback to direct PDF document access
- Implemented scale-aware positioning to maintain accurate link hotspots
at different zoom levels
- Added support for internal navigation (GoTo actions) using smooth
scrolling and external links (URI actions) opening in new tabs
- Integrated accessibility features with proper ARIA labels and keyboard
navigation
- Modified `LocalEmbedPDF.tsx` to always register the annotation plugin
(even when editing is disabled) to enable reading existing link
annotations
- Updated `ReactRoutingController.java` and test formatting for code
style consistency


**Key features:**
- Multi-source annotation detection (annotation API → document API →
page API fallback)
- Navigation lock to prevent race conditions
- React performance optimizations (useMemo, useCallback)
- TypeScript type safety for PDF actions and destinations



This does not address support for Attachment links. Sadly, that does not
seem to be possible with EmbedPDF


<img width="773" height="957" alt="image"
src="https://github.com/user-attachments/assets/8a04d15a-79b5-46b6-af8b-3d27246581a7"
/>
<img width="773" height="957" alt="image"
src="https://github.com/user-attachments/assets/eeb39ca7-a114-4bd5-a4eb-9e8a27331297"
/>
<img width="773" height="957" alt="image"
src="https://github.com/user-attachments/assets/1ee6bba3-d233-4a11-bf1a-1b56696265e1"
/>


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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2025-11-29 12:53:26 +00:00
Reece BrowneandGitHub 058a81d554 Improved language select (#5062) 2025-11-28 22:49:23 +00:00
Reece BrowneandGitHub e4c6ce5836 Cookie consent banner and footer on login screen (#5042) 2025-11-28 16:24:10 +00:00
ConnorYohandGitHub 250979e271 Desktop Self-hosted guidance improvements (#5060)
Removed timeout when waiting for backend port to allow for slow backend
spinups



<img width="1185" height="951" alt="image"
src="https://github.com/user-attachments/assets/badaf8e5-611d-44aa-aca2-7c1c906c2019"
/>
<img width="1213" height="1533" alt="image"
src="https://github.com/user-attachments/assets/ce78b67a-07e0-4c23-9087-5de0c5f203c6"
/>
<img width="1207" height="1202" alt="image"
src="https://github.com/user-attachments/assets/c6e5b4c5-9cc3-4973-a634-3b7aa1e1dd34"
/>
2025-11-28 15:55:37 +00:00
125 changed files with 7249 additions and 833 deletions
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Analysis",
description =
"""
Document analysis and information extraction services for content intelligence and insights.
Read-only inspection of PDFs: page count, page sizes, fonts, form fields, annotations, document properties, and security details.
Use these endpoints to understand what's inside a document without changing it.
This endpoint group provides analytical capabilities to understand document structure,
extract information, and generate insights from PDF content for automated processing.
Common use cases:
• Document inventory management and content audit for compliance verification
• Quality assurance workflows and business intelligence analytics
• Migration planning, accessibility evaluation, and document forensics
Business applications:
• Legal discovery, financial document review, and healthcare records analysis
• Academic research, government processing, and publishing optimization
Operational scenarios:
• Large-scale profiling, migration assessment, and performance optimization
• Automated quality control and content strategy development
Target users: Data analysts, QA teams, administrators, and business intelligence
professionals requiring detailed document insights.
Typical uses:
• Get page counts and dimensions for layout or print rules
• List fonts and annotations to spot compatibility issues
• Inspect form fields before deciding how to fill or modify them
• Pull metadata and security settings for audits or reports
""")
public @interface AnalysisApi {}
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Convert",
description =
"""
Document format transformation services for cross-platform compatibility and workflow integration.
Convert PDFs to and from other formats (Word, images, HTML, Markdown, PDF/A, CBZ/CBR, EML, etc.).
This group also powers the text-editor / jobId-based editing flow for incremental PDF edits.
This endpoint group enables transformation between various formats, supporting
diverse business workflows and system integrations for mixed document ecosystems.
Common use cases:
• Legacy system integration, document migration, and cross-platform sharing
• Archive standardization, publishing preparation, and content adaptation
• Accessibility compliance and mobile-friendly document preparation
Business applications:
• Enterprise content management, digital publishing, and educational platforms
• Legal document processing, healthcare interoperability, and government standardization
Integration scenarios:
• API-driven pipelines, automated workflow preparation, and batch conversions
• Real-time format adaptation for user requests
Target users: System integrators, content managers, digital archivists, and
organizations requiring flexible document format interoperability.
Typical uses:
• Turn PDFs into Word or text for editing
• Convert office files, images, HTML, or email (EML) into PDFs
• Create PDF/A for long-term archiving
• Export PDFs as images, HTML, CSV, or Markdown for search, analysis, or reuse
""")
public @interface ConvertApi {}
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Filter",
description =
"""
Document content filtering and search operations for information discovery and organization.
Check basic properties of PDFs before you process them: page count, file size, page size/rotation, and whether they contain text or images.
Use these endpoints as a "pre-check" step to decide what to do with a file next.
This endpoint group enables intelligent content discovery and organization within
document collections for content-based processing and information extraction.
Common use cases:
• Legal discovery, research organization, and compliance auditing
• Content moderation, academic research, and business intelligence
• Quality assurance and content validation workflows
Business applications:
• Contract analysis, financial review, and healthcare records organization
• Government processing, educational curation, and IP protection
Workflow scenarios:
• Large-scale processing, automated classification, and information extraction
• Document preparation for further processing or analysis
Target users: Legal professionals, researchers, compliance officers, and
organizations requiring intelligent document content discovery and organization.
Typical uses:
• Reject files that are too big or too small
• Detect image-only PDFs that should go through OCR
• Ensure a document has enough pages before it enters a workflow
• Check orientation of pages before printing or merging
""")
public @interface FilterApi {}
@@ -22,21 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "General",
description =
"""
Core PDF processing operations for fundamental document manipulation workflows.
Page-level PDF editing: split, merge, rotate, crop, rearrange, and scale pages.
These endpoints handle most daily "I opened a PDF editor just to…" type tasks.
This endpoint group provides essential PDF functionality that forms the foundation
of most document processing workflows across various industries.
Common use cases:
• Document preparation for archival systems and content organization
• File preparation for distribution, accessibility compliance, and batch processing
• Document consolidation for reporting and legal compliance workflows
Typical applications:
• Content management, publishing workflows, and educational content distribution
• Business process automation and archive management
Target users: Content managers, document processors, and organizations requiring
reliable foundational PDF manipulation capabilities.
Typical uses:
• Split a large PDF into smaller files (by pages, chapters, or size)
• Merge several PDFs into one report or pack
• Rotate or reorder pages before sending or archiving
• Turn a multi-page document into one long scrolling page
""")
public @interface GeneralApi {}
@@ -22,25 +22,15 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Misc",
description =
"""
Specialized utilities and supplementary tools for enhanced document processing workflows.
Tools that don't fit neatly elsewhere: OCR, compress, repair, flatten, extract images, update metadata, add stamps/page numbers/images, and more.
These endpoints help fix problem PDFs and prepare them for sharing, storage, or further processing.
This endpoint group provides utility operations that support core document processing
tasks and address specific workflow needs in real-world scenarios.
Common use cases:
• Document optimization for bandwidth-limited environments and storage cost management
• Document repair, content extraction, and validation for quality assurance
• Accessibility improvement and custom processing for specialized needs
Business applications:
• Web publishing optimization, email attachment management, and archive efficiency
• Mobile compatibility, print production, and legacy document recovery
Operational scenarios:
• Batch processing, quality control, and performance optimization
• Troubleshooting and recovery of problematic documents
Target users: System administrators, document specialists, and organizations requiring
specialized document processing and optimization tools.
Typical uses:
• Repair a damaged PDF or remove blank pages
• Run OCR on scanned PDFs so they become searchable
• Compress large PDFs for email or web download
• Extract embedded images or scans
• Add page numbers, stamps, or overlay an image (e.g. logo, seal)
• Update PDF metadata (title, author, etc.)
""")
public @interface MiscApi {}
@@ -22,25 +22,12 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Pipeline",
description =
"""
Automated document processing workflows for complex multi-stage business operations.
Run several PDF operations in one configured pipeline instead of calling multiple endpoints yourself.
Useful when you always do the same steps in sequence (for example: convert → OCR → compress → watermark).
This endpoint group enables organizations to create sophisticated document processing
workflows that combine multiple operations into streamlined, repeatable processes.
Common use cases:
• Invoice processing, legal document review, and healthcare records standardization
• Government processing, educational content preparation, and publishing automation
• Contract lifecycle management and approval processes
Business applications:
• Automated compliance reporting, large-scale migration, and quality assurance
• Archive preparation, content delivery, and document approval workflows
Operational scenarios:
• Scheduled batch processing and event-driven document processing
• Multi-department coordination and business system integration
Target users: Business process managers, IT automation specialists, and organizations
requiring consistent, repeatable document processing workflows.
Typical uses:
• Process incoming invoices in one go (clean, OCR, compress, stamp, etc.)
• Normalise documents before they enter an archive
• Wrap a complex document flow behind a single API call for your own apps
""")
public @interface PipelineApi {}
@@ -22,25 +22,13 @@ import io.swagger.v3.oas.annotations.tags.Tag;
name = "Security",
description =
"""
Document security and protection services for confidential and sensitive content.
Protect and clean PDFs: passwords, digital signatures, redaction, and sanitizing.
These endpoints help you control who can open a file, what they can do with it, and remove sensitive content when needed.
This endpoint group provides essential security operations for organizations handling
sensitive documents and materials requiring controlled access.
Common use cases:
• Legal confidentiality, healthcare privacy (HIPAA), and financial regulatory compliance
• Government classified handling, corporate IP protection, and educational privacy (FERPA)
• Contract security for business transactions
Business applications:
• Document authentication, confidential sharing, and secure archiving
• Content watermarking, access control, and privacy protection through redaction
Industry scenarios:
• Legal discovery, medical records exchange, financial audit documentation
• Enterprise policy enforcement and data governance
Target users: Legal professionals, healthcare administrators, compliance officers,
government agencies, and enterprises handling sensitive content.
Typical uses:
• Add or remove a password on a PDF
• Redact personal or confidential information (manually or automatically)
• Validate or remove digital signatures
• Sanitize a PDF to strip scripts and embedded content
""")
public @interface SecurityApi {}
@@ -590,6 +590,7 @@ public class ApplicationProperties {
private boolean ssoAutoLogin;
private boolean database;
private CustomMetadata customMetadata = new CustomMetadata();
private Chatbot chatbot = new Chatbot();
@Data
public static class CustomMetadata {
@@ -608,6 +609,61 @@ public class ApplicationProperties {
: producer;
}
}
@Data
public static class Chatbot {
private boolean enabled;
private boolean alphaWarning = true;
private Cache cache = new Cache();
private Models models = new Models();
private Rag rag = new Rag();
private Ocr ocr = new Ocr();
private Audit audit = new Audit();
private long maxPromptCharacters = 4000;
private double minConfidenceNano = 0.65;
private Usage usage = new Usage();
@Data
public static class Cache {
private long ttlMinutes = 720;
private long maxEntries = 200;
private long maxDocumentCharacters = 200000;
}
@Data
public static class Models {
private String provider = "openai";
private String primary = "gpt-5-nano";
private String fallback = "gpt-5-mini";
private String embedding = "text-embedding-3-small";
private double topP = 0.95;
private long connectTimeoutMillis = 10000;
private long readTimeoutMillis = 60000;
}
@Data
public static class Rag {
private int chunkSizeTokens = 512;
private int chunkOverlapTokens = 128;
private int topK = 8;
}
@Data
public static class Ocr {
private boolean enabledByDefault;
}
@Data
public static class Audit {
private boolean enabled = true;
}
@Data
public static class Usage {
private long perUserMonthlyTokens = 200000;
private double warnAtRatio = 0.7;
}
}
}
@Data
@@ -0,0 +1,21 @@
package stirling.software.common.service;
import java.io.IOException;
/**
* Interface for personal signature access (proprietary feature). Implemented only in proprietary
* module to provide authenticated users access to their personal signatures.
*/
public interface PersonalSignatureServiceInterface {
/**
* Get a personal signature from the user's folder. Only checks personal folder, not shared
* folder.
*
* @param username Username of the signature owner
* @param fileName Signature filename
* @return Personal signature image bytes
* @throws IOException If file not found or read error
*/
byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException;
}
@@ -159,6 +159,8 @@ public class RequestUriUtils {
|| trimmedUri.startsWith(
"/api/v1/proprietary/ui-data/login") // Login page config (SSO providers +
// enableLogin)
|| trimmedUri.startsWith(
"/api/v1/ui-data/footer-info") // Public footer configuration
|| trimmedUri.startsWith("/v1/api-docs")
|| trimmedUri.startsWith("/api/v1/invite/validate")
|| trimmedUri.startsWith("/api/v1/invite/accept")
@@ -51,7 +51,8 @@ public class RequestUriUtilsTest {
@Test
void testIsFrontendRoute() {
assertTrue(RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(
RequestUriUtils.isFrontendRoute("", "/"), "Root path should be a frontend route");
assertTrue(
RequestUriUtils.isFrontendRoute("", "/app/dashboard"),
"React routes without extensions should be frontend routes");
@@ -21,6 +21,9 @@ public class SpringDocConfig {
"/api/v1/user/**",
"/api/v1/settings/**",
"/api/v1/team/**",
"/api/v1/auth/**",
"/api/v1/invite/**",
"/api/v1/audit/**",
"/api/v1/ui-data/**",
"/api/v1/proprietary/ui-data/**",
"/api/v1/info/**",
@@ -33,7 +36,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - Processing API")
.description(
"API documentation for PDF processing operations including conversion, manipulation, security, and utilities."));
"APIs for converting, editing, securing, and analysing PDF documents. Use these endpoints to automate common PDF tasks (like split, merge, convert, OCR) and plug them into your own apps and backend jobs."));
})
.build();
}
@@ -47,14 +50,17 @@ public class SpringDocConfig {
"/api/v1/admin/**",
"/api/v1/user/**",
"/api/v1/settings/**",
"/api/v1/team/**")
"/api/v1/team/**",
"/api/v1/auth/**",
"/api/v1/invite/**",
"/api/v1/audit/**")
.addOpenApiCustomizer(
openApi -> {
openApi.info(
openApi.getInfo()
.title("Stirling PDF - Admin API")
.title("Stirling PDF - Management API")
.description(
"API documentation for administrative functions, user management, and system configuration."));
"Endpoints for authentication, user management, invitations, audit logging, and system configuration."));
})
.build();
}
@@ -76,7 +82,7 @@ public class SpringDocConfig {
openApi.getInfo()
.title("Stirling PDF - System API")
.description(
"API documentation for system information, UI data, and utility endpoints."));
"System information, UI metadata, job status, and file management endpoints."));
})
.build();
}
@@ -26,7 +26,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.Dependency;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.annotations.api.UiDataApi;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
@@ -40,14 +40,14 @@ import stirling.software.common.util.GeneralUtils;
public class UIDataController {
private final ApplicationProperties applicationProperties;
private final SignatureService signatureService;
private final SharedSignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public UIDataController(
ApplicationProperties applicationProperties,
SignatureService signatureService,
SharedSignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -58,6 +58,21 @@ public class UIDataController {
this.runtimePathConfig = runtimePathConfig;
}
@GetMapping("/footer-info")
@Operation(summary = "Get public footer configuration data")
public ResponseEntity<FooterData> getFooterData() {
FooterData data = new FooterData();
data.setAnalyticsEnabled(applicationProperties.getSystem().getEnableAnalytics());
data.setTermsAndConditions(applicationProperties.getLegal().getTermsAndConditions());
data.setPrivacyPolicy(applicationProperties.getLegal().getPrivacyPolicy());
data.setAccessibilityStatement(
applicationProperties.getLegal().getAccessibilityStatement());
data.setCookiePolicy(applicationProperties.getLegal().getCookiePolicy());
data.setImpressum(applicationProperties.getLegal().getImpressum());
return ResponseEntity.ok(data);
}
@GetMapping("/home")
@Operation(summary = "Get home page data")
public ResponseEntity<HomeData> getHomeData() {
@@ -237,6 +252,16 @@ public class UIDataController {
}
// Data classes
@Data
public static class FooterData {
private Boolean analyticsEnabled;
private String termsAndConditions;
private String privacyPolicy;
private String accessibilityStatement;
private String cookiePolicy;
private String impressum;
}
@Data
public static class HomeData {
private boolean showSurveyFromDocker;
@@ -25,7 +25,7 @@ import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.service.UserServiceInterface;
@@ -37,13 +37,13 @@ import stirling.software.common.util.GeneralUtils;
@Slf4j
public class GeneralWebController {
private final SignatureService signatureService;
private final SharedSignatureService signatureService;
private final UserServiceInterface userService;
private final ResourceLoader resourceLoader;
private final RuntimePathConfig runtimePathConfig;
public GeneralWebController(
SignatureService signatureService,
SharedSignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService,
ResourceLoader resourceLoader,
RuntimePathConfig runtimePathConfig) {
@@ -6,12 +6,14 @@ import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ReactRoutingController {
@GetMapping("/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
@GetMapping(
"/{path:^(?!api|static|robots\\.txt|favicon\\.ico|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*$}")
public String forwardRootPaths() {
return "forward:/index.html";
}
@GetMapping("/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
@GetMapping(
"/{path:^(?!api|static|pipeline|pdfjs|pdfjs-legacy|fonts|images|files|css|js)[^\\.]*}/{subpath:^(?!.*\\.).*$}")
public String forwardNestedPaths() {
return "forward:/index.html";
}
@@ -1,48 +0,0 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.SPDF.service.SignatureService;
import stirling.software.common.service.UserServiceInterface;
// @Controller // Disabled - Backend-only mode, no Thymeleaf UI
@RequestMapping("/api/v1/general")
public class SignatureController {
private final SignatureService signatureService;
private final UserServiceInterface userService;
public SignatureController(
SignatureService signatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.signatureService = signatureService;
this.userService = userService;
}
@GetMapping("/sign/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName)
throws IOException {
String username = "NON_SECURITY_USER";
if (userService != null) {
username = userService.getCurrentUsername();
}
// Verify access permission
if (!signatureService.hasAccessToFile(username, fileName)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
byte[] imageBytes = signatureService.getSignatureBytes(username, fileName);
return ResponseEntity.ok()
.contentType( // Adjust based on file type
MediaType.IMAGE_JPEG)
.body(imageBytes);
}
}
@@ -0,0 +1,84 @@
package stirling.software.SPDF.controller.web;
import java.io.IOException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.service.SharedSignatureService;
import stirling.software.common.service.PersonalSignatureServiceInterface;
import stirling.software.common.service.UserServiceInterface;
/**
* Unified signature image controller that works for both authenticated and unauthenticated users.
* Uses composition pattern: - Core SharedSignatureService (always available): reads shared
* signatures - PersonalSignatureService (proprietary, optional): reads personal signatures For
* authenticated signature management (save/delete), see proprietary SignatureController.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/general")
public class SignatureImageController {
private final SharedSignatureService sharedSignatureService;
private final PersonalSignatureServiceInterface personalSignatureService;
private final UserServiceInterface userService;
public SignatureImageController(
SharedSignatureService sharedSignatureService,
@Autowired(required = false) PersonalSignatureServiceInterface personalSignatureService,
@Autowired(required = false) UserServiceInterface userService) {
this.sharedSignatureService = sharedSignatureService;
this.personalSignatureService = personalSignatureService;
this.userService = userService;
}
/**
* Get a signature image (works for both authenticated and unauthenticated users). -
* Authenticated with proprietary: tries personal first, then shared - Unauthenticated or
* community: tries shared only
*/
@GetMapping("/signatures/{fileName}")
public ResponseEntity<byte[]> getSignature(@PathVariable(name = "fileName") String fileName) {
try {
byte[] imageBytes = null;
// If proprietary service available and user authenticated, try personal folder first
if (personalSignatureService != null && userService != null) {
try {
String username = userService.getCurrentUsername();
imageBytes =
personalSignatureService.getPersonalSignatureBytes(username, fileName);
} catch (Exception e) {
// Not found in personal folder or not authenticated, will try shared
log.debug("Personal signature not found, trying shared: {}", e.getMessage());
}
}
// If not found in personal (or no personal service), try shared
if (imageBytes == null) {
imageBytes = sharedSignatureService.getSharedSignatureBytes(fileName);
}
// Determine content type from file extension
MediaType contentType = MediaType.IMAGE_PNG; // Default
String lowerFileName = fileName.toLowerCase();
if (lowerFileName.endsWith(".jpg") || lowerFileName.endsWith(".jpeg")) {
contentType = MediaType.IMAGE_JPEG;
}
return ResponseEntity.ok().contentType(contentType).body(imageBytes);
} catch (IOException e) {
log.debug("Signature not found: {}", fileName);
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
}
@@ -0,0 +1,18 @@
package stirling.software.SPDF.model.api.signature;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SavedSignatureRequest {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
}
@@ -0,0 +1,22 @@
package stirling.software.SPDF.model.api.signature;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SavedSignatureResponse {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types (or URL to fetch image)
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
private Long createdAt;
private Long updatedAt;
}
@@ -0,0 +1,308 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.SPDF.model.api.signature.SavedSignatureRequest;
import stirling.software.SPDF.model.api.signature.SavedSignatureResponse;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SharedSignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
public SharedSignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
this.objectMapper = new ObjectMapper();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
/**
* Get a signature from the shared (ALL_USERS) folder. This is always available for both
* authenticated and unauthenticated users.
*/
public byte[] getSharedSignatureBytes(String fileName) throws IOException {
validateFileName(fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (!Files.exists(allUsersPath)) {
throw new FileNotFoundException("Shared signature file not found");
}
return Files.readAllBytes(allUsersPath);
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
// Whitelist only safe image extensions
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
}
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
throws IOException {
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
if (!canonicalResolved.startsWith(canonicalTarget)) {
throw new IOException("Resolved path is outside the target directory");
}
}
/** Save a signature as image file */
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
throws IOException {
validateFileName(request.getId());
// Determine folder based on scope
String scope = request.getScope();
if (scope == null || scope.isEmpty()) {
scope = "personal"; // Default to personal
}
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
Files.createDirectories(targetFolder);
long timestamp = System.currentTimeMillis();
SavedSignatureResponse response = new SavedSignatureResponse();
response.setId(request.getId());
response.setLabel(request.getLabel());
response.setType(request.getType());
response.setScope(scope);
response.setCreatedAt(timestamp);
response.setUpdatedAt(timestamp);
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
String extension = validateAndNormalizeExtension(rawExtension);
// Save image file only
String imageFileName = request.getId() + "." + extension;
Path imagePath = targetFolder.resolve(imageFileName);
// Verify path is within target directory
verifyPathWithinDirectory(imagePath, targetFolder);
Files.write(
imagePath,
imageBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
// Store reference to image file
response.setDataUrl("/api/v1/general/sign/" + imageFileName);
}
log.info("Saved signature {} for user {}", request.getId(), username);
return response;
}
/** Get all saved signatures for a user */
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
List<SavedSignatureResponse> signatures = new ArrayList<>();
// Load personal signatures
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("personal");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
// Load shared signatures
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id =
fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id); // Use ID as label
sig.setType("image"); // Default type
sig.setScope("shared");
sig.setDataUrl("/api/v1/general/sign/" + fileName);
sig.setCreatedAt(
Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(
Files.getLastModifiedTime(path).toMillis());
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
}
return signatures;
}
/** Delete a saved signature */
public void deleteSignature(String username, String signatureId) throws IOException {
validateFileName(signatureId);
// Try to find and delete image file in personal folder
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
boolean deleted = false;
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
// Try shared folder if not found in personal
if (!deleted) {
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
try (Stream<Path> stream = Files.list(sharedFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
}
}
}
}
if (!deleted) {
throw new FileNotFoundException("Signature not found");
}
log.info("Deleted signature {} for user {}", signatureId, username);
}
}
@@ -1,107 +0,0 @@
package stirling.software.SPDF.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.model.SignatureFile;
import stirling.software.common.configuration.InstallationPathConfig;
@Service
@Slf4j
public class SignatureService {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
public SignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
}
public boolean hasAccessToFile(String username, String fileName) throws IOException {
validateFileName(fileName);
// Check if file exists in user's personal folder or ALL_USERS folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
return Files.exists(userPath) || Files.exists(allUsersPath);
}
public List<SignatureFile> getAvailableSignatures(String username) {
List<SignatureFile> signatures = new ArrayList<>();
// Get signatures from user's personal folder
if (StringUtils.hasText(username)) {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(userFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(userFolder, "Personal"));
} catch (IOException e) {
log.error("Error reading user signatures folder", e);
}
}
}
// Get signatures from ALL_USERS folder
Path allUsersFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(allUsersFolder)) {
try {
signatures.addAll(getSignaturesFromFolder(allUsersFolder, "Shared"));
} catch (IOException e) {
log.error("Error reading shared signatures folder", e);
}
}
return signatures;
}
private List<SignatureFile> getSignaturesFromFolder(Path folder, String category)
throws IOException {
try (Stream<Path> stream = Files.list(folder)) {
return stream.filter(this::isImageFile)
.map(path -> new SignatureFile(path.getFileName().toString(), category))
.toList();
}
}
public byte[] getSignatureBytes(String username, String fileName) throws IOException {
validateFileName(fileName);
// First try user's personal folder
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
if (Files.exists(userPath)) {
return Files.readAllBytes(userPath);
}
// Then try ALL_USERS folder
Path allUsersPath = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER, fileName);
if (Files.exists(allUsersPath)) {
return Files.readAllBytes(allUsersPath);
}
throw new FileNotFoundException("Signature file not found");
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg")
|| fileName.endsWith(".jpeg")
|| fileName.endsWith(".png")
|| fileName.endsWith(".gif");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
}
}
@@ -4,9 +4,8 @@ logging.level.org.springframework.security=WARN
logging.level.org.hibernate=WARN
logging.level.org.eclipse.jetty=WARN
#logging.level.org.springframework.security.oauth2=DEBUG
#logging.level.org.springframework.security=DEBUG
#logging.level.org.opensaml=DEBUG
#logging.level.stirling.software.proprietary.security=DEBUG
logging.level.stirling.software.proprietary.security=DEBUG
logging.level.com.zaxxer.hikari=WARN
logging.level.stirling.software.SPDF.service.PdfJsonConversionService=INFO
logging.level.stirling.software.common.service.JobExecutorService=INFO
@@ -52,9 +51,39 @@ server.servlet.session.timeout:30m
springdoc.api-docs.path=/v1/api-docs
# Set the URL of the OpenAPI JSON for the Swagger UI
springdoc.swagger-ui.url=/v1/api-docs
springdoc.swagger-ui.path=/swagger-ui.html
# Spring AI OpenAI Configuration
# Uses GPT-5-nano as primary model and GPT-5-mini as fallback (configured in settings.yml)
spring.ai.openai.enabled=true
#spring.ai.openai.api-key=# todo <API-KEY-HERE>
spring.ai.openai.base-url=https://api.openai.com
spring.ai.openai.chat.enabled=true
spring.ai.openai.chat.options.model=gpt-5-nano
# Note: Some models only support default temperature value of 1.0
spring.ai.openai.chat.options.temperature=1.0
# For newer models, use max-completion-tokens instead of max-tokens
spring.ai.openai.chat.options.max-completion-tokens=4000
spring.ai.openai.embedding.enabled=true
spring.ai.openai.embedding.options.model=text-embedding-ada-002
# Increase timeout for OpenAI API calls (default is 10 seconds)
spring.ai.openai.chat.options.connection-timeout=60s
spring.ai.openai.chat.options.read-timeout=60s
spring.ai.openai.embedding.options.connection-timeout=60s
spring.ai.openai.embedding.options.read-timeout=60s
# Spring AI Ollama Configuration (disabled to avoid bean conflicts)
spring.ai.ollama.enabled=false
spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.enabled=false
spring.ai.ollama.chat.options.model=llama3
spring.ai.ollama.chat.options.temperature=1.0
spring.ai.ollama.embedding.enabled=false
spring.ai.ollama.embedding.options.model=nomic-embed-text
# Force OpenAPI 3.0 specification version
springdoc.swagger-ui.path=/swagger-ui.html
springdoc.api-docs.version=OPENAPI_3_0
posthog.api.key=phc_fiR65u5j6qmXTYL56MNrLZSWqLaDW74OrZH0Insd2xq
posthog.host=https://eu.i.posthog.com
@@ -91,6 +91,32 @@ premium:
author: username
creator: Stirling-PDF
producer: Stirling-PDF
chatbot:
enabled: false # Master toggle for Stirling PDF chatbot feature
alphaWarning: true # Display alpha-state warning before any processing
cache:
ttlMinutes: 720 # Cache entry lifetime (12h)
maxEntries: 200 # Maximum number of cached documents per node
maxDocumentCharacters: 600000 # Reject uploads exceeding this character count
models:
primary: gpt-5-nano # Default lightweight model
fallback: gpt-5-mini # Escalation model for complex prompts
embedding: text-embedding-3-small # Embedding model for vector store usage
temperature: 0.2 # Sampling temperature for LLM responses
topP: 0.95 # Top-p (nucleus) sampling for LLM responses
rag:
chunkSizeTokens: 512 # Token window used when chunking text
chunkOverlapTokens: 128 # Overlap between successive chunks
topK: 8 # Number of chunks to retrieve per query
ocr:
enabledByDefault: false # Whether OCR pre-processing is opted-in automatically
audit:
enabled: true # Emit audit records for chatbot activity
maxPromptCharacters: 4000 # Server-side guardrail for incoming prompts
minConfidenceNano: 0.65 # Minimum nano confidence to avoid escalation
usage:
perUserMonthlyTokens: 200000 # Monthly RAG + chat token budget per user
warnAtRatio: 0.7 # Warn users when usage exceeds 70%
enterpriseFeatures:
audit:
enabled: true # Enable audit logging
@@ -24,8 +24,8 @@
--cc-toggle-on-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-off-knob-bg: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-enabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-disabled-icon-color: var(--cc-btn-primary-color);
--cc-toggle-readonly-bg: var(--md-sys-color-surface);
--cc-toggle-readonly-knob-bg: var(--md-sys-color-outline);
@@ -34,10 +34,10 @@
--cc-section-category-border: var(--md-sys-color-outline);
--cc-cookie-category-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-block-hover-border: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-bg: var(--cc-btn-secondary-bg);
--cc-cookie-category-expanded-block-hover-bg: var(--cc-toggle-readonly-bg);
@@ -47,7 +47,7 @@
*/
--cc-footer-bg: var(--cc-bg);
--cc-footer-color: var(--cc-primary-color);
--cc-footer-border-color: var(--cc-bg);
--cc-footer-border-color: var(--cc-bg);
}
.cm__body{
max-width: 90% !important;
@@ -81,4 +81,4 @@
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
}
@@ -23,7 +23,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
class SignatureServiceTest {
@TempDir Path tempDir;
private SignatureService signatureService;
private SharedSignatureService signatureService;
private Path personalSignatureFolder;
private Path sharedSignatureFolder;
private final String ALL_USERS_FOLDER = "ALL_USERS";
@@ -53,7 +53,7 @@ class SignatureServiceTest {
.thenReturn(tempDir.toString());
// Initialize the service with our temp directory
signatureService = new SignatureService();
signatureService = new SharedSignatureService();
}
}
@@ -165,7 +165,7 @@ class SignatureServiceTest {
}
@Test
void testGetSignatureBytes_PersonalFile() throws IOException {
void testGetSharedSignatureBytes_SharedFile() throws IOException {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
@@ -173,28 +173,8 @@ class SignatureServiceTest {
.when(InstallationPathConfig::getSignaturesPath)
.thenReturn(tempDir.toString());
// Test
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "personal.png");
// Verify
assertEquals(
"personal signature content",
new String(bytes),
"Should return the correct content for personal file");
}
}
@Test
void testGetSignatureBytes_SharedFile() throws IOException {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
mockedConfig
.when(InstallationPathConfig::getSignaturesPath)
.thenReturn(tempDir.toString());
// Test
byte[] bytes = signatureService.getSignatureBytes(TEST_USER, "shared.jpg");
// Test - core service only reads shared signatures
byte[] bytes = signatureService.getSharedSignatureBytes("shared.jpg");
// Verify
assertEquals(
@@ -205,7 +185,7 @@ class SignatureServiceTest {
}
@Test
void testGetSignatureBytes_FileNotFound() {
void testGetSharedSignatureBytes_FileNotFound() {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
@@ -216,13 +196,13 @@ class SignatureServiceTest {
// Test and verify
assertThrows(
FileNotFoundException.class,
() -> signatureService.getSignatureBytes(TEST_USER, "nonexistent.png"),
() -> signatureService.getSharedSignatureBytes("nonexistent.png"),
"Should throw exception for non-existent files");
}
}
@Test
void testGetSignatureBytes_InvalidFileName() {
void testGetSharedSignatureBytes_InvalidFileName() {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
@@ -233,11 +213,28 @@ class SignatureServiceTest {
// Test and verify
assertThrows(
IllegalArgumentException.class,
() -> signatureService.getSignatureBytes(TEST_USER, "../invalid.png"),
() -> signatureService.getSharedSignatureBytes("../invalid.png"),
"Should throw exception for file names with directory traversal");
}
}
@Test
void testGetSharedSignatureBytes_CannotAccessPersonalFiles() {
// Mock static method for each test
try (MockedStatic<InstallationPathConfig> mockedConfig =
mockStatic(InstallationPathConfig.class)) {
mockedConfig
.when(InstallationPathConfig::getSignaturesPath)
.thenReturn(tempDir.toString());
// Test and verify - core service should NOT be able to read personal files
assertThrows(
FileNotFoundException.class,
() -> signatureService.getSharedSignatureBytes("personal.png"),
"Core service should not have access to personal signatures");
}
}
@Test
void testGetAvailableSignatures_EmptyUsername() throws IOException {
// Mock static method for each test
+7 -1
View File
@@ -41,6 +41,8 @@ dependencies {
api 'org.springframework:spring-webmvc'
api 'org.springframework.session:spring-session-core'
api "org.springframework.security:spring-security-core:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-web:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-config:$springSecuritySamlVersion"
api "org.springframework.security:spring-security-saml2-service-provider:$springSecuritySamlVersion"
api 'org.springframework.boot:spring-boot-starter-jetty'
api 'org.springframework.boot:spring-boot-starter-security'
@@ -50,12 +52,16 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.38'
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-model-ollama'
implementation 'org.springframework.ai:spring-ai-starter-vector-store-redis'
implementation 'redis.clients:jedis:5.1.0'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.15.0'
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
implementation 'org.bouncycastle:bcprov-jdk18on:1.82'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE'
// implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity5:3.1.3.RELEASE' // Removed - UI moved to React frontend
api 'io.micrometer:micrometer-registry-prometheus'
implementation 'com.unboundid.product.scim2:scim2-sdk-client:4.0.0'
@@ -0,0 +1,102 @@
package stirling.software.proprietary.config;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.extern.slf4j.Slf4j;
import redis.clients.jedis.Connection;
import redis.clients.jedis.DefaultJedisClientConfig;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisClientConfig;
import redis.clients.jedis.JedisPooled;
@Configuration
@ConditionalOnProperty(value = "premium.proFeatures.chatbot.enabled", havingValue = "true")
@Slf4j
public class ChatbotRedisConfig {
@Value("${spring.data.redis.host:localhost}")
private String redisHost;
@Value("${spring.data.redis.port:6379}")
private int redisPort;
@Value("${spring.data.redis.password:}")
private String redisPassword;
@Value("${spring.data.redis.timeout:60000}")
private int redisTimeout;
@Value("${spring.data.redis.ssl.enabled:false}")
private boolean sslEnabled;
@Bean
public JedisPooled jedisPooled() {
try {
log.info("Creating JedisPooled connection to {}:{}", redisHost, redisPort);
// Create pool configuration
GenericObjectPoolConfig<Connection> poolConfig = new GenericObjectPoolConfig<>();
poolConfig.setMaxTotal(50);
poolConfig.setMaxIdle(25);
poolConfig.setMinIdle(5);
poolConfig.setTestOnBorrow(true);
poolConfig.setTestOnReturn(true);
poolConfig.setTestWhileIdle(true);
// Create host and port configuration
HostAndPort hostAndPort = new HostAndPort(redisHost, redisPort);
// Create client configuration with authentication if password is provided
JedisClientConfig clientConfig;
if (redisPassword != null && !redisPassword.trim().isEmpty()) {
clientConfig =
DefaultJedisClientConfig.builder()
.password(redisPassword)
.connectionTimeoutMillis(redisTimeout)
.socketTimeoutMillis(redisTimeout)
.ssl(sslEnabled)
.build();
} else {
clientConfig =
DefaultJedisClientConfig.builder()
.connectionTimeoutMillis(redisTimeout)
.socketTimeoutMillis(redisTimeout)
.ssl(sslEnabled)
.build();
}
// Create JedisPooled with configuration
JedisPooled jedisPooled = new JedisPooled(poolConfig, hostAndPort, clientConfig);
// Test the connection
try {
jedisPooled.ping();
log.info("Successfully connected to Redis at {}:{}", redisHost, redisPort);
} catch (Exception pingException) {
log.warn(
"Redis ping failed at {}:{} - {}. Redis might be unavailable.",
redisHost,
redisPort,
pingException.getMessage());
// Close the pool if ping fails
try {
jedisPooled.close();
} catch (Exception closeException) {
// Ignore close exceptions
}
return null;
}
return jedisPooled;
} catch (Exception e) {
log.error("Failed to create JedisPooled connection", e);
// Return null to fall back to SimpleVectorStore
return null;
}
}
}
@@ -0,0 +1,52 @@
package stirling.software.proprietary.config;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.vectorstore.redis.RedisVectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import lombok.extern.slf4j.Slf4j;
import redis.clients.jedis.JedisPooled;
@Configuration
@ConditionalOnProperty(value = "premium.proFeatures.chatbot.enabled", havingValue = "true")
@Slf4j
public class ChatbotVectorStoreConfig {
private static final String DEFAULT_INDEX = "stirling-chatbot-index";
private static final String DEFAULT_PREFIX = "stirling:chatbot:";
@Bean
@Primary
public VectorStore chatbotVectorStore(
@Autowired(required = false) JedisPooled jedisPooled, EmbeddingModel embeddingModel) {
if (jedisPooled != null) {
try {
log.info("Initialising Redis vector store for chatbot usage");
return RedisVectorStore.builder(jedisPooled, embeddingModel)
.indexName(DEFAULT_INDEX)
.prefix(DEFAULT_PREFIX)
.initializeSchema(true)
.build();
} catch (RuntimeException ex) {
log.warn(
"Redis vector store unavailable ({}). Falling back to SimpleVectorStore.",
sanitize(ex.getMessage()));
}
} else {
log.info("No Redis connection detected; using SimpleVectorStore for chatbot.");
}
return SimpleVectorStore.builder(embeddingModel).build();
}
private String sanitize(String message) {
return message == null ? "unknown error" : message.replaceAll("\\s+", " ").trim();
}
}
@@ -0,0 +1,82 @@
package stirling.software.proprietary.config;
import java.time.Duration;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestTemplate;
import lombok.extern.slf4j.Slf4j;
/**
* Spring AI Configuration for Stirling PDF Chatbot
*
* <p>This configuration enables Spring AI auto-configuration for chatbot features. The actual
* ChatModel and EmbeddingModel beans are provided by Spring Boot's auto-configuration based on the
* spring.ai.* properties in application-proprietary.properties
*
* <p>For OpenAI: - spring.ai.openai.enabled=true - spring.ai.openai.api-key=your-api-key
*
* <p>For Ollama (as fallback): - spring.ai.ollama.enabled=true -
* spring.ai.ollama.base-url=http://localhost:11434
*/
@Configuration
@Slf4j
public class SpringAIConfig {
public SpringAIConfig() {
log.info("Spring AI Configuration enabled for Stirling PDF Chatbot");
log.info(
"ChatModel and EmbeddingModel beans will be auto-configured based on spring.ai.* properties");
}
/** Primary ChatModel bean that delegates to OpenAI's auto-configured bean */
@Bean
@Primary
public ChatModel primaryChatModel(@Qualifier("openAiChatModel") ChatModel openAiChatModel) {
log.info("Using OpenAI ChatModel as primary");
return openAiChatModel;
}
/** Primary EmbeddingModel bean that delegates to OpenAI's auto-configured bean */
@Bean
@Primary
public EmbeddingModel primaryEmbeddingModel(
@Qualifier("openAiEmbeddingModel") EmbeddingModel openAiEmbeddingModel) {
log.info("Using OpenAI EmbeddingModel as primary");
return openAiEmbeddingModel;
}
/**
* Custom RestTemplate for Spring AI OpenAI client with increased timeouts. This helps prevent
* timeout errors when processing large documents or complex queries.
*/
@Bean(name = "openAiRestTemplate")
public RestTemplate openAiRestTemplate(RestTemplateBuilder builder) {
log.info("Creating custom RestTemplate for OpenAI with 60s timeouts");
return builder.connectTimeout(Duration.ofSeconds(60))
.readTimeout(Duration.ofSeconds(60))
.build();
}
/**
* Custom RestClient for Spring AI OpenAI with increased timeouts. Spring AI 1.0.3+ prefers
* RestClient over RestTemplate.
*/
@Bean(name = "openAiRestClient")
public RestClient openAiRestClient() {
log.info("Creating custom RestClient for OpenAI with 60s timeouts");
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(60));
factory.setReadTimeout(Duration.ofSeconds(60));
return RestClient.builder().requestFactory(factory).build();
}
}
@@ -0,0 +1,60 @@
package stirling.software.proprietary.configuration;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.Optional;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.web.client.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.Chatbot;
@Configuration
@ConditionalOnClass(RestClientCustomizer.class)
@ConditionalOnProperty(value = "spring.ai.openai.enabled", havingValue = "true")
public class ChatbotAiClientConfiguration {
@Bean
public RestClientCustomizer chatbotRestClientCustomizer(
ApplicationProperties applicationProperties) {
long connectTimeout = resolveConnectTimeout(applicationProperties);
long readTimeout = resolveReadTimeout(applicationProperties);
return builder -> builder.requestFactory(createRequestFactory(connectTimeout, readTimeout));
}
private JdkClientHttpRequestFactory createRequestFactory(
long connectTimeoutMillis, long readTimeoutMillis) {
HttpClient httpClient =
HttpClient.newBuilder()
.connectTimeout(Duration.ofMillis(connectTimeoutMillis))
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
factory.setReadTimeout((int) readTimeoutMillis);
return factory;
}
private long resolveConnectTimeout(ApplicationProperties properties) {
long configured = resolveChatbot(properties).getModels().getConnectTimeoutMillis();
return configured > 0 ? configured : 30000L;
}
private long resolveReadTimeout(ApplicationProperties properties) {
long configured = resolveChatbot(properties).getModels().getReadTimeoutMillis();
return configured > 0 ? configured : 120000L;
}
private Chatbot resolveChatbot(ApplicationProperties properties) {
return Optional.ofNullable(properties)
.map(ApplicationProperties::getPremium)
.map(Premium::getProFeatures)
.map(ProFeatures::getChatbot)
.orElseGet(Chatbot::new);
}
}
@@ -0,0 +1,126 @@
package stirling.software.proprietary.controller;
import java.util.ArrayList;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest;
import stirling.software.proprietary.model.chatbot.ChatbotResponse;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest;
import stirling.software.proprietary.model.chatbot.ChatbotSessionResponse;
import stirling.software.proprietary.model.chatbot.ChatbotUsageSummary;
import stirling.software.proprietary.service.chatbot.ChatbotCacheService;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
import stirling.software.proprietary.service.chatbot.ChatbotService;
import stirling.software.proprietary.service.chatbot.ChatbotSessionRegistry;
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/internal/chatbot")
public class ChatbotController {
private final ChatbotService chatbotService;
private final ChatbotSessionRegistry sessionRegistry;
private final ChatbotCacheService cacheService;
private final ChatbotFeatureProperties featureProperties;
@PostMapping("/session")
public ResponseEntity<ChatbotSessionResponse> createSession(
@RequestBody ChatbotSessionCreateRequest request) {
ChatbotSession session = chatbotService.createSession(request);
ChatbotSettings settings = featureProperties.current();
ChatbotSessionResponse response = toResponse(session, settings);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@PostMapping("/query")
public ResponseEntity<ChatbotResponse> query(@RequestBody ChatbotQueryRequest request) {
ChatbotResponse response = chatbotService.ask(request);
return ResponseEntity.ok(response);
}
@GetMapping("/session/{sessionId}")
public ResponseEntity<ChatbotSessionResponse> getSession(@PathVariable String sessionId) {
ChatbotSettings settings = featureProperties.current();
ChatbotSession session =
sessionRegistry
.findById(sessionId)
.orElseThrow(() -> new ChatbotException("Session not found"));
ChatbotSessionResponse response = toResponse(session, settings);
return ResponseEntity.ok(response);
}
@GetMapping("/document/{documentId}")
public ResponseEntity<ChatbotSessionResponse> getSessionByDocument(
@PathVariable String documentId) {
ChatbotSettings settings = featureProperties.current();
ChatbotSession session =
sessionRegistry
.findByDocumentId(documentId)
.orElseThrow(() -> new ChatbotException("Session not found"));
return ResponseEntity.ok(toResponse(session, settings));
}
@DeleteMapping("/session/{sessionId}")
public ResponseEntity<Void> closeSession(@PathVariable String sessionId) {
chatbotService.close(sessionId);
return ResponseEntity.noContent().build();
}
private List<String> sessionWarnings(ChatbotSettings settings, ChatbotSession session) {
List<String> warnings = new ArrayList<>();
if (session != null && session.isImageContentDetected()) {
warnings.add("Images detected - Images are not currently supported.");
}
warnings.add("Images are not yet supported. Only extracted text is sent for analysis.");
if (session != null && session.isOcrRequested()) {
warnings.add("OCR requested – uses credits .");
}
if (session != null && session.getUsageSummary() != null) {
ChatbotUsageSummary usage = session.getUsageSummary();
if (usage.isLimitExceeded()) {
warnings.add("Monthly chatbot allocation exceeded – requests may be throttled.");
} else if (usage.isNearingLimit()) {
warnings.add("You are approaching the monthly chatbot allocation.");
}
}
return warnings;
}
private ChatbotSessionResponse toResponse(ChatbotSession session, ChatbotSettings settings) {
return ChatbotSessionResponse.builder()
.sessionId(session.getSessionId())
.documentId(session.getDocumentId())
.alphaWarning(settings.alphaWarning())
.ocrRequested(session.isOcrRequested())
.imageContentDetected(session.isImageContentDetected())
.textCharacters(session.getTextCharacters())
.estimatedTokens(session.getEstimatedTokens())
.maxCachedCharacters(cacheService.getMaxDocumentCharacters())
.createdAt(session.getCreatedAt())
.warnings(sessionWarnings(settings, session))
.metadata(session.getMetadata())
.usageSummary(session.getUsageSummary())
.build();
}
}
@@ -0,0 +1,57 @@
package stirling.software.proprietary.controller;
import java.time.Instant;
import java.util.Map;
import org.eclipse.jetty.client.HttpResponseException;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.service.chatbot.ChatbotService;
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
import stirling.software.proprietary.service.chatbot.exception.NoTextDetectedException;
@RestControllerAdvice(assignableTypes = ChatbotController.class)
@Slf4j
// @ConditionalOnProperty(value = "premium.proFeatures.chatbot.enabled", havingValue = "true")
@ConditionalOnBean(ChatbotService.class)
public class ChatbotExceptionHandler {
@ExceptionHandler(NoTextDetectedException.class)
public ResponseEntity<Map<String, Object>> handleNoText(NoTextDetectedException ex) {
return buildResponse(HttpStatus.UNPROCESSABLE_ENTITY, ex.getMessage());
}
@ExceptionHandler(ChatbotException.class)
public ResponseEntity<Map<String, Object>> handleChatbot(ChatbotException ex) {
log.debug("Chatbot exception: {}", ex.getMessage());
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegalArgument(IllegalArgumentException ex) {
return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
}
@ExceptionHandler(HttpResponseException.class)
public ResponseEntity<Map<String, Object>> handleProvider(HttpResponseException ex) {
log.warn("Chatbot provider error", ex);
return buildResponse(
HttpStatus.BAD_GATEWAY,
"Chatbot provider rejected the request: " + ex.getMessage());
}
private ResponseEntity<Map<String, Object>> buildResponse(HttpStatus status, String message) {
Map<String, Object> payload =
Map.of(
"timestamp", Instant.now().toString(),
"status", status.value(),
"error", message);
return ResponseEntity.status(status).body(payload);
}
}
@@ -0,0 +1,102 @@
package stirling.software.proprietary.controller.api;
import java.io.IOException;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.SignatureService;
/**
* Controller for managing user signatures in proprietary/authenticated mode only. Requires user
* authentication and enforces per-user storage limits. All endpoints require authentication
* via @PreAuthorize("isAuthenticated()").
*/
@UserApi
@Slf4j
@RestController
@RequestMapping("/api/v1/proprietary/signatures")
@RequiredArgsConstructor
@PreAuthorize("isAuthenticated()")
public class SignatureController {
private final SignatureService signatureService;
private final UserService userService;
/**
* Save a new signature for the authenticated user. Enforces storage limits and authentication
* requirements.
*/
@PostMapping
public ResponseEntity<SavedSignatureResponse> saveSignature(
@RequestBody SavedSignatureRequest request) {
try {
String username = userService.getCurrentUsername();
// Validate request
if (request.getDataUrl() == null || request.getDataUrl().isEmpty()) {
log.warn("User {} attempted to save signature without dataUrl", username);
return ResponseEntity.badRequest().build();
}
SavedSignatureResponse response = signatureService.saveSignature(username, request);
log.info("User {} saved signature {}", username, request.getId());
return ResponseEntity.ok(response);
} catch (IllegalArgumentException e) {
log.warn("Invalid signature save request: {}", e.getMessage());
return ResponseEntity.badRequest().build();
} catch (IOException e) {
log.error("Failed to save signature", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* List all signatures accessible to the authenticated user. Includes both personal and shared
* signatures.
*/
@GetMapping
public ResponseEntity<List<SavedSignatureResponse>> listSignatures() {
try {
String username = userService.getCurrentUsername();
List<SavedSignatureResponse> signatures = signatureService.getSavedSignatures(username);
return ResponseEntity.ok(signatures);
} catch (IOException e) {
log.error("Failed to list signatures for user", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
/**
* Delete a signature owned by the authenticated user. Users can only delete their own personal
* signatures, not shared ones.
*/
@DeleteMapping("/{signatureId}")
public ResponseEntity<Void> deleteSignature(@PathVariable String signatureId) {
try {
String username = userService.getCurrentUsername();
signatureService.deleteSignature(username, signatureId);
log.info("User {} deleted signature {}", username, signatureId);
return ResponseEntity.noContent().build();
} catch (IOException e) {
log.warn("Failed to delete signature {} for user: {}", signatureId, e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
}
}
@@ -34,7 +34,19 @@ import stirling.software.proprietary.util.FormUtils;
@RestController
@RequestMapping("/api/v1/form")
@Tag(name = "Forms", description = "PDF form APIs")
@Tag(
name = "Forms",
description =
"""
Work with PDF form fields: read them, fill them, edit them, or remove them.
Treats a PDF as a structured form instead of just flat pages.
Typical uses:
• Inspect which form fields exist in a PDF
• Autofill forms from your own systems (e.g. CRM, ERP)
• Change or delete form fields before sending out a final, non-editable copy
• Unlock read-only form fields when you need to update them
""")
@RequiredArgsConstructor
public class FormFillController {
@@ -0,0 +1,18 @@
package stirling.software.proprietary.model.api.signature;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
public class SavedSignatureRequest {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.model.api.signature;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SavedSignatureResponse {
private String id;
private String label;
private String type; // "canvas", "image", "text"
private String scope; // "personal", "shared"
private String dataUrl; // For canvas and image types (or URL to fetch image)
private String signerName; // For text type
private String fontFamily; // For text type
private Integer fontSize; // For text type
private String textColor; // For text type
private Long createdAt;
private Long updatedAt;
}
@@ -0,0 +1,30 @@
package stirling.software.proprietary.model.chatbot;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatbotDocumentCacheEntry {
private String cacheKey;
private String sessionId;
private String documentId;
private Map<String, String> metadata;
private boolean ocrApplied;
private boolean imageContentDetected;
private long textCharacters;
private Instant storedAt;
public Map<String, String> getMetadata() {
return metadata == null ? Collections.emptyMap() : metadata;
}
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.model.chatbot;
import java.time.Instant;
/** Simple record representing a stored chatbot conversation turn. */
public record ChatbotHistoryEntry(
String role, String content, String documentId, String documentName, Instant timestamp) {}
@@ -0,0 +1,17 @@
package stirling.software.proprietary.model.chatbot;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatbotQueryRequest {
private String sessionId;
private String prompt;
private boolean allowEscalation;
}
@@ -0,0 +1,41 @@
package stirling.software.proprietary.model.chatbot;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatbotResponse {
private String sessionId;
private String modelUsed;
private double confidence;
private String answer;
private boolean escalated;
private boolean servedFromNanoOnly;
private boolean cacheHit;
private Instant respondedAt;
private List<String> warnings;
private Map<String, Object> metadata;
private long promptTokens;
private long completionTokens;
private long totalTokens;
private ChatbotUsageSummary usageSummary;
public List<String> getWarnings() {
return warnings == null ? Collections.emptyList() : warnings;
}
public Map<String, Object> getMetadata() {
return metadata == null ? Collections.emptyMap() : metadata;
}
}
@@ -0,0 +1,37 @@
package stirling.software.proprietary.model.chatbot;
import java.time.Instant;
import java.util.Collections;
import java.util.Map;
import java.util.UUID;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class ChatbotSession {
private String sessionId;
private String documentId;
private String userId;
private Map<String, String> metadata;
private boolean ocrRequested;
private boolean warningsAccepted;
private boolean alphaWarningRequired;
private boolean imageContentDetected;
private long textCharacters;
private long estimatedTokens;
private String cacheKey;
private String vectorStoreId;
private Instant createdAt;
private ChatbotUsageSummary usageSummary;
public static String randomSessionId() {
return UUID.randomUUID().toString();
}
public Map<String, String> getMetadata() {
return metadata == null ? Collections.emptyMap() : metadata;
}
}
@@ -0,0 +1,24 @@
package stirling.software.proprietary.model.chatbot;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatbotSessionCreateRequest {
private String sessionId;
private String documentId;
private String userId;
private String text;
private Map<String, String> metadata;
private boolean ocrRequested;
private boolean warningsAccepted;
private boolean imagesDetected;
}
@@ -0,0 +1,39 @@
package stirling.software.proprietary.model.chatbot;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatbotSessionResponse {
private String sessionId;
private String documentId;
private boolean alphaWarning;
private boolean ocrRequested;
private boolean imageContentDetected;
private long maxCachedCharacters;
private long textCharacters;
private long estimatedTokens;
private Instant createdAt;
private List<String> warnings;
private Map<String, String> metadata;
private ChatbotUsageSummary usageSummary;
public List<String> getWarnings() {
return warnings == null ? Collections.emptyList() : warnings;
}
public Map<String, String> getMetadata() {
return metadata == null ? Collections.emptyMap() : metadata;
}
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.model.chatbot;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ChatbotUsageSummary {
private long allocatedTokens;
private long consumedTokens;
private long remainingTokens;
private double usageRatio;
private boolean nearingLimit;
private boolean limitExceeded;
private long lastIncrementTokens;
private String window;
}
@@ -35,15 +35,40 @@ public class MailConfig {
JavaMailSenderImpl mailSender = new JavaMailSenderImpl();
mailSender.setHost(mailProperties.getHost());
mailSender.setPort(mailProperties.getPort());
mailSender.setUsername(mailProperties.getUsername());
mailSender.setPassword(mailProperties.getPassword());
mailSender.setDefaultEncoding("UTF-8");
// Only set username and password if they are provided
String username = mailProperties.getUsername();
String password = mailProperties.getPassword();
boolean hasCredentials =
(username != null && !username.trim().isEmpty())
|| (password != null && !password.trim().isEmpty());
if (username != null && !username.trim().isEmpty()) {
mailSender.setUsername(username);
log.info("SMTP username configured");
} else {
log.info("SMTP username not configured - using anonymous connection");
}
if (password != null && !password.trim().isEmpty()) {
mailSender.setPassword(password);
log.info("SMTP password configured");
} else {
log.info("SMTP password not configured");
}
// Retrieves the JavaMail properties to configure additional SMTP parameters
Properties props = mailSender.getJavaMailProperties();
// Enables SMTP authentication
props.put("mail.smtp.auth", "true");
// Only enable SMTP authentication if credentials are provided
if (hasCredentials) {
props.put("mail.smtp.auth", "true");
log.info("SMTP authentication enabled");
} else {
props.put("mail.smtp.auth", "false");
log.info("SMTP authentication disabled - no credentials provided");
}
// Enables STARTTLS to encrypt the connection if supported by the SMTP server
props.put("mail.smtp.starttls.enable", "true");
@@ -59,6 +59,7 @@ import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@Slf4j
@Configuration
@@ -84,8 +85,7 @@ public class SecurityConfiguration {
private final GrantedAuthoritiesMapper oAuth2userAuthoritiesMapper;
private final RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations;
private final OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final UserLicenseSettingsService licenseSettingsService;
public SecurityConfiguration(
PersistentLoginRepository persistentLoginRepository,
@@ -106,8 +106,7 @@ public class SecurityConfiguration {
RelyingPartyRegistrationRepository saml2RelyingPartyRegistrations,
@Autowired(required = false)
OpenSaml4AuthenticationRequestResolver saml2AuthenticationRequestResolver,
stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService) {
UserLicenseSettingsService licenseSettingsService) {
this.userDetailsService = userDetailsService;
this.userService = userService;
this.loginEnabledValue = loginEnabledValue;
@@ -221,9 +220,19 @@ public class SecurityConfiguration {
csrf.ignoringRequestMatchers(
request -> {
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
String trimmedUri =
uri.startsWith(contextPath)
? uri.substring(
contextPath.length())
: uri;
// Ignore CSRF for auth endpoints
if (uri.startsWith("/api/v1/auth/")) {
// Ignore CSRF for auth endpoints + oauth/saml
if (trimmedUri.startsWith("/api/v1/auth/")
|| trimmedUri.startsWith("/oauth2")
|| trimmedUri.startsWith("/saml2")
|| trimmedUri.startsWith(
"/login/oauth2/code/")) {
return true;
}
@@ -360,7 +369,8 @@ public class SecurityConfiguration {
securityProperties.getOauth2(),
userService,
jwtService,
licenseSettingsService))
licenseSettingsService,
applicationProperties))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
@@ -1,22 +1,32 @@
package stirling.software.proprietary.security.controller.api;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.util.GeneralUtils;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier;
@@ -242,4 +252,159 @@ public class AdminLicenseController {
.body(Map.of("error", "Failed to retrieve license information"));
}
}
/**
* Upload a license certificate file for offline activation. Accepts .lic or .cert files,
* validates the certificate format, saves to configs directory, and activates the license.
*
* @param file The license certificate file to upload
* @return Response with success status, license type, and file information
*/
@PostMapping(value = "/license-file", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Upload license certificate file",
description =
"Upload a license certificate file (.lic, .cert) for offline activation."
+ " Validates the file format and activates the license.")
public ResponseEntity<Map<String, Object>> uploadLicenseFile(
@RequestParam("file") MultipartFile file) {
// Validate file exists
if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File is empty"));
}
String filename = file.getOriginalFilename();
if (filename == null || filename.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid filename"));
}
// Prevent path traversal and enforce single filename component
if (filename.contains("..") || filename.contains("/") || filename.contains("\\")) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Filename must not contain path separators or '..'"));
}
// Validate file extension
if (!isValidLicenseFile(filename)) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Invalid file type. Expected .lic or .cert"));
}
// Check file size (max 1MB for license files)
if (file.getSize() > 1_048_576) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "File too large. Maximum 1MB allowed"));
}
try {
// Validate certificate format by reading content
byte[] fileBytes = file.getBytes();
String content = new String(fileBytes, StandardCharsets.UTF_8);
if (!content.trim().startsWith("-----BEGIN LICENSE FILE-----")) {
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Invalid license certificate format"));
}
// Get config directory and target path
Path configPath = Paths.get(InstallationPathConfig.getConfigPath());
Path targetPath = configPath.resolve(filename).normalize();
// Prevent directory traversal: ensure targetPath is inside configPath
if (!targetPath.startsWith(configPath.normalize().toAbsolutePath())) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "error", "Invalid file path"));
}
// Backup existing file if present
if (Files.exists(targetPath)) {
Path backupDir = configPath.resolve("backup");
Files.createDirectories(backupDir);
String backupFilename = filename + ".bak." + System.currentTimeMillis();
Path backupPath = backupDir.resolve(backupFilename);
Files.copy(targetPath, backupPath, StandardCopyOption.REPLACE_EXISTING);
log.info("Backed up existing license file to: {}", backupPath);
}
// Write new license file
Files.write(targetPath, fileBytes);
log.info("License file saved to: {}", targetPath);
// assume premium enabled when setting license key
applicationProperties.getPremium().setEnabled(true);
// Update settings with file reference (relative path)
String fileReference = "file:configs/" + filename;
licenseKeyChecker.updateLicenseKey(fileReference);
// Get license status after activation
License license = licenseKeyChecker.getPremiumLicenseEnabledResult();
Map<String, Object> response = new HashMap<>();
response.put("success", true);
response.put("licenseType", license.name());
response.put("filename", filename);
response.put("filePath", "configs/" + filename);
response.put("enabled", applicationProperties.getPremium().isEnabled());
response.put("maxUsers", applicationProperties.getPremium().getMaxUsers());
response.put("message", "License file uploaded and activated");
log.info(
"License file uploaded and activated: filename={}, type={}",
filename,
license.name());
return ResponseEntity.ok(response);
} catch (IOException e) {
log.error("Failed to save license file", e);
return ResponseEntity.internalServerError()
.body(
Map.of(
"success",
false,
"error",
"Failed to save license file: " + e.getMessage()));
} catch (Exception e) {
log.error("Failed to activate license from file", e);
return ResponseEntity.badRequest()
.body(
Map.of(
"success",
false,
"error",
"Failed to activate license: " + e.getMessage()));
}
}
/**
* Validates if the filename has a valid license file extension (.lic or .cert)
*
* @param filename The filename to validate
* @return true if the filename ends with .lic or .cert (case-insensitive)
*/
private boolean isValidLicenseFile(String filename) {
if (filename == null) {
return false;
}
String lower = filename.toLowerCase();
return lower.endsWith(".lic") || lower.endsWith(".cert");
}
}
@@ -407,7 +407,8 @@ public class UserController {
public ResponseEntity<?> inviteUsers(
@RequestParam(name = "emails", required = true) String emails,
@RequestParam(name = "role", defaultValue = "ROLE_USER") String role,
@RequestParam(name = "teamId", required = false) Long teamId)
@RequestParam(name = "teamId", required = false) Long teamId,
HttpServletRequest request)
throws SQLException, UnsupportedProviderException {
// Check if email invites are enabled
@@ -477,6 +478,9 @@ public class UserController {
}
}
// Build login URL
String loginUrl = buildLoginUrl(request);
int successCount = 0;
int failureCount = 0;
StringBuilder errors = new StringBuilder();
@@ -488,7 +492,7 @@ public class UserController {
continue;
}
InviteResult result = processEmailInvite(email, effectiveTeamId, role);
InviteResult result = processEmailInvite(email, effectiveTeamId, role, loginUrl);
if (result.isSuccess()) {
successCount++;
} else {
@@ -687,15 +691,45 @@ public class UserController {
return ResponseEntity.ok(apiKey);
}
/**
* Helper method to build the login URL from the application configuration or request.
*
* @param request The HTTP request
* @return The login URL
*/
private String buildLoginUrl(HttpServletRequest request) {
String baseUrl;
String configuredFrontendUrl = applicationProperties.getSystem().getFrontendUrl();
if (configuredFrontendUrl != null && !configuredFrontendUrl.trim().isEmpty()) {
// Use configured frontend URL (remove trailing slash if present)
baseUrl =
configuredFrontendUrl.endsWith("/")
? configuredFrontendUrl.substring(0, configuredFrontendUrl.length() - 1)
: configuredFrontendUrl;
} else {
// Fall back to backend URL from request
baseUrl =
request.getScheme()
+ "://"
+ request.getServerName()
+ (request.getServerPort() != 80 && request.getServerPort() != 443
? ":" + request.getServerPort()
: "");
}
return baseUrl + "/login";
}
/**
* Helper method to process a single email invitation.
*
* @param email The email address to invite
* @param teamId The team ID to assign the user to
* @param role The role to assign to the user
* @param loginUrl The URL to the login page
* @return InviteResult containing success status and optional error message
*/
private InviteResult processEmailInvite(String email, Long teamId, String role) {
private InviteResult processEmailInvite(
String email, Long teamId, String role, String loginUrl) {
try {
// Validate email format (basic check)
if (!email.contains("@") || !email.contains(".")) {
@@ -715,7 +749,7 @@ public class UserController {
// Send invite email
try {
emailService.get().sendInviteEmail(email, email, temporaryPassword);
emailService.get().sendInviteEmail(email, email, temporaryPassword, loginUrl);
log.info("Sent invite email to: {}", email);
return InviteResult.success();
} catch (Exception emailEx) {
@@ -299,7 +299,8 @@ public class UserAuthenticationFilter extends OncePerRequestFilter {
contextPath + "/api/v1/auth/refresh",
contextPath + "/api/v1/auth/me",
contextPath + "/api/v1/invite/validate",
contextPath + "/api/v1/invite/accept"
contextPath + "/api/v1/invite/accept",
contextPath + "/api/v1/ui-data/footer-info"
};
for (String pattern : publicApiPatterns) {
@@ -38,6 +38,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.service.UserLicenseSettingsService;
@RequiredArgsConstructor
public class CustomOAuth2AuthenticationSuccessHandler
@@ -50,8 +51,8 @@ public class CustomOAuth2AuthenticationSuccessHandler
private final ApplicationProperties.Security.OAUTH2 oauth2Properties;
private final UserService userService;
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final UserLicenseSettingsService licenseSettingsService;
private final ApplicationProperties applicationProperties;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -115,10 +115,12 @@ public class EmailService {
* @param to The recipient email address
* @param username The username for the new account
* @param temporaryPassword The temporary password
* @param loginUrl The URL to the login page
* @throws MessagingException If there is an issue with creating or sending the email.
*/
@Async
public void sendInviteEmail(String to, String username, String temporaryPassword)
public void sendInviteEmail(
String to, String username, String temporaryPassword, String loginUrl)
throws MessagingException {
String subject = "Welcome to Stirling PDF";
@@ -144,6 +146,14 @@ public class EmailService {
<div style="background-color: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0; color: #856404;"><strong>⚠️ Important:</strong> You will be required to change your password upon first login for security reasons.</p>
</div>
<!-- CTA Button -->
<div style="text-align: center; margin: 30px 0;">
<a href="%s" style="display: inline-block; background-color: #007bff; color: #ffffff; padding: 14px 28px; text-decoration: none; border-radius: 5px; font-weight: bold;">Log In to Stirling PDF</a>
</div>
<p style="font-size: 14px; color: #666;">Or copy and paste this link in your browser:</p>
<div style="background-color: #f8f9fa; padding: 12px; margin: 15px 0; border-radius: 4px; word-break: break-all; font-size: 13px; color: #555;">
%s
</div>
<p>Please keep these credentials secure and do not share them with anyone.</p>
<p style="margin-bottom: 0;">— The Stirling PDF Team</p>
</div>
@@ -155,7 +165,7 @@ public class EmailService {
</div>
</body></html>
"""
.formatted(username, temporaryPassword);
.formatted(username, temporaryPassword, loginUrl, loginUrl);
sendPlainEmail(to, subject, body, true);
}
@@ -0,0 +1,299 @@
package stirling.software.proprietary.service;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.service.PersonalSignatureServiceInterface;
import stirling.software.proprietary.model.api.signature.SavedSignatureRequest;
import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
/**
* Service for managing user signatures with authentication and storage limits. This proprietary
* version enforces per-user quotas and requires authentication. Provides access to personal
* signatures only (shared signatures handled by core service).
*/
@Service
@Slf4j
public class SignatureService implements PersonalSignatureServiceInterface {
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
// Storage limits per user
private static final int MAX_SIGNATURES_PER_USER = 20;
private static final long MAX_SIGNATURE_SIZE_BYTES = 2_000_000; // 2MB per signature
private static final long MAX_TOTAL_USER_STORAGE_BYTES = 20_000_000; // 20MB total per user
public SignatureService() {
SIGNATURE_BASE_PATH = InstallationPathConfig.getSignaturesPath();
}
/**
* Get a personal signature from the user's folder only. Does NOT check shared folder (that's
* handled by core service).
*/
@Override
public byte[] getPersonalSignatureBytes(String username, String fileName) throws IOException {
validateFileName(fileName);
Path userPath = Paths.get(SIGNATURE_BASE_PATH, username, fileName);
if (!Files.exists(userPath)) {
throw new FileNotFoundException("Personal signature not found");
}
return Files.readAllBytes(userPath);
}
/** Save a signature with storage limits enforced. */
public SavedSignatureResponse saveSignature(String username, SavedSignatureRequest request)
throws IOException {
validateFileName(request.getId());
// Determine folder based on scope
String scope = request.getScope();
if (scope == null || scope.isEmpty()) {
scope = "personal"; // Default to personal
}
String folderName = "shared".equals(scope) ? ALL_USERS_FOLDER : username;
Path targetFolder = Paths.get(SIGNATURE_BASE_PATH, folderName);
// Only enforce limits for personal signatures (not shared)
if ("personal".equals(scope)) {
enforceStorageLimits(username, request.getDataUrl());
}
Files.createDirectories(targetFolder);
long timestamp = System.currentTimeMillis();
SavedSignatureResponse response = new SavedSignatureResponse();
response.setId(request.getId());
response.setLabel(request.getLabel());
response.setType(request.getType());
response.setScope(scope);
response.setCreatedAt(timestamp);
response.setUpdatedAt(timestamp);
// Extract and save image data
String dataUrl = request.getDataUrl();
if (dataUrl != null && dataUrl.startsWith("data:image/")) {
// Validate dataUrl size before decoding
if (dataUrl.length() > MAX_SIGNATURE_SIZE_BYTES * 2) {
throw new IllegalArgumentException(
"Signature data too large (max "
+ (MAX_SIGNATURE_SIZE_BYTES / 1024)
+ "KB)");
}
// Extract base64 data
String base64Data = dataUrl.substring(dataUrl.indexOf(",") + 1);
byte[] imageBytes = Base64.getDecoder().decode(base64Data);
// Validate decoded size
if (imageBytes.length > MAX_SIGNATURE_SIZE_BYTES) {
throw new IllegalArgumentException(
"Signature image too large (max "
+ (MAX_SIGNATURE_SIZE_BYTES / 1024)
+ "KB)");
}
// Determine and validate file extension from data URL
String mimeType = dataUrl.substring(dataUrl.indexOf(":") + 1, dataUrl.indexOf(";"));
String rawExtension = mimeType.substring(mimeType.indexOf("/") + 1);
String extension = validateAndNormalizeExtension(rawExtension);
// Save image file
String imageFileName = request.getId() + "." + extension;
Path imagePath = targetFolder.resolve(imageFileName);
// Verify path is within target directory
verifyPathWithinDirectory(imagePath, targetFolder);
Files.write(
imagePath,
imageBytes,
StandardOpenOption.CREATE,
StandardOpenOption.TRUNCATE_EXISTING);
// Store reference to image file (unified endpoint for all signatures)
response.setDataUrl("/api/v1/general/signatures/" + imageFileName);
}
log.info("Saved signature {} for user {} (scope: {})", request.getId(), username, scope);
return response;
}
/** Get all saved signatures for a user (personal + shared). */
public List<SavedSignatureResponse> getSavedSignatures(String username) throws IOException {
List<SavedSignatureResponse> signatures = new ArrayList<>();
// Load personal signatures
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (Files.exists(personalFolder)) {
signatures.addAll(loadSignaturesFromFolder(personalFolder, "personal", true));
}
// Load shared signatures
Path sharedFolder = Paths.get(SIGNATURE_BASE_PATH, ALL_USERS_FOLDER);
if (Files.exists(sharedFolder)) {
signatures.addAll(loadSignaturesFromFolder(sharedFolder, "shared", false));
}
return signatures;
}
/** Delete a signature from user's personal folder. Cannot delete shared signatures. */
public void deleteSignature(String username, String signatureId) throws IOException {
validateFileName(signatureId);
// Only allow deletion from personal folder
Path personalFolder = Paths.get(SIGNATURE_BASE_PATH, username);
boolean deleted = false;
if (Files.exists(personalFolder)) {
try (Stream<Path> stream = Files.list(personalFolder)) {
List<Path> matchingFiles =
stream.filter(
path ->
path.getFileName()
.toString()
.startsWith(signatureId + "."))
.toList();
for (Path file : matchingFiles) {
Files.delete(file);
deleted = true;
log.info("Deleted signature file: {}", file);
}
}
}
if (!deleted) {
throw new FileNotFoundException("Signature not found or cannot be deleted");
}
}
// Private helper methods
private void enforceStorageLimits(String username, String dataUrlToAdd) throws IOException {
Path userFolder = Paths.get(SIGNATURE_BASE_PATH, username);
if (!Files.exists(userFolder)) {
return; // First signature, no limits to check
}
// Count existing signatures
long signatureCount;
try (Stream<Path> stream = Files.list(userFolder)) {
signatureCount = stream.filter(this::isImageFile).count();
}
if (signatureCount >= MAX_SIGNATURES_PER_USER) {
throw new IllegalArgumentException(
"Maximum signatures limit reached (" + MAX_SIGNATURES_PER_USER + ")");
}
// Calculate total storage used
long totalSize = 0;
try (Stream<Path> stream = Files.list(userFolder)) {
totalSize =
stream.filter(this::isImageFile)
.mapToLong(
path -> {
try {
return Files.size(path);
} catch (IOException e) {
return 0;
}
})
.sum();
}
// Estimate new signature size (base64 decodes to ~75% of original)
long estimatedNewSize = (long) (dataUrlToAdd.length() * 0.75);
if (totalSize + estimatedNewSize > MAX_TOTAL_USER_STORAGE_BYTES) {
throw new IllegalArgumentException(
"Storage quota exceeded (max "
+ (MAX_TOTAL_USER_STORAGE_BYTES / 1_000_000)
+ "MB)");
}
}
private List<SavedSignatureResponse> loadSignaturesFromFolder(
Path folder, String scope, boolean isPersonal) throws IOException {
List<SavedSignatureResponse> signatures = new ArrayList<>();
try (Stream<Path> stream = Files.list(folder)) {
stream.filter(this::isImageFile)
.forEach(
path -> {
try {
String fileName = path.getFileName().toString();
String id = fileName.substring(0, fileName.lastIndexOf('.'));
SavedSignatureResponse sig = new SavedSignatureResponse();
sig.setId(id);
sig.setLabel(id);
sig.setType("image");
sig.setScope(scope);
sig.setCreatedAt(Files.getLastModifiedTime(path).toMillis());
sig.setUpdatedAt(Files.getLastModifiedTime(path).toMillis());
// Set unified URL path (works for both personal and shared)
sig.setDataUrl("/api/v1/general/signatures/" + fileName);
signatures.add(sig);
} catch (IOException e) {
log.error("Error reading signature file: " + path, e);
}
});
}
return signatures;
}
private boolean isImageFile(Path path) {
String fileName = path.getFileName().toString().toLowerCase();
return fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") || fileName.endsWith(".png");
}
private void validateFileName(String fileName) {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
}
private void verifyPathWithinDirectory(Path resolvedPath, Path targetDirectory)
throws IOException {
Path canonicalTarget = targetDirectory.toAbsolutePath().normalize();
Path canonicalResolved = resolvedPath.toAbsolutePath().normalize();
if (!canonicalResolved.startsWith(canonicalTarget)) {
throw new IOException("Resolved path is outside the target directory");
}
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.service.chatbot;
import java.time.Duration;
import java.time.Instant;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Service;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.Chatbot;
import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry;
@Service
// @ConditionalOnProperty(value = "premium.proFeatures.chatbot.enabled", havingValue = "true")
@Slf4j
public class ChatbotCacheService {
private final Cache<String, ChatbotDocumentCacheEntry>
documentCache; // todo: can redis be used instead?
private final long maxDocumentCharacters;
private final Map<String, String> sessionToCacheKey = new ConcurrentHashMap<>();
public ChatbotCacheService(ApplicationProperties applicationProperties) {
Chatbot chatbotConfig = resolveChatbot(applicationProperties);
ApplicationProperties.Premium.ProFeatures.Chatbot.Cache cacheSettings =
chatbotConfig.getCache();
this.maxDocumentCharacters = cacheSettings.getMaxDocumentCharacters();
long ttlMinutes = Math.max(cacheSettings.getTtlMinutes(), 1);
long maxEntries = Math.max(cacheSettings.getMaxEntries(), 1);
long maxTotalCharacters =
Math.max(cacheSettings.getMaxDocumentCharacters() * maxEntries, 1);
this.documentCache =
Caffeine.newBuilder()
.maximumWeight(maxTotalCharacters)
.weigher(
(String key, ChatbotDocumentCacheEntry entry) ->
(int)
Math.min(
entry.getTextCharacters()
+ estimateMetadataWeight(entry),
Integer.MAX_VALUE))
.expireAfterWrite(Duration.ofMinutes(ttlMinutes))
.recordStats()
.build();
log.info(
"Initialised chatbot document cache with maxEntries={} ttlMinutes={} maxChars={} maxWeight={} characters",
maxEntries,
ttlMinutes,
maxDocumentCharacters,
maxTotalCharacters);
}
public long getMaxDocumentCharacters() {
return maxDocumentCharacters;
}
private long estimateMetadataWeight(ChatbotDocumentCacheEntry entry) {
if (entry == null || entry.getMetadata() == null) {
return 0L;
}
return entry.getMetadata().entrySet().stream()
.mapToLong(e -> safeLength(e.getKey()) + safeLength(e.getValue()))
.sum();
}
private long safeLength(String value) {
return value == null ? 0L : value.length();
}
public String register(
String sessionId,
String documentId,
Map<String, String> metadata,
boolean ocrApplied,
boolean imageContentDetected,
long textCharacters) {
Objects.requireNonNull(sessionId, "sessionId must not be null");
Objects.requireNonNull(documentId, "documentId must not be null");
String cacheKey =
sessionToCacheKey.computeIfAbsent(sessionId, k -> UUID.randomUUID().toString());
ChatbotDocumentCacheEntry entry =
ChatbotDocumentCacheEntry.builder()
.cacheKey(cacheKey)
.sessionId(sessionId)
.documentId(documentId)
.metadata(metadata)
.ocrApplied(ocrApplied)
.imageContentDetected(imageContentDetected)
.textCharacters(textCharacters)
.storedAt(Instant.now())
.build();
documentCache.put(cacheKey, entry);
return cacheKey;
}
public Optional<ChatbotDocumentCacheEntry> resolveByCacheKey(String cacheKey) {
return Optional.ofNullable(documentCache.getIfPresent(cacheKey));
}
public Optional<ChatbotDocumentCacheEntry> resolveBySessionId(String sessionId) {
return Optional.ofNullable(sessionToCacheKey.get(sessionId))
.flatMap(this::resolveByCacheKey);
}
public void invalidateSession(String sessionId) {
Optional.ofNullable(sessionToCacheKey.remove(sessionId))
.ifPresent(documentCache::invalidate);
}
public void invalidateCacheKey(String cacheKey) {
documentCache.invalidate(cacheKey);
sessionToCacheKey.values().removeIf(value -> value.equals(cacheKey));
}
public Map<String, ChatbotDocumentCacheEntry> snapshot() {
return Map.copyOf(documentCache.asMap());
}
private Chatbot resolveChatbot(ApplicationProperties properties) {
if (properties == null) {
return new Chatbot();
}
Premium premium = properties.getPremium();
if (premium == null) {
return new Chatbot();
}
ProFeatures pro = premium.getProFeatures();
if (pro == null) {
return new Chatbot();
}
Chatbot chatbot = pro.getChatbot();
return chatbot == null ? new Chatbot() : chatbot;
}
}
@@ -0,0 +1,49 @@
package stirling.software.proprietary.service.chatbot;
import java.util.List;
import org.springframework.ai.document.Document;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
@Component
public class ChatbotContextCompressor {
private static final int DEFAULT_SUMMARY_LIMIT = 3000;
private static final int MIN_CHUNK_SNIPPET = 160;
public String summarize(List<Document> documents, int requestedLimit) {
if (CollectionUtils.isEmpty(documents)) {
return "No contextual snippets available for this session.";
}
int maxChars =
requestedLimit > 0
? Math.min(requestedLimit, DEFAULT_SUMMARY_LIMIT)
: DEFAULT_SUMMARY_LIMIT;
StringBuilder builder = new StringBuilder();
int perChunkLimit = Math.max(MIN_CHUNK_SNIPPET, maxChars / Math.max(documents.size(), 1));
for (Document doc : documents) {
if (builder.length() >= maxChars) {
break;
}
String chunkOrder = doc.getMetadata().getOrDefault("chunkOrder", "?").toString();
String text = trimContent(doc.getText(), perChunkLimit);
builder.append("Chunk ").append(chunkOrder).append(": ").append(text).append('\n');
}
if (builder.length() == 0) {
return "Unable to summarise context; original content unavailable.";
}
return builder.substring(0, Math.min(builder.length(), maxChars)).trim();
}
private String trimContent(String content, int perChunkLimit) {
if (content == null || content.isBlank()) {
return "(empty chunk)";
}
String normalized = content.replaceAll("\\s+", " ").trim();
if (normalized.length() <= perChunkLimit) {
return normalized;
}
return normalized.substring(0, Math.max(0, perChunkLimit - 3)) + "...";
}
}
@@ -0,0 +1,633 @@
package stirling.software.proprietary.service.chatbot;
import java.io.IOException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.atomic.AtomicBoolean;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.metadata.Usage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.document.Document;
import org.springframework.ai.ollama.OllamaChatModel;
import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry;
import stirling.software.proprietary.model.chatbot.ChatbotHistoryEntry;
import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest;
import stirling.software.proprietary.model.chatbot.ChatbotResponse;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
import stirling.software.proprietary.model.chatbot.ChatbotUsageSummary;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatbotConversationService {
private static final int SUMMARY_TRIGGER_MULTIPLIER = 3;
private static final int SUMMARY_TRANSCRIPT_MAX_CHARS = 4000;
private final ChatModel chatModel;
private final ChatbotSessionRegistry sessionRegistry;
private final ChatbotCacheService cacheService;
private final ChatbotFeatureProperties featureProperties;
private final ChatbotRetrievalService retrievalService;
private final ChatbotContextCompressor contextCompressor;
private final ChatbotMemoryService memoryService;
private final ChatbotUsageService usageService;
private final ChatbotConversationStore conversationStore;
private final ObjectMapper objectMapper;
private final AtomicBoolean modelSwitchVerified = new AtomicBoolean(false);
public ChatbotResponse handleQuery(ChatbotQueryRequest request) {
ChatbotSettings settings = featureProperties.current();
if (!settings.enabled()) {
throw new ChatbotException("Chatbot feature is disabled");
}
if (!StringUtils.hasText(request.getPrompt())) {
throw new ChatbotException("Prompt cannot be empty");
}
if (request.getPrompt().length() > settings.maxPromptCharacters()) {
throw new ChatbotException("Prompt exceeds maximum allowed characters");
}
ChatbotSession session =
sessionRegistry
.findById(request.getSessionId())
.orElseThrow(() -> new ChatbotException("Unknown chatbot session"));
ensureModelSwitchCapability(settings);
ChatbotDocumentCacheEntry cacheEntry =
cacheService
.resolveBySessionId(request.getSessionId())
.orElseThrow(() -> new ChatbotException("Session cache not found"));
List<String> warnings = buildWarnings(settings, session);
List<Document> context =
retrievalService.retrieveTopK(
request.getSessionId(), request.getPrompt(), settings);
String contextSummary =
contextCompressor.summarize(
context, (int) Math.max(settings.maxPromptCharacters() / 2, 1000));
List<ChatbotHistoryEntry> conversationHistory =
loadConversationHistory(session.getSessionId());
String conversationSummary = loadConversationSummary(session.getSessionId());
ModelReply nanoReply =
invokeModel(
settings,
settings.models().primary(),
request.getPrompt(),
session,
context,
contextSummary,
cacheEntry.getMetadata(),
conversationHistory,
conversationSummary);
boolean shouldEscalate =
request.isAllowEscalation()
&& (nanoReply.requiresEscalation()
|| nanoReply.confidence() < settings.minConfidenceNano()
|| request.getPrompt().length() > settings.maxPromptCharacters());
ModelReply finalReply = nanoReply;
boolean escalated = false;
if (shouldEscalate) {
escalated = true;
finalReply =
invokeModel(
settings,
settings.models().fallback(),
request.getPrompt(),
session,
context,
contextSummary,
cacheEntry.getMetadata(),
conversationHistory,
conversationSummary);
}
ChatbotUsageSummary usageSummary =
usageService.registerGeneration(
session.getUserId(),
finalReply.promptTokens(),
finalReply.completionTokens());
session.setUsageSummary(usageSummary);
memoryService.recordTurn(session, request.getPrompt(), finalReply.answer());
recordHistoryTurn(session, "user", request.getPrompt());
recordHistoryTurn(session, "assistant", finalReply.answer());
summarizeConversation(settings, session);
enforceHistoryRetention(session);
return ChatbotResponse.builder()
.sessionId(request.getSessionId())
.modelUsed(
shouldEscalate ? settings.models().fallback() : settings.models().primary())
.confidence(finalReply.confidence())
.answer(finalReply.answer())
.escalated(escalated)
.servedFromNanoOnly(!escalated)
.cacheHit(true)
.respondedAt(Instant.now())
.warnings(warnings)
.metadata(buildMetadata(settings, session, finalReply, context.size(), escalated))
.promptTokens(finalReply.promptTokens())
.completionTokens(finalReply.completionTokens())
.totalTokens(finalReply.totalTokens())
.usageSummary(usageSummary)
.build();
}
private List<String> buildWarnings(ChatbotSettings settings, ChatbotSession session) {
List<String> warnings = new ArrayList<>();
warnings.add("Chatbot is in alpha – behaviour may change.");
if (session.isImageContentDetected()) {
warnings.add("Image content is not yet supported.");
}
if (session.isOcrRequested()) {
warnings.add("OCR costs may apply for this session.");
}
return warnings;
}
private Map<String, Object> buildMetadata(
ChatbotSettings settings,
ChatbotSession session,
ModelReply reply,
int contextSize,
boolean escalated) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("contextSize", contextSize);
metadata.put("requiresEscalation", reply.requiresEscalation());
metadata.put("escalated", escalated);
metadata.put("rationale", reply.rationale());
metadata.put("modelProvider", settings.models().provider().name());
metadata.put("imageContentDetected", session.isImageContentDetected());
metadata.put("charactersCached", session.getTextCharacters());
metadata.put("promptTokens", reply.promptTokens());
metadata.put("completionTokens", reply.completionTokens());
metadata.put("totalTokens", reply.totalTokens());
return metadata;
}
private void ensureModelSwitchCapability(ChatbotSettings settings) {
ChatbotSettings.ModelProvider provider = settings.models().provider();
switch (provider) {
case OPENAI -> {
if (!(chatModel instanceof OpenAiChatModel)) {
throw new ChatbotException(
"Chatbot requires an OpenAI chat model to support runtime model switching.");
}
}
case OLLAMA -> {
if (!(chatModel instanceof OllamaChatModel)) {
throw new ChatbotException(
"Chatbot is configured for Ollama but no Ollama chat model bean is available.");
}
}
}
if (modelSwitchVerified.compareAndSet(false, true)) {
log.info(
"Verified runtime model override support for provider {} ({} -> {})",
provider,
settings.models().primary(),
settings.models().fallback());
}
}
private ModelReply invokeModel(
ChatbotSettings settings,
String model,
String prompt,
ChatbotSession session,
List<Document> context,
String contextSummary,
Map<String, String> metadata,
List<ChatbotHistoryEntry> history,
String conversationSummary) {
Prompt requestPrompt =
buildPrompt(
settings,
model,
prompt,
session,
context,
contextSummary,
metadata,
history,
conversationSummary);
ChatResponse response;
try {
response = chatModel.call(requestPrompt);
} catch (org.eclipse.jetty.client.HttpResponseException ex) {
throw new ChatbotException(
"Chat model rejected the request: " + sanitizeRemoteMessage(ex.getMessage()),
ex);
} catch (RuntimeException ex) {
throw new ChatbotException(
"Failed to contact chat model provider: "
+ sanitizeRemoteMessage(ex.getMessage()),
ex);
}
long promptTokens = 0L;
long completionTokens = 0L;
long totalTokens = 0L;
if (response != null && response.getMetadata() != null) {
Usage usage = response.getMetadata().getUsage();
if (usage != null) {
promptTokens = toLong(usage.getPromptTokens());
completionTokens = toLong(usage.getCompletionTokens());
totalTokens =
usage.getTotalTokens() != null
? usage.getTotalTokens()
: promptTokens + completionTokens;
}
}
String content =
Optional.ofNullable(response)
.map(ChatResponse::getResults)
.filter(results -> !results.isEmpty())
.map(results -> results.get(0).getOutput().getText())
.orElse("");
return parseModelResponse(content, promptTokens, completionTokens, totalTokens);
}
private Prompt buildPrompt(
ChatbotSettings settings,
String model,
String question,
ChatbotSession session,
List<Document> context,
String contextSummary,
Map<String, String> metadata,
List<ChatbotHistoryEntry> history,
String conversationSummary) {
String chunkOutline = buildChunkOutline(context);
String chunkExcerpts = buildChunkExcerpts(context);
String metadataSummary =
metadata.entrySet().stream()
.map(entry -> entry.getKey() + ": " + entry.getValue())
.reduce((left, right) -> left + ", " + right)
.orElse("none");
String recentTurns = buildConversationOutline(history);
String imageDirective =
session.isImageContentDetected()
? "Images were detected in this PDF. You must explain that image analysis is not available."
: "No images detected in this PDF.";
String systemPrompt =
"You are Stirling PDF Bot. Use provided context strictly. "
+ "Respond in compact JSON with fields answer (string), confidence (0..1), requiresEscalation (boolean), rationale (string). "
+ "Explain limitations when context insufficient. Always note that image analysis is not supported yet.";
String userPrompt =
"Document metadata: "
+ metadataSummary
+ "\nOCR applied: "
+ session.isOcrRequested()
+ "\n"
+ imageDirective
+ "\nConversation summary:\n"
+ (StringUtils.hasText(conversationSummary)
? conversationSummary
: "No persistent summary available.")
+ "\nRecent conversation turns:\n"
+ recentTurns
+ "\nContext summary:\n"
+ contextSummary
+ "\nContext outline:\n"
+ chunkOutline
+ "\nSelected excerpts:\n"
+ chunkExcerpts
+ "Question: "
+ question;
OpenAiChatOptions options = buildChatOptions(settings, model);
return new Prompt(
List.of(new SystemMessage(systemPrompt), new UserMessage(userPrompt)), options);
}
private OpenAiChatOptions buildChatOptions(ChatbotSettings settings, String model) {
OpenAiChatOptions.Builder builder = OpenAiChatOptions.builder().model(model);
String normalizedModel = model == null ? "" : model.toLowerCase();
boolean reasoningModel = normalizedModel.startsWith("gpt-5-");
if (!reasoningModel) {
builder.topP(settings.models().topP());
}
return builder.build();
}
private String buildChunkOutline(List<Document> context) {
if (context == null || context.isEmpty()) {
return "No chunks retrieved for this question.";
}
StringBuilder outline = new StringBuilder();
for (Document chunk : context) {
String order = chunk.getMetadata().getOrDefault("chunkOrder", "?").toString();
String snippet = chunk.getText();
if (snippet != null) {
snippet = snippet.replaceAll("\\s+", " ").trim();
if (snippet.length() > 240) {
snippet = snippet.substring(0, 237) + "...";
}
} else {
snippet = "(empty)";
}
outline.append("- Chunk ").append(order).append(": ").append(snippet).append("\n");
}
return outline.toString();
}
private String buildChunkExcerpts(List<Document> context) {
if (context == null || context.isEmpty()) {
return "No excerpts available.";
}
StringBuilder excerpts = new StringBuilder();
for (Document chunk : context) {
String order = chunk.getMetadata().getOrDefault("chunkOrder", "?").toString();
String snippet = chunk.getText();
if (!StringUtils.hasText(snippet)) {
continue;
}
String normalized = snippet.replaceAll("\\s+", " ").trim();
int maxExcerpt = 400;
if (normalized.length() > maxExcerpt) {
normalized = normalized.substring(0, maxExcerpt - 3) + "...";
}
excerpts.append("[Chunk ").append(order).append("] ").append(normalized).append("\n");
}
if (!StringUtils.hasText(excerpts)) {
return "Chunks retrieved but no text excerpts available.";
}
return excerpts.toString();
}
private String buildConversationOutline(List<ChatbotHistoryEntry> history) {
if (history == null || history.isEmpty()) {
return "No earlier turns stored for this session.";
}
StringBuilder builder = new StringBuilder();
for (ChatbotHistoryEntry entry : history) {
if (entry == null || !StringUtils.hasText(entry.content())) {
continue;
}
builder.append(entry.role()).append(": ").append(entry.content().trim());
if (StringUtils.hasText(entry.documentName())) {
builder.append(" (doc: ").append(entry.documentName()).append(")");
}
builder.append("\n");
}
if (!StringUtils.hasText(builder)) {
return "Conversation history available but empty after filtering.";
}
return builder.toString();
}
private ModelReply parseModelResponse(
String raw, long promptTokens, long completionTokens, long totalTokens) {
if (!StringUtils.hasText(raw)) {
throw new ChatbotException("Model returned empty response");
}
try {
JsonNode node = objectMapper.readTree(raw);
String answer =
Optional.ofNullable(node.get("answer")).map(JsonNode::asText).orElse(raw);
double confidence =
Optional.ofNullable(node.get("confidence"))
.map(JsonNode::asDouble)
.orElse(0.0D);
boolean requiresEscalation =
Optional.ofNullable(node.get("requiresEscalation"))
.map(JsonNode::asBoolean)
.orElse(false);
String rationale =
Optional.ofNullable(node.get("rationale"))
.map(JsonNode::asText)
.orElse("Model did not provide rationale");
return new ModelReply(
answer,
confidence,
requiresEscalation,
rationale,
promptTokens,
completionTokens,
totalTokens);
} catch (IOException ex) {
log.warn("Failed to parse model JSON response, returning raw text", ex);
return new ModelReply(
raw,
0.0D,
true,
"Unable to parse JSON response",
promptTokens,
completionTokens,
totalTokens);
}
}
private record ModelReply(
String answer,
double confidence,
boolean requiresEscalation,
String rationale,
long promptTokens,
long completionTokens,
long totalTokens) {}
private String sanitizeRemoteMessage(String message) {
if (!StringUtils.hasText(message)) {
return "unexpected provider error";
}
return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***");
}
private long toLong(Integer value) {
return value == null ? 0L : value.longValue();
}
private List<ChatbotHistoryEntry> loadConversationHistory(String sessionId) {
if (conversationStore == null || !StringUtils.hasText(sessionId)) {
return List.of();
}
try {
return conversationStore.getRecentTurns(sessionId, conversationStore.defaultWindow());
} catch (RuntimeException ex) {
log.debug("Conversation history unavailable: {}", ex.getMessage());
return List.of();
}
}
private String loadConversationSummary(String sessionId) {
if (conversationStore == null || !StringUtils.hasText(sessionId)) {
return "";
}
try {
return conversationStore.loadSummary(sessionId);
} catch (RuntimeException ex) {
log.debug("Conversation summary unavailable: {}", ex.getMessage());
return "";
}
}
private void recordHistoryTurn(ChatbotSession session, String role, String content) {
if (conversationStore == null
|| session == null
|| !StringUtils.hasText(session.getSessionId())
|| !StringUtils.hasText(content)) {
return;
}
String documentName =
Optional.ofNullable(session.getMetadata())
.map(meta -> meta.getOrDefault("documentName", ""))
.orElse("");
ChatbotHistoryEntry entry =
conversationStore.createEntry(role, content, session.getDocumentId(), documentName);
try {
conversationStore.appendTurn(session.getSessionId(), entry);
} catch (RuntimeException ex) {
log.debug("Failed to persist chatbot conversation turn: {}", ex.getMessage());
}
}
private void summarizeConversation(ChatbotSettings settings, ChatbotSession session) {
if (conversationStore == null
|| session == null
|| !StringUtils.hasText(session.getSessionId())) {
return;
}
int window = conversationStore.defaultWindow();
long historySize = conversationStore.historyLength(session.getSessionId());
if (historySize < Math.max(window * SUMMARY_TRIGGER_MULTIPLIER, window + 1)) {
return;
}
List<ChatbotHistoryEntry> entries =
conversationStore.getRecentTurns(
session.getSessionId(), conversationStore.retentionWindow());
if (entries.isEmpty() || entries.size() <= window) {
return;
}
int cutoff = entries.size() - window;
List<ChatbotHistoryEntry> summarizable = entries.subList(0, cutoff);
String existingSummary = loadConversationSummary(session.getSessionId());
String updatedSummary = summarizeHistory(settings, session, summarizable, existingSummary);
if (StringUtils.hasText(updatedSummary)) {
try {
conversationStore.storeSummary(session.getSessionId(), updatedSummary);
conversationStore.trimHistory(session.getSessionId(), window);
} catch (RuntimeException ex) {
log.debug("Failed to persist chatbot summary: {}", ex.getMessage());
}
}
}
private String summarizeHistory(
ChatbotSettings settings,
ChatbotSession session,
List<ChatbotHistoryEntry> entries,
String existingSummary) {
if (entries == null || entries.isEmpty()) {
return existingSummary;
}
String priorSummary =
StringUtils.hasText(existingSummary)
? existingSummary
: "No previous summary available.";
String transcript = buildSummaryTranscript(entries);
if (!StringUtils.hasText(transcript)) {
return existingSummary;
}
String systemPrompt =
"You maintain a concise running summary of Stirling PDF Bot conversations. "
+ "Capture user goals, referenced documents, and key conclusions in under 200 words.";
String userPrompt =
"Existing summary:\n"
+ priorSummary
+ "\n\nNew conversation turns:\n"
+ transcript
+ "\n\nRespond with the updated summary only.";
Prompt prompt =
new Prompt(
List.of(new SystemMessage(systemPrompt), new UserMessage(userPrompt)),
buildChatOptions(settings, settings.models().primary()));
try {
ChatResponse response = chatModel.call(prompt);
return Optional.ofNullable(response)
.map(ChatResponse::getResults)
.filter(results -> !results.isEmpty())
.map(results -> results.get(0).getOutput().getText())
.map(String::trim)
.filter(StringUtils::hasText)
.orElse(existingSummary);
} catch (RuntimeException ex) {
log.debug("Conversation summarisation failed: {}", ex.getMessage());
return existingSummary;
}
}
private String buildSummaryTranscript(List<ChatbotHistoryEntry> entries) {
StringBuilder builder = new StringBuilder();
for (ChatbotHistoryEntry entry : entries) {
if (entry == null || !StringUtils.hasText(entry.content())) {
continue;
}
if (builder.length() >= SUMMARY_TRANSCRIPT_MAX_CHARS) {
builder.append("\n[conversation truncated]");
break;
}
builder.append(entry.role()).append(": ").append(entry.content().trim());
if (StringUtils.hasText(entry.documentName())) {
builder.append(" (doc: ").append(entry.documentName()).append(")");
}
builder.append("\n");
}
return builder.toString();
}
private void enforceHistoryRetention(ChatbotSession session) {
if (conversationStore == null
|| session == null
|| !StringUtils.hasText(session.getSessionId())) {
return;
}
try {
conversationStore.trimHistory(
session.getSessionId(), conversationStore.retentionWindow());
} catch (RuntimeException ex) {
log.debug("Failed to enforce chatbot history retention: {}", ex.getMessage());
}
}
}
@@ -0,0 +1,187 @@
package stirling.software.proprietary.service.chatbot;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Supplier;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.chatbot.ChatbotHistoryEntry;
import redis.clients.jedis.JedisPooled;
/**
* Lightweight Redis-backed conversation store that keeps a short rolling window and summary for
* each chatbot session. This lays the groundwork for richer memory handling without yet impacting
* the main conversation flow.
*/
@Component
@Slf4j
public class ChatbotConversationStore {
private static final String HISTORY_KEY = "chatbot:sessions:%s:history";
private static final String SUMMARY_KEY = "chatbot:sessions:%s:summary";
private static final Duration DEFAULT_TTL = Duration.ofHours(24);
private static final int DEFAULT_WINDOW = 10;
private static final int RETENTION_MULTIPLIER = 5;
private static final int RETENTION_WINDOW = DEFAULT_WINDOW * RETENTION_MULTIPLIER;
private final JedisPooled jedis;
private final ObjectMapper objectMapper;
public ChatbotConversationStore(
ObjectProvider<JedisPooled> jedisProvider, ObjectMapper objectMapper) {
this.jedis = jedisProvider.getIfAvailable();
this.objectMapper = objectMapper;
}
public void appendTurn(String sessionId, ChatbotHistoryEntry entry) {
if (!redisReady() || !StringUtils.hasText(sessionId) || entry == null) {
return;
}
execute(
() -> {
try {
String payload = objectMapper.writeValueAsString(entry);
String key = historyKey(sessionId);
jedis.rpush(key, payload);
jedis.expire(key, (int) DEFAULT_TTL.getSeconds());
jedis.expire(summaryKey(sessionId), (int) DEFAULT_TTL.getSeconds());
} catch (JsonProcessingException ex) {
log.debug("Failed to serialise chatbot turn", ex);
}
});
}
public List<ChatbotHistoryEntry> getRecentTurns(String sessionId, int limit) {
if (!redisReady() || !StringUtils.hasText(sessionId)) {
return Collections.emptyList();
}
return execute(
() -> {
String key = historyKey(sessionId);
long size = jedis.llen(key);
if (size <= 0) {
return Collections.emptyList();
}
long start = Math.max(0, size - Math.max(limit, 1));
List<String> raw = jedis.lrange(key, start, size);
if (CollectionUtils.isEmpty(raw)) {
return Collections.emptyList();
}
List<ChatbotHistoryEntry> entries = new ArrayList<>(raw.size());
for (String chunk : raw) {
try {
entries.add(objectMapper.readValue(chunk, ChatbotHistoryEntry.class));
} catch (JsonProcessingException ex) {
log.debug("Ignoring malformed chatbot history payload", ex);
}
}
return entries;
},
Collections.emptyList());
}
public void trimHistory(String sessionId, int retainEntries) {
if (!redisReady() || !StringUtils.hasText(sessionId) || retainEntries <= 0) {
return;
}
execute(
() -> {
String key = historyKey(sessionId);
jedis.ltrim(key, -retainEntries, -1);
});
}
public void storeSummary(String sessionId, String summary) {
if (!redisReady() || !StringUtils.hasText(sessionId)) {
return;
}
execute(() -> jedis.setex(summaryKey(sessionId), (int) DEFAULT_TTL.getSeconds(), summary));
}
public String loadSummary(String sessionId) {
if (!redisReady() || !StringUtils.hasText(sessionId)) {
return "";
}
return execute(() -> jedis.get(summaryKey(sessionId)), "");
}
public void clear(String sessionId) {
if (!redisReady() || !StringUtils.hasText(sessionId)) {
return;
}
execute(
() -> {
jedis.del(historyKey(sessionId));
jedis.del(summaryKey(sessionId));
});
}
public int defaultWindow() {
return DEFAULT_WINDOW;
}
public int retentionWindow() {
return RETENTION_WINDOW;
}
public long historyLength(String sessionId) {
if (!redisReady() || !StringUtils.hasText(sessionId)) {
return 0L;
}
return execute(() -> jedis.llen(historyKey(sessionId)), 0L);
}
private boolean redisReady() {
return jedis != null;
}
private String historyKey(String sessionId) {
return HISTORY_KEY.formatted(sessionId);
}
private String summaryKey(String sessionId) {
return SUMMARY_KEY.formatted(sessionId);
}
private void execute(Runnable action) {
if (!redisReady()) {
return;
}
try {
action.run();
} catch (RuntimeException ex) {
log.warn("Redis conversation store unavailable: {}", ex.getMessage());
}
}
private <T> T execute(Supplier<T> supplier, T fallback) {
if (!redisReady()) {
return fallback;
}
try {
return supplier.get();
} catch (RuntimeException ex) {
log.warn("Redis conversation store unavailable: {}", ex.getMessage());
return fallback;
}
}
/** Convenience factory to create entries for manual tests. */
public ChatbotHistoryEntry createEntry(
String role, String content, String documentId, String documentName) {
return new ChatbotHistoryEntry(role, content, documentId, documentName, Instant.now());
}
}
@@ -0,0 +1,109 @@
package stirling.software.proprietary.service.chatbot;
import java.util.Optional;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.ApplicationProperties.Premium;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures;
import stirling.software.common.model.ApplicationProperties.Premium.ProFeatures.Chatbot;
@Component
public class ChatbotFeatureProperties {
private final ApplicationProperties applicationProperties;
public ChatbotFeatureProperties(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
public ChatbotSettings current() {
Chatbot chatbot = resolveChatbot();
ChatbotSettings.ModelSettings modelSettings =
new ChatbotSettings.ModelSettings(
resolveProvider(chatbot.getModels().getProvider()),
chatbot.getModels().getPrimary(),
chatbot.getModels().getFallback(),
chatbot.getModels().getEmbedding(),
chatbot.getModels().getTopP());
return new ChatbotSettings(
chatbot.isEnabled(),
chatbot.isAlphaWarning(),
chatbot.getMaxPromptCharacters(),
chatbot.getMinConfidenceNano(),
modelSettings,
new ChatbotSettings.RagSettings(
chatbot.getRag().getChunkSizeTokens(),
chatbot.getRag().getChunkOverlapTokens(),
chatbot.getRag().getTopK()),
new ChatbotSettings.CacheSettings(
chatbot.getCache().getTtlMinutes(),
chatbot.getCache().getMaxEntries(),
chatbot.getCache().getMaxDocumentCharacters()),
new ChatbotSettings.OcrSettings(chatbot.getOcr().isEnabledByDefault()),
new ChatbotSettings.AuditSettings(chatbot.getAudit().isEnabled()),
new ChatbotSettings.UsageSettings(
chatbot.getUsage().getPerUserMonthlyTokens(),
chatbot.getUsage().getWarnAtRatio()));
}
public boolean isEnabled() {
return current().enabled();
}
private Chatbot resolveChatbot() {
return Optional.ofNullable(applicationProperties)
.map(ApplicationProperties::getPremium)
.map(Premium::getProFeatures)
.map(ProFeatures::getChatbot)
.orElseGet(Chatbot::new);
}
private ChatbotSettings.ModelProvider resolveProvider(String configuredProvider) {
if (!StringUtils.hasText(configuredProvider)) {
return ChatbotSettings.ModelProvider.OPENAI;
}
try {
return ChatbotSettings.ModelProvider.valueOf(configuredProvider.trim().toUpperCase());
} catch (IllegalArgumentException ignored) {
return ChatbotSettings.ModelProvider.OPENAI;
}
}
public record ChatbotSettings(
boolean enabled,
boolean alphaWarning,
long maxPromptCharacters,
double minConfidenceNano,
ModelSettings models,
RagSettings rag,
CacheSettings cache,
OcrSettings ocr,
AuditSettings audit,
UsageSettings usage) {
public record ModelSettings(
ModelProvider provider,
String primary,
String fallback,
String embedding,
double topP) {}
public record RagSettings(int chunkSizeTokens, int chunkOverlapTokens, int topK) {}
public record CacheSettings(long ttlMinutes, long maxEntries, long maxDocumentCharacters) {}
public record OcrSettings(boolean enabledByDefault) {}
public record AuditSettings(boolean enabled) {}
public record UsageSettings(long perUserMonthlyTokens, double warnAtRatio) {}
public enum ModelProvider {
OPENAI,
OLLAMA
}
}
}
@@ -0,0 +1,169 @@
package stirling.software.proprietary.service.chatbot;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
import stirling.software.proprietary.service.chatbot.exception.NoTextDetectedException;
@Service
@Slf4j
@RequiredArgsConstructor
public class ChatbotIngestionService {
private final ChatbotCacheService cacheService;
private final ChatbotSessionRegistry sessionRegistry;
private final ChatbotFeatureProperties featureProperties;
private final VectorStore vectorStore;
private final ChatbotUsageService usageService;
public ChatbotSession ingest(ChatbotSessionCreateRequest request) {
ChatbotSettings settings = featureProperties.current();
if (!settings.enabled()) {
throw new ChatbotException("Chatbot feature is disabled");
}
if (!request.isWarningsAccepted() && settings.alphaWarning()) {
throw new ChatbotException("Alpha warning must be accepted before use");
}
if (!StringUtils.hasText(request.getText())) {
throw new NoTextDetectedException(
"No text detected in document payload. Images are currently unsupported – enable OCR to continue.");
}
long characterLimit = cacheService.getMaxDocumentCharacters();
long textCharacters = request.getText().length();
if (textCharacters > characterLimit) {
throw new ChatbotException(
"Document text exceeds maximum allowed characters: " + characterLimit);
}
String sessionId =
StringUtils.hasText(request.getSessionId())
? request.getSessionId()
: ChatbotSession.randomSessionId();
boolean imagesDetected = request.isImagesDetected();
boolean ocrApplied = request.isOcrRequested();
Map<String, String> metadata = new HashMap<>();
if (request.getMetadata() != null) {
metadata.putAll(request.getMetadata());
}
metadata.put("content.imagesDetected", Boolean.toString(imagesDetected));
metadata.put("content.characterCount", String.valueOf(textCharacters));
metadata.put(
"content.extractionSource", ocrApplied ? "ocr-text-layer" : "embedded-text-layer");
Map<String, String> immutableMetadata = Map.copyOf(metadata);
List<Document> documents =
buildDocuments(
sessionId, request.getDocumentId(), request.getText(), metadata, settings);
try {
vectorStore.add(documents);
} catch (RuntimeException ex) {
throw new ChatbotException(
"Failed to index document content in vector store: "
+ sanitizeRemoteMessage(ex.getMessage()),
ex);
}
String cacheKey =
cacheService.register(
sessionId,
request.getDocumentId(),
immutableMetadata,
ocrApplied,
imagesDetected,
textCharacters);
long estimatedTokens = Math.max(1L, Math.round(textCharacters / 4.0));
ChatbotSession session =
ChatbotSession.builder()
.sessionId(sessionId)
.documentId(request.getDocumentId())
.userId(request.getUserId())
.metadata(immutableMetadata)
.ocrRequested(ocrApplied)
.imageContentDetected(imagesDetected)
.textCharacters(textCharacters)
.estimatedTokens(estimatedTokens)
.warningsAccepted(request.isWarningsAccepted())
.alphaWarningRequired(settings.alphaWarning())
.cacheKey(cacheKey)
.createdAt(Instant.now())
.build();
session.setUsageSummary(
usageService.registerIngestion(session.getUserId(), estimatedTokens));
sessionRegistry.register(session);
log.info(
"Registered chatbot session {} for document {} with {} RAG chunks",
sessionId,
request.getDocumentId(),
documents.size());
return session;
}
private List<Document> buildDocuments(
String sessionId,
String documentId,
String text,
Map<String, String> metadata,
ChatbotSettings settings) {
List<Document> documents = new ArrayList<>();
if (!StringUtils.hasText(text)) {
return documents;
}
int chunkChars = Math.max(512, settings.rag().chunkSizeTokens() * 4);
int overlapChars = Math.max(64, settings.rag().chunkOverlapTokens() * 4);
int index = 0;
int order = 0;
while (index < text.length()) {
int end = Math.min(text.length(), index + chunkChars);
String chunk = text.substring(index, end).trim();
if (!chunk.isEmpty()) {
Document document = new Document(chunk);
document.getMetadata().putAll(metadata);
document.getMetadata().put("sessionId", sessionId);
document.getMetadata().put("documentId", documentId);
document.getMetadata().put("chunkOrder", Integer.toString(order));
documents.add(document);
order++;
}
if (end == text.length()) {
break;
}
int nextIndex = end - overlapChars;
if (nextIndex <= index) {
nextIndex = end;
}
index = nextIndex;
}
if (documents.isEmpty()) {
throw new ChatbotException("Unable to split document text into searchable chunks");
}
return documents;
}
private String sanitizeRemoteMessage(String message) {
if (!StringUtils.hasText(message)) {
return "unexpected provider error";
}
return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***");
}
}
@@ -0,0 +1,52 @@
package stirling.software.proprietary.service.chatbot;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
@Slf4j
@Service
@RequiredArgsConstructor
public class ChatbotMemoryService {
private final VectorStore vectorStore;
public void recordTurn(ChatbotSession session, String prompt, String answer) {
if (session == null) {
return;
}
if (!StringUtils.hasText(prompt) && !StringUtils.hasText(answer)) {
return;
}
Map<String, Object> metadata = new HashMap<>();
metadata.put("sessionId", session.getSessionId());
metadata.put("documentId", session.getDocumentId());
metadata.put("turnType", "conversation");
metadata.put("turnTimestamp", Instant.now().toString());
metadata.put("userId", session.getUserId());
StringBuilder contentBuilder = new StringBuilder();
if (StringUtils.hasText(prompt)) {
contentBuilder.append("User: ").append(prompt.trim()).append("\n");
}
if (StringUtils.hasText(answer)) {
contentBuilder.append("Assistant: ").append(answer.trim());
}
try {
vectorStore.add(List.of(new Document(contentBuilder.toString(), metadata)));
} catch (RuntimeException ex) {
log.warn("Failed to persist chatbot conversation turn: {}", ex.getMessage());
}
}
}
@@ -0,0 +1,94 @@
package stirling.software.proprietary.service.chatbot;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import org.springframework.ai.document.Document;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
@Service
@RequiredArgsConstructor
@Slf4j
public class ChatbotRetrievalService {
private final ChatbotCacheService cacheService;
private final VectorStore vectorStore;
private final Cache<String, List<Document>> retrievalCache =
Caffeine.newBuilder().maximumSize(200).expireAfterWrite(30, TimeUnit.SECONDS).build();
public List<Document> retrieveTopK(String sessionId, String query, ChatbotSettings settings) {
cacheService
.resolveBySessionId(sessionId)
.orElseThrow(() -> new ChatbotException("Unknown chatbot session"));
int topK = Math.max(settings.rag().topK(), 1);
String sanitizedQuery = StringUtils.hasText(query) ? query : "";
String filterExpression = "metadata.sessionId == '" + escape(sessionId) + "'";
String cacheKey = cacheKey(sessionId, sanitizedQuery, topK);
List<Document> cached = retrievalCache.getIfPresent(cacheKey);
if (cached != null) {
return cached;
}
SearchRequest searchRequest =
SearchRequest.builder()
.query(sanitizedQuery)
.topK(topK)
.filterExpression(filterExpression)
.similarityThreshold(0.7f)
.build();
List<Document> results;
try {
results = vectorStore.similaritySearch(searchRequest);
} catch (RuntimeException ex) {
throw new ChatbotException(
"Failed to perform vector similarity search: "
+ sanitizeRemoteMessage(ex.getMessage()),
ex);
}
results =
results.stream()
.filter(
doc ->
sessionId.equals(
doc.getMetadata().getOrDefault("sessionId", "")))
.limit(topK)
.toList();
if (results.isEmpty()) {
log.warn("No context available for chatbot session {}", sessionId);
}
List<Document> immutableResults = List.copyOf(results);
retrievalCache.put(cacheKey, immutableResults);
return immutableResults;
}
private String sanitizeRemoteMessage(String message) {
if (!StringUtils.hasText(message)) {
return "unexpected provider error";
}
return message.replaceAll("(?i)api[-_ ]?key\\s*=[^\\s]+", "api-key=***");
}
private String escape(String value) {
return value.replace("'", "\\'");
}
private String cacheKey(String sessionId, String query, int topK) {
return sessionId + "::" + Objects.hash(query, topK);
}
}
@@ -0,0 +1,80 @@
package stirling.software.proprietary.service.chatbot;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest;
import stirling.software.proprietary.model.chatbot.ChatbotResponse;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.AuditService;
import stirling.software.proprietary.service.chatbot.exception.ChatbotException;
@Service
@Slf4j
@RequiredArgsConstructor
public class ChatbotService {
private final ChatbotIngestionService ingestionService;
private final ChatbotConversationService conversationService;
private final ChatbotSessionRegistry sessionRegistry;
private final ChatbotCacheService cacheService;
private final ChatbotFeatureProperties featureProperties;
private final AuditService auditService;
private final UserService userService;
public ChatbotSession createSession(ChatbotSessionCreateRequest request) {
if (!StringUtils.hasText(request.getUserId())) {
request.setUserId(userService.getCurrentUsername());
}
ChatbotSession session = ingestionService.ingest(request);
log.debug("Chatbot session {} initialised", session.getSessionId());
audit(
"CHATBOT_SESSION_CREATED",
session.getSessionId(),
Map.of(
"documentId", session.getDocumentId(),
"ocrRequested", session.isOcrRequested(),
"imagesDetected", session.isImageContentDetected(),
"textCharacters", session.getTextCharacters()));
return session;
}
public ChatbotResponse ask(ChatbotQueryRequest request) {
ChatbotResponse response = conversationService.handleQuery(request);
audit(
"CHATBOT_QUERY",
request.getSessionId(),
Map.of(
"modelUsed", response.getModelUsed(),
"escalated", response.isEscalated(),
"confidence", response.getConfidence()));
return response;
}
public void close(String sessionId) {
sessionRegistry
.findById(sessionId)
.orElseThrow(() -> new ChatbotException("Session not found for closure"));
sessionRegistry.remove(sessionId);
cacheService.invalidateSession(sessionId);
audit("CHATBOT_SESSION_CLOSED", sessionId, Map.of());
log.debug("Chatbot session {} closed", sessionId);
}
private void audit(String action, String sessionId, Map<String, Object> data) {
if (!featureProperties.current().audit().enabled()) {
return;
}
Map<String, Object> payload = new HashMap<>(data == null ? Map.of() : data);
payload.put("sessionId", sessionId);
auditService.audit(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS, payload);
}
}
@@ -0,0 +1,41 @@
package stirling.software.proprietary.service.chatbot;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.stereotype.Component;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
@Component
public class ChatbotSessionRegistry {
private final Map<String, ChatbotSession> sessionStore = new ConcurrentHashMap<>();
private final Map<String, String> documentToSession = new ConcurrentHashMap<>();
public void register(ChatbotSession session) {
sessionStore.put(session.getSessionId(), session);
if (session.getDocumentId() != null) {
documentToSession.put(session.getDocumentId(), session.getSessionId());
}
}
public Optional<ChatbotSession> findById(String sessionId) {
return Optional.ofNullable(sessionStore.get(sessionId));
}
public void remove(String sessionId) {
Optional.ofNullable(sessionStore.remove(sessionId))
.map(ChatbotSession::getDocumentId)
.ifPresent(documentToSession::remove);
}
public Optional<ChatbotSession> findByDocumentId(String documentId) {
return Optional.ofNullable(documentToSession.get(documentId)).flatMap(this::findById);
}
public void removeByDocumentId(String documentId) {
Optional.ofNullable(documentToSession.remove(documentId)).ifPresent(sessionStore::remove);
}
}
@@ -0,0 +1,106 @@
package stirling.software.proprietary.service.chatbot;
import java.time.YearMonth;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.model.chatbot.ChatbotUsageSummary;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
@Service
@RequiredArgsConstructor
@Slf4j
public class ChatbotUsageService {
private final ChatbotFeatureProperties featureProperties;
private final Map<String, UsageWindow> usageByUser = new ConcurrentHashMap<>();
public ChatbotUsageSummary registerIngestion(String userId, long estimatedTokens) {
return incrementUsage(userId, Math.max(estimatedTokens, 0L));
}
public ChatbotUsageSummary registerGeneration(
String userId, long promptTokens, long completionTokens) {
long total = Math.max(promptTokens + completionTokens, 0L);
return incrementUsage(userId, total);
}
public ChatbotUsageSummary currentUsage(String userId) {
String key = normalizeUserId(userId);
UsageWindow window = usageByUser.get(key);
if (window == null) {
return buildSummary(key, 0L, 0L);
}
return buildSummary(key, window.tokens.get(), 0L);
}
private ChatbotUsageSummary incrementUsage(String userId, long deltaTokens) {
String key = normalizeUserId(userId);
YearMonth now = YearMonth.now(ZoneOffset.UTC);
UsageWindow window =
usageByUser.compute(
key,
(ignored, existing) -> {
if (existing == null || !existing.window.equals(now)) {
existing = new UsageWindow(now);
}
if (deltaTokens > 0) {
existing.tokens.addAndGet(deltaTokens);
}
return existing;
});
return buildSummary(key, window.tokens.get(), deltaTokens);
}
private ChatbotUsageSummary buildSummary(String userKey, long consumed, long deltaTokens) {
ChatbotSettings settings = featureProperties.current();
long allocation = Math.max(settings.usage().perUserMonthlyTokens(), 1L);
double ratio = allocation == 0 ? 1.0 : (double) consumed / allocation;
long remaining = Math.max(allocation - consumed, 0L);
boolean limitExceeded = consumed > allocation;
boolean nearingLimit = ratio >= settings.usage().warnAtRatio();
return ChatbotUsageSummary.builder()
.allocatedTokens(allocation)
.consumedTokens(consumed)
.remainingTokens(remaining)
.usageRatio(Math.min(ratio, 1.0))
.nearingLimit(nearingLimit)
.limitExceeded(limitExceeded)
.lastIncrementTokens(deltaTokens)
.window(currentWindowDescription(userKey))
.build();
}
private String currentWindowDescription(String userKey) {
UsageWindow window = usageByUser.get(userKey);
if (window == null) {
return YearMonth.now(ZoneOffset.UTC).toString();
}
return window.window.toString();
}
private String normalizeUserId(String userId) {
if (!StringUtils.hasText(userId)) {
return "anonymous";
}
return userId.trim().toLowerCase();
}
private static final class UsageWindow {
private final YearMonth window;
private final AtomicLong tokens = new AtomicLong();
private UsageWindow(YearMonth window) {
this.window = window;
}
}
}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.service.chatbot.exception;
public class ChatbotException extends RuntimeException {
public ChatbotException(String message) {
super(message);
}
public ChatbotException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,8 @@
package stirling.software.proprietary.service.chatbot.exception;
public class NoTextDetectedException extends ChatbotException {
public NoTextDetectedException(String message) {
super(message);
}
}
@@ -0,0 +1,33 @@
# Spring AI OpenAI Configuration
# Uses GPT-5-nano as primary model and GPT-5-mini as fallback (configured in settings.yml)
spring.ai.openai.enabled=true
#spring.ai.openai.api-key=# todo <API-KEY-HERE>
spring.ai.openai.base-url=https://api.openai.com
spring.ai.openai.chat.enabled=true
spring.ai.openai.chat.options.model=gpt-5-nano
# Note: Some models only support default temperature value of 1.0
spring.ai.openai.chat.options.temperature=1.0
# For newer models, use max-completion-tokens instead of max-tokens
spring.ai.openai.chat.options.max-completion-tokens=4000
spring.ai.openai.embedding.enabled=true
spring.ai.openai.embedding.options.model=text-embedding-ada-002
# Increase timeout for OpenAI API calls (default is 10 seconds)
spring.ai.openai.chat.options.connection-timeout=60s
spring.ai.openai.chat.options.read-timeout=60s
spring.ai.openai.embedding.options.connection-timeout=60s
spring.ai.openai.embedding.options.read-timeout=60s
# Spring AI Ollama Configuration (disabled to avoid bean conflicts)
spring.ai.ollama.enabled=false
spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.enabled=false
spring.ai.ollama.chat.options.model=llama3
spring.ai.ollama.chat.options.temperature=1.0
spring.ai.ollama.embedding.enabled=false
spring.ai.ollama.embedding.options.model=nomic-embed-text
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.data.redis.password=
spring.data.redis.timeout=60000
spring.data.redis.ssl.enabled=false
@@ -0,0 +1,53 @@
package stirling.software.proprietary.service.chatbot;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.model.chatbot.ChatbotDocumentCacheEntry;
class ChatbotCacheServiceTest {
private ApplicationProperties properties;
@BeforeEach
void setup() {
properties = new ApplicationProperties();
ApplicationProperties.Premium premium = new ApplicationProperties.Premium();
ApplicationProperties.Premium.ProFeatures pro =
new ApplicationProperties.Premium.ProFeatures();
ApplicationProperties.Premium.ProFeatures.Chatbot chatbot =
new ApplicationProperties.Premium.ProFeatures.Chatbot();
chatbot.setEnabled(true);
chatbot.getCache().setMaxDocumentCharacters(50);
chatbot.getCache().setMaxEntries(10);
chatbot.getCache().setTtlMinutes(60);
pro.setChatbot(chatbot);
premium.setProFeatures(pro);
properties.setPremium(premium);
}
@Test
void registerAndResolveSession() {
ChatbotCacheService cacheService = new ChatbotCacheService(properties);
String cacheKey =
cacheService.register(
"session1",
"doc1",
Map.of("title", "Sample"),
false,
false,
"hello world".length());
assertTrue(cacheService.resolveBySessionId("session1").isPresent());
ChatbotDocumentCacheEntry entry = cacheService.resolveByCacheKey(cacheKey).orElseThrow();
assertEquals("doc1", entry.getDocumentId());
assertEquals("Sample", entry.getMetadata().get("title"));
assertEquals("hello world".length(), entry.getTextCharacters());
assertTrue(!entry.isImageContentDetected());
}
}
@@ -0,0 +1,142 @@
package stirling.software.proprietary.service.chatbot;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.test.util.ReflectionTestUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.proprietary.model.chatbot.ChatbotHistoryEntry;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
@ExtendWith(MockitoExtension.class)
class ChatbotConversationServiceTest {
@Mock private ChatModel chatModel;
@Mock private ChatbotSessionRegistry sessionRegistry;
@Mock private ChatbotCacheService cacheService;
@Mock private ChatbotFeatureProperties featureProperties;
@Mock private ChatbotRetrievalService retrievalService;
@Mock private ChatbotContextCompressor contextCompressor;
@Mock private ChatbotMemoryService memoryService;
@Mock private ChatbotUsageService usageService;
@Mock private ChatbotConversationStore conversationStore;
private ChatbotConversationService conversationService;
private ChatbotSettings defaultSettings;
@BeforeEach
void setUp() {
conversationService =
new ChatbotConversationService(
chatModel,
sessionRegistry,
cacheService,
featureProperties,
retrievalService,
contextCompressor,
memoryService,
usageService,
conversationStore,
new ObjectMapper());
defaultSettings =
new ChatbotSettings(
true,
true,
4000,
0.65D,
new ChatbotSettings.ModelSettings(
ChatbotSettings.ModelProvider.OPENAI,
"gpt-5-nano",
"gpt-5-mini",
"embed",
0.95D),
new ChatbotSettings.RagSettings(512, 128, 4),
new ChatbotSettings.CacheSettings(60, 10, 1000),
new ChatbotSettings.OcrSettings(false),
new ChatbotSettings.AuditSettings(false),
new ChatbotSettings.UsageSettings(100_000L, 0.7D));
}
@Test
void summarizesAndTrimsHistoryWhenThresholdReached() {
ChatbotSession session =
ChatbotSession.builder()
.sessionId("session-1")
.documentId("doc-123")
.metadata(Map.of("documentName", "Quarterly Report"))
.build();
when(conversationStore.defaultWindow()).thenReturn(2);
when(conversationStore.retentionWindow()).thenReturn(10);
when(conversationStore.historyLength("session-1")).thenReturn(6L);
when(conversationStore.getRecentTurns("session-1", 10))
.thenReturn(historyEntries(6, "doc-123", "Quarterly Report"));
when(conversationStore.loadSummary("session-1")).thenReturn("previous summary");
when(chatModel.call(any(Prompt.class)))
.thenReturn(
new ChatResponse(
List.of(new Generation(new AssistantMessage("updated summary")))));
ReflectionTestUtils.invokeMethod(
conversationService, "summarizeConversation", defaultSettings, session);
verify(chatModel, times(1)).call(any(Prompt.class));
verify(conversationStore).storeSummary("session-1", "updated summary");
verify(conversationStore).trimHistory("session-1", 2);
}
@Test
void skipsSummarizationWhenHistoryBelowThreshold() {
ChatbotSession session =
ChatbotSession.builder().sessionId("session-2").documentId("doc").build();
when(conversationStore.defaultWindow()).thenReturn(4);
when(conversationStore.historyLength("session-2")).thenReturn(5L);
ReflectionTestUtils.invokeMethod(
conversationService, "summarizeConversation", defaultSettings, session);
verify(chatModel, never()).call(any(org.springframework.ai.chat.prompt.Prompt.class));
verify(conversationStore, never()).storeSummary(anyString(), anyString());
verify(conversationStore, never()).trimHistory(anyString(), anyInt());
}
private List<ChatbotHistoryEntry> historyEntries(
int count, String documentId, String documentName) {
List<ChatbotHistoryEntry> entries = new ArrayList<>();
for (int i = 0; i < count; i++) {
entries.add(
new ChatbotHistoryEntry(
i % 2 == 0 ? "user" : "assistant",
"message-" + i,
documentId,
documentName,
Instant.now().minusSeconds(60L - i)));
}
return entries;
}
}
@@ -0,0 +1,146 @@
package stirling.software.proprietary.service.chatbot;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.eq;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.time.Instant;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import stirling.software.proprietary.model.chatbot.ChatbotQueryRequest;
import stirling.software.proprietary.model.chatbot.ChatbotResponse;
import stirling.software.proprietary.model.chatbot.ChatbotSession;
import stirling.software.proprietary.model.chatbot.ChatbotSessionCreateRequest;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.service.AuditService;
import stirling.software.proprietary.service.chatbot.ChatbotFeatureProperties.ChatbotSettings;
@ExtendWith(MockitoExtension.class)
class ChatbotServiceTest {
@Mock private ChatbotIngestionService ingestionService;
@Mock private ChatbotConversationService conversationService;
@Mock private ChatbotSessionRegistry sessionRegistry;
@Mock private ChatbotCacheService cacheService;
@Mock private ChatbotFeatureProperties featureProperties;
@Mock private AuditService auditService;
@Mock private UserService userService;
@InjectMocks private ChatbotService chatbotService;
private ChatbotSettings auditEnabledSettings;
private ChatbotSettings auditDisabledSettings;
@BeforeEach
void init() {
auditEnabledSettings =
new ChatbotSettings(
true,
true,
4000,
0.5D,
new ChatbotSettings.ModelSettings(
ChatbotSettings.ModelProvider.OPENAI,
"gpt-5-nano",
"gpt-5-mini",
"embed",
0.95D),
new ChatbotSettings.RagSettings(512, 128, 4),
new ChatbotSettings.CacheSettings(60, 10, 1000),
new ChatbotSettings.OcrSettings(false),
new ChatbotSettings.AuditSettings(true),
new ChatbotSettings.UsageSettings(100000L, 0.7D));
auditDisabledSettings =
new ChatbotSettings(
true,
true,
4000,
0.5D,
new ChatbotSettings.ModelSettings(
ChatbotSettings.ModelProvider.OPENAI,
"gpt-5-nano",
"gpt-5-mini",
"embed",
0.95D),
new ChatbotSettings.RagSettings(512, 128, 4),
new ChatbotSettings.CacheSettings(60, 10, 1000),
new ChatbotSettings.OcrSettings(false),
new ChatbotSettings.AuditSettings(false),
new ChatbotSettings.UsageSettings(100000L, 0.7D));
}
@Test
void createSessionEmitsAuditWhenEnabled() {
ChatbotSession session =
ChatbotSession.builder()
.sessionId("session-1")
.documentId("doc-1")
.ocrRequested(true)
.createdAt(Instant.now())
.build();
when(ingestionService.ingest(any())).thenReturn(session);
when(featureProperties.current()).thenReturn(auditEnabledSettings);
when(userService.getCurrentUsername()).thenReturn("tester");
chatbotService.createSession(
ChatbotSessionCreateRequest.builder().text("abc").warningsAccepted(true).build());
ArgumentCaptor<Map<String, Object>> payloadCaptor = ArgumentCaptor.forClass(Map.class);
verify(auditService)
.audit(
eq(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS),
payloadCaptor.capture());
Map<String, Object> payload = payloadCaptor.getValue();
verify(cacheService, times(0)).invalidateSession(any());
verify(userService).getCurrentUsername();
org.junit.jupiter.api.Assertions.assertEquals("session-1", payload.get("sessionId"));
}
@Test
void querySkipsAuditWhenDisabled() {
ChatbotQueryRequest request =
ChatbotQueryRequest.builder()
.sessionId("session-2")
.prompt("Hello?")
.allowEscalation(true)
.build();
ChatbotResponse response =
ChatbotResponse.builder()
.sessionId("session-2")
.modelUsed("gpt-5-nano")
.confidence(0.8D)
.build();
when(conversationService.handleQuery(request)).thenReturn(response);
when(featureProperties.current()).thenReturn(auditDisabledSettings);
chatbotService.ask(request);
verify(auditService, times(0))
.audit(eq(stirling.software.proprietary.audit.AuditEventType.PDF_PROCESS), any());
}
@Test
void closeSessionInvalidatesCache() {
ChatbotSession session =
ChatbotSession.builder().sessionId("session-3").documentId("doc").build();
when(sessionRegistry.findById("session-3")).thenReturn(Optional.of(session));
when(featureProperties.current()).thenReturn(auditEnabledSettings);
chatbotService.close("session-3");
verify(sessionRegistry).remove("session-3");
verify(cacheService).invalidateSession("session-3");
}
}
+5 -1
View File
@@ -15,6 +15,7 @@ import com.github.jk1.license.render.*
ext {
springBootVersion = "3.5.6"
springAiVersion = "1.0.3"
pdfboxVersion = "3.0.5"
imageioVersion = "3.12.0"
lombokVersion = "1.18.42"
@@ -57,7 +58,7 @@ repositories {
allprojects {
group = 'stirling.software'
version = '2.0.1'
version = '2.0.2'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@@ -93,6 +94,8 @@ subprojects {
repositories {
mavenCentral()
maven { url = 'https://repo.spring.io/release' }
maven { url 'https://repo.spring.io/milestone' }
}
configurations.configureEach {
@@ -107,6 +110,7 @@ subprojects {
dependencyManagement {
imports {
mavenBom "org.springframework.boot:spring-boot-dependencies:$springBootVersion"
mavenBom "org.springframework.ai:spring-ai-bom:$springAiVersion"
}
}
+5
View File
@@ -587,3 +587,8 @@ In your Thymeleaf templates, use the `#{key}` syntax to reference the new transl
```
Remember, never hard-code text in your templates or Java code. Always use translation keys to ensure proper localization.
### Chatbot Feature Configuration
- The chatbot backend is disabled unless `premium.proFeatures.chatbot.enabled` is true in `configs/settings.yml`.
- Provide an OpenAI-compatible key via `SPRING_AI_OPENAI_API_KEY` (or `spring.ai.openai.api-key`) and set `spring.ai.openai.enabled=true` when you want chatbot beans to load. Leaving this property disabled allows the rest of Stirling-PDF to run without AI credentials.
+41 -16
View File
@@ -455,6 +455,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@@ -498,6 +499,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@@ -578,6 +580,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.4.1.tgz",
"integrity": "sha512-TGpxn2CvAKRnOJWJ3bsK+dKBiCp75ehxftRUmv7wAmPomhnG5XrDfoWJungvO+zbbqAwso6PocdeXINVt3hlAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/engines": "1.4.1",
"@embedpdf/models": "1.4.1"
@@ -677,6 +680,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.4.1.tgz",
"integrity": "sha512-5WLDiNMH6tACkLGGv/lJtNsDeozOhSbrh0mjD1btHun8u7Yscu/Vf8tdJRUOsd+nULivo2nQ2NFNKu0OTbVo8w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -693,6 +697,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.4.1.tgz",
"integrity": "sha512-Ng02S9SFIAi9JZS5rI+NXSnZZ1Yk9YYRw4MlN2pig49qOyivZdz0oScZaYxQPewo8ccJkLeghjdeWswOBW/6cA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -710,6 +715,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.4.1.tgz",
"integrity": "sha512-m3ZOk8JygsLxoa4cZ+0BVB5pfRWuBCg2/gPqjhoFZNKTqAFw4J6HGUrhYKg94GRYe+w1cTJl/NbTBYuU5DOrsA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -746,6 +752,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.4.1.tgz",
"integrity": "sha512-gKCdNKw6WBHBEpTc2DLBWIWOxzsNnaNbpfeY6C4f2Bum0EO+XW3Hl2oIx1uaRHjIhhnXso1J3QweqelsPwDGwg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -780,6 +787,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.4.1.tgz",
"integrity": "sha512-Y9O+matB4j4fLim5s/jn7qIi+lMC9vmDJRpJhiWe8bvD9oYLP2xfD/DdhFgAjRKcNhPoxC+j8q8QN5BMeGAv2Q==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -816,6 +824,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.4.1.tgz",
"integrity": "sha512-lo5Ytk1PH0PrRKv6zKVupm4t02VGsqIrnSIeP6NO8Ujx0wfqEhj//sqIuO/EwfFVJD8lcQIP9UUo9y8baCrEog==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -891,6 +900,7 @@
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.4.1.tgz",
"integrity": "sha512-+TgFHKPCLTBiDYe2DdsmTS37hwQgcZ3dYIc7bE0l5cp+GVwouu1h0MTmjL+90loizeWwCiu10E/zXR6hz+CUaQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@embedpdf/models": "1.4.1"
},
@@ -1046,6 +1056,7 @@
"resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz",
"integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -1089,6 +1100,7 @@
"resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz",
"integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.18.3",
"@emotion/babel-plugin": "^11.13.5",
@@ -2119,6 +2131,7 @@
"resolved": "https://registry.npmjs.org/@mantine/core/-/core-8.3.6.tgz",
"integrity": "sha512-paTl+0x+O/QtgMtqVJaG8maD8sfiOdgPmLOyG485FmeGZ1L3KMdEkhxZtmdGlDFsLXhmMGQ57ducT90bvhXX5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/react": "^0.27.16",
"clsx": "^2.1.1",
@@ -2169,6 +2182,7 @@
"resolved": "https://registry.npmjs.org/@mantine/hooks/-/hooks-8.3.6.tgz",
"integrity": "sha512-liHfaWXHAkLjJy+Bkr29UsCwAoDQ/a64WrM67lksx8F0qqyjR5RQH8zVlhuOjdpQnwtlUkE/YiTvbJiPcoI0bw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "^18.x || ^19.x"
}
@@ -2236,6 +2250,7 @@
"resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.5.tgz",
"integrity": "sha512-8VVxFmp1GIm9PpmnQoCoYo0UWHoOrdA57tDL62vkpzEgvb/d71Wsbv4FRg7r1Gyx7PuSo0tflH34cdl/NvfHNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4",
"@mui/core-downloads-tracker": "^7.3.5",
@@ -3168,6 +3183,7 @@
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.16"
}
@@ -3286,7 +3302,6 @@
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz",
"integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"acorn": "^8.9.0"
}
@@ -4063,6 +4078,7 @@
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -4391,6 +4407,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -4401,6 +4418,7 @@
"integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -4470,6 +4488,7 @@
"integrity": "sha512-6m1I5RmHBGTnUGS113G04DMu3CpSdxCAU/UvtjNWL4Nuf3MW9tQhiJqRlHzChIkhy6kZSAQmc+I1bcGjE3yNKg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.46.3",
"@typescript-eslint/types": "8.46.3",
@@ -5183,7 +5202,6 @@
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/shared": "3.5.24"
}
@@ -5193,7 +5211,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5204,7 +5221,6 @@
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
@@ -5217,7 +5233,6 @@
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
@@ -5244,6 +5259,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -5651,7 +5667,6 @@
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -5928,6 +5943,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -6975,7 +6991,8 @@
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz",
"integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==",
"dev": true,
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/dezalgo": {
"version": "1.0.4",
@@ -7370,6 +7387,7 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -7540,6 +7558,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -7706,8 +7725,7 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/espree": {
"version": "10.4.0",
@@ -7772,7 +7790,6 @@
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz",
"integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
@@ -8863,6 +8880,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.27.6"
},
@@ -9339,7 +9357,6 @@
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "^1.0.6"
}
@@ -9660,6 +9677,7 @@
"integrity": "sha512-Pcfm3eZ+eO4JdZCXthW9tCDT3nF4K+9dmeZ+5X39n+Kqz0DDIABRP5CAEOHRFZk8RGuC2efksTJxrjp8EXCunQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@acemir/cssom": "^0.9.19",
"@asamuzakjp/dom-selector": "^6.7.3",
@@ -10246,8 +10264,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/locate-path": {
"version": "6.0.0",
@@ -11393,6 +11410,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -11672,6 +11690,7 @@
"resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz",
"integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
@@ -12054,6 +12073,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -12063,6 +12083,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -13574,7 +13595,6 @@
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">= 0.4"
}
@@ -13783,6 +13803,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14084,6 +14105,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -14165,6 +14187,7 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"napi-postinstall": "^0.3.0"
},
@@ -14369,6 +14392,7 @@
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -14520,6 +14544,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -14533,6 +14558,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",
@@ -15144,8 +15170,7 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/zod": {
"version": "3.25.76",
@@ -54,17 +54,17 @@
--cc-secondary-color: #b0b0b0;
--cc-btn-primary-bg: #4dabf7;
--cc-btn-primary-color: #2d2d2d;
--cc-btn-primary-color: #ffffff;
--cc-btn-primary-border-color: #4dabf7;
--cc-btn-primary-hover-bg: #3d3d3d;
--cc-btn-primary-hover-color: #e5e5e5;
--cc-btn-primary-hover-color: #ffffff;
--cc-btn-primary-hover-border-color: #3d3d3d;
--cc-btn-secondary-bg: #3d3d3d;
--cc-btn-secondary-color: #e5e5e5;
--cc-btn-secondary-color: #ffffff;
--cc-btn-secondary-border-color: #3d3d3d;
--cc-btn-secondary-hover-bg: #4dabf7;
--cc-btn-secondary-hover-color: #2d2d2d;
--cc-btn-secondary-hover-color: #ffffff;
--cc-btn-secondary-hover-border-color: #4dabf7;
--cc-separator-border-color: #555555;
@@ -180,4 +180,27 @@
/* Lower z-index so cookie banner appears behind onboarding modals */
#cc-main {
z-index: 100 !important;
}
/* Ensure consent modal text is visible in both themes */
#cc-main .cm {
background: var(--cc-bg) !important;
color: var(--cc-primary-color) !important;
}
#cc-main .cm__title {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__desc {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer {
color: var(--cc-primary-color) !important;
}
#cc-main .cm__footer-links a,
#cc-main .cm__link {
color: var(--cc-primary-color) !important;
}
+38 -2
View File
@@ -2267,8 +2267,16 @@ defaultCanvasLabel = "Drawing signature"
defaultImageLabel = "Uploaded signature"
defaultTextLabel = "Typed signature"
saveButton = "Save signature"
savePersonal = "Save Personal"
saveShared = "Save Shared"
saveUnavailable = "Create a signature first to save it."
noChanges = "Current signature is already saved."
tempStorageTitle = "Temporary browser storage"
tempStorageDescription = "Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers."
personalHeading = "Personal Signatures"
sharedHeading = "Shared Signatures"
personalDescription = "Only you can see these signatures."
sharedDescription = "All users can see and use these signatures."
[sign.saved.type]
canvas = "Drawing"
@@ -4508,6 +4516,7 @@ description = "URL or filename to impressum (required in some jurisdictions)"
title = "Premium & Enterprise"
description = "Configure your premium or enterprise license key."
license = "License Configuration"
noInput = "Please provide a license key or file"
[admin.settings.premium.licenseKey]
toggle = "Got a license key or certificate file?"
@@ -4525,6 +4534,26 @@ line1 = "Overwriting your current license key cannot be undone."
line2 = "Your previous license will be permanently lost unless you have backed it up elsewhere."
line3 = "Important: Keep license keys private and secure. Never share them publicly."
[admin.settings.premium.inputMethod]
text = "License Key"
file = "Certificate File"
[admin.settings.premium.file]
label = "License Certificate File"
description = "Upload your .lic or .cert license file from offline purchases"
choose = "Choose License File"
selected = "Selected: {{filename}} ({{size}})"
successMessage = "License file uploaded and activated successfully. No restart required."
[admin.settings.premium.currentLicense]
title = "Active License"
file = "Source: License file ({{path}})"
key = "Source: License key"
type = "Type: {{type}}"
noInput = "Please provide a license key or upload a certificate file"
success = "Success"
[admin.settings.premium.enabled]
label = "Enable Premium Features"
description = "Enable license key checks for pro/enterprise features"
@@ -5261,7 +5290,7 @@ emailsPlaceholder = "user1@example.com, user2@example.com"
emailsRequired = "At least one email address is required"
submit = "Send Invites"
success = "user(s) invited successfully"
partialSuccess = "Some invites failed"
partialFailure = "Some invites failed"
allFailed = "Failed to invite users"
error = "Failed to send invites"
@@ -5777,7 +5806,7 @@ description = "Sign in with your Stirling account"
[setup.mode.selfhosted]
title = "Self-Hosted Server"
description = "Connect to your own Stirling PDF server"
description = "Connect to your own Stirling PDF server with your personal account"
[setup.saas]
title = "Sign in to Stirling"
@@ -5813,6 +5842,13 @@ submit = "Login"
signInWith = "Sign in with"
oauthPending = "Opening browser for authentication..."
orContinueWith = "Or continue with email"
serverRequirement = "Note: The server must have login enabled."
showInstructions = "How to enable?"
hideInstructions = "Hide instructions"
instructions = "To enable login on your Stirling PDF server:"
instructionsEnvVar = "Set the environment variable:"
instructionsOrYml = "Or in settings.yml:"
instructionsRestart = "Then restart your server for the changes to take effect."
[setup.login.username]
label = "Username"
+2 -2
View File
@@ -11,8 +11,8 @@
{
"identifier": "http:allow-fetch",
"allow": [
{ "url": "http://localhost:*" },
{ "url": "http://127.0.0.1:*" },
{ "url": "http://*" },
{ "url": "http://*:*" },
{ "url": "https://*" }
]
},
+1
View File
@@ -3,6 +3,7 @@ Version=1.0
Type=Application
Name=Stirling-PDF
Comment=Locally hosted web application that allows you to perform various operations on PDF files
Exec=/usr/bin/stirling-pdf
Icon={{icon}}
Terminal=false
MimeType=application/pdf;
@@ -15,6 +15,7 @@ import { SignatureProvider } from "@app/contexts/SignatureContext";
import { OnboardingProvider } from "@app/contexts/OnboardingContext";
import { TourOrchestrationProvider } from "@app/contexts/TourOrchestrationContext";
import { AdminTourOrchestrationProvider } from "@app/contexts/AdminTourOrchestrationContext";
import { ChatbotProvider } from "@app/contexts/ChatbotContext";
import { PageEditorProvider } from "@app/contexts/PageEditorContext";
import { BannerProvider } from "@app/contexts/BannerContext";
import ErrorBoundary from "@app/components/shared/ErrorBoundary";
@@ -98,7 +99,9 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<RightRailProvider>
<TourOrchestrationProvider>
<AdminTourOrchestrationProvider>
{children}
<ChatbotProvider>
{children}
</ChatbotProvider>
</AdminTourOrchestrationProvider>
</TourOrchestrationProvider>
</RightRailProvider>
@@ -0,0 +1,644 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import {
Badge,
Box,
Button,
Divider,
Group,
Modal,
ScrollArea,
Select,
Stack,
Switch,
Text,
Textarea,
} from '@mantine/core';
import { useMediaQuery, useViewportSize } from '@mantine/hooks';
import { useTranslation } from 'react-i18next';
import SmartToyRoundedIcon from '@mui/icons-material/SmartToyRounded';
import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded';
import SendRoundedIcon from '@mui/icons-material/SendRounded';
import RefreshRoundedIcon from '@mui/icons-material/RefreshRounded';
import { useChatbot } from '@app/contexts/ChatbotContext';
import { useFileState } from '@app/contexts/FileContext';
import {
ChatbotMessageResponse,
ChatbotSessionInfo,
ChatbotUsageSummary,
sendChatbotPrompt,
} from '@app/services/chatbotService';
import { useToast } from '@app/components/toast';
import type { StirlingFile } from '@app/types/fileContext';
import { useSidebarContext } from '@app/contexts/SidebarContext';
interface ChatMessage {
id: string;
role: 'user' | 'assistant';
content: string;
confidence?: number;
modelUsed?: string;
createdAt: Date;
documentId?: string;
documentName?: string;
}
function createMessageId() {
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
return `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`;
}
const MAX_PROMPT_CHARS = 4000;
const ALPHA_ACK_KEY = 'stirling.chatbot.alphaAck';
const ChatbotDrawer = () => {
const { t } = useTranslation();
const isMobile = useMediaQuery('(max-width: 768px)');
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const {
isOpen,
closeChat,
preferredFileId,
setPreferredFileId,
sessions: preparedSessions,
requestPreprocessing,
} = useChatbot();
const { selectors } = useFileState();
const { sidebarRefs } = useSidebarContext();
const { show } = useToast();
const files = selectors.getFiles();
const [selectedFileId, setSelectedFileId] = useState<string | undefined>();
const [alphaAccepted, setAlphaAccepted] = useState(false);
const [runOcr, setRunOcr] = useState(false);
const [isStartingSession, setIsStartingSession] = useState(false);
const [isSendingMessage, setIsSendingMessage] = useState(false);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [prompt, setPrompt] = useState('');
const [warnings, setWarnings] = useState<string[]>([]);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const [panelAnchor, setPanelAnchor] = useState<{ right: number; top: number } | null>(null);
const usageAlertState = useRef<'none' | 'warned' | 'limit'>('none');
const selectedFile = useMemo<StirlingFile | undefined>(
() => files.find((file) => file.fileId === selectedFileId),
[files, selectedFileId]
);
const selectedSessionEntry = selectedFileId
? preparedSessions[selectedFileId]
: undefined;
const sessionStatus = selectedSessionEntry?.status ?? 'idle';
const sessionError = selectedSessionEntry?.error;
const sessionInfo: ChatbotSessionInfo | null = selectedSessionEntry?.session ?? null;
const selectedDocumentName = selectedFile?.name ?? selectedSessionEntry?.fileName;
const contextStats =
selectedSessionEntry?.status === 'ready' && selectedSessionEntry?.characterCount !== undefined
? {
pageCount: selectedSessionEntry.pageCount ?? 0,
characterCount: selectedSessionEntry.characterCount ?? 0,
}
: null;
const preparationWarnings = selectedSessionEntry?.warnings ?? [];
const derivedStatusMessage = useMemo(() => {
if (!alphaAccepted) {
return t('chatbot.autoSyncPrompt', 'Acknowledge the alpha notice to start syncing automatically.');
}
if (sessionStatus === 'processing' || isStartingSession) {
return t('chatbot.status.syncing', 'Preparing document for chat…');
}
if (sessionStatus === 'error') {
return sessionError || t('chatbot.errors.preprocessing', 'Unable to prepare this document.');
}
if (sessionStatus === 'unsupported') {
return sessionError || t('chatbot.errors.unsupported', 'Unsupported document type.');
}
return null;
}, [alphaAccepted, sessionStatus, sessionError, isStartingSession, t]);
const assistantWarnings = useMemo(
() => [...preparationWarnings, ...warnings.filter(Boolean)],
[preparationWarnings, warnings]
);
useEffect(() => {
if (!isOpen) {
return;
}
const storedAck =
typeof window !== 'undefined'
? window.localStorage.getItem(ALPHA_ACK_KEY) === 'true'
: false;
setAlphaAccepted(storedAck);
}, [isOpen]);
useEffect(() => {
if (!isOpen) {
return;
}
if (preferredFileId) {
setSelectedFileId(preferredFileId);
setPreferredFileId(undefined);
return;
}
if (!selectedFileId && files.length > 0) {
setSelectedFileId(files[0].fileId);
}
}, [isOpen, preferredFileId, setPreferredFileId, files, selectedFileId]);
useEffect(() => {
if (!isOpen) {
return;
}
if (scrollViewportRef.current) {
scrollViewportRef.current.scrollTo({
top: scrollViewportRef.current.scrollHeight,
behavior: 'smooth',
});
}
}, [messages, isOpen]);
useEffect(() => {
usageAlertState.current = 'none';
if (sessionInfo) {
maybeShowUsageWarning(sessionInfo.usageSummary);
}
}, [sessionInfo?.sessionId]);
const maybeShowUsageWarning = (usage?: ChatbotUsageSummary | null) => {
if (!usage) {
return;
}
if (usage.limitExceeded && usageAlertState.current !== 'limit') {
usageAlertState.current = 'limit';
show({
alertType: 'warning',
title: t('chatbot.usage.limitReachedTitle', 'Chatbot limit reached'),
body: t(
'chatbot.usage.limitReachedBody',
'You have exceeded the current monthly allocation for the chatbot. Further responses may be throttled.'
),
});
return;
}
if (usage.nearingLimit && usageAlertState.current === 'none') {
usageAlertState.current = 'warned';
show({
alertType: 'warning',
title: t('chatbot.usage.nearingLimitTitle', 'Approaching usage limit'),
body: t(
'chatbot.usage.nearingLimitBody',
'You are nearing your monthly chatbot allocation. Consider limiting very large requests.'
),
});
}
};
useLayoutEffect(() => {
if (isMobile || !isOpen) {
setPanelAnchor(null);
return;
}
const panelEl = sidebarRefs.toolPanelRef.current;
if (!panelEl) {
setPanelAnchor(null);
return;
}
const updateAnchor = () => {
const rect = panelEl.getBoundingClientRect();
setPanelAnchor({
right: rect.right,
top: rect.top,
});
};
updateAnchor();
const observer = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateAnchor()) : null;
observer?.observe(panelEl);
const handleResize = () => updateAnchor();
window.addEventListener('resize', handleResize);
return () => {
observer?.disconnect();
window.removeEventListener('resize', handleResize);
};
}, [isMobile, isOpen, sidebarRefs.toolPanelRef]);
const ensureFileSelected = () => {
if (!selectedFile) {
show({
alertType: 'warning',
title: t('chatbot.toasts.noFileTitle', 'No PDF selected'),
body: t('chatbot.toasts.noFileBody', 'Please choose a document before starting the chatbot.'),
});
return false;
}
return true;
};
const handleAlphaAccept = (checked: boolean) => {
setAlphaAccepted(checked);
if (typeof window !== 'undefined') {
if (checked) {
window.localStorage.setItem(ALPHA_ACK_KEY, 'true');
} else {
window.localStorage.removeItem(ALPHA_ACK_KEY);
}
}
};
const handleManualPrepare = async (forceOcr?: boolean) => {
if (!ensureFileSelected() || !selectedFileId) {
return;
}
setIsStartingSession(true);
try {
await requestPreprocessing(selectedFileId, { force: true, forceOcr: forceOcr ?? runOcr });
usageAlertState.current = 'none';
} catch (error) {
console.error('[Chatbot] Failed to prepare document', error);
show({
alertType: 'error',
title: t('chatbot.toasts.failedSessionTitle', 'Could not prepare document'),
body: error instanceof Error ? error.message : String(error),
});
} finally {
setIsStartingSession(false);
}
};
const handleSendMessage = async () => {
if (!sessionInfo || sessionStatus !== 'ready') {
show({
alertType: 'neutral',
title: t('chatbot.toasts.noSessionTitle', 'Sync your document first'),
body: t('chatbot.toasts.noSessionBody', 'Send your PDF to the chatbot before asking questions.'),
});
return;
}
if (!prompt.trim()) {
return;
}
const trimmedPrompt = prompt.slice(0, MAX_PROMPT_CHARS);
const userMessage: ChatMessage = {
id: createMessageId(),
role: 'user',
content: trimmedPrompt,
createdAt: new Date(),
documentId: selectedFileId,
documentName: selectedDocumentName,
};
setMessages((prev) => [...prev, userMessage]);
setPrompt('');
setIsSendingMessage(true);
try {
const reply = await sendChatbotPrompt({
sessionId: sessionInfo.sessionId,
prompt: trimmedPrompt,
allowEscalation: true,
});
maybeShowUsageWarning(reply.usageSummary);
setWarnings(reply.warnings ?? []);
const assistant = convertAssistantMessage(reply);
setMessages((prev) => [...prev, assistant]);
} catch (error) {
console.error('[Chatbot] Failed to send prompt', error);
show({
alertType: 'error',
title: t('chatbot.toasts.failedPromptTitle', 'Unable to ask question'),
body: error instanceof Error ? error.message : String(error),
});
// Revert optimistic user message
setMessages((prev) => prev.filter((message) => message.id !== userMessage.id));
} finally {
setIsSendingMessage(false);
}
};
const convertAssistantMessage = (reply: ChatbotMessageResponse): ChatMessage => ({
id: createMessageId(),
role: 'assistant',
content: reply.answer,
confidence: reply.confidence,
modelUsed: reply.modelUsed,
createdAt: new Date(),
documentId: selectedFileId,
documentName: selectedDocumentName,
});
const fileOptions = useMemo(
() =>
files.map((file) => ({
value: file.fileId,
label: `${file.name} (${(file.size / 1024 / 1024).toFixed(2)} MB)`,
})),
[files]
);
const disablePromptInput =
!sessionInfo || sessionStatus !== 'ready' || isStartingSession || isSendingMessage;
const canSend = !disablePromptInput && prompt.trim().length > 0;
const handlePromptKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === 'Enter' &&
!event.shiftKey &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey
) {
if (canSend) {
event.preventDefault();
handleSendMessage();
}
}
};
const drawerTitle = (
<Group gap="xs">
<SmartToyRoundedIcon fontSize="small" />
<Text fw={600}>{t('chatbot.title', 'Stirling PDF Bot')}</Text>
<Badge color="yellow" size="sm">{t('chatbot.alphaBadge', 'Alpha')}</Badge>
</Group>
);
const safeViewportWidth =
viewportWidth || (typeof window !== 'undefined' ? window.innerWidth : 1280);
const safeViewportHeight =
viewportHeight || (typeof window !== 'undefined' ? window.innerHeight : 900);
const desktopLeft = !isMobile ? (panelAnchor ? panelAnchor.right + 16 : 280) : undefined;
const desktopBottom = !isMobile ? 24 : undefined;
const desktopWidth = !isMobile
? Math.min(440, Math.max(320, safeViewportWidth - (desktopLeft ?? 24) - 240))
: undefined;
const desktopHeightPx = !isMobile
? Math.max(520, Math.min(safeViewportHeight - 48, Math.round(safeViewportHeight * 0.85)))
: undefined;
const renderMessageBubble = (message: ChatMessage) => {
const isUser = message.role === 'user';
const bubbleColor = isUser ? '#1f7ae0' : '#f3f4f6';
const textColor = isUser ? '#fff' : '#1f1f1f';
return (
<Box
key={message.id + message.role + message.createdAt.getTime()}
style={{
display: 'flex',
justifyContent: isUser ? 'flex-end' : 'flex-start',
}}
>
<Box
p="sm"
maw="85%"
bg={bubbleColor}
style={{
borderRadius: 14,
borderTopRightRadius: isUser ? 4 : 14,
borderTopLeftRadius: isUser ? 14 : 4,
boxShadow: '0 2px 12px rgba(16,24,40,0.06)',
}}
>
<Group justify="space-between" mb={4} gap="xs" align="flex-start">
<Text size="xs" c={isUser ? 'rgba(255,255,255,0.8)' : 'dimmed'} tt="uppercase">
{isUser ? t('chatbot.userLabel', 'You') : t('chatbot.botLabel', 'Stirling Bot')}
</Text>
{!isUser && message.confidence !== undefined && (
<Badge
size="xs"
variant="light"
color={message.confidence >= 0.6 ? 'green' : 'yellow'}
>
{t('chatbot.confidence', 'Confidence: {{value}}%', {
value: Math.round(message.confidence * 100),
})}
</Badge>
)}
</Group>
<Text size="sm" c={textColor} style={{ whiteSpace: 'pre-wrap' }}>
{message.content}
</Text>
{!isUser && message.modelUsed && (
<Text size="xs" c="dimmed" mt={4}>
{t('chatbot.modelTag', 'Model: {{name}}', { name: message.modelUsed })}
</Text>
)}
{message.documentName && (
<Badge
size="xs"
variant="light"
color={isUser ? 'blue' : 'gray'}
mt={6}
>
{message.documentName}
</Badge>
)}
</Box>
</Box>
);
};
return (
<>
<Modal
opened={isOpen}
onClose={closeChat}
withCloseButton
radius="lg"
overlayProps={{ opacity: 0.5, blur: 2 }}
fullScreen={isMobile}
centered={isMobile}
title={drawerTitle}
styles={{
content: {
width: isMobile ? '100%' : desktopWidth,
left: isMobile ? undefined : desktopLeft,
right: isMobile ? 0 : undefined,
margin: isMobile ? undefined : 0,
top: isMobile ? undefined : undefined,
bottom: isMobile ? 0 : desktopBottom,
position: isMobile ? undefined : 'fixed',
height: isMobile ? '100%' : desktopHeightPx ? `${desktopHeightPx}px` : '75vh',
overflow: 'hidden',
},
body: {
paddingTop: 'var(--mantine-spacing-md)',
paddingBottom: 'var(--mantine-spacing-md)',
height: '100%',
display: 'flex',
flexDirection: 'column',
},
}}
transitionProps={{ transition: 'slide-left', duration: 200 }}
>
<Stack gap="sm" h="100%" style={{ minHeight: 0 }}>
<Box
p="sm"
style={{
border: '1px solid var(--border-subtle)',
borderRadius: 8,
backgroundColor: 'var(--bg-subtle)',
display: 'flex',
gap: '0.5rem',
alignItems: 'flex-start',
}}
>
<WarningAmberRoundedIcon fontSize="small" style={{ color: 'var(--text-warning)' }} />
<Box>
<Text fw={600}>{t('chatbot.alphaTitle', 'Experimental feature')}</Text>
<Text size="sm">
{t(
'chatbot.alphaDescription',
'This chatbot is in alpha. It currently ignores images and may produce inaccurate answers.'
)}
</Text>
</Box>
</Box>
<Group align="flex-end" justify="space-between" gap="md" wrap="wrap">
<Select
label={t('chatbot.fileLabel', 'Document')}
placeholder={t('chatbot.filePlaceholder', 'Select an uploaded PDF')}
data={fileOptions}
value={selectedFileId}
onChange={(value) => setSelectedFileId(value || undefined)}
nothingFoundMessage={t('chatbot.noFiles', 'Upload a PDF from File Manager to start chatting.')}
style={{ flex: '1 1 200px' }}
/>
<Stack gap={4} style={{ minWidth: 180 }}>
<Switch
checked={alphaAccepted}
onChange={(event) => handleAlphaAccept(event.currentTarget.checked)}
label={t('chatbot.acceptAlphaLabel', 'I acknowledge this experimental feature')}
/>
<Switch
checked={runOcr}
onChange={(event) => setRunOcr(event.currentTarget.checked)}
label={t('chatbot.ocrToggle', 'Run OCR before extracting text')}
/>
</Stack>
</Group>
<Button
fullWidth
variant="filled"
leftSection={<RefreshRoundedIcon fontSize="small" />}
loading={isStartingSession || sessionStatus === 'processing'}
onClick={() => handleManualPrepare()}
disabled={!selectedFile || !alphaAccepted || sessionStatus === 'processing'}
>
{sessionStatus === 'ready'
? t('chatbot.refreshButton', 'Reprocess document')
: t('chatbot.startButton', 'Prepare document for chat')}
</Button>
{derivedStatusMessage && (
<Box
p="sm"
style={{
border: '1px solid var(--border-subtle)',
borderRadius: 8,
backgroundColor: 'var(--bg-muted)',
}}
>
<Text
size="sm"
c={
sessionStatus === 'error' || sessionStatus === 'unsupported'
? 'var(--text-warning)'
: 'blue'
}
>
{derivedStatusMessage}
</Text>
</Box>
)}
{sessionInfo && contextStats && (
<Box>
<Text fw={600}>{t('chatbot.sessionSummary', 'Context summary')}</Text>
<Text size="sm" c="dimmed">
{t('chatbot.contextDetails', '{{pages}} pages · {{chars}} characters synced', {
pages: contextStats.pageCount,
chars: contextStats.characterCount.toLocaleString(),
})}
</Text>
</Box>
)}
<Divider label={t('chatbot.conversationTitle', 'Conversation')} />
<Box style={{ flex: 1, minHeight: 0 }}>
<ScrollArea viewportRef={scrollViewportRef} style={{ height: '100%' }}>
<Stack gap="sm" pr="xs">
{assistantWarnings.length > 0 &&
assistantWarnings.map((warning) => (
<Box
key={warning}
p="sm"
bg="var(--bg-muted)"
style={{ borderRadius: 12, border: '1px dashed var(--border-subtle)' }}
>
<Group gap="xs" align="flex-start">
<WarningAmberRoundedIcon fontSize="small" style={{ color: 'var(--text-warning)' }} />
<Text size="sm">{warning}</Text>
</Group>
</Box>
))}
{messages.length === 0 && (
<Text size="sm" c="dimmed">
{t('chatbot.emptyState', 'Ask a question about your PDF to start the conversation.')}
</Text>
)}
{messages.map(renderMessageBubble)}
</Stack>
</ScrollArea>
</Box>
<Stack
gap="xs"
style={{
flexShrink: 0,
border: '1px solid var(--border-subtle)',
borderRadius: 12,
padding: '0.75rem',
background: 'var(--bg-toolbar)',
}}
>
<Textarea
placeholder={t('chatbot.promptPlaceholder', 'Ask anything about this PDF…')}
minRows={2}
autosize
maxRows={6}
value={prompt}
maxLength={MAX_PROMPT_CHARS}
onChange={(event) => setPrompt(event.currentTarget.value)}
disabled={disablePromptInput}
onKeyDown={handlePromptKeyDown}
/>
<Group justify="space-between">
<Text size="xs" c="dimmed">
{t('chatbot.promptCounter', '{{used}} / {{limit}} characters', {
used: prompt.length,
limit: MAX_PROMPT_CHARS,
})}
</Text>
<Button
rightSection={<SendRoundedIcon fontSize="small" />}
onClick={handleSendMessage}
loading={isSendingMessage}
disabled={!canSend}
>
{t('chatbot.sendButton', 'Send')}
</Button>
</Group>
</Stack>
</Stack>
</Modal>
</>
);
};
export default ChatbotDrawer;
+65 -40
View File
@@ -1,6 +1,7 @@
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useCookieConsent } from '@app/hooks/useCookieConsent';
import { useFooterInfo } from '@app/hooks/useFooterInfo';
interface FooterProps {
privacyPolicy?: string;
@@ -9,6 +10,7 @@ interface FooterProps {
cookiePolicy?: string;
impressum?: string;
analyticsEnabled?: boolean;
forceLightMode?: boolean;
}
export default function Footer({
@@ -17,10 +19,36 @@ export default function Footer({
accessibilityStatement,
cookiePolicy,
impressum,
analyticsEnabled = false
analyticsEnabled,
forceLightMode = false
}: FooterProps) {
const { t } = useTranslation();
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled });
const { footerInfo } = useFooterInfo();
console.log('[Footer] Props analyticsEnabled:', analyticsEnabled);
console.log('[Footer] Fetched footerInfo:', footerInfo);
// Use props if provided, otherwise fall back to fetched footer info
const finalAnalyticsEnabled = analyticsEnabled ?? footerInfo?.analyticsEnabled ?? false;
const finalPrivacyPolicy = privacyPolicy ?? footerInfo?.privacyPolicy;
const finalTermsAndConditions = termsAndConditions ?? footerInfo?.termsAndConditions;
const finalAccessibilityStatement = accessibilityStatement ?? footerInfo?.accessibilityStatement;
const finalCookiePolicy = cookiePolicy ?? footerInfo?.cookiePolicy;
const finalImpressum = impressum ?? footerInfo?.impressum;
console.log('[Footer] Final analyticsEnabled:', finalAnalyticsEnabled);
const { showCookiePreferences } = useCookieConsent({ analyticsEnabled: finalAnalyticsEnabled, forceLightMode });
// Default URLs
const defaultTermsUrl = "https://www.stirling.com/legal/terms-of-service";
const defaultPrivacyUrl = "https://www.stirling.com/legal/privacy-policy";
const defaultAccessibilityUrl = "https://www.stirling.com/accessibility";
// Use provided URLs or fall back to defaults
const finalTermsUrl = finalTermsAndConditions || defaultTermsUrl;
const finalPrivacyUrl = finalPrivacyPolicy || defaultPrivacyUrl;
const finalAccessibilityUrl = finalAccessibilityStatement || defaultAccessibilityUrl;
// Helper to check if a value is valid (not null/undefined/empty string)
const isValidLink = (link?: string) => link && link.trim().length > 0;
@@ -28,8 +56,8 @@ export default function Footer({
return (
<div style={{
height: 'var(--footer-height)',
backgroundColor: 'var(--mantine-color-gray-1)',
borderTop: '1px solid var(--mantine-color-gray-2)',
backgroundColor: forceLightMode ? '#f1f3f5' : 'var(--mantine-color-gray-1)',
borderTop: forceLightMode ? '1px solid #e9ecef' : '1px solid var(--mantine-color-gray-2)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
@@ -38,7 +66,10 @@ export default function Footer({
justify="center"
align="center"
direction="row"
style={{ fontSize: '0.75rem' }}>
style={{
fontSize: '0.75rem',
color: forceLightMode ? '#495057' : undefined
}}>
<a
className="footer-link px-3"
id="survey"
@@ -48,57 +79,51 @@ export default function Footer({
>
{t('survey.nav', 'Survey')}
</a>
{isValidLink(privacyPolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalPrivacyUrl}
>
{t('legal.privacy', 'Privacy Policy')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalTermsUrl}
>
{t('legal.terms', 'Terms and Conditions')}
</a>
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={finalAccessibilityUrl}
>
{t('legal.accessibility', 'Accessibility')}
</a>
{isValidLink(finalCookiePolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={privacyPolicy}
>
{t('legal.privacy', 'Privacy Policy')}
</a>
)}
{isValidLink(termsAndConditions) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={termsAndConditions}
>
{t('legal.terms', 'Terms and Conditions')}
</a>
)}
{isValidLink(accessibilityStatement) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={accessibilityStatement}
>
{t('legal.accessibility', 'Accessibility')}
</a>
)}
{isValidLink(cookiePolicy) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={cookiePolicy}
href={finalCookiePolicy}
>
{t('legal.cookie', 'Cookie Policy')}
</a>
)}
{isValidLink(impressum) && (
{isValidLink(finalImpressum) && (
<a
className="footer-link px-3"
target="_blank"
rel="noopener noreferrer"
href={impressum}
href={finalImpressum}
>
{t('legal.impressum', 'Impressum')}
</a>
)}
{analyticsEnabled && (
{finalAnalyticsEnabled && (
<button
className="footer-link px-3"
id="cookieBanner"
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Menu, Button, ScrollArea, ActionIcon, Tooltip } from '@mantine/core';
import { Menu, Button, ActionIcon } from '@mantine/core';
import { Tooltip } from '@app/components/shared/Tooltip';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '@app/i18n';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -11,6 +12,7 @@ interface LanguageSelectorProps {
position?: React.ComponentProps<typeof Menu>['position'];
offset?: number;
compact?: boolean; // icon-only trigger
tooltip?: string; // tooltip text for compact mode
}
interface LanguageOption {
@@ -51,7 +53,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
const { t } = useTranslation();
const label = disabled ? (
<Tooltip label={t('comingSoon', 'Coming soon')} position="left" withArrow>
<Tooltip content={t('comingSoon', 'Coming soon')} position="left" arrow>
<p>{option.label}</p>
</Tooltip>
) : (
@@ -64,7 +66,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
style={{
opacity: animationTriggered ? 1 : 0,
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
transition: `opacity 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s, transform 0.15s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.01}s`,
}}
>
<Button
@@ -90,7 +92,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
: isSelected
? 'light-dark(var(--mantine-color-blue-9), var(--mantine-color-white))'
: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-white))',
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
transition: 'all 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
cursor: disabled ? 'not-allowed' : 'pointer',
'&:hover': !disabled ? {
backgroundColor: isSelected
@@ -126,7 +128,7 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
backgroundColor: 'var(--mantine-color-blue-4)',
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
animation: 'ripple-expand 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
zIndex: 1,
}}
/>
@@ -149,7 +151,12 @@ const RippleStyles: React.FC = () => (
);
// Main component
const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-start', offset = 8, compact = false }) => {
const LanguageSelector: React.FC<LanguageSelectorProps> = ({
position = 'bottom-start',
offset = 8,
compact = false,
tooltip
}) => {
const { i18n } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
@@ -184,14 +191,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
setOpened(false);
// Clear ripple effect
setTimeout(() => setRippleEffect(null), 100);
setTimeout(() => setRippleEffect(null), 50);
// Force a full reload so RTL/LTR layout and tooltips re-evaluate correctly
if (typeof window !== 'undefined') {
window.location.reload();
}
}, 300);
}, 200);
}, 150);
}, 100);
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
@@ -202,7 +209,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
if (opened) {
setAnimationTriggered(false);
// Small delay to ensure DOM is ready
setTimeout(() => setAnimationTriggered(true), 50);
setTimeout(() => setAnimationTriggered(true), 20);
}
}, [opened]);
@@ -218,7 +225,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
zIndex={Z_INDEX_OVER_FULLSCREEN_SURFACE}
transitionProps={{
transition: 'scale-y',
duration: 200,
duration: 120,
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
>
@@ -227,8 +234,8 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
<ActionIcon
variant="subtle"
radius="md"
title={currentLanguage}
className="right-rail-icon"
title={!opened && tooltip ? tooltip : undefined}
styles={{
root: {
color: 'var(--right-rail-icon)',
@@ -274,8 +281,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
zIndex: Z_INDEX_OVER_FULLSCREEN_SURFACE,
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => {
const enabledLanguages = [
'en-GB', 'zh-CN', 'zh-TW', 'ar-AR', 'fa-IR', 'tr-TR', 'uk-UA', 'zh-BO', 'sl-SI',
@@ -301,8 +307,7 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({ position = 'bottom-
/>
);
})}
</div>
</ScrollArea>
</div>
</Menu.Dropdown>
</Menu>
</>
@@ -20,6 +20,7 @@ import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useChatbot } from '@app/contexts/ChatbotContext';
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
@@ -51,6 +52,7 @@ export default function RightRail() {
const viewerContext = React.useContext(ViewerContext);
const { toggleTheme, themeMode } = useRainbowThemeContext();
const { buttons, actions, allButtonsDisabled } = useRightRail();
const { openChat } = useChatbot();
const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow();
const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker';
@@ -65,6 +67,8 @@ export default function RightRail() {
const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0;
const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0;
const exportState = viewerContext?.getExportState?.();
const chatLabel = t('chatbot.viewerButton', 'Chat about this PDF');
const viewerActiveFile = activeFiles[viewerContext?.activeFileIndex ?? 0];
const totalItems = useMemo(() => {
if (currentView === 'pageEditor') return pageEditorTotalPages;
@@ -216,14 +220,12 @@ export default function RightRail() {
tooltipOffset
)}
{renderWithTooltip(
<div style={{ display: 'inline-flex' }}>
<LanguageSelector position="left-start" offset={6} compact />
</div>,
t('rightRail.language', 'Language'),
tooltipPosition,
tooltipOffset
)}
<LanguageSelector
position="left-start"
offset={6}
compact
tooltip={t('rightRail.language', 'Language')}
/>
{renderWithTooltip(
<ActionIcon
@@ -242,6 +244,24 @@ export default function RightRail() {
tooltipPosition,
tooltipOffset
)}
{renderWithTooltip(
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => {
if (viewerActiveFile) {
openChat({ source: 'viewer', fileId: viewerActiveFile.fileId });
} else {
openChat({ source: 'viewer' });
}
}}
disabled={!viewerActiveFile}
>
<LocalIcon icon="smart-toy-rounded" width="1.5rem" height="1.5rem" />
</ActionIcon>,
chatLabel
)}
</div>
<div className="right-rail-spacer" />
@@ -1,7 +1,8 @@
import React, { useState, useEffect } from 'react';
import { Text, Loader } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { tempOcrLanguages, getAutoOcrLanguage } from '@app/utils/languageMapping';
import { getAutoOcrLanguage, getBrowserLanguagesForOcr, getOcrDisplayName } from '@app/utils/languageMapping';
import apiClient from '@app/services/apiClient';
import DropdownListWithFooter, { DropdownItem } from '@app/components/shared/DropdownListWithFooter';
export interface LanguageOption {
@@ -37,32 +38,41 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
// Fetch available languages from backend
const fetchLanguages = async () => {
try {
const response = await fetch(languagesEndpoint);
const { data } = await apiClient.get<{ languages: string[] }>(languagesEndpoint);
const displayNames = typeof Intl.DisplayNames !== 'undefined'
? new Intl.DisplayNames([i18n.language], { type: 'language' })
: null;
if (response.ok) {
const data: { languages: string[] } = await response.json();
const languages = data.languages;
const languageOptions = [...new Set(data.languages)]
.map((lang) => {
const displayName = getOcrDisplayName(lang);
const browserLanguageCodes = getBrowserLanguagesForOcr(lang);
const langKey = `lang.${lang}`;
const translatedFromKey = t(langKey);
const hasKeyTranslation = translatedFromKey !== langKey;
const languageOptions = languages.map(lang => {
// TODO: Use actual language translations when they become available
// For now, use temporary English translations
const translatedName = tempOcrLanguages.lang[lang as keyof typeof tempOcrLanguages.lang] || lang;
const displayName = translatedName;
const intlTranslatedName = displayNames
? browserLanguageCodes
.map((code) => displayNames.of(code))
.find((name): name is string => Boolean(name))
: null;
const translatedName =
(hasKeyTranslation ? translatedFromKey : null)
|| intlTranslatedName
|| t(`ocr.languages.${lang}`, displayName);
return {
value: lang,
name: displayName
name: translatedName,
label: translatedName
};
});
})
.sort((a, b) => a.name.localeCompare(b.name, i18n.language));
setAvailableLanguages(languageOptions);
} else {
console.error('[LanguagePicker] Response not OK:', response.status, response.statusText);
const errorText = await response.text();
console.error('[LanguagePicker] Error response body:', errorText);
}
setAvailableLanguages(languageOptions);
} catch (error) {
console.error('[LanguagePicker] Fetch failed with error:', error);
console.error('[LanguagePicker] Error details:', {
@@ -76,7 +86,7 @@ const LanguagePicker: React.FC<LanguagePickerProps> = ({
};
fetchLanguages();
}, [languagesEndpoint]);
}, [languagesEndpoint, i18n.language, t]);
// Auto-fill OCR language based on browser language when languages are loaded
useEffect(() => {
@@ -1,13 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ActionIcon, Alert, Badge, Box, Card, Group, Stack, Text, TextInput, Tooltip } from '@mantine/core';
import { LocalIcon } from '@app/components/shared/LocalIcon';
import { MAX_SAVED_SIGNATURES, SavedSignature, SavedSignatureType } from '@app/hooks/tools/sign/useSavedSignatures';
import type { StorageType } from '@app/services/signatureStorageService';
interface SavedSignaturesSectionProps {
signatures: SavedSignature[];
disabled?: boolean;
isAtCapacity: boolean;
storageType?: StorageType | null;
onUseSignature: (signature: SavedSignature) => void;
onDeleteSignature: (signature: SavedSignature) => void;
onRenameSignature: (id: string, label: string) => void;
@@ -24,6 +26,7 @@ export const SavedSignaturesSection = ({
signatures,
disabled = false,
isAtCapacity,
storageType: _storageType,
onUseSignature,
onDeleteSignature,
onRenameSignature,
@@ -36,20 +39,30 @@ export const SavedSignaturesSection = ({
[t, translationScope]
);
const [labelDrafts, setLabelDrafts] = useState<Record<string, string>>({});
const [activeIndex, setActiveIndex] = useState(0);
const activeSignature = signatures[activeIndex];
const activeSignatureRef = useRef<SavedSignature | null>(activeSignature ?? null);
const appliedSignatureIdRef = useRef<string | null>(null);
// Group signatures by scope
const groupedSignatures = useMemo(() => {
const personal = signatures.filter(sig => sig.scope === 'personal');
const shared = signatures.filter(sig => sig.scope === 'shared');
const localStorage = signatures.filter(sig => sig.scope === 'localStorage');
return { personal, shared, localStorage };
}, [signatures]);
// Separate carousel state for each category
const [activePersonalIndex, setActivePersonalIndex] = useState(0);
const [activeSharedIndex, setActiveSharedIndex] = useState(0);
const [activeLocalStorageIndex, setActiveLocalStorageIndex] = useState(0);
const activePersonalSignature = groupedSignatures.personal[activePersonalIndex];
const activeSharedSignature = groupedSignatures.shared[activeSharedIndex];
const activeLocalStorageSignature = groupedSignatures.localStorage[activeLocalStorageIndex];
const onUseSignatureRef = useRef(onUseSignature);
useEffect(() => {
onUseSignatureRef.current = onUseSignature;
}, [onUseSignature]);
useEffect(() => {
activeSignatureRef.current = activeSignature ?? null;
}, [activeSignature]);
useEffect(() => {
setLabelDrafts(prev => {
const nextDrafts: Record<string, string> = {};
@@ -60,25 +73,18 @@ export const SavedSignaturesSection = ({
});
}, [signatures]);
// Reset carousel indices when categories change
useEffect(() => {
if (signatures.length === 0) {
setActiveIndex(0);
return;
}
setActiveIndex(prev => Math.min(prev, Math.max(signatures.length - 1, 0)));
}, [signatures.length]);
setActivePersonalIndex(prev => Math.min(prev, Math.max(groupedSignatures.personal.length - 1, 0)));
}, [groupedSignatures.personal.length]);
const handleNavigate = useCallback(
(direction: 'prev' | 'next') => {
setActiveIndex(prev => {
if (direction === 'prev') {
return Math.max(0, prev - 1);
}
return Math.min(signatures.length - 1, prev + 1);
});
},
[signatures.length]
);
useEffect(() => {
setActiveSharedIndex(prev => Math.min(prev, Math.max(groupedSignatures.shared.length - 1, 0)));
}, [groupedSignatures.shared.length]);
useEffect(() => {
setActiveLocalStorageIndex(prev => Math.min(prev, Math.max(groupedSignatures.localStorage.length - 1, 0)));
}, [groupedSignatures.localStorage.length]);
const renderPreview = (signature: SavedSignature) => {
if (signature.type === 'text') {
@@ -193,21 +199,6 @@ export const SavedSignaturesSection = ({
}
};
useEffect(() => {
const signature = activeSignatureRef.current;
if (!signature || disabled) {
appliedSignatureIdRef.current = null;
return;
}
if (appliedSignatureIdRef.current === signature.id) {
return;
}
appliedSignatureIdRef.current = signature.id;
onUseSignatureRef.current(signature);
}, [activeSignature?.id, disabled]);
return (
<Stack gap="sm">
<Group justify="space-between" align="flex-start">
@@ -234,67 +225,253 @@ export const SavedSignaturesSection = ({
{signatures.length === 0 ? (
emptyState
) : (
<Stack gap="xs">
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeIndex + 1,
total: signatures.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => handleNavigate('prev')}
disabled={disabled || activeIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => handleNavigate('next')}
disabled={disabled || activeIndex >= signatures.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
<Stack gap="md">
{/* Personal Signatures */}
{groupedSignatures.personal.length > 0 && activePersonalSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:person-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.personalHeading', 'Personal Signatures')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{translate('saved.personalDescription', 'Only you can see these signatures.')}
</Text>
{activeSignature && (
<Card withBorder padding="sm" key={activeSignature.id}>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeSignature.type]} variant="light">
{typeLabel(activeSignature.type)}
</Badge>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activePersonalIndex + 1,
total: groupedSignatures.personal.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => setActivePersonalIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activePersonalIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => setActivePersonalIndex(prev => Math.min(groupedSignatures.personal.length - 1, prev + 1))}
disabled={disabled || activePersonalIndex >= groupedSignatures.personal.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
{renderPreview(activeSignature)}
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activePersonalSignature.type]} variant="light">
{typeLabel(activePersonalSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
onClick={() => onUseSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activePersonalSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activePersonalSignature)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activePersonalSignature.id] ?? activePersonalSignature.label}
onChange={event => handleLabelChange(event, activePersonalSignature)}
onBlur={() => handleLabelBlur(activePersonalSignature)}
onKeyDown={event => handleLabelKeyDown(event, activePersonalSignature)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activeSignature.id] ?? activeSignature.label}
onChange={event => handleLabelChange(event, activeSignature)}
onBlur={() => handleLabelBlur(activeSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeSignature)}
disabled={disabled}
/>
{/* Shared Signatures */}
{groupedSignatures.shared.length > 0 && activeSharedSignature && (
<Stack gap="xs">
<Group gap="xs">
<LocalIcon icon="material-symbols:groups-rounded" width={18} height={18} />
<Text fw={600} size="sm">
{translate('saved.sharedHeading', 'Shared Signatures')}
</Text>
</Group>
<Text size="xs" c="dimmed">
{translate('saved.sharedDescription', 'All users can see and use these signatures.')}
</Text>
</Stack>
</Card>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeSharedIndex + 1,
total: groupedSignatures.shared.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => setActiveSharedIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeSharedIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => setActiveSharedIndex(prev => Math.min(groupedSignatures.shared.length - 1, prev + 1))}
disabled={disabled || activeSharedIndex >= groupedSignatures.shared.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeSharedSignature.type]} variant="light">
{typeLabel(activeSharedSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
onClick={() => onUseSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeSharedSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activeSharedSignature)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activeSharedSignature.id] ?? activeSharedSignature.label}
onChange={event => handleLabelChange(event, activeSharedSignature)}
onBlur={() => handleLabelBlur(activeSharedSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeSharedSignature)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
{/* Browser Storage (localStorage) - Temporary */}
{groupedSignatures.localStorage.length > 0 && activeLocalStorageSignature && (
<Stack gap="xs">
<Alert color="blue" title={translate('saved.tempStorageTitle', 'Temporary browser storage')}>
<Text size="xs">
{translate(
'saved.tempStorageDescription',
'Signatures are stored in your browser only. They will be lost if you clear browser data or switch browsers.'
)}
</Text>
</Alert>
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{translate('saved.carouselPosition', '{{current}} of {{total}}', {
current: activeLocalStorageIndex + 1,
total: groupedSignatures.localStorage.length,
})}
</Text>
<Group gap={4}>
<ActionIcon
variant="light"
aria-label={translate('saved.prev', 'Previous')}
onClick={() => setActiveLocalStorageIndex(prev => Math.max(0, prev - 1))}
disabled={disabled || activeLocalStorageIndex === 0}
>
<LocalIcon icon="material-symbols:chevron-left-rounded" width={18} height={18} />
</ActionIcon>
<ActionIcon
variant="light"
aria-label={translate('saved.next', 'Next')}
onClick={() => setActiveLocalStorageIndex(prev => Math.min(groupedSignatures.localStorage.length - 1, prev + 1))}
disabled={disabled || activeLocalStorageIndex >= groupedSignatures.localStorage.length - 1}
>
<LocalIcon icon="material-symbols:chevron-right-rounded" width={18} height={18} />
</ActionIcon>
</Group>
</Group>
<Card withBorder padding="sm">
<Stack gap="sm">
<Group justify="space-between" align="center">
<Badge color={typeBadgeColor[activeLocalStorageSignature.type]} variant="light">
{typeLabel(activeLocalStorageSignature.type)}
</Badge>
<Group gap="xs">
<ActionIcon
variant="subtle"
color="blue"
aria-label="Use signature"
onClick={() => onUseSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:check-circle-outline-rounded" width={18} height={18} />
</ActionIcon>
<Tooltip label={translate('saved.delete', 'Remove')}>
<ActionIcon
variant="subtle"
color="red"
aria-label={translate('saved.delete', 'Remove')}
onClick={() => onDeleteSignature(activeLocalStorageSignature)}
disabled={disabled}
>
<LocalIcon icon="material-symbols:delete-outline-rounded" width={18} height={18} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
{renderPreview(activeLocalStorageSignature)}
<TextInput
label={translate('saved.label', 'Label')}
value={labelDrafts[activeLocalStorageSignature.id] ?? activeLocalStorageSignature.label}
onChange={event => handleLabelChange(event, activeLocalStorageSignature)}
onBlur={() => handleLabelBlur(activeLocalStorageSignature)}
onKeyDown={event => handleLabelKeyDown(event, activeLocalStorageSignature)}
disabled={disabled}
/>
</Stack>
</Card>
</Stack>
)}
</Stack>
)}
@@ -16,6 +16,7 @@ import { ColorPicker } from "@app/components/annotation/shared/ColorPicker";
import { LocalIcon } from "@app/components/shared/LocalIcon";
import { useSavedSignatures, SavedSignature, SavedSignaturePayload, SavedSignatureType, MAX_SAVED_SIGNATURES, AddSignatureResult } from '@app/hooks/tools/sign/useSavedSignatures';
import { SavedSignaturesSection } from '@app/components/tools/sign/SavedSignaturesSection';
import { buildSignaturePreview } from '@app/utils/signaturePreview';
type SignatureDrafts = {
canvas?: string;
@@ -99,6 +100,7 @@ const SignSettings = ({
removeSignature,
updateSignatureLabel,
byTypeCounts,
storageType,
} = useSavedSignatures();
const [signatureSource, setSignatureSource] = useState<SignatureSource>(() => {
const paramSource = parameters.signatureType as SignatureSource;
@@ -157,11 +159,11 @@ const SignSettings = ({
}, [canvasSignatureData, imageSignatureData, buildTextSignatureKey, parameters.signerName, parameters.fontSize, parameters.fontFamily, parameters.textColor]);
const saveSignatureToLibrary = useCallback(
(payload: SavedSignaturePayload, type: SavedSignatureType): AddSignatureResult => {
async (payload: SavedSignaturePayload, type: SavedSignatureType, scope: 'personal' | 'shared'): Promise<AddSignatureResult> => {
if (isSavedSignatureLimitReached) {
return { success: false, reason: 'limit' };
}
return addSignature(payload, getDefaultSavedLabel(type));
return await addSignature(payload, getDefaultSavedLabel(type), scope);
},
[addSignature, getDefaultSavedLabel, isSavedSignatureLimitReached]
);
@@ -176,40 +178,57 @@ const SignSettings = ({
[signatureKeysByType]
);
const handleSaveCanvasSignature = useCallback(() => {
const handleSaveCanvasSignature = useCallback(async (scope: 'personal' | 'shared') => {
if (!canvasSignatureData) {
return;
}
const result = saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas');
const result = await saveSignatureToLibrary({ type: 'canvas', dataUrl: canvasSignatureData }, 'canvas', scope);
if (result.success) {
setLastSavedKeyForType('canvas');
}
}, [canvasSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
const handleSaveImageSignature = useCallback(() => {
const handleSaveImageSignature = useCallback(async (scope: 'personal' | 'shared') => {
if (!imageSignatureData) {
return;
}
const result = saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image');
const result = await saveSignatureToLibrary({ type: 'image', dataUrl: imageSignatureData }, 'image', scope);
if (result.success) {
setLastSavedKeyForType('image');
}
}, [imageSignatureData, saveSignatureToLibrary, setLastSavedKeyForType]);
const handleSaveTextSignature = useCallback(() => {
const handleSaveTextSignature = useCallback(async (scope: 'personal' | 'shared') => {
const signerName = (parameters.signerName ?? '').trim();
if (!signerName) {
return;
}
const result = saveSignatureToLibrary(
// Generate image from text signature
const preview = await buildSignaturePreview({
signatureType: 'text',
signerName,
fontFamily: parameters.fontFamily ?? 'Helvetica',
fontSize: parameters.fontSize ?? 16,
textColor: parameters.textColor ?? '#000000',
});
if (!preview?.dataUrl) {
console.error('Failed to generate text signature preview');
return;
}
const result = await saveSignatureToLibrary(
{
type: 'text',
dataUrl: preview.dataUrl,
signerName,
fontFamily: parameters.fontFamily ?? 'Helvetica',
fontSize: parameters.fontSize ?? 16,
textColor: parameters.textColor ?? '#000000',
},
'text'
'text',
scope
);
if (result.success) {
setLastSavedKeyForType('text');
@@ -286,16 +305,21 @@ const SignSettings = ({
[updateSignatureLabel]
);
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
const renderSaveButton = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void, scope: 'personal' | 'shared', icon: string, label: string) => {
if (!canUseSavedLibrary) {
return null;
}
const label = translate('saved.saveButton', 'Save signature');
const currentKey = signatureKeysByType[type];
const lastSavedKey = lastSavedSignatureKeys[type];
const hasChanges = Boolean(currentKey && currentKey !== lastSavedKey);
const isSaved = isReady && !hasChanges;
// Only show backend storage buttons when backend is available
const showButton = storageType === 'backend' || storageType === null;
if (!showButton) {
return null;
}
let tooltipMessage: string | undefined;
if (!isReady) {
tooltipMessage = translate('saved.saveUnavailable', 'Create a signature first to save it.');
@@ -312,11 +336,11 @@ const SignSettings = ({
size="xs"
variant="outline"
color={isSaved ? 'green' : undefined}
onClick={onClick}
onClick={() => onClick(scope)}
disabled={!isReady || disabled || isSavedSignatureLimitReached || !hasChanges}
leftSection={<LocalIcon icon="material-symbols:save-rounded" width={16} height={16} />}
leftSection={<LocalIcon icon={icon} width={16} height={16} />}
>
{isSaved ? translate('saved.status.saved', 'Saved') : label}
{label}
</Button>
);
@@ -331,15 +355,38 @@ const SignSettings = ({
return button;
};
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: () => void) => {
const button = renderSaveButton(type, isReady, onClick);
if (!button) {
const renderSaveButtonRow = (type: SavedSignatureType, isReady: boolean, onClick: (scope: 'personal' | 'shared') => void) => {
if (!canUseSavedLibrary) {
return null;
}
const personalButton = renderSaveButton(
type,
isReady,
onClick,
'personal',
'material-symbols:person-rounded',
translate('saved.savePersonal', 'Save Personal')
);
const sharedButton = renderSaveButton(
type,
isReady,
onClick,
'shared',
'material-symbols:groups-rounded',
translate('saved.saveShared', 'Save Shared')
);
if (!personalButton && !sharedButton) {
return null;
}
return (
<Box style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{button}
</Box>
<Group gap="xs" style={{ alignSelf: 'flex-start', marginTop: '0.4rem' }}>
{personalButton}
{sharedButton}
</Group>
);
};
@@ -744,6 +791,7 @@ const SignSettings = ({
signatures={savedSignatures}
disabled={disabled}
isAtCapacity={isSavedSignatureLimitReached}
storageType={storageType}
onUseSignature={handleUseSavedSignature}
onDeleteSignature={handleDeleteSavedSignature}
onRenameSignature={handleRenameSavedSignature}
@@ -0,0 +1,244 @@
import React, { useEffect, useState, useMemo, useCallback } from 'react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useScroll } from '@embedpdf/plugin-scroll/react';
enum PDFActionType {
GoTo = 0,
GoToR = 1,
GoToE = 2,
URI = 3,
// Add other types as needed
}
interface PDFRect {
origin: { x: number; y: number };
size: { width: number; height: number };
}
interface PDFDestination {
pageIndex: number;
view: [number, number];
}
interface PDFAction {
type: string | number;
destination?: PDFDestination;
uri?: string;
}
interface LinkAnnotation {
id: string;
type: number;
rect: PDFRect;
target?: {
type: string;
action?: PDFAction;
destination?: PDFDestination;
uri?: string;
};
}
function isGoToAction(action: PDFAction): boolean {
return action.type === 'GoTo' || action.type === PDFActionType.GoTo;
}
function isURIAction(action: PDFAction): boolean {
return action.type === 'URI' || action.type === PDFActionType.URI;
}
function isInternalLink(link: LinkAnnotation): boolean {
return Boolean(link.target?.type === 'destination' ||
(link.target?.type === 'action' && link.target.action && isGoToAction(link.target.action)));
}
function isExternalLink(link: LinkAnnotation): boolean {
return Boolean(link.target?.type === 'uri' ||
(link.target?.type === 'action' && link.target.action && isURIAction(link.target.action)));
}
interface LinkLayerProps {
pageIndex: number;
scale: number;
document?: any;
pdfFile?: File | Blob;
onLinkClick?: (target: any) => void;
}
const getLinkTitle = (link: LinkAnnotation): string => {
if (link.target?.type === 'destination') {
return `Go to page ${(link.target.destination?.pageIndex ?? 0) + 1}`;
}
if (link.target?.type === 'action' && link.target.action?.type === 'GoTo') {
return `Go to page ${(link.target.action.destination?.pageIndex ?? 0) + 1}`;
}
if (link.target?.type === 'action' && (link.target.action?.type === 'URI' || link.target.action?.type === 3)) {
return `Open link: ${link.target.action.uri}`;
}
if (link.target?.uri) {
return `Open link: ${link.target.uri}`;
}
return 'Link';
};
const getLinkAriaLabel = (link: LinkAnnotation): string => {
if (link.target?.type === 'destination') {
return `Navigate to page ${(link.target.destination?.pageIndex ?? 0) + 1}`;
}
if (link.target?.type === 'action' && link.target.action?.type === 'GoTo') {
return `Navigate to page ${(link.target.action.destination?.pageIndex ?? 0) + 1}`;
}
if (link.target?.type === 'action' && (link.target.action?.type === 'URI' || link.target.action?.type === 3)) {
return 'Open external link';
}
return 'Open external link';
};
export const LinkLayer: React.FC<LinkLayerProps> = ({
pageIndex,
scale,
document: pdfDocument,
onLinkClick
}) => {
const { provides: annotation } = useAnnotationCapability();
const { provides: scroll } = useScroll();
const [links, setLinks] = useState<LinkAnnotation[]>([]);
const [isNavigating, setIsNavigating] = useState(false);
const processedLinks = useMemo(() => {
return links.map(link => ({
...link,
scaledRect: {
left: link.rect.origin.x * scale,
top: link.rect.origin.y * scale,
width: link.rect.size.width * scale,
height: link.rect.size.height * scale,
}
}));
}, [links, scale]);
useEffect(() => {
const fetchLinks = async () => {
if (!annotation) return;
try {
// Use the annotation API's built-in filtering if available
const pageAnnotations = await annotation
.getPageAnnotations({
pageIndex,
// Try to filter for link annotations (type 2) if the API supports it
...(annotation.getPageAnnotations.length > 1 ? { types: [2] } : {})
})
.toPromise();
// Filter for link annotations (type 2 is LINK in PDF spec) as fallback
const linkAnnotations = pageAnnotations.filter(
(ann: any) => ann.type === 2
) as LinkAnnotation[];
if (linkAnnotations.length > 0) {
setLinks(linkAnnotations);
return;
}
} catch (error) {
console.error('[LinkLayer] Failed to fetch links from annotation API:', error);
}
if (pdfDocument) {
try {
// Try different methods to get link annotations
let pdfLinks: any[] = [];
if (pdfDocument.getPageAnnotations && typeof pdfDocument.getPageAnnotations === 'function') {
pdfLinks = await pdfDocument.getPageAnnotations(pageIndex);
} else if (pdfDocument.getAnnotations && typeof pdfDocument.getAnnotations === 'function') {
const allAnnotations = await pdfDocument.getAnnotations();
pdfLinks = allAnnotations.filter((ann: any) => ann.pageIndex === pageIndex && ann.type === 2);
} else if (pdfDocument.pages && pdfDocument.pages[pageIndex]) {
const page = pdfDocument.pages[pageIndex];
if (page.getAnnotations && typeof page.getAnnotations === 'function') {
pdfLinks = await page.getAnnotations();
}
}
const convertedLinks = pdfLinks.map((ann: any) => ({
id: ann.id || `pdf-link-${pageIndex}-${Math.random()}`,
type: ann.type || 2,
rect: ann.rect || ann,
target: ann.target || ann.action
})) as LinkAnnotation[];
setLinks(convertedLinks);
} catch (error) {
console.warn('[LinkLayer] Failed to get annotations from PDF document:', error);
}
} else {
console.warn('[LinkLayer] No annotation API or PDF document available');
}
};
fetchLinks();
}, [annotation, pageIndex, pdfDocument]);
const handleLinkClick = useCallback(async (link: LinkAnnotation) => {
if (isNavigating) return; // Prevent multiple simultaneous navigations
try {
setIsNavigating(true);
if (onLinkClick) {
onLinkClick(link.target);
return;
}
if (isInternalLink(link)) {
const targetPage = link.target?.destination?.pageIndex ??
link.target?.action?.destination?.pageIndex;
if (targetPage !== undefined && scroll) {
await scroll.scrollToPage({
pageNumber: targetPage + 1, // PDF pages are 1-indexed
behavior: 'smooth',
});
}
} else if (isExternalLink(link)) {
const uri = link.target?.uri ?? link.target?.action?.uri;
if (uri) {
window.open(uri, '_blank', 'noopener,noreferrer');
}
} else {
throw new Error(`Unsupported link type: ${link.target?.type}`);
}
} catch (error) {
console.error('[LinkLayer] Navigation failed:', error);
} finally {
setIsNavigating(false);
}
}, [isNavigating, onLinkClick, scroll]);
return (
<div className="absolute inset-0 pointer-events-none">
{processedLinks.map((link) => {
const { id } = link;
const { left, top, width, height } = link.scaledRect;
return (
<button
key={id}
onClick={() => handleLinkClick(link)}
disabled={isNavigating}
className={`absolute opacity-0 hover:opacity-20 bg-blue-500 transition-opacity cursor-pointer pointer-events-auto border border-blue-400 hover:border-blue-600 ${isNavigating ? 'cursor-not-allowed opacity-50' : ''}`}
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
minWidth: '8px',
minHeight: '8px',
}}
title={getLinkTitle(link)}
aria-label={getLinkAriaLabel(link)}
/>
);
})}
</div>
);
};
@@ -43,6 +43,7 @@ import { ExportAPIBridge } from '@app/components/viewer/ExportAPIBridge';
import { BookmarkAPIBridge } from '@app/components/viewer/BookmarkAPIBridge';
import { isPdfFile } from '@app/utils/fileUtils';
import { useTranslation } from 'react-i18next';
import { LinkLayer } from '@app/components/viewer/LinkLayer';
interface LocalEmbedPDFProps {
file?: File | Blob;
@@ -103,15 +104,17 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
createPluginRegistration(SelectionPluginPackage),
// Register history plugin for undo/redo (recommended for annotations)
...(enableAnnotations ? [createPluginRegistration(HistoryPluginPackage)] : []),
// Always register for reading existing annotations
createPluginRegistration(HistoryPluginPackage),
// Register annotation plugin (depends on InteractionManager, Selection, History)
...(enableAnnotations ? [createPluginRegistration(AnnotationPluginPackage, {
// Always register for reading existing annotations like links
createPluginRegistration(AnnotationPluginPackage, {
annotationAuthor: 'Digital Signature',
autoCommit: true,
deactivateToolAfterCreate: false,
selectAfterCreate: true,
})] : []),
}),
// Register pan plugin (depends on Viewport, InteractionManager)
createPluginRegistration(PanPluginPackage, {
@@ -229,68 +232,62 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
<EmbedPDF
engine={engine}
plugins={plugins}
onInitialized={enableAnnotations ? async (registry) => {
onInitialized={async (registry) => {
const annotationPlugin = registry.getPlugin('annotation');
if (!annotationPlugin || !annotationPlugin.provides) return;
const annotationApi = annotationPlugin.provides();
if (!annotationApi) return;
// Add custom signature stamp tool for image signatures
annotationApi.addTool({
id: 'signatureStamp',
name: 'Digital Signature',
interaction: { exclusive: false, cursor: 'copy' },
matchScore: () => 0,
defaults: {
type: PdfAnnotationSubtype.STAMP,
// Image will be set dynamically when signature is created
},
});
if (enableAnnotations) {
annotationApi.addTool({
id: 'signatureStamp',
name: 'Digital Signature',
interaction: { exclusive: false, cursor: 'copy' },
matchScore: () => 0,
defaults: {
type: PdfAnnotationSubtype.STAMP,
},
});
// Add custom ink signature tool for drawn signatures
annotationApi.addTool({
id: 'signatureInk',
name: 'Signature Draw',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: () => 0,
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#000000',
opacity: 1.0,
borderWidth: 2,
},
});
annotationApi.addTool({
id: 'signatureInk',
name: 'Signature Draw',
interaction: { exclusive: true, cursor: 'crosshair' },
matchScore: () => 0,
defaults: {
type: PdfAnnotationSubtype.INK,
color: '#000000',
opacity: 1.0,
borderWidth: 2,
},
});
// Listen for annotation events to track annotations and notify parent
annotationApi.onAnnotationEvent((event: any) => {
if (event.type === 'create' && event.committed) {
// Add to annotations list
setAnnotations(prev => [...prev, {
id: event.annotation.id,
pageIndex: event.pageIndex,
rect: event.annotation.rect
}]);
annotationApi.onAnnotationEvent((event: any) => {
if (event.type === 'create' && event.committed) {
setAnnotations(prev => [...prev, {
id: event.annotation.id,
pageIndex: event.pageIndex,
rect: event.annotation.rect
}]);
// Notify parent if callback provided
if (onSignatureAdded) {
onSignatureAdded(event.annotation);
if (onSignatureAdded) {
onSignatureAdded(event.annotation);
}
} else if (event.type === 'delete' && event.committed) {
setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id));
} else if (event.type === 'loaded') {
const loadedAnnotations = event.annotations || [];
setAnnotations(loadedAnnotations.map((ann: any) => ({
id: ann.id,
pageIndex: ann.pageIndex || 0,
rect: ann.rect
})));
}
} else if (event.type === 'delete' && event.committed) {
// Remove from annotations list
setAnnotations(prev => prev.filter(ann => ann.id !== event.annotation.id));
} else if (event.type === 'loaded') {
// Handle initial load of annotations
const loadedAnnotations = event.annotations || [];
setAnnotations(loadedAnnotations.map((ann: any) => ({
id: ann.id,
pageIndex: ann.pageIndex || 0,
rect: ann.rect
})));
}
});
} : undefined}
});
}
}}
>
<ZoomAPIBridge />
<ScrollAPIBridge />
@@ -352,6 +349,10 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
{/* Selection layer for text interaction */}
<SelectionLayer pageIndex={pageIndex} scale={scale} />
{/* Link layer for clickable PDF links */}
<LinkLayer pageIndex={pageIndex} scale={scale} document={document} pdfFile={file} />
{/* Annotation layer for signatures (only when enabled) */}
{enableAnnotations && (
<AnnotationLayer
@@ -122,7 +122,7 @@ const createTextStampImage = (
export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPIBridge(_, ref) {
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize } = useSignature();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize, setSignaturesApplied } = useSignature();
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
const lastStampImageRef = useRef<string | null>(null);
@@ -389,6 +389,11 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return;
}
// Mark signatures as not applied when a new signature is placed
if (event.type === 'create') {
setSignaturesApplied(false);
}
const directData =
extractDataUrl(annotation.imageSrc) ||
extractDataUrl(annotation.imageData) ||
@@ -408,7 +413,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData]);
}, [annotationApi, storeImageData, setSignaturesApplied]);
useEffect(() => {
if (!isPlacementMode) {
@@ -443,6 +448,11 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return;
}
// Mark signatures as not applied when a new signature is placed
if (event.type === 'create') {
setSignaturesApplied(false);
}
const directData =
extractDataUrl(annotation.imageSrc) ||
extractDataUrl(annotation.imageData) ||
@@ -462,7 +472,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData]);
}, [annotationApi, storeImageData, setSignaturesApplied]);
useEffect(() => {
if (!isPlacementMode) {
@@ -7,12 +7,16 @@ import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { SearchInterface } from '@app/components/viewer/SearchInterface';
import ViewerAnnotationControls from '@app/components/shared/rightRail/ViewerAnnotationControls';
import { useFileState } from '@app/contexts/FileContext';
import { useSidebarContext } from '@app/contexts/SidebarContext';
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
export function useViewerRightRailButtons() {
const { t, i18n } = useTranslation();
const viewer = useViewer();
const { selectors } = useFileState();
const filesSignature = selectors.getFilesSignature();
const files = useMemo(() => selectors.getFiles(), [selectors, filesSignature]);
const [isPanning, setIsPanning] = useState<boolean>(() => viewer.getPanState()?.isPanning ?? false);
const { sidebarRefs } = useSidebarContext();
const { position: tooltipPosition } = useRightRailTooltipSide(sidebarRefs, 12);
@@ -136,7 +140,19 @@ export function useViewerRightRailButtons() {
)
}
];
}, [t, i18n.language, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel, bookmarkLabel, tooltipPosition]);
}, [
t,
i18n.language,
viewer,
isPanning,
searchLabel,
panLabel,
rotateLeftLabel,
rotateRightLabel,
sidebarLabel,
bookmarkLabel,
tooltipPosition,
]);
useRightRailButtons(viewerButtons);
}
@@ -96,6 +96,34 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
const initialDelay = retryOptions?.initialDelay ?? 1000;
const fetchConfig = useCallback(async (force = false) => {
// First check if user has a JWT token - if not, they're not authenticated
const hasJWT = localStorage.getItem('stirling_jwt');
// Check if on auth page
// Need to check for paths with or without base path
const pathname = window.location.pathname;
const isAuthPage = pathname.endsWith('/login') ||
pathname.endsWith('/signup') ||
pathname.endsWith('/auth/callback') ||
pathname.includes('/auth/') ||
pathname.includes('/invite/');
// Skip config fetch if:
// 1. On auth page, OR
// 2. No JWT token (not authenticated) and not forcing
if (isAuthPage || (!hasJWT && !force)) {
console.debug('[AppConfig] Skipping config fetch:', {
reason: isAuthPage ? 'On auth page' : 'No JWT token',
pathname,
hasJWT: !!hasJWT,
force
});
setLoading(false);
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
return;
}
// Prevent duplicate fetches unless forced
if (!force && fetchCountRef.current > 0) {
console.debug('[AppConfig] Already fetched, skipping');
@@ -129,6 +157,16 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
console.log('[AppConfig] Fetching app config...');
}
// GUARD: Only make the API call if user has JWT token
const currentJWT = localStorage.getItem('stirling_jwt');
if (!currentJWT && !force) {
console.debug('[AppConfig] No JWT token, skipping API call entirely');
setConfig({ enableLogin: true });
setHasResolvedConfig(true);
setLoading(false);
return;
}
// apiClient automatically adds JWT header if available via interceptors
// Always suppress error toast - we handle 401 errors locally
const response = await apiClient.get<AppConfig>(
@@ -203,7 +241,7 @@ export const AppConfigProvider: React.FC<AppConfigProviderProps> = ({
if (autoFetch) {
fetchConfig();
}
}, [autoFetch, fetchConfig]);
}, [autoFetch]);
// Listen for JWT availability (triggered on login/signup)
useEffect(() => {
@@ -0,0 +1,281 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from 'react';
import { useFileState } from '@app/contexts/FileContext';
import type { StirlingFile } from '@app/types/fileContext';
import { extractTextFromPdf } from '@app/services/pdfTextExtractor';
import { extractTextFromDocx } from '@app/services/docxTextExtractor';
import {
ChatbotSessionInfo,
createChatbotSession,
} from '@app/services/chatbotService';
import { runOcrForChat } from '@app/services/chatbotOcrService';
type ChatbotSource = 'viewer' | 'tool';
interface OpenChatOptions {
source?: ChatbotSource;
fileId?: string;
}
type PreparationStatus = 'idle' | 'processing' | 'ready' | 'error' | 'unsupported';
interface PreparedChatbotDocument {
documentId: string;
fileId: string;
fileName: string;
status: PreparationStatus;
session?: ChatbotSessionInfo;
characterCount?: number;
pageCount?: number;
warnings?: string[];
error?: string;
}
interface PreprocessOptions {
force?: boolean;
forceOcr?: boolean;
}
interface ChatbotContextValue {
isOpen: boolean;
source: ChatbotSource;
preferredFileId?: string;
openChat: (options?: OpenChatOptions) => void;
closeChat: () => void;
setPreferredFileId: (fileId?: string) => void;
sessions: Record<string, PreparedChatbotDocument>;
requestPreprocessing: (fileId: string, options?: PreprocessOptions) => Promise<void>;
}
const ChatbotContext = createContext<ChatbotContextValue | undefined>(undefined);
export function ChatbotProvider({ children }: { children: ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const [source, setSource] = useState<ChatbotSource>('viewer');
const [preferredFileId, setPreferredFileId] = useState<string | undefined>();
const { selectors } = useFileState();
const [preparedSessions, setPreparedSessions] = useState<
Record<string, PreparedChatbotDocument>
>({});
const sessionsRef = useRef(preparedSessions);
sessionsRef.current = preparedSessions;
const inFlightRef = useRef<Map<string, Promise<void>>>(new Map());
const supportedExtensions = useMemo(
() => new Set(['pdf', 'doc', 'docx']),
[]
);
const getExtension = useCallback((file: StirlingFile) => {
const parts = file.name.split('.');
return parts.length > 1 ? parts.at(-1)!.toLowerCase() : '';
}, []);
const updateSessionEntry = useCallback((file: StirlingFile, partial: Partial<PreparedChatbotDocument>) => {
setPreparedSessions((prev) => ({
...prev,
[file.fileId]: {
...prev[file.fileId],
documentId: file.fileId,
fileId: file.fileId,
fileName: file.name,
status: 'idle',
...partial,
},
}));
}, []);
const preprocessFile = useCallback(
async (file: StirlingFile, options?: PreprocessOptions) => {
const extension = getExtension(file);
if (!supportedExtensions.has(extension)) {
updateSessionEntry(file, {
status: 'unsupported',
error: 'Only PDF and Word documents are indexed for chat.',
});
return;
}
if (extension === 'doc') {
updateSessionEntry(file, {
status: 'unsupported',
error: 'Legacy Word (.doc) files are not supported yet.',
});
return;
}
updateSessionEntry(file, {
status: 'processing',
error: undefined,
session: undefined,
warnings: undefined,
characterCount: undefined,
pageCount: undefined,
});
try {
let workingFile: File = file;
const shouldRunOcr = Boolean(options?.forceOcr && extension === 'pdf');
if (shouldRunOcr) {
workingFile = await runOcrForChat(file);
}
let extracted: { text: string; pageCount?: number; characterCount: number };
if (extension === 'pdf') {
const pdfResult = await extractTextFromPdf(workingFile);
extracted = {
text: pdfResult.text,
pageCount: pdfResult.pageCount,
characterCount: pdfResult.characterCount,
};
} else {
const docxResult = await extractTextFromDocx(workingFile);
extracted = {
text: docxResult.text,
pageCount: 0,
characterCount: docxResult.characterCount,
};
}
if (!extracted.text || extracted.text.trim().length === 0) {
throw new Error(
'No text detected. Try running OCR from the chat window.'
);
}
const metadata: Record<string, string> = {
fileName: workingFile.name,
fileSize: String(workingFile.size),
fileType: workingFile.type || extension,
characterCount: String(extracted.characterCount),
ocrApplied: shouldRunOcr ? 'true' : 'false',
};
if (typeof extracted.pageCount === 'number') {
metadata.pageCount = String(extracted.pageCount);
}
const session = await createChatbotSession({
sessionId: file.fileId,
documentId: file.fileId,
text: extracted.text,
metadata,
ocrRequested: shouldRunOcr,
warningsAccepted: true,
});
updateSessionEntry(file, {
status: 'ready',
session,
characterCount: extracted.characterCount,
pageCount: extracted.pageCount,
warnings: session.warnings ?? [],
error: undefined,
});
} catch (error) {
const message =
error instanceof Error
? error.message
: 'Failed to prepare document for chatbot.';
updateSessionEntry(file, {
status: 'error',
error: message,
});
throw error;
}
},
[getExtension, supportedExtensions, updateSessionEntry]
);
const requestPreprocessing = useCallback(
async (fileId: string, options?: PreprocessOptions) => {
const file = selectors.getFile(fileId as any);
if (!file) {
return;
}
if (inFlightRef.current.has(fileId) && !options?.force) {
return inFlightRef.current.get(fileId);
}
const promise = preprocessFile(file, options)
.finally(() => {
inFlightRef.current.delete(fileId);
});
inFlightRef.current.set(fileId, promise);
return promise;
},
[selectors, preprocessFile]
);
const filesSignature = selectors.getFilesSignature();
const availableFiles = useMemo(
() => selectors.getFiles(),
[filesSignature, selectors]
);
useEffect(() => {
availableFiles.forEach((file) => {
if (!supportedExtensions.has(getExtension(file))) {
return;
}
if (!sessionsRef.current[file.fileId]) {
requestPreprocessing(file.fileId).catch(() => {});
}
});
const currentIds = new Set(availableFiles.map((file) => file.fileId));
setPreparedSessions((prev) => {
const next = { ...prev };
Object.keys(next).forEach((fileId) => {
if (!currentIds.has(fileId as any)) {
delete next[fileId];
}
});
return next;
});
}, [availableFiles, getExtension, requestPreprocessing, supportedExtensions]);
const openChat = useCallback((options: OpenChatOptions = {}) => {
if (options.source) {
setSource(options.source);
}
if (options.fileId) {
setPreferredFileId(options.fileId);
}
setIsOpen(true);
}, []);
const closeChat = useCallback(() => {
setIsOpen(false);
}, []);
const value = useMemo(
() => ({
isOpen,
source,
preferredFileId,
openChat,
closeChat,
setPreferredFileId,
sessions: preparedSessions,
requestPreprocessing,
}),
[isOpen, source, preferredFileId, openChat, closeChat, preparedSessions, requestPreprocessing]
);
return <ChatbotContext.Provider value={value}>{children}</ChatbotContext.Provider>;
}
export function useChatbot() {
const context = useContext(ChatbotContext);
if (!context) {
throw new Error('useChatbot must be used within a ChatbotProvider');
}
return context;
}
@@ -74,8 +74,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
if (signatureApiRef.current) {
signatureApiRef.current.activateDrawMode();
setPlacementMode(true);
// Mark signatures as not applied when entering draw mode
setState(prev => ({ ...prev, signaturesApplied: false }));
}
}, [setPlacementMode]);
@@ -90,8 +88,6 @@ export const SignatureProvider: React.FC<{ children: ReactNode }> = ({ children
if (signatureApiRef.current) {
signatureApiRef.current.activateSignaturePlacementMode();
setPlacementMode(true);
// Mark signatures as not applied when placing new signatures
setState(prev => ({ ...prev, signaturesApplied: false }));
}
}, [setPlacementMode]);
@@ -5,6 +5,7 @@ import SplitPdfPanel from "@app/tools/Split";
import CompressPdfPanel from "@app/tools/Compress";
import OCRPanel from "@app/tools/OCR";
import ConvertPanel from "@app/tools/Convert";
import ChatbotAssistant from "@app/tools/ChatbotAssistant";
import Sanitize from "@app/tools/Sanitize";
import AddPassword from "@app/tools/AddPassword";
import ChangePermissions from "@app/tools/ChangePermissions";
@@ -163,6 +164,18 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
supportsAutomate: false,
automationSettings: null
},
chatbot: {
icon: <LocalIcon icon="smart-toy-rounded" width="1.5rem" height="1.5rem" />,
name: t('chatbot.toolTitleMenu', 'Chatbot (Alpha)'),
component: ChatbotAssistant,
description: t('chatbot.toolMenuDescription', 'Chat with Stirling Bot about the contents of your PDF.'),
categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.AUTOMATION,
maxFiles: 1,
automationSettings: null,
supportsAutomate: false,
synonyms: getSynonyms(t, 'chatbot'),
},
merge: {
icon: <LocalIcon icon="library-add-rounded" width="1.5rem" height="1.5rem" />,
name: t("home.merge.title", "Merge"),
@@ -1,9 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { signatureStorageService, type StorageType } from '@app/services/signatureStorageService';
const STORAGE_KEY = 'stirling:saved-signatures:v1';
export const MAX_SAVED_SIGNATURES = 10;
export type SavedSignatureType = 'canvas' | 'image' | 'text';
export type SignatureScope = 'personal' | 'shared' | 'localStorage';
export type SavedSignaturePayload =
| {
@@ -16,6 +17,7 @@ export type SavedSignaturePayload =
}
| {
type: 'text';
dataUrl: string;
signerName: string;
fontFamily: string;
fontSize: number;
@@ -25,6 +27,7 @@ export type SavedSignaturePayload =
export type SavedSignature = SavedSignaturePayload & {
id: string;
label: string;
scope: SignatureScope;
createdAt: number;
updatedAt: number;
};
@@ -35,68 +38,6 @@ export type AddSignatureResult =
const isSupportedEnvironment = () => typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
const safeParse = (raw: string | null): SavedSignature[] => {
if (!raw) {
return [];
}
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter((entry: any): entry is SavedSignature => {
if (!entry || typeof entry !== 'object') {
return false;
}
if (typeof entry.id !== 'string' || typeof entry.label !== 'string') {
return false;
}
if (typeof entry.type !== 'string') {
return false;
}
if (entry.type === 'text') {
return (
typeof entry.signerName === 'string' &&
typeof entry.fontFamily === 'string' &&
typeof entry.fontSize === 'number' &&
typeof entry.textColor === 'string'
);
}
return typeof entry.dataUrl === 'string';
});
} catch {
return [];
}
};
const readFromStorage = (): SavedSignature[] => {
if (!isSupportedEnvironment()) {
return [];
}
try {
return safeParse(window.localStorage.getItem(STORAGE_KEY));
} catch {
return [];
}
};
const writeToStorage = (entries: SavedSignature[]) => {
if (!isSupportedEnvironment()) {
return;
}
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(entries));
} catch {
// Swallow storage errors silently; we still keep state in memory.
}
};
const generateId = () => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
@@ -105,29 +46,61 @@ const generateId = () => {
};
export const useSavedSignatures = () => {
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>(() => readFromStorage());
const [savedSignatures, setSavedSignatures] = useState<SavedSignature[]>([]);
const [storageType, setStorageType] = useState<StorageType | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Load signatures and detect storage type on mount
useEffect(() => {
if (!isSupportedEnvironment()) {
const loadSignatures = async () => {
try {
const [signatures, type] = await Promise.all([
signatureStorageService.loadSignatures(),
signatureStorageService.getStorageType(),
]);
setSavedSignatures(signatures);
setStorageType(type);
} catch (error) {
console.error('[useSavedSignatures] Failed to load signatures:', error);
} finally {
setIsLoading(false);
}
};
loadSignatures();
}, []);
// Attempt migration from localStorage to backend when backend becomes available
useEffect(() => {
if (storageType === 'backend' && !isLoading) {
signatureStorageService.migrateToBackend().then(result => {
if (result.migrated > 0) {
console.log(`[useSavedSignatures] Migrated ${result.migrated} signatures to backend`);
// Reload after migration
signatureStorageService.loadSignatures().then(setSavedSignatures);
}
});
}
}, [storageType, isLoading]);
// Listen for storage events (for localStorage only)
useEffect(() => {
if (!isSupportedEnvironment() || storageType !== 'localStorage') {
return;
}
const syncFromStorage = () => {
setSavedSignatures(readFromStorage());
signatureStorageService.loadSignatures().then(setSavedSignatures);
};
window.addEventListener('storage', syncFromStorage);
return () => window.removeEventListener('storage', syncFromStorage);
}, []);
useEffect(() => {
writeToStorage(savedSignatures);
}, [savedSignatures]);
}, [storageType]);
const isAtCapacity = savedSignatures.length >= MAX_SAVED_SIGNATURES;
const addSignature = useCallback(
(payload: SavedSignaturePayload, label?: string): AddSignatureResult => {
async (payload: SavedSignaturePayload, label?: string, scope?: SignatureScope): Promise<AddSignatureResult> => {
if (
(payload.type === 'text' && !payload.signerName.trim()) ||
((payload.type === 'canvas' || payload.type === 'image') && !payload.dataUrl)
@@ -135,62 +108,85 @@ export const useSavedSignatures = () => {
return { success: false, reason: 'invalid' };
}
let createdSignature: SavedSignature | null = null;
setSavedSignatures(prev => {
if (prev.length >= MAX_SAVED_SIGNATURES) {
return prev;
}
if (savedSignatures.length >= MAX_SAVED_SIGNATURES) {
return { success: false, reason: 'limit' };
}
const timestamp = Date.now();
const nextEntry: SavedSignature = {
...payload,
id: generateId(),
label: (label || 'Signature').trim() || 'Signature',
createdAt: timestamp,
updatedAt: timestamp,
};
createdSignature = nextEntry;
return [nextEntry, ...prev];
});
const timestamp = Date.now();
const newSignature: SavedSignature = {
...payload,
id: generateId(),
label: (label || 'Signature').trim() || 'Signature',
scope: scope || (storageType === 'backend' ? 'personal' : 'localStorage'),
createdAt: timestamp,
updatedAt: timestamp,
};
return createdSignature
? { success: true, signature: createdSignature }
: { success: false, reason: 'limit' };
try {
await signatureStorageService.saveSignature(newSignature);
setSavedSignatures(prev => [newSignature, ...prev]);
return { success: true, signature: newSignature };
} catch (error) {
console.error('[useSavedSignatures] Failed to save signature:', error);
return { success: false, reason: 'invalid' };
}
},
[]
[savedSignatures.length, storageType]
);
const removeSignature = useCallback((id: string) => {
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
const removeSignature = useCallback(async (id: string) => {
try {
await signatureStorageService.deleteSignature(id);
setSavedSignatures(prev => prev.filter(entry => entry.id !== id));
} catch (error) {
console.error('[useSavedSignatures] Failed to delete signature:', error);
}
}, []);
const updateSignatureLabel = useCallback((id: string, nextLabel: string) => {
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
: entry
)
);
const updateSignatureLabel = useCallback(async (id: string, nextLabel: string) => {
try {
await signatureStorageService.updateSignatureLabel(id, nextLabel);
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? { ...entry, label: nextLabel.trim() || entry.label || 'Signature', updatedAt: Date.now() }
: entry
)
);
} catch (error) {
console.error('[useSavedSignatures] Failed to update signature label:', error);
}
}, []);
const replaceSignature = useCallback((id: string, payload: SavedSignaturePayload) => {
setSavedSignatures(prev =>
prev.map(entry =>
entry.id === id
? {
...entry,
...payload,
updatedAt: Date.now(),
}
: entry
)
);
}, []);
const replaceSignature = useCallback(
async (id: string, payload: SavedSignaturePayload) => {
const existing = savedSignatures.find(s => s.id === id);
if (!existing) return;
const clearSignatures = useCallback(() => {
setSavedSignatures([]);
}, []);
const updated: SavedSignature = {
...existing,
...payload,
updatedAt: Date.now(),
};
try {
await signatureStorageService.saveSignature(updated);
setSavedSignatures(prev => prev.map(entry => (entry.id === id ? updated : entry)));
} catch (error) {
console.error('[useSavedSignatures] Failed to replace signature:', error);
}
},
[savedSignatures]
);
const clearSignatures = useCallback(async () => {
try {
await Promise.all(savedSignatures.map(sig => signatureStorageService.deleteSignature(sig.id)));
setSavedSignatures([]);
} catch (error) {
console.error('[useSavedSignatures] Failed to clear signatures:', error);
}
}, [savedSignatures]);
const byTypeCounts = useMemo(() => {
return savedSignatures.reduce<Record<SavedSignatureType, number>>(
@@ -211,6 +207,8 @@ export const useSavedSignatures = () => {
replaceSignature,
clearSignatures,
byTypeCounts,
storageType,
isLoading,
};
};
+5 -3
View File
@@ -4,7 +4,7 @@ import auditService, { AuditFilters } from '@app/services/auditService';
/**
* Shared hook for managing audit filters across components
*/
export function useAuditFilters(initialFilters: Partial<AuditFilters> = {}) {
export function useAuditFilters(initialFilters: Partial<AuditFilters> = {}, loginEnabled: boolean = true) {
const [eventTypes, setEventTypes] = useState<string[]>([]);
const [users, setUsers] = useState<string[]>([]);
const [filters, setFilters] = useState<AuditFilters>({
@@ -30,8 +30,10 @@ export function useAuditFilters(initialFilters: Partial<AuditFilters> = {}) {
}
};
fetchMetadata();
}, []);
if (loginEnabled) {
fetchMetadata();
}
}, [loginEnabled]);
const handleFilterChange = (key: keyof AuditFilters, value: any) => {
setFilters((prev) => ({ ...prev, [key]: value }));
+84 -14
View File
@@ -19,10 +19,12 @@ declare global {
interface CookieConsentConfig {
analyticsEnabled?: boolean;
forceLightMode?: boolean;
}
export const useCookieConsent = ({
analyticsEnabled = false
analyticsEnabled = false,
forceLightMode = false
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
@@ -69,6 +71,12 @@ export const useCookieConsent = ({
// Detect current theme and set appropriate mode
const detectTheme = () => {
// If forceLightMode is enabled, always use light mode
if (forceLightMode) {
document.documentElement.classList.remove('cc--darkmode');
return false;
}
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
const hasLightClass = document.documentElement.classList.contains('light');
const hasDarkClass = document.documentElement.classList.contains('dark');
@@ -104,21 +112,24 @@ export const useCookieConsent = ({
return;
}
// Listen for theme changes
const themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-mantine-color-scheme' ||
mutation.attributeName === 'class')) {
detectTheme();
}
// Listen for theme changes (but not if forceLightMode is enabled)
let themeObserver: MutationObserver | null = null;
if (!forceLightMode) {
themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-mantine-color-scheme' ||
mutation.attributeName === 'class')) {
detectTheme();
}
});
});
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mantine-color-scheme', 'class']
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mantine-color-scheme', 'class']
});
}
// Initialize cookie consent with full configuration
@@ -245,6 +256,65 @@ export const useCookieConsent = ({
};
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
// Update theme when forceLightMode changes
useEffect(() => {
if (!isInitialized) return;
const detectTheme = () => {
if (forceLightMode) {
document.documentElement.classList.remove('cc--darkmode');
return false;
}
const mantineScheme = document.documentElement.getAttribute('data-mantine-color-scheme');
const hasLightClass = document.documentElement.classList.contains('light');
const hasDarkClass = document.documentElement.classList.contains('dark');
const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
let isDarkMode = false;
if (mantineScheme) {
isDarkMode = mantineScheme === 'dark';
} else if (hasLightClass) {
isDarkMode = false;
} else if (hasDarkClass) {
isDarkMode = true;
} else {
isDarkMode = systemPrefersDark;
}
document.documentElement.classList.toggle('cc--darkmode', isDarkMode);
return isDarkMode;
};
// Update theme immediately
detectTheme();
// Set up or remove theme observer based on forceLightMode
let themeObserver: MutationObserver | null = null;
if (!forceLightMode) {
themeObserver = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'data-mantine-color-scheme' ||
mutation.attributeName === 'class')) {
detectTheme();
}
});
});
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-mantine-color-scheme', 'class']
});
}
return () => {
if (themeObserver) {
themeObserver.disconnect();
}
};
}, [forceLightMode, isInitialized]);
// Hide cookie banner when tour is active
useEffect(() => {
if (!isInitialized || !window.CookieConsent) {
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
export interface FooterInfo {
analyticsEnabled?: boolean;
termsAndConditions?: string;
privacyPolicy?: string;
accessibilityStatement?: string;
cookiePolicy?: string;
impressum?: string;
}
/**
* Hook to fetch public footer configuration data.
* This endpoint is always accessible without authentication.
*/
export function useFooterInfo() {
const [footerInfo, setFooterInfo] = useState<FooterInfo | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const fetchFooterInfo = async () => {
try {
setLoading(true);
console.log('[useFooterInfo] Fetching footer info from /api/v1/ui-data/footer-info...');
const response = await apiClient.get<FooterInfo>('/api/v1/ui-data/footer-info', {
suppressErrorToast: true,
} as any);
console.log('[useFooterInfo] Footer info received:', response.data);
setFooterInfo(response.data);
setError(null);
} catch (err) {
console.error('[useFooterInfo] Failed to fetch footer info:', err);
setError(err as Error);
// Set defaults on error
setFooterInfo({
analyticsEnabled: false,
});
} finally {
setLoading(false);
}
};
fetchFooterInfo();
}, []);
return { footerInfo, loading, error };
}
+2
View File
@@ -23,6 +23,7 @@ import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
import AdminAnalyticsChoiceModal from "@app/components/shared/AdminAnalyticsChoiceModal";
import ChatbotDrawer from "@app/components/chatbot/ChatbotDrawer";
import "@app/pages/HomePage.css";
@@ -297,6 +298,7 @@ export default function HomePage() {
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</Group>
)}
<ChatbotDrawer />
</div>
);
}
+2
View File
@@ -8,6 +8,8 @@ const apiClient = axios.create({
baseURL: getApiBaseUrl(),
responseType: 'json',
withCredentials: true,
xsrfCookieName: 'XSRF-TOKEN',
xsrfHeaderName: 'X-XSRF-TOKEN',
});
// Setup interceptors (core does nothing, proprietary adds JWT auth)
@@ -1,12 +1,26 @@
import type { AxiosInstance } from 'axios';
import { getBrowserId } from '@app/utils/browserIdentifier';
function readXsrfToken(): string | undefined {
const match = document.cookie
.split(';')
.map((cookie) => cookie.trim())
.find((cookie) => cookie.startsWith('XSRF-TOKEN='));
return match ? decodeURIComponent(match.substring('XSRF-TOKEN='.length)) : undefined;
}
export function setupApiInterceptors(client: AxiosInstance): void {
// Add browser ID header for WAU tracking
client.interceptors.request.use(
(config) => {
const browserId = getBrowserId();
config.headers['X-Browser-Id'] = browserId;
const token = readXsrfToken();
if (token) {
config.headers = config.headers ?? {};
config.headers['X-XSRF-TOKEN'] = token;
}
return config;
},
(error) => Promise.reject(error)
@@ -0,0 +1,65 @@
import apiClient from '@app/services/apiClient';
const LANGUAGE_MAP: Record<string, string> = {
en: 'eng',
fr: 'fra',
de: 'deu',
es: 'spa',
it: 'ita',
pt: 'por',
nl: 'nld',
sv: 'swe',
fi: 'fin',
da: 'dan',
no: 'nor',
cs: 'ces',
pl: 'pol',
ru: 'rus',
ja: 'jpn',
ko: 'kor',
zh: 'chi_sim',
};
function detectOcrLanguage(): string {
if (typeof navigator === 'undefined') {
return 'eng';
}
const locale = navigator.language?.toLowerCase() ?? 'en';
const short = locale.split('-')[0];
return LANGUAGE_MAP[short] || 'eng';
}
export async function runOcrForChat(file: File): Promise<File> {
const language = detectOcrLanguage();
const formData = new FormData();
formData.append('fileInput', file, file.name);
formData.append('languages', language);
formData.append('ocrType', 'skip-text');
formData.append('ocrRenderType', 'sandwich');
formData.append('sidecar', 'false');
formData.append('deskew', 'false');
formData.append('clean', 'false');
formData.append('cleanFinal', 'false');
formData.append('removeImagesAfter', 'false');
const response = await apiClient.post<Blob>(
'/api/v1/misc/ocr-pdf',
formData,
{
responseType: 'blob',
headers: {
'Content-Type': 'multipart/form-data',
},
}
);
const blob = response.data;
const head = await blob.slice(0, 5).text().catch(() => '');
if (!head.startsWith('%PDF')) {
throw new Error('OCR service did not return a valid PDF response.');
}
const safeName = file.name.replace(/\.pdf$/i, '');
const outputName = `${safeName || 'ocr'}_chat.pdf`;
return new File([blob], outputName, { type: 'application/pdf' });
}

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