Compare commits

...
Author SHA1 Message Date
aandClaude Opus 4.6 d86afba440 Add workflow to clear all GitHub Actions caches
Runs on push to this branch and via manual workflow_dispatch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:52:16 +01:00
Anthony Stirlinganda ebab5a4456 pipeline fixes (#6068)
Co-authored-by: a <a>
2026-04-04 10:19:38 +01:00
Reece Browne 436c8cbed2 Line seperator fix for redaction drift (#6064) 2026-04-03 17:47:48 +01:00
Anthony Stirling 81dc90cd6d possible fix permission issues and fix thread timing issues (#6061)
# 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-04-03 16:49:16 +01:00
EthanHealy01andReece Browne 917edc43b3 Add specific View Scope For Selected Files (#6050)
## Fix 1 — Viewer bug (8 tools)

8 tools called `useFileSelection()` directly instead of routing through
`useBaseTool`. In the viewer, this meant they operated on **all selected
files**
instead of only the one being viewed. For example: 10 files loaded,
viewing
file 3, running Add Stamp — all 10 files got stamped.

**Root cause:** These tools had no view-scope awareness.
`useFileSelection()`
returns the raw workbench selection with no knowledge of which file is
active in
the viewer.

**Fix:** A new hook `useViewScopedFiles` was introduced:

```ts
// Viewer → only the active file
// Everywhere else → all loaded files
const selectedFiles = useViewScopedFiles();
```

The 8 tools were updated to call this instead of `useFileSelection()`.

**Tools fixed:** Add Stamp, Add Watermark, Add Password, Add Page
Numbers,
Add Attachments, Reorganize Pages, OCR, Convert

---

## Fix 2 — Page selector / active files context (all tools)

`useBaseTool` returned `selectedFiles` (checked files only) in
non-viewer
contexts. In the page selector this is typically empty or stale — not
the full
set of loaded files that tools should operate on.

**Fix:** `useBaseTool` was updated to use `useViewScopedFiles`, which
returns
all loaded files in non-viewer contexts. This affected every tool via
`useBaseTool`.

---

## Workarounds for Compare & Merge

Two tools intentionally need all loaded files regardless of view, so
they use
`ignoreViewerScope: true` in `useBaseTool`.

**Compare** — needs exactly 2 files for its Original/Edited slots.
Scoping to
one file would break the comparison entirely. `ignoreViewerScope: true`
is set
and `disableScopeHints: true` hides the "(this file)" button label hint.
The
slot auto-mapping logic was also improved alongside this fix.

**Merge** — needs 2+ files; merging a single file is meaningless. Rather
than
leaving the button silently disabled, Merge now:
- Auto-redirects to the active files view on first open from the viewer
- If the user navigates back to the viewer, shows a disabled button with
a hint
  and a "Go to active files view" shortcut button

---

## How to Test

---

## Fix 1 — 8 tools (viewer scoping)

### Test steps (same for each)
1. Load 3 PDFs into workbench
2. Open viewer, navigate to file 2
3. Open the tool, configure settings, run
4.  Only file 2 is in the results
5.  Button label shows **"[Action] (this file)"**
6.  A note below the button reads **"Only applying to: [filename]"**

| Tool | What to configure |
|---|---|
| **Add Stamp** | Enter any text stamp or upload an image stamp |
| **Add Watermark** | Select text watermark, enter any text |
| **Add Page Numbers** | Leave defaults |
| **Add Password** | Enter any owner + user password |
| **Add Attachments** | Attach any small file |
| **Reorganize Pages** | Enter a page range e.g. `1,2` |
| **OCR** | Leave default language |
| **Convert** | Convert PDF → any format |

---

## Fix 2 — All tools (page selector context)

### Test steps
1. Load 3 PDFs into workbench
2. Open the page selector view 
3. Open any tool from the sidebar, run it
4.  All 3 files are processed (not zero or a stale subset)

---

## Compare (intentionally ignores view scope)

**A — Auto-fill with exactly 2 files**
1. Load exactly 2 PDFs
2. Open Compare from either the viewer or active files view
3.  Both slots are filled automatically (Original + Edited)
4.  No scope hint appears on the button

**B — Manual selection with 3+ files**
1. Load 3+ PDFs
2. Open Compare
3.  The first 2 files fill the slots
4.  A 3rd file does not add a 3rd slot (capped at 2)

**C — File removed mid-session**
1. Load 2 PDFs, let Compare auto-fill both slots
2. Remove one file from the workbench
3.  The corresponding slot clears; the other slot is unchanged

**D — Viewer mode**
1. Load 2 PDFs, open viewer
2. Open Compare from the viewer sidebar
3.  Both files are still available for slot selection (not scoped to
current file)

---

## Merge (intentionally ignores view scope, disabled in viewer)

**A — Auto-redirect on first open from viewer**
1. Load 2+ PDFs, open the viewer
2. Open Merge from the viewer sidebar
3.  Immediately redirected to the active files view

**B — Viewer mode disabled state (after navigating back)**
1. From the active files view, open Merge, then navigate back to the
viewer
2.  Execute button is **disabled** with tooltip "Switch to the file
editor to select multiple files"
3.  A note appears: *"Merge needs 2 or more files. Head to the file
editor to select them."*
4.  A **"Go to active files view"** button is shown; clicking it
navigates back

**C — Active files view works normally**
1. Load 3 PDFs, open Merge from the active files view
2.  All 3 files appear in the merge list
3.  Button shows **"Merge (3 files)"**
4. Run the merge
5.  Output is a single PDF containing all 3 files

---

## Button label behaviour (all tools)

| Context | Expected button text |
|---|---|
| Viewer, 1 file loaded | `[Action]` (no suffix) |
| Viewer, 2+ files loaded | `[Action] (this file)` |
| Active files view, 1 file loaded | `[Action]` (no suffix) |
| Active files view, 2+ files loaded | `[Action] (N files)` |
| Merge in viewer | disabled — no suffix |
| Compare | never shows scope suffix (`disableScopeHints: true`) |

---------

Co-authored-by: Reece Browne <74901996+reecebrowne@users.noreply.github.com>
2026-04-03 16:04:38 +01:00
Anthony Stirling 3c48740c5e dep updates (#6058) 2026-04-03 13:24:41 +01:00
stirlingbot[bot]andAnthony Stirling 2c940569d1 🤖 format everything with pre-commit by stirlingbot (#6000)
Co-authored-by: stirlingbot[bot] <195170888+stirlingbot[bot]@users.noreply.github.com>
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
2026-04-02 18:47:52 +01:00
Thomas BERNARD 7bbb04b594 translate more messages to fr-FR (#6042) 2026-04-02 17:55:17 +01:00
Dexterity fca40e5544 Fix image stamp cropping and align preview with PDF output for add-stamp (#6013) 2026-04-02 17:54:13 +01:00
Anthony Stirling c9a70f3754 removeffmpeg (#6053) 2026-04-02 17:40:02 +01:00
Reece Browne 0adcbeedf1 Fix/redact bug (#6048) 2026-04-02 17:39:45 +01:00
Anthony Stirlinganda de9625942b Pipeline changes and version bump (#6047)
# Description of Changes

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

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

Closes #(issue_number)
-->

---

## Checklist

### General

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

### Documentation

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

### Translations (if applicable)

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

### UI Changes (if applicable)

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

### Testing (if applicable)

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

---------

Co-authored-by: a <a>
2026-04-02 12:52:22 +01:00
Peter Dave Hello da9327ab1c Restore English search aliases in zh-TW tags (#6039)
# Description of Changes

Preserve the translated zh-TW tags while restoring the English aliases
used by frontend tool search.

This keeps common English technical queries such as permissions or
access control discoverable in the zh-TW locale.
---

## Checklist

### General

- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [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)

- [x] 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.

## GitHub Copilot Pull Reuqest summary

> This pull request significantly expands the keyword tags for a wide
range of PDF-related tools and actions in the Traditional Chinese
(`zh-TW`) translation file. The main goal is to improve searchability
and discoverability of features by including a comprehensive set of
English and Chinese keywords, synonyms, and related phrases for each
tool.
> 
> The most important changes include:
> 
> **Localization and Search Optimization:**
> 
> * Expanded the `tags` fields for all tools and actions under the
`[home.*]` sections in `frontend/public/locales/zh-TW/translation.toml`
to include a broad set of English and Chinese keywords, synonyms, and
common search phrases. This enhances feature discoverability for users
searching in either language.
[[1]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3885-R3925)
[[2]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3934-R4039)
[[3]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4054-R4209)
[[4]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4218-R4218)
> 
> **Consistency and Coverage:**
> 
> * Ensured that each tool/action now has a rich set of tags that cover
various ways users might refer to the feature, including technical
terms, synonyms, and related concepts (e.g., "merge", "combine", "join"
for PDF merging).
[[1]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3885-R3925)
[[2]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3934-R4039)
[[3]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4054-R4209)
[[4]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4218-R4218)
> 
> **Internationalization Improvements:**
> 
> * Added English keywords alongside Chinese ones to support bilingual
search and better serve users who may search using English terms in a
localized interface.
[[1]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3885-R3925)
[[2]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L3934-R4039)
[[3]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4054-R4209)
[[4]](diffhunk://#diff-5979ec7aabfd804ffe625390faee80bfc5b97bdb00f72cc3ce27359f82450e87L4218-R4218)
> 
> These changes collectively make it easier for users to find the
features they need, regardless of the language or terminology they use.
2026-04-02 08:35:38 +00:00
EthanHealy01 61280f758a bump deps (#6041)
bump deps and add a one week buffer to releases that we merge in to
allow for vulnerabilities to be caught.
2026-04-01 18:08:45 +01:00
ConnorYoh 801cc8a5f4 Alpha flag for file storage settings (#6044)
## Summary
- Added "Alpha" badge to the File Storage & Sharing nav item in the
settings sidebar
- Added "Alpha" badge to the File Storage & Sharing page title
- Removed the old inline "(Alpha)" text from the Enable Group Signing
label
- Restructured all toggle cards so the switch is anchored to the right
of each row
- Tightened spacing between cards for a more compact layout
- Extended `ConfigNavItem` interface with optional `badge` and
`badgeColor` fields for reuse elsewhere
<img width="1696" height="1057" alt="image"
src="https://github.com/user-attachments/assets/77ac8276-ed65-4cae-8470-65de8f56dd74"
/>
2026-04-01 17:18:48 +01:00
EthanHealy01 74153b6deb Bug/connection mode fixes (#5998) 2026-04-01 15:33:46 +01:00
Anthony Stirling ecd1d3cad3 fix new line in redact (#6035) 2026-04-01 11:58:38 +01:00
Anthony Stirling 0a098cf7b7 idle cpu fix test (#6015) 2026-04-01 11:58:10 +01:00
Anthony Stirling cfa8d1e5d7 qr split fixes (#6043) 2026-04-01 11:54:33 +01:00
Anthony Stirling 5ffa808c0f Remove gosu (#6036) 2026-04-01 11:54:12 +01:00
Matheus Saito 212f12a81f Added back ctrl+r as rotate if on desktop (#5982) (#5993)
Fix #5982

Behaviour of ctrl+r altered to support rotate on desktop, while the web
version continue to use refresh as default.
2026-04-01 11:48:53 +01:00
James Brunton c31e4253dd Fix any type usage in proprietary/ (#5949)
# Description of Changes
Follow on from #5934, expanding `any` type usage ban to the
`proprietary/` folder
2026-04-01 08:21:26 +00:00
Peter Dave Hello a96b95e198 Update and improve zh-TW Traditional Chinese locale (#6034) 2026-03-30 21:10:49 +01:00
Anthony Stirling a06b6a4bac pdf layer toggle (#6028) 2026-03-30 17:04:53 +01:00
Anthony Stirlinganda cdc288e78d nonpdf-viewer (#6024)
Co-authored-by: a <a>
2026-03-30 16:39:11 +01:00
Anthony Stirling 82a3b8c770 Unlock account (#5984) 2026-03-30 16:07:57 +01:00
ConnorYoh 1e97a32d4b feat(desktop): gate shared signing behind self-hosted auth (#6002)
## Summary

This PR adds full desktop (Tauri) support for the shared signing feature
when connected to a self-hosted server, and fixes several bugs
discovered during that work.

### Feature gating

Shared signing, file sharing, and share links are proprietary server
features that require an authenticated self-hosted session. Previously
these were read directly from `config` with no awareness of connection
mode or auth state, meaning the UI could appear in SaaS/local mode or
when logged out.

- Introduce `useGroupSigningEnabled` and `useSharingEnabled` hooks with
core implementations (web behaviour unchanged) and desktop overrides
that require `selfhosted` mode + an active authenticated session
- Extract shared subscription logic into `useSelfHostedAuth` (connection
mode + auth state + config refetch)
- `QuickAccessBar` now derives all three flags from the hooks instead of
raw config

### Config timing fix

When a user logs in via the SetupWizard, the `jwt-available` event fires
a config fetch *before* the mode is switched to `selfhosted`. This meant
the config was fetched from the local bundled backend (port ~59567)
which has no knowledge of `storageGroupSigningEnabled`, causing the
group signing button to stay hidden until a full page refresh.
`useSelfHostedAuth` detects the mode transition and triggers a fresh
config fetch at the correct moment, after the self-hosted URL is active.

### Bug fixes

**`SignPopout.tsx`** — Manually setting `Content-Type:
multipart/form-data` on two `FormData` POST requests stripped the
auto-generated boundary, causing a `400 bad multipart` from the server.
Removed the explicit headers so Axios sets them correctly.

**`tauriHttpClient.ts`** — `response.json()` was called before
`response.ok` was checked. A plain-text error body from the server (e.g.
`"Cannot sign..."`) caused a `SyntaxError` that fell into the network
error catch block and was reported as `ERR_NETWORK`, hiding the real
failure. The fix checks `response.ok` first, reads error bodies as text,
and handles empty 200 bodies (returning `null` instead of throwing).

---

## Testing

### Prerequisites
- Desktop app running in self-hosted mode pointed at a local
Stirling-PDF instance (`http://localhost:8080`)
- The self-hosted instance has group signing and storage enabled in
settings
- At least two user accounts on the self-hosted instance

### 1. Feature gating — group signing button

| Step | Expected |
|---|---|
| Open the desktop app in **local mode** (no server configured) | Group
signing button absent from QuickAccessBar |
| Switch to self-hosted mode but **do not log in** | Group signing
button absent |
| Log in to the self-hosted server | Group signing button appears
without requiring a page refresh |
| Log out | Group signing button disappears immediately |
| Log back in | Group signing button reappears without a page refresh |

### 2. Feature gating — file sharing

Repeat the same steps above, verifying the share and share-link buttons
in the file manager follow the same visibility rules.

### 3. Create a signing session

1. Log in, open the group signing panel from QuickAccessBar
2. Select a PDF, add a participant, configure signature defaults and
submit
3. Verify the session is created successfully (no `400 bad multipart`
error)

### 4. Participant signing

1. As the invited participant, open the signing request from
QuickAccessBar
2. Upload or draw a signature and submit
3. Verify signing completes successfully (no `ERR_NETWORK` error)

### 5. Error surfacing

1. Attempt an action that the server rejects (e.g. sign a document with
an invalid certificate)
2. Verify the actual server error message is shown rather than a generic
network error
2026-03-30 14:37:45 +00:00
James Brunton 4a6b426651 Only allow Tauri imports in the desktop app (#5995)
# Description of Changes
Adds an eslint rule to disallow importing any Tauri APIs outside the
desktop folder to help hint to developers that they should be following
the frontend architecture.

While doing this, I also discovered that you can provide a custom
message in the `no-restricted-imports` rule, which is nicer than the
comments that I'd previously added to the eslint config file to explain
why they weren't allowed:

```text
/Users/jamesbrunton/Dev/spdf1/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx
  19:1  error  'src/core/contexts/PreferencesContext' import is restricted from being used by a pattern. Use @app/* imports instead of absolute src/ imports              no-restricted-imports
  20:1  error  '../../../../../core/contexts/AppConfigContext' import is restricted from being used by a pattern. Use @app/* imports instead of relative imports          no-restricted-imports
  21:1  error  '@tauri-apps/core' import is restricted from being used by a pattern. Tauri APIs are desktop-only. Review frontend/DeveloperGuide.md for structure advice  no-restricted-imports
```
2026-03-30 14:24:16 +00:00
ConnorYoh 0e29640766 fix: get all Playwright E2E tests loading and expand CI to run full suite (#6009)
## Fix Playwright E2E tests and expand CI to run full suite

### Problem

The full Playwright suite was broken in two ways:

1. **`ConvertE2E.spec.ts` crashed at import time** —
`conversionEndpointDiscovery.ts` imported a React hook at the top level,
which pulled in the entire component tree. That chain eventually
required `material-symbols-icons.json` (a generated file that didn't
exist), crashing module resolution before any tests ran.

2. **CI only ran cert validation tests** — both `build.yml` and
`nightly.yml` hardcoded `src/core/tests/certValidation` as the test
path, silently ignoring everything else.

### Changes

**`ConvertE2E.spec.ts` — complete rewrite**
The old tests were useless in practice: all 9 dynamic conversion tests
were permanently skipped unless a real Spring Boot backend was running
(they called a live `/api/v1/config/endpoints-enabled` endpoint at
module load time). Replaced with 4 focused tests that use `page.route()`
mocking — no backend required, same pattern as
`CertificateValidationE2E`.

New tests cover:
- Convert button absent before a format pair is selected
- Successful PDF→PNG conversion shows a download button (mocked API
response)
- API error surfaces as an error notification
- Convert button appears and is enabled after selecting valid formats

**`conversionEndpointDiscovery.ts` — deleted**
Only existed to support the old tests. The `useConversionEndpoints`
React hook it exported was never imported anywhere else.

**`ReviewToolStep.tsx`**
Added `data-testid="download-result-button"` to the download button —
required for the happy-path test assertion.

**CI workflows (`build.yml`, `nightly.yml`)**
- Added a `Generate icons` step before Playwright runs (`node
scripts/generate-icons.js`) — the icon JSON is generated by `npm run
dev` locally but skipped by `npm ci` in CI
- Removed the `src/core/tests/certValidation` path filter so the full
suite runs
2026-03-30 11:27:55 +01:00
albanobattistella 05b4255751 Update Italian translations (#6014) 2026-03-30 11:04:11 +01:00
dependabot[bot] 1ab07a9027 build(deps): bump crazy-max/ghaction-github-labeler from 5.3.0 to 6.0.0 (#6019)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 11:03:11 +01:00
dependabot[bot] 75421b4223 build(deps): bump qrcode from 8.0 to 8.2 in /testing/cucumber (#6022)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 11:02:30 +01:00
dependabot[bot] a7fe4e9a76 build(deps): bump pypdf from 6.7.5 to 6.9.2 in /testing/cucumber (#6020)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 11:02:12 +01:00
dependabot[bot] 10ab2872f6 build(deps): bump requests from 2.32.5 to 2.33.0 in /testing/cucumber (#6017)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 11:01:56 +01:00
Anthony Stirling 2fdc9c112f test reports for test.sh and fix test.sh deployments (#6027) 2026-03-29 23:35:45 +01:00
ConnorYoh dd44de349c Shared Sign Cert Validation (#5996)
## PR: Certificate Pre-Validation for Document Signing

### Problem

When a participant uploaded a certificate to sign a document, there was
no validation at submission time. If the certificate had the wrong
password, was expired, or was incompatible with the signing algorithm,
the error only surfaced during **finalization** — potentially days
later, after all other participants had signed. At that point the
session is stuck with no way to recover.

Additionally, `buildKeystore` in the finalization service only
recognised `"P12"` as a cert type, causing a `400 Invalid certificate
type: PKCS12` error when the **owner** signed using the standard
`PKCS12` identifier.

---

### What this PR does

#### Backend — Certificate pre-validation service

Adds `CertificateSubmissionValidator`, which validates a keystore before
it is stored by:
1. Loading the keystore with the provided password (catches wrong
password / corrupt file)
2. Checking the certificate's validity dates (catches expired and
not-yet-valid certs)
3. Test-signing a blank PDF using the same `PdfSigningService` code path
as finalization (catches algorithm incompatibilities)

This runs on both the participant submission endpoint
(`WorkflowParticipantController`) and the owner signing endpoint
(`SigningSessionController`), so both flows are protected.

#### Backend — Bug fix

`SigningFinalizationService.buildKeystore` now accepts `"PKCS12"` and
`"PFX"` as aliases for `"P12"`, consistent with how the validator
already handles them. This fixes a `400` error when the owner signed
using the `PKCS12` cert type.

#### Frontend — Real-time validation feedback

`ParticipantView` gains a debounced validation call (600ms) triggered
whenever the cert file or password changes. The UI shows:
- A spinner while validating
- Green "Certificate valid until [date] · [subject name]" on success
- Red error message on failure (wrong password, expired, not yet valid)
- The submit button is disabled while validation is in flight

#### Tests — Three layers

| Layer | File | Coverage |
|---|---|---|
| Service unit | `CertificateSubmissionValidatorTest` | 11 tests — valid
P12/JKS, wrong password, corrupt bytes, expired, not-yet-valid, signing
failure, cert type aliases |
| Controller unit | `WorkflowParticipantValidateCertificateTest` | 4
tests — valid cert, invalid cert, missing file, invalid token |
| Controller integration | `CertificateValidationIntegrationTest` | 6
tests — real `.p12`/`.jks` files through the full controller → validator
stack |
| Frontend E2E | `CertificateValidationE2E.spec.ts` | 7 Playwright tests
— all feedback states, button behaviour, SERVER type bypass |

#### CI

- **PR**: Playwright runs on chromium when frontend files change (~2-3
min)
- **Nightly / on-demand**: All three browsers (chromium, firefox,
webkit) at 2 AM UTC, also manually triggerable via `workflow_dispatch`
2026-03-27 14:01:10 +00:00
James Brunton e10c5f6283 Redesign Python AI engine (#5991)
# Description of Changes
Redesign the Python AI engine to be properly agentic and make use of
`pydantic-ai` instead of `langchain` for correctness and ergonomics.
This should be a good foundation for us to build our AI engine on going
forwards.
2026-03-26 10:35:47 +00:00
Anthony StirlingandClaude Haiku 4.5 9500acd69f Base docker image (#5958)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-25 15:41:58 +00:00
Anthony Stirlinganda bb43e9dcdf dark mode PDF filter init (#5994)
Co-authored-by: a <a>
2026-03-25 15:38:42 +00:00
28613caf8a fileshare (#5414)
Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Co-authored-by: Connor Yoh <con.yoh13@gmail.com>
Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-25 11:00:40 +00:00
609 changed files with 66168 additions and 34293 deletions
+23
View File
@@ -8,6 +8,9 @@ build/
**/build/
out/
target/
**/target/
bin/
version_builds/
# Gradle caches (local, not what's in the container)
.gradle/
@@ -16,9 +19,15 @@ target/
# Node / frontend
node_modules/
**/node_modules/
frontend/node_modules/
frontend/dist/
.npm/
.yarn/
# Tauri/desktop builds
src-tauri/target/
src-tauri/dist/
# IDE and editor
.idea/
.vscode/
@@ -46,7 +55,21 @@ Dockerfile*
**/test-results/
**/jacoco/
# Testing and documentation (not needed in build)
testing/
docs/
*.md
README*
# Local env
.env
.env.*
!.env.example
# Misc
*.swp
*.swo
*~
.DS_Store
.cache/
.pytest_cache/
+10 -3
View File
@@ -6,14 +6,20 @@ openapi: &openapi
- *build
- app/(common|core|proprietary)/src/main/java/**
docker-base: &docker-base
- docker/base/Dockerfile
- ".github/workflows/push-docker-base.yml"
docker: &docker
- Dockerfile
- Dockerfile.fat
- Dockerfile.ultra-lite
- docker/embedded/Dockerfile
- docker/embedded/Dockerfile.fat
- docker/embedded/Dockerfile.ultra-lite
- ".github/workflows/build.yml"
- ".github/workflows/push-docker.yml"
- scripts/init.sh
- scripts/init-without-ocr.sh
- exampleYmlFiles/**
- *docker-base
project: &project
- app/(common|core|proprietary)/src/(main|test)/java/**
@@ -24,6 +30,7 @@ project: &project
- libs/**
- "testing/**/!(requirements*.txt|requirements*.in)*"
- *docker
- *docker-base
- gradle.properties
- gradlew
- gradlew.bat
+74 -1
View File
@@ -30,6 +30,7 @@ jobs:
project: ${{ steps.changes.outputs.project }}
openapi: ${{ steps.changes.outputs.openapi }}
frontend: ${{ steps.changes.outputs.frontend }}
docker-base: ${{ steps.changes.outputs.docker-base }}
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
@@ -216,6 +217,39 @@ jobs:
path: frontend/dist/
retention-days: 3
playwright-e2e:
if: needs.files-changed.outputs.frontend == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Generate icons
run: cd frontend && node scripts/generate-icons.js
- name: Install Playwright (chromium only)
run: cd frontend && npx playwright install chromium --with-deps
- name: Run E2E tests (chromium)
run: cd frontend && npx playwright test --project=chromium
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-report-pr-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 7
check-licence:
if: needs.files-changed.outputs.build == 'true'
needs: [files-changed, build]
@@ -357,6 +391,7 @@ jobs:
MAVEN_USER: ${{ secrets.MAVEN_USER }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
MAVEN_PUBLIC_URL: ${{ secrets.MAVEN_PUBLIC_URL }}
DOCKER_BASE_CHANGED: ${{ needs.files-changed.outputs.docker-base }}
- name: Upload Cucumber Report
if: always()
@@ -367,6 +402,15 @@ jobs:
retention-days: 7
if-no-files-found: warn
- name: Upload Test Reports
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: docker-compose-test-reports
path: testing/reports/
retention-days: 7
if-no-files-found: warn
- name: Cucumber Test Report
if: always()
uses: dorny/test-reporter@b082adf0eced0765477756c2a610396589b8c637 # v2.5.0
@@ -402,6 +446,17 @@ jobs:
- name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Free disk space on runner
run: |
echo "Disk space before cleanup:" && df -h
@@ -446,6 +501,22 @@ jobs:
id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Build base image locally (PR base change only)
if: github.event_name == 'pull_request' && needs.files-changed.outputs.docker-base == 'true'
run: |
docker build -t stirling-pdf-base:pr-test -f docker/base/Dockerfile docker/base
- name: Set base image and platform for this build
id: build-params
run: |
if [ "${{ github.event_name }}" == "pull_request" ] && [ "${{ needs.files-changed.outputs.docker-base }}" == "true" ]; then
echo "base_image=stirling-pdf-base:pr-test" >> $GITHUB_OUTPUT
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
else
echo "base_image=stirlingtools/stirling-pdf-base:latest" >> $GITHUB_OUTPUT
echo "platforms=linux/amd64,linux/arm64/v8" >> $GITHUB_OUTPUT
fi
- name: Build ${{ matrix.docker-rev }}
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
@@ -455,7 +526,9 @@ jobs:
push: false
cache-from: type=gha,scope=${{ matrix.cache-scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.cache-scope }}
platforms: linux/amd64,linux/arm64/v8
platforms: ${{ steps.build-params.outputs.platforms }}
build-args: |
BASE_IMAGE=${{ steps.build-params.outputs.base_image }}
provenance: true
sbom: true
+56
View File
@@ -0,0 +1,56 @@
name: Clear GitHub Actions Cache
on:
workflow_dispatch:
push:
branches:
- clear-github-cache
jobs:
clear-cache:
runs-on: ubuntu-latest
permissions:
actions: write
steps:
- name: Clear all caches
uses: actions/github-script@v7
with:
script: |
const caches = await github.rest.actions.getActionsCacheList({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
let deleted = 0;
for (const cache of caches.data.actions_caches) {
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
await github.rest.actions.deleteActionsCacheById({
owner: context.repo.owner,
repo: context.repo.repo,
cache_id: cache.id,
});
deleted++;
}
// Handle pagination if more than 100 caches
let totalCount = caches.data.total_count;
while (deleted < totalCount) {
const moreCaches = await github.rest.actions.getActionsCacheList({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 100,
});
if (moreCaches.data.actions_caches.length === 0) break;
for (const cache of moreCaches.data.actions_caches) {
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
await github.rest.actions.deleteActionsCacheById({
owner: context.repo.owner,
repo: context.repo.repo,
cache_id: cache.id,
});
deleted++;
}
}
console.log(`Successfully deleted ${deleted} caches.`);
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Labeler
uses: crazy-max/ghaction-github-labeler@24d110aa46a59976b8a7f35518cb7f14f434c916 # v5.3.0
uses: crazy-max/ghaction-github-labeler@548a7c3603594ec17c819e1239f281a3b801ab4d # v6.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
yaml-file: .github/labels.yml
+3
View File
@@ -182,6 +182,9 @@ jobs:
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
allowed-endpoints: >
one.digicert.com:443
clientauth.one.digicert.com:443
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+53
View File
@@ -0,0 +1,53 @@
name: Nightly E2E Tests
on:
schedule:
- cron: "0 2 * * *" # 2 AM UTC every night
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
playwright-all-browsers:
name: Playwright (chromium + firefox + webkit)
runs-on: ubuntu-latest
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: "22"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: cd frontend && npm ci
- name: Generate icons
run: cd frontend && node scripts/generate-icons.js
- name: Install all Playwright browsers
run: cd frontend && npx playwright install --with-deps
- name: Run E2E tests (all browsers)
run: cd frontend && npx playwright test
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: playwright-nightly-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 14
+122
View File
@@ -0,0 +1,122 @@
name: Push Docker Base Image
on:
push:
branches:
- baseDockerImage
- accessIssueFix
workflow_dispatch:
inputs:
version:
description: 'Base image version (e.g., 1.0.0, 1.0.1)'
required: true
type: string
permissions:
contents: read
jobs:
push-base:
if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
runs-on: ubuntu-24.04-8core
permissions:
packages: write
id-token: write
steps:
- name: Verify authorized user
run: |
if [ "${{ github.actor }}" != "Frooodle" ]; then
echo "Error: Only Frooodle is authorized to run this workflow"
exit 1
fi
- name: Set version
id: version
run: |
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
elif [ "${{ github.ref_name }}" == "accessIssueFix" ]; then
VERSION="1.0.3"
else
VERSION="1.0.0"
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Set up QEMU
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Generate tags for base image
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: |
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-base
tags: |
type=raw,value=${{ steps.version.outputs.version }}
- name: Build and push base image
id: build-push-base
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
builder: ${{ steps.buildx.outputs.name }}
context: docker/base
file: ./docker/base/Dockerfile
push: true
cache-from: type=gha,scope=stirling-pdf-base
cache-to: type=gha,mode=max,scope=stirling-pdf-base
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
- name: Install cosign
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.4.1"
- name: Sign base images
env:
DIGEST: ${{ steps.build-push-base.outputs.digest }}
TAGS: ${{ steps.meta.outputs.tags }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
run: |
if [ -n "$COSIGN_PRIVATE_KEY" ]; then
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
cosign sign --yes \
--key env://COSIGN_PRIVATE_KEY \
"${tag}@${DIGEST}"
done
else
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping image signing"
fi
+3 -1
View File
@@ -130,7 +130,9 @@ jobs:
cache-to: type=gha,mode=max,scope=stirling-pdf-latest
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
build-args: |
VERSION_TAG=${{ steps.versionNumber.outputs.versionNumber }}
BASE_VERSION=1.0.0
platforms: linux/amd64,linux/arm64/v8
provenance: true
sbom: true
+93
View File
@@ -0,0 +1,93 @@
name: Rollback Latest Tags to Version
on:
workflow_dispatch:
inputs:
version:
description: "Version to rollback to (e.g. 2.8.0)"
required: true
type: string
permissions:
contents: read
jobs:
rollback:
runs-on: ubuntu-latest
permissions:
packages: write
steps:
- name: Harden Runner
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
with:
egress-policy: audit
- name: Install crane
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_API }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Convert repository owner to lowercase
id: repoowner
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
- name: Rollback all latest tags to v${{ inputs.version }}
env:
VERSION: ${{ inputs.version }}
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
DOCKER_HUB_ORG_USERNAME: ${{ secrets.DOCKER_HUB_ORG_USERNAME }}
REPO_OWNER: ${{ steps.repoowner.outputs.lowercase }}
run: |
set -euo pipefail
IMAGES=(
"${DOCKER_HUB_USERNAME}/s-pdf"
"ghcr.io/${REPO_OWNER}/s-pdf"
"ghcr.io/${REPO_OWNER}/stirling-pdf"
"${DOCKER_HUB_ORG_USERNAME}/stirling-pdf"
)
VARIANTS=(
"${VERSION}:latest"
"${VERSION}-fat:latest-fat"
"${VERSION}-ultra-lite:latest-ultra-lite"
)
FAILED=0
for image in "${IMAGES[@]}"; do
for variant in "${VARIANTS[@]}"; do
SOURCE_TAG="${variant%%:*}"
TARGET_TAG="${variant##*:}"
echo "::group::${image} — ${SOURCE_TAG} → ${TARGET_TAG}"
if crane manifest "${image}:${SOURCE_TAG}" > /dev/null 2>&1; then
crane cp "${image}:${SOURCE_TAG}" "${image}:${TARGET_TAG}"
echo "✅ ${image}:${TARGET_TAG} now points to ${SOURCE_TAG}"
else
echo "::warning::⚠️ ${image}:${SOURCE_TAG} not found, skipping"
FAILED=1
fi
echo "::endgroup::"
done
done
if [ "$FAILED" -ne 0 ]; then
echo "::warning::Some source tags were not found. This is expected if not all variants exist for this version."
fi
echo ""
echo "🎉 Rollback to ${VERSION} complete!"
+9
View File
@@ -29,8 +29,14 @@ clientWebUI/
exampleYmlFiles/stirling/
/stirling/
/testing/file_snapshots
/testing/cucumber/junit/
/testing/cucumber/report.html
/testing/.failed_tests
SwaggerDoc.json
# Runtime storage for uploaded files and user data (not Java source code)
app/core/storage/
# Frontend build artifacts copied to backend static resources
# These are generated by npm build and should not be committed
app/core/src/main/resources/static/assets/
@@ -197,6 +203,9 @@ out/
*.jks
*.asc
# Allow test fixture certificates (synthetic, no real credentials)
!frontend/src/core/tests/test-fixtures/certs/**
# SSH Keys
*.pub
*.priv
+4 -8
View File
@@ -20,16 +20,12 @@ This file provides guidance to AI Agents when working with code in this reposito
Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security features during development. This is required for testing the full version locally.
### Python Development
Development for the AI engine happens in the `engine/` folder. It's built with Langchain and Pydantic and allows for the creation and editing of PDF documents. The frontend calls the Python via Java as a proxy.
Development for the AI engine happens in the `engine/` folder. The frontend calls the Python via Java as a proxy.
- Python version is 3.13; use modern Python features (type aliases, pattern matching, dataclasses, etc.) where they help clarity.
- Write fully type-correct code; keep pyright clean and avoid `Any` unless strictly necessary.
- JSON handling: deserialize into fully typed Pydantic models as early as possible, and serialize back from Pydantic models as late as possible.
- Follow the engine-specific guidance in [engine/AGENTS.md](engine/AGENTS.md) for Python architecture, code style, and AI usage.
- Use Makefile commands for Python work:
- From `engine/`: `make check` to lint, type-check, test, etc. and `make fix` to fix easily fixable linting & formatting issues.
- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed appropriately there, followed by running `make install`.
- Prefer using classes to nesting functions, and make other similar architectural decisions to improve testability. Do not nest classes or functions unless specifically required to for the code construct (like a decorator).
- All environment variables used within the code must begin with the `STIRLING_` prefix in order to keep them unique and easier to find.
- From `engine/`: `make check` to lint, type-check, test, etc. and `make fix` to fix easily fixable linting and formatting issues.
- The project structure is defined in `engine/pyproject.toml`. Any new dependencies should be listed there, followed by running `make install`.
### Frontend Development
- **Frontend dev server**: `cd frontend && npm run dev` (requires backend on localhost:8080)
+444
View File
@@ -0,0 +1,444 @@
# File Sharing Feature - Architecture & Workflow
## Overview
The File Sharing feature enables users to store files server-side and share them with other registered users or via token-based share links. Files are stored using a pluggable storage provider (local filesystem or database) with optional quota enforcement.
**Key Capabilities:**
- Server-side file storage (upload, update, download, delete)
- Optional history bundle and audit log attachments per file
- Direct user-to-user sharing with access roles
- Token-based share links (requires `system.frontendUrl`)
- Optional email notifications for shares (requires `mail.enabled`)
- Access audit trail (tracks who accessed a share link and how)
- Automatic share link expiration
- Storage quotas (per-user and total)
- Pluggable storage backend (local filesystem or database BLOB)
- Integration with the Shared Signing workflow
## Architecture
### Database Schema
**`stored_files`**
- One record per uploaded file
- Stores file metadata (name, content type, size, storage key)
- Optionally links to a history bundle and audit log as separate stored objects
- `workflow_session_id` — nullable link to a `WorkflowSession` (signing feature)
- `file_purpose` — enum classifying the file's role: `GENERIC`, `SIGNING_ORIGINAL`, `SIGNING_SIGNED`, `SIGNING_HISTORY`
**`file_shares`**
- One record per sharing relationship
- Two share types, distinguished by which fields are set:
- **User share**: `shared_with_user_id` is set, `share_token` is null
- **Link share**: `share_token` is set (UUID), `shared_with_user_id` is null
- `access_role``EDITOR`, `COMMENTER`, or `VIEWER`
- `expires_at` — nullable expiration for link shares
- `workflow_participant_id` — when set, marks this as a **workflow share** (hidden from the file manager, accessible only via workflow endpoints)
**`file_share_accesses`**
- One record per access event on a share link
- Tracks: user, share link, access type (`VIEW` or `DOWNLOAD`), timestamp
**`storage_cleanup_entries`**
- Queue of storage keys to be deleted asynchronously
- Used when a file is deleted but the physical storage object cleanup is deferred
### Access Roles
| Role | Can Read | Can Write |
|------|----------|-----------|
| `EDITOR` | ✅ | ✅ |
| `COMMENTER` | ✅ | ❌ |
| `VIEWER` | ✅ | ❌ |
Default role when none is specified: `EDITOR`.
Owners always have full access regardless of role.
#### Role Semantics: COMMENTER vs VIEWER
In the file storage layer, `COMMENTER` and `VIEWER` are equivalent — both grant read-only access and neither can replace file content. The distinction is meaningful in the **signing workflow** context:
| Context | COMMENTER | VIEWER |
|---------|-----------|--------|
| File storage | Read only (same as VIEWER) | Read only |
| Signing workflow | Can submit a signing action | Read only |
`WorkflowParticipant.canEdit()` returns `true` for `COMMENTER` (and `EDITOR`) roles, which the signing workflow uses to determine if a participant can still submit a signature. Once a participant has signed or declined, their effective role is automatically downgraded to `VIEWER` regardless of their configured role.
The rationale: "annotating" a document (submitting a signature) is not the same as "replacing" it. COMMENTER grants annotation rights without file-replacement rights.
### Backend Architecture
#### Service Layer
**FileStorageService** (`1137 lines`)
- Core file management service
- Upload, update, download, and delete operations
- User share management (share, revoke, leave)
- Link share management (create, revoke, access)
- Access recording and listing
- Storage quota enforcement
- Configuration feature gate checks
**StorageCleanupService**
- Scheduled daily: deletes orphaned storage keys from `storage_cleanup_entries`
- Scheduled daily: purges expired share links from `file_shares`
- Processes cleanup in batches of 50 entries
#### Storage Providers
**LocalStorageProvider**
- Files stored on the filesystem under `storage.local.basePath` (default: `./storage`)
- Storage key is a path relative to the base directory
**DatabaseStorageProvider**
- Files stored as BLOBs in `stored_file_blobs` table
- No filesystem dependency
Provider is selected at startup via `storage.provider: local | database`.
#### Controller Layer
**FileStorageController** (`/api/v1/storage`)
- All endpoints require authentication
- File CRUD and sharing operations
### Data Flow
```
User uploads file → StorageProvider stores bytes → StoredFile record created
Owner shares file → FileShare record created (user or link)
Recipient accesses file → Access recorded → File bytes streamed
```
## File Operations
### Upload File
```bash
POST /api/v1/storage/files
Content-Type: multipart/form-data
file: document.pdf # Required — main file
historyBundle: history.json # Optional — version history
auditLog: audit.json # Optional — audit trail
```
**Response:**
```json
{
"id": 42,
"fileName": "document.pdf",
"contentType": "application/pdf",
"sizeBytes": 102400,
"owner": "alice",
"ownedByCurrentUser": true,
"accessRole": "editor",
"createdAt": "2025-01-01T12:00:00",
"updatedAt": "2025-01-01T12:00:00",
"sharedWithUsers": [],
"sharedUsers": [],
"shareLinks": []
}
```
### Update File
Replaces the file content. Only the owner can update.
```bash
PUT /api/v1/storage/files/{fileId}
Content-Type: multipart/form-data
file: document_v2.pdf
historyBundle: history.json # Optional
auditLog: audit.json # Optional
```
### List Files
Returns all files owned by or shared with the current user. Workflow-shared files (signing participants) are excluded — those are accessible via signing endpoints only.
```bash
GET /api/v1/storage/files
```
Response is sorted by `createdAt` descending.
### Download File
```bash
GET /api/v1/storage/files/{fileId}/download?inline=false
```
- `inline=false` (default) — `Content-Disposition: attachment`
- `inline=true``Content-Disposition: inline` (for browser preview)
### Delete File
Only the owner can delete. All associated share links and their access records are deleted first, then the database record, then the physical storage object.
```bash
DELETE /api/v1/storage/files/{fileId}
```
## Sharing Operations
### Share with User
```bash
POST /api/v1/storage/files/{fileId}/shares/users
Content-Type: application/json
{
"username": "bob", # Username or email address
"accessRole": "editor" # "editor", "commenter", or "viewer" (default: "editor")
}
```
**Behaviour:**
- If the target user exists: creates/updates a `FileShare` with `sharedWithUser` set
- If `username` is an email address and the user doesn't exist: creates a share link and sends a notification email (requires `sharing.emailEnabled` and `sharing.linkEnabled`)
- If the target user is the owner: returns 400
- If sharing is disabled: returns 403
### Revoke User Share
Only the owner can revoke.
```bash
DELETE /api/v1/storage/files/{fileId}/shares/users/{username}
```
### Leave Shared File
The recipient removes themselves from a shared file.
```bash
DELETE /api/v1/storage/files/{fileId}/shares/self
```
### Create Share Link
Creates a token-based link for anonymous/authenticated access. Requires `sharing.linkEnabled` and `system.frontendUrl` to be configured.
```bash
POST /api/v1/storage/files/{fileId}/shares/links
Content-Type: application/json
{
"accessRole": "viewer" # Optional (default: "editor")
}
```
**Response:**
```json
{
"token": "550e8400-e29b-41d4-a716-446655440000",
"accessRole": "viewer",
"createdAt": "2025-01-01T12:00:00",
"expiresAt": "2025-01-04T12:00:00"
}
```
Expiration is set to `now + sharing.linkExpirationDays` (default: 3 days).
### Revoke Share Link
```bash
DELETE /api/v1/storage/files/{fileId}/shares/links/{token}
```
Also deletes all access records for that token.
## Share Link Access
### Download via Share Link
Authentication is required (even for share links). Anonymous access is not permitted.
```bash
GET /api/v1/storage/share-links/{token}?inline=false
```
- Returns 401 if unauthenticated
- Returns 403 if authenticated but link doesn't permit access
- Returns 410 if the link has expired
- Records a `FileShareAccess` entry on success
> **Token-as-credential semantics:** Any authenticated user who holds the token can access the file — the token is the credential. If you need per-user access control (only a specific person can open it), use "Share with User" instead. Share links are appropriate for broader distribution where possession of the token implies authorization.
### Get Share Link Metadata
```bash
GET /api/v1/storage/share-links/{token}/metadata
```
Returns file name, owner, access role, creation/expiry timestamps, and whether the current user owns the file.
### List Accessed Share Links
Returns the most recent access for each non-expired share link the current user has accessed.
```bash
GET /api/v1/storage/share-links/accessed
```
### List Accesses for a Link (Owner Only)
```bash
GET /api/v1/storage/files/{fileId}/shares/links/{token}/accesses
```
Returns per-user access history (username, VIEW/DOWNLOAD, timestamp), sorted descending by time.
## Workflow Share Integration
Signing workflow participants access documents via their own `WorkflowParticipant.shareToken`. No `FileShare` record is created for participants; access control is self-contained in the `WorkflowParticipant` entity.
The `FileShare.workflow_participant_id` column and the `FileShare.isWorkflowShare()` method are **deprecated**. Legacy data (sessions created before this change) may still have `FileShare` records with `workflow_participant_id` set, which continue to work via the existing token lookup path in `UnifiedAccessControlService`. No new records are created.
`GET /api/v1/storage/files` returns all files owned by or shared with the current user (via `FileShare`). Signing-session PDFs use the `file_purpose` field (`SIGNING_ORIGINAL`, `SIGNING_SIGNED`, etc.) to distinguish them from generic files. The file manager UI can filter on this field if needed.
## API Reference
| Method | Endpoint | Description | Auth |
|--------|----------|-------------|------|
| POST | `/api/v1/storage/files` | Upload file | Required |
| PUT | `/api/v1/storage/files/{id}` | Update file | Required (owner) |
| GET | `/api/v1/storage/files` | List accessible files | Required |
| GET | `/api/v1/storage/files/{id}` | Get file metadata | Required |
| GET | `/api/v1/storage/files/{id}/download` | Download file | Required |
| DELETE | `/api/v1/storage/files/{id}` | Delete file | Required (owner) |
| POST | `/api/v1/storage/files/{id}/shares/users` | Share with user | Required (owner) |
| DELETE | `/api/v1/storage/files/{id}/shares/users/{username}` | Revoke user share | Required (owner) |
| DELETE | `/api/v1/storage/files/{id}/shares/self` | Leave shared file | Required |
| POST | `/api/v1/storage/files/{id}/shares/links` | Create share link | Required (owner) |
| DELETE | `/api/v1/storage/files/{id}/shares/links/{token}` | Revoke share link | Required (owner) |
| GET | `/api/v1/storage/share-links/{token}` | Download via share link | Required |
| GET | `/api/v1/storage/share-links/{token}/metadata` | Get share link metadata | Required |
| GET | `/api/v1/storage/share-links/accessed` | List accessed share links | Required |
| GET | `/api/v1/storage/files/{id}/shares/links/{token}/accesses` | List share accesses | Required (owner) |
## Configuration
All storage settings live under the `storage:` key in `settings.yml`:
```yaml
storage:
enabled: true # Requires security.enableLogin = true
provider: local # 'local' or 'database'
local:
basePath: './storage' # Filesystem base directory (local provider only)
quotas:
maxStorageMbPerUser: -1 # Per-user storage cap in MB; -1 = unlimited
maxStorageMbTotal: -1 # Total storage cap in MB; -1 = unlimited
maxFileMb: -1 # Max size per upload (main + history + audit) in MB; -1 = unlimited
sharing:
enabled: false # Master switch for all sharing (opt-in)
linkEnabled: false # Enable token-based share links (requires system.frontendUrl)
emailEnabled: false # Enable email notifications (requires mail.enabled)
linkExpirationDays: 3 # Days until share links expire
```
**Prerequisites:**
- `storage.enabled` requires `security.enableLogin = true`
- `sharing.linkEnabled` requires `system.frontendUrl` to be set (used to build share link URLs)
- `sharing.emailEnabled` requires `mail.enabled = true`
## Security Considerations
### Access Control
- All endpoints require authentication — there is no anonymous access
- Owner-only operations enforced in service layer (not just controller)
- `requireReadAccess` / `requireEditorAccess` checked on every download
### Share Link Security
- Tokens are UUIDs (random, not guessable)
- Expiration enforced on every access
- Expired links return HTTP 410 Gone
- Revoked links delete all access records
### Quota Enforcement
- Checked before storing (not after)
- Accounts for existing file size when replacing (only the delta counts)
- Covers main file + history bundle + audit log in a single check
## Automatic Cleanup
`StorageCleanupService` runs two scheduled jobs daily:
1. **Orphaned storage cleanup** — processes up to 50 `StorageCleanupEntry` records, deletes the physical storage object, then removes the entry. Failed attempts increment `attemptCount` for retry.
2. **Expired share link cleanup** — deletes all `FileShare` records where `expiresAt` is in the past and `shareToken` is set.
## Troubleshooting
**"Storage is disabled":**
- Check `storage.enabled: true` in settings
- Verify `security.enableLogin: true`
**"Share links are disabled":**
- Check `sharing.linkEnabled: true`
- Verify `system.frontendUrl` is set and non-empty
**"Email sharing is disabled":**
- Check `sharing.emailEnabled: true`
- Verify `mail.enabled: true` and mail configuration
**Signing-session PDF appearing in the general file list:**
- This is expected — signing PDFs are accessible to owners and shared users
- Filter by `file_purpose` (`SIGNING_ORIGINAL`, `SIGNING_SIGNED`) in the UI to distinguish them
**Share link returns 410:**
- Link has expired — check `expires_at` in `file_shares` table
- Owner must create a new link
### Debug Queries
```sql
-- List files and their share counts
SELECT sf.stored_file_id, sf.original_filename, u.username as owner,
COUNT(DISTINCT fs.file_share_id) FILTER (WHERE fs.shared_with_user_id IS NOT NULL) as user_shares,
COUNT(DISTINCT fs.file_share_id) FILTER (WHERE fs.share_token IS NOT NULL) as link_shares
FROM stored_files sf
LEFT JOIN users u ON sf.owner_id = u.user_id
LEFT JOIN file_shares fs ON fs.stored_file_id = sf.stored_file_id
GROUP BY sf.stored_file_id, u.username;
-- Check share link expiration
SELECT share_token, access_role, created_at, expires_at,
expires_at < NOW() as is_expired
FROM file_shares
WHERE share_token IS NOT NULL;
-- Check access history for a share link
SELECT u.username, fsa.access_type, fsa.accessed_at
FROM file_share_accesses fsa
JOIN file_shares fs ON fsa.file_share_id = fs.file_share_id
JOIN users u ON fsa.user_id = u.user_id
WHERE fs.share_token = '{token}'
ORDER BY fsa.accessed_at DESC;
-- Pending cleanup entries
SELECT storage_key, attempt_count, updated_at
FROM storage_cleanup_entries
ORDER BY updated_at ASC;
```
## Summary
The File Sharing feature provides:
- ✅ Server-side file storage with pluggable backend (local/database)
- ✅ History bundle and audit log attachments per file
- ✅ Direct user-to-user sharing with EDITOR/COMMENTER/VIEWER roles
- ✅ Token-based share links with expiration
- ✅ Optional email notifications for shares
- ✅ Per-access audit trail for share links
- ✅ Storage quotas (per-user, total, per-file)
- ✅ Automatic cleanup of expired links and orphaned storage
- ✅ Workflow integration (signing-session PDFs stored via same infrastructure; participant access via `WorkflowParticipant.shareToken`)
+691
View File
@@ -0,0 +1,691 @@
# Shared Signing Feature - Architecture & Workflow
## Overview
The Shared Signing feature enables collaborative document signing workflows where a document owner can request signatures from multiple participants. Each participant receives a secure token to access the document, submit their digital signature (with optional wet signature overlay), and track the signing progress.
**Key Capabilities:**
- Multi-participant signing sessions
- Digital certificate signatures (P12/PKCS12, JKS, SERVER, USER_CERT, PEM/UPLOAD)
- Visual wet signature overlays (drawn, typed, or uploaded) — multiple per participant
- Token-based participant access (no authentication required for participants)
- Authenticated participant access for registered users via sign-requests API
- Progress tracking for session owners
- Optional signature summary page appended to finalized PDF
- Automatic role downgrade after signing (security)
- GDPR-compliant wet signature metadata cleanup
## Architecture
### Database Schema
#### Core Tables
**`workflow_sessions`**
- Tracks signing sessions created by document owners
- Links to original and processed (signed) PDF files
- Stores session metadata (message, due date, status)
**`workflow_participants`**
- One record per participant per session
- Tracks participant status: PENDING → VIEWED → SIGNED/DECLINED
- `NOTIFIED` status is reserved for a future email notification feature; no current code path sets it
- Stores participant-specific metadata (certificates, wet signatures) as JSONB
- Each participant holds their own `shareToken` (UUID) for token-based access — no separate `FileShare` record is created
- `accessRole` controls what actions the participant can perform. `COMMENTER` (and `EDITOR`) allow submitting a signature; `VIEWER` does not. After signing/declining, effective role is automatically downgraded to `VIEWER`
**`user_server_certificates`**
- Stores auto-generated certificates per user
- Enables "Use My Personal Certificate" option
#### Extended Tables
**`stored_files`**
- Added `workflow_session_id` to link files to signing sessions
- Added `file_purpose` enum (SIGNING_ORIGINAL, SIGNING_SIGNED, etc.)
**`file_shares`**
- Regular file shares are created when the session owner shares the document with other users via the file manager
- The `workflow_participant_id` column is deprecated; participant access is self-contained in `WorkflowParticipant.shareToken`
### Backend Architecture
#### Service Layer
**WorkflowSessionService** (`816 lines`)
- Core workflow management service
- Creates sessions with participants
- Handles participant status updates
- Stores signature metadata (certificates and wet signatures)
- Finalizes sessions by coordinating signing process
Key responsibilities:
- Session lifecycle management (create, list, get details, delete)
- Participant management (add, remove, notify)
- Certificate submission storage
- Wet signature metadata storage
- Session finalization orchestration
**UnifiedAccessControlService**
- Validates participant tokens
- Checks session status and expiration
- Maps participant status to effective access role
- Automatic role downgrade after signing: SIGNED/DECLINED → VIEWER role
**UserServerCertificateService**
- Auto-generates personal certificates for users
- Manages certificate storage and retrieval
- Enables "Use My Personal Certificate" signing option
#### Controller Layer
**SigningSessionController** (Owner-facing + Authenticated participant endpoints)
- `POST /api/v1/security/cert-sign/sessions` - Create signing session
- `GET /api/v1/security/cert-sign/sessions` - List user's sessions
- `GET /api/v1/security/cert-sign/sessions/{id}` - Get session details
- `GET /api/v1/security/cert-sign/sessions/{id}/pdf` - Download original PDF
- `POST /api/v1/security/cert-sign/sessions/{id}/finalize` - Finalize and apply signatures
- `GET /api/v1/security/cert-sign/sessions/{id}/signed-pdf` - Download signed PDF
- `DELETE /api/v1/security/cert-sign/sessions/{id}` - Delete session
- `POST /api/v1/security/cert-sign/sessions/{id}/participants` - Add participants
- `DELETE /api/v1/security/cert-sign/sessions/{id}/participants/{participantId}` - Remove participant
- `GET /api/v1/security/cert-sign/sign-requests` - List sign requests for authenticated user
- `GET /api/v1/security/cert-sign/sign-requests/{id}` - Get sign request details
- `GET /api/v1/security/cert-sign/sign-requests/{id}/document` - Download document for signing
- `POST /api/v1/security/cert-sign/sign-requests/{id}/sign` - Sign document (authenticated)
- `POST /api/v1/security/cert-sign/sign-requests/{id}/decline` - Decline sign request (authenticated)
**WorkflowParticipantController** (Participant-facing, token-based)
- `GET /api/v1/workflow/participant/session?token={token}` - View session details
- `GET /api/v1/workflow/participant/details?token={token}` - Get participant details
- `GET /api/v1/workflow/participant/document?token={token}` - Download PDF
- `POST /api/v1/workflow/participant/submit-signature` - Submit signature
- `POST /api/v1/workflow/participant/decline?token={token}` - Decline to sign
#### Data Flow
```
Owner creates session → Participants receive tokens →
Participants access via token (or authenticated) → Participants submit signatures →
Owner finalizes → System applies signatures → [Optional: append summary page] → Signed PDF generated
```
### Frontend Architecture
#### Quick Access Integration
**SignPopout Component**
- Displays in Quick Access Bar (top navigation)
- Shows active and completed signing sessions
- Auto-refreshes every 15 seconds to show signature progress
- Badge indicator shows count of pending sessions
**ActiveSessionsPanel**
- Lists sessions where user is owner or participant
- Shows signature progress: "X/Y signatures" (e.g., "2/5 signatures")
- Color-coded badges:
- Blue: No signatures yet (0/X)
- Yellow: Partial signatures (X/Y)
- Green: Ready to finalize (X/X)
**CompletedSessionsPanel**
- Lists finalized sessions and declined sign requests
- Allows viewing/downloading signed PDFs
#### Workbench Views
**SignRequestWorkbenchView**
- Full-screen view for participants to sign documents
- Integrated PDF viewer with annotation support
- Certificate selection (Personal/Organization/Custom P12)
- Wet signature input (draw, type, or upload)
- Signature placement on PDF pages
**SessionDetailWorkbenchView**
- Owner's view of session details
- Participant list with status indicators
- Ability to add/remove participants
- Finalize button when all signatures collected
- Download original/signed PDF
#### State Management
**FileContext Integration**
- Signing sessions operate within FileContext workflow
- PDFs loaded once, persist across tool switches
- Memory management for large files (up to 100GB+)
**ToolWorkflowContext**
- Registers custom workbench views
- Manages navigation between viewer and signing tools
- Preserves file state during signing operations
#### Services & Hooks
**workflowService.ts**
- API client for all signing endpoints
- Handles session creation, listing, and management
- Participant operations (submit, decline)
**useWorkflowSession.ts**
- React hook for owner session management
- State management for session list and details
**useParticipantSession.ts**
- React hook for participant signing workflow
- Manages signature submission state
## Signing Workflow Process
### 1. Session Creation (Owner)
```
Owner → Uploads PDF → Selects participants → Creates session
System creates:
- WorkflowSession record
- WorkflowParticipant records (one per participant, each with a unique shareToken)
Participants receive token (via email or share link)
```
**API Call:**
```bash
POST /api/v1/security/cert-sign/sessions
Content-Type: multipart/form-data
file: document.pdf
workflowType: SIGNING
documentName: "contract.pdf" # Optional display name
participantUserIds: [1, 2, 3] # Registered user IDs
participantEmails: ["a@b.com"] # External/unregistered users
participants: [...] # Detailed participant configs (optional)
message: "Please sign this contract"
dueDate: "2025-12-31"
ownerEmail: "owner@example.com" # Optional, for notifications
workflowMetadata: '{"showSignature": false, "showLogo": false, "includeSummaryPage": true}'
```
**Session-level `workflowMetadata` fields:**
| Field | Type | Description |
|-------|------|-------------|
| `showSignature` | boolean | Show visible digital signature block on PDF |
| `pageNumber` | integer | Page to place digital signature on |
| `showLogo` | boolean | Show logo in digital signature block |
| `includeSummaryPage` | boolean | Append a signature summary page before digital signing |
**Response:**
```json
{
"sessionId": "uuid",
"documentName": "contract.pdf",
"participants": [
{
"userId": 1,
"email": "user1@example.com",
"shareToken": "token1",
"status": "PENDING"
}
],
"participantCount": 3,
"signedCount": 0
}
```
### 2. Participant Access
```
Participant → Clicks token link → Views session details
Status changes: PENDING/NOTIFIED → VIEWED
Participant downloads PDF to review
```
**Access URL (unauthenticated):**
```
https://app.example.com/sign?token={participant_token}
```
**Authenticated participants** can also use:
```
GET /api/v1/security/cert-sign/sign-requests
GET /api/v1/security/cert-sign/sign-requests/{sessionId}
GET /api/v1/security/cert-sign/sign-requests/{sessionId}/document
```
**Automatic Status Update:**
- First access: PENDING/NOTIFIED → VIEWED
- Downloads tracked but don't change status
### 3. Signature Submission
```
Participant → Selects certificate type → Uploads certificate (if needed)
→ Draws/uploads wet signatures (optional, multiple supported)
→ Submits signature
System stores:
- Certificate data (P12/JKS keystore as base64)
- Certificate password
- Wet signatures metadata (JSON array: base64 image + coordinates per signature)
Status changes: VIEWED → SIGNED
Access role: EDITOR → VIEWER (automatic downgrade)
```
**API Call (token-based, unauthenticated):**
```bash
POST /api/v1/workflow/participant/submit-signature
Content-Type: multipart/form-data
participantToken: {token}
certType: P12 | JKS | SERVER | USER_CERT
p12File: certificate.p12 (if certType=P12)
jksFile: keystore.jks (if certType=JKS)
password: cert_password
showSignature: false
pageNumber: 1
location: "New York"
reason: "I approve this contract"
showLogo: false
wetSignaturesData: '[{"page":0,"x":100,"y":200,"width":150,"height":50,"type":"IMAGE","data":"base64..."}]'
```
**API Call (authenticated users):**
```bash
POST /api/v1/security/cert-sign/sign-requests/{sessionId}/sign
Content-Type: multipart/form-data
certType: SERVER | USER_CERT | UPLOAD | PEM | PKCS12 | PFX | JKS
p12File: certificate.p12 (if applicable)
password: cert_password
reason: "I approve this contract"
location: "New York"
wetSignaturesData: '[...]'
```
**Metadata Storage (JSONB):**
```json
{
"certificateSubmission": {
"certType": "P12",
"password": "cert_password",
"p12Keystore": "base64_encoded_keystore",
"showSignature": false,
"pageNumber": 1,
"location": "New York",
"reason": "I approve this contract",
"showLogo": false
},
"wetSignatures": [
{
"type": "IMAGE",
"data": "base64_image",
"page": 0,
"x": 100,
"y": 200,
"width": 150,
"height": 50
}
]
}
```
Note: Multiple wet signatures are supported per participant (array).
### 4. Progress Tracking (Owner)
```
Owner → Views session list → Sees "2/5 signatures"
→ Clicks session → Views participant status
Participant list shows:
- user1@example.com: SIGNED ✓
- user2@example.com: SIGNED ✓
- user3@example.com: VIEWED (pending)
- user4@example.com: PENDING
- user5@example.com: DECLINED ✗
Auto-refresh every 15 seconds
```
**Badge Colors:**
- 🔵 Blue: 0/5 signatures (awaiting)
- 🟡 Yellow: 2/5 signatures (partial)
- 🟢 Green: 5/5 signatures (ready to finalize)
### 5. Session Finalization
```
Owner → Clicks "Finalize" → System processes signatures
Processing steps:
1. Apply wet signatures to PDF (visual overlays)
1.5. Append signature summary page (if includeSummaryPage=true)
2. Apply digital certificates in participant order
- Visual signature block suppressed when summary page is enabled
3. Store signed PDF
4. Clear wet signature metadata (GDPR compliance)
Owner downloads signed PDF
```
**Finalization Process:**
1. **Apply Wet Signatures First**
```java
for (WetSignature sig : wetSignatures) {
PDPage page = document.getPage(sig.getPage());
byte[] imageBytes = Base64.decode(sig.getData());
// Convert Y from top-left (UI) to bottom-left (PDF) coordinate system
float pdfY = page.getMediaBox().getHeight() - sig.getY() - sig.getHeight();
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "signature");
contentStream.drawImage(image, sig.getX(), pdfY, sig.getWidth(), sig.getHeight());
}
```
2. **Append Summary Page (optional, before digital signing)**
If `includeSummaryPage=true`, a new A4 page is appended showing:
- Stirling logo and "Signature Summary" title
- Document name and session owner
- Finalization timestamp
- Per-participant: name, email, status, signed timestamp, reason, location, certificate type
- Supports overflow to additional pages
This step occurs **before** digital certificate signing so signatures are not invalidated.
When a summary page is added, the visual digital signature block (`showSignature`) is suppressed — wet signatures (hand-drawn overlays) are unaffected.
3. **Apply Digital Certificates (in participant order)**
```java
for (Participant p : participants) {
if (p.status == SIGNED) {
KeyStore keystore = buildKeystore(p.certificate);
// Reason: participant override > owner default > "Document Signing"
// Location: participant-provided only (no default)
CertSignController.sign(pdfBytes, keystore, password, settings);
}
}
```
4. **Store and Cleanup**
```java
StoredFile signedFile = storeFile(signedPdfBytes, SIGNING_SIGNED);
session.setProcessedFile(signedFile);
session.setFinalized(true);
// GDPR: Clear sensitive metadata after finalization
for (Participant p : participants) {
p.metadata.remove("wetSignatures"); // Clears wet signature image data
p.metadata.remove("certificateSubmission"); // Clears keystore bytes + password
}
```
**API Call:**
```bash
POST /api/v1/security/cert-sign/sessions/{sessionId}/finalize
Authorization: Bearer {owner_token}
```
**Response:** Binary PDF file with Content-Disposition header
## Key Technical Features
### 1. Double JSON Encoding Fix (Recent)
**Problem:** JSONB columns were storing JSON strings instead of JSON objects, requiring double-parsing.
**Solution:** Created `JsonMapConverter` JPA AttributeConverter:
```java
@Convert(converter = JsonMapConverter.class)
@Column(name = "participant_metadata", columnDefinition = "jsonb")
private Map<String, Object> participantMetadata;
```
**Benefits:**
- Single parse on read
- Proper JSON storage in PostgreSQL
- Type-safe Map access
- Backward compatible with legacy data
### 2. Signature Progress Display (Recent)
**Implementation:**
- `WorkflowSessionResponse` includes `participantCount` and `signedCount`
- `WorkflowMapper` calculates counts when converting to DTO
- Frontend displays "X/Y signatures" in session list
- Auto-refresh every 15 seconds keeps counts updated
### 3. Token-Based Security
**No Authentication Required for Participants:**
- Participants access via secure token (UUID)
- Token linked to specific participant and session
- Automatic expiration support
- One-time signing (cannot sign twice)
**Authenticated Participant Access:**
- Registered users can also access sign requests via `/api/v1/security/cert-sign/sign-requests`
- Standard Spring Security authentication required
- Supports additional cert types: UPLOAD, PEM, PKCS12, PFX
**Automatic Role Downgrade:**
- After signing: EDITOR → VIEWER
- After declining: EDITOR → VIEWER
- Prevents modification after action taken
### 4. Storage Integration
**Unified with File Sharing:**
- All PDFs stored via `StorageProvider` (Database or Local)
- Respects storage quotas
- Supports files up to 100GB+ (with Local storage)
- Consistent with existing file sharing infrastructure
### 5. Certificate Types
**P12/PKCS12/PFX:** User uploads PKCS#12 file + password
**JKS:** User uploads Java KeyStore + password
**PEM/UPLOAD:** User uploads PEM certificate + private key
**SERVER:** Uses organization's server certificate (no upload needed)
**USER_CERT:** Uses user's auto-generated personal certificate (one-click)
Note: UPLOAD, PEM, PKCS12, PFX are available on the authenticated (`sign-requests`) path. The token-based path uses P12, JKS, SERVER, USER_CERT.
## Frontend Components Overview
### Owner Workflow Components
1. **CreateSessionPanel** - Form to create new signing session
2. **ActiveSessionsPanel** - List of pending sessions with progress
3. **SessionDetailWorkbenchView** - Full session management interface
4. **CompletedSessionsPanel** - History of finalized sessions
### Participant Workflow Components
1. **SignRequestWorkbenchView** - Main signing interface
2. **SignatureSettingsInput** - Certificate selection and configuration
3. **WetSignatureInput** - Draw/type/upload signature overlay
4. **SignatureSettingsDisplay** - Preview of signature settings
### Shared Components
1. **UserSelector** - Multi-select user picker for participants
2. **LocalEmbedPDFWithAnnotations** - PDF viewer with signature placement
## Configuration
### Backend Configuration
**application.properties:**
```properties
# Database (H2 or PostgreSQL)
spring.jpa.hibernate.ddl-auto=update
# Security
DOCKER_ENABLE_SECURITY=true
# Storage Provider (DATABASE or LOCAL)
storage.provider=LOCAL
storage.maxFileSize=100GB
```
### Frontend Configuration
**Quick Access Bar:**
- Signing popout accessible from top navigation
- Auto-refresh interval: 15 seconds
- Badge shows pending session count
## API Reference Summary
### Owner Endpoints (Authenticated)
| Method | Endpoint | Description |
|--------|----------|-------------|
| POST | `/api/v1/security/cert-sign/sessions` | Create session |
| GET | `/api/v1/security/cert-sign/sessions` | List sessions |
| GET | `/api/v1/security/cert-sign/sessions/{id}` | Get details |
| POST | `/api/v1/security/cert-sign/sessions/{id}/finalize` | Finalize session |
| GET | `/api/v1/security/cert-sign/sessions/{id}/pdf` | Download original |
| GET | `/api/v1/security/cert-sign/sessions/{id}/signed-pdf` | Download signed |
| DELETE | `/api/v1/security/cert-sign/sessions/{id}` | Delete session |
| POST | `/api/v1/security/cert-sign/sessions/{id}/participants` | Add participants |
| DELETE | `/api/v1/security/cert-sign/sessions/{id}/participants/{pid}` | Remove participant |
### Authenticated Participant Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/security/cert-sign/sign-requests` | List sign requests |
| GET | `/api/v1/security/cert-sign/sign-requests/{id}` | Get sign request details |
| GET | `/api/v1/security/cert-sign/sign-requests/{id}/document` | Download document |
| POST | `/api/v1/security/cert-sign/sign-requests/{id}/sign` | Sign document |
| POST | `/api/v1/security/cert-sign/sign-requests/{id}/decline` | Decline signing |
### Token-Based Participant Endpoints (No Auth Required)
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/v1/workflow/participant/session?token={token}` | View session |
| GET | `/api/v1/workflow/participant/details?token={token}` | Get participant details |
| GET | `/api/v1/workflow/participant/document?token={token}` | Download PDF |
| POST | `/api/v1/workflow/participant/submit-signature` | Submit signature |
| POST | `/api/v1/workflow/participant/decline?token={token}` | Decline signing |
## Security Considerations
### Data Protection
- Wet signature image data cleared after finalization (GDPR compliance)
- Certificate submission data (keystore bytes + password) cleared after finalization (GDPR compliance)
- Certificate passwords are not encrypted at rest while stored (TODO: encrypt at rest)
- Token expiration support
### Access Control
- Owner authentication required for session management
- Participant access via secure UUID tokens (no auth) or standard auth (sign-requests)
- Automatic role downgrade prevents re-signing
- Session status checks prevent unauthorized actions
### Audit Trail
- All participant actions tracked
- FileShare access logged
- Status transitions recorded
- Notification history maintained
## Performance Characteristics
### Scalability
- Supports PDFs up to 100GB+ (with Local storage provider)
- Memory-efficient streaming for large files
- IndexedDB caching on frontend
- Database indexes on session_id, share_token, workflow_session_id
### Response Times
- Session creation: ~500ms (10MB file)
- Session listing: ~100ms
- Token validation: ~50ms
- Finalization: ~2s per MB of PDF (varies by certificate operations)
## Future Enhancements
### Planned Features
- Email notifications for participants
- Reminder system for pending signatures
- Bulk signing operations
- Template-based signing workflows
- Signature validation/verification UI
- Certificate password encryption at rest
- Certificate keystore cleanup after finalization (GDPR)
- Webhook support for external integrations
- Analytics dashboard for signing metrics
### Additional Workflow Types
- **REVIEW** - Document review with comments
- **APPROVAL** - Multi-level approval chains
- **COLLABORATION** - Real-time collaborative editing
## Troubleshooting
### Common Issues
**"Token invalid" error:**
- Check token exists in workflow_participants table
- Verify session is not finalized
- Check expiration date (expires_at)
**Signature not appearing on PDF:**
- Verify certificate type is correct
- Check certificate password
- Review logs for signing errors
- Ensure PDFDocumentFactory is available
**"Awaiting signatures" not updating:**
- Backend should return participantCount and signedCount
- Frontend auto-refresh every 15 seconds
- Check network tab for API errors
**Wet signatures not visible after finalization:**
- Wet signatures are applied first as image overlays (Step 1)
- Check `wetSignaturesData` was sent as valid JSON array
- Verify page index is within document bounds
- Note: wet signatures survive regardless of `includeSummaryPage` setting
### Debug Queries
```sql
-- Check session status
SELECT session_id, status, finalized,
(SELECT COUNT(*) FROM workflow_participants WHERE workflow_session_id = ws.id) as participant_count,
(SELECT COUNT(*) FROM workflow_participants WHERE workflow_session_id = ws.id AND status = 'SIGNED') as signed_count
FROM workflow_sessions ws;
-- Check participant tokens
SELECT email, status, share_token, expires_at
FROM workflow_participants
WHERE workflow_session_id = (SELECT id FROM workflow_sessions WHERE session_id = '{session_id}');
-- Check metadata storage
SELECT email,
participant_metadata->'certificateSubmission'->>'certType' as cert_type,
jsonb_array_length(participant_metadata->'wetSignatures') as wet_sig_count
FROM workflow_participants;
```
## Summary
The Shared Signing feature provides a complete collaborative signing workflow with:
- ✅ Multi-participant support with progress tracking
- ✅ Multiple certificate types (P12/PKCS12/PFX, JKS, PEM, SERVER, USER_CERT)
- ✅ Visual wet signature overlays (multiple per participant)
- ✅ Token-based security for unauthenticated participants
- ✅ Authenticated participant access via sign-requests API
- ✅ Automatic role management
- ✅ Large file support (100GB+)
- ✅ GDPR-compliant wet signature metadata cleanup
- ✅ Real-time progress updates
- ✅ Full frontend integration with Quick Access Bar
- ✅ Optional signature summary page with logo and participant details
The architecture leverages existing file sharing infrastructure while adding workflow-specific features, ensuring consistency and maintainability across the application.
+3 -3
View File
@@ -27,10 +27,10 @@ spotless {
}
}
dependencies {
api 'com.google.guava:guava:33.4.8-jre'
api 'com.google.guava:guava:33.5.0-jre'
api 'org.springframework.boot:spring-boot-starter-webmvc'
api 'org.springframework.boot:spring-boot-starter-aspectj'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
api 'com.fathzer:javaluator:3.0.6'
api 'com.posthog.java:posthog:1.2.0'
api 'org.apache.commons:commons-lang3:3.20.0'
@@ -43,7 +43,7 @@ dependencies {
api 'com.github.junrar:junrar:7.5.8' // RAR archive support for CBR files
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1"
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
@@ -58,6 +58,7 @@ public class ApplicationProperties {
private Legal legal = new Legal();
private Security security = new Security();
private System system = new System();
private Storage storage = new Storage();
private Ui ui = new Ui();
private Endpoints endpoints = new Endpoints();
private Metrics metrics = new Metrics();
@@ -634,6 +635,41 @@ public class ApplicationProperties {
}
}
@Data
public static class Storage {
private boolean enabled = false;
private String provider = "local";
private Local local = new Local();
private Quotas quotas = new Quotas();
private Sharing sharing = new Sharing();
private Signing signing = new Signing();
@Data
public static class Local {
private String basePath = InstallationPathConfig.getPath() + "storage";
}
@Data
public static class Sharing {
private boolean enabled = false;
private boolean linkEnabled = false;
private boolean emailEnabled = false;
private int linkExpirationDays = 3;
}
@Data
public static class Quotas {
private long maxStorageMbPerUser = -1;
private long maxStorageMbTotal = -1;
private long maxFileMb = -1;
}
@Data
public static class Signing {
private boolean enabled = false;
}
}
@Data
public static class DatabaseBackup {
private String cron = "0 0 0 * * ?"; // daily at midnight
@@ -0,0 +1,16 @@
package stirling.software.common.model.api.security;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserSummaryDTO {
private Long userId;
private String username;
private String displayName;
private String teamName;
private boolean enabled;
}
@@ -0,0 +1,37 @@
package stirling.software.common.service;
import java.security.KeyStore;
/**
* Abstraction for PDF digital signature operations. Defined in common so that proprietary services
* can use it without creating a circular dependency on core.
*/
public interface PdfSigningService {
/**
* Signs a PDF document using the provided KeyStore.
*
* @param pdfBytes raw PDF bytes to sign
* @param keystore the KeyStore containing the signing key and certificate chain
* @param password keystore password
* @param showSignature whether to render a visible signature block
* @param pageNumber 0-indexed page on which to render the visible signature (may be null)
* @param name signer name embedded in the signature
* @param location location string embedded in the signature
* @param reason reason string embedded in the signature
* @param showLogo whether to include the Stirling-PDF logo in the visible signature
* @return signed PDF bytes
* @throws Exception on any signing failure
*/
byte[] signWithKeystore(
byte[] pdfBytes,
KeyStore keystore,
char[] password,
boolean showSignature,
Integer pageNumber,
String name,
String location,
String reason,
boolean showLogo)
throws Exception;
}
@@ -5,6 +5,8 @@ public interface UserServiceInterface {
String getCurrentUsername();
String getCurrentUserApiKey();
long getTotalUsersCount();
boolean isCurrentUserAdmin();
@@ -112,8 +112,6 @@ public class FileMonitor {
All files observed changes in the last iteration will be considered as staging files.
If those files are not modified in current iteration, they will be considered as ready for processing.
*/
stagingFiles = new HashSet<>(newlyDiscoveredFiles);
readyForProcessingFiles.clear();
if (path2KeyMapping.isEmpty()) {
log.warn("Not monitoring any directories; attempting to re-register root paths.");
@@ -129,8 +127,19 @@ public class FileMonitor {
}
}
WatchKey key;
while ((key = watchService.poll()) != null) {
// Skip expensive collection work when there is nothing to track
WatchKey firstKey = watchService.poll();
if (firstKey == null
&& newlyDiscoveredFiles.isEmpty()
&& readyForProcessingFiles.isEmpty()) {
return;
}
stagingFiles = new HashSet<>(newlyDiscoveredFiles);
readyForProcessingFiles.clear();
WatchKey key = firstKey;
while (key != null) {
final Path watchingDir = (Path) key.watchable();
key.pollEvents()
.forEach(
@@ -167,6 +176,7 @@ public class FileMonitor {
if (!isKeyValid) { // key is invalid when the directory itself is no longer exists
path2KeyMapping.remove((Path) key.watchable());
}
key = watchService.poll();
}
readyForProcessingFiles.addAll(stagingFiles);
}
@@ -180,7 +180,9 @@ public class RequestUriUtils {
|| trimmedUri.startsWith("/readiness")
|| trimmedUri.startsWith(
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|| trimmedUri.startsWith("/v1/api-docs");
|| trimmedUri.startsWith("/v1/api-docs")
// Workflow participant endpoints — access controlled by share tokens, not login
|| trimmedUri.startsWith("/api/v1/workflow/participant/");
}
private static String stripContextPath(String contextPath, String requestURI) {
+2 -2
View File
@@ -66,7 +66,7 @@ dependencies {
implementation 'commons-io:commons-io:2.21.0'
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
implementation 'io.micrometer:micrometer-core:1.16.2'
implementation 'io.micrometer:micrometer-core'
implementation 'com.google.zxing:core:3.5.4'
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
@@ -82,7 +82,7 @@ dependencies {
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
implementation 'javax.xml.bind:jaxb-api:2.3.1'
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
implementation 'com.sun.xml.bind:jaxb-core:4.0.6'
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
implementation 'org.apache.poi:poi-ooxml:5.5.1'
// https://mvnrepository.com/artifact/technology.tabula/tabula
@@ -73,7 +73,8 @@ public class ExternalAppDepConfig {
tmp.put("tesseract", List.of("tesseract"));
tmp.put("rar", List.of("rar")); // Required for real CBR output
tmp.put(calibrePath, List.of("Calibre"));
tmp.put("ffmpeg", List.of("FFmpeg"));
// ffmpeg disabled due to raised CVEs
// tmp.put("ffmpeg", List.of("FFmpeg"));
tmp.put("magick", List.of("ImageMagick"));
this.commandToGroupMapping = Collections.unmodifiableMap(tmp);
}
@@ -8,10 +8,7 @@ import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.DecimalFormat;
import java.text.DecimalFormatSymbols;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
@@ -23,31 +20,17 @@ import javax.imageio.ImageIO;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.ImageType;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import io.github.pixee.security.Filenames;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.ConvertApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.ProcessExecutor;
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
import stirling.software.common.util.TempDirectory;
import stirling.software.common.util.TempFile;
import stirling.software.common.util.TempFileManager;
import stirling.software.common.util.WebResponseUtils;
@ConvertApi
@RequiredArgsConstructor
@@ -64,6 +47,8 @@ public class ConvertPdfToVideoController {
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
// ffmpeg disabled due to raised CVEs
/*
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
@Operation(
summary = "Convert PDF to Video Slideshow",
@@ -163,6 +148,7 @@ public class ConvertPdfToVideoController {
return WebResponseUtils.bytesToWebResponse(videoBytes, outputName, mediaType);
}
}
*/
private void generateFrames(
Path inputPdf,
@@ -1,19 +1,22 @@
package stirling.software.SPDF.controller.api.misc;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferByte;
import java.awt.image.DataBufferInt;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -21,6 +24,7 @@ import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.multipart.MultipartFile;
import com.google.zxing.*;
import com.google.zxing.common.GlobalHistogramBinarizer;
import com.google.zxing.common.HybridBinarizer;
import io.github.pixee.security.Filenames;
@@ -35,7 +39,6 @@ import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.annotations.api.MiscApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.ApplicationContextProvider;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.TempFile;
@@ -48,61 +51,219 @@ import stirling.software.common.util.WebResponseUtils;
public class AutoSplitPdfController {
private static final Set<String> VALID_QR_CONTENTS =
new HashSet<>(
Set.of(
"https://github.com/Stirling-Tools/Stirling-PDF",
"https://github.com/Frooodle/Stirling-PDF",
"https://stirlingpdf.com"));
Set.of(
"https://github.com/Stirling-Tools/Stirling-PDF",
"https://github.com/Frooodle/Stirling-PDF",
"https://stirlingpdf.com");
private static final int MAX_IMAGES_FOR_DIRECT_EXTRACTION = 3;
// 150 DPI is sufficient for QR code detection — higher wastes memory and CPU
private static final int QR_DETECTION_DPI = 150;
// Max total pixels before we downscale to avoid OOM on getRGB() allocation
private static final long MAX_IMAGE_PIXELS = 100_000_000L; // ~10000x10000
// Number of evenly-spaced pixel samples used for the blank image check
private static final int BLANK_CHECK_SAMPLES = 20;
private static final Map<DecodeHintType, Object> DECODE_HINTS;
static {
DECODE_HINTS = new EnumMap<>(DecodeHintType.class);
DECODE_HINTS.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
DECODE_HINTS.put(DecodeHintType.ALSO_INVERTED, Boolean.TRUE);
DECODE_HINTS.put(DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.QR_CODE));
}
private final CustomPDFDocumentFactory pdfDocumentFactory;
private final TempFileManager tempFileManager;
private final ApplicationProperties applicationProperties;
private static String decodeQRCode(BufferedImage bufferedImage) {
LuminanceSource source;
if (bufferedImage.getRaster().getDataBuffer() instanceof DataBufferByte dataBufferByte) {
byte[] pixels = dataBufferByte.getData();
source =
new PlanarYUVLuminanceSource(
pixels,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
0,
0,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
false);
} else if (bufferedImage.getRaster().getDataBuffer()
instanceof DataBufferInt dataBufferInt) {
int[] pixels = dataBufferInt.getData();
byte[] newPixels = new byte[pixels.length];
for (int i = 0; i < pixels.length; i++) {
newPixels[i] = (byte) (pixels[i] & 0xff);
}
source =
new PlanarYUVLuminanceSource(
newPixels,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
0,
0,
bufferedImage.getWidth(),
bufferedImage.getHeight(),
false);
} else {
throw new IllegalArgumentException(
"BufferedImage must have 8-bit gray scale, 24-bit RGB, 32-bit ARGB (packed"
+ " int), byte gray, or 3-byte/4-byte RGB image data");
/**
* Downscale an image if it exceeds the maximum pixel count. Scales uniformly based on the
* pixel-count ratio so both portrait and landscape images are handled correctly.
*/
private static BufferedImage downscaleIfNeeded(BufferedImage image) {
long totalPixels = (long) image.getWidth() * image.getHeight();
if (totalPixels <= MAX_IMAGE_PIXELS) {
return image;
}
double scale = Math.sqrt((double) MAX_IMAGE_PIXELS / totalPixels);
int newWidth = Math.max(1, (int) (image.getWidth() * scale));
int newHeight = Math.max(1, (int) (image.getHeight() * scale));
log.debug(
"Downscaling image from {}x{} to {}x{} for QR detection",
image.getWidth(),
image.getHeight(),
newWidth,
newHeight);
BufferedImage scaled = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.drawImage(image, 0, 0, newWidth, newHeight, null);
g.dispose();
return scaled;
}
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
/**
* Quick check whether an image appears to be blank (single solid colour). Samples pixels at
* evenly-spaced positions — if all samples match the first pixel the image is almost certainly
* blank (e.g. a masked image that returned solid white).
*/
private static boolean isBlankImage(int[] pixels) {
if (pixels.length == 0) return true;
int first = pixels[0];
int step = Math.max(1, pixels.length / BLANK_CHECK_SAMPLES);
for (int i = step; i < pixels.length; i += step) {
if (pixels[i] != first) {
return false;
}
}
return true;
}
/**
* Try to decode a QR code from pre-extracted RGB pixel data using multiple binarization
* strategies. Returns the decoded text or null.
*
* <p>Strategy 1: HybridBinarizer — good for variable brightness (digital PDFs).
*
* <p>Strategy 2: GlobalHistogramBinarizer — better for scanned/noisy images with uniform
* lighting, and for QR codes with embedded logos that confuse the hybrid approach.
*/
private static String tryDecodeQR(int[] pixels, int width, int height) {
RGBLuminanceSource source = new RGBLuminanceSource(width, height, pixels);
MultiFormatReader reader = new MultiFormatReader();
// Strategy 1: HybridBinarizer — good for variable brightness (digital PDFs)
try {
Result result = new MultiFormatReader().decode(bitmap);
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(source));
Result result = reader.decode(bitmap, DECODE_HINTS);
log.debug("QR detected via HybridBinarizer: '{}'", result.getText());
return result.getText();
} catch (NotFoundException e) {
return null; // there is no QR code in the image
// continue
}
// Strategy 2: GlobalHistogramBinarizer — better for scanned/noisy images
try {
BinaryBitmap bitmap = new BinaryBitmap(new GlobalHistogramBinarizer(source));
Result result = reader.decode(bitmap, DECODE_HINTS);
log.debug("QR detected via GlobalHistogramBinarizer: '{}'", result.getText());
return result.getText();
} catch (NotFoundException e) {
return null;
}
}
/**
* Attempt to decode a QR code from a BufferedImage. Handles downscaling for oversized images
* and skips blank images early.
*/
private static String decodeQRCode(BufferedImage bufferedImage) {
bufferedImage = downscaleIfNeeded(bufferedImage);
int width = bufferedImage.getWidth();
int height = bufferedImage.getHeight();
int[] pixels = new int[width * height];
bufferedImage.getRGB(0, 0, width, height, pixels, 0, width);
// Skip blank images early (e.g. masked images that decode to solid white)
if (isBlankImage(pixels)) {
log.debug("Skipping blank {}x{} image", width, height);
return null;
}
return tryDecodeQR(pixels, width, height);
}
/** Count the number of images embedded in a page's resources. */
private static int countPageImages(PDPage page) {
if (page.getResources() == null || page.getResources().getXObjectNames() == null) {
return 0;
}
int count = 0;
for (COSName name : page.getResources().getXObjectNames()) {
if (page.getResources().isImageXObject(name)) {
count++;
}
}
return count;
}
/**
* Extract images directly from a page's resources and check each for a QR code. Returns the QR
* code text if found, null otherwise.
*/
private static String checkPageImagesDirect(PDPage page) throws IOException {
if (page.getResources() == null || page.getResources().getXObjectNames() == null) {
return null;
}
for (COSName name : page.getResources().getXObjectNames()) {
if (!page.getResources().isImageXObject(name)) {
continue;
}
PDImageXObject imageObject = (PDImageXObject) page.getResources().getXObject(name);
BufferedImage image;
try {
image = imageObject.getImage();
} catch (OutOfMemoryError e) {
log.warn(
"Skipping oversized embedded image '{}' ({}x{}) - out of memory",
name.getName(),
imageObject.getWidth(),
imageObject.getHeight());
continue;
}
String result = decodeQRCode(image);
if (result != null) {
return result;
}
}
return null;
}
/**
* Render the full page to an image and scan it for a QR code. Tries a low DPI first (fast, low
* memory) and only retries at the system's maxDPI if detection fails. The first rendered image
* is released before the retry to allow GC to reclaim it.
*/
private String checkPageByRendering(PDFRenderer pdfRenderer, int pageNum) throws IOException {
log.debug("Rendering page {} at {} DPI for QR detection", pageNum + 1, QR_DETECTION_DPI);
BufferedImage bim =
ExceptionUtils.handleOomRendering(
pageNum + 1,
QR_DETECTION_DPI,
() -> pdfRenderer.renderImageWithDPI(pageNum, QR_DETECTION_DPI));
String result = decodeQRCode(bim);
bim = null; // allow GC before potential high-DPI retry
if (result == null) {
int maxDpi = getSystemMaxDpi();
if (maxDpi > QR_DETECTION_DPI) {
log.debug(
"Retrying page {} at {} DPI (low-DPI detection failed)",
pageNum + 1,
maxDpi);
BufferedImage highRes =
ExceptionUtils.handleOomRendering(
pageNum + 1,
maxDpi,
() -> pdfRenderer.renderImageWithDPI(pageNum, maxDpi));
result = decodeQRCode(highRes);
}
}
return result;
}
private int getSystemMaxDpi() {
if (applicationProperties != null && applicationProperties.getSystem() != null) {
return applicationProperties.getSystem().getMaxDPI();
}
return QR_DETECTION_DPI;
}
@AutoJobPostMapping(value = "/auto-split-pdf", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -111,42 +272,56 @@ public class AutoSplitPdfController {
summary = "Auto split PDF pages into separate documents",
description =
"This endpoint accepts a PDF file, scans each page for a specific QR code, and"
+ " splits the document at the QR code boundaries. The output is a zip file"
+ " containing each separate PDF document. Input:PDF Output:ZIP-PDF"
+ " splits the document at the QR code boundaries. The output is a zip"
+ " file containing each separate PDF document. Input:PDF Output:ZIP-PDF"
+ " Type:SISO")
public ResponseEntity<byte[]> autoSplitPdf(@ModelAttribute AutoSplitPdfRequest request)
throws IOException {
MultipartFile file = request.getFileInput();
boolean duplexMode = Boolean.TRUE.equals(request.getDuplexMode());
log.info(
"Auto-split starting: filename='{}', size={} bytes, duplexMode={}",
file.getOriginalFilename(),
file.getSize(),
duplexMode);
List<PDDocument> splitDocuments = new ArrayList<>();
try (TempFile outputTempFile = new TempFile(tempFileManager, ".zip");
PDDocument document = pdfDocumentFactory.load(file.getInputStream())) {
int totalPages = document.getNumberOfPages();
log.info("PDF loaded, totalPages={}", totalPages);
PDFRenderer pdfRenderer = new PDFRenderer(document);
pdfRenderer.setSubsamplingAllowed(true);
for (int page = 0; page < document.getNumberOfPages(); ++page) {
BufferedImage bim;
for (int page = 0; page < totalPages; ++page) {
PDPage pdPage = document.getPage(page);
int imageCount = countPageImages(pdPage);
// Use global maximum DPI setting, fallback to 300 if not set
int renderDpi = 150; // Default fallback
ApplicationProperties properties =
ApplicationContextProvider.getBean(ApplicationProperties.class);
if (properties != null && properties.getSystem() != null) {
renderDpi = properties.getSystem().getMaxDPI();
String qrResult;
if (imageCount > 0 && imageCount <= MAX_IMAGES_FOR_DIRECT_EXTRACTION) {
// Try extracting images directly from the PDF (faster, avoids rendering)
qrResult = checkPageImagesDirect(pdPage);
if (qrResult == null) {
// Fall back to rendering — the image may use masking/compositing
// that getImage() doesn't resolve, or the QR may be vector-drawn
qrResult = checkPageByRendering(pdfRenderer, page);
}
} else {
// Too many images or no images — render the full page
qrResult = checkPageByRendering(pdfRenderer, page);
}
final int dpi = renderDpi;
final int pageNum = page;
bim =
ExceptionUtils.handleOomRendering(
pageNum + 1,
dpi,
() -> pdfRenderer.renderImageWithDPI(pageNum, dpi));
String result = decodeQRCode(bim);
boolean isValidQrCode = qrResult != null && VALID_QR_CONTENTS.contains(qrResult);
if (isValidQrCode) {
log.info(
"Page {}/{} contains QR divider ('{}')",
page + 1,
totalPages,
qrResult);
}
boolean isValidQrCode = VALID_QR_CONTENTS.contains(result);
log.debug("detected qr code {}, code is vale={}", result, isValidQrCode);
if (isValidQrCode && page != 0) {
splitDocuments.add(new PDDocument());
}
@@ -159,32 +334,25 @@ public class AutoSplitPdfController {
splitDocuments.add(firstDocument);
}
// If duplexMode is true and current page is a divider, then skip next page
if (duplexMode && isValidQrCode) {
page++;
page++; // skip back of divider page
}
}
// Remove split documents that have no pages
splitDocuments.removeIf(pdDocument -> pdDocument.getNumberOfPages() == 0);
log.info("Split complete, {} output documents", splitDocuments.size());
String filename =
GeneralUtils.removeExtension(
Filenames.toSimpleFileName(file.getOriginalFilename()));
try (ZipOutputStream zipOut =
new ZipOutputStream(Files.newOutputStream(outputTempFile.getPath()))) {
// Stream split documents directly into zip — avoids holding all PDFs in memory
try (OutputStream fileOut = Files.newOutputStream(outputTempFile.getPath());
ZipOutputStream zipOut = new ZipOutputStream(fileOut)) {
for (int i = 0; i < splitDocuments.size(); i++) {
String fileName = filename + "_" + (i + 1) + ".pdf";
PDDocument splitDocument = splitDocuments.get(i);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
splitDocument.save(baos);
byte[] pdf = baos.toByteArray();
ZipEntry pdfEntry = new ZipEntry(fileName);
zipOut.putNextEntry(pdfEntry);
zipOut.write(pdf);
zipOut.putNextEntry(new ZipEntry(fileName));
splitDocuments.get(i).save(zipOut);
zipOut.closeEntry();
}
}
@@ -197,7 +365,6 @@ public class AutoSplitPdfController {
log.error("Error in auto split", e);
throw e;
} finally {
// Clean up split documents
for (PDDocument splitDoc : splitDocuments) {
try {
splitDoc.close();
@@ -204,6 +204,27 @@ public class ConfigController {
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
configData.put("enableEmailInvites", smtpEnabled && invitesEnabled);
// Storage settings
boolean storageEnabled = enableLogin && applicationProperties.getStorage().isEnabled();
boolean sharingEnabled =
storageEnabled && applicationProperties.getStorage().getSharing().isEnabled();
boolean frontendUrlConfigured = frontendUrl != null && !frontendUrl.trim().isEmpty();
boolean shareLinksEnabled =
sharingEnabled
&& applicationProperties.getStorage().getSharing().isLinkEnabled()
&& frontendUrlConfigured;
boolean shareEmailEnabled =
sharingEnabled
&& applicationProperties.getStorage().getSharing().isEmailEnabled()
&& applicationProperties.getMail().isEnabled();
boolean groupSigningEnabled =
storageEnabled && applicationProperties.getStorage().getSigning().isEnabled();
configData.put("storageEnabled", storageEnabled);
configData.put("storageSharingEnabled", sharingEnabled);
configData.put("storageShareLinksEnabled", shareLinksEnabled);
configData.put("storageShareEmailEnabled", shareEmailEnabled);
configData.put("storageGroupSigningEnabled", groupSigningEnabled);
// Check if user is admin using UserServiceInterface
boolean isAdmin = false;
if (userService != null) {
@@ -483,9 +483,19 @@ public class StampController {
y = overrideY;
} else {
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, margin);
y = calculatePositionY(pageSize, position, desiredPhysicalHeight, margin);
// drawImage() places the lower-left corner at (x, y); use image-specific Y logic
y = calculateImagePositionY(pageSize, position, desiredPhysicalHeight, margin);
}
float llx = pageSize.getLowerLeftX();
float lly = pageSize.getLowerLeftY();
float urx = pageSize.getUpperRightX();
float ury = pageSize.getUpperRightY();
float xMax = Math.max(llx, urx - desiredPhysicalWidth);
float yMax = Math.max(lly, ury - desiredPhysicalHeight);
x = Math.min(xMax, Math.max(llx, x));
y = Math.min(yMax, Math.max(lly, y));
contentStream.saveGraphicsState();
contentStream.transform(Matrix.getTranslateInstance(x, y));
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
@@ -495,18 +505,37 @@ public class StampController {
private float calculatePositionX(
PDRectangle pageSize, int position, float contentWidth, float margin) {
float llx = pageSize.getLowerLeftX();
float urx = pageSize.getUpperRightX();
return switch (position % 3) {
case 1: // Left
yield pageSize.getLowerLeftX() + margin;
yield llx + margin;
case 2: // Center
yield (pageSize.getWidth() - contentWidth) / 2;
yield llx + (pageSize.getWidth() - contentWidth) / 2;
case 0: // Right
yield pageSize.getUpperRightX() - contentWidth - margin;
yield urx - contentWidth - margin;
default:
yield 0;
};
}
private float calculateImagePositionY(
PDRectangle pageSize, int position, float imageHeight, float margin) {
float lly = pageSize.getLowerLeftY();
float pageHeight = pageSize.getHeight();
float ury = pageSize.getUpperRightY();
return switch ((position - 1) / 3) {
case 0: // Top - upper image edge flush below top margin
yield ury - margin - imageHeight;
case 1: // Middle - center image on page
yield lly + (pageHeight - imageHeight) / 2;
case 2: // Bottom - lower image edge at bottom margin
yield lly + margin;
default:
yield lly;
};
}
private float calculatePositionY(
PDRectangle pageSize, int position, float height, float margin) {
return switch ((position - 1) / 3) {
@@ -84,8 +84,39 @@ public class PipelineProcessor {
return name.substring(0, underscoreIndex) + extension;
}
// Allowlist of URL path prefixes permitted through the pipeline.
private static final List<String> ALLOWED_PIPELINE_PATH_PREFIXES =
List.of(
"/api/v1/general/",
"/api/v1/misc/",
"/api/v1/security/",
"/api/v1/convert/",
"/api/v1/filter/");
private void validatePipelineUrl(String url) {
// Strip scheme+host to get the path portion for comparison
String path = url;
int schemeEnd = url.indexOf("://");
if (schemeEnd != -1) {
int pathStart = url.indexOf('/', schemeEnd + 3);
path = pathStart != -1 ? url.substring(pathStart) : "/";
}
final String pathToCheck = path;
boolean allowed = ALLOWED_PIPELINE_PATH_PREFIXES.stream().anyMatch(pathToCheck::contains);
if (!allowed) {
log.warn("Blocked pipeline request to disallowed URL: {}", url);
throw new SecurityException(
"Pipeline operation not permitted for endpoint: " + pathToCheck);
}
}
private String getApiKeyForUser() {
if (userService == null) return "";
String username = userService.getCurrentUsername();
if (username != null && !username.equals("anonymousUser")) {
return userService.getApiKeyForUser(username);
}
// Scheduled/internal context — no user in security context
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
}
@@ -283,6 +314,7 @@ public class PipelineProcessor {
/* package */ ResponseEntity<Resource> sendWebRequest(
String url, MultiValueMap<String, Object> body) {
validatePipelineUrl(url);
RestTemplate restTemplate = new RestTemplate();
// Set up headers, including API key
HttpHeaders headers = new HttpHeaders();
@@ -113,7 +113,7 @@ public class CertSignController {
this.serverCertificateService = serverCertificateService;
}
private static void sign(
public static void sign(
CustomPDFDocumentFactory pdfDocumentFactory,
MultipartFile input,
OutputStream output,
@@ -304,7 +304,7 @@ public class CertSignController {
}
}
class CreateSignature extends CreateSignatureBase {
public static class CreateSignature extends CreateSignatureBase {
File logoFile;
public CreateSignature(KeyStore keystore, char[] pin)
@@ -34,6 +34,7 @@ public class TextFinder extends PDFTextStripper {
this.useRegex = useRegex;
this.wholeWordSearch = wholeWordSearch;
this.setWordSeparator(" ");
this.setLineSeparator("\n");
}
@Override
@@ -0,0 +1,111 @@
package stirling.software.SPDF.service;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.security.KeyStore;
import org.springframework.stereotype.Service;
import stirling.software.SPDF.controller.api.security.CertSignController;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.service.PdfSigningService;
/** Core implementation of {@link PdfSigningService} backed by {@link CertSignController}. */
@Service
public class PdfSigningServiceImpl implements PdfSigningService {
private final CustomPDFDocumentFactory pdfDocumentFactory;
public PdfSigningServiceImpl(CustomPDFDocumentFactory pdfDocumentFactory) {
this.pdfDocumentFactory = pdfDocumentFactory;
}
@Override
public byte[] signWithKeystore(
byte[] pdfBytes,
KeyStore keystore,
char[] password,
boolean showSignature,
Integer pageNumber,
String name,
String location,
String reason,
boolean showLogo)
throws Exception {
CertSignController.CreateSignature createSignature =
new CertSignController.CreateSignature(keystore, password);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ByteArrayMultipartFile inputFile =
new ByteArrayMultipartFile(pdfBytes, "document.pdf", "application/pdf");
CertSignController.sign(
pdfDocumentFactory,
inputFile,
outputStream,
createSignature,
showSignature,
pageNumber,
name,
location,
reason,
showLogo);
return outputStream.toByteArray();
}
/** Minimal MultipartFile wrapper for passing raw PDF bytes to CertSignController.sign(). */
private static class ByteArrayMultipartFile
implements org.springframework.web.multipart.MultipartFile {
private final byte[] content;
private final String filename;
private final String contentType;
ByteArrayMultipartFile(byte[] content, String filename, String contentType) {
this.content = content;
this.filename = filename;
this.contentType = contentType;
}
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return filename;
}
@Override
public String getContentType() {
return contentType;
}
@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}
@Override
public long getSize() {
return content == null ? 0 : content.length;
}
@Override
public byte[] getBytes() {
return content;
}
@Override
public java.io.InputStream getInputStream() {
return new ByteArrayInputStream(content);
}
@Override
public void transferTo(java.io.File dest) throws java.io.IOException {
java.nio.file.Files.write(dest.toPath(), content);
}
}
}
@@ -50,6 +50,8 @@ spring.mvc.problemdetails.enabled=false
# Or via SYSTEMFILEUPLOADLIMIT/SYSTEM_MAXFILESIZE which will also set fileUploadLimit in settings.yml
spring.servlet.multipart.max-file-size=${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:2000MB}
spring.servlet.multipart.max-request-size=${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:2000MB}
# Jetty max form content size (default 200KB is too small for signature images)
server.jetty.max-http-form-post-size=10MB
server.servlet.session.tracking-modes=cookie
server.servlet.context-path=${SYSTEM_ROOTURIPATH:/}
spring.devtools.restart.enabled=true
@@ -240,6 +240,22 @@ system:
databaseBackup:
cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
storage:
enabled: false # set to 'true' to allow users to store files on the server (requires security.enableLogin) [ALPHA]
provider: local # storage provider: 'local' for filesystem storage, 'database' for DB-backed storage
local:
basePath: './storage' # base directory for stored files
quotas:
maxStorageMbPerUser: -1 # Max storage per user in MB; -1 disables per-user cap
maxStorageMbTotal: -1 # Max storage across all users in MB; -1 disables total cap
maxFileMb: -1 # Max size per stored file (including history/audit) in MB; -1 disables limit
sharing:
enabled: false # set to 'true' to enable file sharing features [ALPHA]
linkEnabled: true # set to 'false' to disable share links (requires system.frontendUrl)
emailEnabled: false # set to 'true' to allow sharing by email (requires mail.enabled)
linkExpirationDays: 3 # Number of days before share links expire
signing:
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
autoPipeline:
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
fileReadiness:
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -1,164 +0,0 @@
package stirling.software.SPDF.controller.api.converters;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockMultipartFile;
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
import stirling.software.common.service.CustomPDFDocumentFactory;
import stirling.software.common.util.CheckProgramInstall;
import stirling.software.common.util.TempFileManager;
@ExtendWith(MockitoExtension.class)
class ConvertPdfToVideoControllerTest {
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
@Mock private TempFileManager tempFileManager;
@InjectMocks private ConvertPdfToVideoController controller;
@Test
void convertPdfToVideo_ffmpegNotAvailableThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(false);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_nullFileThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
request.setFileInput(null);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_emptyFileThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile emptyFile =
new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", new byte[0]);
request.setFileInput(emptyFile);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_nonPdfContentTypeReturnsBadRequest() throws Exception {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile txtFile =
new MockMultipartFile("fileInput", "doc.txt", "text/plain", "content".getBytes());
request.setFileInput(txtFile);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
ResponseEntity<byte[]> response = controller.convertPdfToVideo(request);
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
}
}
@Test
void convertPdfToVideo_invalidOpacityThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
request.setOpacity(1.5f);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_negativeOpacityThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
request.setOpacity(-0.1f);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_negativeSecondsPerPageThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
request.setSecondsPerPage(-1);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void convertPdfToVideo_zeroSecondsPerPageThrows() {
PdfToVideoRequest request = new PdfToVideoRequest();
MockMultipartFile pdfFile =
new MockMultipartFile(
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
request.setFileInput(pdfFile);
request.setSecondsPerPage(0);
try (MockedStatic<CheckProgramInstall> mock =
Mockito.mockStatic(CheckProgramInstall.class)) {
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
}
}
@Test
void controllerIsConstructed() {
assertNotNull(controller);
}
}
@@ -9,6 +9,7 @@ import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
@@ -46,6 +47,7 @@ class StampControllerTest {
private Method processStampTextMethod;
private Method processCustomDateFormatMethod;
private Method calculateImagePositionYMethod;
@BeforeEach
void setUp() throws NoSuchMethodException {
@@ -63,6 +65,26 @@ class StampControllerTest {
StampController.class.getDeclaredMethod(
"processCustomDateFormat", String.class, LocalDateTime.class);
processCustomDateFormatMethod.setAccessible(true);
calculateImagePositionYMethod =
StampController.class.getDeclaredMethod(
"calculateImagePositionY",
PDRectangle.class,
int.class,
float.class,
float.class);
calculateImagePositionYMethod.setAccessible(true);
}
private float invokeCalculateImagePositionY(
PDRectangle pageSize, int position, float imageHeight, float margin) throws Exception {
try {
return (float)
calculateImagePositionYMethod.invoke(
stampController, pageSize, position, imageHeight, margin);
} catch (InvocationTargetException e) {
throw (Exception) e.getCause();
}
}
private String invokeProcessStampText(
@@ -86,6 +108,45 @@ class StampControllerTest {
}
}
@Nested
@DisplayName("Image stamp position (lower-left anchor)")
class ImagePositionYTests {
@Test
@DisplayName("Top row: upper edge of image sits below top margin")
void topRowUsesUpperRightMinusMarginMinusHeight() throws Exception {
PDRectangle page = new PDRectangle(0, 0, 600, 800);
float y = invokeCalculateImagePositionY(page, 3, 100f, 10f);
assertEquals(690f, y, 0.001f);
}
@Test
@DisplayName("Middle row: image is vertically centred on page")
void middleRowCentresImage() throws Exception {
PDRectangle page = new PDRectangle(0, 0, 600, 800);
float y = invokeCalculateImagePositionY(page, 5, 100f, 10f);
assertEquals(350f, y, 0.001f);
}
@Test
@DisplayName("Bottom row: lower edge of image sits above bottom margin")
void bottomRowUsesLowerLeftPlusMargin() throws Exception {
PDRectangle page = new PDRectangle(0, 0, 600, 800);
float y = invokeCalculateImagePositionY(page, 7, 100f, 10f);
assertEquals(10f, y, 0.001f);
}
@Test
@DisplayName("Honours non-zero media box origin")
void respectsLowerLeftOrigin() throws Exception {
PDRectangle page = new PDRectangle(50f, 100f, 400f, 300f);
float yMid = invokeCalculateImagePositionY(page, 5, 20f, 5f);
assertEquals(240f, yMid, 0.001f);
float yTop = invokeCalculateImagePositionY(page, 3, 20f, 5f);
assertEquals(375f, yTop, 0.001f);
}
}
@Nested
@DisplayName("Basic Variable Substitution Tests")
class BasicVariableTests {
@@ -205,7 +205,8 @@ class PipelineProcessorTest {
});
})) {
ResponseEntity<Resource> response =
pipelineProcessor.sendWebRequest("http://localhost/api", body);
pipelineProcessor.sendWebRequest(
"http://localhost/api/v1/general/merge-pdfs", body);
assertNotNull(response);
assertEquals(HttpStatus.OK, response.getStatusCode());
+3
View File
@@ -171,6 +171,9 @@ out/
*.jks
*.asc
# Allow test fixture certificates (synthetic, no real credentials)
!src/test/resources/test-certs/**
# SSH Keys
*.pub
*.priv
+4 -4
View File
@@ -37,7 +37,7 @@ spotless {
}
dependencies {
implementation project(':common')
api 'com.google.guava:guava:33.4.8-jre'
api 'com.google.guava:guava:33.5.0-jre'
api 'org.springframework:spring-jdbc'
api 'org.springframework:spring-webmvc'
@@ -51,8 +51,8 @@ dependencies {
api 'org.springframework.boot:spring-boot-starter-mail'
api 'org.springframework.boot:spring-boot-starter-cache'
api 'com.github.ben-manes.caffeine:caffeine'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.43'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.16.1'
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
implementation 'com.bucket4j:bucket4j_jdk17-core:8.17.0'
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
@@ -62,7 +62,7 @@ dependencies {
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database - file format incompatible with 2.4.x, would break existing user databases
runtimeOnly 'org.postgresql:postgresql:42.7.10'
implementation('com.coveo:saml-client:5.0.0') {
exclude group: 'org.opensaml', module: 'opensaml-core'
@@ -47,6 +47,7 @@ import stirling.software.proprietary.security.model.dto.AdminUserSummary;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.DatabaseService;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -71,6 +72,7 @@ public class ProprietaryUIDataController {
private final UserLicenseSettingsService licenseSettingsService;
private final PersistentAuditEventRepository auditRepository;
private final MfaService mfaService;
private final LoginAttemptService loginAttemptService;
public ProprietaryUIDataController(
ApplicationProperties applicationProperties,
@@ -84,7 +86,8 @@ public class ProprietaryUIDataController {
@Qualifier("runningEE") boolean runningEE,
UserLicenseSettingsService licenseSettingsService,
PersistentAuditEventRepository auditRepository,
MfaService mfaService) {
MfaService mfaService,
LoginAttemptService loginAttemptService) {
this.applicationProperties = applicationProperties;
this.auditConfig = auditConfig;
this.sessionPersistentRegistry = sessionPersistentRegistry;
@@ -97,6 +100,7 @@ public class ProprietaryUIDataController {
this.licenseSettingsService = licenseSettingsService;
this.auditRepository = auditRepository;
this.mfaService = mfaService;
this.loginAttemptService = loginAttemptService;
}
/**
@@ -387,6 +391,7 @@ public class ProprietaryUIDataController {
data.setPremiumEnabled(premiumEnabled);
data.setMailEnabled(applicationProperties.getMail().isEnabled());
data.setUserSettings(userSettings);
data.setLockedUsers(loginAttemptService.getAllBlockedUsers());
return ResponseEntity.ok(data);
}
@@ -605,6 +610,7 @@ public class ProprietaryUIDataController {
private boolean premiumEnabled;
private boolean mailEnabled;
private Map<String, Map<String, String>> userSettings;
private List<String> lockedUsers;
}
@Data
@@ -28,9 +28,16 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
basePackages = {
"stirling.software.proprietary.security.database.repository",
"stirling.software.proprietary.security.repository",
"stirling.software.proprietary.repository"
"stirling.software.proprietary.repository",
"stirling.software.proprietary.storage.repository",
"stirling.software.proprietary.workflow.repository"
})
@EntityScan({"stirling.software.proprietary.security.model", "stirling.software.proprietary.model"})
@EntityScan({
"stirling.software.proprietary.security.model",
"stirling.software.proprietary.model",
"stirling.software.proprietary.storage.model",
"stirling.software.proprietary.workflow.model"
})
public class DatabaseConfig {
public final String DATASOURCE_DEFAULT_URL;
@@ -0,0 +1,22 @@
package stirling.software.proprietary.security.configuration;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.filter.ParticipantRateLimitInterceptor;
@Configuration
@RequiredArgsConstructor
public class ProprietaryWebMvcConfig implements WebMvcConfigurer {
private final ParticipantRateLimitInterceptor participantRateLimitInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(participantRateLimitInterceptor)
.addPathPatterns("/api/v1/workflow/participant/**");
}
}
@@ -159,10 +159,12 @@ public class SecurityConfiguration {
firewall.setAllowedHeaderValues(
headerValue -> headerValue != null && allowedChars.matcher(headerValue).matches());
// Apply the same rules to parameter values for consistency.
// Allow non-ASCII characters and newlines in parameter values.
Pattern allowedParamChars = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]\\r\\n]*");
firewall.setAllowedParameterValues(
parameterValue ->
parameterValue != null && allowedChars.matcher(parameterValue).matches());
parameterValue != null
&& allowedParamChars.matcher(parameterValue).matches());
return firewall;
}
@@ -291,7 +293,12 @@ public class SecurityConfiguration {
http.addFilterBefore(
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
// TODO: IPRateLimitingFilter disabled — limit is 1M (no-op) and raw Filter
// impl causes Spring Security async dispatch bug (response already committed
// errors on StreamingResponseBody endpoints). Re-enable once converted to
// OncePerRequestFilter with proper config-driven limits.
// .addFilterBefore(rateLimitingFilter,
// UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
http.sessionManagement(
@@ -612,6 +612,7 @@ public class AdminSettingsController {
case "endpoints" -> applicationProperties.getEndpoints();
case "metrics" -> applicationProperties.getMetrics();
case "mail" -> applicationProperties.getMail();
case "storage" -> applicationProperties.getStorage();
case "premium" -> applicationProperties.getPremium();
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
@@ -633,6 +634,7 @@ public class AdminSettingsController {
"endpoints",
"metrics",
"mail",
"storage",
"premium",
"processExecutor",
"processexecutor",
@@ -18,6 +18,7 @@ import org.springframework.security.core.session.SessionInformation;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -33,6 +34,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.annotations.api.UserApi;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.common.model.api.security.UserSummaryDTO;
import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.proprietary.audit.AuditEventType;
@@ -46,6 +48,7 @@ import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.SaveUserRequest;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
@@ -65,6 +68,7 @@ public class UserController {
private final UserRepository userRepository;
private final Optional<EmailService> emailService;
private final UserLicenseSettingsService licenseSettingsService;
private final LoginAttemptService loginAttemptService;
@PreAuthorize("!hasAuthority('ROLE_DEMO_USER')")
@PostMapping("/register")
@@ -773,8 +777,17 @@ public class UserController {
Map.of("message", "User " + (enabled ? "enabled" : "disabled") + " successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/unlockUser/{username}")
@Audited(type = AuditEventType.SETTINGS_CHANGED, level = AuditLevel.BASIC)
public ResponseEntity<?> unlockUser(@PathVariable("username") String username) {
loginAttemptService.resetAttempts(username);
return ResponseEntity.ok(Map.of("message", "User account unlocked successfully"));
}
@PreAuthorize("hasRole('ROLE_ADMIN')")
@PostMapping("/admin/deleteUser/{username}")
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
public ResponseEntity<?> deleteUser(
@PathVariable("username") String username, Authentication authentication) {
if (!userService.usernameExistsIgnoreCase(username)) {
@@ -964,4 +977,34 @@ public class UserController {
.body("Failed to complete initial setup");
}
}
/**
* List all enabled users for selection in signing workflows.
*
* @param principal The authenticated user
* @return List of user summaries
*/
@GetMapping("/users")
public ResponseEntity<List<UserSummaryDTO>> listUsers(Principal principal) {
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
List<UserSummaryDTO> users =
userRepository.findAll().stream()
.filter(User::isEnabled)
.map(this::toUserSummaryDTO)
.collect(java.util.stream.Collectors.toList());
return ResponseEntity.ok(users);
}
private UserSummaryDTO toUserSummaryDTO(User user) {
return new UserSummaryDTO(
user.getId(),
user.getUsername(),
user.getUsername(), // Use username as displayName
user.getTeam() != null ? user.getTeam().getName() : null,
user.isEnabled());
}
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.security.filter;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/** Per-IP rate limiter for the unauthenticated participant token endpoints. */
@Slf4j
@Component
public class ParticipantRateLimitInterceptor implements HandlerInterceptor {
private static final int MAX_REQUESTS_PER_MINUTE = 20;
private static final long WINDOW_MS = 60_000L;
// value: [requestCount, windowStartMs]
private final ConcurrentHashMap<String, long[]> requestCounts = new ConcurrentHashMap<>();
@Override
public boolean preHandle(
HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String ip = getClientIp(request);
long now = System.currentTimeMillis();
long[] entry =
requestCounts.compute(
ip,
(key, existing) -> {
if (existing == null || now - existing[1] >= WINDOW_MS) {
return new long[] {1, now};
}
existing[0]++;
return existing;
});
if (entry[0] > MAX_REQUESTS_PER_MINUTE) {
log.warn(
"Rate limit exceeded for IP {} on participant endpoint {}",
ip,
request.getRequestURI());
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader("Retry-After", "60");
response.setContentType("application/json");
response.getWriter()
.write("{\"error\":\"Rate limit exceeded. Try again in 60 seconds.\"}");
return false;
}
return true;
}
private String getClientIp(HttpServletRequest request) {
// Do not trust X-Forwarded-For: it is user-controlled and trivially spoofed,
// which would allow an attacker to bypass this rate limiter by rotating fake IPs.
// Operators who deploy behind a trusted reverse proxy should configure Spring's
// RemoteIpFilter / ForwardedHeaderFilter at the framework level instead.
return request.getRemoteAddr();
}
@Scheduled(fixedDelay = 300_000)
public void cleanupExpiredWindows() {
long cutoff = System.currentTimeMillis() - WINDOW_MS;
requestCounts.entrySet().removeIf(e -> e.getValue()[1] < cutoff);
}
}
@@ -40,6 +40,7 @@ public class User implements UserDetails, Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "user_id")
@EqualsAndHashCode.Include
private Long id;
@Column(name = "username", unique = true)
@@ -1,8 +1,11 @@
package stirling.software.proprietary.security.service;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
@@ -79,6 +82,28 @@ public class LoginAttemptService {
return attemptCounter.getAttemptCount() >= MAX_ATTEMPT;
}
public void resetAttempts(String key) {
if (key == null || key.trim().isEmpty()) {
return;
}
String normalizedKey = key.toLowerCase(Locale.ROOT);
attemptsCache.remove(normalizedKey);
}
public boolean isBlockingEnabled() {
return isBlockedEnabled;
}
public List<String> getAllBlockedUsers() {
if (!isBlockedEnabled) {
return List.of();
}
return attemptsCache.entrySet().stream()
.filter(entry -> entry.getValue().getAttemptCount() >= MAX_ATTEMPT)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
}
public int getRemainingAttempts(String key) {
if (!isBlockedEnabled || key == null || key.trim().isEmpty()) {
// Arbitrarily high number if tracking is disabled
@@ -41,6 +41,7 @@ import stirling.software.common.service.UserServiceInterface;
import stirling.software.common.util.RegexPatternUtils;
import stirling.software.proprietary.model.Team;
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.Authority;
@@ -48,6 +49,16 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import stirling.software.proprietary.workflow.service.UserServerCertificateService;
@Service
@Slf4j
@@ -68,6 +79,15 @@ public class UserService implements UserServiceInterface {
private final ApplicationProperties.Security.OAUTH2 oAuth2;
private final PersistentLoginRepository persistentLoginRepository;
private final UserServerCertificateService userServerCertificateService;
private final WorkflowParticipantRepository workflowParticipantRepository;
private final WorkflowSessionRepository workflowSessionRepository;
private final StoredFileRepository storedFileRepository;
private final StorageCleanupEntryRepository storageCleanupEntryRepository;
private final FileShareRepository fileShareRepository;
private final FileShareAccessRepository fileShareAccessRepository;
@Transactional
public void processSSOPostLogin(
String username,
@@ -178,6 +198,15 @@ public class UserService implements UserServiceInterface {
return user.getApiKey();
}
@Override
public String getCurrentUserApiKey() {
String username = getCurrentUsername();
if (username == null || username.isEmpty()) {
throw new IllegalStateException("Cannot determine calling user for API key lookup");
}
return getApiKeyForUser(username);
}
public boolean isValidApiKey(String apiKey) {
return userRepository.findByApiKey(apiKey).isPresent();
}
@@ -200,19 +229,78 @@ public class UserService implements UserServiceInterface {
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
}
@Transactional
public void deleteUser(String username) {
Optional<User> userOpt = findByUsernameIgnoreCase(username);
if (userOpt.isPresent()) {
for (Authority authority : userOpt.get().getAuthorities()) {
User user = userOpt.get();
for (Authority authority : user.getAuthorities()) {
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
return;
}
}
userRepository.delete(userOpt.get());
deleteUserRelatedData(user);
userRepository.delete(user);
persistentLoginRepository.deleteByUsername(username);
}
invalidateUserSessions(username);
}
private void deleteUserRelatedData(User user) {
log.info("Deleting all associated data for user: {}", user.getUsername());
// Delete server certificate (non-nullable OneToOne → User)
userServerCertificateService.deleteUserCertificate(user.getId());
// Delete FileShareAccess records where this user is the accessor
fileShareAccessRepository.deleteByUser(user);
// Delete FileShare records where this user is the recipient (shared with them by others).
// FileShareAccess for those shares must be cleared first (no cascade from FileShare side).
List<FileShare> sharesTargetingUser = fileShareRepository.findBySharedWithUser(user);
sharesTargetingUser.forEach(fileShareAccessRepository::deleteByFileShare);
fileShareRepository.deleteAll(sharesTargetingUser);
// Null out WorkflowParticipant.user for sessions this user participates in but does not
// own.
// The participant record is retained to preserve the workflow audit trail.
workflowParticipantRepository.clearUserReferences(user);
// Break circular FK: null out stored_files.workflow_session_id before deleting sessions
storedFileRepository.clearWorkflowSessionReferencesByOwner(user);
// Delete WorkflowSessions (CascadeType.ALL cascades to WorkflowParticipant)
workflowSessionRepository.deleteAll(
workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user));
// Collect storage keys for physical cleanup before deleting DB records
List<StoredFile> files = storedFileRepository.findAllByOwner(user);
List<String> storageKeys =
files.stream()
.flatMap(
f ->
java.util.stream.Stream.of(
f.getStorageKey(),
f.getHistoryStorageKey(),
f.getAuditLogStorageKey()))
.filter(k -> k != null && !k.isBlank())
.toList();
// Clear FileShareAccess per share (no cascade from FileShare), then delete StoredFiles
// (CascadeType.ALL on StoredFile.shares cascades to FileShare)
for (StoredFile file : files) {
file.getShares().forEach(fileShareAccessRepository::deleteByFileShare);
}
storedFileRepository.deleteAll(files);
// Schedule physical deletion of all storage blobs; StorageCleanupService handles retry
for (String key : storageKeys) {
StorageCleanupEntry entry = new StorageCleanupEntry();
entry.setStorageKey(key);
storageCleanupEntryRepository.save(entry);
}
}
public boolean usernameExists(String username) {
return findByUsername(username).isPresent();
}
@@ -0,0 +1,74 @@
package stirling.software.proprietary.storage.config;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Locale;
import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.storage.provider.DatabaseStorageProvider;
import stirling.software.proprietary.storage.provider.LocalStorageProvider;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.StoredFileBlobRepository;
@Configuration
@RequiredArgsConstructor
@Slf4j
public class StorageProviderConfig {
private final ApplicationProperties applicationProperties;
private final StoredFileBlobRepository storedFileBlobRepository;
@Bean
public StorageProvider storageProvider() {
boolean storageEnabled = applicationProperties.getStorage().isEnabled();
String providerName =
Optional.ofNullable(applicationProperties.getStorage().getProvider())
.orElse("local")
.trim()
.toLowerCase(Locale.ROOT);
if ("database".equals(providerName)) {
return new DatabaseStorageProvider(storedFileBlobRepository);
}
if (!"local".equals(providerName)) {
throw new IllegalStateException("Storage provider not supported: " + providerName);
}
String basePathValue = applicationProperties.getStorage().getLocal().getBasePath();
if (basePathValue == null || basePathValue.isBlank()) {
if (storageEnabled) {
throw new IllegalStateException("Storage base path is not configured");
}
basePathValue = InstallationPathConfig.getPath() + "storage";
}
Path basePath = Paths.get(basePathValue).toAbsolutePath().normalize();
Path installRoot = Paths.get(InstallationPathConfig.getPath()).toAbsolutePath().normalize();
if (!basePath.startsWith(installRoot)) {
// Warn rather than hard-fail: admins may legitimately point storage at an external
// volume, but an unexpected path could indicate a misconfiguration or traversal
// attempt.
log.warn(
"Storage basePath '{}' is outside the installation directory '{}'. "
+ "Verify this is intentional.",
basePath,
installRoot);
}
if (storageEnabled) {
try {
Files.createDirectories(basePath);
} catch (IOException e) {
throw new IllegalStateException(
"Unable to create storage base directory: " + basePath, e);
}
}
return new LocalStorageProvider(basePath);
}
}
@@ -0,0 +1,270 @@
package stirling.software.proprietary.storage.controller;
import java.util.List;
import java.util.Locale;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.model.api.CreateShareLinkRequest;
import stirling.software.proprietary.storage.model.api.ShareLinkAccessResponse;
import stirling.software.proprietary.storage.model.api.ShareLinkMetadataResponse;
import stirling.software.proprietary.storage.model.api.ShareLinkResponse;
import stirling.software.proprietary.storage.model.api.ShareWithUserRequest;
import stirling.software.proprietary.storage.model.api.StoredFileResponse;
import stirling.software.proprietary.storage.service.FileStorageService;
@RestController
@RequestMapping("/api/v1/storage")
@RequiredArgsConstructor
public class FileStorageController {
private final FileStorageService fileStorageService;
@PostMapping(
value = "/files",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public StoredFileResponse uploadFile(
@RequestPart("file") MultipartFile file,
@RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle,
@RequestPart(name = "auditLog", required = false) MultipartFile auditLog) {
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.storeFileResponse(user, file, historyBundle, auditLog);
}
@PutMapping(
value = "/files/{fileId}",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public StoredFileResponse updateFile(
@PathVariable Long fileId,
@RequestPart("file") MultipartFile file,
@RequestPart(name = "historyBundle", required = false) MultipartFile historyBundle,
@RequestPart(name = "auditLog", required = false) MultipartFile auditLog) {
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.updateFileResponse(user, fileId, file, historyBundle, auditLog);
}
@GetMapping(value = "/files", produces = MediaType.APPLICATION_JSON_VALUE)
public List<StoredFileResponse> listFiles() {
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.listAccessibleFileResponses(user);
}
@GetMapping(value = "/files/{fileId}", produces = MediaType.APPLICATION_JSON_VALUE)
public StoredFileResponse getFileMetadata(@PathVariable Long fileId) {
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.getAccessibleFileResponse(user, fileId);
}
@GetMapping("/files/{fileId}/download")
public ResponseEntity<org.springframework.core.io.Resource> downloadFile(
@PathVariable Long fileId,
@RequestParam(name = "inline", defaultValue = "false") boolean inline) {
User user = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getAccessibleFile(user, fileId);
fileStorageService.requireReadAccess(user, file);
return buildFileResponse(file, inline);
}
@DeleteMapping("/files/{fileId}")
public ResponseEntity<Void> deleteFile(@PathVariable Long fileId) {
User user = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(user, fileId);
fileStorageService.deleteFile(user, file);
return ResponseEntity.noContent().build();
}
@PostMapping(
value = "/files/{fileId}/shares/users",
produces = MediaType.APPLICATION_JSON_VALUE)
public StoredFileResponse shareWithUser(
@PathVariable Long fileId, @RequestBody ShareWithUserRequest request) {
User owner = fileStorageService.requireAuthenticatedUser();
if (request == null || request.getUsername() == null || request.getUsername().isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Username is required");
}
return fileStorageService.shareWithUserResponse(
owner,
fileId,
request.getUsername(),
fileStorageService.normalizeShareRole(request.getAccessRole()));
}
@DeleteMapping("/files/{fileId}/shares/users/{username}")
public ResponseEntity<Void> revokeUserShare(
@PathVariable Long fileId, @PathVariable String username) {
User owner = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(owner, fileId);
fileStorageService.revokeUserShare(owner, file, username);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/files/{fileId}/shares/self")
public ResponseEntity<Void> leaveUserShare(@PathVariable Long fileId) {
User user = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getAccessibleFile(user, fileId);
fileStorageService.leaveUserShare(user, file);
return ResponseEntity.noContent().build();
}
@PostMapping(
value = "/files/{fileId}/shares/links",
produces = MediaType.APPLICATION_JSON_VALUE)
public ShareLinkResponse createShareLink(
@PathVariable Long fileId, @RequestBody CreateShareLinkRequest request) {
User owner = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(owner, fileId);
FileShare share =
fileStorageService.createShareLink(
owner,
file,
fileStorageService.normalizeShareRole(
request != null ? request.getAccessRole() : null));
return ShareLinkResponse.builder()
.token(share.getShareToken())
.accessRole(
share.getAccessRole() != null
? share.getAccessRole().name().toLowerCase(Locale.ROOT)
: null)
.createdAt(share.getCreatedAt())
.expiresAt(share.getExpiresAt())
.build();
}
@DeleteMapping("/files/{fileId}/shares/links/{token}")
public ResponseEntity<Void> revokeShareLink(
@PathVariable Long fileId, @PathVariable String token) {
User owner = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(owner, fileId);
fileStorageService.revokeShareLink(owner, file, token);
return ResponseEntity.noContent().build();
}
@GetMapping("/share-links/{token}")
public ResponseEntity<org.springframework.core.io.Resource> downloadShareLink(
@PathVariable String token,
Authentication authentication,
@RequestParam(name = "inline", defaultValue = "false") boolean inline) {
fileStorageService.ensureShareLinksEnabled();
FileShare share = fileStorageService.getShareByToken(token);
if (!fileStorageService.canAccessShareLink(share, authentication)) {
HttpStatus status =
isAuthenticated(authentication)
? HttpStatus.FORBIDDEN
: HttpStatus.UNAUTHORIZED;
String message =
status == HttpStatus.FORBIDDEN
? "Access denied for this share link"
: "Authentication required for this share link";
throw new ResponseStatusException(status, message);
}
fileStorageService.requireReadAccess(share);
fileStorageService.recordShareAccess(share, authentication, inline);
StoredFile file = share.getFile();
return buildFileResponse(file, inline);
}
@GetMapping("/share-links/{token}/metadata")
public ShareLinkMetadataResponse getShareLinkMetadata(
@PathVariable String token, Authentication authentication) {
fileStorageService.ensureShareLinksEnabled();
FileShare share = fileStorageService.getShareByToken(token);
if (!fileStorageService.canAccessShareLink(share, authentication)) {
HttpStatus status =
isAuthenticated(authentication)
? HttpStatus.FORBIDDEN
: HttpStatus.UNAUTHORIZED;
String message =
status == HttpStatus.FORBIDDEN
? "Access denied for this share link"
: "Authentication required for this share link";
throw new ResponseStatusException(status, message);
}
StoredFile file = share.getFile();
User currentUser = fileStorageService.requireAuthenticatedUser();
boolean ownedByCurrentUser =
currentUser != null
&& file.getOwner() != null
&& currentUser.getId().equals(file.getOwner().getId());
return ShareLinkMetadataResponse.builder()
.shareToken(share.getShareToken())
.fileId(file.getId())
.fileName(file.getOriginalFilename())
.owner(file.getOwner() != null ? file.getOwner().getUsername() : null)
.ownedByCurrentUser(ownedByCurrentUser)
.accessRole(
share.getAccessRole() != null
? share.getAccessRole().name().toLowerCase(Locale.ROOT)
: null)
.createdAt(share.getCreatedAt())
.expiresAt(share.getExpiresAt())
.build();
}
@GetMapping("/share-links/accessed")
public List<ShareLinkMetadataResponse> listAccessedShareLinks() {
fileStorageService.ensureShareLinksEnabled();
User user = fileStorageService.requireAuthenticatedUser();
return fileStorageService.listAccessedShareLinkResponses(user);
}
@GetMapping("/files/{fileId}/shares/links/{token}/accesses")
public List<ShareLinkAccessResponse> listShareAccesses(
@PathVariable Long fileId, @PathVariable String token) {
fileStorageService.ensureShareLinksEnabled();
User owner = fileStorageService.requireAuthenticatedUser();
StoredFile file = fileStorageService.getOwnedFile(owner, fileId);
return fileStorageService.listShareAccessResponses(owner, file, token);
}
private ResponseEntity<org.springframework.core.io.Resource> buildFileResponse(
StoredFile file, boolean inline) {
org.springframework.core.io.Resource resource = fileStorageService.loadFile(file);
String contentType =
file.getContentType() == null
? MediaType.APPLICATION_OCTET_STREAM_VALUE
: file.getContentType();
ContentDisposition disposition =
ContentDisposition.builder(inline ? "inline" : "attachment")
.filename(file.getOriginalFilename())
.build();
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(disposition);
try {
headers.setContentType(MediaType.parseMediaType(contentType));
} catch (IllegalArgumentException ex) {
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
}
headers.setContentLength(file.getSizeBytes());
return ResponseEntity.ok().headers(headers).body(resource);
}
private boolean isAuthenticated(Authentication authentication) {
return authentication != null
&& authentication.isAuthenticated()
&& !"anonymousUser".equals(authentication.getPrincipal());
}
}
@@ -0,0 +1,81 @@
package stirling.software.proprietary.storage.converter;
import java.util.HashMap;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.persistence.AttributeConverter;
import jakarta.persistence.Converter;
import lombok.extern.slf4j.Slf4j;
/**
* JPA AttributeConverter for storing Map<String, Object> as JSON in database columns.
*
* <p>Converts between Java Map objects and JSON strings for PostgreSQL JSONB or TEXT columns.
* Includes backward compatibility handling for legacy double-encoded JSON data.
*/
@Converter
@Slf4j
public class JsonMapConverter implements AttributeConverter<Map<String, Object>, String> {
private static final ObjectMapper objectMapper = new ObjectMapper();
@Override
public String convertToDatabaseColumn(Map<String, Object> attribute) {
if (attribute == null || attribute.isEmpty()) {
return null;
}
try {
return objectMapper.writeValueAsString(attribute);
} catch (JsonProcessingException e) {
log.error("Failed to convert map to JSON", e);
throw new RuntimeException("Failed to convert map to JSON", e);
}
}
@Override
public Map<String, Object> convertToEntityAttribute(String dbData) {
if (dbData == null || dbData.isBlank()) {
return new HashMap<>();
}
try {
// Try normal parsing first
return objectMapper.readValue(dbData, new TypeReference<Map<String, Object>>() {});
} catch (JsonProcessingException e) {
// Fallback: try double-parsing for legacy double-encoded data
// This handles data that was stored as JSON strings instead of JSON objects
log.debug("Attempting double-decode fallback for legacy metadata format");
try {
JsonNode node = objectMapper.readTree(dbData);
if (node.isTextual()) {
log.warn(
"╔════════════════════════════════════════════════════════════════════╗");
log.warn(
"║ WARNING: DOUBLE-ENCODED JSON DETECTED - LEGACY DATA FOUND ║");
log.warn(
"║ This should not occur in newly created records. ║");
log.warn(
"║ Data preview: {}",
dbData.length() > 100 ? dbData.substring(0, 100) + "..." : dbData);
log.warn(
"╚════════════════════════════════════════════════════════════════════╝");
return objectMapper.readValue(
node.asText(), new TypeReference<Map<String, Object>>() {});
}
} catch (JsonProcessingException e2) {
log.error("Failed to parse metadata even with double-decode fallback", e2);
}
// If all parsing fails, return empty map to prevent application errors
log.error("Unable to parse JSON metadata, returning empty map", e);
return new HashMap<>();
}
}
}
@@ -0,0 +1,19 @@
package stirling.software.proprietary.storage.model;
/**
* Defines the purpose classification for stored files. Used to categorize files based on their role
* in the system.
*/
public enum FilePurpose {
/** Regular file sharing - generic uploaded files */
GENERIC,
/** Original PDF in a signing session - the document to be signed */
SIGNING_ORIGINAL,
/** Final signed PDF - the completed document with all signatures applied */
SIGNING_SIGNED,
/** Audit trail for signing session - history and metadata */
SIGNING_HISTORY
}
@@ -0,0 +1,77 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
/** Represents a file sharing relationship between a file and a user or token. */
@Entity
@Table(
name = "file_shares",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_file_share_user",
columnNames = {"stored_file_id", "shared_with_user_id"}),
@UniqueConstraint(
name = "uk_file_share_token",
columnNames = {"share_token"})
},
indexes = {
@Index(name = "idx_file_shares_file_id", columnList = "stored_file_id"),
@Index(name = "idx_file_shares_share_token", columnList = "share_token")
})
@NoArgsConstructor
@Getter
@Setter
public class FileShare implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "file_share_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "stored_file_id", nullable = false)
private StoredFile file;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "shared_with_user_id")
private User sharedWithUser;
@Column(name = "share_token", unique = true)
private String shareToken;
@Enumerated(EnumType.STRING)
@Column(name = "access_role")
private ShareAccessRole accessRole;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
}
@@ -0,0 +1,64 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(
name = "file_share_accesses",
indexes = {
@Index(name = "idx_share_access_file_share", columnList = "file_share_id"),
@Index(name = "idx_share_access_user", columnList = "user_id"),
@Index(
name = "idx_share_access_file_share_accessed",
columnList = "file_share_id, accessed_at")
})
@NoArgsConstructor
@Getter
@Setter
public class FileShareAccess implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "file_share_access_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "file_share_id", nullable = false)
private FileShare fileShare;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(name = "access_type", nullable = false)
private FileShareAccessType accessType;
@CreationTimestamp
@Column(name = "accessed_at", updatable = false)
private LocalDateTime accessedAt;
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.storage.model;
public enum FileShareAccessType {
VIEW,
DOWNLOAD
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.storage.model;
public enum ShareAccessRole {
EDITOR,
COMMENTER,
VIEWER
}
@@ -0,0 +1,47 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "storage_cleanup_entries")
@NoArgsConstructor
@Getter
@Setter
public class StorageCleanupEntry implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "cleanup_entry_id")
private Long id;
@Column(name = "storage_key", nullable = false, length = 128)
private String storageKey;
@Column(name = "attempt_count")
private int attemptCount;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,116 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.HashSet;
import java.util.Set;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.WorkflowSession;
@Entity
@Table(
name = "stored_files",
indexes = {
@Index(name = "idx_stored_files_owner", columnList = "owner_id"),
@Index(name = "idx_stored_files_workflow", columnList = "workflow_session_id")
})
@NoArgsConstructor
@Getter
@Setter
public class StoredFile implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "stored_file_id")
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@Column(name = "original_filename", nullable = false)
private String originalFilename;
@Column(name = "content_type")
private String contentType;
@Column(name = "size_bytes")
private long sizeBytes;
@Column(name = "storage_key", nullable = false, unique = true)
private String storageKey;
@Column(name = "history_filename")
private String historyFilename;
@Column(name = "history_content_type")
private String historyContentType;
@Column(name = "history_size_bytes")
private Long historySizeBytes;
@Column(name = "history_storage_key", unique = true)
private String historyStorageKey;
@Column(name = "audit_log_filename")
private String auditLogFilename;
@Column(name = "audit_log_content_type")
private String auditLogContentType;
@Column(name = "audit_log_size_bytes")
private Long auditLogSizeBytes;
@Column(name = "audit_log_storage_key", unique = true)
private String auditLogStorageKey;
// Link to workflow if this file is part of a workflow
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "workflow_session_id")
private WorkflowSession workflowSession;
// Purpose classification
@Column(name = "file_purpose")
@Enumerated(EnumType.STRING)
private FilePurpose purpose;
@OneToMany(
mappedBy = "file",
fetch = FetchType.LAZY,
cascade = CascadeType.ALL,
orphanRemoval = true)
private Set<FileShare> shares = new HashSet<>();
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,31 @@
package stirling.software.proprietary.storage.model;
import java.io.Serializable;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Lob;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Entity
@Table(name = "stored_file_blobs")
@NoArgsConstructor
@Getter
@Setter
public class StoredFileBlob implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@Column(name = "storage_key", nullable = false, length = 128)
private String storageKey;
@Lob
@Column(name = "data", nullable = false, columnDefinition = "BYTEA")
private byte[] data;
}
@@ -0,0 +1,12 @@
package stirling.software.proprietary.storage.model.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class CreateShareLinkRequest {
private String accessRole;
}
@@ -0,0 +1,14 @@
package stirling.software.proprietary.storage.model.api;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ShareLinkAccessResponse {
private final String username;
private final String accessType;
private final LocalDateTime accessedAt;
}
@@ -0,0 +1,20 @@
package stirling.software.proprietary.storage.model.api;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ShareLinkMetadataResponse {
private final String shareToken;
private final Long fileId;
private final String fileName;
private final String owner;
private final boolean ownedByCurrentUser;
private final String accessRole;
private final LocalDateTime createdAt;
private final LocalDateTime expiresAt;
private final LocalDateTime lastAccessedAt;
}
@@ -0,0 +1,15 @@
package stirling.software.proprietary.storage.model.api;
import java.time.LocalDateTime;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ShareLinkResponse {
private final String token;
private final String accessRole;
private final LocalDateTime createdAt;
private final LocalDateTime expiresAt;
}
@@ -0,0 +1,13 @@
package stirling.software.proprietary.storage.model.api;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@NoArgsConstructor
public class ShareWithUserRequest {
private String username;
private String accessRole;
}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.storage.model.api;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class SharedUserResponse {
private final String username;
private final String accessRole;
}
@@ -0,0 +1,25 @@
package stirling.software.proprietary.storage.model.api;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class StoredFileResponse {
private final Long id;
private final String fileName;
private final String contentType;
private final long sizeBytes;
private final String owner;
private final boolean ownedByCurrentUser;
private final String accessRole;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
private final List<String> sharedWithUsers;
private final List<SharedUserResponse> sharedUsers;
private final List<ShareLinkResponse> shareLinks;
private final String filePurpose;
}
@@ -0,0 +1,53 @@
package stirling.software.proprietary.storage.provider;
import java.io.IOException;
import java.util.UUID;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.StoredFileBlob;
import stirling.software.proprietary.storage.repository.StoredFileBlobRepository;
@RequiredArgsConstructor
public class DatabaseStorageProvider implements StorageProvider {
private final StoredFileBlobRepository storedFileBlobRepository;
@Override
public StoredObject store(User owner, MultipartFile file) throws IOException {
String storageKey = UUID.randomUUID().toString();
StoredFileBlob blob = new StoredFileBlob();
blob.setStorageKey(storageKey);
blob.setData(file.getBytes());
storedFileBlobRepository.save(blob);
return StoredObject.builder()
.storageKey(storageKey)
.originalFilename(file.getOriginalFilename())
.contentType(file.getContentType())
.sizeBytes(file.getSize())
.build();
}
@Override
public Resource load(String storageKey) throws IOException {
StoredFileBlob blob =
storedFileBlobRepository
.findById(storageKey)
.orElseThrow(() -> new IOException("File not found"));
return new ByteArrayResource(blob.getData());
}
@Override
public void delete(String storageKey) throws IOException {
if (!storedFileBlobRepository.existsById(storageKey)) {
return;
}
storedFileBlobRepository.deleteById(storageKey);
}
}
@@ -0,0 +1,82 @@
package stirling.software.proprietary.storage.provider;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Optional;
import java.util.UUID;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import lombok.RequiredArgsConstructor;
import stirling.software.proprietary.security.model.User;
@RequiredArgsConstructor
public class LocalStorageProvider implements StorageProvider {
private final Path basePath;
@Override
public StoredObject store(User owner, MultipartFile file) throws IOException {
String originalFilename = sanitizeFilename(file.getOriginalFilename());
String storageKey =
owner.getId()
+ "/"
+ UUID.randomUUID()
+ "_"
+ Optional.ofNullable(originalFilename).orElse("file");
Path targetPath = basePath.resolve(storageKey).normalize();
if (!targetPath.startsWith(basePath)) {
throw new IOException("Resolved storage path is outside the storage directory");
}
Files.createDirectories(targetPath.getParent());
try (InputStream inputStream = file.getInputStream()) {
Files.copy(inputStream, targetPath, StandardCopyOption.REPLACE_EXISTING);
}
return StoredObject.builder()
.storageKey(storageKey)
.originalFilename(originalFilename)
.contentType(file.getContentType())
.sizeBytes(file.getSize())
.build();
}
@Override
public Resource load(String storageKey) throws IOException {
Path targetPath = basePath.resolve(storageKey).normalize();
if (!targetPath.startsWith(basePath)) {
throw new IOException("Resolved storage path is outside the storage directory");
}
if (!Files.exists(targetPath)) {
throw new IOException("File not found");
}
return new FileSystemResource(targetPath.toFile());
}
@Override
public void delete(String storageKey) throws IOException {
Path targetPath = basePath.resolve(storageKey).normalize();
if (!targetPath.startsWith(basePath)) {
throw new IOException("Resolved storage path is outside the storage directory");
}
Files.deleteIfExists(targetPath);
}
private String sanitizeFilename(String filename) {
if (filename == null || filename.isBlank()) {
return "file";
}
return Paths.get(filename).getFileName().toString();
}
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.storage.provider;
import java.io.IOException;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.proprietary.security.model.User;
public interface StorageProvider {
StoredObject store(User owner, MultipartFile file) throws IOException;
Resource load(String storageKey) throws IOException;
void delete(String storageKey) throws IOException;
}
@@ -0,0 +1,13 @@
package stirling.software.proprietary.storage.provider;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class StoredObject {
private final String storageKey;
private final String originalFilename;
private final String contentType;
private final long sizeBytes;
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.storage.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.FileShareAccess;
public interface FileShareAccessRepository extends JpaRepository<FileShareAccess, Long> {
@Query(
"SELECT a FROM FileShareAccess a "
+ "LEFT JOIN FETCH a.user "
+ "WHERE a.fileShare = :fileShare "
+ "ORDER BY a.accessedAt DESC")
List<FileShareAccess> findByFileShareWithUserOrderByAccessedAtDesc(
@Param("fileShare") FileShare fileShare);
void deleteByFileShare(FileShare fileShare);
void deleteByUser(User user);
@Query(
"SELECT a FROM FileShareAccess a "
+ "JOIN FETCH a.fileShare s "
+ "JOIN FETCH s.file f "
+ "LEFT JOIN FETCH f.owner "
+ "WHERE a.user = :user "
+ "ORDER BY a.accessedAt DESC")
List<FileShareAccess> findByUserWithShareAndFile(@Param("user") User user);
}
@@ -0,0 +1,39 @@
package stirling.software.proprietary.storage.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.StoredFile;
public interface FileShareRepository extends JpaRepository<FileShare, Long> {
Optional<FileShare> findByFileAndSharedWithUser(StoredFile file, User sharedWithUser);
Optional<FileShare> findByShareToken(String shareToken);
@Query(
"SELECT s FROM FileShare s "
+ "JOIN FETCH s.file f "
+ "LEFT JOIN FETCH f.owner "
+ "WHERE s.shareToken = :shareToken")
Optional<FileShare> findByShareTokenWithFile(@Param("shareToken") String shareToken);
@Query("SELECT s FROM FileShare s WHERE s.file = :file AND s.shareToken IS NOT NULL")
List<FileShare> findShareLinks(@Param("file") StoredFile file);
List<FileShare> findBySharedWithUser(User sharedWithUser);
List<FileShare> findByExpiresAtBeforeAndShareTokenNotNull(java.time.LocalDateTime now);
@Query(
"SELECT s FROM FileShare s "
+ "JOIN FETCH s.file f "
+ "WHERE s.sharedWithUser = :user AND f IN :files")
List<FileShare> findBySharedWithUserAndFileIn(
@Param("user") User user, @Param("files") List<StoredFile> files);
}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.storage.repository;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
public interface StorageCleanupEntryRepository extends JpaRepository<StorageCleanupEntry, Long> {
List<StorageCleanupEntry> findTop50ByOrderByUpdatedAtAsc();
}
@@ -0,0 +1,7 @@
package stirling.software.proprietary.storage.repository;
import org.springframework.data.jpa.repository.JpaRepository;
import stirling.software.proprietary.storage.model.StoredFileBlob;
public interface StoredFileBlobRepository extends JpaRepository<StoredFileBlob, String> {}
@@ -0,0 +1,69 @@
package stirling.software.proprietary.storage.repository;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.workflow.model.WorkflowSession;
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
Optional<StoredFile> findByIdAndOwner(Long id, User owner);
@Query(
"SELECT DISTINCT f FROM StoredFile f "
+ "LEFT JOIN FETCH f.owner "
+ "LEFT JOIN FETCH f.shares s "
+ "LEFT JOIN FETCH s.sharedWithUser "
+ "WHERE f.id = :id AND f.owner = :owner")
Optional<StoredFile> findByIdAndOwnerWithShares(
@Param("id") Long id, @Param("owner") User owner);
@Query(
"SELECT DISTINCT f FROM StoredFile f "
+ "LEFT JOIN FETCH f.owner "
+ "LEFT JOIN FETCH f.shares s "
+ "LEFT JOIN FETCH s.sharedWithUser "
+ "WHERE f.id = :id")
Optional<StoredFile> findByIdWithShares(@Param("id") Long id);
@Query(
"SELECT DISTINCT f FROM StoredFile f "
+ "LEFT JOIN FETCH f.owner "
+ "LEFT JOIN FETCH f.shares s "
+ "LEFT JOIN FETCH s.sharedWithUser "
+ "WHERE f.owner = :user "
+ "OR s.sharedWithUser = :user")
List<StoredFile> findAccessibleFiles(@Param("user") User user);
@Query(
"SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) "
+ "+ COALESCE(f.auditLogSizeBytes, 0)), 0) "
+ "FROM StoredFile f WHERE f.owner = :owner")
long sumStorageBytesByOwner(@Param("owner") User owner);
@Query(
"SELECT COALESCE(SUM(f.sizeBytes + COALESCE(f.historySizeBytes, 0) "
+ "+ COALESCE(f.auditLogSizeBytes, 0)), 0) "
+ "FROM StoredFile f")
long sumStorageBytesTotal();
/** Finds all files associated with a workflow session. */
List<StoredFile> findByWorkflowSession(WorkflowSession workflowSession);
List<StoredFile> findAllByOwner(User owner);
@Modifying
@Transactional
@Query(
"UPDATE StoredFile sf SET sf.workflowSession = null "
+ "WHERE sf.workflowSession IN "
+ "(SELECT ws FROM WorkflowSession ws WHERE ws.owner = :user)")
void clearWorkflowSessionReferencesByOwner(@Param("user") User user);
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.storage.service;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
@Service
@RequiredArgsConstructor
@Slf4j
public class StorageCleanupService {
private static final int MAX_CLEANUP_ATTEMPTS = 10;
private final StorageProvider storageProvider;
private final StorageCleanupEntryRepository cleanupEntryRepository;
private final FileShareRepository fileShareRepository;
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
public void cleanupOrphanedStorage() {
List<StorageCleanupEntry> entries = cleanupEntryRepository.findTop50ByOrderByUpdatedAtAsc();
if (entries.isEmpty()) {
return;
}
for (StorageCleanupEntry entry : entries) {
try {
storageProvider.delete(entry.getStorageKey());
cleanupEntryRepository.delete(entry);
} catch (IOException ex) {
int attempts = entry.getAttemptCount() + 1;
if (attempts >= MAX_CLEANUP_ATTEMPTS) {
log.error(
"Abandoning cleanup for storage key {} after {} failed attempts."
+ " The blob may be orphaned and require manual removal.",
entry.getStorageKey(),
attempts,
ex);
cleanupEntryRepository.delete(entry);
} else {
entry.setAttemptCount(attempts);
cleanupEntryRepository.save(entry);
log.warn(
"Failed to cleanup storage key {} (attempt {}/{})",
entry.getStorageKey(),
attempts,
MAX_CLEANUP_ATTEMPTS,
ex);
}
}
}
}
@Scheduled(fixedDelay = 1, timeUnit = TimeUnit.DAYS)
public void cleanupExpiredShareLinks() {
List<stirling.software.proprietary.storage.model.FileShare> expired =
fileShareRepository.findByExpiresAtBeforeAndShareTokenNotNull(LocalDateTime.now());
if (expired.isEmpty()) {
return;
}
fileShareRepository.deleteAll(expired);
}
}
@@ -0,0 +1,493 @@
package stirling.software.proprietary.workflow.controller;
import java.io.IOException;
import java.security.Principal;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.common.util.GeneralUtils;
import stirling.software.common.util.WebResponseUtils;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
import stirling.software.proprietary.workflow.dto.CertificateValidationResponse;
import stirling.software.proprietary.workflow.dto.ParticipantRequest;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.SigningFinalizationService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
@Slf4j
@RestController
@RequestMapping("/api/v1/security")
@Tag(name = "Security", description = "Security APIs - Signing Workflows")
@RequiredArgsConstructor
public class SigningSessionController {
private final WorkflowSessionService workflowSessionService;
private final UserService userService;
private final SigningFinalizationService signingFinalizationService;
private final CertificateSubmissionValidator certificateSubmissionValidator;
private final ObjectMapper objectMapper = new ObjectMapper();
@Operation(summary = "List all signing sessions for current user")
@Transactional(readOnly = true)
@GetMapping(value = "/cert-sign/sessions")
public ResponseEntity<?> listSessions(Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
List<stirling.software.proprietary.workflow.model.WorkflowSession> sessions =
workflowSessionService.listUserSessions(user);
List<stirling.software.proprietary.workflow.dto.WorkflowSessionResponse> responses =
sessions.stream()
.map(
stirling.software.proprietary.workflow.util.WorkflowMapper
::toResponse)
.collect(java.util.stream.Collectors.toList());
return ResponseEntity.ok(responses);
} catch (Exception e) {
log.error("Error listing sessions for user {}", principal.getName(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Error listing sessions");
}
}
@PostMapping(
consumes = {MediaType.MULTIPART_FORM_DATA_VALUE},
value = "/cert-sign/sessions",
produces = MediaType.APPLICATION_JSON_VALUE)
@Operation(
summary = "Create a shared signing session",
description =
"Starts a collaboration session, distributes share links, and optionally notifies"
+ " participants. Input:PDF Output:JSON Type:SISO")
public ResponseEntity<?> createSession(
@org.springframework.web.bind.annotation.RequestParam("file")
org.springframework.web.multipart.MultipartFile file,
@ModelAttribute WorkflowCreationRequest request,
Principal principal)
throws Exception {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
WorkflowSession session = workflowSessionService.createSession(owner, file, request);
return ResponseEntity.ok(
stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(session));
} catch (Exception e) {
log.error("Error creating signing session", e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
}
}
@Operation(summary = "Fetch signing session details")
@Transactional(readOnly = true)
@GetMapping(value = "/cert-sign/sessions/{sessionId}")
public ResponseEntity<?> getSession(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
WorkflowSession session = workflowSessionService.getSessionForOwner(sessionId, owner);
// Include wet signatures in response for owner preview
return ResponseEntity.ok(
stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(
session, objectMapper));
} catch (Exception e) {
log.error("Error fetching session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Access denied or session not found");
}
}
@Operation(summary = "Delete a signing session")
@DeleteMapping(value = "/cert-sign/sessions/{sessionId}")
public ResponseEntity<?> deleteSession(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
workflowSessionService.deleteSession(sessionId, owner);
return ResponseEntity.noContent().build();
} catch (Exception e) {
log.error("Error deleting session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Cannot delete session: " + e.getMessage());
}
}
@Operation(summary = "Add participants to an existing session")
@PostMapping(value = "/cert-sign/sessions/{sessionId}/participants")
public ResponseEntity<?> addParticipants(
@PathVariable("sessionId") @NotBlank String sessionId,
@RequestBody List<ParticipantRequest> participants,
Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
workflowSessionService.addParticipants(sessionId, participants, owner);
WorkflowSession session =
workflowSessionService.getSessionWithParticipantsForOwner(sessionId, owner);
return ResponseEntity.ok(
stirling.software.proprietary.workflow.util.WorkflowMapper.toResponse(session));
} catch (Exception e) {
log.error("Error adding participants to session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Cannot add participants: " + e.getMessage());
}
}
@Operation(summary = "Remove a participant from a session")
@DeleteMapping(value = "/cert-sign/sessions/{sessionId}/participants/{participantId}")
public ResponseEntity<?> removeParticipant(
@PathVariable("sessionId") @NotBlank String sessionId,
@PathVariable("participantId") Long participantId,
Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User owner = getCurrentUser(principal);
workflowSessionService.removeParticipant(sessionId, participantId, owner);
return ResponseEntity.noContent().build();
} catch (Exception e) {
log.error("Error removing participant {} from session {}", participantId, sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Cannot remove participant: " + e.getMessage());
}
}
@Operation(summary = "Get session PDF for participant view")
@GetMapping(value = "/cert-sign/sessions/{sessionId}/pdf")
public ResponseEntity<byte[]> getSessionPdf(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
User owner = getCurrentUser(principal);
workflowSessionService.getSessionForOwner(sessionId, owner);
byte[] pdfBytes = workflowSessionService.getOriginalFile(sessionId);
return WebResponseUtils.bytesToWebResponse(pdfBytes, "document.pdf");
} catch (Exception e) {
log.error("Error fetching PDF for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@PostMapping(value = "/cert-sign/sessions/{sessionId}/finalize")
@Operation(
summary = "Finalize signing session",
description =
"Applies collected wet signatures and digital certificates, then returns the"
+ " signed document.")
@StandardPdfResponse
public ResponseEntity<byte[]> finalizeSession(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal)
throws Exception {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
User owner = getCurrentUser(principal);
WorkflowSession session =
workflowSessionService.getSessionWithParticipantsForOwner(sessionId, owner);
byte[] originalPdf = workflowSessionService.getOriginalFile(sessionId);
byte[] pdf = signingFinalizationService.finalizeDocument(session, originalPdf);
String filename = session.getDocumentName().replace(".pdf", "") + "_shared_signed.pdf";
workflowSessionService.storeProcessedFile(session, pdf, filename);
workflowSessionService.finalizeSession(sessionId, owner);
workflowSessionService.deleteOriginalFile(session);
try {
signingFinalizationService.clearSensitiveMetadata(session);
} catch (Exception e) {
log.error(
"SECURITY: Failed to clear sensitive metadata for session {} "
+ "(participants: {}). Keystore credentials may remain in the "
+ "database until manual cleanup.",
sessionId,
session.getParticipants() != null
? session.getParticipants().stream().map(p -> p.getEmail()).toList()
: "unknown",
e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR,
"Document signed successfully but post-signing cleanup failed. "
+ "Contact your administrator to complete the cleanup.");
}
return WebResponseUtils.bytesToWebResponse(pdf, filename);
} catch (Exception e) {
log.error("Error finalizing session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
@Operation(summary = "Get signed PDF from finalized session")
@GetMapping(value = "/cert-sign/sessions/{sessionId}/signed-pdf")
@StandardPdfResponse
public ResponseEntity<byte[]> getSignedPdf(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
User owner = getCurrentUser(principal);
byte[] signedPdf = workflowSessionService.getProcessedFile(sessionId, owner);
if (signedPdf == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body("Session not finalized".getBytes());
}
WorkflowSession session = workflowSessionService.getSessionForOwner(sessionId, owner);
return WebResponseUtils.bytesToWebResponse(
signedPdf,
GeneralUtils.generateFilename(session.getDocumentName(), "_shared_signed.pdf"));
} catch (Exception e) {
log.error("Error fetching signed PDF for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
// ===== SIGN REQUESTS (Participant View) =====
@Operation(summary = "List sign requests for authenticated user")
@Transactional(readOnly = true)
@GetMapping(value = "/cert-sign/sign-requests")
public ResponseEntity<?> listSignRequests(Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
return ResponseEntity.ok(workflowSessionService.listSignRequests(user));
} catch (Exception e) {
log.error("Error listing sign requests for user {}", principal.getName(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Cannot list sign requests: " + e.getMessage());
}
}
@Transactional(readOnly = true)
@Operation(summary = "Get sign request detail for participant")
@GetMapping(value = "/cert-sign/sign-requests/{sessionId}")
public ResponseEntity<?> getSignRequestDetail(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
return ResponseEntity.ok(workflowSessionService.getSignRequestDetail(sessionId, user));
} catch (Exception e) {
log.error("Error fetching sign request detail for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Access denied or sign request not found: " + e.getMessage());
}
}
@Operation(summary = "Get document for sign request")
@GetMapping(value = "/cert-sign/sign-requests/{sessionId}/document")
public ResponseEntity<byte[]> getSignRequestDocument(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
try {
User user = getCurrentUser(principal);
byte[] document = workflowSessionService.getSignRequestDocument(sessionId, user);
return WebResponseUtils.bytesToWebResponse(document, "document.pdf");
} catch (Exception e) {
log.error("Error fetching document for sign request {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
@Operation(summary = "Sign a document with certificate and optional wet signature")
@PostMapping(
value = "/cert-sign/sign-requests/{sessionId}/sign",
consumes = {
MediaType.MULTIPART_FORM_DATA_VALUE,
MediaType.APPLICATION_FORM_URLENCODED_VALUE
})
public ResponseEntity<?> signDocument(
@PathVariable("sessionId") @NotBlank String sessionId,
@ModelAttribute stirling.software.proprietary.workflow.dto.SignDocumentRequest request,
Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
workflowSessionService.signDocument(sessionId, user, request);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
log.error("Invalid sign request for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
} catch (Exception e) {
log.error("Error signing document for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body("Cannot sign document: " + e.getMessage());
}
}
@Operation(summary = "Decline a sign request")
@PostMapping(value = "/cert-sign/sign-requests/{sessionId}/decline")
public ResponseEntity<?> declineSignRequest(
@PathVariable("sessionId") @NotBlank String sessionId, Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Authentication required");
}
try {
User user = getCurrentUser(principal);
workflowSessionService.declineSignRequest(sessionId, user);
return ResponseEntity.noContent().build();
} catch (Exception e) {
log.error("Error declining sign request for session {}", sessionId, e);
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body("Cannot decline sign request: " + e.getMessage());
}
}
@Operation(
summary = "Pre-validate a certificate before signing",
description =
"Validates that the provided certificate is loadable, not expired, and can "
+ "successfully sign a document. Returns validation details so the "
+ "user can confirm the correct certificate before committing.")
@PostMapping(
value = "/cert-sign/validate-certificate",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CertificateValidationResponse> validateCertificate(
@RequestParam("certType") String certType,
@RequestParam(value = "password", required = false) String password,
@RequestParam(value = "p12File", required = false) MultipartFile p12File,
@RequestParam(value = "jksFile", required = false) MultipartFile jksFile,
Principal principal) {
workflowSessionService.ensureSigningEnabled();
if (principal == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
if (!"SERVER".equalsIgnoreCase(certType)
&& !"USER_CERT".equalsIgnoreCase(certType)
&& (p12File == null || p12File.isEmpty())
&& (jksFile == null || jksFile.isEmpty())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "No certificate file provided");
}
try {
byte[] keystoreBytes = null;
if (p12File != null && !p12File.isEmpty()) {
keystoreBytes = p12File.getBytes();
} else if (jksFile != null && !jksFile.isEmpty()) {
keystoreBytes = jksFile.getBytes();
}
CertificateInfo info =
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, certType, password);
if (info == null) {
return ResponseEntity.ok(
new CertificateValidationResponse(
true, null, null, null, null, false, null));
}
return ResponseEntity.ok(
new CertificateValidationResponse(
true,
info.subjectName(),
info.issuerName(),
info.notAfter() != null ? info.notAfter().toInstant().toString() : null,
info.notBefore() != null
? info.notBefore().toInstant().toString()
: null,
info.selfSigned(),
null));
} catch (ResponseStatusException e) {
return ResponseEntity.ok(
new CertificateValidationResponse(
false, null, null, null, null, false, e.getReason()));
} catch (IOException e) {
log.error("Error reading certificate file during pre-validation", e);
return ResponseEntity.ok(
new CertificateValidationResponse(
false,
null,
null,
null,
null,
false,
"Failed to read certificate file"));
}
}
// ===== HELPER METHODS =====
private User getCurrentUser(Principal principal) {
return userService
.findByUsernameIgnoreCase(principal.getName())
.orElseThrow(
() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized"));
}
}
@@ -0,0 +1,442 @@
package stirling.software.proprietary.workflow.controller;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
import stirling.software.proprietary.workflow.dto.CertificateValidationResponse;
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
import stirling.software.proprietary.workflow.dto.SignatureSubmissionRequest;
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.service.CertificateSubmissionValidator;
import stirling.software.proprietary.workflow.service.MetadataEncryptionService;
import stirling.software.proprietary.workflow.service.WorkflowSessionService;
import stirling.software.proprietary.workflow.util.WorkflowMapper;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* REST controller for workflow participant actions. Handles participant-facing operations like
* viewing sessions, submitting signatures, and updating participant status.
*
* <p>Access is controlled via share tokens, not requiring authentication.
*/
@Slf4j
@RestController
@RequestMapping("/api/v1/workflow/participant")
@Tag(name = "Workflow Participant", description = "Participant Action APIs")
@RequiredArgsConstructor
public class WorkflowParticipantController {
private final WorkflowSessionService workflowSessionService;
private final WorkflowParticipantRepository participantRepository;
private final ObjectMapper objectMapper;
private final MetadataEncryptionService metadataEncryptionService;
private final CertificateSubmissionValidator certificateSubmissionValidator;
private static final DateTimeFormatter ISO_UTC =
DateTimeFormatter.ISO_INSTANT.withZone(ZoneOffset.UTC);
@Operation(
summary = "Get workflow session details by participant token",
description = "Allows participants to view session details using their share token")
@GetMapping(value = "/session", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<WorkflowSessionResponse> getSessionByToken(
@RequestParam("token") @NotBlank String token) {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
// Check if participant is expired
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
// Mark as viewed if not already
if (participant.getStatus() == ParticipantStatus.PENDING
|| participant.getStatus() == ParticipantStatus.NOTIFIED) {
workflowSessionService.updateParticipantStatus(
participant.getId(), ParticipantStatus.VIEWED);
}
WorkflowSession session = participant.getWorkflowSession();
return ResponseEntity.ok(WorkflowMapper.toResponse(session));
}
@Operation(
summary = "Get participant details by token",
description = "Returns participant-specific information")
@GetMapping(value = "/details", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ParticipantResponse> getParticipantDetails(
@RequestParam("token") @NotBlank String token) {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
}
@Operation(
summary = "Submit signature (wet signature and/or certificate)",
description =
"Participants submit their signature data and certificate information for signing")
@PostMapping(
value = "/submit-signature",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ParticipantResponse> submitSignature(
@ModelAttribute SignatureSubmissionRequest request) {
workflowSessionService.ensureSigningEnabled();
if (request.getParticipantToken() == null || request.getParticipantToken().isBlank()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant token is required");
}
WorkflowParticipant participant =
participantRepository
.findByShareToken(request.getParticipantToken())
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
// Check if participant can still submit
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
if (participant.hasCompleted()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant has already completed their action");
}
if (!participant.getWorkflowSession().isActive()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Workflow session is no longer active");
}
try {
// Build metadata map with certificate and wet signature data
Map<String, Object> metadata = buildSubmissionMetadata(request);
participant.setParticipantMetadata(metadata);
// Update status to SIGNED
participant.setStatus(ParticipantStatus.SIGNED);
participant = participantRepository.save(participant);
log.info(
"Participant {} submitted signature for session {}",
participant.getEmail(),
participant.getWorkflowSession().getSessionId());
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
log.error("Error submitting signature for participant {}", participant.getEmail(), e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Failed to submit signature", e);
}
}
@Operation(
summary = "Decline participation",
description = "Participant declines to sign or participate in the workflow")
@PostMapping(value = "/decline", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ParticipantResponse> declineParticipation(
@RequestParam("token") @NotBlank String token,
@RequestParam(value = "reason", required = false) @Size(max = 500) String reason) {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (participant.hasCompleted()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant has already completed their action");
}
// Update status to DECLINED
participant.setStatus(ParticipantStatus.DECLINED);
// Add decline reason to notifications
if (reason != null && !reason.isBlank()) {
workflowSessionService.addParticipantNotification(
participant.getId(), "Declined: " + reason);
} else {
workflowSessionService.addParticipantNotification(
participant.getId(), "Declined participation");
}
participant = participantRepository.save(participant);
log.info(
"Participant {} declined workflow session {}",
participant.getEmail(),
participant.getWorkflowSession().getSessionId());
return ResponseEntity.ok(WorkflowMapper.toParticipantResponse(participant));
}
@Operation(
summary = "Get original PDF for review",
description = "Participant downloads the original document")
@GetMapping(value = "/document", produces = MediaType.APPLICATION_PDF_VALUE)
public ResponseEntity<byte[]> getDocument(@RequestParam("token") @NotBlank String token) {
workflowSessionService.ensureSigningEnabled();
WorkflowParticipant participant =
participantRepository
.findByShareToken(token)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
if (participant.isExpired()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Participant access expired");
}
try {
WorkflowSession session = participant.getWorkflowSession();
byte[] pdf = workflowSessionService.getOriginalFile(session.getSessionId());
return ResponseEntity.ok()
.header(
HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment()
.filename(session.getDocumentName(), StandardCharsets.UTF_8)
.build()
.toString())
.contentType(org.springframework.http.MediaType.APPLICATION_PDF)
.body(pdf);
} catch (IOException e) {
log.error("Error retrieving document for participant", e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Failed to retrieve document", e);
}
}
@Operation(
summary = "Pre-validate a certificate before submission",
description =
"Validates that the provided certificate is loadable, not expired, and can "
+ "successfully sign a document. Returns validation details so the "
+ "participant can confirm the correct certificate before committing.")
@PostMapping(
value = "/validate-certificate",
consumes = MediaType.MULTIPART_FORM_DATA_VALUE,
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<CertificateValidationResponse> validateCertificate(
@RequestParam("participantToken") @NotBlank String participantToken,
@RequestParam("certType") String certType,
@RequestParam(value = "password", required = false) String password,
@RequestParam(value = "p12File", required = false) MultipartFile p12File,
@RequestParam(value = "jksFile", required = false) MultipartFile jksFile) {
workflowSessionService.ensureSigningEnabled();
participantRepository
.findByShareToken(participantToken)
.filter(p -> !p.isExpired())
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"Invalid or expired participant token"));
// Require a file for non-SERVER/non-USER_CERT types — this is a request error, not a
// validation failure
if (!"SERVER".equalsIgnoreCase(certType)
&& !"USER_CERT".equalsIgnoreCase(certType)
&& (p12File == null || p12File.isEmpty())
&& (jksFile == null || jksFile.isEmpty())) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "No certificate file provided");
}
try {
byte[] keystoreBytes = null;
if (p12File != null && !p12File.isEmpty()) {
keystoreBytes = p12File.getBytes();
} else if (jksFile != null && !jksFile.isEmpty()) {
keystoreBytes = jksFile.getBytes();
}
CertificateInfo info =
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, certType, password);
if (info == null) {
// SERVER type — nothing to validate
return ResponseEntity.ok(
new CertificateValidationResponse(
true, null, null, null, null, false, null));
}
return ResponseEntity.ok(
new CertificateValidationResponse(
true,
info.subjectName(),
info.issuerName(),
info.notAfter() != null ? info.notAfter().toInstant().toString() : null,
info.notBefore() != null
? info.notBefore().toInstant().toString()
: null,
info.selfSigned(),
null));
} catch (ResponseStatusException e) {
// Validation failure — return 200 with valid:false so the frontend can display inline
return ResponseEntity.ok(
new CertificateValidationResponse(
false, null, null, null, null, false, e.getReason()));
} catch (IOException e) {
log.error("Error reading certificate file during pre-validation", e);
return ResponseEntity.ok(
new CertificateValidationResponse(
false,
null,
null,
null,
null,
false,
"Failed to read certificate file"));
}
}
/**
* Builds metadata map from signature submission request. Includes certificate submission and
* wet signature data.
*/
private Map<String, Object> buildSubmissionMetadata(SignatureSubmissionRequest request)
throws IOException {
Map<String, Object> metadata = new HashMap<>();
// Validate certificate before storing — throws 400 if invalid, expired, or wrong password
if (request.getCertType() != null && !"SERVER".equalsIgnoreCase(request.getCertType())) {
byte[] keystoreBytes = null;
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
keystoreBytes = request.getP12File().getBytes();
} else if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
keystoreBytes = request.getJksFile().getBytes();
}
if (keystoreBytes != null) {
certificateSubmissionValidator.validateAndExtractInfo(
keystoreBytes, request.getCertType(), request.getPassword());
}
}
// Add certificate submission if provided
if (request.getCertType() != null) {
Map<String, Object> certSubmission = new HashMap<>();
certSubmission.put("certType", request.getCertType());
certSubmission.put(
"password", metadataEncryptionService.encrypt(request.getPassword()));
certSubmission.put("showSignature", request.getShowSignature());
certSubmission.put("pageNumber", request.getPageNumber());
certSubmission.put("location", request.getLocation());
certSubmission.put("reason", request.getReason());
certSubmission.put("showLogo", request.getShowLogo());
// Store certificate files as base64
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
certSubmission.put(
"p12Keystore",
java.util.Base64.getEncoder()
.encodeToString(request.getP12File().getBytes()));
}
if (request.getJksFile() != null && !request.getJksFile().isEmpty()) {
certSubmission.put(
"jksKeystore",
java.util.Base64.getEncoder()
.encodeToString(request.getJksFile().getBytes()));
}
metadata.put("certificateSubmission", certSubmission);
}
// Add wet signatures data if provided - parse once and store as List directly
if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) {
if (request.getWetSignaturesData().length() > 5 * 1024 * 1024) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Wet signatures data exceeds maximum allowed size");
}
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> wetSigs =
objectMapper.readValue(
request.getWetSignaturesData(),
new TypeReference<java.util.List<Map<String, Object>>>() {});
if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Too many wet signatures submitted");
}
metadata.put("wetSignatures", wetSigs);
}
return metadata;
}
}
@@ -0,0 +1,11 @@
package stirling.software.proprietary.workflow.dto;
import java.util.Date;
/**
* Certificate metadata extracted from a keystore submission. Returned by
* CertificateSubmissionValidator after successful validation so callers can surface details
* (expiry, subject) to the user.
*/
public record CertificateInfo(
String subjectName, String issuerName, Date notBefore, Date notAfter, boolean selfSigned) {}
@@ -0,0 +1,46 @@
package stirling.software.proprietary.workflow.dto;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* Certificate submission details extracted from a participant's stored metadata. Contains the
* certificate type, optional keystore bytes (decoded from base64), password, and per-participant
* signature appearance overrides.
*/
@Getter
@Setter
@NoArgsConstructor
public class CertificateSubmission {
/** Certificate type: P12, JKS, SERVER, or USER_CERT */
private String certType;
/**
* Keystore password. Stored encrypted at rest; decrypted by MetadataEncryptionService before
* use. Cleared from the database after finalization.
*/
private String password;
/** PKCS12 keystore bytes, decoded from the base64 stored in participant metadata. */
private byte[] p12Keystore;
/** JKS keystore bytes, decoded from the base64 stored in participant metadata. */
private byte[] jksKeystore;
/** Whether to show a visible digital signature block on the page. */
private Boolean showSignature;
/** 1-indexed page number for the digital signature block (session-level default). */
private Integer pageNumber;
/** Participant's location when signing (included in digital signature metadata). */
private String location;
/** Participant's reason for signing (included in digital signature metadata). */
private String reason;
/** Whether to show the Stirling logo in the digital signature block. */
private Boolean showLogo;
}
@@ -0,0 +1,18 @@
package stirling.software.proprietary.workflow.dto;
/**
* API response returned by the certificate pre-validation endpoints. Always returns HTTP 200; the
* {@code valid} field indicates success. Frontend should use this to display inline feedback before
* the user completes signing.
*/
public record CertificateValidationResponse(
boolean valid,
String subjectName,
String issuerName,
/** ISO-8601 formatted expiry date, or null if validation failed. */
String notAfter,
/** ISO-8601 formatted start-of-validity date, or null if validation failed. */
String notBefore,
boolean selfSigned,
/** Human-readable error message, or null if valid. */
String error) {}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.workflow.dto;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.storage.model.ShareAccessRole;
/**
* Request DTO for adding or configuring a workflow participant. Supports both registered users and
* external email participants.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantRequest {
/** User ID if participant is a registered user */
private Long userId;
/** Email address (required for external users, optional for registered users) */
private String email;
/** Display name for the participant */
private String name;
/** Access role for the participant (EDITOR, COMMENTER, VIEWER) */
private ShareAccessRole accessRole;
/** Optional expiration timestamp for participant access */
private LocalDateTime expiresAt;
/** Participant-specific metadata (JSON string) */
private String participantMetadata;
/** Whether to send notification immediately */
private boolean sendNotification = true;
/** Owner-set default reason for this participant's signature */
private String defaultReason;
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.workflow.dto;
import java.time.LocalDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
/**
* Response DTO for workflow participant details. Used in API responses to provide participant
* information.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ParticipantResponse {
private Long id;
private Long userId;
private String email;
private String name;
private ParticipantStatus status;
private String shareToken;
private ShareAccessRole accessRole;
private LocalDateTime expiresAt;
private LocalDateTime lastUpdated;
private boolean hasCompleted;
private boolean isExpired;
private List<WetSignatureMetadata> wetSignatures;
}
@@ -0,0 +1,72 @@
package stirling.software.proprietary.workflow.dto;
import java.util.ArrayList;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Request object for signing a document. Combines certificate submission data with optional wet
* signature (visual signature) metadata. Supports multiple wet signatures.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignDocumentRequest {
// Certificate-related fields
@NotNull(message = "Certificate type is required")
@Pattern(
regexp = "SERVER|USER_CERT|UPLOAD|PEM|PKCS12|PFX|JKS",
message = "Invalid certificate type")
private String certType;
private MultipartFile p12File;
private String password;
private MultipartFile privateKeyFile;
private MultipartFile certFile;
// Signature metadata (participant can override owner defaults)
private String reason; // Participant's reason for signing
private String location; // Participant's location when signing
// Wet signatures as JSON string (from frontend FormData)
private String wetSignaturesData;
// Parsed wet signatures (populated by controller/service)
private List<WetSignatureMetadata> wetSignatures;
/**
* Checks if this request includes wet signature metadata.
*
* @return true if wet signatures list is not empty
*/
public boolean hasWetSignatures() {
return wetSignatures != null && !wetSignatures.isEmpty();
}
/**
* Extracts and validates wet signature metadata.
*
* @return List of validated WetSignatureMetadata objects
*/
public List<WetSignatureMetadata> extractWetSignatureMetadata() {
List<WetSignatureMetadata> signatures = new ArrayList<>();
if (hasWetSignatures()) {
for (WetSignatureMetadata signature : wetSignatures) {
signature.validate();
signatures.add(signature);
}
}
return signatures;
}
}
@@ -0,0 +1,27 @@
package stirling.software.proprietary.workflow.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
/** DTO for sign request detail (participant view) */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignRequestDetailDTO {
private String sessionId;
private String documentName;
private String ownerUsername;
private String message;
private String dueDate;
private String createdAt;
private ParticipantStatus myStatus;
// Signature appearance settings (read-only, configured by owner)
private Boolean showSignature;
private Integer pageNumber;
private String reason;
private String location;
private Boolean showLogo;
}
@@ -0,0 +1,20 @@
package stirling.software.proprietary.workflow.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
/** DTO for sign request summary (participant view) */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignRequestSummaryDTO {
private String sessionId;
private String documentName;
private String ownerUsername;
private String createdAt;
private String dueDate;
private ParticipantStatus myStatus;
}
@@ -0,0 +1,34 @@
package stirling.software.proprietary.workflow.dto;
import org.springframework.web.multipart.MultipartFile;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Request DTO for submitting a signature (wet signature or certificate). Used when a participant
* completes their signing action. Supports multiple wet signatures.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class SignatureSubmissionRequest {
// Certificate submission fields
private String certType; // P12, JKS, SERVER, USER_CERT
private String password;
private MultipartFile p12File;
private MultipartFile jksFile;
private Boolean showSignature;
private Integer pageNumber;
private String location;
private String reason;
private Boolean showLogo;
// Wet signatures (JSON array string with coordinates and image data)
private String wetSignaturesData;
// Participant identification
private String participantToken;
}
@@ -0,0 +1,112 @@
package stirling.software.proprietary.workflow.dto;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Data Transfer Object for wet signature (visual signature) metadata. Contains information about a
* signature annotation placed by a participant on the PDF. This data is used to overlay the
* signature on the PDF during finalization.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WetSignatureMetadata {
/** Maximum number of wet signatures allowed per participant submission. */
public static final int MAX_SIGNATURES_PER_PARTICIPANT = 50;
/** Type of wet signature: "canvas" (drawn), "image" (uploaded), or "text" (typed) */
@NotNull(message = "Wet signature type is required")
@Pattern(
regexp = "canvas|image|text",
message = "Wet signature type must be canvas, image, or text")
private String type;
/**
* Base64-encoded image data or text content for the signature. For canvas/image types:
* data:image/png;base64,... format For text type: plain text string
*/
@NotNull(message = "Wet signature data is required")
@Size(max = 5_000_000, message = "Wet signature data exceeds maximum size of 5MB")
private String data;
/** Zero-indexed page number where the signature is placed */
@NotNull(message = "Page number is required")
@PositiveOrZero(message = "Page number must be zero or positive")
private Integer page;
/** X position as a fraction (01) of page width, measured from left edge */
@NotNull(message = "X coordinate is required")
@PositiveOrZero(message = "X coordinate must be zero or positive")
@DecimalMax(value = "1.0", message = "X coordinate must not exceed 1.0 (page width)")
private Double x;
/**
* Y position as a fraction (01) of page height, measured from top edge. Note: This is UI
* coordinate system (top-left origin). Will be converted to PDF coordinate system (bottom-left
* origin) during overlay.
*/
@NotNull(message = "Y coordinate is required")
@PositiveOrZero(message = "Y coordinate must be zero or positive")
@DecimalMax(value = "1.0", message = "Y coordinate must not exceed 1.0 (page height)")
private Double y;
/** Width of the signature rectangle as a fraction (01) of page width */
@NotNull(message = "Width is required")
@Positive(message = "Width must be positive")
@DecimalMax(value = "1.0", message = "Width must not exceed 1.0 (page width)")
private Double width;
/** Height of the signature rectangle as a fraction (01) of page height */
@NotNull(message = "Height is required")
@Positive(message = "Height must be positive")
@DecimalMax(value = "1.0", message = "Height must not exceed 1.0 (page height)")
private Double height;
/**
* Validates that the wet signature data is properly formatted based on type. For image types,
* ensures data starts with data:image prefix.
*
* @return true if validation passes
* @throws IllegalArgumentException if validation fails
*/
public boolean validate() {
if (type.equals("canvas") || type.equals("image")) {
if (!data.startsWith("data:image/")) {
throw new IllegalArgumentException(
"Image wet signature data must start with data:image/ prefix");
}
}
if (x != null && width != null && x + width > 1.0) {
throw new IllegalArgumentException(
"Signature extends beyond the right edge of the page (x + width > 1.0)");
}
if (y != null && height != null && y + height > 1.0) {
throw new IllegalArgumentException(
"Signature extends beyond the bottom edge of the page (y + height > 1.0)");
}
return true;
}
/**
* Extracts just the base64 data portion from a data URL. Removes the "data:image/png;base64,"
* prefix.
*
* @return pure base64 string without data URL prefix
*/
public String extractBase64Data() {
if (data != null && data.contains(",")) {
return data.substring(data.indexOf(",") + 1);
}
return data;
}
}
@@ -0,0 +1,43 @@
package stirling.software.proprietary.workflow.dto;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.workflow.model.WorkflowType;
/**
* Request DTO for creating a new workflow session. Used to initialize workflow sessions with
* participants and settings.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WorkflowCreationRequest {
/** Type of workflow to create (SIGNING, REVIEW, APPROVAL) */
private WorkflowType workflowType;
/** Display name for the document in the workflow */
private String documentName;
/** Owner's email address (optional, used for notifications) */
private String ownerEmail;
/** Message/instructions for participants */
private String message;
/** Due date for workflow completion (flexible string format) */
private String dueDate;
/** List of participant user IDs (for registered users) */
private List<Long> participantUserIds;
/** List of participant email addresses (for external/unregistered users) */
private List<String> participantEmails;
/** Workflow-specific metadata (JSON string) */
private String workflowMetadata;
}
@@ -0,0 +1,40 @@
package stirling.software.proprietary.workflow.dto;
import java.time.LocalDateTime;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.model.WorkflowType;
/**
* Response DTO for workflow session details. Used in API responses to provide session information
* to clients.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class WorkflowSessionResponse {
private String sessionId;
private Long ownerId;
private String ownerUsername;
private WorkflowType workflowType;
private String documentName;
private String ownerEmail;
private String message;
private String dueDate;
private WorkflowStatus status;
private boolean finalized;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private List<ParticipantResponse> participants;
private int participantCount;
private int signedCount;
private boolean hasProcessedFile;
private Long originalFileId;
private Long processedFileId;
}
@@ -0,0 +1,6 @@
package stirling.software.proprietary.workflow.model;
public enum CertificateType {
AUTO_GENERATED,
USER_UPLOADED
}
@@ -0,0 +1,22 @@
package stirling.software.proprietary.workflow.model;
/**
* Defines the status of a participant in a workflow session. Tracks participant progress through
* the workflow lifecycle.
*/
public enum ParticipantStatus {
/** Participant has been added but not yet notified */
PENDING,
/** Participant has been notified via email or other means */
NOTIFIED,
/** Participant has viewed the document */
VIEWED,
/** Participant has completed their action (e.g., signed the document) */
SIGNED,
/** Participant has declined to participate or rejected the action */
DECLINED
}
@@ -0,0 +1,73 @@
package stirling.software.proprietary.workflow.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.*;
import stirling.software.proprietary.security.model.User;
@Entity
@Table(name = "user_server_certificates")
@NoArgsConstructor
@Getter
@Setter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@ToString(onlyExplicitlyIncluded = true)
public class UserServerCertificateEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
@EqualsAndHashCode.Include
@ToString.Include
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", unique = true, nullable = false)
@JsonIgnore
private User user;
@Lob
@Basic(fetch = FetchType.EAGER)
@Column(name = "keystore_data", nullable = false, columnDefinition = "bytea")
@JsonIgnore
private byte[] keystoreData;
@Column(name = "keystore_password", nullable = false)
@JsonIgnore
private String keystorePassword;
@Enumerated(EnumType.STRING)
@Column(name = "certificate_type", nullable = false, length = 50)
private CertificateType certificateType;
@Column(name = "subject_dn", length = 500)
private String subjectDn;
@Column(name = "issuer_dn", length = 500)
private String issuerDn;
@Column(name = "valid_from")
private LocalDateTime validFrom;
@Column(name = "valid_to")
private LocalDateTime validTo;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
}
@@ -0,0 +1,141 @@
package stirling.software.proprietary.workflow.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.CollectionTable;
import jakarta.persistence.Column;
import jakarta.persistence.ElementCollection;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.ShareAccessRole;
/**
* Represents a participant in a workflow session. Replaces SigningParticipantEntity with broader
* workflow support.
*
* <p>Integrates with FileShare for access control - each participant gets a FileShare entry linked
* to this participant record for unified access control.
*/
@Entity
@Table(
name = "workflow_participants",
indexes = {
@Index(name = "idx_workflow_participants_session", columnList = "workflow_session_id"),
@Index(name = "idx_workflow_participants_token", columnList = "share_token"),
@Index(name = "idx_workflow_participants_user", columnList = "user_id")
})
@NoArgsConstructor
@Getter
@Setter
public class WorkflowParticipant implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "workflow_session_id", nullable = false)
private WorkflowSession workflowSession;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@Column(name = "email")
private String email;
@Column(name = "name")
private String name;
// Workflow progress tracking
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 20)
private ParticipantStatus status = ParticipantStatus.PENDING;
// Access control (unified with FileShare)
@Column(name = "share_token", unique = true, length = 36)
private String shareToken;
@Enumerated(EnumType.STRING)
@Column(name = "access_role", nullable = false, length = 20)
private ShareAccessRole accessRole;
@Column(name = "expires_at")
private LocalDateTime expiresAt;
// Workflow-specific data stored as JSON for flexibility
// For signing: wet signature coordinates, signature appearance settings
// For review: assigned review sections, comment preferences
// For approval: decision criteria, approval authority level
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
@Column(name = "participant_metadata", columnDefinition = "jsonb")
private Map<String, Object> participantMetadata = new HashMap<>();
// Notification history
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(
name = "participant_notifications",
joinColumns = @JoinColumn(name = "participant_id"))
@Column(name = "notification_message", columnDefinition = "text")
private List<String> notifications = new ArrayList<>();
@UpdateTimestamp
@Column(name = "last_updated")
private LocalDateTime lastUpdated;
// Helper methods
public void addNotification(String message) {
notifications.add(message);
}
public boolean isExpired() {
return expiresAt != null && LocalDateTime.now().isAfter(expiresAt);
}
public boolean hasCompleted() {
return status == ParticipantStatus.SIGNED || status == ParticipantStatus.DECLINED;
}
/**
* Determines the effective access role based on participant status. After completion
* (signed/declined), downgrade to VIEWER.
*/
public ShareAccessRole getEffectiveRole() {
if (hasCompleted()) {
return ShareAccessRole.VIEWER;
}
return accessRole;
}
public boolean canEdit() {
return !hasCompleted()
&& !isExpired()
&& (accessRole == ShareAccessRole.EDITOR
|| accessRole == ShareAccessRole.COMMENTER);
}
}
@@ -0,0 +1,143 @@
package stirling.software.proprietary.workflow.model;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.StoredFile;
/**
* Represents a workflow session for multi-participant document processing. Replaces
* SigningSessionEntity with a more generic workflow abstraction that supports signing, review,
* approval, and other collaborative workflows.
*
* <p>This entity coordinates the workflow lifecycle and links to StoredFile for actual document
* storage (no more direct BLOBs).
*/
@Entity
@Table(
name = "workflow_sessions",
indexes = {
@Index(name = "idx_workflow_sessions_owner", columnList = "owner_id"),
@Index(name = "idx_workflow_sessions_session_id", columnList = "session_id")
})
@NoArgsConstructor
@Getter
@Setter
public class WorkflowSession implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "session_id", unique = true, nullable = false, length = 36)
private String sessionId = UUID.randomUUID().toString();
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id", nullable = false)
private User owner;
@Column(name = "workflow_type", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private WorkflowType workflowType;
@Column(name = "document_name", nullable = false)
private String documentName;
// Replaces BLOB storage with StoredFile reference
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "original_file_id", nullable = false)
private StoredFile originalFile;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "processed_file_id")
private StoredFile processedFile;
@Column(name = "owner_email")
private String ownerEmail;
@Column(name = "message", columnDefinition = "text")
private String message;
@Column(name = "due_date", length = 50)
private String dueDate;
@Column(name = "status", nullable = false, length = 20)
@Enumerated(EnumType.STRING)
private WorkflowStatus status = WorkflowStatus.IN_PROGRESS;
@Column(name = "finalized", nullable = false)
private boolean finalized = false;
@OneToMany(
mappedBy = "workflowSession",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY)
private List<WorkflowParticipant> participants = new ArrayList<>();
// Workflow-specific settings stored as JSON for flexibility
// For signing: signature appearance settings, wet signature metadata
// For review: review guidelines, comment templates
// For approval: approval criteria, decision options
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
@Column(name = "workflow_metadata", columnDefinition = "jsonb")
private Map<String, Object> workflowMetadata = new HashMap<>();
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at")
private LocalDateTime updatedAt;
// Helper methods
public void addParticipant(WorkflowParticipant participant) {
participants.add(participant);
participant.setWorkflowSession(this);
}
public void removeParticipant(WorkflowParticipant participant) {
participants.remove(participant);
participant.setWorkflowSession(null);
}
public boolean isActive() {
return status == WorkflowStatus.IN_PROGRESS && !finalized;
}
public boolean hasProcessedFile() {
return processedFile != null;
}
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.workflow.model;
/**
* Defines the overall status of a workflow session. Tracks the lifecycle from creation through
* completion or cancellation.
*/
public enum WorkflowStatus {
/** Workflow is active and awaiting participant actions */
IN_PROGRESS,
/** Workflow has been successfully completed by all participants */
COMPLETED,
/** Workflow has been cancelled by the owner or system */
CANCELLED
}
@@ -0,0 +1,16 @@
package stirling.software.proprietary.workflow.model;
/**
* Defines the type of workflow being executed. Determines the business logic and lifecycle for the
* workflow session.
*/
public enum WorkflowType {
/** Document signing workflow - participants sign a PDF with digital certificates */
SIGNING,
/** Document review workflow - participants review and comment on a document */
REVIEW,
/** Document approval workflow - participants approve or reject a document */
APPROVAL
}
@@ -0,0 +1,23 @@
package stirling.software.proprietary.workflow.repository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import stirling.software.proprietary.workflow.model.UserServerCertificateEntity;
@Repository
public interface UserServerCertificateRepository
extends JpaRepository<UserServerCertificateEntity, Long> {
@Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.id = :userId")
Optional<UserServerCertificateEntity> findByUserId(@Param("userId") Long userId);
@Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.username = :username")
Optional<UserServerCertificateEntity> findByUsername(@Param("username") String username);
boolean existsByUserId(Long userId);
}

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