Compare commits

...
28 Commits
Author SHA1 Message Date
Anthony Stirling b5e950bc12 remove gosu, mad merge 2026-04-01 14:38:55 +01:00
Anthony Stirling a3d5141a04 version bump 2026-04-01 14:29:57 +01:00
Anthony StirlingandClaude Opus 4.6 76e0611a1e Merge origin/main into useLatestFFMPEG
Resolve conflict in docker/base/Dockerfile: keep custom ffmpeg-build
stage approach (build from source + runtime shared libs) instead of
packaged ffmpeg from main.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 12:00:24 +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
Anthony Stirling a6448e8732 update ffmpeg 2026-03-31 09:43:59 +01: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
514 changed files with 37281 additions and 32344 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
+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
+119
View File
@@ -0,0 +1,119 @@
name: Push Docker Base Image
on:
push:
branches:
- baseDockerImage
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 }}"
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
+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.
@@ -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;
}
@@ -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,17 @@ 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 +174,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) {
@@ -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) {
@@ -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)
@@ -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

+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
@@ -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,13 @@ 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;
}
@@ -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,
@@ -200,19 +220,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);
}
@@ -0,0 +1,75 @@
package stirling.software.proprietary.workflow.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.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
@Repository
public interface WorkflowParticipantRepository extends JpaRepository<WorkflowParticipant, Long> {
/** Find participant by share token */
Optional<WorkflowParticipant> findByShareToken(String shareToken);
/** Find all participants in a workflow session */
List<WorkflowParticipant> findByWorkflowSession(WorkflowSession session);
/** Find participant by session and user */
Optional<WorkflowParticipant> findByWorkflowSessionAndUser(WorkflowSession session, User user);
/** Find participant by session and email */
Optional<WorkflowParticipant> findByWorkflowSessionAndEmail(
WorkflowSession session, String email);
/** Find all participants with a specific status in a session */
List<WorkflowParticipant> findByWorkflowSessionAndStatus(
WorkflowSession session, ParticipantStatus status);
/** Find all sessions where a user is a participant */
List<WorkflowParticipant> findByUserOrderByLastUpdatedDesc(User user);
/** Find all sessions where an email is a participant */
List<WorkflowParticipant> findByEmailOrderByLastUpdatedDesc(String email);
/** Check if a participant exists by share token */
boolean existsByShareToken(String shareToken);
/** Count participants in a session by status */
long countByWorkflowSessionAndStatus(WorkflowSession session, ParticipantStatus status);
/** Find expired participants that haven't completed */
@Query(
"SELECT p FROM WorkflowParticipant p WHERE p.expiresAt < CURRENT_TIMESTAMP AND p.status NOT IN ('SIGNED', 'DECLINED')")
List<WorkflowParticipant> findExpiredIncompleteParticipants();
/** Find all participants pending notification */
@Query(
"SELECT p FROM WorkflowParticipant p WHERE p.status = 'PENDING' AND p.workflowSession.status = 'IN_PROGRESS'")
List<WorkflowParticipant> findPendingNotifications();
/** Delete participant by ID and session owner (for authorization) */
@Query(
"DELETE FROM WorkflowParticipant p WHERE p.id = :participantId AND p.workflowSession.owner = :owner")
void deleteByIdAndSessionOwner(
@Param("participantId") Long participantId, @Param("owner") User owner);
/**
* Null out the user reference for all participants linked to the given user. Used during user
* deletion to preserve workflow audit history while removing the personal data link.
* Participants in sessions owned by others are retained but de-linked from the deleted account.
*/
@Modifying
@Transactional
@Query("UPDATE WorkflowParticipant wp SET wp.user = null WHERE wp.user = :user")
void clearUserReferences(@Param("user") User user);
}
@@ -0,0 +1,61 @@
package stirling.software.proprietary.workflow.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 org.springframework.stereotype.Repository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.WorkflowSession;
import stirling.software.proprietary.workflow.model.WorkflowStatus;
import stirling.software.proprietary.workflow.model.WorkflowType;
@Repository
public interface WorkflowSessionRepository extends JpaRepository<WorkflowSession, Long> {
/** Find workflow session by unique session ID */
Optional<WorkflowSession> findBySessionId(String sessionId);
/** Find workflow session by unique session ID with participants eagerly loaded */
@Query(
"SELECT ws FROM WorkflowSession ws LEFT JOIN FETCH ws.participants WHERE ws.sessionId = :sessionId")
Optional<WorkflowSession> findBySessionIdWithParticipants(@Param("sessionId") String sessionId);
/** Find all workflow sessions owned by a specific user */
List<WorkflowSession> findByOwnerOrderByCreatedAtDesc(User owner);
/** Find all workflow sessions of a specific type for a user */
List<WorkflowSession> findByOwnerAndWorkflowTypeOrderByCreatedAtDesc(
User owner, WorkflowType workflowType);
/** Find all workflow sessions with a specific status */
List<WorkflowSession> findByStatusOrderByCreatedAtDesc(WorkflowStatus status);
/** Find all active (non-finalized, in-progress) sessions for a user */
@Query(
"SELECT ws FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false ORDER BY ws.createdAt DESC")
List<WorkflowSession> findActiveSessionsByOwner(@Param("owner") User owner);
/** Find all finalized sessions for a user */
List<WorkflowSession> findByOwnerAndFinalizedTrueOrderByCreatedAtDesc(User owner);
/** Check if a session exists by session ID */
boolean existsBySessionId(String sessionId);
/** Find sessions that need cleanup (e.g., old cancelled sessions) */
@Query(
"SELECT ws FROM WorkflowSession ws WHERE ws.status = 'CANCELLED' AND ws.updatedAt < :cutoffDate")
List<WorkflowSession> findCancelledSessionsOlderThan(
@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
/** Count active sessions for a user */
@Query(
"SELECT COUNT(ws) FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false")
long countActiveSessionsByOwner(@Param("owner") User owner);
/** Delete session by session ID and owner (for authorization) */
void deleteBySessionIdAndOwner(String sessionId, User owner);
}
@@ -0,0 +1,213 @@
package stirling.software.proprietary.workflow.service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.PrivateKey;
import java.security.UnrecoverableKeyException;
import java.security.cert.Certificate;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateNotYetValidException;
import java.security.cert.X509Certificate;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Enumeration;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.service.PdfSigningService;
import stirling.software.proprietary.workflow.dto.CertificateInfo;
/**
* Validates a certificate submission before it is stored in participant metadata. Catches issues
* (wrong password, expired cert, algorithm incompatibility) at signing time rather than days later
* at finalization.
*
* <p>The core check is a test-sign of a minimal blank PDF using the exact same {@link
* PdfSigningService} code path used at finalization, so any failure that would block finalization
* is caught here first.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class CertificateSubmissionValidator {
private static final DateTimeFormatter DATE_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss z").withZone(ZoneId.systemDefault());
private final PdfSigningService pdfSigningService;
/**
* Validates a certificate submission end-to-end by:
*
* <ol>
* <li>Loading the keystore with the provided password
* <li>Checking certificate validity (expiry, not-yet-valid)
* <li>Test-signing a blank PDF to confirm the key and certificate are fully functional
* </ol>
*
* @param keystoreBytes raw bytes of the keystore file
* @param certType "P12", "PKCS12", "PFX", or "JKS" (case-insensitive)
* @param password keystore password (may be null or empty)
* @return {@link CertificateInfo} with subject, issuer, and validity dates on success
* @throws ResponseStatusException HTTP 400 with a user-friendly message on any failure
*/
public CertificateInfo validateAndExtractInfo(
byte[] keystoreBytes, String certType, String password) {
if (certType == null
|| "SERVER".equalsIgnoreCase(certType)
|| "USER_CERT".equalsIgnoreCase(certType)) {
// Server-managed or pre-configured user certificate — no file uploaded, nothing to
// validate
return null;
}
char[] passwordChars = password != null ? password.toCharArray() : new char[0];
KeyStore keystore = loadKeyStore(keystoreBytes, certType, passwordChars);
X509Certificate cert = extractSigningCert(keystore, passwordChars);
validateCertValidity(cert);
String subjectName = extractCN(cert.getSubjectX500Principal().getName());
String issuerName = extractCN(cert.getIssuerX500Principal().getName());
boolean selfSigned = cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal());
testSign(keystore, passwordChars, subjectName);
return new CertificateInfo(
subjectName, issuerName, cert.getNotBefore(), cert.getNotAfter(), selfSigned);
}
// ---- private helpers ----
private KeyStore loadKeyStore(byte[] bytes, String certType, char[] password) {
String keystoreType = resolveKeystoreType(certType);
try {
KeyStore ks = KeyStore.getInstance(keystoreType);
ks.load(new java.io.ByteArrayInputStream(bytes), password);
return ks;
} catch (IOException e) {
// PKCS12: wrong password produces an IOException with "keystore password was incorrect"
// JKS: wrong password produces IOException wrapping UnrecoverableKeyException
log.debug("Failed to load {} keystore: {}", keystoreType, e.getMessage());
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid certificate password or corrupt keystore file");
} catch (Exception e) {
log.debug("Failed to instantiate {} keystore: {}", keystoreType, e.getMessage());
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid certificate password or corrupt keystore file");
}
}
private X509Certificate extractSigningCert(KeyStore keystore, char[] password) {
try {
Enumeration<String> aliases = keystore.aliases();
while (aliases.hasMoreElements()) {
String alias = aliases.nextElement();
PrivateKey key = null;
try {
key = (PrivateKey) keystore.getKey(alias, password);
} catch (UnrecoverableKeyException | java.security.NoSuchAlgorithmException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid certificate password or corrupt keystore file");
}
if (key == null) continue;
Certificate[] chain = keystore.getCertificateChain(alias);
if (chain != null && chain.length > 0 && chain[0] instanceof X509Certificate) {
return (X509Certificate) chain[0];
}
}
} catch (ResponseStatusException e) {
throw e;
} catch (KeyStoreException e) {
log.debug("KeyStore alias enumeration failed: {}", e.getMessage());
}
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "No private key found in the provided keystore");
}
private void validateCertValidity(X509Certificate cert) {
try {
cert.checkValidity();
} catch (CertificateExpiredException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Certificate has expired (expired: "
+ DATE_FORMAT.format(cert.getNotAfter().toInstant())
+ ")");
} catch (CertificateNotYetValidException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Certificate is not yet valid (valid from: "
+ DATE_FORMAT.format(cert.getNotBefore().toInstant())
+ ")");
}
}
private void testSign(KeyStore keystore, char[] password, String signerName) {
try {
byte[] blankPdf = createBlankPdf();
pdfSigningService.signWithKeystore(
blankPdf, keystore, password, false, null, signerName, null, null, false);
} catch (ResponseStatusException e) {
throw e;
} catch (Exception e) {
log.debug("Certificate test-sign failed: {}", e.getMessage());
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Certificate is not compatible with the signing algorithm: " + e.getMessage());
}
}
/** Creates a minimal valid 1-page blank PDF for use in test-signing. */
private byte[] createBlankPdf() throws IOException {
try (PDDocument doc = new PDDocument();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
doc.addPage(new PDPage());
doc.save(out);
return out.toByteArray();
}
}
/**
* Maps the user-facing certType string to a JCA KeyStore type string.
*
* <p>All PKCS12 variants ("P12", "PKCS12", "PFX") map to {@code "PKCS12"}. {@code "JKS"} maps
* to {@code "JKS"}.
*/
private String resolveKeystoreType(String certType) {
if (certType == null) return "PKCS12";
return switch (certType.toUpperCase()) {
case "JKS" -> "JKS";
default -> "PKCS12"; // P12, PKCS12, PFX
};
}
/**
* Extracts the CN value from an X.500 distinguished name string. Falls back to the full DN if
* no CN attribute is present.
*/
private String extractCN(String dn) {
if (dn == null) return "";
for (String part : dn.split(",")) {
String trimmed = part.trim();
if (trimmed.toUpperCase().startsWith("CN=")) {
return trimmed.substring(3);
}
}
return dn;
}
}
@@ -0,0 +1,119 @@
package stirling.software.proprietary.workflow.service;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Arrays;
import java.util.Base64;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
/**
* Provides AES-256-GCM encryption for sensitive fields stored in JSONB metadata columns (e.g.
* keystore passwords). The encryption key is derived from the application's
* AutomaticallyGenerated.key, which is persisted in settings on first run.
*
* <p>Encrypted values are prefixed with {@value #ENC_PREFIX} so that legacy plaintext values
* written before this service was introduced can still be decrypted transparently.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MetadataEncryptionService {
static final String ENC_PREFIX = "enc:";
private static final String ALGORITHM = "AES/GCM/NoPadding";
private static final int GCM_IV_LENGTH = 12;
private static final int GCM_TAG_LENGTH = 128; // bits
private final ApplicationProperties applicationProperties;
// ── Public API ──────────────────────────────────────────────────────────
/**
* Encrypts {@code plaintext} with AES-256-GCM and returns a Base64-encoded ciphertext prefixed
* with {@value #ENC_PREFIX}.
*/
public String encrypt(String plaintext) {
if (plaintext == null) {
return null;
}
try {
SecretKeySpec keySpec = deriveKey();
byte[] iv = generateIv();
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
byte[] cipherBytes = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
// Prepend IV to ciphertext for storage: [12-byte IV][ciphertext+tag]
byte[] combined = new byte[iv.length + cipherBytes.length];
System.arraycopy(iv, 0, combined, 0, iv.length);
System.arraycopy(cipherBytes, 0, combined, iv.length, cipherBytes.length);
return ENC_PREFIX + Base64.getEncoder().encodeToString(combined);
} catch (Exception e) {
throw new IllegalStateException("Failed to encrypt metadata field", e);
}
}
/**
* Decrypts a value produced by {@link #encrypt}. If the value does not start with {@value
* #ENC_PREFIX} it is returned as-is to preserve backwards compatibility with plaintext values
* written before this service existed.
*/
public String decrypt(String value) {
if (value == null) {
return null;
}
if (!value.startsWith(ENC_PREFIX)) {
// Legacy plaintext return unchanged
return value;
}
try {
SecretKeySpec keySpec = deriveKey();
byte[] combined = Base64.getDecoder().decode(value.substring(ENC_PREFIX.length()));
byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH);
byte[] cipherBytes = Arrays.copyOfRange(combined, GCM_IV_LENGTH, combined.length);
Cipher cipher = Cipher.getInstance(ALGORITHM);
cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
return new String(cipher.doFinal(cipherBytes), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new IllegalStateException("Failed to decrypt metadata field", e);
}
}
// ── Internals ───────────────────────────────────────────────────────────
private SecretKeySpec deriveKey() throws Exception {
String rawKey = applicationProperties.getAutomaticallyGenerated().getKey();
if (rawKey == null || rawKey.isBlank()) {
throw new IllegalStateException(
"AutomaticallyGenerated.key is not initialised — cannot derive encryption key");
}
// SHA-256 of the raw key gives a stable 32-byte AES-256 key
byte[] hash =
MessageDigest.getInstance("SHA-256")
.digest(rawKey.getBytes(StandardCharsets.UTF_8));
return new SecretKeySpec(hash, "AES");
}
private static byte[] generateIv() {
byte[] iv = new byte[GCM_IV_LENGTH];
new SecureRandom().nextBytes(iv);
return iv;
}
}
@@ -0,0 +1,233 @@
package stirling.software.proprietary.workflow.service;
import java.time.LocalDateTime;
import java.util.Optional;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FileShare;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.repository.FileShareRepository;
import stirling.software.proprietary.workflow.model.ParticipantStatus;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
/**
* Unified access control service that consolidates validation logic for both generic file shares
* and workflow participants.
*
* <p>This service bridges the gap between the file sharing infrastructure and workflow-specific
* access control.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class UnifiedAccessControlService {
private final FileShareRepository fileShareRepository;
private final WorkflowParticipantRepository workflowParticipantRepository;
/**
* Validates a share token and returns access validation result. Works for both generic file
* shares and workflow participant shares.
*/
public AccessValidationResult validateToken(String token, User user) {
log.debug("Validating access token: {}", token);
// First try as file share token
Optional<FileShare> fileShareOpt = fileShareRepository.findByShareTokenWithFile(token);
if (fileShareOpt.isPresent()) {
return validateGenericShare(fileShareOpt.get(), user);
}
// Try as workflow participant token
Optional<WorkflowParticipant> participantOpt =
workflowParticipantRepository.findByShareToken(token);
if (participantOpt.isPresent()) {
return validateParticipant(participantOpt.get(), user);
}
log.warn("Invalid or expired token: {}", token);
return AccessValidationResult.denied("Invalid or expired access token");
}
/** Validates a generic file share (non-workflow) */
private AccessValidationResult validateGenericShare(FileShare share, User user) {
// Check expiration
if (share.getExpiresAt() != null && LocalDateTime.now().isAfter(share.getExpiresAt())) {
log.warn("Share token expired: {}", share.getShareToken());
return AccessValidationResult.denied("Access link has expired");
}
// Check if user matches (if share is user-specific)
if (share.getSharedWithUser() != null && !share.getSharedWithUser().equals(user)) {
log.warn(
"User mismatch for share: expected {}, got {}",
share.getSharedWithUser().getId(),
user != null ? user.getId() : "null");
return AccessValidationResult.denied("Access denied for this user");
}
return AccessValidationResult.allowed(share.getFile(), share.getAccessRole(), null, false);
}
/** Validates a workflow participant by token */
private AccessValidationResult validateParticipant(WorkflowParticipant participant, User user) {
// Check expiration
if (participant.isExpired()) {
log.warn("Workflow participant access expired: {}", participant.getShareToken());
return AccessValidationResult.denied("Workflow access has expired");
}
// Check if workflow is still active
if (!participant.getWorkflowSession().isActive()) {
log.info(
"Workflow session no longer active: {}",
participant.getWorkflowSession().getSessionId());
return AccessValidationResult.denied("Workflow session is no longer active");
}
// Check user authorization
if (participant.getUser() != null && !participant.getUser().equals(user)) {
log.warn(
"User mismatch for participant: expected {}, got {}",
participant.getUser().getId(),
user != null ? user.getId() : "null");
return AccessValidationResult.denied("Access denied for this user");
}
// Get effective role based on participant status
ShareAccessRole effectiveRole = getEffectiveRole(participant);
// Get the file from the workflow session
StoredFile file = participant.getWorkflowSession().getOriginalFile();
return AccessValidationResult.allowed(file, effectiveRole, participant, true);
}
/**
* Maps participant status to effective access role. After completion (signed/declined),
* downgrade to VIEWER.
*/
public ShareAccessRole getEffectiveRole(WorkflowParticipant participant) {
ParticipantStatus status = participant.getStatus();
switch (status) {
case SIGNED:
case DECLINED:
// After action completed, downgrade to read-only
return ShareAccessRole.VIEWER;
case PENDING:
case NOTIFIED:
case VIEWED:
// Active participants retain their assigned role
return participant.getAccessRole();
default:
log.warn("Unknown participant status: {}", status);
return ShareAccessRole.VIEWER;
}
}
/** Checks if a user can access a specific file */
public boolean canAccessFile(User user, StoredFile file) {
// Owner always has access
if (file.getOwner().equals(user)) {
return true;
}
// Check for file share
Optional<FileShare> share = fileShareRepository.findByFileAndSharedWithUser(file, user);
if (share.isPresent() && !isExpired(share.get())) {
return true;
}
// Check for workflow participant access
if (file.getWorkflowSession() != null) {
Optional<WorkflowParticipant> participant =
workflowParticipantRepository.findByWorkflowSessionAndUser(
file.getWorkflowSession(), user);
return participant.isPresent()
&& !participant.get().isExpired()
&& participant.get().getWorkflowSession().isActive();
}
return false;
}
private boolean isExpired(FileShare share) {
return share.getExpiresAt() != null && LocalDateTime.now().isAfter(share.getExpiresAt());
}
/** Result of access validation */
public static class AccessValidationResult {
private final boolean allowed;
private final String denialReason;
private final StoredFile file;
private final ShareAccessRole role;
private final WorkflowParticipant participant;
private final boolean isWorkflowAccess;
private AccessValidationResult(
boolean allowed,
String denialReason,
StoredFile file,
ShareAccessRole role,
WorkflowParticipant participant,
boolean isWorkflowAccess) {
this.allowed = allowed;
this.denialReason = denialReason;
this.file = file;
this.role = role;
this.participant = participant;
this.isWorkflowAccess = isWorkflowAccess;
}
public static AccessValidationResult allowed(
StoredFile file,
ShareAccessRole role,
WorkflowParticipant participant,
boolean isWorkflowAccess) {
return new AccessValidationResult(
true, null, file, role, participant, isWorkflowAccess);
}
public static AccessValidationResult denied(String reason) {
return new AccessValidationResult(false, reason, null, null, null, false);
}
public boolean isAllowed() {
return allowed;
}
public String getDenialReason() {
return denialReason;
}
public StoredFile getFile() {
return file;
}
public ShareAccessRole getRole() {
return role;
}
public WorkflowParticipant getParticipant() {
return participant;
}
public boolean isWorkflowAccess() {
return isWorkflowAccess;
}
public boolean canEdit() {
return allowed && (role == ShareAccessRole.EDITOR || role == ShareAccessRole.COMMENTER);
}
}
}
@@ -0,0 +1,258 @@
package stirling.software.proprietary.workflow.service;
import java.io.*;
import java.math.BigInteger;
import java.security.*;
import java.security.cert.Certificate;
import java.security.cert.X509Certificate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
import java.util.Optional;
import org.bouncycastle.asn1.x500.X500Name;
import org.bouncycastle.asn1.x509.BasicConstraints;
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
import org.bouncycastle.asn1.x509.Extension;
import org.bouncycastle.asn1.x509.KeyPurposeId;
import org.bouncycastle.asn1.x509.KeyUsage;
import org.bouncycastle.cert.X509CertificateHolder;
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.operator.ContentSigner;
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.workflow.model.CertificateType;
import stirling.software.proprietary.workflow.model.UserServerCertificateEntity;
import stirling.software.proprietary.workflow.repository.UserServerCertificateRepository;
@Service
@Slf4j
@RequiredArgsConstructor
public class UserServerCertificateService {
private static final String KEYSTORE_ALIAS = "stirling-pdf-user-cert";
private static final String DEFAULT_PASSWORD_PREFIX = "stirling-user-cert-";
private static final int VALIDITY_DAYS = 365;
private final UserServerCertificateRepository certificateRepository;
private final UserRepository userRepository;
private final MetadataEncryptionService metadataEncryptionService;
static {
Security.addProvider(new BouncyCastleProvider());
}
/** Get or create user certificate (auto-generate if not exists) */
@Transactional
public UserServerCertificateEntity getOrCreateUserCertificate(Long userId) throws Exception {
Optional<UserServerCertificateEntity> existing = certificateRepository.findByUserId(userId);
if (existing.isPresent()) {
return existing.get();
}
User user =
userRepository
.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
return generateUserCertificate(user);
}
/** Generate new certificate for user */
@Transactional
public UserServerCertificateEntity generateUserCertificate(User user) throws Exception {
log.info("Generating server certificate for user: {}", user.getUsername());
// Generate key pair
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
keyPairGenerator.initialize(2048, new SecureRandom());
KeyPair keyPair = keyPairGenerator.generateKeyPair();
// Certificate details with username
String username = user.getUsername();
X500Name subject = new X500Name("CN=" + username + ", O=Stirling-PDF User, C=US");
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
Date notBefore = new Date();
Date notAfter =
new Date(notBefore.getTime() + ((long) VALIDITY_DAYS * 24 * 60 * 60 * 1000));
// Build certificate
JcaX509v3CertificateBuilder certBuilder =
new JcaX509v3CertificateBuilder(
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
// Add PDF-specific certificate extensions
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
// End-entity certificate, not a CA
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
// Key usage for PDF digital signatures
certBuilder.addExtension(
Extension.keyUsage,
true,
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
// Extended key usage for document signing
certBuilder.addExtension(
Extension.extendedKeyUsage,
false,
new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning));
// Subject Key Identifier
certBuilder.addExtension(
Extension.subjectKeyIdentifier,
false,
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
// Authority Key Identifier for self-signed cert
certBuilder.addExtension(
Extension.authorityKeyIdentifier,
false,
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
// Sign certificate
ContentSigner signer =
new JcaContentSignerBuilder("SHA256WithRSA")
.setProvider("BC")
.build(keyPair.getPrivate());
X509CertificateHolder certHolder = certBuilder.build(signer);
X509Certificate cert =
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
// Create keystore
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(null, null);
String password = generateUserPassword(user.getId());
keyStore.setKeyEntry(
KEYSTORE_ALIAS,
keyPair.getPrivate(),
password.toCharArray(),
new Certificate[] {cert});
// Store keystore bytes
ByteArrayOutputStream baos = new ByteArrayOutputStream();
keyStore.store(baos, password.toCharArray());
byte[] keystoreBytes = baos.toByteArray();
// Create entity
UserServerCertificateEntity entity = new UserServerCertificateEntity();
entity.setUser(user);
entity.setKeystoreData(keystoreBytes);
entity.setKeystorePassword(metadataEncryptionService.encrypt(password));
entity.setCertificateType(CertificateType.AUTO_GENERATED);
entity.setSubjectDn(cert.getSubjectX500Principal().getName());
entity.setIssuerDn(cert.getIssuerX500Principal().getName());
entity.setValidFrom(
LocalDateTime.ofInstant(cert.getNotBefore().toInstant(), ZoneId.systemDefault()));
entity.setValidTo(
LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault()));
return certificateRepository.save(entity);
}
/** Upload user-provided certificate */
@Transactional
public UserServerCertificateEntity uploadUserCertificate(
User user, InputStream p12Stream, String password) throws Exception {
log.info("Uploading user certificate for user: {}", user.getUsername());
// Validate keystore
byte[] keystoreBytes = p12Stream.readNBytes(10 * 1024 * 1024 + 1); // read at most 10 MB + 1
if (keystoreBytes.length > 10 * 1024 * 1024) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Keystore file exceeds maximum allowed size of 10 MB");
}
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(new ByteArrayInputStream(keystoreBytes), password.toCharArray());
// Extract certificate info
String alias = keyStore.aliases().nextElement();
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
if (cert == null) {
throw new IllegalArgumentException("No certificate found in keystore");
}
// Create or update entity
UserServerCertificateEntity entity =
certificateRepository
.findByUserId(user.getId())
.orElse(new UserServerCertificateEntity());
entity.setUser(user);
entity.setKeystoreData(keystoreBytes);
entity.setKeystorePassword(metadataEncryptionService.encrypt(password));
entity.setCertificateType(CertificateType.USER_UPLOADED);
entity.setSubjectDn(cert.getSubjectX500Principal().getName());
entity.setIssuerDn(cert.getIssuerX500Principal().getName());
entity.setValidFrom(
LocalDateTime.ofInstant(cert.getNotBefore().toInstant(), ZoneId.systemDefault()));
entity.setValidTo(
LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault()));
return certificateRepository.save(entity);
}
/** Get user's KeyStore for signing operations */
@Transactional(readOnly = true)
public KeyStore getUserKeyStore(Long userId) throws Exception {
UserServerCertificateEntity cert =
certificateRepository
.findByUserId(userId)
.orElseThrow(
() -> new IllegalArgumentException("User certificate not found"));
KeyStore keyStore = KeyStore.getInstance("PKCS12");
keyStore.load(
new ByteArrayInputStream(cert.getKeystoreData()),
metadataEncryptionService.decrypt(cert.getKeystorePassword()).toCharArray());
return keyStore;
}
/** Get user's keystore password */
@Transactional(readOnly = true)
public String getUserKeystorePassword(Long userId) {
UserServerCertificateEntity cert =
certificateRepository
.findByUserId(userId)
.orElseThrow(
() -> new IllegalArgumentException("User certificate not found"));
return metadataEncryptionService.decrypt(cert.getKeystorePassword());
}
/** Delete user certificate */
@Transactional
public void deleteUserCertificate(Long userId) {
certificateRepository.findByUserId(userId).ifPresent(certificateRepository::delete);
}
/** Check if user has certificate */
@Transactional(readOnly = true)
public boolean hasUserCertificate(Long userId) {
return certificateRepository.findByUserId(userId).isPresent();
}
/** Get certificate info (without keystore data) */
@Transactional(readOnly = true)
public Optional<UserServerCertificateEntity> getCertificateInfo(Long userId) {
return certificateRepository.findByUserId(userId);
}
/** Generate consistent password for user (based on user ID) */
private String generateUserPassword(Long userId) {
return DEFAULT_PASSWORD_PREFIX + userId;
}
}
@@ -0,0 +1,890 @@
package stirling.software.proprietary.workflow.service;
import java.io.IOException;
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.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.database.repository.UserRepository;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.storage.model.FilePurpose;
import stirling.software.proprietary.storage.model.ShareAccessRole;
import stirling.software.proprietary.storage.model.StoredFile;
import stirling.software.proprietary.storage.provider.StorageProvider;
import stirling.software.proprietary.storage.provider.StoredObject;
import stirling.software.proprietary.storage.repository.StoredFileRepository;
import stirling.software.proprietary.workflow.dto.ParticipantRequest;
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
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.model.WorkflowStatus;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import tools.jackson.core.JacksonException;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
/**
* Core service for workflow session management. Handles creation, participant management, and
* lifecycle coordination.
*
* <p>Delegates file storage to FileStorageService/StorageProvider and integrates with the file
* sharing infrastructure.
*/
@Service
@RequiredArgsConstructor
@Slf4j
@Transactional
public class WorkflowSessionService {
private final WorkflowSessionRepository workflowSessionRepository;
private final WorkflowParticipantRepository workflowParticipantRepository;
private final StoredFileRepository storedFileRepository;
private final UserRepository userRepository;
private final StorageProvider storageProvider;
private final ObjectMapper objectMapper;
private final ApplicationProperties applicationProperties;
private final MetadataEncryptionService metadataEncryptionService;
private final CertificateSubmissionValidator certificateSubmissionValidator;
public void ensureSigningEnabled() {
if (!applicationProperties.getStorage().isEnabled()
|| !applicationProperties.getStorage().getSigning().isEnabled()) {
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Group signing is disabled");
}
}
/**
* Creates a new workflow session with participants. Stores the original file using
* StorageProvider.
*/
public WorkflowSession createSession(
User owner, MultipartFile file, WorkflowCreationRequest request) throws IOException {
log.info(
"Creating workflow session for user {} with type {}",
owner.getUsername(),
request.getWorkflowType());
// Validate request
if (file == null || file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File is required");
}
if (request.getWorkflowType() == null) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Workflow type is required");
}
// Store original file using StorageProvider
StoredFile originalFile = storeWorkflowFile(owner, file, FilePurpose.SIGNING_ORIGINAL);
// Create workflow session
WorkflowSession session = new WorkflowSession();
session.setSessionId(UUID.randomUUID().toString());
session.setOwner(owner);
session.setWorkflowType(request.getWorkflowType());
session.setDocumentName(
request.getDocumentName() != null
? request.getDocumentName()
: file.getOriginalFilename());
session.setOriginalFile(originalFile);
session.setOwnerEmail(request.getOwnerEmail());
session.setMessage(request.getMessage());
session.setDueDate(request.getDueDate());
session.setStatus(WorkflowStatus.IN_PROGRESS);
// Parse workflow metadata from JSON string to Map
if (request.getWorkflowMetadata() != null && !request.getWorkflowMetadata().isBlank()) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> metadataMap =
objectMapper.readValue(request.getWorkflowMetadata(), Map.class);
session.setWorkflowMetadata(metadataMap);
} catch (JacksonException e) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Invalid workflowMetadata: must be a valid JSON object");
}
}
// Link file back to session
originalFile.setWorkflowSession(session);
originalFile.setPurpose(FilePurpose.SIGNING_ORIGINAL);
session = workflowSessionRepository.save(session);
storedFileRepository.save(originalFile);
// Add participants
List<ParticipantRequest> participants = new ArrayList<>();
if (request.getParticipantUserIds() != null) {
for (Long userId : request.getParticipantUserIds()) {
ParticipantRequest pr = new ParticipantRequest();
pr.setUserId(userId);
pr.setAccessRole(ShareAccessRole.EDITOR);
participants.add(pr);
}
}
if (request.getParticipantEmails() != null) {
for (String email : request.getParticipantEmails()) {
ParticipantRequest pr = new ParticipantRequest();
pr.setEmail(email);
pr.setAccessRole(ShareAccessRole.EDITOR);
participants.add(pr);
}
}
if (!participants.isEmpty()) {
addParticipantsToSession(session, participants);
}
log.info(
"Created workflow session {} with {} participants",
session.getSessionId(),
session.getParticipants().size());
return session;
}
/** Adds participants to a workflow session. */
private void addParticipantsToSession(
WorkflowSession session, List<ParticipantRequest> participantRequests) {
for (ParticipantRequest request : participantRequests) {
WorkflowParticipant participant = new WorkflowParticipant();
participant.setShareToken(UUID.randomUUID().toString());
participant.setAccessRole(
request.getAccessRole() != null
? request.getAccessRole()
: ShareAccessRole.EDITOR);
participant.setExpiresAt(request.getExpiresAt());
// Parse participant metadata from JSON string to Map
if (request.getParticipantMetadata() != null
&& !request.getParticipantMetadata().isBlank()) {
try {
@SuppressWarnings("unchecked")
Map<String, Object> metadataMap =
objectMapper.readValue(request.getParticipantMetadata(), Map.class);
participant.setParticipantMetadata(metadataMap);
} catch (JacksonException e) {
log.warn(
"Failed to parse participant metadata for {}, using empty map",
request.getEmail(),
e);
participant.setParticipantMetadata(new HashMap<>());
}
}
// Store defaultReason in participant metadata if provided
if (request.getDefaultReason() != null && !request.getDefaultReason().isBlank()) {
Map<String, Object> metadata = participant.getParticipantMetadata();
if (metadata == null) {
metadata = new HashMap<>();
}
metadata.put("defaultReason", request.getDefaultReason());
participant.setParticipantMetadata(metadata);
log.debug(
"Set default reason for participant {}: {}",
request.getEmail(),
request.getDefaultReason());
}
participant.setStatus(ParticipantStatus.PENDING);
// Set user or email
if (request.getUserId() != null) {
User user =
userRepository
.findById(request.getUserId())
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"User not found: " + request.getUserId()));
participant.setUser(user);
participant.setEmail(user.getUsername()); // User entity uses username, not email
participant.setName(user.getUsername());
} else if (request.getEmail() != null) {
participant.setEmail(request.getEmail());
participant.setName(
request.getName() != null ? request.getName() : request.getEmail());
} else {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant must have either userId or email");
}
session.addParticipant(participant);
participant = workflowParticipantRepository.save(participant);
}
}
/** Stores a file as part of a workflow using the StorageProvider. */
private StoredFile storeWorkflowFile(User owner, MultipartFile file, FilePurpose purpose)
throws IOException {
// Store file content (storage provider generates the key)
StoredObject storedObject = storageProvider.store(owner, file);
// Create StoredFile entity
StoredFile storedFile = new StoredFile();
storedFile.setOwner(owner);
storedFile.setOriginalFilename(storedObject.getOriginalFilename());
storedFile.setContentType(storedObject.getContentType());
storedFile.setSizeBytes(storedObject.getSizeBytes());
storedFile.setStorageKey(storedObject.getStorageKey());
storedFile.setPurpose(purpose);
return storedFileRepository.save(storedFile);
}
/** Retrieves a workflow session by session ID. */
@Transactional(readOnly = true)
public WorkflowSession getSession(String sessionId) {
return workflowSessionRepository
.findBySessionId(sessionId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Workflow session not found: " + sessionId));
}
/** Retrieves a workflow session with authorization check. */
@Transactional(readOnly = true)
public WorkflowSession getSessionForOwner(String sessionId, User owner) {
WorkflowSession session = getSession(sessionId);
if (!session.getOwner().equals(owner)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Not authorized to access this workflow session");
}
return session;
}
/** Retrieves a workflow session with participants eagerly loaded for finalization. */
@Transactional(readOnly = true)
public WorkflowSession getSessionWithParticipants(String sessionId) {
return workflowSessionRepository
.findBySessionIdWithParticipants(sessionId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Workflow session not found: " + sessionId));
}
/** Retrieves a workflow session with participants, with authorization check. */
@Transactional(readOnly = true)
public WorkflowSession getSessionWithParticipantsForOwner(String sessionId, User owner) {
WorkflowSession session = getSessionWithParticipants(sessionId);
if (!session.getOwner().equals(owner)) {
throw new ResponseStatusException(
HttpStatus.FORBIDDEN, "Not authorized to access this workflow session");
}
return session;
}
/** Lists all workflow sessions owned by a user. */
@Transactional(readOnly = true)
public List<WorkflowSession> listUserSessions(User owner) {
return workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner);
}
/** Lists active workflow sessions for a user. */
@Transactional(readOnly = true)
public List<WorkflowSession> listActiveSessions(User owner) {
return workflowSessionRepository.findActiveSessionsByOwner(owner);
}
/** Adds additional participants to an existing session. */
@Transactional
public void addParticipants(
String sessionId, List<ParticipantRequest> participants, User owner) {
WorkflowSession session = getSessionForOwner(sessionId, owner);
if (!session.isActive()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Cannot add participants to inactive workflow");
}
addParticipantsToSession(session, participants);
log.info("Added {} participants to session {}", participants.size(), sessionId);
}
/** Removes a participant from a workflow session. */
@Transactional
public void removeParticipant(String sessionId, Long participantId, User owner) {
WorkflowSession session = getSessionForOwner(sessionId, owner);
WorkflowParticipant participant =
workflowParticipantRepository
.findById(participantId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Participant not found: " + participantId));
if (!participant.getWorkflowSession().equals(session)) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Participant not in this workflow session");
}
session.removeParticipant(participant);
workflowParticipantRepository.delete(participant);
log.info("Removed participant {} from session {}", participantId, sessionId);
}
/** Updates participant status (e.g., NOTIFIED, VIEWED, SIGNED). */
public void updateParticipantStatus(Long participantId, ParticipantStatus newStatus) {
WorkflowParticipant participant =
workflowParticipantRepository
.findById(participantId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Participant not found: " + participantId));
participant.setStatus(newStatus);
workflowParticipantRepository.save(participant);
log.debug("Updated participant {} status to {}", participantId, newStatus);
}
/** Adds a notification message to a participant's history. */
public void addParticipantNotification(Long participantId, String message) {
WorkflowParticipant participant =
workflowParticipantRepository
.findById(participantId)
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Participant not found: " + participantId));
String timestampedMessage = LocalDateTime.now().toString() + ": " + message;
participant.addNotification(timestampedMessage);
workflowParticipantRepository.save(participant);
}
/** Stores the processed/finalized file for a workflow session. */
public void storeProcessedFile(WorkflowSession session, byte[] fileData, String filename)
throws IOException {
log.info("Storing processed file for session {}", session.getSessionId());
// Create a temporary multipart file wrapper
MultipartFile processedFile = new ByteArrayMultipartFile(fileData, filename);
// Store using StorageProvider
StoredFile storedFile =
storeWorkflowFile(session.getOwner(), processedFile, FilePurpose.SIGNING_SIGNED);
// Link to session
storedFile.setWorkflowSession(session);
session.setProcessedFile(storedFile);
storedFileRepository.save(storedFile);
workflowSessionRepository.save(session);
}
/** Marks a workflow session as finalized. */
public void finalizeSession(String sessionId, User owner) {
WorkflowSession session = getSessionForOwner(sessionId, owner);
if (session.isFinalized()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Workflow session already finalized");
}
session.setFinalized(true);
session.setStatus(WorkflowStatus.COMPLETED);
workflowSessionRepository.save(session);
log.info("Finalized workflow session {}", sessionId);
}
/** Retrieves the processed file data for a workflow session. */
@Transactional(readOnly = true)
public byte[] getProcessedFile(String sessionId, User owner) throws IOException {
WorkflowSession session = getSessionForOwner(sessionId, owner);
if (session.getProcessedFile() == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "No processed file available for this session");
}
String storageKey = session.getProcessedFile().getStorageKey();
org.springframework.core.io.Resource resource = storageProvider.load(storageKey);
return resource.getContentAsByteArray();
}
/** Retrieves the original file data for a workflow session. */
@Transactional(readOnly = true)
public byte[] getOriginalFile(String sessionId) throws IOException {
WorkflowSession session = getSession(sessionId);
if (session.getOriginalFile() == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Original file no longer available (session may be finalized)");
}
String storageKey = session.getOriginalFile().getStorageKey();
org.springframework.core.io.Resource resource = storageProvider.load(storageKey);
return resource.getContentAsByteArray();
}
/** Deletes a workflow session and associated files. */
@Transactional
public void deleteSession(String sessionId, User owner) {
WorkflowSession session = getSessionForOwner(sessionId, owner);
if (session.isFinalized()) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Cannot delete a finalized session. The signed PDF remains accessible from your session history.");
}
// Delete physical storage files (non-fatal; may already be absent)
try {
if (session.getOriginalFile() != null) {
storageProvider.delete(session.getOriginalFile().getStorageKey());
}
if (session.getProcessedFile() != null) {
storageProvider.delete(session.getProcessedFile().getStorageKey());
}
} catch (Exception e) {
log.error("Error deleting files for session {}", sessionId, e);
}
// Clear only the StoredFile → WorkflowSession back-reference before deleting.
//
// We do NOT null session.originalFile here because that would emit an UPDATE with
// original_file_id=NULL, violating the NOT NULL constraint. There is no need to — a
// DELETE statement removes the row entirely, so the NOT NULL constraint never triggers.
//
// We DO null StoredFile.workflowSession (workflow_session_id IS nullable) so that
// Hibernate does not see a persistent StoredFile referencing a "removed" WorkflowSession
// during flush, which would throw TransientPropertyValueException.
StoredFile originalFile = session.getOriginalFile();
StoredFile processedFile = session.getProcessedFile();
if (originalFile != null) {
originalFile.setWorkflowSession(null);
storedFileRepository.save(originalFile);
}
if (processedFile != null) {
processedFile.setWorkflowSession(null);
storedFileRepository.save(processedFile);
}
// Delete the session row. Cascades to WorkflowParticipant via orphanRemoval=true.
workflowSessionRepository.delete(session);
// StoredFile rows can now be deleted — the workflow_sessions FK is gone.
if (originalFile != null) storedFileRepository.delete(originalFile);
if (processedFile != null) storedFileRepository.delete(processedFile);
log.info("Deleted workflow session {}", sessionId);
}
/**
* Deletes the original (presigned) file from storage after finalization. The original file is
* no longer needed once the signed document has been stored. Non-fatal: logs errors but does
* not fail finalization.
*/
public void deleteOriginalFile(WorkflowSession session) {
if (session.getOriginalFile() == null) {
return;
}
try {
storageProvider.delete(session.getOriginalFile().getStorageKey());
StoredFile originalFile = session.getOriginalFile();
session.setOriginalFile(null);
workflowSessionRepository.save(session);
storedFileRepository.delete(originalFile);
log.info("Deleted original presigned file for session {}", session.getSessionId());
} catch (Exception e) {
log.error(
"Failed to delete original file for session {}: {}",
session.getSessionId(),
e.getMessage());
}
}
// ===== SIGN REQUEST METHODS (Participant View) =====
/**
* List all sign requests where the user is a participant.
*
* @param user The participant user
* @return List of sign request summaries
*/
@Transactional(readOnly = true)
public List<stirling.software.proprietary.workflow.dto.SignRequestSummaryDTO> listSignRequests(
User user) {
List<WorkflowParticipant> participations =
workflowParticipantRepository.findByUserOrderByLastUpdatedDesc(user);
return participations.stream()
.map(
p -> {
WorkflowSession session = p.getWorkflowSession();
stirling.software.proprietary.workflow.dto.SignRequestSummaryDTO dto =
new stirling.software.proprietary.workflow.dto
.SignRequestSummaryDTO();
dto.setSessionId(session.getSessionId());
dto.setDocumentName(session.getDocumentName());
dto.setOwnerUsername(session.getOwner().getUsername());
dto.setCreatedAt(session.getCreatedAt().toString());
dto.setDueDate(
session.getDueDate() != null
? session.getDueDate().toString()
: null);
dto.setMyStatus(p.getStatus());
return dto;
})
.collect(java.util.stream.Collectors.toList());
}
/**
* Get detailed information about a sign request.
*
* @param sessionId The session ID
* @param user The participant user
* @return Sign request detail
*/
@Transactional(readOnly = true)
public stirling.software.proprietary.workflow.dto.SignRequestDetailDTO getSignRequestDetail(
String sessionId, User user) {
WorkflowSession session = getSession(sessionId);
WorkflowParticipant participant = getParticipantForUser(session, user);
stirling.software.proprietary.workflow.dto.SignRequestDetailDTO dto =
new stirling.software.proprietary.workflow.dto.SignRequestDetailDTO();
dto.setSessionId(session.getSessionId());
dto.setDocumentName(session.getDocumentName());
dto.setOwnerUsername(session.getOwner().getUsername());
dto.setMessage(session.getMessage());
dto.setDueDate(session.getDueDate());
dto.setCreatedAt(session.getCreatedAt().toString());
dto.setMyStatus(participant.getStatus());
// Load signature appearance settings from workflow metadata
Map<String, Object> metadata = session.getWorkflowMetadata();
if (metadata != null && !metadata.isEmpty()) {
dto.setShowSignature(
metadata.containsKey("showSignature")
? (Boolean) metadata.get("showSignature")
: false);
dto.setPageNumber(
metadata.containsKey("pageNumber")
? ((Number) metadata.get("pageNumber")).intValue()
: null);
dto.setReason(metadata.containsKey("reason") ? (String) metadata.get("reason") : null);
dto.setLocation(
metadata.containsKey("location") ? (String) metadata.get("location") : null);
dto.setShowLogo(
metadata.containsKey("showLogo") ? (Boolean) metadata.get("showLogo") : false);
} else {
// Default values if no metadata
dto.setShowSignature(false);
dto.setPageNumber(null);
dto.setReason(null);
dto.setLocation(null);
dto.setShowLogo(false);
}
// Update status to VIEWED if it was NOTIFIED
if (participant.getStatus() == ParticipantStatus.NOTIFIED) {
participant.setStatus(ParticipantStatus.VIEWED);
workflowParticipantRepository.save(participant);
}
return dto;
}
/**
* Get the document for a sign request.
*
* <p>After finalization, returns the signed document. Before finalization, returns the
* original.
*
* @param sessionId The session ID
* @param user The participant user
* @return PDF document bytes
*/
@Transactional(readOnly = true)
public byte[] getSignRequestDocument(String sessionId, User user) {
WorkflowSession session = getSession(sessionId);
getParticipantForUser(session, user); // Verify participant access
// After finalization, serve the signed document instead of the original
StoredFile fileToServe =
(session.isFinalized() && session.getProcessedFile() != null)
? session.getProcessedFile()
: session.getOriginalFile();
if (fileToServe == null) {
throw new ResponseStatusException(
HttpStatus.NOT_FOUND, "Document not available for this session");
}
try {
org.springframework.core.io.Resource resource =
storageProvider.load(fileToServe.getStorageKey());
return resource.getContentAsByteArray();
} catch (IOException e) {
log.error("Failed to retrieve document for session {}", sessionId, e);
throw new ResponseStatusException(
HttpStatus.INTERNAL_SERVER_ERROR, "Failed to retrieve document");
}
}
/**
* Sign a document in a workflow session.
*
* @param sessionId The session ID
* @param user The participant user
* @param request Sign document request with certificate and optional wet signature
*/
public void signDocument(
String sessionId,
User user,
stirling.software.proprietary.workflow.dto.SignDocumentRequest request) {
WorkflowSession session = getSession(sessionId);
WorkflowParticipant participant = getParticipantForUser(session, user);
if (participant.getStatus() == ParticipantStatus.SIGNED) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Document already signed by this user");
}
if (participant.getStatus() == ParticipantStatus.DECLINED) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Cannot sign after declining");
}
// Build metadata JSON containing certificate submission and wet signature data
// Merge with existing metadata if present (preserves owner-configured appearance
// settings)
Map<String, Object> metadata = new HashMap<>();
// Get existing metadata if present
Map<String, Object> existingMetadata = participant.getParticipantMetadata();
if (existingMetadata != null && !existingMetadata.isEmpty()) {
metadata = new HashMap<>(existingMetadata);
}
// 1. Validate certificate before storing — throws 400 if invalid, expired, or wrong
// password
if (request.getCertType() != null
&& !"SERVER".equalsIgnoreCase(request.getCertType())
&& request.getP12File() != null
&& !request.getP12File().isEmpty()) {
try {
certificateSubmissionValidator.validateAndExtractInfo(
request.getP12File().getBytes(),
request.getCertType(),
request.getPassword());
} catch (ResponseStatusException e) {
throw e;
} catch (IOException e) {
log.error("Failed to read P12 keystore file for validation", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
}
// 2. Store certificate submission data
Map<String, Object> certSubmission = new HashMap<>();
certSubmission.put("certType", request.getCertType());
certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword()));
// Store keystore files as base64 if provided
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
try {
byte[] keystoreBytes = request.getP12File().getBytes();
String base64Keystore = java.util.Base64.getEncoder().encodeToString(keystoreBytes);
certSubmission.put("p12Keystore", base64Keystore);
} catch (IOException e) {
log.error("Failed to read P12 keystore file", e);
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
}
}
// Note: Signature appearance settings (showSignature, pageNumber, location, reason,
// showLogo)
// may already be in metadata if owner configured them when adding participant.
// If not present, the finalization process will use defaults.
metadata.put("certificateSubmission", certSubmission);
// 2. Parse wet signatures from JSON string if provided
if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) {
try {
List<WetSignatureMetadata> wetSigs =
objectMapper.readValue(
request.getWetSignaturesData(),
new TypeReference<List<WetSignatureMetadata>>() {});
if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Too many wet signatures submitted");
}
request.setWetSignatures(wetSigs);
log.info("Parsed {} wet signatures from wetSignaturesData", wetSigs.size());
} catch (JacksonException e) {
log.error("Failed to parse wetSignaturesData: {}", e.getMessage());
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Invalid wet signatures data");
}
}
// 3. Store wet signatures metadata if provided (supports multiple signatures)
if (request.hasWetSignatures()) {
List<WetSignatureMetadata> wetSigs = request.extractWetSignatureMetadata();
List<Map<String, Object>> wetSignatures = new ArrayList<>();
for (WetSignatureMetadata wetSig : wetSigs) {
Map<String, Object> wetSignature = new HashMap<>();
wetSignature.put("type", wetSig.getType());
wetSignature.put("data", wetSig.getData());
wetSignature.put("page", wetSig.getPage());
wetSignature.put("x", wetSig.getX());
wetSignature.put("y", wetSig.getY());
wetSignature.put("width", wetSig.getWidth());
wetSignature.put("height", wetSig.getHeight());
wetSignatures.add(wetSignature);
}
// Always store as array
metadata.put("wetSignatures", wetSignatures);
log.info(
"Stored {} wet signature(s) metadata for participant {}",
wetSignatures.size(),
user.getUsername());
}
// 4. Store metadata in participant (JPA converter handles JSON serialization)
participant.setParticipantMetadata(metadata);
log.info(
"Stored signature metadata for participant ID {}, email {}: {} wet signatures, cert type: {}",
participant.getId(),
user.getUsername(),
metadata.containsKey("wetSignatures")
? ((List<?>) metadata.get("wetSignatures")).size()
: 0,
((Map<?, ?>) metadata.get("certificateSubmission")).get("certType"));
// 5. Update participant status
participant.setStatus(ParticipantStatus.SIGNED);
workflowParticipantRepository.save(participant);
log.info(
"User {} signed document in session {} - certificate and signature data stored",
user.getUsername(),
sessionId);
}
/**
* Decline a sign request.
*
* @param sessionId The session ID
* @param user The participant user
*/
public void declineSignRequest(String sessionId, User user) {
WorkflowSession session = getSession(sessionId);
WorkflowParticipant participant = getParticipantForUser(session, user);
if (participant.getStatus() == ParticipantStatus.SIGNED) {
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST, "Cannot decline after signing");
}
participant.setStatus(ParticipantStatus.DECLINED);
workflowParticipantRepository.save(participant); // updatedAt is auto-updated
log.info("User {} declined sign request for session {}", user.getUsername(), sessionId);
}
/**
* Get participant record for a user in a session.
*
* @param session The workflow session
* @param user The user
* @return Participant record
* @throws ResponseStatusException if user is not a participant
*/
private WorkflowParticipant getParticipantForUser(WorkflowSession session, User user) {
return session.getParticipants().stream()
.filter(p -> p.getUser() != null && p.getUser().equals(user))
.findFirst()
.orElseThrow(
() ->
new ResponseStatusException(
HttpStatus.FORBIDDEN,
"User is not a participant in this session"));
}
/** Helper class to wrap byte array as MultipartFile. */
private static class ByteArrayMultipartFile implements MultipartFile {
private final byte[] content;
private final String filename;
public ByteArrayMultipartFile(byte[] content, String filename) {
this.content = content;
this.filename = filename;
}
@Override
public String getName() {
return "file";
}
@Override
public String getOriginalFilename() {
return filename;
}
@Override
public String getContentType() {
return "application/pdf";
}
@Override
public boolean isEmpty() {
return content == null || content.length == 0;
}
@Override
public long getSize() {
return content.length;
}
@Override
public byte[] getBytes() {
return content;
}
@Override
public java.io.InputStream getInputStream() {
return new java.io.ByteArrayInputStream(content);
}
@Override
public void transferTo(java.io.File dest) throws IOException {
java.nio.file.Files.write(dest.toPath(), content);
}
}
}
@@ -0,0 +1,169 @@
package stirling.software.proprietary.workflow.util;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.fasterxml.jackson.databind.ObjectMapper;
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse;
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
import stirling.software.proprietary.workflow.model.WorkflowSession;
/**
* Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent
* API responses.
*/
public class WorkflowMapper {
/** Converts a WorkflowSession entity to a response DTO. */
public static WorkflowSessionResponse toResponse(WorkflowSession session) {
return toResponse(session, null);
}
/**
* Converts a WorkflowSession entity to a response DTO with optional wet signature extraction.
*
* @param session The workflow session entity
* @param objectMapper ObjectMapper for JSON processing (null to skip wet signature extraction)
* @return WorkflowSessionResponse with participants (and wet signatures if objectMapper
* provided)
*/
public static WorkflowSessionResponse toResponse(
WorkflowSession session, ObjectMapper objectMapper) {
if (session == null) {
return null;
}
WorkflowSessionResponse response = new WorkflowSessionResponse();
response.setSessionId(session.getSessionId());
response.setOwnerId(session.getOwner().getId());
response.setOwnerUsername(session.getOwner().getUsername());
response.setWorkflowType(session.getWorkflowType());
response.setDocumentName(session.getDocumentName());
response.setOwnerEmail(session.getOwnerEmail());
response.setMessage(session.getMessage());
response.setDueDate(session.getDueDate());
response.setStatus(session.getStatus());
response.setFinalized(session.isFinalized());
response.setCreatedAt(session.getCreatedAt());
response.setUpdatedAt(session.getUpdatedAt());
response.setHasProcessedFile(session.hasProcessedFile());
if (session.getOriginalFile() != null) {
response.setOriginalFileId(session.getOriginalFile().getId());
}
if (session.getProcessedFile() != null) {
response.setProcessedFileId(session.getProcessedFile().getId());
}
// Convert participants (with wet signatures if objectMapper provided)
if (objectMapper != null) {
response.setParticipants(
session.getParticipants().stream()
.map(p -> toParticipantResponse(p, objectMapper))
.collect(Collectors.toList()));
} else {
response.setParticipants(
session.getParticipants().stream()
.map(WorkflowMapper::toParticipantResponse)
.collect(Collectors.toList()));
}
// Calculate participant counts
response.setParticipantCount(session.getParticipants().size());
response.setSignedCount(
(int)
session.getParticipants().stream()
.filter(
p ->
p.getStatus()
== stirling.software.proprietary.workflow
.model.ParticipantStatus.SIGNED)
.count());
return response;
}
/** Converts a WorkflowParticipant entity to a response DTO. */
public static ParticipantResponse toParticipantResponse(WorkflowParticipant participant) {
if (participant == null) {
return null;
}
ParticipantResponse response = new ParticipantResponse();
response.setId(participant.getId());
if (participant.getUser() != null) {
response.setUserId(participant.getUser().getId());
}
response.setEmail(participant.getEmail());
response.setName(participant.getName());
response.setStatus(participant.getStatus());
response.setShareToken(participant.getShareToken());
response.setAccessRole(participant.getAccessRole());
response.setExpiresAt(participant.getExpiresAt());
response.setLastUpdated(participant.getLastUpdated());
response.setHasCompleted(participant.hasCompleted());
response.setExpired(
participant.isExpired()); // Lombok generates setExpired() for isExpired field
return response;
}
/**
* Converts a WorkflowParticipant entity to a response DTO with wet signatures extracted.
*
* @param participant The participant entity
* @param objectMapper ObjectMapper for JSON processing
* @return ParticipantResponse with wet signatures included
*/
public static ParticipantResponse toParticipantResponse(
WorkflowParticipant participant, ObjectMapper objectMapper) {
ParticipantResponse response = toParticipantResponse(participant);
if (response != null) {
response.setWetSignatures(extractWetSignatures(participant, objectMapper));
}
return response;
}
/**
* Extracts wet signature metadata from a participant's metadata JSON field.
*
* @param participant The participant entity
* @param objectMapper ObjectMapper for JSON processing
* @return List of wet signatures, empty if none found
*/
private static List<WetSignatureMetadata> extractWetSignatures(
WorkflowParticipant participant, ObjectMapper objectMapper) {
List<WetSignatureMetadata> signatures = new ArrayList<>();
Map<String, Object> metadata = participant.getParticipantMetadata();
if (metadata == null || metadata.isEmpty() || !metadata.containsKey("wetSignatures")) {
return signatures;
}
try {
// Convert metadata to JsonNode for processing
var node = objectMapper.valueToTree(metadata);
if (node.has("wetSignatures")) {
var wetSigsNode = node.get("wetSignatures");
if (wetSigsNode.isArray()) {
for (var wetSigNode : wetSigsNode) {
WetSignatureMetadata wetSig =
objectMapper.treeToValue(wetSigNode, WetSignatureMetadata.class);
signatures.add(wetSig);
}
}
}
} catch (Exception e) {
// Log error but don't fail the entire response
// In production, you might want to use a logger here
return signatures;
}
return signatures;
}
}
@@ -29,6 +29,7 @@ import stirling.software.proprietary.security.model.Authority;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
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.session.SessionPersistentRegistry;
import stirling.software.proprietary.service.UserLicenseSettingsService;
@@ -47,6 +48,7 @@ class ProprietaryUIDataControllerTest {
@Mock private UserLicenseSettingsService licenseSettingsService;
@Mock private PersistentAuditEventRepository auditRepository;
@Mock private MfaService mfaService;
@Mock private LoginAttemptService loginAttemptService;
private ApplicationProperties applicationProperties;
private AuditConfigurationProperties auditConfig;
@@ -79,7 +81,8 @@ class ProprietaryUIDataControllerTest {
false,
licenseSettingsService,
auditRepository,
mfaService);
mfaService,
loginAttemptService);
}
@Test
@@ -28,6 +28,7 @@ import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.UsernameAndPass;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.service.EmailService;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.TeamService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
@@ -47,6 +48,7 @@ class UserControllerTest {
@Mock private UserRepository userRepository;
@Mock private EmailService emailService;
@Mock private UserLicenseSettingsService licenseSettingsService;
@Mock private LoginAttemptService loginAttemptService;
private ApplicationProperties applicationProperties;
private MockMvc mockMvc;
@@ -65,7 +67,8 @@ class UserControllerTest {
teamRepository,
userRepository,
Optional.of(emailService),
licenseSettingsService);
licenseSettingsService,
loginAttemptService);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -138,4 +141,13 @@ class UserControllerTest {
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").value("User not found."));
}
@Test
void unlockUserCallsResetAttemptsAndReturnsOk() throws Exception {
mockMvc.perform(post("/api/v1/user/admin/unlockUser/lockeduser"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.message").value("User account unlocked successfully"));
verify(loginAttemptService).resetAttempts("lockeduser");
}
}
@@ -6,6 +6,7 @@ import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ConcurrentHashMap;
@@ -236,4 +237,145 @@ class LoginAttemptServiceTest {
// If you later clamp to 0, update this assertion accordingly and add a new test.
assertEquals(expected, actual, "Current behavior returns negative values without clamping");
}
@Test
@DisplayName("resetAttempts(): removes entry from cache for given key")
void resetAttempts_shouldRemoveEntryFromCache() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
AttemptCounter counter = new AttemptCounter();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
ac.setInt(counter, 5);
attemptsCache.put("blockeduser", counter);
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("resetAttempts", String.class);
method.invoke(svc, "BlockedUser"); // case-insensitive
assertFalse(
attemptsCache.containsKey("blockeduser"),
"resetAttempts should remove the user's entry from the cache");
}
@Test
@DisplayName("resetAttempts(): does nothing for null or blank key")
void resetAttempts_shouldDoNothingForNullOrBlankKey() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
attemptsCache.put("existing", new AttemptCounter());
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("resetAttempts", String.class);
method.invoke(svc, (Object) null);
method.invoke(svc, " ");
assertEquals(1, attemptsCache.size(), "Null or blank key should not modify the cache");
}
@Test
@DisplayName("isBlockingEnabled(): returns true when blocking is enabled")
void isBlockingEnabled_shouldReturnTrueWhenEnabled() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
var method = svc.getClass().getMethod("isBlockingEnabled");
boolean result = (Boolean) method.invoke(svc);
assertTrue(result, "isBlockingEnabled should return true when isBlockedEnabled is true");
}
@Test
@DisplayName("isBlockingEnabled(): returns false when blocking is disabled")
void isBlockingEnabled_shouldReturnFalseWhenDisabled() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", false);
var method = svc.getClass().getMethod("isBlockingEnabled");
boolean result = (Boolean) method.invoke(svc);
assertFalse(result, "isBlockingEnabled should return false when isBlockedEnabled is false");
}
@Test
@DisplayName("getAllBlockedUsers(): returns empty list when blocking is disabled")
void getAllBlockedUsers_shouldReturnEmptyWhenDisabled() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", false);
setPrivate(svc, "attemptsCache", new ConcurrentHashMap<String, AttemptCounter>());
var method = svc.getClass().getMethod("getAllBlockedUsers");
@SuppressWarnings("unchecked")
List<String> result = (List<String>) method.invoke(svc);
assertTrue(
result.isEmpty(),
"getAllBlockedUsers should return empty list when blocking is disabled");
}
@Test
@DisplayName("getAllBlockedUsers(): returns only users at or above MAX_ATTEMPT")
void getAllBlockedUsers_shouldReturnOnlyBlockedUsers() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
setPrivate(svc, "MAX_ATTEMPT", 3);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
// User with exactly MAX_ATTEMPT attempts (blocked)
AttemptCounter blocked1 = new AttemptCounter();
ac.setInt(blocked1, 3);
attemptsCache.put("blocked1", blocked1);
// User with more than MAX_ATTEMPT attempts (blocked)
AttemptCounter blocked2 = new AttemptCounter();
ac.setInt(blocked2, 5);
attemptsCache.put("blocked2", blocked2);
// User with fewer than MAX_ATTEMPT attempts (not blocked)
AttemptCounter notBlocked = new AttemptCounter();
ac.setInt(notBlocked, 2);
attemptsCache.put("safe", notBlocked);
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("getAllBlockedUsers");
@SuppressWarnings("unchecked")
List<String> result = (List<String>) method.invoke(svc);
assertEquals(2, result.size(), "Should return exactly 2 blocked users");
assertTrue(result.contains("blocked1"), "Should contain blocked1");
assertTrue(result.contains("blocked2"), "Should contain blocked2");
assertFalse(result.contains("safe"), "Should not contain safe user");
}
@Test
@DisplayName("getAllBlockedUsers(): returns empty list when no users are blocked")
void getAllBlockedUsers_shouldReturnEmptyWhenNoUsersBlocked() throws Exception {
Object svc = constructLoginAttemptService();
setPrivateBoolean(svc, "isBlockedEnabled", true);
setPrivate(svc, "MAX_ATTEMPT", 3);
var attemptsCache = new ConcurrentHashMap<String, AttemptCounter>();
Field ac = AttemptCounter.class.getDeclaredField("attemptCount");
ac.setAccessible(true);
AttemptCounter notBlocked = new AttemptCounter();
ac.setInt(notBlocked, 1);
attemptsCache.put("user1", notBlocked);
setPrivate(svc, "attemptsCache", attemptsCache);
var method = svc.getClass().getMethod("getAllBlockedUsers");
@SuppressWarnings("unchecked")
List<String> result = (List<String>) method.invoke(svc);
assertTrue(result.isEmpty(), "Should return empty list when no users exceed MAX_ATTEMPT");
}
}
@@ -6,7 +6,10 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.*;
import java.sql.SQLException;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
@@ -23,11 +26,23 @@ import stirling.software.common.model.enumeration.Role;
import stirling.software.common.model.exception.UnsupportedProviderException;
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;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.repository.TeamRepository;
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
import stirling.software.proprietary.storage.model.FileShare;
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.model.WorkflowSession;
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
import stirling.software.proprietary.workflow.service.UserServerCertificateService;
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@@ -40,6 +55,14 @@ class UserServiceTest {
@Mock private SessionPersistentRegistry sessionRegistry;
@Mock private DatabaseServiceInterface databaseService;
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
@Mock private PersistentLoginRepository persistentLoginRepository;
@Mock private UserServerCertificateService userServerCertificateService;
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
@Mock private WorkflowSessionRepository workflowSessionRepository;
@Mock private StoredFileRepository storedFileRepository;
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
@Mock private FileShareRepository fileShareRepository;
@Mock private FileShareAccessRepository fileShareAccessRepository;
@Spy @InjectMocks private UserService userService;
@@ -185,4 +208,86 @@ class UserServiceTest {
assertFalse(userService.isUsernameValid("ALL_USERS"));
assertTrue(userService.isUsernameValid("valid@example.com"));
}
@Test
void deleteUser_withRelatedData_cleansUpInCorrectOrder() {
User user = new User();
user.setId(1L);
user.setUsername("target");
FileShare share = new FileShare();
StoredFile ownedFile = new StoredFile();
ownedFile.setOwner(user);
ownedFile.setStorageKey("key-main");
ownedFile.setHistoryStorageKey("key-history");
Set<FileShare> shares = new HashSet<>();
shares.add(share);
ownedFile.setShares(shares);
WorkflowSession session = new WorkflowSession();
session.setOwner(user);
FileShare inboundShare = new FileShare();
when(userRepository.findByUsernameIgnoreCase("target")).thenReturn(Optional.of(user));
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user))
.thenReturn(List.of(session));
when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of(ownedFile));
when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of(inboundShare));
userService.deleteUser("target");
verify(userServerCertificateService).deleteUserCertificate(1L);
verify(fileShareAccessRepository).deleteByUser(user);
// Inbound share (file shared with this user by others) cleaned up
verify(fileShareAccessRepository).deleteByFileShare(inboundShare);
verify(fileShareRepository).deleteAll(List.of(inboundShare));
// Participant records in others' sessions de-linked (not deleted) to preserve audit trail
verify(workflowParticipantRepository).clearUserReferences(user);
verify(storedFileRepository).clearWorkflowSessionReferencesByOwner(user);
verify(workflowSessionRepository).deleteAll(List.of(session));
verify(fileShareAccessRepository).deleteByFileShare(share);
verify(storedFileRepository).deleteAll(List.of(ownedFile));
verify(userRepository).delete(user);
// Persistent login (remember-me) tokens revoked
verify(persistentLoginRepository).deleteByUsername("target");
// Storage blobs scheduled for physical deletion
verify(storageCleanupEntryRepository, times(2)).save(any());
verify(userService).invalidateUserSessions("target");
}
@Test
void deleteUser_withNoRelatedData_deletesUserSuccessfully() {
User user = new User();
user.setId(2L);
user.setUsername("clean");
when(userRepository.findByUsernameIgnoreCase("clean")).thenReturn(Optional.of(user));
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user)).thenReturn(List.of());
when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of());
when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of());
userService.deleteUser("clean");
verify(userRepository).delete(user);
verify(fileShareAccessRepository, never()).deleteByFileShare(any());
verify(workflowSessionRepository).deleteAll(List.of());
verify(storedFileRepository).deleteAll(List.of());
}
@Test
void deleteUser_internalApiUser_isNotDeleted() {
Authority internalAuth = new Authority();
internalAuth.setAuthority(Role.INTERNAL_API_USER.getRoleId());
User user = new User();
user.setId(3L);
user.setUsername("internal");
user.getAuthorities().add(internalAuth);
when(userRepository.findByUsernameIgnoreCase("internal")).thenReturn(Optional.of(user));
userService.deleteUser("internal");
verify(userRepository, never()).delete(any());
verify(workflowSessionRepository, never()).findByOwnerOrderByCreatedAtDesc(any());
}
}
@@ -0,0 +1,97 @@
package stirling.software.proprietary.storage.converter;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import java.util.Map;
import org.junit.jupiter.api.Test;
class JsonMapConverterTest {
private final JsonMapConverter converter = new JsonMapConverter();
// -------------------------------------------------------------------------
// convertToDatabaseColumn
// -------------------------------------------------------------------------
@Test
void convertToDatabaseColumn_nullMap_returnsNull() {
assertThat(converter.convertToDatabaseColumn(null)).isNull();
}
@Test
void convertToDatabaseColumn_emptyMap_returnsNull() {
assertThat(converter.convertToDatabaseColumn(Map.of())).isNull();
}
@Test
void convertToDatabaseColumn_singleEntry_producesValidJson() {
String json = converter.convertToDatabaseColumn(Map.of("key", "value"));
assertThat(json).contains("\"key\"").contains("\"value\"");
}
@Test
void convertToDatabaseColumn_mapWithMixedTypes_roundTrips() {
Map<String, Object> input = Map.of("str", "hello", "num", 42);
String json = converter.convertToDatabaseColumn(input);
Map<String, Object> result = converter.convertToEntityAttribute(json);
assertThat(result.get("str")).isEqualTo("hello");
assertThat(result.get("num")).isEqualTo(42);
}
// -------------------------------------------------------------------------
// convertToEntityAttribute — normal paths
// -------------------------------------------------------------------------
@Test
void convertToEntityAttribute_nullInput_returnsEmptyMap() {
assertThat(converter.convertToEntityAttribute(null)).isEmpty();
}
@Test
void convertToEntityAttribute_blankInput_returnsEmptyMap() {
assertThat(converter.convertToEntityAttribute(" ")).isEmpty();
}
@Test
void convertToEntityAttribute_validJson_restoresMap() {
Map<String, Object> result = converter.convertToEntityAttribute("{\"foo\":\"bar\"}");
assertThat(result).containsEntry("foo", "bar");
}
@Test
void convertToEntityAttribute_nestedObject_preservesStructure() {
String json = "{\"outer\":{\"inner\":\"value\"}}";
Map<String, Object> result = converter.convertToEntityAttribute(json);
assertThat(result).containsKey("outer");
}
// -------------------------------------------------------------------------
// convertToEntityAttribute — legacy double-encoded fallback
// -------------------------------------------------------------------------
@Test
void convertToEntityAttribute_doubleEncodedJson_fallbackRecovery() {
// A JSON string node whose text content is itself valid JSON
String doubleEncoded = "\"{\\\"foo\\\":\\\"bar\\\"}\"";
Map<String, Object> result = converter.convertToEntityAttribute(doubleEncoded);
assertThat(result).containsEntry("foo", "bar");
}
// -------------------------------------------------------------------------
// convertToEntityAttribute — malformed input
// -------------------------------------------------------------------------
@Test
void convertToEntityAttribute_completelyMalformed_returnsEmptyMap() {
Map<String, Object> result = converter.convertToEntityAttribute("not-json-at-all");
assertThat(result).isEmpty();
}
@Test
void convertToEntityAttribute_malformedJson_doesNotThrow() {
assertThatCode(() -> converter.convertToEntityAttribute("{broken"))
.doesNotThrowAnyException();
}
}

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