Compare commits

..
Author SHA1 Message Date
Reece Browne 757a666f5e Chore/v2/improve annotation UI (#5724) 2026-02-16 22:01:15 +00:00
Anthony Stirling 558c75a2b1 JWT enhancements for desktop (#5742)
# Description of Changes

This is temporary solution which will be enhanced in future

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

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

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


## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

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

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

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

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

# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
2026-02-16 12:40:50 +00:00
Balázs Szücs e310493966 refactor(api): replace regex string literals with Pattern instances for improved performance and readability (#5680)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

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

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

### Changes

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

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

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

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

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


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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

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

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

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

### File up-to-date with disk

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

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

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

### File not tracked on disk

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

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

---------

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

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

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


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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Signed-off-by: Balázs Szücs <bszucs1209@gmail.com>
2026-02-12 19:26:26 +00:00
dependabot[bot]andAnthony Stirling 4b14ddfb37 build(deps): bump com.diffplug.spotless from 8.1.0 to 8.2.1 (#5592)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-02-11 23:51:13 +00:00
stirlingbot[bot] 597cc460aa 🌐 Sync Translations + Update README Progress Table (#5668)
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
2026-02-11 23:37:16 +00:00
Balázs Szücs f88f1db7e7 fix(markdown): markdown conversion image handling and zip support (#5677) 2026-02-11 23:31:41 +00:00
Balázs Szücs e523190f39 fix(api): address potential backend resource leaks and improve frontend accessibility (#5678) 2026-02-11 23:31:06 +00:00
Anthony StirlingandConnorYoh f9d2f36ab7 Bug fixing and debugs (#5704)
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
2026-02-11 18:43:29 +00:00
James Brunton 5df466266a Enhance SSO SAML in desktop app (#5705)
# Description of Changes
Change the SAML support for SSO to understand when a request is coming
from the desktop app, and use the alternate auth flow that the desktop
app requires.
2026-02-11 16:07:06 +00:00
277 changed files with 280721 additions and 270050 deletions
@@ -391,13 +391,24 @@ public class EndpointConfiguration {
addEndpointToGroup("Advance", "extract-image-scans");
addEndpointToGroup("Advance", "repair");
addEndpointToGroup("Advance", "auto-rename");
addEndpointToGroup("Advance", "handleData");
addEndpointToGroup("Advance", "scanner-effect");
addEndpointToGroup("Advance", "show-javascript");
addEndpointToGroup("Advance", "overlay-pdf");
// Backend-only endpoints
addEndpointToGroup("Advance", "adjust-contrast");
addEndpointToGroup("Advance", "pipeline");
// Adding endpoints to "Automation" group
addEndpointToGroup("Automation", "handleData");
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
addEndpointToGroup("Automation", "pipeline");
// Adding endpoints to "DeveloperTools" group
addEndpointToGroup("DeveloperTools", "show-javascript");
// Adding endpoints to "DeveloperDocs" group (fake endpoints for link-only tools)
addEndpointToGroup("DeveloperDocs", "dev-api-docs");
addEndpointToGroup("DeveloperDocs", "dev-folder-scanning-docs");
addEndpointToGroup("DeveloperDocs", "dev-sso-guide-docs");
addEndpointToGroup("DeveloperDocs", "dev-airgapped-docs");
// CLI
addEndpointToGroup("CLI", "compress-pdf");
@@ -0,0 +1,49 @@
package stirling.software.common.constants;
/**
* Centralized constants for JWT token management.
*
* <p>These defaults are used when configuration values are not explicitly set.
*/
public final class JwtConstants {
private JwtConstants() {
throw new UnsupportedOperationException("Utility class");
}
/** Default JWT access token lifetime in minutes (24 hours). */
public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440;
/** Default desktop client token lifetime in minutes (30 days). */
public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200;
/**
* Default refresh grace period in minutes.
*
* <p>Allows refresh of expired tokens within this window after expiration.
*/
public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
/**
* Default allowed clock skew in seconds.
*
* <p>Tolerates small time drift between client and server clocks during validation.
*/
public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60;
/** Milliseconds per minute. */
public static final long MILLIS_PER_MINUTE = 60_000L;
/** Seconds per minute. */
public static final long SECONDS_PER_MINUTE = 60L;
/** JWT issuer identifier. */
public static final String ISSUER = "https://stirling.com";
/**
* Maximum refresh attempts allowed within the grace period window.
*
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
*/
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
}
@@ -29,16 +29,17 @@ import org.springframework.stereotype.Component;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.annotation.PostConstruct;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import jakarta.annotation.PostConstruct;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.YamlPropertySourceFactory;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
@@ -101,8 +102,8 @@ public class ApplicationProperties {
}
/**
* Initialize fileUploadLimit from environment variables if not set in settings.yml.
* Supports SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
* Initialize fileUploadLimit from environment variables if not set in settings.yml. Supports
* SYSTEMFILEUPLOADLIMIT (format: "100MB") and SYSTEM_MAXFILESIZE (format: "100" in MB).
*/
@PostConstruct
public void initializeFileUploadLimitFromEnv() {
@@ -124,12 +125,18 @@ public class ApplicationProperties {
long sizeInMB = Long.parseLong(systemMaxFileSize.trim());
if (sizeInMB > 0 && sizeInMB <= 999) {
fileUploadLimit = sizeInMB + "MB";
log.info("Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB", sizeInMB);
log.info(
"Setting fileUploadLimit from SYSTEM_MAXFILESIZE: {}MB",
sizeInMB);
} else {
log.warn("SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring", sizeInMB);
log.warn(
"SYSTEM_MAXFILESIZE value {} is out of valid range (1-999), ignoring",
sizeInMB);
}
} catch (NumberFormatException e) {
log.warn("SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring", systemMaxFileSize);
log.warn(
"SYSTEM_MAXFILESIZE value '{}' is not a valid number, ignoring",
systemMaxFileSize);
}
}
}
@@ -387,12 +394,107 @@ public class ApplicationProperties {
}
}
/**
* JWT token configuration.
*
* <p><b>BREAKING CHANGE (v2.0):</b> Default token expiry increased from 12 hours (720
* minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set
* {@code tokenExpiryMinutes: 720} in your configuration.
*/
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
private int keyRetentionDays = 7;
/**
* JWT access token lifetime in minutes for web clients.
*
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
*
* <p><b>BREAKING CHANGE:</b> Previously hardcoded to 720 minutes (12 hours). Now
* defaults to 1440 minutes (24 hours).
*/
private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
/**
* JWT access token lifetime in minutes for desktop clients (Tauri app).
*
* <p>Desktop clients are automatically detected via User-Agent header and receive
* longer-lived tokens because they run on personal devices with OS-level encrypted
* storage (macOS Keychain, Windows Credential Manager, Linux Secret Service).
*
* <p>This provides better UX (login once per month) while maintaining security through
* device encryption and secure storage, matching the behavior of popular desktop apps
* like Slack, Discord, VS Code, etc.
*
* <p>Default: 43200 minutes (30 days).
*/
private int desktopTokenExpiryMinutes = 43200;
/**
* Allowed clock skew in seconds for JWT validation.
*
* <p>Tolerates small time drift between client and server clocks. Tokens that are
* slightly expired or slightly in the future (within this window) will still be
* accepted.
*
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
*/
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
/**
* Grace period in minutes for refreshing expired tokens.
*
* <p>Allows token refresh using an expired access token if the token expired within
* this many minutes. This provides better UX by allowing users to refresh slightly
* expired tokens without re-authentication.
*
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
*
* <p>Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes.
*/
private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
/**
* Calculate number of days to retain old JWT signing keys.
*
* <p>Automatically calculated based on the longest token lifetime plus a proportional
* safety buffer. Keys must be retained for at least as long as the tokens they signed
* remain valid, otherwise token verification will fail.
*
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
*
* <p>The buffer includes:
*
* <ul>
* <li>10% of token lifetime (scales with token duration)
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
* </ul>
*
* @return calculated key retention period in days
*/
public int getKeyRetentionDays() {
final int MINUTES_PER_DAY = 1440;
final double BUFFER_PERCENTAGE = 0.10; // 10% buffer
int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes);
// Add 10% buffer (scales with token lifetime)
int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE);
// Add refresh grace period
bufferMinutes += refreshGraceMinutes;
// Add clock skew (convert seconds to minutes, round up)
bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0);
// Total retention in minutes, convert to days (round up)
int totalMinutes = maxTokenExpiryMinutes + bufferMinutes;
return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY);
}
}
@Data
@@ -610,6 +712,8 @@ public class ApplicationProperties {
private String appNameNavbar;
private List<String> languages;
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
private boolean defaultHideUnavailableTools = false;
private boolean defaultHideUnavailableConversions = false;
public String getAppNameNavbar() {
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
@@ -0,0 +1,98 @@
package stirling.software.common.model;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** Form field information with coordinates for interactive form viewer. */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Form field with coordinates and metadata")
public class FormFieldWithCoordinates {
@Schema(description = "Fully qualified field name", example = "form1.firstName")
private String name;
@Schema(description = "Display label for the field", example = "First Name")
private String label;
@Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature")
private String type;
@Schema(description = "Current field value")
private String value;
@Schema(
description =
"Available options (export values) for choice fields"
+ " (dropdown, radio, listbox)")
private List<String> options;
@Schema(
description =
"Human-readable display labels for choice field options,"
+ " parallel to the 'options' list. Null when identical to options.")
private List<String> displayOptions;
@Schema(description = "Whether the field is required")
private boolean required;
@Schema(description = "Whether the field is read-only")
private boolean readOnly;
@Schema(description = "Whether this is a multi-select list box")
private boolean multiSelect;
@Schema(description = "Whether this is a multi-line text field")
private boolean multiline;
@Schema(description = "Tooltip/alternate name for the field")
private String tooltip;
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
private List<WidgetCoordinates> widgets;
/**
* Coordinates for a single widget annotation (visual representation of the field). A field can
* have multiple widgets if it appears on multiple pages.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(description = "Widget coordinates in PDF space")
public static class WidgetCoordinates {
@Schema(description = "Page index (0-based)", example = "0")
private int pageIndex;
@Schema(description = "X coordinate in PDF points (lower-left origin)")
private float x;
@Schema(description = "Y coordinate in PDF points (lower-left origin)")
private float y;
@Schema(description = "Width in PDF points")
private float width;
@Schema(description = "Height in PDF points")
private float height;
@Schema(description = "Export value for this widget (radio/checkbox buttons only)")
private String exportValue;
@Schema(description = "Font size in PDF points")
private Float fontSize;
}
}
@@ -0,0 +1,29 @@
package stirling.software.common.service;
/**
* Interface for checking license status dynamically. Implementation provided by proprietary module
* when available.
*/
public interface LicenseServiceInterface {
/**
* Get the license type as a string.
*
* @return "NORMAL", "SERVER", or "ENTERPRISE"
*/
String getLicenseTypeName();
/**
* Check if running Pro or higher (SERVER or ENTERPRISE license).
*
* @return true if SERVER or ENTERPRISE license is active
*/
boolean isRunningProOrHigher();
/**
* Check if running Enterprise edition.
*
* @return true if ENTERPRISE license is active
*/
boolean isRunningEE();
}
@@ -9,6 +9,7 @@ import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
@@ -25,6 +26,9 @@ import lombok.extern.slf4j.Slf4j;
public class MobileScannerService {
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
private final Path tempDirectory;
@@ -121,7 +125,8 @@ public class MobileScannerService {
// Handle duplicate filenames
int counter = 1;
while (Files.exists(filePath)) {
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
String nameWithoutExt =
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
String ext =
safeFilename.contains(".")
? safeFilename.substring(safeFilename.lastIndexOf("."))
@@ -271,14 +276,14 @@ public class MobileScannerService {
throw new IllegalArgumentException("Session ID cannot be empty");
}
// Basic validation: alphanumeric and hyphens only
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
if (!SESSION_ID_VALIDATION_PATTERN.matcher(sessionId).matches()) {
throw new IllegalArgumentException("Invalid session ID format");
}
}
private String sanitizeFilename(String filename) {
// Remove path traversal attempts and dangerous characters
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
// Ensure we have a non-empty, safe filename
if (sanitized.isBlank()) {
sanitized = "upload-" + System.currentTimeMillis();
@@ -107,56 +107,65 @@ public class PDFToFile {
File[] outputFiles =
Objects.requireNonNull(tempOutputDir.getPath().toFile().listFiles());
List<File> markdownFiles = new ArrayList<>();
List<File> imageFiles = new ArrayList<>();
// Convert HTML files to Markdown
// Convert HTML files to Markdown and collect image files
for (File outputFile : outputFiles) {
if (outputFile.getName().endsWith(".html")) {
String html = Files.readString(outputFile.toPath());
String markdown = htmlToMarkdownConverter.convert(html);
// Update image references to point to images/ folder
markdown = updateImageReferences(markdown);
String mdFileName = outputFile.getName().replace(".html", ".md");
File mdFile = new File(tempOutputDir.getPath().toFile(), mdFileName);
Files.writeString(mdFile.toPath(), markdown);
markdownFiles.add(mdFile);
} else if (!outputFile.getName().endsWith(".md")) {
// Collect non-HTML, non-MD files as images/assets
imageFiles.add(outputFile);
}
}
// If there's only one markdown file, return it directly
if (markdownFiles.size() == 1) {
fileName = pdfBaseName + ".md";
fileBytes = Files.readAllBytes(markdownFiles.get(0).toPath());
} else {
// Multiple files - create a zip
fileName = pdfBaseName + "ToMarkdown.zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
// Always create a ZIP file
fileName = pdfBaseName + "ToMarkdown.zip";
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
// Add markdown files
for (File mdFile : markdownFiles) {
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
zipOutputStream.putNextEntry(mdEntry);
Files.copy(mdFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
// Add images and other assets
for (File file : outputFiles) {
if (!file.getName().endsWith(".html") && !file.getName().endsWith(".md")) {
ZipEntry assetEntry = new ZipEntry(file.getName());
zipOutputStream.putNextEntry(assetEntry);
Files.copy(file.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
}
try (ZipOutputStream zipOutputStream = new ZipOutputStream(byteArrayOutputStream)) {
// Add markdown files to root of ZIP
for (File mdFile : markdownFiles) {
ZipEntry mdEntry = new ZipEntry(mdFile.getName());
zipOutputStream.putNextEntry(mdEntry);
Files.copy(mdFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
fileBytes = byteArrayOutputStream.toByteArray();
// Add images and other assets to images/ folder
for (File imageFile : imageFiles) {
ZipEntry assetEntry = new ZipEntry("images/" + imageFile.getName());
zipOutputStream.putNextEntry(assetEntry);
Files.copy(imageFile.toPath(), zipOutputStream);
zipOutputStream.closeEntry();
}
}
fileBytes = byteArrayOutputStream.toByteArray();
}
return WebResponseUtils.bytesToWebResponse(
fileBytes, fileName, MediaType.APPLICATION_OCTET_STREAM);
}
/**
* Updates image references in markdown to point to the images/ folder. Matches patterns like
* ![alt](filename.png) and converts to ![alt](images/filename.png)
*/
private String updateImageReferences(String markdown) {
// Match markdown image syntax: ![alt text](image.png)
// Only update if the path doesn't already start with images/
return markdown.replaceAll("(!\\[.*?\\])\\((?!images/)([^/)][^)]*?)\\)", "$1(images/$2)");
}
public ResponseEntity<byte[]> processPdfToHtml(MultipartFile inputFile)
throws IOException, InterruptedException {
if (!MediaType.APPLICATION_PDF_VALUE.equals(inputFile.getContentType())) {
@@ -574,34 +574,39 @@ public class PdfUtils {
boolean everyPage)
throws IOException {
PDDocument document = pdfDocumentFactory.load(pdfBytes);
// Get the first page of the PDF
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document, page, PDPageContentStream.AppendMode.APPEND, true, true)) {
// Create an image object from the image bytes
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "");
// Draw the image onto the page at the specified x and y coordinates
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF");
if (!everyPage && i == 0) {
break;
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
// Get the first page of the PDF
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
// Create an image object from the image bytes
PDImageXObject image =
PDImageXObject.createFromByteArray(document, imageBytes, "");
// Draw the image onto the page at the specified x and y coordinates
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF");
if (!everyPage && i == 0) {
break;
}
} catch (IOException e) {
// Log an error message if there is an issue overlaying the image onto the PDF
log.error("Error overlaying image onto PDF", e);
throw e;
}
} catch (IOException e) {
// Log an error message if there is an issue overlaying the image onto the PDF
log.error("Error overlaying image onto PDF", e);
throw e;
}
// Create a ByteArrayOutputStream to save the PDF to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
log.info("PDF successfully saved to byte array");
return baos.toByteArray();
}
// Create a ByteArrayOutputStream to save the PDF to
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
log.info("PDF successfully saved to byte array");
return baos.toByteArray();
}
public boolean containsTextInFile(PDDocument pdfDocument, String text, String pagesToCheck)
@@ -47,6 +47,7 @@ public class SvgSanitizer {
private static final Pattern DATA_SCRIPT_PATTERN =
Pattern.compile(
"^\\s*data\\s*:[^,]*(?:script|javascript|vbscript)", Pattern.CASE_INSENSITIVE);
private static final Pattern NULL_BYTE_PATTERN = Pattern.compile("\u0000");
private final SsrfProtectionService ssrfProtectionService;
private final ApplicationProperties applicationProperties;
@@ -210,7 +211,7 @@ public class SvgSanitizer {
String result = url.trim();
result = result.replaceAll("\u0000", "");
result = NULL_BYTE_PATTERN.matcher(result).replaceAll("");
for (int i = 0; i < 3; i++) {
try {
@@ -153,11 +153,12 @@ class PDFToFileTest {
MediaType.APPLICATION_PDF_VALUE,
"Fake PDF content".getBytes());
// Create a mock HTML output file
// Create a mock HTML output file with image references
Path htmlOutputFile = tempDir.resolve("test.html");
Files.write(
htmlOutputFile,
"<html><body><h1>Test</h1><p>This is a test.</p></body></html>".getBytes());
"<html><body><h1>Test</h1><p>This is a test.</p><img src=\"image1.png\" /></body></html>"
.getBytes());
// Setup ProcessExecutor mock
mockedStaticProcessExecutor
@@ -174,18 +175,61 @@ class PDFToFileTest {
Files.copy(
htmlOutputFile, Path.of(outputDir.getPath(), "test.html"));
// Create a mock image file
Files.write(
Path.of(outputDir.getPath(), "image1.png"),
"Fake image data".getBytes());
return mockExecutorResult;
});
// Execute the method
ResponseEntity<byte[]> response = pdfToFile.processPdfToMarkdown(pdfFile);
// Verify
// Verify - should now return a ZIP file instead of plain markdown
assertEquals(HttpStatus.OK, response.getStatusCode());
assertNotNull(response.getBody());
assertTrue(response.getBody().length > 0);
// Verify content disposition indicates a ZIP file
assertTrue(
response.getHeaders().getContentDisposition().toString().contains("test.md"));
response.getHeaders()
.getContentDisposition()
.toString()
.contains("ToMarkdown.zip"));
// Verify the content by unzipping it
try (ZipInputStream zipStream =
ZipSecurity.createHardenedInputStream(
new java.io.ByteArrayInputStream(response.getBody()))) {
ZipEntry entry;
boolean foundMdFile = false;
boolean foundImageInFolder = false;
String markdownContent = null;
while ((entry = zipStream.getNextEntry()) != null) {
if (entry.getName().endsWith(".md")) {
foundMdFile = true;
// Read markdown content to verify image references
markdownContent =
new String(
zipStream.readAllBytes(),
java.nio.charset.StandardCharsets.UTF_8);
} else if (entry.getName().startsWith("images/")
&& entry.getName().endsWith(".png")) {
foundImageInFolder = true;
}
zipStream.closeEntry();
}
assertTrue(foundMdFile, "ZIP should contain Markdown file");
assertTrue(foundImageInFolder, "ZIP should contain image in images/ folder");
assertNotNull(markdownContent, "Markdown content should be present");
// Verify markdown references images with images/ prefix
assertTrue(
markdownContent.contains("images/"),
"Markdown should reference images with images/ prefix");
}
}
}
@@ -256,14 +300,15 @@ class PDFToFileTest {
while ((entry = zipStream.getNextEntry()) != null) {
if (entry.getName().endsWith(".md")) {
foundMdFiles = true;
} else if (entry.getName().endsWith(".png")) {
} else if (entry.getName().startsWith("images/")
&& entry.getName().endsWith(".png")) {
foundImage = true;
}
zipStream.closeEntry();
}
assertTrue(foundMdFiles, "ZIP should contain Markdown files");
assertTrue(foundImage, "ZIP should contain image files");
assertTrue(foundImage, "ZIP should contain image files in images/ folder");
}
}
}
+2
View File
@@ -168,6 +168,7 @@ def generatedFrontendPaths = [
]
tasks.register('npmInstall', Exec) {
doNotTrackState("node_modules contains symlinks that Gradle cannot snapshot on Windows/WSL")
enabled = buildWithFrontend
group = 'frontend'
description = 'Install frontend dependencies'
@@ -214,6 +215,7 @@ tasks.register('npmInstall', Exec) {
}
tasks.register('npmBuild', Exec) {
doNotTrackState("Frontend build depends on untracked npmInstall task")
enabled = buildWithFrontend
group = 'frontend'
description = 'Build frontend application'
@@ -9,6 +9,7 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.regex.Pattern;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@@ -38,6 +39,10 @@ import stirling.software.common.model.ApplicationProperties;
})
public class SPDFApplication {
private static final Pattern PORT_SUFFIX_PATTERN = Pattern.compile(".+:\\d+$");
private static final Pattern URL_SCHEME_PATTERN =
Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/+$");
private static String serverPortStatic;
private static String baseUrlStatic;
private static String contextPathStatic;
@@ -244,8 +249,8 @@ public class SPDFApplication {
String trimmedBase =
(backendUrl == null || backendUrl.isBlank())
? "http://localhost"
: backendUrl.trim().replaceAll("/+$", "");
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
: TRAILING_SLASH_PATTERN.matcher(backendUrl.trim()).replaceAll("");
boolean hasScheme = URL_SCHEME_PATTERN.matcher(trimmedBase).matches();
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
Integer parsedPort = parsePort(port);
@@ -298,7 +303,7 @@ public class SPDFApplication {
if (port == null) {
return trimmedBase;
}
if (trimmedBase.matches(".+:\\d+$")) {
if (PORT_SUFFIX_PATTERN.matcher(trimmedBase).matches()) {
return trimmedBase;
}
return trimmedBase + ":" + port;
@@ -14,9 +14,9 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.controller.web.UploadLimitService;
/**
* Configuration for Spring multipart file upload settings.
* Synchronizes multipart limits with fileUploadLimit from settings.yml or environment variables
* (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE).
* Configuration for Spring multipart file upload settings. Synchronizes multipart limits with
* fileUploadLimit from settings.yml or environment variables (SYSTEMFILEUPLOADLIMIT or
* SYSTEM_MAXFILESIZE).
*/
@Configuration
@Slf4j
@@ -25,9 +25,9 @@ public class MultipartConfiguration {
@Autowired private UploadLimitService uploadLimitService;
/**
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml
* or environment variables (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE).
* Depends on ApplicationProperties being initialized so @PostConstruct has run.
* Creates MultipartConfigElement that respects fileUploadLimit from settings.yml or environment
* variables (SYSTEMFILEUPLOADLIMIT or SYSTEM_MAXFILESIZE). Depends on ApplicationProperties
* being initialized so @PostConstruct has run.
*/
@Bean
@DependsOn("applicationProperties")
@@ -35,7 +35,8 @@ public class MultipartConfiguration {
MultipartConfigFactory factory = new MultipartConfigFactory();
// First check if SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE is explicitly set
String springMaxFileSize = java.lang.System.getenv("SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE");
String springMaxFileSize =
java.lang.System.getenv("SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE");
long uploadLimitBytes = 0;
if (springMaxFileSize != null && !springMaxFileSize.trim().isEmpty()) {
@@ -45,7 +46,10 @@ public class MultipartConfiguration {
uploadLimitBytes = dataSize.toBytes();
log.info("Using SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}", springMaxFileSize);
} catch (Exception e) {
log.warn("Failed to parse SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}", springMaxFileSize, e);
log.warn(
"Failed to parse SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {}",
springMaxFileSize,
e);
}
}
@@ -73,4 +77,3 @@ public class MultipartConfiguration {
return factory.createMultipartConfig();
}
}
@@ -8,6 +8,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -51,6 +52,7 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class MergeController {
private static final Pattern QUOTE_WRAP_PATTERN = Pattern.compile("^\"|\"$");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@@ -173,7 +175,7 @@ public class MergeController {
String[] parts = inside.split(",");
String[] result = new String[parts.length];
for (int i = 0; i < parts.length; i++) {
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
result[i] = QUOTE_WRAP_PATTERN.matcher(parts[i].trim()).replaceAll("");
}
return result;
}
@@ -101,66 +101,68 @@ public class SplitPdfBySectionsController {
return WebResponseUtils.baosToWebResponse(baos, filename + ".pdf");
}
} else {
TempFile zipTempFile = new TempFile(tempFileManager, ".zip");
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
int pageNum = pageIndex + 1;
if (pagesToSplit.contains(pageIndex)) {
for (int i = 0; i < horiz; i++) {
for (int j = 0; j < verti; j++) {
try (PDDocument subDoc =
pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addSingleSectionToTarget(
sourceDocument,
pageIndex,
subDoc,
subLayerUtility,
i,
j,
horiz,
verti);
int sectionNum = i * verti + j + 1;
String entryName =
filename
+ "_"
+ pageNum
+ "_"
+ sectionNum
+ ".pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error(
"Error creating section {} for page {}",
(i * verti + j + 1),
pageNum,
e);
throw e;
try (TempFile zipTempFile = new TempFile(tempFileManager, ".zip")) {
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(zipTempFile.getPath()))) {
for (int pageIndex = 0;
pageIndex < sourceDocument.getNumberOfPages();
pageIndex++) {
int pageNum = pageIndex + 1;
if (pagesToSplit.contains(pageIndex)) {
for (int i = 0; i < horiz; i++) {
for (int j = 0; j < verti; j++) {
try (PDDocument subDoc =
pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addSingleSectionToTarget(
sourceDocument,
pageIndex,
subDoc,
subLayerUtility,
i,
j,
horiz,
verti);
int sectionNum = i * verti + j + 1;
String entryName =
filename
+ "_"
+ pageNum
+ "_"
+ sectionNum
+ ".pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error(
"Error creating section {} for page {}",
(i * verti + j + 1),
pageNum,
e);
throw e;
}
}
}
}
} else {
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addPageToTarget(sourceDocument, pageIndex, subDoc, subLayerUtility);
String entryName = filename + "_" + pageNum + "_1.pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error("Error processing unsplit page {}", pageNum, e);
throw e;
} else {
try (PDDocument subDoc = pdfDocumentFactory.createNewDocument()) {
LayerUtility subLayerUtility = new LayerUtility(subDoc);
addPageToTarget(
sourceDocument, pageIndex, subDoc, subLayerUtility);
String entryName = filename + "_" + pageNum + "_1.pdf";
saveDocToZip(subDoc, zipOut, entryName);
} catch (IOException e) {
log.error("Error processing unsplit page {}", pageNum, e);
throw e;
}
}
}
} catch (IOException e) {
log.error("Error creating ZIP file with split PDF sections", e);
throw e;
}
} catch (IOException e) {
log.error("Error creating ZIP file with split PDF sections", e);
throw e;
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
}
byte[] zipBytes = Files.readAllBytes(zipTempFile.getPath());
return WebResponseUtils.bytesToWebResponse(
zipBytes, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
}
} catch (Exception e) {
log.error("Error splitting PDF file: {}", file.getOriginalFilename(), e);
@@ -44,7 +44,7 @@ public class ConvertMarkdownToPdf {
@Operation(
summary = "Convert a Markdown file to PDF",
description =
"This endpoint takes a Markdown file input, converts it to HTML, and then to"
"This endpoint takes a Markdown file or ZIP (containing Markdown + images) input, converts it to HTML, and then to"
+ " PDF format. Input:MARKDOWN Output:PDF Type:SISO")
public ResponseEntity<byte[]> markdownToPdf(@ModelAttribute GeneralFile generalFile)
throws Exception {
@@ -52,40 +52,181 @@ public class ConvertMarkdownToPdf {
if (fileInput == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", "Markdown");
"error.fileFormatRequired", "File must be in {0} format", "Markdown or ZIP");
}
String originalFilename = Filenames.toSimpleFileName(fileInput.getOriginalFilename());
if (originalFilename == null || !originalFilename.endsWith(".md")) {
if (originalFilename == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", ".md");
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
}
// Convert Markdown to HTML using CommonMark
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
boolean isZip = originalFilename.toLowerCase().endsWith(".zip");
boolean isMarkdown = originalFilename.toLowerCase().endsWith(".md");
Node document = parser.parse(new String(fileInput.getBytes()));
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
if (!isZip && !isMarkdown) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired", "File must be in {0} format", ".md or .zip");
}
String htmlContent = renderer.render(document);
byte[] pdfBytes;
String outputFilename;
if (isZip) {
// Handle ZIP file containing markdown + images
try (TempDirectory tempDir = new TempDirectory(tempFileManager)) {
// Extract ZIP to temp directory
java.nio.file.Path tempDirPath = tempDir.getPath();
try (java.util.zip.ZipInputStream zipIn =
io.github.pixee.security.ZipSecurity.createHardenedInputStream(
new java.io.ByteArrayInputStream(fileInput.getBytes()))) {
java.util.zip.ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
if (!entry.isDirectory()) {
java.nio.file.Path filePath = tempDirPath.resolve(entry.getName());
java.nio.file.Files.createDirectories(filePath.getParent());
java.nio.file.Files.copy(zipIn, filePath);
}
zipIn.closeEntry();
}
}
// Find the markdown file (look for .md files, prefer index.md or first one)
java.io.File markdownFile = findMarkdownFile(tempDirPath.toFile());
if (markdownFile == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.fileFormatRequired",
"ZIP must contain at least one {0} file",
".md");
}
// Read and convert markdown to HTML
String markdownContent = java.nio.file.Files.readString(markdownFile.toPath());
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
Node document = parser.parse(markdownContent);
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
String htmlContent = renderer.render(document);
// Create a new ZIP with HTML + images for WeasyPrint
byte[] htmlZipBytes = createHtmlZip(htmlContent, tempDirPath.toFile());
// Use FileToPdf which already supports ZIP files with images
pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlZipBytes,
"package.zip",
tempFileManager,
customHtmlSanitizer);
outputFilename =
GeneralUtils.generateFilename(
originalFilename.substring(0, originalFilename.lastIndexOf('.')),
".pdf");
}
} else {
// Handle plain markdown file (no images)
List<Extension> extensions = List.of(TablesExtension.create());
Parser parser = Parser.builder().extensions(extensions).build();
Node document = parser.parse(new String(fileInput.getBytes()));
HtmlRenderer renderer =
HtmlRenderer.builder()
.attributeProviderFactory(context -> new TableAttributeProvider())
.extensions(extensions)
.build();
String htmlContent = renderer.render(document);
pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlContent.getBytes(),
"converted.html",
tempFileManager,
customHtmlSanitizer);
outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
}
byte[] pdfBytes =
FileToPdf.convertHtmlToPdf(
runtimePathConfig.getWeasyPrintPath(),
null,
htmlContent.getBytes(),
"converted.html",
tempFileManager,
customHtmlSanitizer);
pdfBytes = pdfDocumentFactory.createNewBytesBasedOnOldDocument(pdfBytes);
String outputFilename = GeneralUtils.generateFilename(originalFilename, ".pdf");
return WebResponseUtils.bytesToWebResponse(pdfBytes, outputFilename);
}
/**
* Finds a markdown file in the directory. Prefers index.md, otherwise returns the first .md
* file found.
*/
private java.io.File findMarkdownFile(java.io.File directory) throws java.io.IOException {
java.io.File indexMd = new java.io.File(directory, "index.md");
if (indexMd.exists()) {
return indexMd;
}
// Search for any .md file
try (java.util.stream.Stream<java.nio.file.Path> paths =
java.nio.file.Files.walk(directory.toPath())) {
return paths.filter(p -> p.toString().toLowerCase().endsWith(".md"))
.findFirst()
.map(java.nio.file.Path::toFile)
.orElse(null);
}
}
/**
* Creates a ZIP file containing the HTML content and all other files (images) from the
* directory.
*/
private byte[] createHtmlZip(String htmlContent, java.io.File sourceDir)
throws java.io.IOException {
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
try (java.util.zip.ZipOutputStream zos = new java.util.zip.ZipOutputStream(baos)) {
// Add HTML file to root
java.util.zip.ZipEntry htmlEntry = new java.util.zip.ZipEntry("index.html");
zos.putNextEntry(htmlEntry);
zos.write(htmlContent.getBytes(java.nio.charset.StandardCharsets.UTF_8));
zos.closeEntry();
// Add all other files (images, etc.)
addDirectoryToZip(zos, sourceDir.toPath(), sourceDir.toPath());
}
return baos.toByteArray();
}
/** Recursively adds files from a directory to a ZIP, excluding .md files. */
private void addDirectoryToZip(
java.util.zip.ZipOutputStream zos,
java.nio.file.Path sourceDir,
java.nio.file.Path rootDir)
throws java.io.IOException {
try (java.util.stream.Stream<java.nio.file.Path> paths =
java.nio.file.Files.walk(sourceDir, 1)) {
for (java.nio.file.Path path : paths.toList()) {
if (java.nio.file.Files.isDirectory(path)) {
if (!path.equals(sourceDir)) {
addDirectoryToZip(zos, path, rootDir);
}
} else if (!path.toString().toLowerCase().endsWith(".md")) {
// Add file to ZIP, maintaining relative path structure
java.nio.file.Path relativePath = rootDir.relativize(path);
java.util.zip.ZipEntry entry =
new java.util.zip.ZipEntry(relativePath.toString());
zos.putNextEntry(entry);
java.nio.file.Files.copy(path, zos);
zos.closeEntry();
}
}
}
}
}
class TableAttributeProvider implements AttributeProvider {
@@ -1,8 +1,9 @@
package stirling.software.SPDF.controller.api.converters;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.UUID;
import java.nio.charset.StandardCharsets;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
@@ -37,6 +38,7 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class ConvertPdfJsonController {
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
private final PdfJsonConversionService pdfJsonConversionService;
@Autowired(required = false)
@@ -61,7 +63,9 @@ public class ConvertPdfJsonController {
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
: "document";
String docName = baseName + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
@@ -84,7 +88,9 @@ public class ConvertPdfJsonController {
String originalName = jsonFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
: "document";
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
@@ -110,7 +116,7 @@ public class ConvertPdfJsonController {
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
log.debug("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
byte[] jsonBytes =
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
@@ -118,7 +124,9 @@ public class ConvertPdfJsonController {
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(originalName))
.replaceFirst("")
: "document";
String docName = baseName + "_metadata.json";
@@ -155,7 +163,9 @@ public class ConvertPdfJsonController {
String baseName =
(filename != null && !filename.isBlank())
? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "")
? FILE_EXTENSION_PATTERN
.matcher(Filenames.toSimpleFileName(filename))
.replaceFirst("")
: Optional.ofNullable(document.getMetadata())
.map(PdfJsonMetadata::getTitle)
.filter(title -> title != null && !title.isBlank())
@@ -183,7 +193,28 @@ public class ConvertPdfJsonController {
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
@GetMapping(value = "/pdf/text-editor/fonts/{jobId}/{pageNumber}")
@Operation(
summary = "Extract fonts used by a single cached page for text editor",
description =
"Retrieves the font payloads used by a single page from a previously cached PDF document."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user. Output:JSON")
public ResponseEntity<byte[]> extractPageFonts(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
// Validate job ownership
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractPageFonts(jobId, pageNumber);
logJsonResponse("pdf/text-editor/fonts/page", jsonBytes);
String docName = "page_fonts_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@AutoJobPostMapping(
value = "/pdf/text-editor/clear-cache/{jobId}",
consumes = MediaType.ALL_VALUE)
@Operation(
summary = "Clear cached PDF document for text editor",
description =
@@ -218,21 +249,25 @@ public class ConvertPdfJsonController {
log.warn("Returning {} JSON response: null bytes", label);
return;
}
int length = jsonBytes.length;
boolean endsWithJson =
length > 0 && (jsonBytes[length - 1] == '}' || jsonBytes[length - 1] == ']');
String tail = "";
if (length > 0) {
int start = Math.max(0, length - 64);
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
tail = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
// Only perform expensive tail extraction if debug logging is enabled
if (log.isDebugEnabled()) {
int length = jsonBytes.length;
boolean endsWithJson =
length > 0 && (jsonBytes[length - 1] == '}' || jsonBytes[length - 1] == ']');
String tail = "";
if (length > 0) {
int start = Math.max(0, length - 64);
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
tail = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
}
log.debug(
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
label,
length,
endsWithJson,
tail);
}
log.info(
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
label,
length,
endsWithJson,
tail);
if (isPdfJsonDebugDumpEnabled()) {
try {
@@ -245,7 +280,7 @@ public class ConvertPdfJsonController {
java.nio.file.Path dumpPath =
java.nio.file.Files.createTempFile(dumpDir, "pdfjson_", ".json");
java.nio.file.Files.write(dumpPath, jsonBytes);
log.info("PDF JSON debug dump ({}): {}", label, dumpPath);
log.debug("PDF JSON debug dump ({}): {}", label, dumpPath);
} catch (Exception ex) {
log.warn("Failed to write PDF JSON debug dump ({}): {}", label, ex.getMessage());
}
@@ -351,14 +386,16 @@ public class ConvertPdfJsonController {
e.getKey().length(),
e.getValue()))
.collect(java.util.stream.Collectors.joining("; "));
log.info(
log.debug(
"PDF JSON repeat scan ({}): top strings -> {}{}",
label,
summary,
capped ? " (capped)" : "");
} else {
log.info(
"PDF JSON repeat scan ({}): no repeated strings found{}", label, capped ? " (capped)" : "");
log.debug(
"PDF JSON repeat scan ({}): no repeated strings found{}",
label,
capped ? " (capped)" : "");
}
}
@@ -35,6 +35,7 @@ public class ConfigController {
private final EndpointConfiguration endpointConfiguration;
private final ServerCertificateServiceInterface serverCertificateService;
private final UserServiceInterface userService;
private final stirling.software.common.service.LicenseServiceInterface licenseService;
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
public ConfigController(
@@ -45,15 +46,66 @@ public class ConfigController {
ServerCertificateServiceInterface serverCertificateService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
UserServiceInterface userService,
@org.springframework.beans.factory.annotation.Autowired(required = false)
stirling.software.common.service.LicenseServiceInterface licenseService,
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
this.serverCertificateService = serverCertificateService;
this.userService = userService;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
}
/**
* Get current license type dynamically instead of from cached bean. This ensures the frontend
* sees updated license status after admin changes the license key.
*/
private String getCurrentLicenseType() {
// Use LicenseService for fresh license status if available
if (licenseService != null) {
return licenseService.getLicenseTypeName();
}
// Fallback to cached bean if service not available
if (applicationContext.containsBean("license")) {
return applicationContext.getBean("license", String.class);
}
return null;
}
/** Check if running Pro or higher (SERVER or ENTERPRISE license) dynamically. */
private Boolean isRunningProOrHigher() {
// Use LicenseService for fresh license status if available
if (licenseService != null) {
return licenseService.isRunningProOrHigher();
}
// Fallback to cached bean
if (applicationContext.containsBean("runningProOrHigher")) {
return applicationContext.getBean("runningProOrHigher", Boolean.class);
}
return null;
}
/** Check if running Enterprise edition dynamically. */
private Boolean isRunningEE() {
// Use LicenseService for fresh license status if available
if (licenseService != null) {
return licenseService.isRunningEE();
}
// Fallback to cached bean
if (applicationContext.containsBean("runningEE")) {
return applicationContext.getBean("runningEE", Boolean.class);
}
return null;
}
@GetMapping("/app-config")
public ResponseEntity<Map<String, Object>> getAppConfig() {
Map<String, Object> configData = new HashMap<>();
@@ -101,6 +153,14 @@ public class ConfigController {
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
// User preference defaults
configData.put(
"defaultHideUnavailableTools",
applicationProperties.getUi().isDefaultHideUnavailableTools());
configData.put(
"defaultHideUnavailableConversions",
applicationProperties.getUi().isDefaultHideUnavailableConversions());
// Security settings
// enableLogin requires both the config flag AND proprietary features to be loaded
// If userService is null, proprietary module isn't loaded
@@ -185,19 +245,23 @@ public class ConfigController {
applicationProperties.getLegal().getAccessibilityStatement());
// Try to get EEAppConfig values if available
// Get these dynamically to reflect current license status (not cached at startup)
try {
if (applicationContext.containsBean("runningProOrHigher")) {
configData.put(
"runningProOrHigher",
applicationContext.getBean("runningProOrHigher", Boolean.class));
Boolean runningProOrHigher = isRunningProOrHigher();
if (runningProOrHigher != null) {
configData.put("runningProOrHigher", runningProOrHigher);
}
if (applicationContext.containsBean("runningEE")) {
configData.put(
"runningEE", applicationContext.getBean("runningEE", Boolean.class));
Boolean runningEE = isRunningEE();
if (runningEE != null) {
configData.put("runningEE", runningEE);
}
if (applicationContext.containsBean("license")) {
configData.put("license", applicationContext.getBean("license", String.class));
String licenseType = getCurrentLicenseType();
if (licenseType != null) {
configData.put("license", licenseType);
}
if (applicationContext.containsBean("SSOAutoLogin")) {
configData.put(
"SSOAutoLogin",
@@ -55,45 +55,45 @@ public class OverlayImageController {
boolean isSvg = SvgOverlayUtil.isSvgImage(imageBytes);
PDDocument document = pdfDocumentFactory.load(pdfBytes);
try (PDDocument document = pdfDocumentFactory.load(pdfBytes)) {
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
int pages = document.getNumberOfPages();
for (int i = 0; i < pages; i++) {
PDPage page = document.getPage(i);
if (isSvg) {
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
} else {
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
PDImageXObject image =
PDImageXObject.createFromByteArray(document, imageBytes, "");
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF page {}", i);
}
}
if (isSvg) {
SvgOverlayUtil.overlaySvgOnPage(document, page, imageBytes, x, y);
} else {
try (PDPageContentStream contentStream =
new PDPageContentStream(
document,
page,
PDPageContentStream.AppendMode.APPEND,
true,
true)) {
PDImageXObject image =
PDImageXObject.createFromByteArray(document, imageBytes, "");
contentStream.drawImage(image, x, y);
log.info("Image successfully overlaid onto PDF page {}", i);
if (!everyPage && i == 0) {
break;
}
}
if (!everyPage && i == 0) {
break;
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
byte[] result = baos.toByteArray();
log.info("PDF with overlaid image successfully created");
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(
pdfFile.getOriginalFilename(), "_overlayed.pdf"));
}
ByteArrayOutputStream baos = new ByteArrayOutputStream();
document.save(baos);
document.close();
byte[] result = baos.toByteArray();
log.info("PDF with overlaid image successfully created");
return WebResponseUtils.bytesToWebResponse(
result,
GeneralUtils.generateFilename(pdfFile.getOriginalFilename(), "_overlayed.pdf"));
} catch (IOException e) {
log.error("Failed to add image to PDF", e);
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
@@ -57,6 +57,7 @@ import stirling.software.common.util.WebResponseUtils;
@RequiredArgsConstructor
public class StampController {
private static final Pattern NEWLINE_PATTERN = Pattern.compile("\\r?\\n");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
@@ -266,7 +267,7 @@ public class StampController {
.getEscapedNewlinePattern()
.matcher(processedStampText)
.replaceAll("\n");
String[] lines = normalizedText.split("\\r?\\n");
String[] lines = NEWLINE_PATTERN.split(normalizedText);
PDRectangle pageSize = page.getMediaBox();
@@ -21,6 +21,7 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.core.io.Resource;
@@ -44,6 +45,7 @@ import stirling.software.common.util.FileMonitor;
public class PipelineDirectoryProcessor {
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("\\\\?watchedFolders");
private final ObjectMapper objectMapper;
private final ApiDocService apiDocService;
@@ -433,10 +435,12 @@ public class PipelineDirectoryProcessor {
private Path determineOutputPath(PipelineConfig config, Path dir) {
String outputDir =
config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString())
.replaceAll("\\\\?watchedFolders", "");
WATCHED_FOLDERS_PATTERN
.matcher(
config.getOutputDir()
.replace("{outputFolder}", finishedFoldersDir)
.replace("{folderName}", dir.toString()))
.replaceAll("");
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
}
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ClassPathResource;
@@ -28,6 +29,8 @@ public class ReactRoutingController {
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
private static final Pattern BASE_HREF_PATTERN =
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
@Value("${server.servlet.context-path:/}")
private String contextPath;
@@ -94,9 +97,9 @@ public class ReactRoutingController {
html = html.replace("%BASE_URL%", baseUrl);
// Also rewrite any existing <base> tag (Vite may have baked one in)
html =
html.replaceFirst(
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
"<base href=\\\"" + baseUrl + "\\\" />");
BASE_HREF_PATTERN
.matcher(html)
.replaceFirst("<base href=\\\"" + baseUrl + "\\\" />");
// Inject context path as a global variable for API calls
String contextPathScript =
@@ -38,6 +38,7 @@ import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.imageio.ImageIO;
@@ -133,6 +134,9 @@ import stirling.software.common.util.TempFileManager;
@RequiredArgsConstructor
public class PdfJsonConversionService {
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern WHITESPACE_DASH_UNDERSCORE_PATTERN = Pattern.compile("[\\s\\-_]");
private static final Pattern FONT_SUBSET_PREFIX_PATTERN = Pattern.compile("^[A-Z]{6}\\+");
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final ObjectMapper objectMapper;
private final EndpointConfiguration endpointConfiguration;
@@ -235,12 +239,12 @@ public class PdfJsonConversionService {
}
cacheBudgetBytes = effective;
if (cacheBudgetBytes > 0) {
log.info(
log.debug(
"PDF JSON cache budget configured: {} bytes (source: {})",
cacheBudgetBytes,
cacheMaxBytes > 0 ? "max-bytes" : "max-percent");
} else {
log.info("PDF JSON cache budget: unlimited");
log.debug("PDF JSON cache budget: unlimited");
}
}
@@ -429,7 +433,8 @@ public class PdfJsonConversionService {
80, "annotations", "Collecting annotations and form fields"));
boolean includeAnnotationRawData = !(lightweight && isRealJobId);
Map<Integer, List<PdfJsonAnnotation>> annotationsByPage =
collectAnnotations(document, totalPages, progress, includeAnnotationRawData);
collectAnnotations(
document, totalPages, progress, includeAnnotationRawData);
progress.accept(
PdfJsonConversionProgress.of(90, "metadata", "Extracting metadata"));
@@ -437,13 +442,21 @@ public class PdfJsonConversionService {
pdfJson.setMetadata(extractMetadata(document));
pdfJson.setXmpMetadata(extractXmpMetadata(document));
pdfJson.setLazyImages(useLazyImages);
List<PdfJsonFont> serializedFonts = cloneFontList(fonts.values());
serializedFonts.sort(
List<PdfJsonFont> cachedFonts = cloneFontList(fonts.values());
cachedFonts.sort(
Comparator.comparing(
PdfJsonFont::getUid,
Comparator.nullsLast(Comparator.naturalOrder())));
dedupeFontPayloads(serializedFonts);
pdfJson.setFonts(serializedFonts);
dedupeFontPayloads(cachedFonts);
Map<String, PdfJsonFont> cachedFontMap = new LinkedHashMap<>();
for (PdfJsonFont cachedFont : cachedFonts) {
String cacheKey = resolveFontCacheKey(cachedFont);
if (cacheKey != null) {
cachedFontMap.put(cacheKey, cachedFont);
}
}
List<PdfJsonFont> responseFonts = cloneFontList(cachedFonts);
pdfJson.setFonts(responseFonts);
pdfJson.setPages(
extractPages(
document,
@@ -451,11 +464,18 @@ public class PdfJsonConversionService {
imagesByPage,
annotationsByPage,
lightweight && isRealJobId));
pdfJson.setFormFields(collectFormFields(document));
if (lightweight && isRealJobId) {
// Lightweight async editor flow does not use form fields and this payload can
// be
// very large due nested raw dictionaries.
pdfJson.setFormFields(null);
} else {
pdfJson.setFormFields(collectFormFields(document));
}
// Only cache for real async jobIds, not synthetic synchronous ones
if (useLazyImages && isRealJobId) {
log.info(
log.debug(
"Creating cache for jobId: {} (useLazyImages={}, isRealJobId={})",
jobId,
useLazyImages,
@@ -463,7 +483,7 @@ public class PdfJsonConversionService {
PdfJsonDocumentMetadata docMetadata = new PdfJsonDocumentMetadata();
docMetadata.setMetadata(pdfJson.getMetadata());
docMetadata.setXmpMetadata(pdfJson.getXmpMetadata());
docMetadata.setFonts(serializedFonts);
docMetadata.setFonts(cloneFontList(responseFonts));
docMetadata.setFormFields(pdfJson.getFormFields());
docMetadata.setLazyImages(Boolean.TRUE);
@@ -493,7 +513,11 @@ public class PdfJsonConversionService {
}
CachedPdfDocument cached =
buildCachedDocument(
jobId, cachedPdfBytes, docMetadata, fonts, pageFontResources);
jobId,
cachedPdfBytes,
docMetadata,
cachedFontMap,
pageFontResources);
putCachedDocument(jobId, cached);
log.info(
"Successfully cached PDF ({} bytes, {} pages, {} fonts) for jobId: {} (diskBacked={})",
@@ -515,10 +539,11 @@ public class PdfJsonConversionService {
applyLightweightTransformations(pdfJson);
}
if (lightweight && isRealJobId) {
stripFontCosStreamData(serializedFonts);
stripFontProgramPayloads(responseFonts);
stripFontCosStreamData(responseFonts);
}
logFontPayloadStats(serializedFonts, "pdf/text-editor");
logFontPayloadStats(responseFonts, "pdf/text-editor");
analyzePdfJson(pdfJson, "pdf/text-editor");
progress.accept(
@@ -526,7 +551,7 @@ public class PdfJsonConversionService {
// Collect font issues for summary
java.util.List<String> fontsWithMissingProgram =
serializedFonts.stream()
responseFonts.stream()
.filter(
f ->
Boolean.TRUE.equals(f.getEmbedded())
@@ -545,19 +570,20 @@ public class PdfJsonConversionService {
: "Unknown";
// Clean up subset prefix (e.g., "ABCDEF+TimesNewRoman"
// -> "TimesNewRoman")
String cleanName = name.replaceAll("^[A-Z]{6}\\+", "");
String cleanName =
FONT_SUBSET_PREFIX_PATTERN
.matcher(name)
.replaceAll("");
return String.format("%s (%s)", cleanName, subtype);
})
.collect(java.util.stream.Collectors.toList());
long type3Fonts =
serializedFonts.stream()
.filter(f -> "Type3".equals(f.getSubtype()))
.count();
responseFonts.stream().filter(f -> "Type3".equals(f.getSubtype())).count();
if (!fontsWithMissingProgram.isEmpty()) {
log.warn(
"PDF->JSON conversion complete: {} fonts ({} Type3), {} pages. Missing font programs for {} embedded font(s): {}",
serializedFonts.size(),
responseFonts.size(),
type3Fonts,
pdfJson.getPages().size(),
fontsWithMissingProgram.size(),
@@ -565,7 +591,7 @@ public class PdfJsonConversionService {
} else {
log.info(
"PDF->JSON conversion complete: {} fonts ({} Type3), {} pages",
serializedFonts.size(),
responseFonts.size(),
type3Fonts,
pdfJson.getPages().size());
}
@@ -967,40 +993,59 @@ public class PdfJsonConversionService {
if (font == null) {
return null;
}
return PdfJsonFont.builder()
.id(font.getId())
.pageNumber(font.getPageNumber())
.uid(font.getUid())
.baseName(font.getBaseName())
.subtype(font.getSubtype())
.encoding(font.getEncoding())
.cidSystemInfo(font.getCidSystemInfo())
.embedded(font.getEmbedded())
.program(font.getProgram())
.programFormat(font.getProgramFormat())
.webProgram(font.getWebProgram())
.webProgramFormat(font.getWebProgramFormat())
.pdfProgram(font.getPdfProgram())
.pdfProgramFormat(font.getPdfProgramFormat())
.type3Glyphs(
font.getType3Glyphs() == null
? null
: new ArrayList<>(font.getType3Glyphs()))
.conversionCandidates(
font.getConversionCandidates() == null
? null
: new ArrayList<>(font.getConversionCandidates()))
.toUnicode(font.getToUnicode())
.standard14Name(font.getStandard14Name())
.fontDescriptorFlags(font.getFontDescriptorFlags())
.ascent(font.getAscent())
.descent(font.getDescent())
.capHeight(font.getCapHeight())
.xHeight(font.getXHeight())
.italicAngle(font.getItalicAngle())
.unitsPerEm(font.getUnitsPerEm())
.cosDictionary(font.getCosDictionary())
.build();
try {
byte[] bytes = objectMapper.writeValueAsBytes(font);
return objectMapper.readValue(bytes, PdfJsonFont.class);
} catch (Exception ex) {
log.debug(
"Failed deep-cloning font {} via roundtrip: {}", font.getId(), ex.getMessage());
PdfJsonCosValue cosClone = null;
try {
if (font.getCosDictionary() != null) {
byte[] cosBytes = objectMapper.writeValueAsBytes(font.getCosDictionary());
cosClone = objectMapper.readValue(cosBytes, PdfJsonCosValue.class);
}
} catch (Exception cosEx) {
log.debug(
"Failed deep-cloning font cosDictionary {}: {}",
font.getId(),
cosEx.getMessage());
}
return PdfJsonFont.builder()
.id(font.getId())
.pageNumber(font.getPageNumber())
.uid(font.getUid())
.baseName(font.getBaseName())
.subtype(font.getSubtype())
.encoding(font.getEncoding())
.cidSystemInfo(font.getCidSystemInfo())
.embedded(font.getEmbedded())
.program(font.getProgram())
.programFormat(font.getProgramFormat())
.webProgram(font.getWebProgram())
.webProgramFormat(font.getWebProgramFormat())
.pdfProgram(font.getPdfProgram())
.pdfProgramFormat(font.getPdfProgramFormat())
.type3Glyphs(
font.getType3Glyphs() == null
? null
: new ArrayList<>(font.getType3Glyphs()))
.conversionCandidates(
font.getConversionCandidates() == null
? null
: new ArrayList<>(font.getConversionCandidates()))
.toUnicode(font.getToUnicode())
.standard14Name(font.getStandard14Name())
.fontDescriptorFlags(font.getFontDescriptorFlags())
.ascent(font.getAscent())
.descent(font.getDescent())
.capHeight(font.getCapHeight())
.xHeight(font.getXHeight())
.italicAngle(font.getItalicAngle())
.unitsPerEm(font.getUnitsPerEm())
.cosDictionary(cosClone)
.build();
}
}
private void applyLightweightTransformations(PdfJsonDocument document) {
@@ -1054,6 +1099,9 @@ public class PdfJsonConversionService {
}
private void logFontPayloadStats(List<PdfJsonFont> fonts, String label) {
if (!log.isDebugEnabled()) {
return;
}
if (fonts == null || fonts.isEmpty()) {
return;
}
@@ -1095,7 +1143,7 @@ public class PdfJsonConversionService {
}
}
log.info(
log.debug(
"Font payload stats ({}): fonts={}, programBytes={}, webProgramBytes={}, pdfProgramBytes={}, toUnicodeBytes={}, maxFontPayloadBytes={} (fontId={})",
label,
fonts.size(),
@@ -1176,7 +1224,7 @@ public class PdfJsonConversionService {
logDuplicateSummary("resources", label, resourceStats);
logDuplicateSummary("fontCosDictionary", label, fontDictStats);
logDuplicateSummary("annotationRawData", label, annotationStats);
log.info(
log.debug(
"PDF JSON analysis ({}): images={} imageDataBytes={} textElements={} textChars={}",
label,
imageCount,
@@ -1189,7 +1237,7 @@ public class PdfJsonConversionService {
long metadataBytes = sizeOfObject(pdfJson.getMetadata());
long xmpBytes = sizeOfObject(pdfJson.getXmpMetadata());
long formFieldsBytes = sizeOfObject(pdfJson.getFormFields());
log.info(
log.debug(
"PDF JSON analysis ({}): sectionSizes fonts={} pages={} metadata={} xmp={} formFields={}",
label,
fontsBytes,
@@ -1219,9 +1267,10 @@ public class PdfJsonConversionService {
.map(
s ->
String.format(
"page=%d size=%d", s.pageNumber, s.sizeBytes))
"page=%d size=%d",
s.pageNumber, s.sizeBytes))
.collect(java.util.stream.Collectors.joining("; "));
log.info("PDF JSON analysis ({}): topPageSizes -> {}", label, top);
log.debug("PDF JSON analysis ({}): topPageSizes -> {}", label, top);
topPages.stream()
.limit(3)
@@ -1233,7 +1282,7 @@ public class PdfJsonConversionService {
long annotations = sizeOfObject(page.getAnnotations());
long textElements = sizeOfObject(page.getTextElements());
long imageElements = sizeOfObject(page.getImageElements());
log.info(
log.debug(
"PDF JSON analysis ({}): pageBreakdown page={} total={} resources={} contentStreams={} annotations={} textElements={} imageElements={}",
label,
s.pageNumber,
@@ -1259,8 +1308,10 @@ public class PdfJsonConversionService {
if (bytes.length == 0) {
return;
}
String hash = Base64.getEncoder().encodeToString(
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
String hash =
Base64.getEncoder()
.encodeToString(
java.security.MessageDigest.getInstance("SHA-256").digest(bytes));
DuplicateStats entry = stats.computeIfAbsent(hash, k -> new DuplicateStats());
entry.count++;
if (entry.sizeBytes == 0) {
@@ -1276,10 +1327,7 @@ public class PdfJsonConversionService {
List<DuplicateStats> duplicates =
stats.values().stream()
.filter(s -> s.count > 1)
.sorted(
(a, b) ->
Long.compare(
b.totalBytesSaved(), a.totalBytesSaved()))
.sorted((a, b) -> Long.compare(b.totalBytesSaved(), a.totalBytesSaved()))
.limit(5)
.toList();
@@ -1295,11 +1343,7 @@ public class PdfJsonConversionService {
"count=%d size=%d potentialSavings=%d",
s.count, s.sizeBytes, s.totalBytesSaved()))
.collect(java.util.stream.Collectors.joining("; "));
log.info(
"PDF JSON analysis ({}): top duplicates for {} -> {}",
label,
category,
summary);
log.debug("PDF JSON analysis ({}): top duplicates for {} -> {}", label, category, summary);
}
private boolean isPdfJsonDebugAnalyzeEnabled() {
@@ -1374,12 +1418,28 @@ public class PdfJsonConversionService {
}
}
private void stripFontProgramPayloads(List<PdfJsonFont> fonts) {
if (fonts == null || fonts.isEmpty()) {
return;
}
for (PdfJsonFont font : fonts) {
if (font == null) {
continue;
}
font.setProgram(null);
font.setProgramFormat(null);
font.setWebProgram(null);
font.setWebProgramFormat(null);
font.setPdfProgram(null);
font.setPdfProgramFormat(null);
}
}
private void stripFontCosStreamData(List<PdfJsonFont> fonts) {
if (fonts == null || fonts.isEmpty()) {
return;
}
Set<PdfJsonCosValue> visited =
Collections.newSetFromMap(new IdentityHashMap<>());
Set<PdfJsonCosValue> visited = Collections.newSetFromMap(new IdentityHashMap<>());
for (PdfJsonFont font : fonts) {
if (font == null) {
continue;
@@ -1826,9 +1886,9 @@ public class PdfJsonConversionService {
if (!fallbackFontService.canEncodeFully(font, text)) {
String fontName =
fontModel != null && fontModel.getBaseName() != null
? fontModel
.getBaseName()
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
? FONT_SUBSET_PREFIX_PATTERN
.matcher(fontModel.getBaseName())
.replaceAll("") // Remove subset prefix
: (font != null ? font.getName() : "unknown");
String fontKey = fontName + ":" + element.getFontId() + ":" + pageNumber;
if (!warnedFonts.contains(fontKey)) {
@@ -1911,12 +1971,12 @@ public class PdfJsonConversionService {
"[FALLBACK-DEBUG] Reusing cached fallback font {} (key: {})", effectiveId, key);
return font;
}
log.info(
log.debug(
"[FALLBACK-DEBUG] Loading fallback font {} (key: {}) via fallbackFontService",
effectiveId,
key);
PDFont loaded = fallbackFontService.loadFallbackPdfFont(document, effectiveId);
log.info(
log.debug(
"[FALLBACK-DEBUG] Loaded fallback font {} - PDFont class: {}, name: {}",
effectiveId,
loaded.getClass().getSimpleName(),
@@ -2111,7 +2171,7 @@ public class PdfJsonConversionService {
PDStream fontFile3 = descriptor.getFontFile3();
if (fontFile3 != null) {
String subtype = fontFile3.getCOSObject().getNameAsString(COSName.SUBTYPE);
log.info(
log.debug(
"[FONT-DEBUG] Font {}: Found FontFile3 with subtype {}",
font.getName(),
subtype);
@@ -2286,7 +2346,10 @@ public class PdfJsonConversionService {
if (plusIndex >= 0 && plusIndex < normalized.length() - 1) {
normalized = normalized.substring(plusIndex + 1);
}
normalized = normalized.toLowerCase(Locale.ROOT).replaceAll("[\\s\\-_]", "");
normalized =
WHITESPACE_DASH_UNDERSCORE_PATTERN
.matcher(normalized.toLowerCase(Locale.ROOT))
.replaceAll("");
// Exact match after normalization
try {
@@ -2383,14 +2446,15 @@ public class PdfJsonConversionService {
// imageElements
COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES);
COSBase filteredResources = filterImageXObjectsFromResources(resourcesBase);
PdfJsonCosValue resourcesModel =
omitResourceStreamData
? cosMapper.serializeCosValue(
filteredResources,
PdfJsonCosMapper.SerializationContext.RESOURCES_LIGHTWEIGHT)
: cosMapper.serializeCosValue(filteredResources);
pageModel.setResources(resourcesModel);
pageModel.setContentStreams(extractContentStreams(page, true));
if (omitResourceStreamData) {
// In lightweight editor mode, omit heavy resource/content stream payloads entirely.
// Partial export preserves originals from cached PDF when these fields are missing.
pageModel.setResources(null);
pageModel.setContentStreams(null);
} else {
pageModel.setResources(cosMapper.serializeCosValue(filteredResources));
pageModel.setContentStreams(extractContentStreams(page, false));
}
pages.add(pageModel);
pageIndex++;
}
@@ -3274,8 +3338,8 @@ public class PdfJsonConversionService {
baseFontModel.getUid(), Collections.emptySet())
: Collections.emptySet();
boolean hasNormalizedType3 = baseIsType3 && normalizedType3Font != null;
if (hasNormalizedType3 && log.isInfoEnabled()) {
log.info(
if (hasNormalizedType3 && log.isDebugEnabled()) {
log.debug(
"[TYPE3-RUNTIME] Using normalized library font {} for Type3 resource {} on page {}",
normalizedType3Font.getName(),
baseFontModel != null ? baseFontModel.getId() : baseFontId,
@@ -3405,7 +3469,7 @@ public class PdfJsonConversionService {
}
if (rawType3CodesUsed) {
log.info(
log.debug(
"[TYPE3-RUNTIME] Reused original Type3 charCodes for font {} on page {} ({} glyphs)",
baseFontModel != null ? baseFontModel.getId() : baseFontId,
pageNumber,
@@ -3669,7 +3733,7 @@ public class PdfJsonConversionService {
if (value == null) {
return "";
}
String trimmed = value.replaceAll("\s+", " ").trim();
String trimmed = WHITESPACE_PATTERN.matcher(value).replaceAll(" ").trim();
if (trimmed.length() <= 32) {
return trimmed;
}
@@ -4045,22 +4109,22 @@ public class PdfJsonConversionService {
// NOTE: Do NOT sanitize encoded bytes for normalized Type3 fonts
// Multi-byte encodings (UTF-16BE, CID fonts) have null bytes that are essential
// Removing them corrupts the byte boundaries and produces garbled text
log.info(
log.debug(
"[TYPE3] Encoded text '{}' for normalized font {}: encoded={} bytes",
text.length() > 20 ? text.substring(0, 20) + "..." : text,
fontModel.getId(),
encoded != null ? encoded.length : 0);
if (encoded != null && encoded.length > 0) {
log.info(
log.debug(
"[TYPE3] Successfully encoded text for normalized Type3 font {} using standard encoding",
fontModel.getId());
return encoded;
}
log.info(
log.debug(
"[TYPE3] Standard encoding produced empty result for normalized Type3 font {}, falling through to Type3 mapping",
fontModel.getId());
} catch (IOException | IllegalArgumentException ex) {
log.info(
log.debug(
"[TYPE3] Standard encoding failed for normalized Type3 font {}: {}",
fontModel.getId(),
ex.getMessage());
@@ -4586,7 +4650,7 @@ public class PdfJsonConversionService {
// Last resort: Fuzzy match baseName against Standard14 fonts
Standard14Fonts.FontName fuzzyMatch = fuzzyMatchStandard14(fontModel.getBaseName());
if (fuzzyMatch != null) {
log.info(
log.debug(
"Fuzzy-matched font {} (baseName: {}) to Standard14 font {}",
fontModel.getId(),
fontModel.getBaseName(),
@@ -4620,7 +4684,7 @@ public class PdfJsonConversionService {
document, fontModel, source, originalFormat, true, true, true);
if (font != null) {
type3NormalizedFontCache.put(cacheKey, font);
log.info(
log.debug(
"Cached normalized font {} for Type3 {} (key: {})",
source.originLabel(),
fontModel.getId(),
@@ -4667,7 +4731,7 @@ public class PdfJsonConversionService {
String originLabel = source.originLabel();
try {
if (!skipMetadataLog) {
log.info(
log.debug(
"[FONT-DEBUG] Attempting to load font {} using payload {} (format={}, size={} bytes)",
fontModel.getId(),
originLabel,
@@ -4694,7 +4758,7 @@ public class PdfJsonConversionService {
// so all glyphs are available for editing
boolean willBeSubset = !originLabel.contains("type3-library");
if (!willBeSubset) {
log.info(
log.debug(
"[TYPE3-RUNTIME] Loading library font {} WITHOUT subsetting (full glyph set) from {}",
fontModel.getId(),
originLabel);
@@ -4746,7 +4810,7 @@ public class PdfJsonConversionService {
try {
restored = cosMapper.deserializeCosValue(fontModel.getCosDictionary(), document);
} catch (Exception ex) {
log.warn(
log.debug(
"[FONT-RESTORE] Font {} cosDictionary deserialization failed: {}",
fontModel.getId(),
ex.getMessage());
@@ -4754,7 +4818,7 @@ public class PdfJsonConversionService {
}
if (!(restored instanceof COSDictionary cosDictionary)) {
log.warn(
log.debug(
"[FONT-RESTORE] Font {} cosDictionary deserialized to {} instead of COSDictionary",
fontModel.getId(),
restored != null ? restored.getClass().getSimpleName() : "null");
@@ -4764,7 +4828,7 @@ public class PdfJsonConversionService {
// Validate that dictionary contains required font keys
if (!cosDictionary.containsKey(org.apache.pdfbox.cos.COSName.TYPE)
|| !cosDictionary.containsKey(org.apache.pdfbox.cos.COSName.SUBTYPE)) {
log.warn(
log.debug(
"[FONT-RESTORE] Font {} cosDictionary missing required Type or Subtype keys",
fontModel.getId());
return null;
@@ -4773,14 +4837,14 @@ public class PdfJsonConversionService {
try {
PDFont font = PDFontFactory.createFont(cosDictionary);
if (font == null) {
log.warn(
log.debug(
"[FONT-RESTORE] Font {} PDFontFactory returned null for valid dictionary",
fontModel.getId());
return null;
}
if (!font.isEmbedded()) {
log.warn(
log.debug(
"[FONT-RESTORE] Font {} restored from dictionary but is not embedded; rejecting to avoid system font substitution",
fontModel.getId());
return null;
@@ -4794,7 +4858,7 @@ public class PdfJsonConversionService {
return font;
} catch (IOException ex) {
log.warn(
log.debug(
"[FONT-RESTORE] Failed to restore font {} from dictionary ({}): {}",
fontModel.getId(),
fontModel.getSubtype(),
@@ -6060,11 +6124,12 @@ public class PdfJsonConversionService {
docMetadata.setMetadata(extractMetadata(document));
docMetadata.setXmpMetadata(extractXmpMetadata(document));
List<PdfJsonFont> serializedFonts = new ArrayList<>(fonts.values());
List<PdfJsonFont> serializedFonts = cloneFontList(fonts.values());
serializedFonts.sort(
Comparator.comparing(
PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder())));
dedupeFontPayloads(serializedFonts);
stripFontProgramPayloads(serializedFonts);
stripFontCosStreamData(serializedFonts);
docMetadata.setFonts(serializedFonts);
@@ -6082,7 +6147,9 @@ public class PdfJsonConversionService {
pageIndex++;
}
docMetadata.setPageDimensions(pageDimensions);
docMetadata.setFormFields(collectFormFields(document));
// Metadata endpoint is used for lazy editor bootstrapping; omit form fields to avoid
// shipping large duplicate raw dictionaries before any edit occurs.
docMetadata.setFormFields(null);
docMetadata.setLazyImages(Boolean.TRUE);
// Cache PDF bytes, metadata, and fonts for lazy page loading
@@ -6254,11 +6321,8 @@ public class PdfJsonConversionService {
// Extract resources and content streams
COSBase resourcesBase = page.getCOSObject().getDictionaryObject(COSName.RESOURCES);
COSBase filteredResources = filterImageXObjectsFromResources(resourcesBase);
pageModel.setResources(
cosMapper.serializeCosValue(
filteredResources,
PdfJsonCosMapper.SerializationContext.RESOURCES_LIGHTWEIGHT));
pageModel.setContentStreams(extractContentStreams(page, true));
pageModel.setResources(null);
pageModel.setContentStreams(null);
log.debug(
"Extracted page {} (text: {}, images: {}, annotations: {}) for jobId: {}",
@@ -6272,11 +6336,59 @@ public class PdfJsonConversionService {
}
}
public byte[] extractPageFonts(String jobId, int pageNumber) throws IOException {
CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
throw new stirling.software.SPDF.exception.CacheUnavailableException(
"No cached document found for jobId: " + jobId);
}
int totalPages = cached.getMetadata().getPageDimensions().size();
if (pageNumber < 1 || pageNumber > totalPages) {
throw new IllegalArgumentException(
String.format("pageNumber must be between 1 and %d", totalPages));
}
Map<Integer, Map<PDFont, String>> pageFontResources = cached.getPageFontResources();
Map<PDFont, String> pageMap =
pageFontResources != null ? pageFontResources.get(pageNumber) : null;
if (pageMap == null || pageMap.isEmpty()) {
return objectMapper.writeValueAsBytes(Collections.emptyList());
}
Map<String, PdfJsonFont> cachedFonts = cached.getFonts();
List<PdfJsonFont> pageFonts = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (String fontId : pageMap.values()) {
if (fontId == null || fontId.isBlank()) {
continue;
}
String key = buildFontKey(jobId, pageNumber, fontId);
if (!seen.add(key)) {
continue;
}
PdfJsonFont font = cachedFonts.get(key);
if (font == null) {
// Fallback to unscoped key for resilience with legacy cached entries.
font = cachedFonts.get(buildFontKey(null, pageNumber, fontId));
}
if (font == null) {
continue;
}
PdfJsonFont clone = cloneFont(font);
pageFonts.add(clone != null ? clone : font);
}
pageFonts.sort(
Comparator.comparing(
PdfJsonFont::getUid, Comparator.nullsLast(Comparator.naturalOrder())));
return objectMapper.writeValueAsBytes(pageFonts);
}
public byte[] exportUpdatedPages(String jobId, PdfJsonDocument updates) throws IOException {
if (jobId == null || jobId.isBlank()) {
throw new IllegalArgumentException("jobId is required for incremental export");
}
log.info("Looking up cache for jobId: {}", jobId);
log.debug("Looking up cache for jobId: {}", jobId);
CachedPdfDocument cached = getCachedDocument(jobId);
if (cached == null) {
log.error(
@@ -6286,7 +6398,7 @@ public class PdfJsonConversionService {
throw new stirling.software.SPDF.exception.CacheUnavailableException(
"No cached document available for jobId: " + jobId);
}
log.info(
log.debug(
"Found cached document for jobId: {} (size={}, diskBacked={})",
jobId,
cached.getPdfSize(),
@@ -6467,7 +6579,8 @@ public class PdfJsonConversionService {
shouldPreserveExistingAnnotations(pageModel.getAnnotations());
boolean preserveExistingContentStreams =
shouldPreserveExistingContentStreams(pageModel.getContentStreams());
boolean preserveExistingResources = shouldPreserveExistingResources(pageModel.getResources());
boolean preserveExistingResources =
shouldPreserveExistingResources(pageModel.getResources());
PDRectangle currentBox = page.getMediaBox();
float fallbackWidth = currentBox != null ? currentBox.getWidth() : 612f;
@@ -6627,9 +6740,12 @@ public class PdfJsonConversionService {
}
private boolean shouldPreserveExistingAnnotations(List<PdfJsonAnnotation> annotations) {
if (annotations == null || annotations.isEmpty()) {
if (annotations == null) {
return true;
}
if (annotations.isEmpty()) {
return false;
}
for (PdfJsonAnnotation annotation : annotations) {
if (annotation == null || annotation.getRawData() == null) {
return true;
@@ -6642,7 +6758,10 @@ public class PdfJsonConversionService {
}
private boolean shouldPreserveExistingContentStreams(List<PdfJsonStream> streams) {
if (streams == null || streams.isEmpty()) {
if (streams == null) {
return true;
}
if (streams.isEmpty()) {
return false;
}
for (PdfJsonStream stream : streams) {
@@ -6654,6 +6773,9 @@ public class PdfJsonConversionService {
}
private boolean shouldPreserveExistingResources(PdfJsonCosValue resources) {
if (resources == null) {
return true;
}
return hasMissingStreamData(resources);
}
@@ -6673,9 +6795,16 @@ public class PdfJsonConversionService {
}
private boolean hasMissingStreamData(PdfJsonCosValue value) {
return hasMissingStreamData(value, Collections.newSetFromMap(new IdentityHashMap<>()));
}
private boolean hasMissingStreamData(PdfJsonCosValue value, Set<PdfJsonCosValue> visited) {
if (value == null || value.getType() == null) {
return false;
}
if (!visited.add(value)) {
return false;
}
switch (value.getType()) {
case STREAM:
PdfJsonStream stream = value.getStream();
@@ -6683,7 +6812,7 @@ public class PdfJsonConversionService {
case ARRAY:
if (value.getItems() != null) {
for (PdfJsonCosValue item : value.getItems()) {
if (hasMissingStreamData(item)) {
if (hasMissingStreamData(item, visited)) {
return true;
}
}
@@ -6692,7 +6821,7 @@ public class PdfJsonConversionService {
case DICTIONARY:
if (value.getEntries() != null) {
for (PdfJsonCosValue entry : value.getEntries().values()) {
if (hasMissingStreamData(entry)) {
if (hasMissingStreamData(entry, visited)) {
return true;
}
}
@@ -45,7 +45,7 @@ public class PdfJsonCosMapper {
RESOURCES_LIGHTWEIGHT;
public boolean omitStreamData() {
return this != DEFAULT;
return this == CONTENT_STREAMS_LIGHTWEIGHT || this == RESOURCES_LIGHTWEIGHT;
}
}
@@ -74,8 +74,7 @@ public class PdfJsonCosMapper {
if (cosStream == null) {
return null;
}
SerializationContext effective =
context != null ? context : SerializationContext.DEFAULT;
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
return serializeStream(
cosStream, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
}
@@ -97,8 +96,7 @@ public class PdfJsonCosMapper {
public PdfJsonCosValue serializeCosValue(COSBase base, SerializationContext context)
throws IOException {
SerializationContext effective =
context != null ? context : SerializationContext.DEFAULT;
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
return serializeCosValue(
base, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
}
@@ -274,15 +272,16 @@ public class PdfJsonCosMapper {
return builder.build();
}
if (base instanceof COSStream stream) {
builder.type(PdfJsonCosValue.Type.STREAM)
.stream(serializeStream(stream, visited, context));
builder.type(PdfJsonCosValue.Type.STREAM).stream(
serializeStream(stream, visited, context));
return builder.build();
}
if (base instanceof COSDictionary dictionary) {
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
for (COSName key : dictionary.keySet()) {
PdfJsonCosValue serialized =
serializeCosValue(dictionary.getDictionaryObject(key), visited, context);
serializeCosValue(
dictionary.getDictionaryObject(key), visited, context);
entries.put(key.getName(), serialized);
}
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
@@ -7,6 +7,7 @@ import java.io.InputStream;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDFont;
@@ -310,6 +311,11 @@ public class PdfJsonFallbackFontService {
"classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf",
"DejaVuSansMono-BoldOblique",
"ttf")));
private static final Pattern BOLD_FONT_WEIGHT_PATTERN =
Pattern.compile(".*[_-]?[6-9]00(wght)?.*");
private static final Pattern FONT_NAME_DELIMITER_PATTERN = Pattern.compile("[-_,+]");
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
private static final Pattern PATTERN = Pattern.compile("^[A-Z]{6}\\+");
private final ResourceLoader resourceLoader;
private final stirling.software.common.model.ApplicationProperties applicationProperties;
@@ -418,16 +424,18 @@ public class PdfJsonFallbackFontService {
// Normalize font name: remove subset prefix (e.g. "PXAAAC+"), convert to lowercase,
// remove spaces
String normalized =
originalFontName
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
.toLowerCase()
.replaceAll("\\s+", ""); // Remove spaces (e.g. "Times New Roman" ->
WHITESPACE_PATTERN
.matcher(
PATTERN.matcher(originalFontName)
.replaceAll("") // Remove subset prefix
.toLowerCase())
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
// "timesnewroman")
// Extract base name without weight/style suffixes
// Split on common delimiters: hyphen, underscore, comma, plus
// Handles: "Arimo_700wght" -> "arimo", "Arial-Bold" -> "arial", "Arial,Bold" -> "arial"
String baseName = normalized.split("[-_,+]")[0];
String baseName = FONT_NAME_DELIMITER_PATTERN.split(normalized)[0];
String aliasedFontId = FONT_NAME_ALIASES.get(baseName);
if (aliasedFontId != null) {
@@ -470,7 +478,7 @@ public class PdfJsonFallbackFontService {
// Check for numeric weight indicators (600-900 = bold)
// Handles: "Arimo_700wght", "Arial-700", "Font-w700"
if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) {
if (BOLD_FONT_WEIGHT_PATTERN.matcher(normalizedFontName).matches()) {
return true;
}
@@ -514,7 +522,7 @@ public class PdfJsonFallbackFontService {
// Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono)
boolean isSupported =
baseFontId.startsWith("fallback-liberation-")
|| baseFontId.equals("fallback-noto-sans")
|| "fallback-noto-sans".equals(baseFontId)
|| baseFontId.startsWith("fallback-dejavu-");
if (!isSupported) {
@@ -523,8 +531,8 @@ public class PdfJsonFallbackFontService {
// DejaVu Sans and Mono use "oblique" instead of "italic"
boolean useOblique =
baseFontId.equals("fallback-dejavu-sans")
|| baseFontId.equals("fallback-dejavu-mono");
"fallback-dejavu-sans".equals(baseFontId)
|| "fallback-dejavu-mono".equals(baseFontId);
if (isBold && isItalic) {
return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic");
@@ -9,6 +9,7 @@ import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
@@ -27,6 +28,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
@Slf4j
public class SharedSignatureService {
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper;
@@ -105,7 +107,7 @@ public class SharedSignatureService {
throw new IllegalArgumentException("Invalid filename");
}
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
@@ -113,7 +115,7 @@ public class SharedSignatureService {
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
// Whitelist only safe image extensions
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
@@ -64,7 +64,10 @@ security:
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
validation: # PDF signature validation settings
trust:
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
@@ -230,10 +233,12 @@ ui:
appNameNavbar: "" # name displayed on the navigation bar
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
endpoints:
toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice'])
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice', 'DeveloperTools', 'DeveloperDocs', 'Automation'])
metrics:
enabled: true # 'true' to enable Info APIs (`/api/*`) endpoints, 'false' to disable
@@ -18,6 +18,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.junit.jupiter.api.AfterEach;
@@ -46,6 +47,7 @@ import stirling.software.common.util.WebResponseUtils;
public class ConvertWebsiteToPdfTest {
private static final Pattern PDF_FILENAME_PATTERN = Pattern.compile("[A-Za-z0-9_]+\\.pdf");
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private RuntimePathConfig runtimePathConfig;
@@ -142,7 +144,7 @@ public class ConvertWebsiteToPdfTest {
assertTrue(out.endsWith(".pdf"));
// Only AZ, az, 09, underscore and dot allowed
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
// no truncation here (source not that long)
assertTrue(out.length() <= 54);
}
@@ -159,7 +161,7 @@ public class ConvertWebsiteToPdfTest {
String out = (String) m.invoke(sut, longUrl);
assertTrue(out.endsWith(".pdf"));
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
// safeName limited to 50 -> total max 54 including '.pdf'
assertTrue(out.length() <= 54, "Filename should be truncated to 50 + '.pdf'");
}
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
@@ -26,6 +27,18 @@ import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class StampControllerTest {
private static final Pattern UUID_HEX_PATTERN = Pattern.compile("[0-9a-f]{8}");
private static final Pattern DATE_LITERAL_REGEX =
Pattern.compile("@date is \\d{4}-\\d{2}-\\d{2}");
private static final Pattern DATE_TIME_MIN_PATTERN =
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}");
private static final Pattern DATE_SLASH_PATTERN = Pattern.compile("\\d{2}/\\d{2}/\\d{4}");
private static final Pattern DAY_LABEL_PATTERN = Pattern.compile("Day: \\d{2}");
private static final Pattern MONTH_LABEL_PATTERN = Pattern.compile("Month: \\d{2}");
private static final Pattern DATE_TIME_FULL_PATTERN =
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}");
private static final Pattern TIME_LABEL_PATTERN = Pattern.compile("Time: \\d{2}:\\d{2}:\\d{2}");
private static final Pattern DATE_LABEL_PATTERN = Pattern.compile("Date: \\d{4}-\\d{2}-\\d{2}");
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@@ -173,7 +186,7 @@ class StampControllerTest {
void testDateReplacement() throws Exception {
String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
assertTrue(
result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
DATE_LABEL_PATTERN.matcher(result).matches(),
"Date should match YYYY-MM-DD format");
}
@@ -182,7 +195,7 @@ class StampControllerTest {
void testTimeReplacement() throws Exception {
String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
assertTrue(
result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
TIME_LABEL_PATTERN.matcher(result).matches(),
"Time should match HH:mm:ss format");
}
@@ -192,7 +205,7 @@ class StampControllerTest {
String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
// DateTime format: YYYY-MM-DD HH:mm:ss
assertTrue(
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
DATE_TIME_FULL_PATTERN.matcher(result).matches(),
"DateTime should match YYYY-MM-DD HH:mm:ss format");
}
@@ -208,14 +221,15 @@ class StampControllerTest {
@DisplayName("Should replace @month with zero-padded month")
void testMonthReplacement() throws Exception {
String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
assertTrue(
MONTH_LABEL_PATTERN.matcher(result).matches(), "Month should be zero-padded");
}
@Test
@DisplayName("Should replace @day with zero-padded day")
void testDayReplacement() throws Exception {
String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
assertTrue(DAY_LABEL_PATTERN.matcher(result).matches(), "Day should be zero-padded");
}
}
@@ -228,7 +242,7 @@ class StampControllerTest {
void testCustomDateFormatSlash() throws Exception {
String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
assertTrue(
result.matches("\\d{2}/\\d{2}/\\d{4}"),
DATE_SLASH_PATTERN.matcher(result).matches(),
"Should match dd/MM/yyyy format: " + result);
}
@@ -238,7 +252,7 @@ class StampControllerTest {
String result =
invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
assertTrue(
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
DATE_TIME_MIN_PATTERN.matcher(result).matches(),
"Should match yyyy-MM-dd HH:mm format: " + result);
}
@@ -345,7 +359,7 @@ class StampControllerTest {
// @@date should become @date, and @date should be replaced with actual date
assertTrue(result.startsWith("@date is "), "Should start with literal @date");
assertTrue(
result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
DATE_LITERAL_REGEX.matcher(result).matches(),
"Should have date after: " + result);
}
@@ -463,7 +477,9 @@ class StampControllerTest {
@DisplayName("UUID should contain only hex characters")
void testUuidFormat() throws Exception {
String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
assertTrue(
UUID_HEX_PATTERN.matcher(result).matches(),
"UUID should be 8 hex characters: " + result);
}
}
@@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.WebResponseUtils;
@@ -104,12 +105,40 @@ public class FormFillController {
requirePdf(file);
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
FormUtils.repairMissingWidgetPageReferences(document);
FormUtils.FormFieldExtraction extraction =
FormUtils.extractFieldsWithTemplate(document);
return ResponseEntity.ok(extraction);
}
}
@PostMapping(value = "/fields-with-coordinates", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Inspect PDF form fields with widget coordinates",
description =
"Returns metadata describing each field in the provided PDF form, "
+ "including precise widget coordinates for interactive rendering")
public ResponseEntity<List<FormFieldWithCoordinates>> listFieldsWithCoordinates(
@Parameter(
description = "The input PDF file",
required = true,
content =
@Content(
mediaType = MediaType.APPLICATION_PDF_VALUE,
schema = @Schema(type = "string", format = "binary")))
@RequestParam("file")
MultipartFile file)
throws IOException {
requirePdf(file);
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
FormUtils.repairMissingWidgetPageReferences(document);
List<FormFieldWithCoordinates> fields =
FormUtils.extractFormFieldsWithCoordinates(document);
return ResponseEntity.ok(fields);
}
}
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@Operation(
summary = "Modify existing form fields",
@@ -215,6 +244,7 @@ public class FormFillController {
String baseName = buildBaseName(file, suffix);
try (PDDocument document = pdfDocumentFactory.load(file)) {
FormUtils.repairMissingWidgetPageReferences(document);
processor.accept(document);
return saveDocument(document, baseName);
}
@@ -2,7 +2,7 @@ package stirling.software.proprietary.security.configuration;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
@@ -11,15 +11,22 @@ import org.springframework.context.annotation.Configuration;
import com.github.benmanes.caffeine.cache.Caffeine;
import stirling.software.common.model.ApplicationProperties;
@Configuration
@EnableCaching
public class CacheConfig {
@Value("${security.jwt.keyRetentionDays}")
private int keyRetentionDays;
private final ApplicationProperties applicationProperties;
@Autowired
public CacheConfig(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@Bean
public CacheManager cacheManager() {
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(
Caffeine.newBuilder()
@@ -361,7 +361,8 @@ public class SecurityConfiguration {
securityProperties.getOauth2(),
userService,
jwtService,
licenseSettingsService))
licenseSettingsService,
applicationProperties))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
@@ -0,0 +1,51 @@
package stirling.software.proprietary.security.configuration.ee;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import stirling.software.common.service.LicenseServiceInterface;
import stirling.software.proprietary.security.configuration.ee.KeygenLicenseVerifier.License;
/**
* Service that provides dynamic license checking instead of cached beans. This ensures that when
* admins update the license key, the changes are immediately reflected in the UI and config
* endpoints without requiring a restart.
*
* <p>Note: Some components (EnterpriseEndpointAspect, PremiumEndpointAspect, filters) still inject
* cached beans at startup for performance. These will require a restart to reflect license changes.
* This is acceptable because: 1. Most deployments add licenses during initial setup 2. License
* changes in production typically warrant a restart anyway 3. UI reflects changes immediately
* (banner disappears, license status updates)
*/
@Service
@RequiredArgsConstructor
public class DynamicLicenseService implements LicenseServiceInterface {
private final LicenseKeyChecker licenseKeyChecker;
/**
* Get the current license type dynamically (not cached).
*
* @return Current license: NORMAL, SERVER, or ENTERPRISE
*/
public License getCurrentLicense() {
return licenseKeyChecker.getPremiumLicenseEnabledResult();
}
@Override
public boolean isRunningProOrHigher() {
License license = getCurrentLicense();
return license == License.SERVER || license == License.ENTERPRISE;
}
@Override
public boolean isRunningEE() {
return getCurrentLicense() == License.ENTERPRISE;
}
@Override
public String getLicenseTypeName() {
return getCurrentLicense().name();
}
}
@@ -26,6 +26,7 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
@@ -34,12 +35,15 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
/** REST API Controller for authentication operations. */
@RestController
@@ -55,7 +59,9 @@ public class AuthController {
private final LoginAttemptService loginAttemptService;
private final MfaService mfaService;
private final TotpService totpService;
private final RefreshRateLimitService refreshRateLimitService;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties applicationProperties;
/**
* Login endpoint - replaces Supabase signInWithPassword
@@ -171,16 +177,52 @@ public class AuthController {
claims.put("authType", AuthenticationType.WEB.toString());
claims.put("role", user.getRolesAsString());
String token = jwtService.generateToken(user.getUsername(), claims);
// Detect desktop client and issue longer-lived tokens for better UX
// Desktop apps run on personal devices with OS-level encryption (secure storage)
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(httpRequest);
String token;
int keyRetentionDays = securityProperties.getJwt().getKeyRetentionDays();
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
token = jwtService.generateToken(user.getUsername(), claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP token for user '{}': expiry={}min ({}d), keyRetention={}d",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440,
keyRetentionDays);
} else {
// Web: Use configured web expiry (default 24 hours)
token = jwtService.generateToken(user.getUsername(), claims);
int webExpiryMinutes =
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
log.info(
"Issued WEB token for user '{}': expiry={}min ({}d), keyRetention={}d",
username,
webExpiryMinutes,
webExpiryMinutes / 1440,
keyRetentionDays);
}
// Record successful login
loginAttemptService.loginSucceeded(username);
log.info("Login successful for user: {} from IP: {}", username, ip);
log.info(
"Login successful for user: {} from IP: {} (desktop: {})",
username,
ip,
isDesktopClient);
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session", Map.of("access_token", token, "expires_in", 3600)));
"session",
Map.of(
"access_token",
token,
"expires_in",
getTokenExpirySeconds(isDesktopClient))));
} catch (UsernameNotFoundException e) {
String username = request.getUsername();
@@ -272,25 +314,92 @@ public class AuthController {
.body(Map.of("error", "No token found"));
}
jwtService.validateToken(token);
String username = jwtService.extractUsername(token);
// Generate token hash for rate limiting (avoid storing actual tokens)
String tokenHash = generateTokenHash(token);
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
if (!isRefreshWithinGrace(claims)) {
log.warn("Token refresh rejected: token expired beyond configured grace window");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
}
// Only apply rate limiting if token is actually expired (not for valid tokens)
// This prevents false-positive 429 errors with multiple tabs, retries, etc.
long expMillis = extractEpochMillis(claims.get("exp"));
boolean isExpired = expMillis > 0 && expMillis < System.currentTimeMillis();
if (isExpired
&& !refreshRateLimitService.isRefreshAllowed(
tokenHash, getRefreshGraceMillis())) {
log.warn(
"Token refresh rejected: rate limit exceeded (max {} attempts allowed)",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(
Map.of(
"error",
"Too many refresh attempts",
"max_attempts",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE));
}
Object usernameClaim = claims.get("sub");
String username = usernameClaim != null ? usernameClaim.toString() : null;
if (username == null || username.isBlank()) {
log.warn("Token refresh rejected: missing subject claim");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
}
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
User user = (User) userDetails;
Map<String, Object> claims = new HashMap<>();
claims.put("authType", user.getAuthenticationType());
claims.put("role", user.getRolesAsString());
Map<String, Object> newClaims = new HashMap<>();
newClaims.put("authType", user.getAuthenticationType());
newClaims.put("role", user.getRolesAsString());
String newToken = jwtService.generateToken(username, claims);
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String newToken;
if (isDesktopClient) {
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
newToken = jwtService.generateToken(username, newClaims, desktopExpiryMinutes);
log.info(
"Refreshed DESKTOP token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
newToken = jwtService.generateToken(username, newClaims);
int webExpiryMinutes =
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
log.info(
"Refreshed WEB token for user '{}': expiry={}min ({}d)",
username,
webExpiryMinutes,
webExpiryMinutes / 1440);
}
// Don't clear rate limit tracking - let it expire naturally after grace period
// This prevents reusing the same expired token indefinitely
log.debug("Token refreshed for user: {}", username);
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session", Map.of("access_token", newToken, "expires_in", 3600)));
"session",
Map.of(
"access_token",
newToken,
"expires_in",
getTokenExpirySeconds(isDesktopClient))));
} catch (AuthenticationFailureException e) {
log.warn("Token refresh failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
} catch (Exception e) {
log.error("Token refresh error", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
@@ -532,6 +641,95 @@ public class AuthController {
return userMap;
}
private long getTokenExpirySeconds() {
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
int expiryMinutes =
configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
return expiryMinutes * JwtConstants.SECONDS_PER_MINUTE;
}
private long getTokenExpirySeconds(boolean isDesktop) {
if (isDesktop) {
// Desktop: use configured desktop token expiry
return DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties)
* JwtConstants.SECONDS_PER_MINUTE;
}
// Web: use configured web value
return getTokenExpirySeconds();
}
private boolean isRefreshWithinGrace(Map<String, Object> claims) {
long expMillis = extractEpochMillis(claims.get("exp"));
if (expMillis <= 0) {
return false;
}
long now = System.currentTimeMillis();
if (expMillis >= now) {
return true;
}
long expiredForMillis = now - expMillis;
return expiredForMillis <= getRefreshGraceMillis();
}
private long getRefreshGraceMillis() {
int configuredMinutes = securityProperties.getJwt().getRefreshGraceMinutes();
int graceMinutes =
configuredMinutes >= 0
? configuredMinutes
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
return graceMinutes * JwtConstants.MILLIS_PER_MINUTE;
}
private long extractEpochMillis(Object claimValue) {
if (claimValue == null) {
return -1L;
}
if (claimValue instanceof java.util.Date date) {
return date.getTime();
}
if (claimValue instanceof Number number) {
long epochSeconds = number.longValue();
return epochSeconds * 1000L;
}
return -1L;
}
/**
* Generate a hash of the token for rate limiting purposes.
*
* <p>Uses SHA-256 to avoid storing actual token values in memory.
*
* @param token the JWT token
* @return hex-encoded SHA-256 hash of the token
*/
private String generateTokenHash(String token) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] hashBytes =
digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
} catch (java.security.NoSuchAlgorithmException e) {
// Fallback to hashCode if SHA-256 is not available (should never happen)
log.warn("SHA-256 not available, using hashCode for token tracking", e);
return String.valueOf(token.hashCode());
}
}
private ResponseEntity<?> ensureWebAuth(User user) {
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
@@ -9,6 +9,7 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.*;
import java.util.regex.Pattern;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
@@ -36,6 +37,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
@RequiredArgsConstructor
public class UIDataTessdataController {
private static final Pattern INVALID_LANG_CHARS_PATTERN = Pattern.compile("[^A-Za-z0-9_+\\-]");
private final RuntimePathConfig runtimePathConfig;
private static volatile List<String> cachedRemoteTessdata = null;
private static volatile long cachedRemoteTessdataExpiry = 0L;
@@ -88,7 +90,7 @@ public class UIDataTessdataController {
failed.add(language);
continue;
}
String safeLang = language.replaceAll("[^A-Za-z0-9_+\\-]", "");
String safeLang = INVALID_LANG_CHARS_PATTERN.matcher(language).replaceAll("");
if (!safeLang.equals(language)) {
failed.add(language);
continue;
@@ -69,27 +69,28 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (!apiKeyExists(request, response)) {
String jwtToken = jwtService.extractToken(request);
if (jwtToken == null) {
// Allow auth endpoints to pass through without JWT
if (!isPublicAuthEndpoint(requestURI, contextPath)) {
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
if (requestURI.startsWith(contextPath + "/api/")
|| (acceptHeader != null
&& acceptHeader.contains("application/json"))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Authentication required\"}");
return;
}
// Check if this is a public endpoint BEFORE validating JWT
// This allows public endpoints to work even with expired tokens in the request
if (isPublicAuthEndpoint(requestURI, contextPath)) {
// For public auth endpoints, skip JWT validation and continue
filterChain.doFilter(request, response);
return;
}
// For HTML requests (SPA routes), let React Router handle it (serve
// index.html)
filterChain.doFilter(request, response);
if (jwtToken == null) {
// No JWT token and not a public endpoint
// For API requests, return 401 JSON
String acceptHeader = request.getHeader("Accept");
if (requestURI.startsWith(contextPath + "/api/")
|| (acceptHeader != null && acceptHeader.contains("application/json"))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Authentication required\"}");
return;
}
// For public auth endpoints without JWT, continue to the endpoint
// For HTML requests (SPA routes), let React Router handle it (serve
// index.html)
filterChain.doFilter(request, response);
return;
}
@@ -36,6 +36,7 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
@Slf4j
@RequiredArgsConstructor
@@ -48,6 +49,7 @@ public class CustomOAuth2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final ApplicationProperties applicationProperties;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -150,9 +152,27 @@ public class CustomOAuth2AuthenticationSuccessHandler
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
Map<String, Object> claims = Map.of("authType", AuthenticationType.OAUTH2);
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String jwt;
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(
applicationProperties);
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP OAuth2 token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
// Web: Use default expiry
jwt = jwtService.generateToken(authentication, claims);
log.debug("Issued WEB OAuth2 token for user '{}'", username);
}
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
@Slf4j
@ConditionalOnProperty(name = "security.saml2.enabled", havingValue = "true")
@@ -33,9 +34,33 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica
if (exception instanceof Saml2AuthenticationException) {
Saml2Error error = ((Saml2AuthenticationException) exception).getSaml2Error();
if (TauriSamlUtils.isTauriRelayState(request)) {
String redirectUrl =
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
redirectUrl = appendQueryParam(redirectUrl, "errorOAuth", error.getErrorCode());
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
return;
}
getRedirectStrategy()
.sendRedirect(request, response, "/login?errorOAuth=" + error.getErrorCode());
} else if (exception instanceof ProviderNotFoundException) {
if (TauriSamlUtils.isTauriRelayState(request)) {
String redirectUrl =
TauriOAuthUtils.defaultTauriCallbackPath(request.getContextPath());
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
if (nonce != null) {
redirectUrl = appendQueryParam(redirectUrl, "nonce", nonce);
}
redirectUrl =
appendQueryParam(
redirectUrl, "errorOAuth", "not_authentication_provider_found");
getRedirectStrategy().sendRedirect(request, response, redirectUrl);
return;
}
getRedirectStrategy()
.sendRedirect(
request,
@@ -43,4 +68,19 @@ public class CustomSaml2AuthenticationFailureHandler extends SimpleUrlAuthentica
"/login?errorOAuth=not_authentication_provider_found");
}
}
private String appendQueryParam(String path, String key, String value) {
if (path == null || path.isBlank()) {
return path;
}
String separator = path.contains("?") ? "&" : "?";
String encodedKey =
java.net.URLEncoder.encode(key, java.nio.charset.StandardCharsets.UTF_8);
String encodedValue =
value == null
? ""
: java.net.URLEncoder.encode(
value, java.nio.charset.StandardCharsets.UTF_8);
return path + separator + encodedKey + "=" + encodedValue;
}
}
@@ -33,9 +33,11 @@ import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
import stirling.software.proprietary.audit.Audited;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
@AllArgsConstructor
@Slf4j
@@ -190,10 +192,27 @@ public class CustomSaml2AuthenticationSuccessHandler
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
String jwt =
jwtService.generateToken(
authentication,
Map.of("authType", AuthenticationType.SAML2));
Map<String, Object> claims = Map.of("authType", AuthenticationType.SAML2);
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String jwt;
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(
applicationProperties);
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
// Web: Use default expiry
jwt = jwtService.generateToken(authentication, claims);
log.debug("Issued WEB SAML token for user '{}'", username);
}
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -233,7 +252,16 @@ public class CustomSaml2AuthenticationSuccessHandler
String redirectPath = resolveRedirectPath(request, contextPath);
String origin = resolveOrigin(request);
clearRedirectCookie(response);
return origin + redirectPath + "#access_token=" + jwt;
String url = origin + redirectPath + "#access_token=" + jwt;
String nonce = TauriSamlUtils.extractNonceFromRequest(request);
if (nonce != null) {
url +=
"&nonce="
+ java.net.URLEncoder.encode(
nonce, java.nio.charset.StandardCharsets.UTF_8);
}
return url;
}
/**
@@ -256,6 +284,9 @@ public class CustomSaml2AuthenticationSuccessHandler
}
private String resolveRedirectPath(HttpServletRequest request, String contextPath) {
if (TauriSamlUtils.isTauriRelayState(request)) {
return TauriOAuthUtils.defaultTauriCallbackPath(contextPath);
}
return extractRedirectPathFromCookie(request)
.filter(path -> path.startsWith("/"))
.orElseGet(() -> defaultCallbackPath(contextPath));
@@ -156,6 +156,16 @@ public class Saml2Configuration {
OpenSaml4AuthenticationRequestResolver resolver =
new OpenSaml4AuthenticationRequestResolver(relyingPartyRegistrationRepository);
resolver.setRelayStateResolver(
request -> {
String tauriParam = request.getParameter("tauri");
if (!"1".equals(tauriParam)) {
return null;
}
String nonce = request.getParameter("nonce");
return TauriSamlUtils.buildRelayState(nonce);
});
resolver.setAuthnRequestCustomizer(
customizer -> {
HttpServletRequest request = customizer.getRequest();
@@ -0,0 +1,42 @@
package stirling.software.proprietary.security.saml2;
import jakarta.servlet.http.HttpServletRequest;
/** Utility helpers for the Tauri desktop SAML flow. */
public final class TauriSamlUtils {
public static final String TAURI_RELAY_STATE_PREFIX = "tauri:";
private TauriSamlUtils() {
// Utility class - prevent instantiation
}
public static boolean isTauriRelayState(HttpServletRequest request) {
String relayState = request.getParameter("RelayState");
return relayState != null
&& (relayState.equals("tauri") || relayState.startsWith(TAURI_RELAY_STATE_PREFIX));
}
public static String extractNonceFromRelayState(String relayState) {
if (relayState == null || !relayState.startsWith(TAURI_RELAY_STATE_PREFIX)) {
return null;
}
String[] parts = relayState.split(":");
if (parts.length >= 2) {
String nonce = parts[parts.length - 1];
return nonce.isBlank() ? null : nonce;
}
return null;
}
public static String extractNonceFromRequest(HttpServletRequest request) {
return extractNonceFromRelayState(request.getParameter("RelayState"));
}
public static String buildRelayState(String nonce) {
if (nonce == null || nonce.isBlank()) {
return "tauri";
}
return TAURI_RELAY_STATE_PREFIX + nonce;
}
}
@@ -5,6 +5,7 @@ import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
@@ -19,6 +20,9 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
@@ -30,6 +34,8 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
@@ -38,18 +44,20 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
@Service
public class JwtService implements JwtServiceInterface {
private static final String ISSUER = "https://stirling.com";
private static final long EXPIRATION = 43200000;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
private final ApplicationProperties.Security securityProperties;
@Autowired
public JwtService(
@Qualifier("v2Enabled") boolean v2Enabled,
KeyPersistenceServiceInterface keyPersistenceService) {
KeyPersistenceServiceInterface keyPersistenceService,
ApplicationProperties applicationProperties) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
this.securityProperties = applicationProperties.getSecurity();
}
@Override
@@ -84,9 +92,10 @@ public class JwtService implements JwtServiceInterface {
Jwts.builder()
.claims(claims)
.subject(username)
.issuer(ISSUER)
.issuer(JwtConstants.ISSUER)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
.expiration(
new Date(System.currentTimeMillis() + getExpirationMillis()))
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
String keyId = activeKey.getKeyId();
@@ -100,6 +109,40 @@ public class JwtService implements JwtServiceInterface {
}
}
@Override
public String generateToken(String username, Map<String, Object> claims, int expiryMinutes) {
try {
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
Optional<KeyPair> keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId());
if (keyPairOpt.isEmpty()) {
throw new RuntimeException("Unable to retrieve key pair for active key");
}
KeyPair keyPair = keyPairOpt.get();
long customExpirationMillis = expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
var builder =
Jwts.builder()
.claims(claims)
.subject(username)
.issuer(JwtConstants.ISSUER)
.issuedAt(new Date())
.expiration(
new Date(System.currentTimeMillis() + customExpirationMillis))
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
String keyId = activeKey.getKeyId();
if (keyId != null) {
builder.header().keyId(keyId);
}
return builder.compact();
} catch (Exception e) {
throw new RuntimeException("Failed to generate token with custom expiry", e);
}
}
@Override
public void validateToken(String token) throws AuthenticationFailureException {
extractAllClaims(token);
@@ -114,12 +157,23 @@ public class JwtService implements JwtServiceInterface {
return extractClaim(token, Claims::getSubject);
}
@Override
public String extractUsernameAllowExpired(String token) {
return extractClaim(token, Claims::getSubject, true);
}
@Override
public Map<String, Object> extractClaims(String token) {
Claims claims = extractAllClaims(token);
return new HashMap<>(claims);
}
@Override
public Map<String, Object> extractClaimsAllowExpired(String token) {
Claims claims = extractAllClaims(token, true);
return new HashMap<>(claims);
}
@Override
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
@@ -130,11 +184,21 @@ public class JwtService implements JwtServiceInterface {
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token);
final Claims claims = extractAllClaims(token, false);
return claimsResolver.apply(claims);
}
private <T> T extractClaim(
String token, Function<Claims, T> claimsResolver, boolean allowExpired) {
final Claims claims = extractAllClaims(token, allowExpired);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return extractAllClaims(token, false);
}
private Claims extractAllClaims(String token, boolean allowExpired) {
try {
String keyId = extractKeyId(token);
KeyPair keyPair;
@@ -176,11 +240,12 @@ public class JwtService implements JwtServiceInterface {
} else {
log.debug("No key ID in token header, trying all available keys");
// Try all available keys when no keyId is present
return tryAllKeys(token);
return tryAllKeys(token, allowExpired);
}
return Jwts.parser()
.verifyWith(keyPair.getPublic())
.clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
@@ -191,7 +256,13 @@ public class JwtService implements JwtServiceInterface {
log.warn("Invalid token: {}", e.getMessage());
throw new AuthenticationFailureException("Invalid token", e);
} catch (ExpiredJwtException e) {
log.warn("The token has expired: {}", e.getMessage());
if (allowExpired) {
log.debug(
"Extracting claims from expired token (allowed for refresh grace period): {}",
e.getMessage());
return e.getClaims();
}
log.warn("Token validation failed - token has expired: {}", e.getMessage());
throw new AuthenticationFailureException("The token has expired", e);
} catch (UnsupportedJwtException e) {
log.warn("The token is unsupported: {}", e.getMessage());
@@ -202,7 +273,8 @@ public class JwtService implements JwtServiceInterface {
}
}
private Claims tryAllKeys(String token) throws AuthenticationFailureException {
private Claims tryAllKeys(String token, boolean allowExpired)
throws AuthenticationFailureException {
// First try the active key
try {
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
@@ -210,9 +282,18 @@ public class JwtService implements JwtServiceInterface {
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
return Jwts.parser()
.verifyWith(publicKey)
.clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
if (allowExpired) {
log.debug(
"Extracting claims from expired token (allowed for refresh grace period)");
return e.getClaims();
}
log.warn("Token validation failed - token has expired");
throw new AuthenticationFailureException("The token has expired", e);
} catch (SignatureException
| NoSuchAlgorithmException
| InvalidKeySpecException activeKeyException) {
@@ -230,9 +311,15 @@ public class JwtService implements JwtServiceInterface {
verificationKey.getVerifyingKey());
return Jwts.parser()
.verifyWith(publicKey)
.clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
if (allowExpired) {
return e.getClaims();
}
throw new AuthenticationFailureException("The token has expired", e);
} catch (SignatureException
| NoSuchAlgorithmException
| InvalidKeySpecException e) {
@@ -266,24 +353,51 @@ public class JwtService implements JwtServiceInterface {
return v2Enabled;
}
/**
* Extract key ID from JWT header without validating the token.
*
* <p>Parses the Base64-encoded JWT header to retrieve the "kid" (key ID) claim. Returns null if
* the header cannot be parsed or does not contain a key ID.
*
* @param token the JWT token
* @return the key ID, or null if not found or parsing fails
*/
private String extractKeyId(String token) {
try {
PublicKey signingKey =
keyPersistenceService.decodePublicKey(
keyPersistenceService.getActiveKey().getVerifyingKey());
String[] tokenParts = token.split("\\.");
if (tokenParts.length < 2) {
log.debug(
"Token does not have enough parts (expected at least 2, got {})",
tokenParts.length);
return null;
}
String keyId =
(String)
Jwts.parser()
.verifyWith(signingKey)
.build()
.parse(token)
.getHeader()
.get("kid");
return keyId;
} catch (Exception e) {
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]);
Map<String, Object> header =
OBJECT_MAPPER.readValue(
headerBytes, new TypeReference<Map<String, Object>>() {});
Object keyId = header.get("kid");
return keyId instanceof String ? (String) keyId : null;
} catch (IllegalArgumentException e) {
log.debug("Failed to decode Base64 JWT header: {}", e.getMessage());
return null;
} catch (java.io.IOException e) {
log.debug("Failed to parse JWT header as JSON: {}", e.getMessage());
return null;
}
}
private long getExpirationMillis() {
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
int expiryMinutes =
configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
return expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
}
private long getAllowedClockSkewSeconds() {
int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds();
return configuredSeconds >= 0 ? configuredSeconds : JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
}
}
@@ -25,6 +25,16 @@ public interface JwtServiceInterface {
*/
String generateToken(String username, Map<String, Object> claims);
/**
* Generate a JWT token for a specific username with custom expiry
*
* @param username the username for which to generate the token
* @param claims additional claims to include in the token
* @param expiryMinutes custom token lifetime in minutes
* @return JWT token as a string
*/
String generateToken(String username, Map<String, Object> claims, int expiryMinutes);
/**
* Validate a JWT token
*
@@ -41,6 +51,15 @@ public interface JwtServiceInterface {
*/
String extractUsername(String token);
/**
* Extract username from JWT token while allowing expired tokens. Signature and token structure
* must still be valid.
*
* @param token the JWT token
* @return username extracted from token
*/
String extractUsernameAllowExpired(String token);
/**
* Extract all claims from JWT token
*
@@ -49,6 +68,15 @@ public interface JwtServiceInterface {
*/
Map<String, Object> extractClaims(String token);
/**
* Extract all claims from JWT token while allowing expired tokens. Signature and token
* structure must still be valid.
*
* @param token the JWT token
* @return map of claims
*/
Map<String, Object> extractClaimsAllowExpired(String token);
/**
* Check if token is expired
*
@@ -10,8 +10,10 @@ import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -41,6 +43,7 @@ import stirling.software.proprietary.security.model.JwtVerificationKey;
public class KeyPersistenceService implements KeyPersistenceServiceInterface {
public static final String KEY_SUFFIX = ".key";
public static final String PUB_KEY_SUFFIX = ".pub";
private final ApplicationProperties.Security.Jwt jwtProperties;
private final CacheManager cacheManager;
@@ -59,19 +62,119 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
@PostConstruct
public void initializeKeystore() {
if (!isKeystoreEnabled()) {
log.info("JWT keystore is disabled - keys will be generated in memory");
return;
}
try {
ensurePrivateKeyDirectoryExists();
loadKeyPair();
loadExistingKeysFromDisk();
} catch (Exception e) {
log.error("Failed to initialize keystore, using in-memory generation", e);
}
}
private void loadKeyPair() {
if (activeKey == null) {
/**
* Load all existing JWT keys from disk into memory on startup.
*
* <p>This ensures tokens signed with previous keys remain valid after server restart. If no
* keys exist on disk, generates a new keypair.
*/
private void loadExistingKeysFromDisk() {
try {
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
if (!Files.exists(keyDirectory)) {
log.info("No existing keys found, generating new keypair");
generateAndStoreKeypair();
return;
}
List<Path> keyFiles;
try (var stream = Files.list(keyDirectory)) {
keyFiles =
stream.filter(path -> path.toString().endsWith(KEY_SUFFIX))
.sorted(
(a, b) ->
b.getFileName().compareTo(a.getFileName())) // Most
// recent
// first
.collect(Collectors.toList());
}
if (keyFiles.isEmpty()) {
log.info("No existing keys found in directory, generating new keypair");
generateAndStoreKeypair();
return;
}
log.info("Loading {} existing JWT keys from disk", keyFiles.size());
int loadedCount = 0;
for (Path keyFile : keyFiles) {
try {
String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, "");
// Load private key first
PrivateKey privateKey = loadPrivateKey(keyId);
// Try to load public key, or generate it from private key if missing
// (migration)
String encodedPublicKey;
try {
encodedPublicKey = loadPublicKey(keyId);
} catch (IOException e) {
// Public key file doesn't exist - generate it from private key (migration)
log.info("Migrating legacy key: generating public key file for {}", keyId);
KeyPair keyPair = reconstructKeyPair(privateKey);
// Save the public key file
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
encodedPublicKey = encodePublicKey(keyPair.getPublic());
Files.writeString(publicKeyFile, encodedPublicKey);
publicKeyFile.toFile().setReadable(true, true);
publicKeyFile.toFile().setWritable(true, true);
publicKeyFile.toFile().setExecutable(false, false);
log.info("Successfully migrated key: {}", keyId);
}
// Create verification key and add to cache
JwtVerificationKey verifyingKey =
new JwtVerificationKey(keyId, encodedPublicKey);
verifyingKeyCache.put(keyId, verifyingKey);
loadedCount++;
// Set the most recent key as active (first in sorted list)
if (activeKey == null) {
activeKey = verifyingKey;
log.info("Set active JWT signing key: {}", keyId);
} else {
log.debug(
"Loaded historical JWT key: {} (created: {})",
keyId,
verifyingKey.getCreatedAt());
}
} catch (Exception e) {
log.warn(
"Failed to load key: {}, skipping. Error: {}",
keyFile.getFileName(),
e.getMessage());
}
}
if (loadedCount == 0) {
log.warn("No valid keys could be loaded from disk, generating new keypair");
generateAndStoreKeypair();
} else {
log.info(
"Successfully loaded {} JWT keys, active key: {}",
loadedCount,
activeKey.getKeyId());
}
} catch (IOException e) {
log.error("Failed to load keys from disk, generating new keypair", e);
generateAndStoreKeypair();
}
}
@@ -84,10 +187,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
KeyPair keyPair = generateRSAKeypair();
String keyId = generateKeyId();
storePrivateKey(keyId, keyPair.getPrivate());
storeKeyPair(keyId, keyPair);
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
verifyingKeyCache.put(keyId, verifyingKey);
activeKey = verifyingKey;
log.info("Generated and stored new JWT keypair: {}", keyId);
} catch (IOException e) {
log.error("Failed to generate and store keypair", e);
}
@@ -200,16 +304,43 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
}
}
private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException {
Path keyFile =
Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX);
String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded());
Files.writeString(keyFile, encodedKey);
/**
* Store both private and public keys to disk.
*
* <p>Private key stored as: keyId.key
*
* <p>Public key stored as: keyId.pub
*/
private void storeKeyPair(String keyId, KeyPair keyPair) throws IOException {
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
// Set read/write to only the owner
keyFile.toFile().setReadable(true, true);
keyFile.toFile().setWritable(true, true);
keyFile.toFile().setExecutable(false, false);
// Store private key
Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX);
String encodedPrivateKey =
Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
Files.writeString(privateKeyFile, encodedPrivateKey);
// Set read/write to only the owner (security)
privateKeyFile.toFile().setReadable(true, true);
privateKeyFile.toFile().setWritable(true, true);
privateKeyFile.toFile().setExecutable(false, false);
// Store public key
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
String encodedPublicKey =
Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
Files.writeString(publicKeyFile, encodedPublicKey);
// Public key can be more permissive but still restrict to owner
publicKeyFile.toFile().setReadable(true, true);
publicKeyFile.toFile().setWritable(true, true);
publicKeyFile.toFile().setExecutable(false, false);
log.debug(
"Stored keypair to disk: {} (private: {}, public: {})",
keyId,
privateKeyFile.getFileName(),
publicKeyFile.getFileName());
}
private PrivateKey loadPrivateKey(String keyId)
@@ -229,6 +360,53 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
return keyFactory.generatePrivate(keySpec);
}
/**
* Load public key from disk.
*
* @param keyId the key identifier
* @return Base64-encoded public key string
* @throws IOException if the public key file is not found
*/
private String loadPublicKey(String keyId) throws IOException {
Path publicKeyFile =
Paths.get(InstallationPathConfig.getPrivateKeyPath())
.resolve(keyId + PUB_KEY_SUFFIX);
if (!Files.exists(publicKeyFile)) {
throw new IOException("Public key not found: " + publicKeyFile);
}
return Files.readString(publicKeyFile).trim();
}
/**
* Reconstruct a KeyPair from a PrivateKey.
*
* <p>For RSA keys, derives the public key from the private key.
*
* @param privateKey the RSA private key
* @return reconstructed KeyPair
* @throws NoSuchAlgorithmException if RSA algorithm is not available
* @throws InvalidKeySpecException if the key specification is invalid
*/
private KeyPair reconstructKeyPair(PrivateKey privateKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
// For RSA, we can derive the public key from the private key
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
// Get the private key spec
RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privateKey;
// Create public key spec from private key parameters
RSAPublicKeySpec publicKeySpec =
new RSAPublicKeySpec(rsaPrivateKey.getModulus(), rsaPrivateKey.getPublicExponent());
// Generate public key
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
return new KeyPair(publicKey, privateKey);
}
private String encodePublicKey(PublicKey publicKey) {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
@@ -0,0 +1,124 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
/**
* Service to rate limit token refresh attempts within the grace period.
*
* <p>Prevents abuse of expired tokens by tracking and limiting refresh attempts per token. Tokens
* are identified by a hash to avoid storing actual token values.
*/
@Service
@Slf4j
public class RefreshRateLimitService {
private final ApplicationProperties.Security.Jwt jwtProperties;
@Autowired
public RefreshRateLimitService(ApplicationProperties applicationProperties) {
this.jwtProperties = applicationProperties.getSecurity().getJwt();
}
private static class RefreshAttempt {
private final AtomicInteger count = new AtomicInteger(0);
private final Instant firstAttempt = Instant.now();
int incrementAndGet() {
return count.incrementAndGet();
}
Instant getFirstAttempt() {
return firstAttempt;
}
int getCount() {
return count.get();
}
}
private final Map<String, RefreshAttempt> attempts = new ConcurrentHashMap<>();
/**
* Check if a refresh attempt is allowed for the given token.
*
* @param tokenHash hash of the token attempting refresh
* @param graceWindowMillis the configured grace window in milliseconds
* @return true if refresh is allowed, false if rate limit exceeded
*/
public boolean isRefreshAllowed(String tokenHash, long graceWindowMillis) {
RefreshAttempt attempt = attempts.computeIfAbsent(tokenHash, k -> new RefreshAttempt());
int attemptCount = attempt.incrementAndGet();
if (attemptCount > JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE) {
log.warn(
"Refresh rate limit exceeded for token (attempt {}). Token hash: {}",
attemptCount,
tokenHash.substring(0, Math.min(8, tokenHash.length())));
return false;
}
// Clean up if outside grace window
Instant cutoff = Instant.now().minusMillis(graceWindowMillis);
if (attempt.getFirstAttempt().isBefore(cutoff)) {
attempts.remove(tokenHash);
}
return true;
}
/**
* Remove tracking for a token after successful refresh.
*
* @param tokenHash hash of the refreshed token
*/
public void clearRefreshAttempts(String tokenHash) {
attempts.remove(tokenHash);
}
/** Clean up expired tracking entries every 5 minutes. */
@Scheduled(fixedRate = 300000)
public void cleanupExpiredEntries() {
// Use configured grace period with same normalization as runtime checks
int configuredMinutes = jwtProperties.getRefreshGraceMinutes();
int graceMinutes =
configuredMinutes >= 0
? configuredMinutes
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
Instant cutoff = Instant.now().minusMillis(graceMinutes * 60000L);
int removed =
attempts.entrySet().stream()
.filter(entry -> entry.getValue().getFirstAttempt().isBefore(cutoff))
.mapToInt(
entry -> {
attempts.remove(entry.getKey());
return 1;
})
.sum();
if (removed > 0) {
log.debug("Cleaned up {} expired refresh tracking entries", removed);
}
}
/** Get current tracking statistics for monitoring. */
public Map<String, Object> getStats() {
return Map.of(
"tracked_tokens",
attempts.size(),
"max_attempts_allowed",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
}
}
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.regex.Pattern;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
@@ -33,6 +34,7 @@ public class TotpService {
private static final String HMAC_ALGORITHM = "HmacSHA1";
private static final String DEFAULT_ISSUER = "Stirling PDF";
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
private static final Pattern TOTP_CODE_PATTERN = Pattern.compile("\\d{6}");
private final ApplicationProperties applicationProperties;
@@ -71,7 +73,7 @@ public class TotpService {
}
String normalizedCode = code.replace(" ", "");
if (!normalizedCode.matches("\\d{6}")) {
if (!TOTP_CODE_PATTERN.matcher(normalizedCode).matches()) {
return null;
}
@@ -0,0 +1,82 @@
package stirling.software.proprietary.security.util;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
/**
* Utility class for detecting desktop clients and determining appropriate token expiry times.
*
* <p>Desktop clients (Tauri, Electron) receive longer-lived tokens because:
*
* <ul>
* <li>They run on personal devices (not shared computers)
* <li>Tokens stored in OS-level encrypted keychain (not browser localStorage)
* <li>Better UX (users expect desktop apps to stay logged in)
* </ul>
*/
@Slf4j
public class DesktopClientUtils {
private DesktopClientUtils() {
// Utility class - prevent instantiation
}
/**
* Detect if the request is from a desktop client (Tauri app).
*
* @param request the HTTP request
* @return true if desktop client, false if web browser
*/
public static boolean isDesktopClient(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
if (userAgent == null) {
return false;
}
// Tauri desktop app includes "Tauri" or "tauri-plugin" in User-Agent
// Also check for common desktop app identifiers
String userAgentLower = userAgent.toLowerCase();
boolean hasTauri = userAgentLower.contains("tauri");
boolean hasStirling = userAgentLower.contains("stirlingpdf-desktop");
boolean hasElectron = userAgentLower.contains("electron");
boolean isDesktop = hasTauri || hasStirling || hasElectron;
log.debug("Desktop client detection: {} (User-Agent: {})", isDesktop, userAgent);
return isDesktop;
}
/**
* Get the configured desktop token expiry time in minutes.
*
* @param applicationProperties the application properties
* @return desktop token expiry in minutes (defaults to 30 days if not configured)
*/
public static int getDesktopTokenExpiryMinutes(ApplicationProperties applicationProperties) {
int configuredMinutes =
applicationProperties.getSecurity().getJwt().getDesktopTokenExpiryMinutes();
// If not configured or invalid, default to 30 days (43200 minutes)
return configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES;
}
/**
* Get the configured web token expiry time in minutes.
*
* @param applicationProperties the application properties
* @return web token expiry in minutes
*/
public static int getWebTokenExpiryMinutes(ApplicationProperties applicationProperties) {
int configuredMinutes =
applicationProperties.getSecurity().getJwt().getTokenExpiryMinutes();
return configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
}
}
@@ -10,6 +10,7 @@ import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import org.springframework.stereotype.Service;
@@ -32,6 +33,7 @@ import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
@Slf4j
public class SignatureService implements PersonalSignatureServiceInterface {
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
private final String SIGNATURE_BASE_PATH;
private final String ALL_USERS_FOLDER = "ALL_USERS";
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -366,14 +368,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
throw new IllegalArgumentException("Invalid filename");
}
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
throw new IllegalArgumentException("Filename contains invalid characters");
}
}
private String validateAndNormalizeExtension(String extension) {
String normalized = extension.toLowerCase().trim();
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
return normalized;
}
throw new IllegalArgumentException("Unsupported image extension: " + extension);
@@ -5,6 +5,7 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
@@ -18,6 +19,7 @@ import java.util.Optional;
import java.util.Set;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -46,6 +48,7 @@ import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.FormFieldWithCoordinates;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.RegexPatternUtils;
@@ -67,6 +70,13 @@ public class FormUtils {
public final Set<String> CHOICE_FIELD_TYPES =
Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO);
/**
* Threshold in PDF points for considering two widgets to be on the same line. Fields whose
* y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead
* of top-to-bottom.
*/
private static final float SAME_LINE_THRESHOLD_PT = 10.0f;
/**
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
* so all callers share identical mapping logic.
@@ -109,6 +119,8 @@ public class FormUtils {
List<FormFieldInfo> fields = new ArrayList<>();
Map<String, Integer> typeCounters = new HashMap<>();
Map<Integer, Integer> pageOrderCounters = new HashMap<>();
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
for (PDField field : acroForm.getFieldTree()) {
if (!(field instanceof PDTerminalField terminalField)) {
continue;
@@ -125,7 +137,7 @@ public class FormUtils {
String currentValue = safeValue(terminalField);
boolean required = field.isRequired();
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField);
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField, annotationPageMap);
List<String> options = resolveOptions(terminalField);
String tooltip = resolveTooltip(terminalField);
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
@@ -164,6 +176,396 @@ public class FormUtils {
return Collections.unmodifiableList(fields);
}
/**
* Extract form fields with widget coordinates for the interactive form viewer.
*
* @param document PDF document
* @return List of form fields with coordinates and metadata
*/
public List<FormFieldWithCoordinates> extractFormFieldsWithCoordinates(PDDocument document) {
if (document == null) return List.of();
PDAcroForm acroForm = getAcroFormSafely(document);
if (acroForm == null) return List.of();
List<FormFieldWithCoordinates> fields = new ArrayList<>();
Map<String, Integer> typeCounters = new HashMap<>();
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
for (PDField field : acroForm.getFieldTree()) {
if (!(field instanceof PDTerminalField terminalField)) {
continue;
}
String type = detectFieldType(terminalField);
String name =
Optional.ofNullable(field.getFullyQualifiedName())
.orElseGet(field::getPartialName);
if (name == null || name.isBlank()) {
continue;
}
String currentValue = safeValue(terminalField);
boolean required = field.isRequired();
boolean readOnly = field.isReadOnly();
List<String> options = resolveOptions(terminalField);
List<String> displayOptions = resolveDisplayOptions(terminalField);
String tooltip = resolveTooltip(terminalField);
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
String displayLabel =
deriveDisplayLabel(field, name, tooltip, type, typeIndex, options);
boolean multiSelect = resolveMultiSelect(terminalField);
boolean multiline =
terminalField instanceof PDTextField
&& ((PDTextField) terminalField).isMultiline();
// Extract widget coordinates
List<FormFieldWithCoordinates.WidgetCoordinates> widgets =
extractWidgetCoordinates(document, terminalField, annotationPageMap);
// Only include displayOptions when they differ from export options
List<String> displayOptsToSend = null;
if (displayOptions != null
&& !displayOptions.isEmpty()
&& !displayOptions.equals(options)) {
displayOptsToSend = displayOptions;
}
fields.add(
FormFieldWithCoordinates.builder()
.name(name)
.label(displayLabel)
.type(type)
.value(currentValue)
.options(options.isEmpty() ? null : options)
.displayOptions(displayOptsToSend)
.required(required)
.readOnly(readOnly)
.multiSelect(multiSelect)
.multiline(multiline)
.tooltip(tooltip)
.widgets(widgets.isEmpty() ? null : widgets)
.build());
}
// Sort by page and position
fields.sort(new FieldCoordinateComparator());
log.debug("Total fields processed: {}", fields.size());
log.debug(
"Fields WITH widgets: {}",
fields.stream()
.filter(f -> f.getWidgets() != null && !f.getWidgets().isEmpty())
.count());
log.debug(
"Fields WITHOUT widgets: {}",
fields.stream()
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
.count());
fields.stream()
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
.forEach(
f ->
log.debug(
"Field '{}' type={} has NO widget coordinates",
f.getName(),
f.getType()));
return Collections.unmodifiableList(fields);
}
/**
* Extract widget coordinates for a form field.
*
* @param document PDF document
* @param field Terminal field
* @return List of widget coordinates
*/
private List<FormFieldWithCoordinates.WidgetCoordinates> extractWidgetCoordinates(
PDDocument document,
PDTerminalField field,
Map<COSDictionary, Integer> annotationPageMap) {
List<FormFieldWithCoordinates.WidgetCoordinates> result = new ArrayList<>();
List<PDAnnotationWidget> widgets = field.getWidgets();
log.debug(
"Field '{}' type={} has {} widgets",
field.getFullyQualifiedName(),
field.getClass().getSimpleName(),
widgets != null ? widgets.size() : 0);
if (widgets == null || widgets.isEmpty()) {
// Some fields (especially text fields) might be their own widget annotation
log.trace(
"Field '{}' has no widgets, checking if field acts as its own annotation",
field.getFullyQualifiedName());
try {
COSDictionary fieldDict = field.getCOSObject();
COSBase rectBase = fieldDict.getDictionaryObject(COSName.RECT);
if (rectBase instanceof COSArray rectArray) {
int pageIndex =
findPageIndexForAnnotation(document, fieldDict, annotationPageMap);
if (pageIndex >= 0) {
PDRectangle rectangle = new PDRectangle(rectArray);
result.add(
createWidgetCoordinates(
document, rectangle, pageIndex, null, field));
} else {
log.warn(
"Found rectangle for field '{}' but could not resolve page index",
field.getFullyQualifiedName());
}
}
} catch (Exception e) {
log.debug(
"Could not extract direct rectangle for field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
return result;
}
// For radio buttons, pre-resolve export values per widget
List<String> exportValues = null;
if (field instanceof PDRadioButton radio) {
exportValues = radio.getExportValues();
}
for (int i = 0; i < widgets.size(); i++) {
PDAnnotationWidget widget = widgets.get(i);
try {
PDRectangle rectangle = widget.getRectangle();
if (rectangle == null) {
log.warn(
"Field '{}' widget {} has NULL rectangle",
field.getFullyQualifiedName(),
i);
continue;
}
int pageIndex = resolveWidgetPageIndex(document, widget, annotationPageMap);
if (pageIndex < 0) {
log.warn(
"Field '{}' widget {} could not resolve page index",
field.getFullyQualifiedName(),
i);
continue;
}
// Resolve export value for radio/checkbox widgets
String exportValue = null;
if (exportValues != null && i < exportValues.size()) {
exportValue = exportValues.get(i);
} else if (field instanceof PDButton) {
// Fall back to appearance state name from the widget's normal appearance
try {
var ap = widget.getAppearance();
if (ap != null && ap.getNormalAppearance() != null) {
var normalAp = ap.getNormalAppearance();
if (normalAp.isSubDictionary()) {
for (var cosName : normalAp.getSubDictionary().keySet()) {
String key = cosName.getName();
if (!"Off".equals(key)) {
exportValue = key;
break;
}
}
}
}
} catch (Exception e) {
log.trace(
"Could not extract export value for widget in '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
}
result.add(
createWidgetCoordinates(
document, rectangle, pageIndex, exportValue, field));
} catch (Exception e) {
log.debug(
"Failed to extract coordinates for widget in field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
}
return result;
}
private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates(
PDDocument document,
PDRectangle rectangle,
int pageIndex,
String exportValue,
PDTerminalField field) {
if (pageIndex < 0 || pageIndex >= document.getNumberOfPages()) {
return null;
}
PDPage page = document.getPage(pageIndex);
PDRectangle cropBox = page.getCropBox();
// Use CropBox dimensions for the y-flip.
// Note: getWidth() and getHeight() return dimensions BEFORE rotation.
float cropHeight = cropBox.getHeight();
// Get absolute widget coordinates (in MediaBox space, un-rotated)
float pdfX = rectangle.getLowerLeftX();
float pdfY = rectangle.getLowerLeftY();
float width = rectangle.getWidth();
float height = rectangle.getHeight();
// Adjust relative to CropBox origin
float relativeX = pdfX - cropBox.getLowerLeftX();
float relativeY = pdfY - cropBox.getLowerLeftY();
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
// Widget /Rect coordinates are always in un-rotated PDF user space.
// The embedpdf viewer wraps all page content inside a <Rotate> CSS
// component that handles visual rotation — we must NOT apply any
// rotation transform here, or widgets would be double-rotated.
float finalX = relativeX;
float finalY = cropHeight - relativeY - height;
float finalW = width;
float finalH = height;
// Validate coordinates are within reasonable bounds
if (finalX < -1.0f
|| finalY < -1.0f
|| finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow
|| finalY > cropHeight + 1.0f) {
log.warn(
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}",
field.getFullyQualifiedName(),
pageIndex,
finalX,
finalY,
finalW,
finalH);
return null;
}
return FormFieldWithCoordinates.WidgetCoordinates.builder()
.pageIndex(pageIndex)
.x(finalX)
.y(finalY)
.width(finalW)
.height(finalH)
.exportValue(exportValue)
.fontSize(extractFontSize(field))
.build();
}
/**
* Repairs widgets with missing page references by scanning all pages and setting the /P entry
* for orphan widgets.
*
* <p>This should be called BEFORE extracting form field coordinates.
*
* @param document PDF document to repair
*/
public void repairMissingWidgetPageReferences(PDDocument document) {
try {
PDAcroForm acroForm = getAcroFormSafely(document);
if (acroForm == null) {
return;
}
log.debug("Checking for widgets with missing page references...");
int repairedCount = 0;
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
for (PDField field : acroForm.getFieldTree()) {
if (!(field instanceof PDTerminalField terminalField)) {
continue;
}
List<PDAnnotationWidget> widgets = terminalField.getWidgets();
if (widgets == null || widgets.isEmpty()) {
continue;
}
for (PDAnnotationWidget widget : widgets) {
if (widget.getPage() == null) {
Integer pageIndex = annotationPageMap.get(widget.getCOSObject());
if (pageIndex != null && pageIndex >= 0) {
PDPage foundPage = document.getPage(pageIndex);
widget.setPage(foundPage);
repairedCount++;
log.debug(
"Repaired widget for field '{}' - set page reference via map",
field.getFullyQualifiedName());
} else {
log.warn(
"Could not find page for widget in field '{}'",
field.getFullyQualifiedName());
}
}
}
}
if (repairedCount > 0) {
log.debug(
"Successfully repaired {} widgets with missing page references",
repairedCount);
} else {
log.debug("No widgets needed repair");
}
} catch (Exception e) {
log.error("Error repairing widget page references: {}", e.getMessage(), e);
}
}
private int findPageIndexForAnnotation(
PDDocument document,
COSDictionary annotDict,
Map<COSDictionary, Integer> annotationPageMap) {
try {
// Method 0: Check the pre-built lookup map (fastest)
if (annotationPageMap != null) {
Integer idx = annotationPageMap.get(annotDict);
if (idx != null) {
return idx;
}
}
// Method 1: Check the /P entry if it points to a page
COSBase base = annotDict.getDictionaryObject(COSName.P);
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
if (pageDict != null) {
for (int i = 0; i < document.getNumberOfPages(); i++) {
if (document.getPage(i).getCOSObject() == pageDict) {
return i;
}
}
}
// Method 2: Fallback search through all pages' annotations
for (int i = 0; i < document.getNumberOfPages(); i++) {
PDPage page = document.getPage(i);
List<PDAnnotation> annotations = page.getAnnotations();
if (annotations != null) {
for (PDAnnotation annot : annotations) {
if (annot != null && annot.getCOSObject() == annotDict) {
return i;
}
}
}
}
} catch (Exception e) {
log.trace("Error finding page for annotation: {}", e.getMessage());
}
return -1;
}
/**
* Build a single record object (field-name -> value placeholder) that can be directly submitted
* to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless
@@ -312,7 +714,24 @@ public class FormUtils {
return;
}
flattenViaRendering(document, acroForm);
if (acroForm == null) {
return;
}
// Use PDFBox's built-in field flattening which bakes form field values
// into the page content stream as static text/graphics, removing the
// interactive form structure but preserving all other document content
// (images, text, annotations, etc.) at full quality.
try {
ensureAppearances(acroForm);
acroForm.flatten();
} catch (Exception e) {
log.warn(
"PDFBox acroForm.flatten() failed, falling back to rendering: {}",
e.getMessage(),
e);
flattenViaRendering(document, acroForm);
}
}
private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi)
@@ -385,7 +804,7 @@ public class FormUtils {
PDPage page = widget.getPage();
if (page == null) {
page = resolveWidgetPage(document, widget);
page = resolveWidgetPage(document, widget, null);
if (page != null) {
widget.setPage(page);
}
@@ -820,6 +1239,16 @@ public class FormUtils {
private String safeValue(PDTerminalField field) {
try {
// PDChoice.getValueAsString() returns a raw COS string representation
// that doesn't reliably reflect the selected value. Use getValue()
// which returns the proper List<String> of selected options.
if (field instanceof PDChoice choiceField) {
List<String> selected = choiceField.getValue();
if (selected == null || selected.isEmpty()) {
return null;
}
return String.join(",", selected);
}
return field.getValueAsString();
} catch (Exception e) {
log.debug(
@@ -833,14 +1262,25 @@ public class FormUtils {
List<String> resolveOptions(PDTerminalField field) {
try {
if (field instanceof PDChoice choice) {
List<String> display = choice.getOptionsDisplayValues();
if (display != null && !display.isEmpty()) {
return new ArrayList<>(display);
}
LinkedHashSet<String> allowed = new LinkedHashSet<>();
List<String> exportValues = choice.getOptionsExportValues();
if (exportValues != null && !exportValues.isEmpty()) {
return new ArrayList<>(exportValues);
List<String> displayValues = choice.getOptionsDisplayValues();
if (exportValues != null) {
exportValues.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.forEach(allowed::add);
}
if (displayValues != null) {
displayValues.stream()
.filter(Objects::nonNull)
.map(String::trim)
.filter(s -> !s.isEmpty())
.forEach(allowed::add);
}
return new ArrayList<>(allowed);
} else if (field instanceof PDRadioButton radio) {
List<String> exports = radio.getExportValues();
if (exports != null && !exports.isEmpty()) {
@@ -861,6 +1301,29 @@ public class FormUtils {
return Collections.emptyList();
}
/**
* Returns the display-value labels for a choice field's options. For radio / checkbox this
* returns an empty list (no separate display values). For PDChoice fields, if the PDF provides
* distinct display values, those are returned; otherwise an empty list (indicating that the
* export values from {@link #resolveOptions} should be shown directly).
*/
List<String> resolveDisplayOptions(PDTerminalField field) {
try {
if (field instanceof PDChoice choice) {
List<String> display = choice.getOptionsDisplayValues();
if (display != null && !display.isEmpty()) {
return new ArrayList<>(display);
}
}
} catch (Exception e) {
log.debug(
"Failed to resolve display options for field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
return Collections.emptyList();
}
private boolean resolveMultiSelect(PDTerminalField field) {
if (field instanceof PDListBox listBox) {
try {
@@ -875,6 +1338,44 @@ public class FormUtils {
return false;
}
private Float extractFontSize(PDTerminalField field) {
try {
String da = null;
if (field instanceof PDVariableText vt) {
da = vt.getDefaultAppearance();
}
if (da == null || da.isBlank()) {
// Check parent/acroform default appearance if field's is missing
PDAcroForm form = field.getAcroForm();
if (form != null) {
da = form.getDefaultAppearance();
}
}
if (da != null && !da.isBlank()) {
// Standard DA looks like: /Helv 12 Tf 0 g
// We want the number before 'Tf'
String[] tokens = da.split("\\s+");
for (int i = 0; i < tokens.length; i++) {
if ("Tf".equals(tokens[i]) && i > 0) {
try {
float size = Float.parseFloat(tokens[i - 1]);
return size > 0 ? size : null;
} catch (NumberFormatException ignored) {
}
}
}
}
} catch (Exception e) {
log.trace(
"Could not extract font size for field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
return null;
}
private boolean isSettableCheckBoxState(String state) {
if (state == null) return false;
String trimmed = state.trim();
@@ -952,6 +1453,13 @@ public class FormUtils {
if (simplified.isEmpty()) return true;
// Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7")
// Standard UUIDs are 32 hex characters; require at least that to avoid
// false positives on short hex-like field names.
String nospaces = simplified.replaceAll("\\s+", "");
if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"))
return true;
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
|| patterns.getOptionalTNumericPattern().matcher(simplified).matches();
@@ -1007,7 +1515,7 @@ public class FormUtils {
PDAnnotationWidget widget = widgets.get(0);
PDRectangle originalRectangle = cloneRectangle(widget.getRectangle());
PDPage page = resolveWidgetPage(document, widget);
PDPage page = resolveWidgetPage(document, widget, null);
if (page == null || originalRectangle == null) {
log.warn(
"Unable to resolve widget page or rectangle for '{}'; skipping",
@@ -1064,7 +1572,7 @@ public class FormUtils {
desiredName,
modification.label(),
resolvedType,
determineWidgetPageIndex(document, widget),
determineWidgetPageIndex(document, widget, null),
originalRectangle.getLowerLeftX(),
originalRectangle.getLowerLeftY(),
originalRectangle.getWidth(),
@@ -1205,59 +1713,43 @@ public class FormUtils {
return null;
}
private int resolveFirstWidgetPageIndex(PDDocument document, PDTerminalField field) {
private int resolveFirstWidgetPageIndex(
PDDocument document,
PDTerminalField field,
Map<COSDictionary, Integer> annotationPageMap) {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets == null || widgets.isEmpty()) {
return -1;
}
Map<PDAnnotationWidget, Integer> widgetPageFallbacks = null;
for (PDAnnotationWidget widget : widgets) {
int idx = resolveWidgetPageIndex(document, widget);
int idx = resolveWidgetPageIndex(document, widget, annotationPageMap);
if (idx >= 0) {
return idx;
}
try {
COSDictionary widgetDictionary = widget.getCOSObject();
if (widgetDictionary != null
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
if (widgetPageFallbacks == null) {
widgetPageFallbacks = buildWidgetPageFallbackMap(document);
}
Integer fallbackIndex = widgetPageFallbacks.get(widget);
if (fallbackIndex != null && fallbackIndex >= 0) {
return fallbackIndex;
}
}
} catch (Exception e) {
log.debug(
"Failed to inspect widget page reference for field '{}': {}",
field.getFullyQualifiedName(),
e.getMessage());
}
}
return -1;
}
private int resolveWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
private int resolveWidgetPageIndex(
PDDocument document,
PDAnnotationWidget widget,
Map<COSDictionary, Integer> annotationPageMap) {
if (document == null || widget == null) {
return -1;
}
try {
COSDictionary widgetDictionary = widget.getCOSObject();
if (widgetDictionary != null
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
Map<PDAnnotationWidget, Integer> fallback = buildWidgetPageFallbackMap(document);
Integer index = fallback.get(widget);
if (index != null) {
return index;
}
// Method 0: Check the pre-built lookup map (fastest)
if (annotationPageMap != null) {
Integer idx = annotationPageMap.get(widget.getCOSObject());
if (idx != null) {
return idx;
}
} catch (Exception e) {
log.debug("Widget page lookup via fallback map failed: {}", e.getMessage());
}
try {
PDPage page = widget.getPage();
if (page != null) {
// indexOf is O(N), still slower than map but better than scanning annotations
int idx = document.getPages().indexOf(page);
if (idx >= 0) {
return idx;
@@ -1267,14 +1759,36 @@ public class FormUtils {
log.debug("Widget page lookup failed: {}", e.getMessage());
}
// Method 1: Check the /P entry if it points to a page
try {
COSDictionary widgetDictionary = widget.getCOSObject();
if (widgetDictionary != null) {
COSBase base = widgetDictionary.getDictionaryObject(COSName.P);
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
if (pageDict != null) {
for (int i = 0; i < document.getNumberOfPages(); i++) {
if (document.getPage(i).getCOSObject() == pageDict) {
return i;
}
}
}
}
} catch (Exception e) {
log.debug("Widget page lookup via /P entry failed: {}", e.getMessage());
}
// Method 2: Fallback search through all pages' annotations
int pageCount = document.getNumberOfPages();
COSDictionary widgetDict = widget.getCOSObject();
for (int i = 0; i < pageCount; i++) {
try {
PDPage candidate = document.getPage(i);
List<PDAnnotation> annotations = candidate.getAnnotations();
for (PDAnnotation annotation : annotations) {
if (annotation == widget) {
return i;
if (annotations != null) {
for (PDAnnotation annot : annotations) {
if (annot != null && annot.getCOSObject() == widgetDict) {
return i;
}
}
}
} catch (IOException e) {
@@ -1317,7 +1831,7 @@ public class FormUtils {
List<PDAnnotationWidget> widgets = field.getWidgets();
if (widgets != null) {
for (PDAnnotationWidget widget : widgets) {
PDPage page = resolveWidgetPage(document, widget);
PDPage page = resolveWidgetPage(document, widget, null);
if (page != null) {
page.getAnnotations().remove(widget);
}
@@ -1437,7 +1951,10 @@ public class FormUtils {
rectangle.getHeight());
}
private PDPage resolveWidgetPage(PDDocument document, PDAnnotationWidget widget) {
private PDPage resolveWidgetPage(
PDDocument document,
PDAnnotationWidget widget,
Map<COSDictionary, Integer> annotationPageMap) {
if (widget == null) {
return null;
}
@@ -1445,7 +1962,7 @@ public class FormUtils {
if (page != null) {
return page;
}
int pageIndex = determineWidgetPageIndex(document, widget);
int pageIndex = determineWidgetPageIndex(document, widget, annotationPageMap);
if (pageIndex >= 0) {
try {
return document.getPage(pageIndex);
@@ -1456,11 +1973,21 @@ public class FormUtils {
return null;
}
private int determineWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
private int determineWidgetPageIndex(
PDDocument document,
PDAnnotationWidget widget,
Map<COSDictionary, Integer> annotationPageMap) {
if (document == null || widget == null) {
return -1;
}
if (annotationPageMap != null) {
Integer idx = annotationPageMap.get(widget.getCOSObject());
if (idx != null) {
return idx;
}
}
PDPage directPage = widget.getPage();
if (directPage != null) {
int index = 0;
@@ -1488,6 +2015,33 @@ public class FormUtils {
return -1;
}
/**
* Build a map of annotation COS dictionaries to their respective page index. Scan once
* per-document to avoid O(N^2) lookups during field extraction.
*/
public Map<COSDictionary, Integer> buildAnnotationPageMap(PDDocument document) {
if (document == null) {
return Collections.emptyMap();
}
Map<COSDictionary, Integer> map = new HashMap<>();
int pageCount = document.getNumberOfPages();
for (int i = 0; i < pageCount; i++) {
try {
PDPage page = document.getPage(i);
List<PDAnnotation> annotations = page.getAnnotations();
for (PDAnnotation annot : annotations) {
if (annot != null) {
map.putIfAbsent(annot.getCOSObject(), i);
}
}
} catch (Exception e) {
log.debug("Failed to index annotations for page {}: {}", i, e.getMessage());
}
}
return map;
}
private Map<PDAnnotationWidget, Integer> buildWidgetPageFallbackMap(PDDocument document) {
if (document == null) {
return Collections.emptyMap();
@@ -1760,4 +2314,46 @@ public class FormUtils {
boolean multiSelect,
String tooltip,
int pageOrder) {}
/**
* Comparator for sorting form fields by page, then vertically (top-to-bottom), then
* horizontally (left-to-right) for fields on approximately the same line.
*/
static final class FieldCoordinateComparator implements Comparator<FormFieldWithCoordinates> {
private static int firstWidgetPageIndex(FormFieldWithCoordinates f) {
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
? f.getWidgets().get(0).getPageIndex()
: -1;
}
private static float firstWidgetY(FormFieldWithCoordinates f) {
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
? f.getWidgets().get(0).getY()
: 0;
}
private static float firstWidgetX(FormFieldWithCoordinates f) {
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
? f.getWidgets().get(0).getX()
: 0;
}
@Override
public int compare(FormFieldWithCoordinates a, FormFieldWithCoordinates b) {
int pageA = firstWidgetPageIndex(a);
int pageB = firstWidgetPageIndex(b);
int pageCompare = Integer.compare(pageA, pageB);
if (pageCompare != 0) return pageCompare;
float yA = firstWidgetY(a);
float yB = firstWidgetY(b);
// Fields on approximately the same line should be sorted left-to-right
if (Math.abs(yA - yB) < SAME_LINE_THRESHOLD_PT) {
return Float.compare(firstWidgetX(a), firstWidgetX(b));
}
return Float.compare(yA, yB);
}
}
}
@@ -10,6 +10,8 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@@ -36,6 +38,7 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
@@ -53,11 +56,17 @@ class AuthControllerLoginTest {
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
@Mock private RefreshRateLimitService refreshRateLimitService;
@BeforeEach
void setUp() {
securityProperties = new ApplicationProperties.Security();
securityProperties.setLoginMethod("all");
securityProperties.getJwt().setTokenExpiryMinutes(60);
securityProperties.getJwt().setRefreshGraceMinutes(5);
ApplicationProperties applicationProperties = new ApplicationProperties();
applicationProperties.setSecurity(securityProperties);
AuthController controller =
new AuthController(
@@ -67,7 +76,9 @@ class AuthControllerLoginTest {
loginAttemptService,
mfaService,
totpService,
securityProperties);
refreshRateLimitService,
securityProperties,
applicationProperties);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -175,7 +186,11 @@ class AuthControllerLoginTest {
void refreshReturnsNewTokenWhenValid() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put("exp", new Date(System.currentTimeMillis() + 60_000));
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
// Rate limiting is not checked for valid tokens, so no stub needed
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
.thenReturn("new-token");
@@ -184,7 +199,75 @@ class AuthControllerLoginTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.user").exists())
.andExpect(jsonPath("$.session.access_token").value("new-token"))
.andExpect(jsonPath("$.session.expires_in").value(3600));
.andExpect(
jsonPath("$.session.expires_in")
.value(3600)); // 60 minutes * 60 = 3600 seconds
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
// period
}
@Test
void refreshRejectsTokenExpiredBeyondGrace() throws Exception {
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put(
"exp",
new Date(
System.currentTimeMillis()
- (10 * 60_000))); // 10 minutes ago, beyond 5 minute grace
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Token refresh failed"));
verify(userDetailsService, never()).loadUserByUsername(any());
verify(refreshRateLimitService, never()).isRefreshAllowed(any(), any(Long.class));
}
@Test
void refreshAcceptsTokenExpiredWithinGrace() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put(
"exp",
new Date(
System.currentTimeMillis()
- 60_000)); // 1 minute ago, within 5 minute grace
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(true);
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
.thenReturn("new-token");
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.session.access_token").value("new-token"));
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
// period
}
@Test
void refreshRejectsWhenRateLimitExceeded() throws Exception {
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put("exp", new Date(System.currentTimeMillis() - 60_000)); // 1 minute ago
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(false);
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.error").value("Too many refresh attempts"))
.andExpect(jsonPath("$.max_attempts").exists());
verify(userDetailsService, never()).loadUserByUsername(any());
verify(refreshRateLimitService, never()).clearRefreshAttempts(any());
}
@Test
@@ -37,13 +37,19 @@ class CustomOAuth2AuthenticationSuccessHandlerTest {
oauth2Props.setAutoCreateUser(true);
oauth2Props.setBlockRegistration(false);
ApplicationProperties applicationProperties = new ApplicationProperties();
ApplicationProperties.Security securityProperties = new ApplicationProperties.Security();
securityProperties.setOauth2(oauth2Props);
applicationProperties.setSecurity(securityProperties);
CustomOAuth2AuthenticationSuccessHandler handler =
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
oauth2Props,
userService,
jwtService,
licenseSettingsService);
licenseSettingsService,
applicationProperties);
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
@@ -31,6 +31,7 @@ import org.springframework.security.core.Authentication;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
@@ -64,7 +65,8 @@ class JwtServiceTest {
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
jwtService = new JwtService(true, keystoreService);
ApplicationProperties applicationProperties = new ApplicationProperties();
jwtService = new JwtService(true, keystoreService, applicationProperties);
}
@Test
@@ -73,8 +75,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -94,8 +94,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -114,8 +112,6 @@ class JwtServiceTest {
void testValidateTokenSuccess() throws Exception {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn("testuser");
@@ -179,8 +175,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(user);
when(user.getUsername()).thenReturn(username);
@@ -207,8 +201,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -281,8 +273,6 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -307,8 +297,6 @@ class JwtServiceTest {
// First, generate a token successfully
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;
@@ -16,6 +17,8 @@ import stirling.software.proprietary.security.util.Base32Codec;
class TotpServiceTest {
private static final Pattern PATTERN = Pattern.compile("[A-Z2-7]+");
private TotpService buildService(String appName) {
ApplicationProperties properties = new ApplicationProperties();
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
@@ -32,7 +35,7 @@ class TotpServiceTest {
assertNotNull(secret);
assertEquals(32, secret.length());
assertTrue(secret.matches("[A-Z2-7]+"));
assertTrue(PATTERN.matcher(secret).matches());
}
@Test
+1 -1
View File
@@ -67,7 +67,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.4.5'
version = '2.5.0'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
@@ -32,7 +32,4 @@ services:
METRICS_ENABLED: "true"
SYSTEM_GOOGLEVISIBILITY: "true"
SHOW_SURVEY: "true"
SPDF_PDFJSON_DUMP: "true"
SPDF_PDFJSON_ANALYZE: "true"
SPDF_PDFJSON_REPEAT_SCAN: "true"
restart: on-failure:5
+259 -177
View File
@@ -10,29 +10,31 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.5.0",
"@embedpdf/engines": "^2.5.0",
"@embedpdf/models": "^2.5.0",
"@embedpdf/plugin-annotation": "^2.5.0",
"@embedpdf/plugin-bookmark": "^2.5.0",
"@embedpdf/plugin-document-manager": "^2.5.0",
"@embedpdf/plugin-export": "^2.5.0",
"@embedpdf/plugin-history": "^2.5.0",
"@embedpdf/plugin-interaction-manager": "^2.5.0",
"@embedpdf/plugin-pan": "^2.5.0",
"@embedpdf/plugin-print": "^2.5.0",
"@embedpdf/plugin-redaction": "^2.5.0",
"@embedpdf/plugin-render": "^2.5.0",
"@embedpdf/plugin-rotate": "^2.5.0",
"@embedpdf/plugin-scroll": "^2.5.0",
"@embedpdf/plugin-search": "^2.5.0",
"@embedpdf/plugin-selection": "^2.5.0",
"@embedpdf/plugin-spread": "^2.5.0",
"@embedpdf/plugin-thumbnail": "^2.5.0",
"@embedpdf/plugin-tiling": "^2.5.0",
"@embedpdf/plugin-viewport": "^2.5.0",
"@embedpdf/plugin-zoom": "^2.5.0",
"@embedpdf/core": "^2.6.0",
"@embedpdf/engines": "^2.6.0",
"@embedpdf/models": "^2.6.0",
"@embedpdf/plugin-annotation": "^2.6.0",
"@embedpdf/plugin-attachment": "^2.6.0",
"@embedpdf/plugin-bookmark": "^2.6.0",
"@embedpdf/plugin-document-manager": "^2.6.0",
"@embedpdf/plugin-export": "^2.6.0",
"@embedpdf/plugin-history": "^2.6.0",
"@embedpdf/plugin-interaction-manager": "^2.6.0",
"@embedpdf/plugin-pan": "^2.6.0",
"@embedpdf/plugin-print": "^2.6.0",
"@embedpdf/plugin-redaction": "^2.6.0",
"@embedpdf/plugin-render": "^2.6.0",
"@embedpdf/plugin-rotate": "^2.6.0",
"@embedpdf/plugin-scroll": "^2.6.0",
"@embedpdf/plugin-search": "^2.6.0",
"@embedpdf/plugin-selection": "^2.6.0",
"@embedpdf/plugin-spread": "^2.6.0",
"@embedpdf/plugin-thumbnail": "^2.6.0",
"@embedpdf/plugin-tiling": "^2.6.0",
"@embedpdf/plugin-viewport": "^2.6.0",
"@embedpdf/plugin-zoom": "^2.6.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -60,7 +62,6 @@
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
@@ -365,6 +366,21 @@
"node": ">=18"
}
},
"node_modules/@cantoo/pdf-lib": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.5.3.tgz",
"integrity": "sha512-SBQp8i/XdWNUhLutn5P67Pwj4X9vU046BRpfOMODJZuYVrgChtsTfgdnlW2O7x8gdXs8j7NoTaWI/b78E2oVmQ==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"color": "^4.2.3",
"crypto-js": "^4.2.0",
"node-html-better-parser": ">=1.4.0",
"pako": "^1.0.11",
"tslib": ">=2"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -554,13 +570,13 @@
}
},
"node_modules/@embedpdf/core": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.5.0.tgz",
"integrity": "sha512-nI7GnA5xCNtJHAdKBLPKJVvi4+yAKjy1sysaDf+qp+z3D81Hy8oAcl///QTaZ9ob0SL2jyqi3x//hKl0Rwmgrw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.6.0.tgz",
"integrity": "sha512-859GUvZ3BLpJuKTiwcPPMNn9CSlMaPjQ4yXnyQRngfbvDAiijIIpVLaC98B08Nx6QsUcD3cs/6+wkB888lNsDw==",
"license": "MIT",
"dependencies": {
"@embedpdf/engines": "2.5.0",
"@embedpdf/models": "2.5.0"
"@embedpdf/engines": "2.6.0",
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -571,9 +587,9 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.5.0.tgz",
"integrity": "sha512-SEknNmQrYvkAZgJllRKXuvXSrHSndDQsr7b3mrIVa9bzV6TeZua0a/YUlvI3/jf74Sdajru3XKPe22iHEOH4Zg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.6.0.tgz",
"integrity": "sha512-zW3927u0wbFBD2tQLWbE45DEBIMkZyN7n5O2p70er6u7mP1XYEz7Ud9NxcPL/3b5MzDfPBTSyxM3T12e+ZeAxw==",
"license": "MIT",
"dependencies": {
"@embedpdf/fonts-arabic": "1.0.0",
@@ -583,8 +599,8 @@
"@embedpdf/fonts-latin": "1.0.0",
"@embedpdf/fonts-sc": "1.0.0",
"@embedpdf/fonts-tc": "1.0.0",
"@embedpdf/models": "2.5.0",
"@embedpdf/pdfium": "2.5.0"
"@embedpdf/models": "2.6.0",
"@embedpdf/pdfium": "2.6.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -637,31 +653,48 @@
"license": "OFL-1.1"
},
"node_modules/@embedpdf/models": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.5.0.tgz",
"integrity": "sha512-wu7XgargYBQEh46hVnfsmkTF6TvuoP9nAkTASR60s5ourjlT12qL9RiFLpwGkOBfs8E58h8V5hkgKsra5t03Lw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.6.0.tgz",
"integrity": "sha512-6zuoJE79WXyRXKhJXhl+8p4njuC1nxPpKYRIs54PRLgTkHOLaou+G+ZunEd99XOoVssHLCjxWBUpg46ihQwXDw==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.5.0.tgz",
"integrity": "sha512-2VEO4cNZsV8ig9upS+C+x3Tb58aqNxiAdaUMlD2ZZT8FgszhsV9xMyEuM2maFRdjeT7EO37FtzYBdXc/K67ivA==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.6.0.tgz",
"integrity": "sha512-eYXU1VvVI0e9OqOzvsTcsU6YSLq9F7jcAiIbtMB+NxApvvH3kHz3FPEcf8ha2ZiLftF5OAD8K89SSE5GLE6t1A==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.5.0.tgz",
"integrity": "sha512-S5zCeWU3hM9jrnaGuW5RAXt+AzXXvQbFtAdCtxHW1hFADiZ97FKr8KS9MGCkkj6C9madtZP6iUJikvnhoLCABQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.6.0.tgz",
"integrity": "sha512-FJgGy6lhKrWsiJjh7jZ92NwMBob5GOwfYejQl28JFk6muEQORLtysz5gaeyMpMIyxnfjlf9Eqv8Z6LBBfGLGOA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0",
"@embedpdf/utils": "2.5.0"
"@embedpdf/models": "2.6.0",
"@embedpdf/utils": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-history": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/plugin-selection": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-history": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/plugin-selection": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
"svelte": ">=5 <6",
"vue": ">=3.2.0"
}
},
"node_modules/@embedpdf/plugin-attachment": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.6.0.tgz",
"integrity": "sha512-6UZkj7jFWCruR69OPQFMqbJTgwdra4rnJSBfLA8yLxgz2zTsgt3owjfQDmlJvAQ7G1/rZM2T+EJeuozulj4NoQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -670,15 +703,15 @@
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.5.0.tgz",
"integrity": "sha512-2N5kGoamUrQqWZC5SMWIhdyBHqZN/CdcGf8GVH71FFw3AU6rmZ1AD/AkLzgqoYGIuZFE8ACckdrhtbpsZMmSDQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.6.0.tgz",
"integrity": "sha512-4JmaFD+gFaLj8Bayi6Fm5qxMoRH+JUy+L3S6xk1KM8YWjJyzsoz9C2mHSXKJ0GBMgiOkjaBuJSWqLgMe/oz7OQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -687,15 +720,15 @@
}
},
"node_modules/@embedpdf/plugin-document-manager": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.5.0.tgz",
"integrity": "sha512-I8Z/0B7R/YhtVaJFruwFO+QBLIDmQfHx9WVlrDXWZs68YiGwEbjSyizEIEqtulUJxcXfPs2Tf7oIBbdSuPG2NQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.6.0.tgz",
"integrity": "sha512-fcx0JKDboEV8eQ4r++ksDHPDuUz40oOmtHDqxYLw6cpos0fqW0p55OP+fKp6LfC/bY7ULVDrmcEQf1cD9Qho4w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -704,15 +737,15 @@
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.5.0.tgz",
"integrity": "sha512-KC9jXqwcxe76QqfxLx0tnrSdFoApTFOpT+dwrvox186uxYKSmSt1JHFWe4THB/A63hCNr8uMwyswYdFO8fWNHw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.6.0.tgz",
"integrity": "sha512-i1Xy7qUipVVLDPnnY22hm3RNMx33lvuNbCuPggql5Ws6WBLG9YhDsK+v0JVe2sDSlifa5SJwuBlMHZWPRTZyxg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -721,15 +754,15 @@
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.5.0.tgz",
"integrity": "sha512-Av9NBSE9Or1Y6cXcNWpx0bBZN3yI4vywa6kSNjhaqOrgpQDWMaTO57eApJpyHzBodqEztY+klE9YJ7MH88zm6w==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.6.0.tgz",
"integrity": "sha512-cfVoBjkIbFiRsQu/cwPEi0rrTAF7jriAGzABWawnSTKYEPFrU3LDHO7TewgBz45kHl9pSwvRexaIdTR8ECIKbQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -738,15 +771,15 @@
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.5.0.tgz",
"integrity": "sha512-QrmowLVvC5FNZdvVr2kczSDdnHHOuhf+So0VG5Ythts/OL1bIR/0OOpuyJsScTyo5boYnRkXv8yPf8htL57YKQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.6.0.tgz",
"integrity": "sha512-9bruF6M6GKVdABRTinHsZ+izf2tDQwDEcNI0CHVc5gurrz3CQfAGP2sJkv8uQrXyYTK3zV2Oq6zGknk7Hdx9mA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -755,17 +788,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.5.0.tgz",
"integrity": "sha512-DfdA+hBm9kGYYy7OuJym6azk2h2U/Geirud+tmVzFSL7+OZ3tZ3K9fqj07w66zx0msyUVlYrXzkYSU9NEmwpLA==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.6.0.tgz",
"integrity": "sha512-r8AXcXUy6NMYDaQeixScbeFfmZIvWpUUjx3gxjP4J90xfxXnuz/g/lnh4D2DBaiK4mt6crIVBpXU9IUwMIcUMQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -774,15 +807,15 @@
}
},
"node_modules/@embedpdf/plugin-print": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.5.0.tgz",
"integrity": "sha512-qejq7/0K9hh3hzop+u+Qmn7ijTqGcDhxaiXoPkyl91CZVOyAD8qMBzWnhC7vRNOB7hcYgBP81uegE3se+EIlcA==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.6.0.tgz",
"integrity": "sha512-cgWRqVtRgCCLCn1ViuZEFr+ZJ3QI61/5s9tl3T9x81rwkBN4HT582BYzyRnLBzTMYKxkQZDr1WxfS8ctdlHEUQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
@@ -791,20 +824,20 @@
}
},
"node_modules/@embedpdf/plugin-redaction": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.5.0.tgz",
"integrity": "sha512-G0cm1hLWi09gU8WV+IShq2XHkmLtEbk+EvD3dIiyJV2kbOjgwGSC2Ezt8br3DzH6R/0bF6RAbDIpFyS2Q0oMfg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.6.0.tgz",
"integrity": "sha512-DdDnmOl9K0N4dpTeUohavxQyrfollhkjT+zdfkna3Fc7F4jfl3Vg6uKoGmT71A+Vp4uTGNLt6cNCscbJW9E9kQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0",
"@embedpdf/utils": "2.5.0"
"@embedpdf/models": "2.6.0",
"@embedpdf/utils": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-annotation": "2.5.0",
"@embedpdf/plugin-history": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/plugin-selection": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-annotation": "2.6.0",
"@embedpdf/plugin-history": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/plugin-selection": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -813,15 +846,15 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.5.0.tgz",
"integrity": "sha512-nrTmg8cVMohcKYiQ/7erErsaWlyaq20OtXbVjmnPNnqz4amJLAjlPyudTJRlWWPyIiri9SF4A0ue5ICDY2sypg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.6.0.tgz",
"integrity": "sha512-Rk4QCxDOzhQrvKPt/G3G+p5ELwnKFkC5ljHMd7ND23atR9E3wm5W3+Nx3FaAYYPrpfqQ7BrbKnfQ7SkUbDxS3w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -830,15 +863,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.5.0.tgz",
"integrity": "sha512-crFsXduaxNZJmVRfgklBpO4x4i9cRxPmfFBvdIoyJ1ea6AGOCL0rQKQcfqHTFdgtPzlVUiIg6Hi2v+033jdLUg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.6.0.tgz",
"integrity": "sha512-zgF2S5cfkOxkOWrwoLQLN8scJgKBEhyhVOv/RNdeAKP6qE3h28AGRmDeMsekBDbiInlIxIHzynE5vVcTNf5EnQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -847,16 +880,16 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.5.0.tgz",
"integrity": "sha512-AdLuSgvAaukLl1uQ0FbswcAIPFaR3Jk2ZbEJpWLd9E6iQ+66Cta0Sz8d5J6ndx7VBlRYAZwoqiXF85utJxpQ5g==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.6.0.tgz",
"integrity": "sha512-BEgSy6cs9+MLCS0Z3/FYMdA4Ygt6ddYIAg28XlF20kN3tLj8BQUo5qx6adI+SlwrFFGY52VAjjK7VSBuGfn19g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -865,15 +898,15 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.5.0.tgz",
"integrity": "sha512-ycHJh05vBZ1PTSdEMgdx6K1py0oklwbwY2eXO4nD54EN9EVZgWlYC4Q+u8nyGOiNL6VmJYcqJ0HmjSWBdmGWBw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.6.0.tgz",
"integrity": "sha512-GSzJkmuK9LE7LmlTwnDl71KdD9prHlCjgFs5Tm0K8qjELOSH+oduFXusIuf654+UQveDYczpzBVUcqb4yBf1xA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -882,17 +915,17 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.5.0.tgz",
"integrity": "sha512-M3WDjahig/6KE83SZGvTaJWhqEOIzH002k2fpJVuks926UBnfgYCH8uqV7SOUQTneQDmIa0PlyFiuEXDw1Ocrw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.6.0.tgz",
"integrity": "sha512-VrW0duVxLwaquInwmuNDMz8o0tfCDwe3j81fvTUDW/s7KqnzFbxK7vEuq5TEtxWuSng2DXxyX3r1ntCm4X/NCg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0",
"@embedpdf/utils": "2.5.0"
"@embedpdf/models": "2.6.0",
"@embedpdf/utils": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -901,15 +934,15 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.5.0.tgz",
"integrity": "sha512-kG8HZMZmbpUVDxCOEyQzIiMPW+VjjebOl93V+quAH+GAI5Tkg6exPyyQ2+/DOJPCtYX4Kh2z2aeoyK2b7NRgIQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.6.0.tgz",
"integrity": "sha512-0mzPCJlw1X7jWeDg5JssU6/HCFtyOP7scEdbIaASYzofGXa2Rj8/+L+UDBrb+KTF6CR4X6fEfeNmxWmAatOAWQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -918,16 +951,16 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.5.0.tgz",
"integrity": "sha512-iWofJSXKbWrgvS2fe8v3U1+e2wjBRXD2i1DUcJKnTrqyfjZ8YzUomc5EzdG2RT7uUjtqrcu7463TZ9JHXUkASQ==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.6.0.tgz",
"integrity": "sha512-Sj4jCV1MNk+19zKWX4KfSl5c0YHrqVG83pEYfqexjSkSX7y7HRwAOtMBtNd3uLInPPSBnzDxj+KlJlIe8RPPJw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-render": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-render": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -936,18 +969,18 @@
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.5.0.tgz",
"integrity": "sha512-oih0GyGOJvfaXPLSEY+qfC05UUU1ZkADEbr6uCwRMmdHIXu/0ZTJnAToegfWXtfE+Sw0J5wscVkipXXIX5azlw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.6.0.tgz",
"integrity": "sha512-qyiHWljryHWQ7uzip2WDg4x28o/1QM0wh9oIyz5WlBnrDaK6bLJGsWUym5P6WfLp0Y8h6GFAslNNcgjBv6E3qw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-render": "2.5.0",
"@embedpdf/plugin-scroll": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-render": "2.6.0",
"@embedpdf/plugin-scroll": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -956,15 +989,15 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.5.0.tgz",
"integrity": "sha512-z0AXHA9Z3rZdCLje7P2NsQbxKLJ4b/l8lgzXOVn5Ow/pIPE0D2P3fn9WzImHTNI1RNrZMdkW9OH3lfkEXFTqHw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.6.0.tgz",
"integrity": "sha512-Ea7s+LivQ4ph01mVngU2tu2Ni/zulxzIyiifCpMaBMHmvjGFQjJcNNYHR90YuM8keto82KCszxdNDuAEEzT6Wg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/core": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -973,17 +1006,17 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.5.0.tgz",
"integrity": "sha512-HWJlqXOXdv/kttV+XWCCStUZAeLl66AuaO8BsnPlAPwEADLLCH4tR4XqJQoWr7/r5watKP7UeQ00FsWu0oGclw==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.6.0.tgz",
"integrity": "sha512-2XUgasN2ZQm2MgpB6ls/re/SKhsREvt2D1gIcvJgXvGkene0NcpxGNIRi/+JN7W0fw4x3QtwLQtyF+/0uMgPmg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.5.0"
"@embedpdf/models": "2.6.0"
},
"peerDependencies": {
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-scroll": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-scroll": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -992,9 +1025,9 @@
}
},
"node_modules/@embedpdf/utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.5.0.tgz",
"integrity": "sha512-JjYj6BRzu9oesA1JOqKPFMEWKinjvJIjziWu1j6lDXxLsE59bkShjUKbaEG+lkXRspuZRWNP++rzE2p2Ht4veg==",
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.6.0.tgz",
"integrity": "sha512-FT6U6L3Et688urUTyISpYH05w4sG+WzoWxaI7aPU4ieh4c/vVgadUtjZj/QCC8v+DebPYRAX1gpUY7e0Y0HlTQ==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
@@ -6462,11 +6495,23 @@
"node": ">=6"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -6479,9 +6524,18 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -6600,6 +6654,12 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
@@ -8849,6 +8909,22 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -10695,6 +10771,15 @@
"node": ">= 0.4.0"
}
},
"node_modules/node-html-better-parser": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
"integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
"license": "MIT",
"dependencies": {
"html-entities": "^2.3.2"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -11250,24 +11335,6 @@
"node": ">= 14.16"
}
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pdfjs-dist": {
"version": "5.4.530",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz",
@@ -12975,6 +13042,21 @@
"integrity": "sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==",
"license": "MIT"
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
+24 -23
View File
@@ -7,28 +7,29 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.5.0",
"@embedpdf/engines": "^2.5.0",
"@embedpdf/models": "^2.5.0",
"@embedpdf/plugin-annotation": "^2.5.0",
"@embedpdf/plugin-bookmark": "^2.5.0",
"@embedpdf/plugin-export": "^2.5.0",
"@embedpdf/plugin-history": "^2.5.0",
"@embedpdf/plugin-document-manager": "^2.5.0",
"@embedpdf/plugin-interaction-manager": "^2.5.0",
"@embedpdf/plugin-pan": "^2.5.0",
"@embedpdf/plugin-print": "^2.5.0",
"@embedpdf/plugin-redaction": "^2.5.0",
"@embedpdf/plugin-render": "^2.5.0",
"@embedpdf/plugin-rotate": "^2.5.0",
"@embedpdf/plugin-scroll": "^2.5.0",
"@embedpdf/plugin-search": "^2.5.0",
"@embedpdf/plugin-selection": "^2.5.0",
"@embedpdf/plugin-spread": "^2.5.0",
"@embedpdf/plugin-thumbnail": "^2.5.0",
"@embedpdf/plugin-tiling": "^2.5.0",
"@embedpdf/plugin-viewport": "^2.5.0",
"@embedpdf/plugin-zoom": "^2.5.0",
"@embedpdf/core": "^2.6.0",
"@embedpdf/engines": "^2.6.0",
"@embedpdf/models": "^2.6.0",
"@embedpdf/plugin-annotation": "^2.6.0",
"@embedpdf/plugin-attachment": "^2.6.0",
"@embedpdf/plugin-bookmark": "^2.6.0",
"@embedpdf/plugin-export": "^2.6.0",
"@embedpdf/plugin-history": "^2.6.0",
"@embedpdf/plugin-document-manager": "^2.6.0",
"@embedpdf/plugin-interaction-manager": "^2.6.0",
"@embedpdf/plugin-pan": "^2.6.0",
"@embedpdf/plugin-print": "^2.6.0",
"@embedpdf/plugin-redaction": "^2.6.0",
"@embedpdf/plugin-render": "^2.6.0",
"@embedpdf/plugin-rotate": "^2.6.0",
"@embedpdf/plugin-scroll": "^2.6.0",
"@embedpdf/plugin-search": "^2.6.0",
"@embedpdf/plugin-selection": "^2.6.0",
"@embedpdf/plugin-spread": "^2.6.0",
"@embedpdf/plugin-thumbnail": "^2.6.0",
"@embedpdf/plugin-tiling": "^2.6.0",
"@embedpdf/plugin-viewport": "^2.6.0",
"@embedpdf/plugin-zoom": "^2.6.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -56,7 +57,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"@cantoo/pdf-lib": "^2.5.3",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,7 @@
],
"permissions": [
"core:default",
"core:window:allow-destroy",
"http:default",
{
"identifier": "http:allow-fetch",
@@ -49,6 +50,7 @@
"allow": [{ "path": "**" }]
},
"dialog:default",
"dialog:allow-message",
"dialog:allow-open",
"dialog:allow-save",
"opener:default",
+9
View File
@@ -33,3 +33,12 @@ pub async fn clear_opened_files() -> Result<(), String> {
Ok(())
}
// Command to atomically get and clear opened file paths
#[tauri::command]
pub async fn pop_opened_files() -> Result<Vec<String>, String> {
let mut opened_files = OPENED_FILES.lock().unwrap();
let all_files = opened_files.clone();
opened_files.clear();
add_log(format!("📂 Returning and clearing {} opened file(s)", all_files.len()));
Ok(all_files)
}

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