mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 05:10:16 +03:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d86afba440 | ||
|
|
ebab5a4456 | ||
|
|
436c8cbed2 | ||
|
|
81dc90cd6d | ||
|
|
917edc43b3 | ||
|
|
3c48740c5e | ||
|
|
2c940569d1 | ||
|
|
7bbb04b594 | ||
|
|
fca40e5544 | ||
|
|
c9a70f3754 | ||
|
|
0adcbeedf1 | ||
|
|
de9625942b | ||
|
|
da9327ab1c | ||
|
|
61280f758a | ||
|
|
801cc8a5f4 | ||
|
|
74153b6deb | ||
|
|
ecd1d3cad3 | ||
|
|
0a098cf7b7 | ||
|
|
cfa8d1e5d7 | ||
|
|
5ffa808c0f | ||
|
|
212f12a81f | ||
|
|
c31e4253dd | ||
|
|
a96b95e198 | ||
|
|
a06b6a4bac | ||
|
|
cdc288e78d | ||
|
|
82a3b8c770 | ||
|
|
1e97a32d4b | ||
|
|
4a6b426651 | ||
|
|
0e29640766 | ||
|
|
05b4255751 | ||
|
|
1ab07a9027 | ||
|
|
75421b4223 | ||
|
|
a7fe4e9a76 | ||
|
|
10ab2872f6 | ||
|
|
2fdc9c112f | ||
|
|
dd44de349c | ||
|
|
e10c5f6283 | ||
|
|
9500acd69f | ||
|
|
bb43e9dcdf | ||
|
|
28613caf8a |
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
name: Clear GitHub Actions Cache
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- clear-github-cache
|
||||
|
||||
jobs:
|
||||
clear-cache:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
steps:
|
||||
- name: Clear all caches
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const caches = await github.rest.actions.getActionsCacheList({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let deleted = 0;
|
||||
for (const cache of caches.data.actions_caches) {
|
||||
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
|
||||
await github.rest.actions.deleteActionsCacheById({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
cache_id: cache.id,
|
||||
});
|
||||
deleted++;
|
||||
}
|
||||
|
||||
// Handle pagination if more than 100 caches
|
||||
let totalCount = caches.data.total_count;
|
||||
while (deleted < totalCount) {
|
||||
const moreCaches = await github.rest.actions.getActionsCacheList({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
per_page: 100,
|
||||
});
|
||||
if (moreCaches.data.actions_caches.length === 0) break;
|
||||
for (const cache of moreCaches.data.actions_caches) {
|
||||
console.log(`Deleting cache: ${cache.key} (${cache.id})`);
|
||||
await github.rest.actions.deleteActionsCacheById({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
cache_id: cache.id,
|
||||
});
|
||||
deleted++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Successfully deleted ${deleted} caches.`);
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Push Docker Base Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- baseDockerImage
|
||||
- accessIssueFix
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Base image version (e.g., 1.0.0, 1.0.1)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
push-base:
|
||||
if: ${{ vars.CI_PROFILE != 'lite' && github.actor == 'Frooodle' }}
|
||||
runs-on: ubuntu-24.04-8core
|
||||
permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Verify authorized user
|
||||
run: |
|
||||
if [ "${{ github.actor }}" != "Frooodle" ]; then
|
||||
echo "Error: Only Frooodle is authorized to run this workflow"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" == "workflow_dispatch" ]; then
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
elif [ "${{ github.ref_name }}" == "accessIssueFix" ]; then
|
||||
VERSION="1.0.3"
|
||||
else
|
||||
VERSION="1.0.0"
|
||||
fi
|
||||
echo "version=${VERSION}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate tags for base image
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: |
|
||||
${{ secrets.DOCKER_HUB_ORG_USERNAME }}/stirling-pdf-base
|
||||
ghcr.io/${{ steps.repoowner.outputs.lowercase }}/stirling-pdf-base
|
||||
tags: |
|
||||
type=raw,value=${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build and push base image
|
||||
id: build-push-base
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
with:
|
||||
builder: ${{ steps.buildx.outputs.name }}
|
||||
context: docker/base
|
||||
file: ./docker/base/Dockerfile
|
||||
push: true
|
||||
cache-from: type=gha,scope=stirling-pdf-base
|
||||
cache-to: type=gha,mode=max,scope=stirling-pdf-base
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64/v8
|
||||
provenance: true
|
||||
sbom: true
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
|
||||
with:
|
||||
cosign-release: "v2.4.1"
|
||||
|
||||
- name: Sign base images
|
||||
env:
|
||||
DIGEST: ${{ steps.build-push-base.outputs.digest }}
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
|
||||
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
|
||||
run: |
|
||||
if [ -n "$COSIGN_PRIVATE_KEY" ]; then
|
||||
echo "$TAGS" | tr ',' '\n' | while read -r tag; do
|
||||
cosign sign --yes \
|
||||
--key env://COSIGN_PRIVATE_KEY \
|
||||
"${tag}@${DIGEST}"
|
||||
done
|
||||
else
|
||||
echo "Warning: COSIGN_PRIVATE_KEY not set, skipping image signing"
|
||||
fi
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
name: Rollback Latest Tags to Version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to rollback to (e.g. 2.8.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
rollback:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Harden Runner
|
||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||
with:
|
||||
egress-policy: audit
|
||||
|
||||
- name: Install crane
|
||||
uses: imjasonh/setup-crane@31b88afe9de28ae0ffa220711af4b60be9435f6e # v0.4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_API }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ github.token }}
|
||||
|
||||
- name: Convert repository owner to lowercase
|
||||
id: repoowner
|
||||
run: echo "lowercase=$(echo ${{ github.repository_owner }} | awk '{print tolower($0)}')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Rollback all latest tags to v${{ inputs.version }}
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
DOCKER_HUB_USERNAME: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
DOCKER_HUB_ORG_USERNAME: ${{ secrets.DOCKER_HUB_ORG_USERNAME }}
|
||||
REPO_OWNER: ${{ steps.repoowner.outputs.lowercase }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
IMAGES=(
|
||||
"${DOCKER_HUB_USERNAME}/s-pdf"
|
||||
"ghcr.io/${REPO_OWNER}/s-pdf"
|
||||
"ghcr.io/${REPO_OWNER}/stirling-pdf"
|
||||
"${DOCKER_HUB_ORG_USERNAME}/stirling-pdf"
|
||||
)
|
||||
|
||||
VARIANTS=(
|
||||
"${VERSION}:latest"
|
||||
"${VERSION}-fat:latest-fat"
|
||||
"${VERSION}-ultra-lite:latest-ultra-lite"
|
||||
)
|
||||
|
||||
FAILED=0
|
||||
|
||||
for image in "${IMAGES[@]}"; do
|
||||
for variant in "${VARIANTS[@]}"; do
|
||||
SOURCE_TAG="${variant%%:*}"
|
||||
TARGET_TAG="${variant##*:}"
|
||||
|
||||
echo "::group::${image} — ${SOURCE_TAG} → ${TARGET_TAG}"
|
||||
|
||||
if crane manifest "${image}:${SOURCE_TAG}" > /dev/null 2>&1; then
|
||||
crane cp "${image}:${SOURCE_TAG}" "${image}:${TARGET_TAG}"
|
||||
echo "✅ ${image}:${TARGET_TAG} now points to ${SOURCE_TAG}"
|
||||
else
|
||||
echo "::warning::⚠️ ${image}:${SOURCE_TAG} not found, skipping"
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
done
|
||||
done
|
||||
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo "::warning::Some source tags were not found. This is expected if not all variants exist for this version."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 Rollback to ${VERSION} complete!"
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
@@ -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.
|
||||
@@ -27,10 +27,10 @@ spotless {
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
api 'com.google.guava:guava:33.4.8-jre'
|
||||
api 'com.google.guava:guava:33.5.0-jre'
|
||||
api 'org.springframework.boot:spring-boot-starter-webmvc'
|
||||
api 'org.springframework.boot:spring-boot-starter-aspectj'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260102.1'
|
||||
api 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20260313.1'
|
||||
api 'com.fathzer:javaluator:3.0.6'
|
||||
api 'com.posthog.java:posthog:1.2.0'
|
||||
api 'org.apache.commons:commons-lang3:3.20.0'
|
||||
@@ -43,7 +43,7 @@ dependencies {
|
||||
api 'com.github.junrar:junrar:7.5.8' // RAR archive support for CBR files
|
||||
api 'jakarta.servlet:jakarta.servlet-api:6.1.0'
|
||||
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.1"
|
||||
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.2"
|
||||
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
|
||||
api 'org.simplejavamail:simple-java-mail:8.12.6'
|
||||
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
|
||||
|
||||
@@ -58,6 +58,7 @@ public class ApplicationProperties {
|
||||
private Legal legal = new Legal();
|
||||
private Security security = new Security();
|
||||
private System system = new System();
|
||||
private Storage storage = new Storage();
|
||||
private Ui ui = new Ui();
|
||||
private Endpoints endpoints = new Endpoints();
|
||||
private Metrics metrics = new Metrics();
|
||||
@@ -634,6 +635,41 @@ public class ApplicationProperties {
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Storage {
|
||||
private boolean enabled = false;
|
||||
private String provider = "local";
|
||||
private Local local = new Local();
|
||||
private Quotas quotas = new Quotas();
|
||||
private Sharing sharing = new Sharing();
|
||||
private Signing signing = new Signing();
|
||||
|
||||
@Data
|
||||
public static class Local {
|
||||
private String basePath = InstallationPathConfig.getPath() + "storage";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Sharing {
|
||||
private boolean enabled = false;
|
||||
private boolean linkEnabled = false;
|
||||
private boolean emailEnabled = false;
|
||||
private int linkExpirationDays = 3;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Quotas {
|
||||
private long maxStorageMbPerUser = -1;
|
||||
private long maxStorageMbTotal = -1;
|
||||
private long maxFileMb = -1;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Signing {
|
||||
private boolean enabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class DatabaseBackup {
|
||||
private String cron = "0 0 0 * * ?"; // daily at midnight
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
package stirling.software.common.model.api.security;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
@Data
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class UserSummaryDTO {
|
||||
private Long userId;
|
||||
private String username;
|
||||
private String displayName;
|
||||
private String teamName;
|
||||
private boolean enabled;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import java.security.KeyStore;
|
||||
|
||||
/**
|
||||
* Abstraction for PDF digital signature operations. Defined in common so that proprietary services
|
||||
* can use it without creating a circular dependency on core.
|
||||
*/
|
||||
public interface PdfSigningService {
|
||||
|
||||
/**
|
||||
* Signs a PDF document using the provided KeyStore.
|
||||
*
|
||||
* @param pdfBytes raw PDF bytes to sign
|
||||
* @param keystore the KeyStore containing the signing key and certificate chain
|
||||
* @param password keystore password
|
||||
* @param showSignature whether to render a visible signature block
|
||||
* @param pageNumber 0-indexed page on which to render the visible signature (may be null)
|
||||
* @param name signer name embedded in the signature
|
||||
* @param location location string embedded in the signature
|
||||
* @param reason reason string embedded in the signature
|
||||
* @param showLogo whether to include the Stirling-PDF logo in the visible signature
|
||||
* @return signed PDF bytes
|
||||
* @throws Exception on any signing failure
|
||||
*/
|
||||
byte[] signWithKeystore(
|
||||
byte[] pdfBytes,
|
||||
KeyStore keystore,
|
||||
char[] password,
|
||||
boolean showSignature,
|
||||
Integer pageNumber,
|
||||
String name,
|
||||
String location,
|
||||
String reason,
|
||||
boolean showLogo)
|
||||
throws Exception;
|
||||
}
|
||||
@@ -5,6 +5,8 @@ public interface UserServiceInterface {
|
||||
|
||||
String getCurrentUsername();
|
||||
|
||||
String getCurrentUserApiKey();
|
||||
|
||||
long getTotalUsersCount();
|
||||
|
||||
boolean isCurrentUserAdmin();
|
||||
|
||||
@@ -112,8 +112,6 @@ public class FileMonitor {
|
||||
All files observed changes in the last iteration will be considered as staging files.
|
||||
If those files are not modified in current iteration, they will be considered as ready for processing.
|
||||
*/
|
||||
stagingFiles = new HashSet<>(newlyDiscoveredFiles);
|
||||
readyForProcessingFiles.clear();
|
||||
|
||||
if (path2KeyMapping.isEmpty()) {
|
||||
log.warn("Not monitoring any directories; attempting to re-register root paths.");
|
||||
@@ -129,8 +127,19 @@ public class FileMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
WatchKey key;
|
||||
while ((key = watchService.poll()) != null) {
|
||||
// Skip expensive collection work when there is nothing to track
|
||||
WatchKey firstKey = watchService.poll();
|
||||
if (firstKey == null
|
||||
&& newlyDiscoveredFiles.isEmpty()
|
||||
&& readyForProcessingFiles.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
stagingFiles = new HashSet<>(newlyDiscoveredFiles);
|
||||
readyForProcessingFiles.clear();
|
||||
|
||||
WatchKey key = firstKey;
|
||||
while (key != null) {
|
||||
final Path watchingDir = (Path) key.watchable();
|
||||
key.pollEvents()
|
||||
.forEach(
|
||||
@@ -167,6 +176,7 @@ public class FileMonitor {
|
||||
if (!isKeyValid) { // key is invalid when the directory itself is no longer exists
|
||||
path2KeyMapping.remove((Path) key.watchable());
|
||||
}
|
||||
key = watchService.poll();
|
||||
}
|
||||
readyForProcessingFiles.addAll(stagingFiles);
|
||||
}
|
||||
|
||||
@@ -180,7 +180,9 @@ public class RequestUriUtils {
|
||||
|| trimmedUri.startsWith("/readiness")
|
||||
|| trimmedUri.startsWith(
|
||||
"/api/v1/mobile-scanner/") // Mobile scanner endpoints (no auth)
|
||||
|| trimmedUri.startsWith("/v1/api-docs");
|
||||
|| trimmedUri.startsWith("/v1/api-docs")
|
||||
// Workflow participant endpoints — access controlled by share tokens, not login
|
||||
|| trimmedUri.startsWith("/api/v1/workflow/participant/");
|
||||
}
|
||||
|
||||
private static String stripContextPath(String contextPath, String requestURI) {
|
||||
|
||||
@@ -66,7 +66,7 @@ dependencies {
|
||||
implementation 'commons-io:commons-io:2.21.0'
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastleVersion"
|
||||
implementation 'io.micrometer:micrometer-core:1.16.2'
|
||||
implementation 'io.micrometer:micrometer-core'
|
||||
implementation 'com.google.zxing:core:3.5.4'
|
||||
implementation "org.commonmark:commonmark:$commonmarkVersion" // https://mvnrepository.com/artifact/org.commonmark/commonmark
|
||||
implementation "org.commonmark:commonmark-ext-gfm-tables:$commonmarkVersion"
|
||||
@@ -82,7 +82,7 @@ dependencies {
|
||||
// veraPDF still uses javax.xml.bind, not the new jakarta namespace
|
||||
implementation 'javax.xml.bind:jaxb-api:2.3.1'
|
||||
implementation 'com.sun.xml.bind:jaxb-impl:2.3.9'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:4.0.6'
|
||||
implementation 'com.sun.xml.bind:jaxb-core:4.0.7'
|
||||
implementation 'org.apache.poi:poi-ooxml:5.5.1'
|
||||
|
||||
// https://mvnrepository.com/artifact/technology.tabula/tabula
|
||||
|
||||
@@ -73,7 +73,8 @@ public class ExternalAppDepConfig {
|
||||
tmp.put("tesseract", List.of("tesseract"));
|
||||
tmp.put("rar", List.of("rar")); // Required for real CBR output
|
||||
tmp.put(calibrePath, List.of("Calibre"));
|
||||
tmp.put("ffmpeg", List.of("FFmpeg"));
|
||||
// ffmpeg disabled due to raised CVEs
|
||||
// tmp.put("ffmpeg", List.of("FFmpeg"));
|
||||
tmp.put("magick", List.of("ImageMagick"));
|
||||
this.commandToGroupMapping = Collections.unmodifiableMap(tmp);
|
||||
}
|
||||
|
||||
+3
-17
@@ -8,10 +8,7 @@ import java.awt.Graphics2D;
|
||||
import java.awt.geom.AffineTransform;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -23,31 +20,17 @@ import javax.imageio.ImageIO;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.rendering.ImageType;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import io.github.pixee.security.Filenames;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.ConvertApi;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.CheckProgramInstall;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.ProcessExecutor;
|
||||
import stirling.software.common.util.ProcessExecutor.ProcessExecutorResult;
|
||||
import stirling.software.common.util.TempDirectory;
|
||||
import stirling.software.common.util.TempFile;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@ConvertApi
|
||||
@RequiredArgsConstructor
|
||||
@@ -64,6 +47,8 @@ public class ConvertPdfToVideoController {
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
// ffmpeg disabled due to raised CVEs
|
||||
/*
|
||||
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/video")
|
||||
@Operation(
|
||||
summary = "Convert PDF to Video Slideshow",
|
||||
@@ -163,6 +148,7 @@ public class ConvertPdfToVideoController {
|
||||
return WebResponseUtils.bytesToWebResponse(videoBytes, outputName, mediaType);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
private void generateFrames(
|
||||
Path inputPdf,
|
||||
|
||||
+251
-84
@@ -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();
|
||||
|
||||
+21
@@ -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) {
|
||||
|
||||
+33
-4
@@ -483,9 +483,19 @@ public class StampController {
|
||||
y = overrideY;
|
||||
} else {
|
||||
x = calculatePositionX(pageSize, position, desiredPhysicalWidth, margin);
|
||||
y = calculatePositionY(pageSize, position, desiredPhysicalHeight, margin);
|
||||
// drawImage() places the lower-left corner at (x, y); use image-specific Y logic
|
||||
y = calculateImagePositionY(pageSize, position, desiredPhysicalHeight, margin);
|
||||
}
|
||||
|
||||
float llx = pageSize.getLowerLeftX();
|
||||
float lly = pageSize.getLowerLeftY();
|
||||
float urx = pageSize.getUpperRightX();
|
||||
float ury = pageSize.getUpperRightY();
|
||||
float xMax = Math.max(llx, urx - desiredPhysicalWidth);
|
||||
float yMax = Math.max(lly, ury - desiredPhysicalHeight);
|
||||
x = Math.min(xMax, Math.max(llx, x));
|
||||
y = Math.min(yMax, Math.max(lly, y));
|
||||
|
||||
contentStream.saveGraphicsState();
|
||||
contentStream.transform(Matrix.getTranslateInstance(x, y));
|
||||
contentStream.transform(Matrix.getRotateInstance(Math.toRadians(rotation), 0, 0));
|
||||
@@ -495,18 +505,37 @@ public class StampController {
|
||||
|
||||
private float calculatePositionX(
|
||||
PDRectangle pageSize, int position, float contentWidth, float margin) {
|
||||
float llx = pageSize.getLowerLeftX();
|
||||
float urx = pageSize.getUpperRightX();
|
||||
return switch (position % 3) {
|
||||
case 1: // Left
|
||||
yield pageSize.getLowerLeftX() + margin;
|
||||
yield llx + margin;
|
||||
case 2: // Center
|
||||
yield (pageSize.getWidth() - contentWidth) / 2;
|
||||
yield llx + (pageSize.getWidth() - contentWidth) / 2;
|
||||
case 0: // Right
|
||||
yield pageSize.getUpperRightX() - contentWidth - margin;
|
||||
yield urx - contentWidth - margin;
|
||||
default:
|
||||
yield 0;
|
||||
};
|
||||
}
|
||||
|
||||
private float calculateImagePositionY(
|
||||
PDRectangle pageSize, int position, float imageHeight, float margin) {
|
||||
float lly = pageSize.getLowerLeftY();
|
||||
float pageHeight = pageSize.getHeight();
|
||||
float ury = pageSize.getUpperRightY();
|
||||
return switch ((position - 1) / 3) {
|
||||
case 0: // Top - upper image edge flush below top margin
|
||||
yield ury - margin - imageHeight;
|
||||
case 1: // Middle - center image on page
|
||||
yield lly + (pageHeight - imageHeight) / 2;
|
||||
case 2: // Bottom - lower image edge at bottom margin
|
||||
yield lly + margin;
|
||||
default:
|
||||
yield lly;
|
||||
};
|
||||
}
|
||||
|
||||
private float calculatePositionY(
|
||||
PDRectangle pageSize, int position, float height, float margin) {
|
||||
return switch ((position - 1) / 3) {
|
||||
|
||||
+32
@@ -84,8 +84,39 @@ public class PipelineProcessor {
|
||||
return name.substring(0, underscoreIndex) + extension;
|
||||
}
|
||||
|
||||
// Allowlist of URL path prefixes permitted through the pipeline.
|
||||
private static final List<String> ALLOWED_PIPELINE_PATH_PREFIXES =
|
||||
List.of(
|
||||
"/api/v1/general/",
|
||||
"/api/v1/misc/",
|
||||
"/api/v1/security/",
|
||||
"/api/v1/convert/",
|
||||
"/api/v1/filter/");
|
||||
|
||||
private void validatePipelineUrl(String url) {
|
||||
// Strip scheme+host to get the path portion for comparison
|
||||
String path = url;
|
||||
int schemeEnd = url.indexOf("://");
|
||||
if (schemeEnd != -1) {
|
||||
int pathStart = url.indexOf('/', schemeEnd + 3);
|
||||
path = pathStart != -1 ? url.substring(pathStart) : "/";
|
||||
}
|
||||
final String pathToCheck = path;
|
||||
boolean allowed = ALLOWED_PIPELINE_PATH_PREFIXES.stream().anyMatch(pathToCheck::contains);
|
||||
if (!allowed) {
|
||||
log.warn("Blocked pipeline request to disallowed URL: {}", url);
|
||||
throw new SecurityException(
|
||||
"Pipeline operation not permitted for endpoint: " + pathToCheck);
|
||||
}
|
||||
}
|
||||
|
||||
private String getApiKeyForUser() {
|
||||
if (userService == null) return "";
|
||||
String username = userService.getCurrentUsername();
|
||||
if (username != null && !username.equals("anonymousUser")) {
|
||||
return userService.getApiKeyForUser(username);
|
||||
}
|
||||
// Scheduled/internal context — no user in security context
|
||||
return userService.getApiKeyForUser(Role.INTERNAL_API_USER.getRoleId());
|
||||
}
|
||||
|
||||
@@ -283,6 +314,7 @@ public class PipelineProcessor {
|
||||
|
||||
/* package */ ResponseEntity<Resource> sendWebRequest(
|
||||
String url, MultiValueMap<String, Object> body) {
|
||||
validatePipelineUrl(url);
|
||||
RestTemplate restTemplate = new RestTemplate();
|
||||
// Set up headers, including API key
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
|
||||
+2
-2
@@ -113,7 +113,7 @@ public class CertSignController {
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
}
|
||||
|
||||
private static void sign(
|
||||
public static void sign(
|
||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||
MultipartFile input,
|
||||
OutputStream output,
|
||||
@@ -304,7 +304,7 @@ public class CertSignController {
|
||||
}
|
||||
}
|
||||
|
||||
class CreateSignature extends CreateSignatureBase {
|
||||
public static class CreateSignature extends CreateSignatureBase {
|
||||
File logoFile;
|
||||
|
||||
public CreateSignature(KeyStore keystore, char[] pin)
|
||||
|
||||
@@ -34,6 +34,7 @@ public class TextFinder extends PDFTextStripper {
|
||||
this.useRegex = useRegex;
|
||||
this.wholeWordSearch = wholeWordSearch;
|
||||
this.setWordSeparator(" ");
|
||||
this.setLineSeparator("\n");
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.security.KeyStore;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.SPDF.controller.api.security.CertSignController;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.PdfSigningService;
|
||||
|
||||
/** Core implementation of {@link PdfSigningService} backed by {@link CertSignController}. */
|
||||
@Service
|
||||
public class PdfSigningServiceImpl implements PdfSigningService {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
public PdfSigningServiceImpl(CustomPDFDocumentFactory pdfDocumentFactory) {
|
||||
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] signWithKeystore(
|
||||
byte[] pdfBytes,
|
||||
KeyStore keystore,
|
||||
char[] password,
|
||||
boolean showSignature,
|
||||
Integer pageNumber,
|
||||
String name,
|
||||
String location,
|
||||
String reason,
|
||||
boolean showLogo)
|
||||
throws Exception {
|
||||
|
||||
CertSignController.CreateSignature createSignature =
|
||||
new CertSignController.CreateSignature(keystore, password);
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
ByteArrayMultipartFile inputFile =
|
||||
new ByteArrayMultipartFile(pdfBytes, "document.pdf", "application/pdf");
|
||||
|
||||
CertSignController.sign(
|
||||
pdfDocumentFactory,
|
||||
inputFile,
|
||||
outputStream,
|
||||
createSignature,
|
||||
showSignature,
|
||||
pageNumber,
|
||||
name,
|
||||
location,
|
||||
reason,
|
||||
showLogo);
|
||||
|
||||
return outputStream.toByteArray();
|
||||
}
|
||||
|
||||
/** Minimal MultipartFile wrapper for passing raw PDF bytes to CertSignController.sign(). */
|
||||
private static class ByteArrayMultipartFile
|
||||
implements org.springframework.web.multipart.MultipartFile {
|
||||
private final byte[] content;
|
||||
private final String filename;
|
||||
private final String contentType;
|
||||
|
||||
ByteArrayMultipartFile(byte[] content, String filename, String contentType) {
|
||||
this.content = content;
|
||||
this.filename = filename;
|
||||
this.contentType = contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "file";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getOriginalFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getContentType() {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isEmpty() {
|
||||
return content == null || content.length == 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getSize() {
|
||||
return content == null ? 0 : content.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] getBytes() {
|
||||
return content;
|
||||
}
|
||||
|
||||
@Override
|
||||
public java.io.InputStream getInputStream() {
|
||||
return new ByteArrayInputStream(content);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void transferTo(java.io.File dest) throws java.io.IOException {
|
||||
java.nio.file.Files.write(dest.toPath(), content);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,8 @@ spring.mvc.problemdetails.enabled=false
|
||||
# Or via SYSTEMFILEUPLOADLIMIT/SYSTEM_MAXFILESIZE which will also set fileUploadLimit in settings.yml
|
||||
spring.servlet.multipart.max-file-size=${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:2000MB}
|
||||
spring.servlet.multipart.max-request-size=${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:2000MB}
|
||||
# Jetty max form content size (default 200KB is too small for signature images)
|
||||
server.jetty.max-http-form-post-size=10MB
|
||||
server.servlet.session.tracking-modes=cookie
|
||||
server.servlet.context-path=${SYSTEM_ROOTURIPATH:/}
|
||||
spring.devtools.restart.enabled=true
|
||||
|
||||
@@ -240,6 +240,22 @@ system:
|
||||
databaseBackup:
|
||||
cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
|
||||
|
||||
storage:
|
||||
enabled: false # set to 'true' to allow users to store files on the server (requires security.enableLogin) [ALPHA]
|
||||
provider: local # storage provider: 'local' for filesystem storage, 'database' for DB-backed storage
|
||||
local:
|
||||
basePath: './storage' # base directory for stored files
|
||||
quotas:
|
||||
maxStorageMbPerUser: -1 # Max storage per user in MB; -1 disables per-user cap
|
||||
maxStorageMbTotal: -1 # Max storage across all users in MB; -1 disables total cap
|
||||
maxFileMb: -1 # Max size per stored file (including history/audit) in MB; -1 disables limit
|
||||
sharing:
|
||||
enabled: false # set to 'true' to enable file sharing features [ALPHA]
|
||||
linkEnabled: true # set to 'false' to disable share links (requires system.frontendUrl)
|
||||
emailEnabled: false # set to 'true' to allow sharing by email (requires mail.enabled)
|
||||
linkExpirationDays: 3 # Number of days before share links expire
|
||||
signing:
|
||||
enabled: false # set to 'true' to enable group signing workflow (requires storage.enabled) [ALPHA]
|
||||
autoPipeline:
|
||||
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
|
||||
fileReadiness:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
-164
@@ -1,164 +0,0 @@
|
||||
package stirling.software.SPDF.controller.api.converters;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.converters.PdfToVideoRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.CheckProgramInstall;
|
||||
import stirling.software.common.util.TempFileManager;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ConvertPdfToVideoControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@InjectMocks private ConvertPdfToVideoController controller;
|
||||
|
||||
@Test
|
||||
void convertPdfToVideo_ffmpegNotAvailableThrows() {
|
||||
PdfToVideoRequest request = new PdfToVideoRequest();
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
|
||||
request.setFileInput(pdfFile);
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> mock =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(false);
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertPdfToVideo_nullFileThrows() {
|
||||
PdfToVideoRequest request = new PdfToVideoRequest();
|
||||
request.setFileInput(null);
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> mock =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertPdfToVideo_emptyFileThrows() {
|
||||
PdfToVideoRequest request = new PdfToVideoRequest();
|
||||
MockMultipartFile emptyFile =
|
||||
new MockMultipartFile("fileInput", "doc.pdf", "application/pdf", new byte[0]);
|
||||
request.setFileInput(emptyFile);
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> mock =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertPdfToVideo_nonPdfContentTypeReturnsBadRequest() throws Exception {
|
||||
PdfToVideoRequest request = new PdfToVideoRequest();
|
||||
MockMultipartFile txtFile =
|
||||
new MockMultipartFile("fileInput", "doc.txt", "text/plain", "content".getBytes());
|
||||
request.setFileInput(txtFile);
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> mock =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
|
||||
|
||||
ResponseEntity<byte[]> response = controller.convertPdfToVideo(request);
|
||||
|
||||
assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertPdfToVideo_invalidOpacityThrows() {
|
||||
PdfToVideoRequest request = new PdfToVideoRequest();
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
|
||||
request.setFileInput(pdfFile);
|
||||
request.setOpacity(1.5f);
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> mock =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertPdfToVideo_negativeOpacityThrows() {
|
||||
PdfToVideoRequest request = new PdfToVideoRequest();
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
|
||||
request.setFileInput(pdfFile);
|
||||
request.setOpacity(-0.1f);
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> mock =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertPdfToVideo_negativeSecondsPerPageThrows() {
|
||||
PdfToVideoRequest request = new PdfToVideoRequest();
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
|
||||
request.setFileInput(pdfFile);
|
||||
request.setSecondsPerPage(-1);
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> mock =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertPdfToVideo_zeroSecondsPerPageThrows() {
|
||||
PdfToVideoRequest request = new PdfToVideoRequest();
|
||||
MockMultipartFile pdfFile =
|
||||
new MockMultipartFile(
|
||||
"fileInput", "doc.pdf", "application/pdf", "content".getBytes());
|
||||
request.setFileInput(pdfFile);
|
||||
request.setSecondsPerPage(0);
|
||||
|
||||
try (MockedStatic<CheckProgramInstall> mock =
|
||||
Mockito.mockStatic(CheckProgramInstall.class)) {
|
||||
mock.when(CheckProgramInstall::isFfmpegAvailable).thenReturn(true);
|
||||
|
||||
assertThrows(Exception.class, () -> controller.convertPdfToVideo(request));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void controllerIsConstructed() {
|
||||
assertNotNull(controller);
|
||||
}
|
||||
}
|
||||
+61
@@ -9,6 +9,7 @@ import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
import org.apache.pdfbox.pdmodel.common.PDRectangle;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
@@ -46,6 +47,7 @@ class StampControllerTest {
|
||||
|
||||
private Method processStampTextMethod;
|
||||
private Method processCustomDateFormatMethod;
|
||||
private Method calculateImagePositionYMethod;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws NoSuchMethodException {
|
||||
@@ -63,6 +65,26 @@ class StampControllerTest {
|
||||
StampController.class.getDeclaredMethod(
|
||||
"processCustomDateFormat", String.class, LocalDateTime.class);
|
||||
processCustomDateFormatMethod.setAccessible(true);
|
||||
|
||||
calculateImagePositionYMethod =
|
||||
StampController.class.getDeclaredMethod(
|
||||
"calculateImagePositionY",
|
||||
PDRectangle.class,
|
||||
int.class,
|
||||
float.class,
|
||||
float.class);
|
||||
calculateImagePositionYMethod.setAccessible(true);
|
||||
}
|
||||
|
||||
private float invokeCalculateImagePositionY(
|
||||
PDRectangle pageSize, int position, float imageHeight, float margin) throws Exception {
|
||||
try {
|
||||
return (float)
|
||||
calculateImagePositionYMethod.invoke(
|
||||
stampController, pageSize, position, imageHeight, margin);
|
||||
} catch (InvocationTargetException e) {
|
||||
throw (Exception) e.getCause();
|
||||
}
|
||||
}
|
||||
|
||||
private String invokeProcessStampText(
|
||||
@@ -86,6 +108,45 @@ class StampControllerTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Image stamp position (lower-left anchor)")
|
||||
class ImagePositionYTests {
|
||||
|
||||
@Test
|
||||
@DisplayName("Top row: upper edge of image sits below top margin")
|
||||
void topRowUsesUpperRightMinusMarginMinusHeight() throws Exception {
|
||||
PDRectangle page = new PDRectangle(0, 0, 600, 800);
|
||||
float y = invokeCalculateImagePositionY(page, 3, 100f, 10f);
|
||||
assertEquals(690f, y, 0.001f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Middle row: image is vertically centred on page")
|
||||
void middleRowCentresImage() throws Exception {
|
||||
PDRectangle page = new PDRectangle(0, 0, 600, 800);
|
||||
float y = invokeCalculateImagePositionY(page, 5, 100f, 10f);
|
||||
assertEquals(350f, y, 0.001f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Bottom row: lower edge of image sits above bottom margin")
|
||||
void bottomRowUsesLowerLeftPlusMargin() throws Exception {
|
||||
PDRectangle page = new PDRectangle(0, 0, 600, 800);
|
||||
float y = invokeCalculateImagePositionY(page, 7, 100f, 10f);
|
||||
assertEquals(10f, y, 0.001f);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Honours non-zero media box origin")
|
||||
void respectsLowerLeftOrigin() throws Exception {
|
||||
PDRectangle page = new PDRectangle(50f, 100f, 400f, 300f);
|
||||
float yMid = invokeCalculateImagePositionY(page, 5, 20f, 5f);
|
||||
assertEquals(240f, yMid, 0.001f);
|
||||
float yTop = invokeCalculateImagePositionY(page, 3, 20f, 5f);
|
||||
assertEquals(375f, yTop, 0.001f);
|
||||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
@DisplayName("Basic Variable Substitution Tests")
|
||||
class BasicVariableTests {
|
||||
|
||||
+2
-1
@@ -205,7 +205,8 @@ class PipelineProcessorTest {
|
||||
});
|
||||
})) {
|
||||
ResponseEntity<Resource> response =
|
||||
pipelineProcessor.sendWebRequest("http://localhost/api", body);
|
||||
pipelineProcessor.sendWebRequest(
|
||||
"http://localhost/api/v1/general/merge-pdfs", body);
|
||||
|
||||
assertNotNull(response);
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
|
||||
@@ -171,6 +171,9 @@ out/
|
||||
*.jks
|
||||
*.asc
|
||||
|
||||
# Allow test fixture certificates (synthetic, no real credentials)
|
||||
!src/test/resources/test-certs/**
|
||||
|
||||
# SSH Keys
|
||||
*.pub
|
||||
*.priv
|
||||
|
||||
@@ -37,7 +37,7 @@ spotless {
|
||||
}
|
||||
dependencies {
|
||||
implementation project(':common')
|
||||
api 'com.google.guava:guava:33.4.8-jre'
|
||||
api 'com.google.guava:guava:33.5.0-jre'
|
||||
|
||||
api 'org.springframework:spring-jdbc'
|
||||
api 'org.springframework:spring-webmvc'
|
||||
@@ -51,8 +51,8 @@ dependencies {
|
||||
api 'org.springframework.boot:spring-boot-starter-mail'
|
||||
api 'org.springframework.boot:spring-boot-starter-cache'
|
||||
api 'com.github.ben-manes.caffeine:caffeine'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.43'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.16.1'
|
||||
api 'io.swagger.core.v3:swagger-core-jakarta:2.2.46'
|
||||
implementation 'com.bucket4j:bucket4j_jdk17-core:8.17.0'
|
||||
|
||||
// https://mvnrepository.com/artifact/com.bucket4j/bucket4j_jdk17
|
||||
implementation "org.bouncycastle:bcprov-jdk18on:$bouncycastleVersion"
|
||||
@@ -62,7 +62,7 @@ dependencies {
|
||||
api "io.jsonwebtoken:jjwt-api:$jwtVersion"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-impl:$jwtVersion"
|
||||
runtimeOnly "io.jsonwebtoken:jjwt-jackson:$jwtVersion"
|
||||
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database
|
||||
runtimeOnly 'com.h2database:h2:2.3.232' // Don't upgrade h2database - file format incompatible with 2.4.x, would break existing user databases
|
||||
runtimeOnly 'org.postgresql:postgresql:42.7.10'
|
||||
implementation('com.coveo:saml-client:5.0.0') {
|
||||
exclude group: 'org.opensaml', module: 'opensaml-core'
|
||||
|
||||
+7
-1
@@ -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
|
||||
|
||||
+9
-2
@@ -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;
|
||||
|
||||
+22
@@ -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/**");
|
||||
}
|
||||
}
|
||||
+10
-3
@@ -159,10 +159,12 @@ public class SecurityConfiguration {
|
||||
firewall.setAllowedHeaderValues(
|
||||
headerValue -> headerValue != null && allowedChars.matcher(headerValue).matches());
|
||||
|
||||
// Apply the same rules to parameter values for consistency.
|
||||
// Allow non-ASCII characters and newlines in parameter values.
|
||||
Pattern allowedParamChars = Pattern.compile("[\\p{IsAssigned}&&[^\\p{IsControl}]\\r\\n]*");
|
||||
firewall.setAllowedParameterValues(
|
||||
parameterValue ->
|
||||
parameterValue != null && allowedChars.matcher(parameterValue).matches());
|
||||
parameterValue != null
|
||||
&& allowedParamChars.matcher(parameterValue).matches());
|
||||
return firewall;
|
||||
}
|
||||
|
||||
@@ -291,7 +293,12 @@ public class SecurityConfiguration {
|
||||
|
||||
http.addFilterBefore(
|
||||
userAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
// TODO: IPRateLimitingFilter disabled — limit is 1M (no-op) and raw Filter
|
||||
// impl causes Spring Security async dispatch bug (response already committed
|
||||
// errors on StreamingResponseBody endpoints). Re-enable once converted to
|
||||
// OncePerRequestFilter with proper config-driven limits.
|
||||
// .addFilterBefore(rateLimitingFilter,
|
||||
// UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);
|
||||
|
||||
http.sessionManagement(
|
||||
|
||||
+2
@@ -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",
|
||||
|
||||
+43
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+73
@@ -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)
|
||||
|
||||
+25
@@ -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
|
||||
|
||||
+90
-2
@@ -41,6 +41,7 @@ import stirling.software.common.service.UserServiceInterface;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
import stirling.software.proprietary.model.Team;
|
||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
||||
import stirling.software.proprietary.security.database.repository.PersistentLoginRepository;
|
||||
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||
import stirling.software.proprietary.security.model.Authority;
|
||||
@@ -48,6 +49,16 @@ import stirling.software.proprietary.security.model.User;
|
||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||
import stirling.software.proprietary.storage.model.FileShare;
|
||||
import stirling.software.proprietary.storage.model.StorageCleanupEntry;
|
||||
import stirling.software.proprietary.storage.model.StoredFile;
|
||||
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
|
||||
import stirling.software.proprietary.storage.repository.FileShareRepository;
|
||||
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
|
||||
import stirling.software.proprietary.storage.repository.StoredFileRepository;
|
||||
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
|
||||
import stirling.software.proprietary.workflow.service.UserServerCertificateService;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
@@ -68,6 +79,15 @@ public class UserService implements UserServiceInterface {
|
||||
|
||||
private final ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||
|
||||
private final PersistentLoginRepository persistentLoginRepository;
|
||||
private final UserServerCertificateService userServerCertificateService;
|
||||
private final WorkflowParticipantRepository workflowParticipantRepository;
|
||||
private final WorkflowSessionRepository workflowSessionRepository;
|
||||
private final StoredFileRepository storedFileRepository;
|
||||
private final StorageCleanupEntryRepository storageCleanupEntryRepository;
|
||||
private final FileShareRepository fileShareRepository;
|
||||
private final FileShareAccessRepository fileShareAccessRepository;
|
||||
|
||||
@Transactional
|
||||
public void processSSOPostLogin(
|
||||
String username,
|
||||
@@ -178,6 +198,15 @@ public class UserService implements UserServiceInterface {
|
||||
return user.getApiKey();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getCurrentUserApiKey() {
|
||||
String username = getCurrentUsername();
|
||||
if (username == null || username.isEmpty()) {
|
||||
throw new IllegalStateException("Cannot determine calling user for API key lookup");
|
||||
}
|
||||
return getApiKeyForUser(username);
|
||||
}
|
||||
|
||||
public boolean isValidApiKey(String apiKey) {
|
||||
return userRepository.findByApiKey(apiKey).isPresent();
|
||||
}
|
||||
@@ -200,19 +229,78 @@ public class UserService implements UserServiceInterface {
|
||||
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteUser(String username) {
|
||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||
if (userOpt.isPresent()) {
|
||||
for (Authority authority : userOpt.get().getAuthorities()) {
|
||||
User user = userOpt.get();
|
||||
for (Authority authority : user.getAuthorities()) {
|
||||
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
userRepository.delete(userOpt.get());
|
||||
deleteUserRelatedData(user);
|
||||
userRepository.delete(user);
|
||||
persistentLoginRepository.deleteByUsername(username);
|
||||
}
|
||||
invalidateUserSessions(username);
|
||||
}
|
||||
|
||||
private void deleteUserRelatedData(User user) {
|
||||
log.info("Deleting all associated data for user: {}", user.getUsername());
|
||||
|
||||
// Delete server certificate (non-nullable OneToOne → User)
|
||||
userServerCertificateService.deleteUserCertificate(user.getId());
|
||||
|
||||
// Delete FileShareAccess records where this user is the accessor
|
||||
fileShareAccessRepository.deleteByUser(user);
|
||||
|
||||
// Delete FileShare records where this user is the recipient (shared with them by others).
|
||||
// FileShareAccess for those shares must be cleared first (no cascade from FileShare side).
|
||||
List<FileShare> sharesTargetingUser = fileShareRepository.findBySharedWithUser(user);
|
||||
sharesTargetingUser.forEach(fileShareAccessRepository::deleteByFileShare);
|
||||
fileShareRepository.deleteAll(sharesTargetingUser);
|
||||
|
||||
// Null out WorkflowParticipant.user for sessions this user participates in but does not
|
||||
// own.
|
||||
// The participant record is retained to preserve the workflow audit trail.
|
||||
workflowParticipantRepository.clearUserReferences(user);
|
||||
|
||||
// Break circular FK: null out stored_files.workflow_session_id before deleting sessions
|
||||
storedFileRepository.clearWorkflowSessionReferencesByOwner(user);
|
||||
|
||||
// Delete WorkflowSessions (CascadeType.ALL cascades to WorkflowParticipant)
|
||||
workflowSessionRepository.deleteAll(
|
||||
workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user));
|
||||
|
||||
// Collect storage keys for physical cleanup before deleting DB records
|
||||
List<StoredFile> files = storedFileRepository.findAllByOwner(user);
|
||||
List<String> storageKeys =
|
||||
files.stream()
|
||||
.flatMap(
|
||||
f ->
|
||||
java.util.stream.Stream.of(
|
||||
f.getStorageKey(),
|
||||
f.getHistoryStorageKey(),
|
||||
f.getAuditLogStorageKey()))
|
||||
.filter(k -> k != null && !k.isBlank())
|
||||
.toList();
|
||||
|
||||
// Clear FileShareAccess per share (no cascade from FileShare), then delete StoredFiles
|
||||
// (CascadeType.ALL on StoredFile.shares cascades to FileShare)
|
||||
for (StoredFile file : files) {
|
||||
file.getShares().forEach(fileShareAccessRepository::deleteByFileShare);
|
||||
}
|
||||
storedFileRepository.deleteAll(files);
|
||||
|
||||
// Schedule physical deletion of all storage blobs; StorageCleanupService handles retry
|
||||
for (String key : storageKeys) {
|
||||
StorageCleanupEntry entry = new StorageCleanupEntry();
|
||||
entry.setStorageKey(key);
|
||||
storageCleanupEntryRepository.save(entry);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean usernameExists(String username) {
|
||||
return findByUsername(username).isPresent();
|
||||
}
|
||||
|
||||
+74
@@ -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);
|
||||
}
|
||||
}
|
||||
+270
@@ -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());
|
||||
}
|
||||
}
|
||||
+81
@@ -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<>();
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -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
|
||||
}
|
||||
+77
@@ -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;
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.storage.model;
|
||||
|
||||
public enum FileShareAccessType {
|
||||
VIEW,
|
||||
DOWNLOAD
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package stirling.software.proprietary.storage.model;
|
||||
|
||||
public enum ShareAccessRole {
|
||||
EDITOR,
|
||||
COMMENTER,
|
||||
VIEWER
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
+116
@@ -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;
|
||||
}
|
||||
+31
@@ -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;
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+15
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
+25
@@ -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;
|
||||
}
|
||||
+53
@@ -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);
|
||||
}
|
||||
}
|
||||
+82
@@ -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();
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
+13
@@ -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;
|
||||
}
|
||||
+34
@@ -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);
|
||||
}
|
||||
+39
@@ -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);
|
||||
}
|
||||
+11
@@ -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();
|
||||
}
|
||||
+7
@@ -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> {}
|
||||
+69
@@ -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);
|
||||
}
|
||||
+1181
File diff suppressed because it is too large
Load Diff
+73
@@ -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);
|
||||
}
|
||||
}
|
||||
+493
@@ -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"));
|
||||
}
|
||||
}
|
||||
+442
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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) {}
|
||||
+46
@@ -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;
|
||||
}
|
||||
+18
@@ -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) {}
|
||||
+43
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+72
@@ -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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+112
@@ -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 (0–1) 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 (0–1) 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 (0–1) 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 (0–1) 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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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;
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package stirling.software.proprietary.workflow.model;
|
||||
|
||||
public enum CertificateType {
|
||||
AUTO_GENERATED,
|
||||
USER_UPLOADED
|
||||
}
|
||||
+22
@@ -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
|
||||
}
|
||||
+73
@@ -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;
|
||||
}
|
||||
+141
@@ -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);
|
||||
}
|
||||
}
|
||||
+143
@@ -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;
|
||||
}
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package stirling.software.proprietary.workflow.repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import stirling.software.proprietary.workflow.model.UserServerCertificateEntity;
|
||||
|
||||
@Repository
|
||||
public interface UserServerCertificateRepository
|
||||
extends JpaRepository<UserServerCertificateEntity, Long> {
|
||||
|
||||
@Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.id = :userId")
|
||||
Optional<UserServerCertificateEntity> findByUserId(@Param("userId") Long userId);
|
||||
|
||||
@Query("SELECT c FROM UserServerCertificateEntity c WHERE c.user.username = :username")
|
||||
Optional<UserServerCertificateEntity> findByUsername(@Param("username") String username);
|
||||
|
||||
boolean existsByUserId(Long userId);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user