mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
fileshare (#5414)
Co-authored-by: ConnorYoh <[email protected]> Co-authored-by: Connor Yoh <[email protected]> Co-authored-by: EthanHealy01 <[email protected]> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
ConnorYoh
Connor Yoh
EthanHealy01
Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
parent
47cad0a131
commit
28613caf8a
@@ -182,6 +182,9 @@ jobs:
|
|||||||
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
uses: step-security/harden-runner@58077d3c7e43986b6b15fba718e8ea69e387dfcc # v2.15.1
|
||||||
with:
|
with:
|
||||||
egress-policy: audit
|
egress-policy: audit
|
||||||
|
allowed-endpoints: >
|
||||||
|
one.digicert.com:443
|
||||||
|
clientauth.one.digicert.com:443
|
||||||
|
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ exampleYmlFiles/stirling/
|
|||||||
/testing/file_snapshots
|
/testing/file_snapshots
|
||||||
SwaggerDoc.json
|
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
|
# Frontend build artifacts copied to backend static resources
|
||||||
# These are generated by npm build and should not be committed
|
# These are generated by npm build and should not be committed
|
||||||
app/core/src/main/resources/static/assets/
|
app/core/src/main/resources/static/assets/
|
||||||
|
|||||||
+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: ["[email protected]"] # External/unregistered users
|
||||||
|
participants: [...] # Detailed participant configs (optional)
|
||||||
|
message: "Please sign this contract"
|
||||||
|
dueDate: "2025-12-31"
|
||||||
|
ownerEmail: "[email protected]" # 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": "[email protected]",
|
||||||
|
"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:
|
||||||
|
- [email protected]: SIGNED ✓
|
||||||
|
- [email protected]: SIGNED ✓
|
||||||
|
- [email protected]: VIEWED (pending)
|
||||||
|
- [email protected]: PENDING
|
||||||
|
- [email protected]: DECLINED ✗
|
||||||
|
↓
|
||||||
|
Auto-refresh every 15 seconds
|
||||||
|
```
|
||||||
|
|
||||||
|
**Badge Colors:**
|
||||||
|
- 🔵 Blue: 0/5 signatures (awaiting)
|
||||||
|
- 🟡 Yellow: 2/5 signatures (partial)
|
||||||
|
- 🟢 Green: 5/5 signatures (ready to finalize)
|
||||||
|
|
||||||
|
### 5. Session Finalization
|
||||||
|
|
||||||
|
```
|
||||||
|
Owner → Clicks "Finalize" → System processes signatures
|
||||||
|
↓
|
||||||
|
Processing steps:
|
||||||
|
1. Apply wet signatures to PDF (visual overlays)
|
||||||
|
1.5. Append signature summary page (if includeSummaryPage=true)
|
||||||
|
2. Apply digital certificates in participant order
|
||||||
|
- Visual signature block suppressed when summary page is enabled
|
||||||
|
3. Store signed PDF
|
||||||
|
4. Clear wet signature metadata (GDPR compliance)
|
||||||
|
↓
|
||||||
|
Owner downloads signed PDF
|
||||||
|
```
|
||||||
|
|
||||||
|
**Finalization Process:**
|
||||||
|
|
||||||
|
1. **Apply Wet Signatures First**
|
||||||
|
```java
|
||||||
|
for (WetSignature sig : wetSignatures) {
|
||||||
|
PDPage page = document.getPage(sig.getPage());
|
||||||
|
byte[] imageBytes = Base64.decode(sig.getData());
|
||||||
|
// Convert Y from top-left (UI) to bottom-left (PDF) coordinate system
|
||||||
|
float pdfY = page.getMediaBox().getHeight() - sig.getY() - sig.getHeight();
|
||||||
|
PDImageXObject image = PDImageXObject.createFromByteArray(document, imageBytes, "signature");
|
||||||
|
contentStream.drawImage(image, sig.getX(), pdfY, sig.getWidth(), sig.getHeight());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Append Summary Page (optional, before digital signing)**
|
||||||
|
|
||||||
|
If `includeSummaryPage=true`, a new A4 page is appended showing:
|
||||||
|
- Stirling logo and "Signature Summary" title
|
||||||
|
- Document name and session owner
|
||||||
|
- Finalization timestamp
|
||||||
|
- Per-participant: name, email, status, signed timestamp, reason, location, certificate type
|
||||||
|
- Supports overflow to additional pages
|
||||||
|
|
||||||
|
This step occurs **before** digital certificate signing so signatures are not invalidated.
|
||||||
|
When a summary page is added, the visual digital signature block (`showSignature`) is suppressed — wet signatures (hand-drawn overlays) are unaffected.
|
||||||
|
|
||||||
|
3. **Apply Digital Certificates (in participant order)**
|
||||||
|
```java
|
||||||
|
for (Participant p : participants) {
|
||||||
|
if (p.status == SIGNED) {
|
||||||
|
KeyStore keystore = buildKeystore(p.certificate);
|
||||||
|
// Reason: participant override > owner default > "Document Signing"
|
||||||
|
// Location: participant-provided only (no default)
|
||||||
|
CertSignController.sign(pdfBytes, keystore, password, settings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Store and Cleanup**
|
||||||
|
```java
|
||||||
|
StoredFile signedFile = storeFile(signedPdfBytes, SIGNING_SIGNED);
|
||||||
|
session.setProcessedFile(signedFile);
|
||||||
|
session.setFinalized(true);
|
||||||
|
|
||||||
|
// GDPR: Clear sensitive metadata after finalization
|
||||||
|
for (Participant p : participants) {
|
||||||
|
p.metadata.remove("wetSignatures"); // Clears wet signature image data
|
||||||
|
p.metadata.remove("certificateSubmission"); // Clears keystore bytes + password
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**API Call:**
|
||||||
|
```bash
|
||||||
|
POST /api/v1/security/cert-sign/sessions/{sessionId}/finalize
|
||||||
|
Authorization: Bearer {owner_token}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Binary PDF file with Content-Disposition header
|
||||||
|
|
||||||
|
## Key Technical Features
|
||||||
|
|
||||||
|
### 1. Double JSON Encoding Fix (Recent)
|
||||||
|
|
||||||
|
**Problem:** JSONB columns were storing JSON strings instead of JSON objects, requiring double-parsing.
|
||||||
|
|
||||||
|
**Solution:** Created `JsonMapConverter` JPA AttributeConverter:
|
||||||
|
```java
|
||||||
|
@Convert(converter = JsonMapConverter.class)
|
||||||
|
@Column(name = "participant_metadata", columnDefinition = "jsonb")
|
||||||
|
private Map<String, Object> participantMetadata;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- Single parse on read
|
||||||
|
- Proper JSON storage in PostgreSQL
|
||||||
|
- Type-safe Map access
|
||||||
|
- Backward compatible with legacy data
|
||||||
|
|
||||||
|
### 2. Signature Progress Display (Recent)
|
||||||
|
|
||||||
|
**Implementation:**
|
||||||
|
- `WorkflowSessionResponse` includes `participantCount` and `signedCount`
|
||||||
|
- `WorkflowMapper` calculates counts when converting to DTO
|
||||||
|
- Frontend displays "X/Y signatures" in session list
|
||||||
|
- Auto-refresh every 15 seconds keeps counts updated
|
||||||
|
|
||||||
|
### 3. Token-Based Security
|
||||||
|
|
||||||
|
**No Authentication Required for Participants:**
|
||||||
|
- Participants access via secure token (UUID)
|
||||||
|
- Token linked to specific participant and session
|
||||||
|
- Automatic expiration support
|
||||||
|
- One-time signing (cannot sign twice)
|
||||||
|
|
||||||
|
**Authenticated Participant Access:**
|
||||||
|
- Registered users can also access sign requests via `/api/v1/security/cert-sign/sign-requests`
|
||||||
|
- Standard Spring Security authentication required
|
||||||
|
- Supports additional cert types: UPLOAD, PEM, PKCS12, PFX
|
||||||
|
|
||||||
|
**Automatic Role Downgrade:**
|
||||||
|
- After signing: EDITOR → VIEWER
|
||||||
|
- After declining: EDITOR → VIEWER
|
||||||
|
- Prevents modification after action taken
|
||||||
|
|
||||||
|
### 4. Storage Integration
|
||||||
|
|
||||||
|
**Unified with File Sharing:**
|
||||||
|
- All PDFs stored via `StorageProvider` (Database or Local)
|
||||||
|
- Respects storage quotas
|
||||||
|
- Supports files up to 100GB+ (with Local storage)
|
||||||
|
- Consistent with existing file sharing infrastructure
|
||||||
|
|
||||||
|
### 5. Certificate Types
|
||||||
|
|
||||||
|
**P12/PKCS12/PFX:** User uploads PKCS#12 file + password
|
||||||
|
**JKS:** User uploads Java KeyStore + password
|
||||||
|
**PEM/UPLOAD:** User uploads PEM certificate + private key
|
||||||
|
**SERVER:** Uses organization's server certificate (no upload needed)
|
||||||
|
**USER_CERT:** Uses user's auto-generated personal certificate (one-click)
|
||||||
|
|
||||||
|
Note: UPLOAD, PEM, PKCS12, PFX are available on the authenticated (`sign-requests`) path. The token-based path uses P12, JKS, SERVER, USER_CERT.
|
||||||
|
|
||||||
|
## Frontend Components Overview
|
||||||
|
|
||||||
|
### Owner Workflow Components
|
||||||
|
|
||||||
|
1. **CreateSessionPanel** - Form to create new signing session
|
||||||
|
2. **ActiveSessionsPanel** - List of pending sessions with progress
|
||||||
|
3. **SessionDetailWorkbenchView** - Full session management interface
|
||||||
|
4. **CompletedSessionsPanel** - History of finalized sessions
|
||||||
|
|
||||||
|
### Participant Workflow Components
|
||||||
|
|
||||||
|
1. **SignRequestWorkbenchView** - Main signing interface
|
||||||
|
2. **SignatureSettingsInput** - Certificate selection and configuration
|
||||||
|
3. **WetSignatureInput** - Draw/type/upload signature overlay
|
||||||
|
4. **SignatureSettingsDisplay** - Preview of signature settings
|
||||||
|
|
||||||
|
### Shared Components
|
||||||
|
|
||||||
|
1. **UserSelector** - Multi-select user picker for participants
|
||||||
|
2. **LocalEmbedPDFWithAnnotations** - PDF viewer with signature placement
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Backend Configuration
|
||||||
|
|
||||||
|
**application.properties:**
|
||||||
|
```properties
|
||||||
|
# Database (H2 or PostgreSQL)
|
||||||
|
spring.jpa.hibernate.ddl-auto=update
|
||||||
|
|
||||||
|
# Security
|
||||||
|
DOCKER_ENABLE_SECURITY=true
|
||||||
|
|
||||||
|
# Storage Provider (DATABASE or LOCAL)
|
||||||
|
storage.provider=LOCAL
|
||||||
|
storage.maxFileSize=100GB
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend Configuration
|
||||||
|
|
||||||
|
**Quick Access Bar:**
|
||||||
|
- Signing popout accessible from top navigation
|
||||||
|
- Auto-refresh interval: 15 seconds
|
||||||
|
- Badge shows pending session count
|
||||||
|
|
||||||
|
## API Reference Summary
|
||||||
|
|
||||||
|
### Owner Endpoints (Authenticated)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| POST | `/api/v1/security/cert-sign/sessions` | Create session |
|
||||||
|
| GET | `/api/v1/security/cert-sign/sessions` | List sessions |
|
||||||
|
| GET | `/api/v1/security/cert-sign/sessions/{id}` | Get details |
|
||||||
|
| POST | `/api/v1/security/cert-sign/sessions/{id}/finalize` | Finalize session |
|
||||||
|
| GET | `/api/v1/security/cert-sign/sessions/{id}/pdf` | Download original |
|
||||||
|
| GET | `/api/v1/security/cert-sign/sessions/{id}/signed-pdf` | Download signed |
|
||||||
|
| DELETE | `/api/v1/security/cert-sign/sessions/{id}` | Delete session |
|
||||||
|
| POST | `/api/v1/security/cert-sign/sessions/{id}/participants` | Add participants |
|
||||||
|
| DELETE | `/api/v1/security/cert-sign/sessions/{id}/participants/{pid}` | Remove participant |
|
||||||
|
|
||||||
|
### Authenticated Participant Endpoints
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/security/cert-sign/sign-requests` | List sign requests |
|
||||||
|
| GET | `/api/v1/security/cert-sign/sign-requests/{id}` | Get sign request details |
|
||||||
|
| GET | `/api/v1/security/cert-sign/sign-requests/{id}/document` | Download document |
|
||||||
|
| POST | `/api/v1/security/cert-sign/sign-requests/{id}/sign` | Sign document |
|
||||||
|
| POST | `/api/v1/security/cert-sign/sign-requests/{id}/decline` | Decline signing |
|
||||||
|
|
||||||
|
### Token-Based Participant Endpoints (No Auth Required)
|
||||||
|
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| GET | `/api/v1/workflow/participant/session?token={token}` | View session |
|
||||||
|
| GET | `/api/v1/workflow/participant/details?token={token}` | Get participant details |
|
||||||
|
| GET | `/api/v1/workflow/participant/document?token={token}` | Download PDF |
|
||||||
|
| POST | `/api/v1/workflow/participant/submit-signature` | Submit signature |
|
||||||
|
| POST | `/api/v1/workflow/participant/decline?token={token}` | Decline signing |
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Data Protection
|
||||||
|
- Wet signature image data cleared after finalization (GDPR compliance)
|
||||||
|
- Certificate submission data (keystore bytes + password) cleared after finalization (GDPR compliance)
|
||||||
|
- Certificate passwords are not encrypted at rest while stored (TODO: encrypt at rest)
|
||||||
|
- Token expiration support
|
||||||
|
|
||||||
|
### Access Control
|
||||||
|
- Owner authentication required for session management
|
||||||
|
- Participant access via secure UUID tokens (no auth) or standard auth (sign-requests)
|
||||||
|
- Automatic role downgrade prevents re-signing
|
||||||
|
- Session status checks prevent unauthorized actions
|
||||||
|
|
||||||
|
### Audit Trail
|
||||||
|
- All participant actions tracked
|
||||||
|
- FileShare access logged
|
||||||
|
- Status transitions recorded
|
||||||
|
- Notification history maintained
|
||||||
|
|
||||||
|
## Performance Characteristics
|
||||||
|
|
||||||
|
### Scalability
|
||||||
|
- Supports PDFs up to 100GB+ (with Local storage provider)
|
||||||
|
- Memory-efficient streaming for large files
|
||||||
|
- IndexedDB caching on frontend
|
||||||
|
- Database indexes on session_id, share_token, workflow_session_id
|
||||||
|
|
||||||
|
### Response Times
|
||||||
|
- Session creation: ~500ms (10MB file)
|
||||||
|
- Session listing: ~100ms
|
||||||
|
- Token validation: ~50ms
|
||||||
|
- Finalization: ~2s per MB of PDF (varies by certificate operations)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Planned Features
|
||||||
|
- Email notifications for participants
|
||||||
|
- Reminder system for pending signatures
|
||||||
|
- Bulk signing operations
|
||||||
|
- Template-based signing workflows
|
||||||
|
- Signature validation/verification UI
|
||||||
|
- Certificate password encryption at rest
|
||||||
|
- Certificate keystore cleanup after finalization (GDPR)
|
||||||
|
- Webhook support for external integrations
|
||||||
|
- Analytics dashboard for signing metrics
|
||||||
|
|
||||||
|
### Additional Workflow Types
|
||||||
|
- **REVIEW** - Document review with comments
|
||||||
|
- **APPROVAL** - Multi-level approval chains
|
||||||
|
- **COLLABORATION** - Real-time collaborative editing
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**"Token invalid" error:**
|
||||||
|
- Check token exists in workflow_participants table
|
||||||
|
- Verify session is not finalized
|
||||||
|
- Check expiration date (expires_at)
|
||||||
|
|
||||||
|
**Signature not appearing on PDF:**
|
||||||
|
- Verify certificate type is correct
|
||||||
|
- Check certificate password
|
||||||
|
- Review logs for signing errors
|
||||||
|
- Ensure PDFDocumentFactory is available
|
||||||
|
|
||||||
|
**"Awaiting signatures" not updating:**
|
||||||
|
- Backend should return participantCount and signedCount
|
||||||
|
- Frontend auto-refresh every 15 seconds
|
||||||
|
- Check network tab for API errors
|
||||||
|
|
||||||
|
**Wet signatures not visible after finalization:**
|
||||||
|
- Wet signatures are applied first as image overlays (Step 1)
|
||||||
|
- Check `wetSignaturesData` was sent as valid JSON array
|
||||||
|
- Verify page index is within document bounds
|
||||||
|
- Note: wet signatures survive regardless of `includeSummaryPage` setting
|
||||||
|
|
||||||
|
### Debug Queries
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check session status
|
||||||
|
SELECT session_id, status, finalized,
|
||||||
|
(SELECT COUNT(*) FROM workflow_participants WHERE workflow_session_id = ws.id) as participant_count,
|
||||||
|
(SELECT COUNT(*) FROM workflow_participants WHERE workflow_session_id = ws.id AND status = 'SIGNED') as signed_count
|
||||||
|
FROM workflow_sessions ws;
|
||||||
|
|
||||||
|
-- Check participant tokens
|
||||||
|
SELECT email, status, share_token, expires_at
|
||||||
|
FROM workflow_participants
|
||||||
|
WHERE workflow_session_id = (SELECT id FROM workflow_sessions WHERE session_id = '{session_id}');
|
||||||
|
|
||||||
|
-- Check metadata storage
|
||||||
|
SELECT email,
|
||||||
|
participant_metadata->'certificateSubmission'->>'certType' as cert_type,
|
||||||
|
jsonb_array_length(participant_metadata->'wetSignatures') as wet_sig_count
|
||||||
|
FROM workflow_participants;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The Shared Signing feature provides a complete collaborative signing workflow with:
|
||||||
|
- ✅ Multi-participant support with progress tracking
|
||||||
|
- ✅ Multiple certificate types (P12/PKCS12/PFX, JKS, PEM, SERVER, USER_CERT)
|
||||||
|
- ✅ Visual wet signature overlays (multiple per participant)
|
||||||
|
- ✅ Token-based security for unauthenticated participants
|
||||||
|
- ✅ Authenticated participant access via sign-requests API
|
||||||
|
- ✅ Automatic role management
|
||||||
|
- ✅ Large file support (100GB+)
|
||||||
|
- ✅ GDPR-compliant wet signature metadata cleanup
|
||||||
|
- ✅ Real-time progress updates
|
||||||
|
- ✅ Full frontend integration with Quick Access Bar
|
||||||
|
- ✅ Optional signature summary page with logo and participant details
|
||||||
|
|
||||||
|
The architecture leverages existing file sharing infrastructure while adding workflow-specific features, ensuring consistency and maintainability across the application.
|
||||||
@@ -58,6 +58,7 @@ public class ApplicationProperties {
|
|||||||
private Legal legal = new Legal();
|
private Legal legal = new Legal();
|
||||||
private Security security = new Security();
|
private Security security = new Security();
|
||||||
private System system = new System();
|
private System system = new System();
|
||||||
|
private Storage storage = new Storage();
|
||||||
private Ui ui = new Ui();
|
private Ui ui = new Ui();
|
||||||
private Endpoints endpoints = new Endpoints();
|
private Endpoints endpoints = new Endpoints();
|
||||||
private Metrics metrics = new Metrics();
|
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
|
@Data
|
||||||
public static class DatabaseBackup {
|
public static class DatabaseBackup {
|
||||||
private String cron = "0 0 0 * * ?"; // daily at midnight
|
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;
|
||||||
|
}
|
||||||
+21
@@ -204,6 +204,27 @@ public class ConfigController {
|
|||||||
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
|
boolean invitesEnabled = applicationProperties.getMail().isEnableInvites();
|
||||||
configData.put("enableEmailInvites", smtpEnabled && invitesEnabled);
|
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
|
// Check if user is admin using UserServiceInterface
|
||||||
boolean isAdmin = false;
|
boolean isAdmin = false;
|
||||||
if (userService != null) {
|
if (userService != null) {
|
||||||
|
|||||||
+2
-2
@@ -113,7 +113,7 @@ public class CertSignController {
|
|||||||
this.serverCertificateService = serverCertificateService;
|
this.serverCertificateService = serverCertificateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void sign(
|
public static void sign(
|
||||||
CustomPDFDocumentFactory pdfDocumentFactory,
|
CustomPDFDocumentFactory pdfDocumentFactory,
|
||||||
MultipartFile input,
|
MultipartFile input,
|
||||||
OutputStream output,
|
OutputStream output,
|
||||||
@@ -304,7 +304,7 @@ public class CertSignController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class CreateSignature extends CreateSignatureBase {
|
public static class CreateSignature extends CreateSignatureBase {
|
||||||
File logoFile;
|
File logoFile;
|
||||||
|
|
||||||
public CreateSignature(KeyStore keystore, char[] pin)
|
public CreateSignature(KeyStore keystore, char[] pin)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package stirling.software.SPDF.service;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import stirling.software.SPDF.controller.api.security.CertSignController;
|
||||||
|
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||||
|
import stirling.software.common.service.PdfSigningService;
|
||||||
|
|
||||||
|
/** Core implementation of {@link PdfSigningService} backed by {@link CertSignController}. */
|
||||||
|
@Service
|
||||||
|
public class PdfSigningServiceImpl implements PdfSigningService {
|
||||||
|
|
||||||
|
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||||
|
|
||||||
|
public PdfSigningServiceImpl(CustomPDFDocumentFactory pdfDocumentFactory) {
|
||||||
|
this.pdfDocumentFactory = pdfDocumentFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] signWithKeystore(
|
||||||
|
byte[] pdfBytes,
|
||||||
|
KeyStore keystore,
|
||||||
|
char[] password,
|
||||||
|
boolean showSignature,
|
||||||
|
Integer pageNumber,
|
||||||
|
String name,
|
||||||
|
String location,
|
||||||
|
String reason,
|
||||||
|
boolean showLogo)
|
||||||
|
throws Exception {
|
||||||
|
|
||||||
|
CertSignController.CreateSignature createSignature =
|
||||||
|
new CertSignController.CreateSignature(keystore, password);
|
||||||
|
|
||||||
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||||
|
ByteArrayMultipartFile inputFile =
|
||||||
|
new ByteArrayMultipartFile(pdfBytes, "document.pdf", "application/pdf");
|
||||||
|
|
||||||
|
CertSignController.sign(
|
||||||
|
pdfDocumentFactory,
|
||||||
|
inputFile,
|
||||||
|
outputStream,
|
||||||
|
createSignature,
|
||||||
|
showSignature,
|
||||||
|
pageNumber,
|
||||||
|
name,
|
||||||
|
location,
|
||||||
|
reason,
|
||||||
|
showLogo);
|
||||||
|
|
||||||
|
return outputStream.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Minimal MultipartFile wrapper for passing raw PDF bytes to CertSignController.sign(). */
|
||||||
|
private static class ByteArrayMultipartFile
|
||||||
|
implements org.springframework.web.multipart.MultipartFile {
|
||||||
|
private final byte[] content;
|
||||||
|
private final String filename;
|
||||||
|
private final String contentType;
|
||||||
|
|
||||||
|
ByteArrayMultipartFile(byte[] content, String filename, String contentType) {
|
||||||
|
this.content = content;
|
||||||
|
this.filename = filename;
|
||||||
|
this.contentType = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getOriginalFilename() {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getContentType() {
|
||||||
|
return contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return content == null || content.length == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getSize() {
|
||||||
|
return content == null ? 0 : content.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] getBytes() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.io.InputStream getInputStream() {
|
||||||
|
return new ByteArrayInputStream(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void transferTo(java.io.File dest) throws java.io.IOException {
|
||||||
|
java.nio.file.Files.write(dest.toPath(), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,8 @@ spring.mvc.problemdetails.enabled=false
|
|||||||
# Or via SYSTEMFILEUPLOADLIMIT/SYSTEM_MAXFILESIZE which will also set fileUploadLimit in settings.yml
|
# 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-file-size=${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:2000MB}
|
||||||
spring.servlet.multipart.max-request-size=${SPRING_SERVLET_MULTIPART_MAX_REQUEST_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.session.tracking-modes=cookie
|
||||||
server.servlet.context-path=${SYSTEM_ROOTURIPATH:/}
|
server.servlet.context-path=${SYSTEM_ROOTURIPATH:/}
|
||||||
spring.devtools.restart.enabled=true
|
spring.devtools.restart.enabled=true
|
||||||
|
|||||||
@@ -240,6 +240,22 @@ system:
|
|||||||
databaseBackup:
|
databaseBackup:
|
||||||
cron: "0 0 0 * * ?" # Cron expression for automatic database backups "0 0 0 * * ?" daily at midnight
|
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:
|
autoPipeline:
|
||||||
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
|
outputFolder: "" # Output folder for processed pipeline files (leave empty for default)
|
||||||
fileReadiness:
|
fileReadiness:
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
+9
-2
@@ -28,9 +28,16 @@ import stirling.software.common.model.exception.UnsupportedProviderException;
|
|||||||
basePackages = {
|
basePackages = {
|
||||||
"stirling.software.proprietary.security.database.repository",
|
"stirling.software.proprietary.security.database.repository",
|
||||||
"stirling.software.proprietary.security.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 class DatabaseConfig {
|
||||||
|
|
||||||
public final String DATASOURCE_DEFAULT_URL;
|
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/**");
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
@@ -612,6 +612,7 @@ public class AdminSettingsController {
|
|||||||
case "endpoints" -> applicationProperties.getEndpoints();
|
case "endpoints" -> applicationProperties.getEndpoints();
|
||||||
case "metrics" -> applicationProperties.getMetrics();
|
case "metrics" -> applicationProperties.getMetrics();
|
||||||
case "mail" -> applicationProperties.getMail();
|
case "mail" -> applicationProperties.getMail();
|
||||||
|
case "storage" -> applicationProperties.getStorage();
|
||||||
case "premium" -> applicationProperties.getPremium();
|
case "premium" -> applicationProperties.getPremium();
|
||||||
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
|
case "processexecutor", "processExecutor" -> applicationProperties.getProcessExecutor();
|
||||||
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
|
case "autopipeline", "autoPipeline" -> applicationProperties.getAutoPipeline();
|
||||||
@@ -633,6 +634,7 @@ public class AdminSettingsController {
|
|||||||
"endpoints",
|
"endpoints",
|
||||||
"metrics",
|
"metrics",
|
||||||
"mail",
|
"mail",
|
||||||
|
"storage",
|
||||||
"premium",
|
"premium",
|
||||||
"processExecutor",
|
"processExecutor",
|
||||||
"processexecutor",
|
"processexecutor",
|
||||||
|
|||||||
+33
@@ -18,6 +18,7 @@ import org.springframework.security.core.session.SessionInformation;
|
|||||||
import org.springframework.security.core.userdetails.UserDetails;
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
import org.springframework.security.oauth2.core.user.OAuth2User;
|
import org.springframework.security.oauth2.core.user.OAuth2User;
|
||||||
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
|
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.PathVariable;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
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.annotations.api.UserApi;
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
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.enumeration.Role;
|
||||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||||
import stirling.software.proprietary.audit.AuditEventType;
|
import stirling.software.proprietary.audit.AuditEventType;
|
||||||
@@ -775,6 +777,7 @@ public class UserController {
|
|||||||
|
|
||||||
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
@PreAuthorize("hasRole('ROLE_ADMIN')")
|
||||||
@PostMapping("/admin/deleteUser/{username}")
|
@PostMapping("/admin/deleteUser/{username}")
|
||||||
|
@Audited(type = AuditEventType.USER_PROFILE_UPDATE, level = AuditLevel.BASIC)
|
||||||
public ResponseEntity<?> deleteUser(
|
public ResponseEntity<?> deleteUser(
|
||||||
@PathVariable("username") String username, Authentication authentication) {
|
@PathVariable("username") String username, Authentication authentication) {
|
||||||
if (!userService.usernameExistsIgnoreCase(username)) {
|
if (!userService.usernameExistsIgnoreCase(username)) {
|
||||||
@@ -964,4 +967,34 @@ public class UserController {
|
|||||||
.body("Failed to complete initial setup");
|
.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
|
@Id
|
||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
@Column(name = "user_id")
|
@Column(name = "user_id")
|
||||||
|
@EqualsAndHashCode.Include
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(name = "username", unique = true)
|
@Column(name = "username", unique = true)
|
||||||
|
|||||||
+81
-2
@@ -41,6 +41,7 @@ import stirling.software.common.service.UserServiceInterface;
|
|||||||
import stirling.software.common.util.RegexPatternUtils;
|
import stirling.software.common.util.RegexPatternUtils;
|
||||||
import stirling.software.proprietary.model.Team;
|
import stirling.software.proprietary.model.Team;
|
||||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
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.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||||
import stirling.software.proprietary.security.model.Authority;
|
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.repository.TeamRepository;
|
||||||
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
|
||||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
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
|
@Service
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -68,6 +79,15 @@ public class UserService implements UserServiceInterface {
|
|||||||
|
|
||||||
private final ApplicationProperties.Security.OAUTH2 oAuth2;
|
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
|
@Transactional
|
||||||
public void processSSOPostLogin(
|
public void processSSOPostLogin(
|
||||||
String username,
|
String username,
|
||||||
@@ -200,19 +220,78 @@ public class UserService implements UserServiceInterface {
|
|||||||
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
|
return userOpt.isPresent() && apiKey.equals(userOpt.get().getApiKey());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
public void deleteUser(String username) {
|
public void deleteUser(String username) {
|
||||||
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
Optional<User> userOpt = findByUsernameIgnoreCase(username);
|
||||||
if (userOpt.isPresent()) {
|
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())) {
|
if (authority.getAuthority().equals(Role.INTERNAL_API_USER.getRoleId())) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
userRepository.delete(userOpt.get());
|
deleteUserRelatedData(user);
|
||||||
|
userRepository.delete(user);
|
||||||
|
persistentLoginRepository.deleteByUsername(username);
|
||||||
}
|
}
|
||||||
invalidateUserSessions(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) {
|
public boolean usernameExists(String username) {
|
||||||
return findByUsername(username).isPresent();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+408
@@ -0,0 +1,408 @@
|
|||||||
|
package stirling.software.proprietary.workflow.controller;
|
||||||
|
|
||||||
|
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.RestController;
|
||||||
|
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.ParticipantRequest;
|
||||||
|
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
|
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 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== HELPER METHODS =====
|
||||||
|
|
||||||
|
private User getCurrentUser(Principal principal) {
|
||||||
|
return userService
|
||||||
|
.findByUsernameIgnoreCase(principal.getName())
|
||||||
|
.orElseThrow(
|
||||||
|
() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Unauthorized"));
|
||||||
|
}
|
||||||
|
}
|
||||||
+328
@@ -0,0 +1,328 @@
|
|||||||
|
package stirling.software.proprietary.workflow.controller;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
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.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.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.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;
|
||||||
|
|
||||||
|
@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 (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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<>();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+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;
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
+75
@@ -0,0 +1,75 @@
|
|||||||
|
package stirling.software.proprietary.workflow.repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.workflow.model.ParticipantStatus;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WorkflowParticipantRepository extends JpaRepository<WorkflowParticipant, Long> {
|
||||||
|
|
||||||
|
/** Find participant by share token */
|
||||||
|
Optional<WorkflowParticipant> findByShareToken(String shareToken);
|
||||||
|
|
||||||
|
/** Find all participants in a workflow session */
|
||||||
|
List<WorkflowParticipant> findByWorkflowSession(WorkflowSession session);
|
||||||
|
|
||||||
|
/** Find participant by session and user */
|
||||||
|
Optional<WorkflowParticipant> findByWorkflowSessionAndUser(WorkflowSession session, User user);
|
||||||
|
|
||||||
|
/** Find participant by session and email */
|
||||||
|
Optional<WorkflowParticipant> findByWorkflowSessionAndEmail(
|
||||||
|
WorkflowSession session, String email);
|
||||||
|
|
||||||
|
/** Find all participants with a specific status in a session */
|
||||||
|
List<WorkflowParticipant> findByWorkflowSessionAndStatus(
|
||||||
|
WorkflowSession session, ParticipantStatus status);
|
||||||
|
|
||||||
|
/** Find all sessions where a user is a participant */
|
||||||
|
List<WorkflowParticipant> findByUserOrderByLastUpdatedDesc(User user);
|
||||||
|
|
||||||
|
/** Find all sessions where an email is a participant */
|
||||||
|
List<WorkflowParticipant> findByEmailOrderByLastUpdatedDesc(String email);
|
||||||
|
|
||||||
|
/** Check if a participant exists by share token */
|
||||||
|
boolean existsByShareToken(String shareToken);
|
||||||
|
|
||||||
|
/** Count participants in a session by status */
|
||||||
|
long countByWorkflowSessionAndStatus(WorkflowSession session, ParticipantStatus status);
|
||||||
|
|
||||||
|
/** Find expired participants that haven't completed */
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM WorkflowParticipant p WHERE p.expiresAt < CURRENT_TIMESTAMP AND p.status NOT IN ('SIGNED', 'DECLINED')")
|
||||||
|
List<WorkflowParticipant> findExpiredIncompleteParticipants();
|
||||||
|
|
||||||
|
/** Find all participants pending notification */
|
||||||
|
@Query(
|
||||||
|
"SELECT p FROM WorkflowParticipant p WHERE p.status = 'PENDING' AND p.workflowSession.status = 'IN_PROGRESS'")
|
||||||
|
List<WorkflowParticipant> findPendingNotifications();
|
||||||
|
|
||||||
|
/** Delete participant by ID and session owner (for authorization) */
|
||||||
|
@Query(
|
||||||
|
"DELETE FROM WorkflowParticipant p WHERE p.id = :participantId AND p.workflowSession.owner = :owner")
|
||||||
|
void deleteByIdAndSessionOwner(
|
||||||
|
@Param("participantId") Long participantId, @Param("owner") User owner);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Null out the user reference for all participants linked to the given user. Used during user
|
||||||
|
* deletion to preserve workflow audit history while removing the personal data link.
|
||||||
|
* Participants in sessions owned by others are retained but de-linked from the deleted account.
|
||||||
|
*/
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query("UPDATE WorkflowParticipant wp SET wp.user = null WHERE wp.user = :user")
|
||||||
|
void clearUserReferences(@Param("user") User user);
|
||||||
|
}
|
||||||
+61
@@ -0,0 +1,61 @@
|
|||||||
|
package stirling.software.proprietary.workflow.repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowStatus;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowType;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WorkflowSessionRepository extends JpaRepository<WorkflowSession, Long> {
|
||||||
|
|
||||||
|
/** Find workflow session by unique session ID */
|
||||||
|
Optional<WorkflowSession> findBySessionId(String sessionId);
|
||||||
|
|
||||||
|
/** Find workflow session by unique session ID with participants eagerly loaded */
|
||||||
|
@Query(
|
||||||
|
"SELECT ws FROM WorkflowSession ws LEFT JOIN FETCH ws.participants WHERE ws.sessionId = :sessionId")
|
||||||
|
Optional<WorkflowSession> findBySessionIdWithParticipants(@Param("sessionId") String sessionId);
|
||||||
|
|
||||||
|
/** Find all workflow sessions owned by a specific user */
|
||||||
|
List<WorkflowSession> findByOwnerOrderByCreatedAtDesc(User owner);
|
||||||
|
|
||||||
|
/** Find all workflow sessions of a specific type for a user */
|
||||||
|
List<WorkflowSession> findByOwnerAndWorkflowTypeOrderByCreatedAtDesc(
|
||||||
|
User owner, WorkflowType workflowType);
|
||||||
|
|
||||||
|
/** Find all workflow sessions with a specific status */
|
||||||
|
List<WorkflowSession> findByStatusOrderByCreatedAtDesc(WorkflowStatus status);
|
||||||
|
|
||||||
|
/** Find all active (non-finalized, in-progress) sessions for a user */
|
||||||
|
@Query(
|
||||||
|
"SELECT ws FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false ORDER BY ws.createdAt DESC")
|
||||||
|
List<WorkflowSession> findActiveSessionsByOwner(@Param("owner") User owner);
|
||||||
|
|
||||||
|
/** Find all finalized sessions for a user */
|
||||||
|
List<WorkflowSession> findByOwnerAndFinalizedTrueOrderByCreatedAtDesc(User owner);
|
||||||
|
|
||||||
|
/** Check if a session exists by session ID */
|
||||||
|
boolean existsBySessionId(String sessionId);
|
||||||
|
|
||||||
|
/** Find sessions that need cleanup (e.g., old cancelled sessions) */
|
||||||
|
@Query(
|
||||||
|
"SELECT ws FROM WorkflowSession ws WHERE ws.status = 'CANCELLED' AND ws.updatedAt < :cutoffDate")
|
||||||
|
List<WorkflowSession> findCancelledSessionsOlderThan(
|
||||||
|
@Param("cutoffDate") java.time.LocalDateTime cutoffDate);
|
||||||
|
|
||||||
|
/** Count active sessions for a user */
|
||||||
|
@Query(
|
||||||
|
"SELECT COUNT(ws) FROM WorkflowSession ws WHERE ws.owner = :owner AND ws.status = 'IN_PROGRESS' AND ws.finalized = false")
|
||||||
|
long countActiveSessionsByOwner(@Param("owner") User owner);
|
||||||
|
|
||||||
|
/** Delete session by session ID and owner (for authorization) */
|
||||||
|
void deleteBySessionIdAndOwner(String sessionId, User owner);
|
||||||
|
}
|
||||||
+119
@@ -0,0 +1,119 @@
|
|||||||
|
package stirling.software.proprietary.workflow.service;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import javax.crypto.Cipher;
|
||||||
|
import javax.crypto.spec.GCMParameterSpec;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides AES-256-GCM encryption for sensitive fields stored in JSONB metadata columns (e.g.
|
||||||
|
* keystore passwords). The encryption key is derived from the application's
|
||||||
|
* AutomaticallyGenerated.key, which is persisted in settings on first run.
|
||||||
|
*
|
||||||
|
* <p>Encrypted values are prefixed with {@value #ENC_PREFIX} so that legacy plaintext values
|
||||||
|
* written before this service was introduced can still be decrypted transparently.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class MetadataEncryptionService {
|
||||||
|
|
||||||
|
static final String ENC_PREFIX = "enc:";
|
||||||
|
private static final String ALGORITHM = "AES/GCM/NoPadding";
|
||||||
|
private static final int GCM_IV_LENGTH = 12;
|
||||||
|
private static final int GCM_TAG_LENGTH = 128; // bits
|
||||||
|
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
|
// ── Public API ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts {@code plaintext} with AES-256-GCM and returns a Base64-encoded ciphertext prefixed
|
||||||
|
* with {@value #ENC_PREFIX}.
|
||||||
|
*/
|
||||||
|
public String encrypt(String plaintext) {
|
||||||
|
if (plaintext == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
SecretKeySpec keySpec = deriveKey();
|
||||||
|
byte[] iv = generateIv();
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||||
|
|
||||||
|
byte[] cipherBytes = cipher.doFinal(plaintext.getBytes(StandardCharsets.UTF_8));
|
||||||
|
|
||||||
|
// Prepend IV to ciphertext for storage: [12-byte IV][ciphertext+tag]
|
||||||
|
byte[] combined = new byte[iv.length + cipherBytes.length];
|
||||||
|
System.arraycopy(iv, 0, combined, 0, iv.length);
|
||||||
|
System.arraycopy(cipherBytes, 0, combined, iv.length, cipherBytes.length);
|
||||||
|
|
||||||
|
return ENC_PREFIX + Base64.getEncoder().encodeToString(combined);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to encrypt metadata field", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts a value produced by {@link #encrypt}. If the value does not start with {@value
|
||||||
|
* #ENC_PREFIX} it is returned as-is to preserve backwards compatibility with plaintext values
|
||||||
|
* written before this service existed.
|
||||||
|
*/
|
||||||
|
public String decrypt(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!value.startsWith(ENC_PREFIX)) {
|
||||||
|
// Legacy plaintext – return unchanged
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
SecretKeySpec keySpec = deriveKey();
|
||||||
|
byte[] combined = Base64.getDecoder().decode(value.substring(ENC_PREFIX.length()));
|
||||||
|
|
||||||
|
byte[] iv = Arrays.copyOfRange(combined, 0, GCM_IV_LENGTH);
|
||||||
|
byte[] cipherBytes = Arrays.copyOfRange(combined, GCM_IV_LENGTH, combined.length);
|
||||||
|
|
||||||
|
Cipher cipher = Cipher.getInstance(ALGORITHM);
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, keySpec, new GCMParameterSpec(GCM_TAG_LENGTH, iv));
|
||||||
|
|
||||||
|
return new String(cipher.doFinal(cipherBytes), StandardCharsets.UTF_8);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to decrypt metadata field", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Internals ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private SecretKeySpec deriveKey() throws Exception {
|
||||||
|
String rawKey = applicationProperties.getAutomaticallyGenerated().getKey();
|
||||||
|
if (rawKey == null || rawKey.isBlank()) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"AutomaticallyGenerated.key is not initialised — cannot derive encryption key");
|
||||||
|
}
|
||||||
|
// SHA-256 of the raw key gives a stable 32-byte AES-256 key
|
||||||
|
byte[] hash =
|
||||||
|
MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(rawKey.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return new SecretKeySpec(hash, "AES");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] generateIv() {
|
||||||
|
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||||
|
new SecureRandom().nextBytes(iv);
|
||||||
|
return iv;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1308
File diff suppressed because it is too large
Load Diff
+233
@@ -0,0 +1,233 @@
|
|||||||
|
package stirling.software.proprietary.workflow.service;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.storage.model.FileShare;
|
||||||
|
import stirling.software.proprietary.storage.model.ShareAccessRole;
|
||||||
|
import stirling.software.proprietary.storage.model.StoredFile;
|
||||||
|
import stirling.software.proprietary.storage.repository.FileShareRepository;
|
||||||
|
import stirling.software.proprietary.workflow.model.ParticipantStatus;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
|
||||||
|
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified access control service that consolidates validation logic for both generic file shares
|
||||||
|
* and workflow participants.
|
||||||
|
*
|
||||||
|
* <p>This service bridges the gap between the file sharing infrastructure and workflow-specific
|
||||||
|
* access control.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public class UnifiedAccessControlService {
|
||||||
|
|
||||||
|
private final FileShareRepository fileShareRepository;
|
||||||
|
private final WorkflowParticipantRepository workflowParticipantRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a share token and returns access validation result. Works for both generic file
|
||||||
|
* shares and workflow participant shares.
|
||||||
|
*/
|
||||||
|
public AccessValidationResult validateToken(String token, User user) {
|
||||||
|
log.debug("Validating access token: {}", token);
|
||||||
|
|
||||||
|
// First try as file share token
|
||||||
|
Optional<FileShare> fileShareOpt = fileShareRepository.findByShareTokenWithFile(token);
|
||||||
|
if (fileShareOpt.isPresent()) {
|
||||||
|
return validateGenericShare(fileShareOpt.get(), user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try as workflow participant token
|
||||||
|
Optional<WorkflowParticipant> participantOpt =
|
||||||
|
workflowParticipantRepository.findByShareToken(token);
|
||||||
|
if (participantOpt.isPresent()) {
|
||||||
|
return validateParticipant(participantOpt.get(), user);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.warn("Invalid or expired token: {}", token);
|
||||||
|
return AccessValidationResult.denied("Invalid or expired access token");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates a generic file share (non-workflow) */
|
||||||
|
private AccessValidationResult validateGenericShare(FileShare share, User user) {
|
||||||
|
// Check expiration
|
||||||
|
if (share.getExpiresAt() != null && LocalDateTime.now().isAfter(share.getExpiresAt())) {
|
||||||
|
log.warn("Share token expired: {}", share.getShareToken());
|
||||||
|
return AccessValidationResult.denied("Access link has expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user matches (if share is user-specific)
|
||||||
|
if (share.getSharedWithUser() != null && !share.getSharedWithUser().equals(user)) {
|
||||||
|
log.warn(
|
||||||
|
"User mismatch for share: expected {}, got {}",
|
||||||
|
share.getSharedWithUser().getId(),
|
||||||
|
user != null ? user.getId() : "null");
|
||||||
|
return AccessValidationResult.denied("Access denied for this user");
|
||||||
|
}
|
||||||
|
|
||||||
|
return AccessValidationResult.allowed(share.getFile(), share.getAccessRole(), null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates a workflow participant by token */
|
||||||
|
private AccessValidationResult validateParticipant(WorkflowParticipant participant, User user) {
|
||||||
|
// Check expiration
|
||||||
|
if (participant.isExpired()) {
|
||||||
|
log.warn("Workflow participant access expired: {}", participant.getShareToken());
|
||||||
|
return AccessValidationResult.denied("Workflow access has expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if workflow is still active
|
||||||
|
if (!participant.getWorkflowSession().isActive()) {
|
||||||
|
log.info(
|
||||||
|
"Workflow session no longer active: {}",
|
||||||
|
participant.getWorkflowSession().getSessionId());
|
||||||
|
return AccessValidationResult.denied("Workflow session is no longer active");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user authorization
|
||||||
|
if (participant.getUser() != null && !participant.getUser().equals(user)) {
|
||||||
|
log.warn(
|
||||||
|
"User mismatch for participant: expected {}, got {}",
|
||||||
|
participant.getUser().getId(),
|
||||||
|
user != null ? user.getId() : "null");
|
||||||
|
return AccessValidationResult.denied("Access denied for this user");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get effective role based on participant status
|
||||||
|
ShareAccessRole effectiveRole = getEffectiveRole(participant);
|
||||||
|
|
||||||
|
// Get the file from the workflow session
|
||||||
|
StoredFile file = participant.getWorkflowSession().getOriginalFile();
|
||||||
|
|
||||||
|
return AccessValidationResult.allowed(file, effectiveRole, participant, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps participant status to effective access role. After completion (signed/declined),
|
||||||
|
* downgrade to VIEWER.
|
||||||
|
*/
|
||||||
|
public ShareAccessRole getEffectiveRole(WorkflowParticipant participant) {
|
||||||
|
ParticipantStatus status = participant.getStatus();
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case SIGNED:
|
||||||
|
case DECLINED:
|
||||||
|
// After action completed, downgrade to read-only
|
||||||
|
return ShareAccessRole.VIEWER;
|
||||||
|
case PENDING:
|
||||||
|
case NOTIFIED:
|
||||||
|
case VIEWED:
|
||||||
|
// Active participants retain their assigned role
|
||||||
|
return participant.getAccessRole();
|
||||||
|
default:
|
||||||
|
log.warn("Unknown participant status: {}", status);
|
||||||
|
return ShareAccessRole.VIEWER;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks if a user can access a specific file */
|
||||||
|
public boolean canAccessFile(User user, StoredFile file) {
|
||||||
|
// Owner always has access
|
||||||
|
if (file.getOwner().equals(user)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for file share
|
||||||
|
Optional<FileShare> share = fileShareRepository.findByFileAndSharedWithUser(file, user);
|
||||||
|
if (share.isPresent() && !isExpired(share.get())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for workflow participant access
|
||||||
|
if (file.getWorkflowSession() != null) {
|
||||||
|
Optional<WorkflowParticipant> participant =
|
||||||
|
workflowParticipantRepository.findByWorkflowSessionAndUser(
|
||||||
|
file.getWorkflowSession(), user);
|
||||||
|
return participant.isPresent()
|
||||||
|
&& !participant.get().isExpired()
|
||||||
|
&& participant.get().getWorkflowSession().isActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isExpired(FileShare share) {
|
||||||
|
return share.getExpiresAt() != null && LocalDateTime.now().isAfter(share.getExpiresAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of access validation */
|
||||||
|
public static class AccessValidationResult {
|
||||||
|
private final boolean allowed;
|
||||||
|
private final String denialReason;
|
||||||
|
private final StoredFile file;
|
||||||
|
private final ShareAccessRole role;
|
||||||
|
private final WorkflowParticipant participant;
|
||||||
|
private final boolean isWorkflowAccess;
|
||||||
|
|
||||||
|
private AccessValidationResult(
|
||||||
|
boolean allowed,
|
||||||
|
String denialReason,
|
||||||
|
StoredFile file,
|
||||||
|
ShareAccessRole role,
|
||||||
|
WorkflowParticipant participant,
|
||||||
|
boolean isWorkflowAccess) {
|
||||||
|
this.allowed = allowed;
|
||||||
|
this.denialReason = denialReason;
|
||||||
|
this.file = file;
|
||||||
|
this.role = role;
|
||||||
|
this.participant = participant;
|
||||||
|
this.isWorkflowAccess = isWorkflowAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AccessValidationResult allowed(
|
||||||
|
StoredFile file,
|
||||||
|
ShareAccessRole role,
|
||||||
|
WorkflowParticipant participant,
|
||||||
|
boolean isWorkflowAccess) {
|
||||||
|
return new AccessValidationResult(
|
||||||
|
true, null, file, role, participant, isWorkflowAccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AccessValidationResult denied(String reason) {
|
||||||
|
return new AccessValidationResult(false, reason, null, null, null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAllowed() {
|
||||||
|
return allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDenialReason() {
|
||||||
|
return denialReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoredFile getFile() {
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ShareAccessRole getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public WorkflowParticipant getParticipant() {
|
||||||
|
return participant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isWorkflowAccess() {
|
||||||
|
return isWorkflowAccess;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean canEdit() {
|
||||||
|
return allowed && (role == ShareAccessRole.EDITOR || role == ShareAccessRole.COMMENTER);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
package stirling.software.proprietary.workflow.service;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.security.*;
|
||||||
|
import java.security.cert.Certificate;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.bouncycastle.asn1.x500.X500Name;
|
||||||
|
import org.bouncycastle.asn1.x509.BasicConstraints;
|
||||||
|
import org.bouncycastle.asn1.x509.ExtendedKeyUsage;
|
||||||
|
import org.bouncycastle.asn1.x509.Extension;
|
||||||
|
import org.bouncycastle.asn1.x509.KeyPurposeId;
|
||||||
|
import org.bouncycastle.asn1.x509.KeyUsage;
|
||||||
|
import org.bouncycastle.cert.X509CertificateHolder;
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils;
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.bouncycastle.operator.ContentSigner;
|
||||||
|
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.workflow.model.CertificateType;
|
||||||
|
import stirling.software.proprietary.workflow.model.UserServerCertificateEntity;
|
||||||
|
import stirling.software.proprietary.workflow.repository.UserServerCertificateRepository;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserServerCertificateService {
|
||||||
|
|
||||||
|
private static final String KEYSTORE_ALIAS = "stirling-pdf-user-cert";
|
||||||
|
private static final String DEFAULT_PASSWORD_PREFIX = "stirling-user-cert-";
|
||||||
|
private static final int VALIDITY_DAYS = 365;
|
||||||
|
|
||||||
|
private final UserServerCertificateRepository certificateRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final MetadataEncryptionService metadataEncryptionService;
|
||||||
|
|
||||||
|
static {
|
||||||
|
Security.addProvider(new BouncyCastleProvider());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get or create user certificate (auto-generate if not exists) */
|
||||||
|
@Transactional
|
||||||
|
public UserServerCertificateEntity getOrCreateUserCertificate(Long userId) throws Exception {
|
||||||
|
Optional<UserServerCertificateEntity> existing = certificateRepository.findByUserId(userId);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
return existing.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
User user =
|
||||||
|
userRepository
|
||||||
|
.findById(userId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||||
|
return generateUserCertificate(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate new certificate for user */
|
||||||
|
@Transactional
|
||||||
|
public UserServerCertificateEntity generateUserCertificate(User user) throws Exception {
|
||||||
|
log.info("Generating server certificate for user: {}", user.getUsername());
|
||||||
|
|
||||||
|
// Generate key pair
|
||||||
|
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||||
|
keyPairGenerator.initialize(2048, new SecureRandom());
|
||||||
|
KeyPair keyPair = keyPairGenerator.generateKeyPair();
|
||||||
|
|
||||||
|
// Certificate details with username
|
||||||
|
String username = user.getUsername();
|
||||||
|
X500Name subject = new X500Name("CN=" + username + ", O=Stirling-PDF User, C=US");
|
||||||
|
BigInteger serialNumber = BigInteger.valueOf(System.currentTimeMillis());
|
||||||
|
Date notBefore = new Date();
|
||||||
|
Date notAfter =
|
||||||
|
new Date(notBefore.getTime() + ((long) VALIDITY_DAYS * 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
|
// Build certificate
|
||||||
|
JcaX509v3CertificateBuilder certBuilder =
|
||||||
|
new JcaX509v3CertificateBuilder(
|
||||||
|
subject, serialNumber, notBefore, notAfter, subject, keyPair.getPublic());
|
||||||
|
|
||||||
|
// Add PDF-specific certificate extensions
|
||||||
|
JcaX509ExtensionUtils extUtils = new JcaX509ExtensionUtils();
|
||||||
|
|
||||||
|
// End-entity certificate, not a CA
|
||||||
|
certBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false));
|
||||||
|
|
||||||
|
// Key usage for PDF digital signatures
|
||||||
|
certBuilder.addExtension(
|
||||||
|
Extension.keyUsage,
|
||||||
|
true,
|
||||||
|
new KeyUsage(KeyUsage.digitalSignature | KeyUsage.nonRepudiation));
|
||||||
|
|
||||||
|
// Extended key usage for document signing
|
||||||
|
certBuilder.addExtension(
|
||||||
|
Extension.extendedKeyUsage,
|
||||||
|
false,
|
||||||
|
new ExtendedKeyUsage(KeyPurposeId.id_kp_codeSigning));
|
||||||
|
|
||||||
|
// Subject Key Identifier
|
||||||
|
certBuilder.addExtension(
|
||||||
|
Extension.subjectKeyIdentifier,
|
||||||
|
false,
|
||||||
|
extUtils.createSubjectKeyIdentifier(keyPair.getPublic()));
|
||||||
|
|
||||||
|
// Authority Key Identifier for self-signed cert
|
||||||
|
certBuilder.addExtension(
|
||||||
|
Extension.authorityKeyIdentifier,
|
||||||
|
false,
|
||||||
|
extUtils.createAuthorityKeyIdentifier(keyPair.getPublic()));
|
||||||
|
|
||||||
|
// Sign certificate
|
||||||
|
ContentSigner signer =
|
||||||
|
new JcaContentSignerBuilder("SHA256WithRSA")
|
||||||
|
.setProvider("BC")
|
||||||
|
.build(keyPair.getPrivate());
|
||||||
|
|
||||||
|
X509CertificateHolder certHolder = certBuilder.build(signer);
|
||||||
|
X509Certificate cert =
|
||||||
|
new JcaX509CertificateConverter().setProvider("BC").getCertificate(certHolder);
|
||||||
|
|
||||||
|
// Create keystore
|
||||||
|
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||||
|
keyStore.load(null, null);
|
||||||
|
String password = generateUserPassword(user.getId());
|
||||||
|
keyStore.setKeyEntry(
|
||||||
|
KEYSTORE_ALIAS,
|
||||||
|
keyPair.getPrivate(),
|
||||||
|
password.toCharArray(),
|
||||||
|
new Certificate[] {cert});
|
||||||
|
|
||||||
|
// Store keystore bytes
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
keyStore.store(baos, password.toCharArray());
|
||||||
|
byte[] keystoreBytes = baos.toByteArray();
|
||||||
|
|
||||||
|
// Create entity
|
||||||
|
UserServerCertificateEntity entity = new UserServerCertificateEntity();
|
||||||
|
entity.setUser(user);
|
||||||
|
entity.setKeystoreData(keystoreBytes);
|
||||||
|
entity.setKeystorePassword(metadataEncryptionService.encrypt(password));
|
||||||
|
entity.setCertificateType(CertificateType.AUTO_GENERATED);
|
||||||
|
entity.setSubjectDn(cert.getSubjectX500Principal().getName());
|
||||||
|
entity.setIssuerDn(cert.getIssuerX500Principal().getName());
|
||||||
|
entity.setValidFrom(
|
||||||
|
LocalDateTime.ofInstant(cert.getNotBefore().toInstant(), ZoneId.systemDefault()));
|
||||||
|
entity.setValidTo(
|
||||||
|
LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault()));
|
||||||
|
|
||||||
|
return certificateRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Upload user-provided certificate */
|
||||||
|
@Transactional
|
||||||
|
public UserServerCertificateEntity uploadUserCertificate(
|
||||||
|
User user, InputStream p12Stream, String password) throws Exception {
|
||||||
|
log.info("Uploading user certificate for user: {}", user.getUsername());
|
||||||
|
|
||||||
|
// Validate keystore
|
||||||
|
byte[] keystoreBytes = p12Stream.readNBytes(10 * 1024 * 1024 + 1); // read at most 10 MB + 1
|
||||||
|
if (keystoreBytes.length > 10 * 1024 * 1024) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Keystore file exceeds maximum allowed size of 10 MB");
|
||||||
|
}
|
||||||
|
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||||
|
keyStore.load(new ByteArrayInputStream(keystoreBytes), password.toCharArray());
|
||||||
|
|
||||||
|
// Extract certificate info
|
||||||
|
String alias = keyStore.aliases().nextElement();
|
||||||
|
X509Certificate cert = (X509Certificate) keyStore.getCertificate(alias);
|
||||||
|
|
||||||
|
if (cert == null) {
|
||||||
|
throw new IllegalArgumentException("No certificate found in keystore");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create or update entity
|
||||||
|
UserServerCertificateEntity entity =
|
||||||
|
certificateRepository
|
||||||
|
.findByUserId(user.getId())
|
||||||
|
.orElse(new UserServerCertificateEntity());
|
||||||
|
|
||||||
|
entity.setUser(user);
|
||||||
|
entity.setKeystoreData(keystoreBytes);
|
||||||
|
entity.setKeystorePassword(metadataEncryptionService.encrypt(password));
|
||||||
|
entity.setCertificateType(CertificateType.USER_UPLOADED);
|
||||||
|
entity.setSubjectDn(cert.getSubjectX500Principal().getName());
|
||||||
|
entity.setIssuerDn(cert.getIssuerX500Principal().getName());
|
||||||
|
entity.setValidFrom(
|
||||||
|
LocalDateTime.ofInstant(cert.getNotBefore().toInstant(), ZoneId.systemDefault()));
|
||||||
|
entity.setValidTo(
|
||||||
|
LocalDateTime.ofInstant(cert.getNotAfter().toInstant(), ZoneId.systemDefault()));
|
||||||
|
|
||||||
|
return certificateRepository.save(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get user's KeyStore for signing operations */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public KeyStore getUserKeyStore(Long userId) throws Exception {
|
||||||
|
UserServerCertificateEntity cert =
|
||||||
|
certificateRepository
|
||||||
|
.findByUserId(userId)
|
||||||
|
.orElseThrow(
|
||||||
|
() -> new IllegalArgumentException("User certificate not found"));
|
||||||
|
|
||||||
|
KeyStore keyStore = KeyStore.getInstance("PKCS12");
|
||||||
|
keyStore.load(
|
||||||
|
new ByteArrayInputStream(cert.getKeystoreData()),
|
||||||
|
metadataEncryptionService.decrypt(cert.getKeystorePassword()).toCharArray());
|
||||||
|
return keyStore;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get user's keystore password */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String getUserKeystorePassword(Long userId) {
|
||||||
|
UserServerCertificateEntity cert =
|
||||||
|
certificateRepository
|
||||||
|
.findByUserId(userId)
|
||||||
|
.orElseThrow(
|
||||||
|
() -> new IllegalArgumentException("User certificate not found"));
|
||||||
|
return metadataEncryptionService.decrypt(cert.getKeystorePassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete user certificate */
|
||||||
|
@Transactional
|
||||||
|
public void deleteUserCertificate(Long userId) {
|
||||||
|
certificateRepository.findByUserId(userId).ifPresent(certificateRepository::delete);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if user has certificate */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean hasUserCertificate(Long userId) {
|
||||||
|
return certificateRepository.findByUserId(userId).isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get certificate info (without keystore data) */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<UserServerCertificateEntity> getCertificateInfo(Long userId) {
|
||||||
|
return certificateRepository.findByUserId(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate consistent password for user (based on user ID) */
|
||||||
|
private String generateUserPassword(Long userId) {
|
||||||
|
return DEFAULT_PASSWORD_PREFIX + userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
+869
@@ -0,0 +1,869 @@
|
|||||||
|
package stirling.software.proprietary.workflow.service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.storage.model.FilePurpose;
|
||||||
|
import stirling.software.proprietary.storage.model.ShareAccessRole;
|
||||||
|
import stirling.software.proprietary.storage.model.StoredFile;
|
||||||
|
import stirling.software.proprietary.storage.provider.StorageProvider;
|
||||||
|
import stirling.software.proprietary.storage.provider.StoredObject;
|
||||||
|
import stirling.software.proprietary.storage.repository.StoredFileRepository;
|
||||||
|
import stirling.software.proprietary.workflow.dto.ParticipantRequest;
|
||||||
|
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
|
||||||
|
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
|
||||||
|
import stirling.software.proprietary.workflow.model.ParticipantStatus;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowStatus;
|
||||||
|
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||||
|
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
|
||||||
|
|
||||||
|
import tools.jackson.core.JacksonException;
|
||||||
|
import tools.jackson.core.type.TypeReference;
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Core service for workflow session management. Handles creation, participant management, and
|
||||||
|
* lifecycle coordination.
|
||||||
|
*
|
||||||
|
* <p>Delegates file storage to FileStorageService/StorageProvider and integrates with the file
|
||||||
|
* sharing infrastructure.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
@Transactional
|
||||||
|
public class WorkflowSessionService {
|
||||||
|
|
||||||
|
private final WorkflowSessionRepository workflowSessionRepository;
|
||||||
|
private final WorkflowParticipantRepository workflowParticipantRepository;
|
||||||
|
private final StoredFileRepository storedFileRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final StorageProvider storageProvider;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
|
private final MetadataEncryptionService metadataEncryptionService;
|
||||||
|
|
||||||
|
public void ensureSigningEnabled() {
|
||||||
|
if (!applicationProperties.getStorage().isEnabled()
|
||||||
|
|| !applicationProperties.getStorage().getSigning().isEnabled()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Group signing is disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new workflow session with participants. Stores the original file using
|
||||||
|
* StorageProvider.
|
||||||
|
*/
|
||||||
|
public WorkflowSession createSession(
|
||||||
|
User owner, MultipartFile file, WorkflowCreationRequest request) throws IOException {
|
||||||
|
log.info(
|
||||||
|
"Creating workflow session for user {} with type {}",
|
||||||
|
owner.getUsername(),
|
||||||
|
request.getWorkflowType());
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "File is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getWorkflowType() == null) {
|
||||||
|
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Workflow type is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store original file using StorageProvider
|
||||||
|
StoredFile originalFile = storeWorkflowFile(owner, file, FilePurpose.SIGNING_ORIGINAL);
|
||||||
|
|
||||||
|
// Create workflow session
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId(UUID.randomUUID().toString());
|
||||||
|
session.setOwner(owner);
|
||||||
|
session.setWorkflowType(request.getWorkflowType());
|
||||||
|
session.setDocumentName(
|
||||||
|
request.getDocumentName() != null
|
||||||
|
? request.getDocumentName()
|
||||||
|
: file.getOriginalFilename());
|
||||||
|
session.setOriginalFile(originalFile);
|
||||||
|
session.setOwnerEmail(request.getOwnerEmail());
|
||||||
|
session.setMessage(request.getMessage());
|
||||||
|
session.setDueDate(request.getDueDate());
|
||||||
|
session.setStatus(WorkflowStatus.IN_PROGRESS);
|
||||||
|
|
||||||
|
// Parse workflow metadata from JSON string to Map
|
||||||
|
if (request.getWorkflowMetadata() != null && !request.getWorkflowMetadata().isBlank()) {
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> metadataMap =
|
||||||
|
objectMapper.readValue(request.getWorkflowMetadata(), Map.class);
|
||||||
|
session.setWorkflowMetadata(metadataMap);
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Invalid workflowMetadata: must be a valid JSON object");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link file back to session
|
||||||
|
originalFile.setWorkflowSession(session);
|
||||||
|
originalFile.setPurpose(FilePurpose.SIGNING_ORIGINAL);
|
||||||
|
|
||||||
|
session = workflowSessionRepository.save(session);
|
||||||
|
storedFileRepository.save(originalFile);
|
||||||
|
|
||||||
|
// Add participants
|
||||||
|
List<ParticipantRequest> participants = new ArrayList<>();
|
||||||
|
|
||||||
|
if (request.getParticipantUserIds() != null) {
|
||||||
|
for (Long userId : request.getParticipantUserIds()) {
|
||||||
|
ParticipantRequest pr = new ParticipantRequest();
|
||||||
|
pr.setUserId(userId);
|
||||||
|
pr.setAccessRole(ShareAccessRole.EDITOR);
|
||||||
|
participants.add(pr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.getParticipantEmails() != null) {
|
||||||
|
for (String email : request.getParticipantEmails()) {
|
||||||
|
ParticipantRequest pr = new ParticipantRequest();
|
||||||
|
pr.setEmail(email);
|
||||||
|
pr.setAccessRole(ShareAccessRole.EDITOR);
|
||||||
|
participants.add(pr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!participants.isEmpty()) {
|
||||||
|
addParticipantsToSession(session, participants);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Created workflow session {} with {} participants",
|
||||||
|
session.getSessionId(),
|
||||||
|
session.getParticipants().size());
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds participants to a workflow session. */
|
||||||
|
private void addParticipantsToSession(
|
||||||
|
WorkflowSession session, List<ParticipantRequest> participantRequests) {
|
||||||
|
for (ParticipantRequest request : participantRequests) {
|
||||||
|
WorkflowParticipant participant = new WorkflowParticipant();
|
||||||
|
participant.setShareToken(UUID.randomUUID().toString());
|
||||||
|
participant.setAccessRole(
|
||||||
|
request.getAccessRole() != null
|
||||||
|
? request.getAccessRole()
|
||||||
|
: ShareAccessRole.EDITOR);
|
||||||
|
participant.setExpiresAt(request.getExpiresAt());
|
||||||
|
|
||||||
|
// Parse participant metadata from JSON string to Map
|
||||||
|
if (request.getParticipantMetadata() != null
|
||||||
|
&& !request.getParticipantMetadata().isBlank()) {
|
||||||
|
try {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> metadataMap =
|
||||||
|
objectMapper.readValue(request.getParticipantMetadata(), Map.class);
|
||||||
|
participant.setParticipantMetadata(metadataMap);
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
log.warn(
|
||||||
|
"Failed to parse participant metadata for {}, using empty map",
|
||||||
|
request.getEmail(),
|
||||||
|
e);
|
||||||
|
participant.setParticipantMetadata(new HashMap<>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store defaultReason in participant metadata if provided
|
||||||
|
if (request.getDefaultReason() != null && !request.getDefaultReason().isBlank()) {
|
||||||
|
Map<String, Object> metadata = participant.getParticipantMetadata();
|
||||||
|
if (metadata == null) {
|
||||||
|
metadata = new HashMap<>();
|
||||||
|
}
|
||||||
|
metadata.put("defaultReason", request.getDefaultReason());
|
||||||
|
participant.setParticipantMetadata(metadata);
|
||||||
|
log.debug(
|
||||||
|
"Set default reason for participant {}: {}",
|
||||||
|
request.getEmail(),
|
||||||
|
request.getDefaultReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
participant.setStatus(ParticipantStatus.PENDING);
|
||||||
|
|
||||||
|
// Set user or email
|
||||||
|
if (request.getUserId() != null) {
|
||||||
|
User user =
|
||||||
|
userRepository
|
||||||
|
.findById(request.getUserId())
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"User not found: " + request.getUserId()));
|
||||||
|
participant.setUser(user);
|
||||||
|
participant.setEmail(user.getUsername()); // User entity uses username, not email
|
||||||
|
participant.setName(user.getUsername());
|
||||||
|
} else if (request.getEmail() != null) {
|
||||||
|
participant.setEmail(request.getEmail());
|
||||||
|
participant.setName(
|
||||||
|
request.getName() != null ? request.getName() : request.getEmail());
|
||||||
|
} else {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Participant must have either userId or email");
|
||||||
|
}
|
||||||
|
|
||||||
|
session.addParticipant(participant);
|
||||||
|
participant = workflowParticipantRepository.save(participant);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores a file as part of a workflow using the StorageProvider. */
|
||||||
|
private StoredFile storeWorkflowFile(User owner, MultipartFile file, FilePurpose purpose)
|
||||||
|
throws IOException {
|
||||||
|
// Store file content (storage provider generates the key)
|
||||||
|
StoredObject storedObject = storageProvider.store(owner, file);
|
||||||
|
|
||||||
|
// Create StoredFile entity
|
||||||
|
StoredFile storedFile = new StoredFile();
|
||||||
|
storedFile.setOwner(owner);
|
||||||
|
storedFile.setOriginalFilename(storedObject.getOriginalFilename());
|
||||||
|
storedFile.setContentType(storedObject.getContentType());
|
||||||
|
storedFile.setSizeBytes(storedObject.getSizeBytes());
|
||||||
|
storedFile.setStorageKey(storedObject.getStorageKey());
|
||||||
|
storedFile.setPurpose(purpose);
|
||||||
|
|
||||||
|
return storedFileRepository.save(storedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves a workflow session by session ID. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public WorkflowSession getSession(String sessionId) {
|
||||||
|
return workflowSessionRepository
|
||||||
|
.findBySessionId(sessionId)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"Workflow session not found: " + sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves a workflow session with authorization check. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public WorkflowSession getSessionForOwner(String sessionId, User owner) {
|
||||||
|
WorkflowSession session = getSession(sessionId);
|
||||||
|
if (!session.getOwner().equals(owner)) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.FORBIDDEN, "Not authorized to access this workflow session");
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves a workflow session with participants eagerly loaded for finalization. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public WorkflowSession getSessionWithParticipants(String sessionId) {
|
||||||
|
return workflowSessionRepository
|
||||||
|
.findBySessionIdWithParticipants(sessionId)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"Workflow session not found: " + sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves a workflow session with participants, with authorization check. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public WorkflowSession getSessionWithParticipantsForOwner(String sessionId, User owner) {
|
||||||
|
WorkflowSession session = getSessionWithParticipants(sessionId);
|
||||||
|
if (!session.getOwner().equals(owner)) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.FORBIDDEN, "Not authorized to access this workflow session");
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists all workflow sessions owned by a user. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<WorkflowSession> listUserSessions(User owner) {
|
||||||
|
return workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lists active workflow sessions for a user. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<WorkflowSession> listActiveSessions(User owner) {
|
||||||
|
return workflowSessionRepository.findActiveSessionsByOwner(owner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds additional participants to an existing session. */
|
||||||
|
@Transactional
|
||||||
|
public void addParticipants(
|
||||||
|
String sessionId, List<ParticipantRequest> participants, User owner) {
|
||||||
|
WorkflowSession session = getSessionForOwner(sessionId, owner);
|
||||||
|
|
||||||
|
if (!session.isActive()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Cannot add participants to inactive workflow");
|
||||||
|
}
|
||||||
|
|
||||||
|
addParticipantsToSession(session, participants);
|
||||||
|
log.info("Added {} participants to session {}", participants.size(), sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a participant from a workflow session. */
|
||||||
|
@Transactional
|
||||||
|
public void removeParticipant(String sessionId, Long participantId, User owner) {
|
||||||
|
WorkflowSession session = getSessionForOwner(sessionId, owner);
|
||||||
|
|
||||||
|
WorkflowParticipant participant =
|
||||||
|
workflowParticipantRepository
|
||||||
|
.findById(participantId)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"Participant not found: " + participantId));
|
||||||
|
|
||||||
|
if (!participant.getWorkflowSession().equals(session)) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Participant not in this workflow session");
|
||||||
|
}
|
||||||
|
|
||||||
|
session.removeParticipant(participant);
|
||||||
|
workflowParticipantRepository.delete(participant);
|
||||||
|
log.info("Removed participant {} from session {}", participantId, sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Updates participant status (e.g., NOTIFIED, VIEWED, SIGNED). */
|
||||||
|
public void updateParticipantStatus(Long participantId, ParticipantStatus newStatus) {
|
||||||
|
WorkflowParticipant participant =
|
||||||
|
workflowParticipantRepository
|
||||||
|
.findById(participantId)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"Participant not found: " + participantId));
|
||||||
|
|
||||||
|
participant.setStatus(newStatus);
|
||||||
|
workflowParticipantRepository.save(participant);
|
||||||
|
log.debug("Updated participant {} status to {}", participantId, newStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a notification message to a participant's history. */
|
||||||
|
public void addParticipantNotification(Long participantId, String message) {
|
||||||
|
WorkflowParticipant participant =
|
||||||
|
workflowParticipantRepository
|
||||||
|
.findById(participantId)
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"Participant not found: " + participantId));
|
||||||
|
|
||||||
|
String timestampedMessage = LocalDateTime.now().toString() + ": " + message;
|
||||||
|
participant.addNotification(timestampedMessage);
|
||||||
|
workflowParticipantRepository.save(participant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stores the processed/finalized file for a workflow session. */
|
||||||
|
public void storeProcessedFile(WorkflowSession session, byte[] fileData, String filename)
|
||||||
|
throws IOException {
|
||||||
|
log.info("Storing processed file for session {}", session.getSessionId());
|
||||||
|
|
||||||
|
// Create a temporary multipart file wrapper
|
||||||
|
MultipartFile processedFile = new ByteArrayMultipartFile(fileData, filename);
|
||||||
|
|
||||||
|
// Store using StorageProvider
|
||||||
|
StoredFile storedFile =
|
||||||
|
storeWorkflowFile(session.getOwner(), processedFile, FilePurpose.SIGNING_SIGNED);
|
||||||
|
|
||||||
|
// Link to session
|
||||||
|
storedFile.setWorkflowSession(session);
|
||||||
|
session.setProcessedFile(storedFile);
|
||||||
|
|
||||||
|
storedFileRepository.save(storedFile);
|
||||||
|
workflowSessionRepository.save(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks a workflow session as finalized. */
|
||||||
|
public void finalizeSession(String sessionId, User owner) {
|
||||||
|
WorkflowSession session = getSessionForOwner(sessionId, owner);
|
||||||
|
|
||||||
|
if (session.isFinalized()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Workflow session already finalized");
|
||||||
|
}
|
||||||
|
|
||||||
|
session.setFinalized(true);
|
||||||
|
session.setStatus(WorkflowStatus.COMPLETED);
|
||||||
|
workflowSessionRepository.save(session);
|
||||||
|
|
||||||
|
log.info("Finalized workflow session {}", sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves the processed file data for a workflow session. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public byte[] getProcessedFile(String sessionId, User owner) throws IOException {
|
||||||
|
WorkflowSession session = getSessionForOwner(sessionId, owner);
|
||||||
|
|
||||||
|
if (session.getProcessedFile() == null) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "No processed file available for this session");
|
||||||
|
}
|
||||||
|
|
||||||
|
String storageKey = session.getProcessedFile().getStorageKey();
|
||||||
|
org.springframework.core.io.Resource resource = storageProvider.load(storageKey);
|
||||||
|
return resource.getContentAsByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retrieves the original file data for a workflow session. */
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public byte[] getOriginalFile(String sessionId) throws IOException {
|
||||||
|
WorkflowSession session = getSession(sessionId);
|
||||||
|
if (session.getOriginalFile() == null) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
"Original file no longer available (session may be finalized)");
|
||||||
|
}
|
||||||
|
String storageKey = session.getOriginalFile().getStorageKey();
|
||||||
|
org.springframework.core.io.Resource resource = storageProvider.load(storageKey);
|
||||||
|
return resource.getContentAsByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deletes a workflow session and associated files. */
|
||||||
|
@Transactional
|
||||||
|
public void deleteSession(String sessionId, User owner) {
|
||||||
|
WorkflowSession session = getSessionForOwner(sessionId, owner);
|
||||||
|
|
||||||
|
if (session.isFinalized()) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST,
|
||||||
|
"Cannot delete a finalized session. The signed PDF remains accessible from your session history.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete physical storage files (non-fatal; may already be absent)
|
||||||
|
try {
|
||||||
|
if (session.getOriginalFile() != null) {
|
||||||
|
storageProvider.delete(session.getOriginalFile().getStorageKey());
|
||||||
|
}
|
||||||
|
if (session.getProcessedFile() != null) {
|
||||||
|
storageProvider.delete(session.getProcessedFile().getStorageKey());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error deleting files for session {}", sessionId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear only the StoredFile → WorkflowSession back-reference before deleting.
|
||||||
|
//
|
||||||
|
// We do NOT null session.originalFile here because that would emit an UPDATE with
|
||||||
|
// original_file_id=NULL, violating the NOT NULL constraint. There is no need to — a
|
||||||
|
// DELETE statement removes the row entirely, so the NOT NULL constraint never triggers.
|
||||||
|
//
|
||||||
|
// We DO null StoredFile.workflowSession (workflow_session_id IS nullable) so that
|
||||||
|
// Hibernate does not see a persistent StoredFile referencing a "removed" WorkflowSession
|
||||||
|
// during flush, which would throw TransientPropertyValueException.
|
||||||
|
StoredFile originalFile = session.getOriginalFile();
|
||||||
|
StoredFile processedFile = session.getProcessedFile();
|
||||||
|
if (originalFile != null) {
|
||||||
|
originalFile.setWorkflowSession(null);
|
||||||
|
storedFileRepository.save(originalFile);
|
||||||
|
}
|
||||||
|
if (processedFile != null) {
|
||||||
|
processedFile.setWorkflowSession(null);
|
||||||
|
storedFileRepository.save(processedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the session row. Cascades to WorkflowParticipant via orphanRemoval=true.
|
||||||
|
workflowSessionRepository.delete(session);
|
||||||
|
|
||||||
|
// StoredFile rows can now be deleted — the workflow_sessions FK is gone.
|
||||||
|
if (originalFile != null) storedFileRepository.delete(originalFile);
|
||||||
|
if (processedFile != null) storedFileRepository.delete(processedFile);
|
||||||
|
log.info("Deleted workflow session {}", sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes the original (presigned) file from storage after finalization. The original file is
|
||||||
|
* no longer needed once the signed document has been stored. Non-fatal: logs errors but does
|
||||||
|
* not fail finalization.
|
||||||
|
*/
|
||||||
|
public void deleteOriginalFile(WorkflowSession session) {
|
||||||
|
if (session.getOriginalFile() == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
storageProvider.delete(session.getOriginalFile().getStorageKey());
|
||||||
|
StoredFile originalFile = session.getOriginalFile();
|
||||||
|
session.setOriginalFile(null);
|
||||||
|
workflowSessionRepository.save(session);
|
||||||
|
storedFileRepository.delete(originalFile);
|
||||||
|
log.info("Deleted original presigned file for session {}", session.getSessionId());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error(
|
||||||
|
"Failed to delete original file for session {}: {}",
|
||||||
|
session.getSessionId(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== SIGN REQUEST METHODS (Participant View) =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all sign requests where the user is a participant.
|
||||||
|
*
|
||||||
|
* @param user The participant user
|
||||||
|
* @return List of sign request summaries
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<stirling.software.proprietary.workflow.dto.SignRequestSummaryDTO> listSignRequests(
|
||||||
|
User user) {
|
||||||
|
List<WorkflowParticipant> participations =
|
||||||
|
workflowParticipantRepository.findByUserOrderByLastUpdatedDesc(user);
|
||||||
|
|
||||||
|
return participations.stream()
|
||||||
|
.map(
|
||||||
|
p -> {
|
||||||
|
WorkflowSession session = p.getWorkflowSession();
|
||||||
|
stirling.software.proprietary.workflow.dto.SignRequestSummaryDTO dto =
|
||||||
|
new stirling.software.proprietary.workflow.dto
|
||||||
|
.SignRequestSummaryDTO();
|
||||||
|
dto.setSessionId(session.getSessionId());
|
||||||
|
dto.setDocumentName(session.getDocumentName());
|
||||||
|
dto.setOwnerUsername(session.getOwner().getUsername());
|
||||||
|
dto.setCreatedAt(session.getCreatedAt().toString());
|
||||||
|
dto.setDueDate(
|
||||||
|
session.getDueDate() != null
|
||||||
|
? session.getDueDate().toString()
|
||||||
|
: null);
|
||||||
|
dto.setMyStatus(p.getStatus());
|
||||||
|
return dto;
|
||||||
|
})
|
||||||
|
.collect(java.util.stream.Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get detailed information about a sign request.
|
||||||
|
*
|
||||||
|
* @param sessionId The session ID
|
||||||
|
* @param user The participant user
|
||||||
|
* @return Sign request detail
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public stirling.software.proprietary.workflow.dto.SignRequestDetailDTO getSignRequestDetail(
|
||||||
|
String sessionId, User user) {
|
||||||
|
WorkflowSession session = getSession(sessionId);
|
||||||
|
WorkflowParticipant participant = getParticipantForUser(session, user);
|
||||||
|
|
||||||
|
stirling.software.proprietary.workflow.dto.SignRequestDetailDTO dto =
|
||||||
|
new stirling.software.proprietary.workflow.dto.SignRequestDetailDTO();
|
||||||
|
dto.setSessionId(session.getSessionId());
|
||||||
|
dto.setDocumentName(session.getDocumentName());
|
||||||
|
dto.setOwnerUsername(session.getOwner().getUsername());
|
||||||
|
dto.setMessage(session.getMessage());
|
||||||
|
dto.setDueDate(session.getDueDate());
|
||||||
|
dto.setCreatedAt(session.getCreatedAt().toString());
|
||||||
|
dto.setMyStatus(participant.getStatus());
|
||||||
|
|
||||||
|
// Load signature appearance settings from workflow metadata
|
||||||
|
Map<String, Object> metadata = session.getWorkflowMetadata();
|
||||||
|
if (metadata != null && !metadata.isEmpty()) {
|
||||||
|
dto.setShowSignature(
|
||||||
|
metadata.containsKey("showSignature")
|
||||||
|
? (Boolean) metadata.get("showSignature")
|
||||||
|
: false);
|
||||||
|
dto.setPageNumber(
|
||||||
|
metadata.containsKey("pageNumber")
|
||||||
|
? ((Number) metadata.get("pageNumber")).intValue()
|
||||||
|
: null);
|
||||||
|
dto.setReason(metadata.containsKey("reason") ? (String) metadata.get("reason") : null);
|
||||||
|
dto.setLocation(
|
||||||
|
metadata.containsKey("location") ? (String) metadata.get("location") : null);
|
||||||
|
dto.setShowLogo(
|
||||||
|
metadata.containsKey("showLogo") ? (Boolean) metadata.get("showLogo") : false);
|
||||||
|
} else {
|
||||||
|
// Default values if no metadata
|
||||||
|
dto.setShowSignature(false);
|
||||||
|
dto.setPageNumber(null);
|
||||||
|
dto.setReason(null);
|
||||||
|
dto.setLocation(null);
|
||||||
|
dto.setShowLogo(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status to VIEWED if it was NOTIFIED
|
||||||
|
if (participant.getStatus() == ParticipantStatus.NOTIFIED) {
|
||||||
|
participant.setStatus(ParticipantStatus.VIEWED);
|
||||||
|
workflowParticipantRepository.save(participant);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the document for a sign request.
|
||||||
|
*
|
||||||
|
* <p>After finalization, returns the signed document. Before finalization, returns the
|
||||||
|
* original.
|
||||||
|
*
|
||||||
|
* @param sessionId The session ID
|
||||||
|
* @param user The participant user
|
||||||
|
* @return PDF document bytes
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public byte[] getSignRequestDocument(String sessionId, User user) {
|
||||||
|
WorkflowSession session = getSession(sessionId);
|
||||||
|
getParticipantForUser(session, user); // Verify participant access
|
||||||
|
|
||||||
|
// After finalization, serve the signed document instead of the original
|
||||||
|
StoredFile fileToServe =
|
||||||
|
(session.isFinalized() && session.getProcessedFile() != null)
|
||||||
|
? session.getProcessedFile()
|
||||||
|
: session.getOriginalFile();
|
||||||
|
|
||||||
|
if (fileToServe == null) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.NOT_FOUND, "Document not available for this session");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
org.springframework.core.io.Resource resource =
|
||||||
|
storageProvider.load(fileToServe.getStorageKey());
|
||||||
|
return resource.getContentAsByteArray();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to retrieve document for session {}", sessionId, e);
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR, "Failed to retrieve document");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign a document in a workflow session.
|
||||||
|
*
|
||||||
|
* @param sessionId The session ID
|
||||||
|
* @param user The participant user
|
||||||
|
* @param request Sign document request with certificate and optional wet signature
|
||||||
|
*/
|
||||||
|
public void signDocument(
|
||||||
|
String sessionId,
|
||||||
|
User user,
|
||||||
|
stirling.software.proprietary.workflow.dto.SignDocumentRequest request) {
|
||||||
|
WorkflowSession session = getSession(sessionId);
|
||||||
|
WorkflowParticipant participant = getParticipantForUser(session, user);
|
||||||
|
|
||||||
|
if (participant.getStatus() == ParticipantStatus.SIGNED) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Document already signed by this user");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (participant.getStatus() == ParticipantStatus.DECLINED) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Cannot sign after declining");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build metadata JSON containing certificate submission and wet signature data
|
||||||
|
// Merge with existing metadata if present (preserves owner-configured appearance
|
||||||
|
// settings)
|
||||||
|
Map<String, Object> metadata = new HashMap<>();
|
||||||
|
|
||||||
|
// Get existing metadata if present
|
||||||
|
Map<String, Object> existingMetadata = participant.getParticipantMetadata();
|
||||||
|
if (existingMetadata != null && !existingMetadata.isEmpty()) {
|
||||||
|
metadata = new HashMap<>(existingMetadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Store certificate submission data
|
||||||
|
Map<String, Object> certSubmission = new HashMap<>();
|
||||||
|
certSubmission.put("certType", request.getCertType());
|
||||||
|
certSubmission.put("password", metadataEncryptionService.encrypt(request.getPassword()));
|
||||||
|
|
||||||
|
// Store keystore files as base64 if provided
|
||||||
|
if (request.getP12File() != null && !request.getP12File().isEmpty()) {
|
||||||
|
try {
|
||||||
|
byte[] keystoreBytes = request.getP12File().getBytes();
|
||||||
|
String base64Keystore = java.util.Base64.getEncoder().encodeToString(keystoreBytes);
|
||||||
|
certSubmission.put("p12Keystore", base64Keystore);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to read P12 keystore file", e);
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Failed to process certificate file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: Signature appearance settings (showSignature, pageNumber, location, reason,
|
||||||
|
// showLogo)
|
||||||
|
// may already be in metadata if owner configured them when adding participant.
|
||||||
|
// If not present, the finalization process will use defaults.
|
||||||
|
|
||||||
|
metadata.put("certificateSubmission", certSubmission);
|
||||||
|
|
||||||
|
// 2. Parse wet signatures from JSON string if provided
|
||||||
|
if (request.getWetSignaturesData() != null && !request.getWetSignaturesData().isBlank()) {
|
||||||
|
try {
|
||||||
|
List<WetSignatureMetadata> wetSigs =
|
||||||
|
objectMapper.readValue(
|
||||||
|
request.getWetSignaturesData(),
|
||||||
|
new TypeReference<List<WetSignatureMetadata>>() {});
|
||||||
|
if (wetSigs.size() > WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Too many wet signatures submitted");
|
||||||
|
}
|
||||||
|
request.setWetSignatures(wetSigs);
|
||||||
|
log.info("Parsed {} wet signatures from wetSignaturesData", wetSigs.size());
|
||||||
|
} catch (JacksonException e) {
|
||||||
|
log.error("Failed to parse wetSignaturesData: {}", e.getMessage());
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Invalid wet signatures data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Store wet signatures metadata if provided (supports multiple signatures)
|
||||||
|
if (request.hasWetSignatures()) {
|
||||||
|
List<WetSignatureMetadata> wetSigs = request.extractWetSignatureMetadata();
|
||||||
|
List<Map<String, Object>> wetSignatures = new ArrayList<>();
|
||||||
|
|
||||||
|
for (WetSignatureMetadata wetSig : wetSigs) {
|
||||||
|
Map<String, Object> wetSignature = new HashMap<>();
|
||||||
|
wetSignature.put("type", wetSig.getType());
|
||||||
|
wetSignature.put("data", wetSig.getData());
|
||||||
|
wetSignature.put("page", wetSig.getPage());
|
||||||
|
wetSignature.put("x", wetSig.getX());
|
||||||
|
wetSignature.put("y", wetSig.getY());
|
||||||
|
wetSignature.put("width", wetSig.getWidth());
|
||||||
|
wetSignature.put("height", wetSig.getHeight());
|
||||||
|
wetSignatures.add(wetSignature);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always store as array
|
||||||
|
metadata.put("wetSignatures", wetSignatures);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"Stored {} wet signature(s) metadata for participant {}",
|
||||||
|
wetSignatures.size(),
|
||||||
|
user.getUsername());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Store metadata in participant (JPA converter handles JSON serialization)
|
||||||
|
participant.setParticipantMetadata(metadata);
|
||||||
|
log.info(
|
||||||
|
"Stored signature metadata for participant ID {}, email {}: {} wet signatures, cert type: {}",
|
||||||
|
participant.getId(),
|
||||||
|
user.getUsername(),
|
||||||
|
metadata.containsKey("wetSignatures")
|
||||||
|
? ((List<?>) metadata.get("wetSignatures")).size()
|
||||||
|
: 0,
|
||||||
|
((Map<?, ?>) metadata.get("certificateSubmission")).get("certType"));
|
||||||
|
|
||||||
|
// 5. Update participant status
|
||||||
|
participant.setStatus(ParticipantStatus.SIGNED);
|
||||||
|
workflowParticipantRepository.save(participant);
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"User {} signed document in session {} - certificate and signature data stored",
|
||||||
|
user.getUsername(),
|
||||||
|
sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decline a sign request.
|
||||||
|
*
|
||||||
|
* @param sessionId The session ID
|
||||||
|
* @param user The participant user
|
||||||
|
*/
|
||||||
|
public void declineSignRequest(String sessionId, User user) {
|
||||||
|
WorkflowSession session = getSession(sessionId);
|
||||||
|
WorkflowParticipant participant = getParticipantForUser(session, user);
|
||||||
|
|
||||||
|
if (participant.getStatus() == ParticipantStatus.SIGNED) {
|
||||||
|
throw new ResponseStatusException(
|
||||||
|
HttpStatus.BAD_REQUEST, "Cannot decline after signing");
|
||||||
|
}
|
||||||
|
|
||||||
|
participant.setStatus(ParticipantStatus.DECLINED);
|
||||||
|
workflowParticipantRepository.save(participant); // updatedAt is auto-updated
|
||||||
|
|
||||||
|
log.info("User {} declined sign request for session {}", user.getUsername(), sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get participant record for a user in a session.
|
||||||
|
*
|
||||||
|
* @param session The workflow session
|
||||||
|
* @param user The user
|
||||||
|
* @return Participant record
|
||||||
|
* @throws ResponseStatusException if user is not a participant
|
||||||
|
*/
|
||||||
|
private WorkflowParticipant getParticipantForUser(WorkflowSession session, User user) {
|
||||||
|
return session.getParticipants().stream()
|
||||||
|
.filter(p -> p.getUser() != null && p.getUser().equals(user))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(
|
||||||
|
() ->
|
||||||
|
new ResponseStatusException(
|
||||||
|
HttpStatus.FORBIDDEN,
|
||||||
|
"User is not a participant in this session"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Helper class to wrap byte array as MultipartFile. */
|
||||||
|
private static class ByteArrayMultipartFile implements MultipartFile {
|
||||||
|
private final byte[] content;
|
||||||
|
private final String filename;
|
||||||
|
|
||||||
|
public ByteArrayMultipartFile(byte[] content, String filename) {
|
||||||
|
this.content = content;
|
||||||
|
this.filename = filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getOriginalFilename() {
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getContentType() {
|
||||||
|
return "application/pdf";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return content == null || content.length == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long getSize() {
|
||||||
|
return content.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public byte[] getBytes() {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public java.io.InputStream getInputStream() {
|
||||||
|
return new java.io.ByteArrayInputStream(content);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void transferTo(java.io.File dest) throws IOException {
|
||||||
|
java.nio.file.Files.write(dest.toPath(), content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
package stirling.software.proprietary.workflow.util;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.workflow.dto.ParticipantResponse;
|
||||||
|
import stirling.software.proprietary.workflow.dto.WetSignatureMetadata;
|
||||||
|
import stirling.software.proprietary.workflow.dto.WorkflowSessionResponse;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for mapping workflow entities to DTOs. Centralizes conversion logic for consistent
|
||||||
|
* API responses.
|
||||||
|
*/
|
||||||
|
public class WorkflowMapper {
|
||||||
|
|
||||||
|
/** Converts a WorkflowSession entity to a response DTO. */
|
||||||
|
public static WorkflowSessionResponse toResponse(WorkflowSession session) {
|
||||||
|
return toResponse(session, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a WorkflowSession entity to a response DTO with optional wet signature extraction.
|
||||||
|
*
|
||||||
|
* @param session The workflow session entity
|
||||||
|
* @param objectMapper ObjectMapper for JSON processing (null to skip wet signature extraction)
|
||||||
|
* @return WorkflowSessionResponse with participants (and wet signatures if objectMapper
|
||||||
|
* provided)
|
||||||
|
*/
|
||||||
|
public static WorkflowSessionResponse toResponse(
|
||||||
|
WorkflowSession session, ObjectMapper objectMapper) {
|
||||||
|
if (session == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkflowSessionResponse response = new WorkflowSessionResponse();
|
||||||
|
response.setSessionId(session.getSessionId());
|
||||||
|
response.setOwnerId(session.getOwner().getId());
|
||||||
|
response.setOwnerUsername(session.getOwner().getUsername());
|
||||||
|
response.setWorkflowType(session.getWorkflowType());
|
||||||
|
response.setDocumentName(session.getDocumentName());
|
||||||
|
response.setOwnerEmail(session.getOwnerEmail());
|
||||||
|
response.setMessage(session.getMessage());
|
||||||
|
response.setDueDate(session.getDueDate());
|
||||||
|
response.setStatus(session.getStatus());
|
||||||
|
response.setFinalized(session.isFinalized());
|
||||||
|
response.setCreatedAt(session.getCreatedAt());
|
||||||
|
response.setUpdatedAt(session.getUpdatedAt());
|
||||||
|
response.setHasProcessedFile(session.hasProcessedFile());
|
||||||
|
|
||||||
|
if (session.getOriginalFile() != null) {
|
||||||
|
response.setOriginalFileId(session.getOriginalFile().getId());
|
||||||
|
}
|
||||||
|
if (session.getProcessedFile() != null) {
|
||||||
|
response.setProcessedFileId(session.getProcessedFile().getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert participants (with wet signatures if objectMapper provided)
|
||||||
|
if (objectMapper != null) {
|
||||||
|
response.setParticipants(
|
||||||
|
session.getParticipants().stream()
|
||||||
|
.map(p -> toParticipantResponse(p, objectMapper))
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
} else {
|
||||||
|
response.setParticipants(
|
||||||
|
session.getParticipants().stream()
|
||||||
|
.map(WorkflowMapper::toParticipantResponse)
|
||||||
|
.collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate participant counts
|
||||||
|
response.setParticipantCount(session.getParticipants().size());
|
||||||
|
response.setSignedCount(
|
||||||
|
(int)
|
||||||
|
session.getParticipants().stream()
|
||||||
|
.filter(
|
||||||
|
p ->
|
||||||
|
p.getStatus()
|
||||||
|
== stirling.software.proprietary.workflow
|
||||||
|
.model.ParticipantStatus.SIGNED)
|
||||||
|
.count());
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Converts a WorkflowParticipant entity to a response DTO. */
|
||||||
|
public static ParticipantResponse toParticipantResponse(WorkflowParticipant participant) {
|
||||||
|
if (participant == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
ParticipantResponse response = new ParticipantResponse();
|
||||||
|
response.setId(participant.getId());
|
||||||
|
if (participant.getUser() != null) {
|
||||||
|
response.setUserId(participant.getUser().getId());
|
||||||
|
}
|
||||||
|
response.setEmail(participant.getEmail());
|
||||||
|
response.setName(participant.getName());
|
||||||
|
response.setStatus(participant.getStatus());
|
||||||
|
response.setShareToken(participant.getShareToken());
|
||||||
|
response.setAccessRole(participant.getAccessRole());
|
||||||
|
response.setExpiresAt(participant.getExpiresAt());
|
||||||
|
response.setLastUpdated(participant.getLastUpdated());
|
||||||
|
response.setHasCompleted(participant.hasCompleted());
|
||||||
|
response.setExpired(
|
||||||
|
participant.isExpired()); // Lombok generates setExpired() for isExpired field
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a WorkflowParticipant entity to a response DTO with wet signatures extracted.
|
||||||
|
*
|
||||||
|
* @param participant The participant entity
|
||||||
|
* @param objectMapper ObjectMapper for JSON processing
|
||||||
|
* @return ParticipantResponse with wet signatures included
|
||||||
|
*/
|
||||||
|
public static ParticipantResponse toParticipantResponse(
|
||||||
|
WorkflowParticipant participant, ObjectMapper objectMapper) {
|
||||||
|
ParticipantResponse response = toParticipantResponse(participant);
|
||||||
|
if (response != null) {
|
||||||
|
response.setWetSignatures(extractWetSignatures(participant, objectMapper));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts wet signature metadata from a participant's metadata JSON field.
|
||||||
|
*
|
||||||
|
* @param participant The participant entity
|
||||||
|
* @param objectMapper ObjectMapper for JSON processing
|
||||||
|
* @return List of wet signatures, empty if none found
|
||||||
|
*/
|
||||||
|
private static List<WetSignatureMetadata> extractWetSignatures(
|
||||||
|
WorkflowParticipant participant, ObjectMapper objectMapper) {
|
||||||
|
List<WetSignatureMetadata> signatures = new ArrayList<>();
|
||||||
|
|
||||||
|
Map<String, Object> metadata = participant.getParticipantMetadata();
|
||||||
|
if (metadata == null || metadata.isEmpty() || !metadata.containsKey("wetSignatures")) {
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert metadata to JsonNode for processing
|
||||||
|
var node = objectMapper.valueToTree(metadata);
|
||||||
|
if (node.has("wetSignatures")) {
|
||||||
|
var wetSigsNode = node.get("wetSignatures");
|
||||||
|
if (wetSigsNode.isArray()) {
|
||||||
|
for (var wetSigNode : wetSigsNode) {
|
||||||
|
WetSignatureMetadata wetSig =
|
||||||
|
objectMapper.treeToValue(wetSigNode, WetSignatureMetadata.class);
|
||||||
|
signatures.add(wetSig);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Log error but don't fail the entire response
|
||||||
|
// In production, you might want to use a logger here
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
|
||||||
|
return signatures;
|
||||||
|
}
|
||||||
|
}
|
||||||
+105
@@ -6,7 +6,10 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
|||||||
import static org.mockito.Mockito.*;
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
@@ -23,11 +26,23 @@ import stirling.software.common.model.enumeration.Role;
|
|||||||
import stirling.software.common.model.exception.UnsupportedProviderException;
|
import stirling.software.common.model.exception.UnsupportedProviderException;
|
||||||
import stirling.software.proprietary.model.Team;
|
import stirling.software.proprietary.model.Team;
|
||||||
import stirling.software.proprietary.security.database.repository.AuthorityRepository;
|
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.database.repository.UserRepository;
|
||||||
import stirling.software.proprietary.security.model.AuthenticationType;
|
import stirling.software.proprietary.security.model.AuthenticationType;
|
||||||
|
import stirling.software.proprietary.security.model.Authority;
|
||||||
import stirling.software.proprietary.security.model.User;
|
import stirling.software.proprietary.security.model.User;
|
||||||
import stirling.software.proprietary.security.repository.TeamRepository;
|
import stirling.software.proprietary.security.repository.TeamRepository;
|
||||||
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
import stirling.software.proprietary.security.session.SessionPersistentRegistry;
|
||||||
|
import stirling.software.proprietary.storage.model.FileShare;
|
||||||
|
import stirling.software.proprietary.storage.model.StoredFile;
|
||||||
|
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
|
||||||
|
import stirling.software.proprietary.storage.repository.FileShareRepository;
|
||||||
|
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
|
||||||
|
import stirling.software.proprietary.storage.repository.StoredFileRepository;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
|
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||||
|
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
|
||||||
|
import stirling.software.proprietary.workflow.service.UserServerCertificateService;
|
||||||
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class UserServiceTest {
|
class UserServiceTest {
|
||||||
@@ -40,6 +55,14 @@ class UserServiceTest {
|
|||||||
@Mock private SessionPersistentRegistry sessionRegistry;
|
@Mock private SessionPersistentRegistry sessionRegistry;
|
||||||
@Mock private DatabaseServiceInterface databaseService;
|
@Mock private DatabaseServiceInterface databaseService;
|
||||||
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
|
@Mock private ApplicationProperties.Security.OAUTH2 oAuth2;
|
||||||
|
@Mock private PersistentLoginRepository persistentLoginRepository;
|
||||||
|
@Mock private UserServerCertificateService userServerCertificateService;
|
||||||
|
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
|
||||||
|
@Mock private WorkflowSessionRepository workflowSessionRepository;
|
||||||
|
@Mock private StoredFileRepository storedFileRepository;
|
||||||
|
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
|
||||||
|
@Mock private FileShareRepository fileShareRepository;
|
||||||
|
@Mock private FileShareAccessRepository fileShareAccessRepository;
|
||||||
|
|
||||||
@Spy @InjectMocks private UserService userService;
|
@Spy @InjectMocks private UserService userService;
|
||||||
|
|
||||||
@@ -185,4 +208,86 @@ class UserServiceTest {
|
|||||||
assertFalse(userService.isUsernameValid("ALL_USERS"));
|
assertFalse(userService.isUsernameValid("ALL_USERS"));
|
||||||
assertTrue(userService.isUsernameValid("[email protected]"));
|
assertTrue(userService.isUsernameValid("[email protected]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUser_withRelatedData_cleansUpInCorrectOrder() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(1L);
|
||||||
|
user.setUsername("target");
|
||||||
|
|
||||||
|
FileShare share = new FileShare();
|
||||||
|
StoredFile ownedFile = new StoredFile();
|
||||||
|
ownedFile.setOwner(user);
|
||||||
|
ownedFile.setStorageKey("key-main");
|
||||||
|
ownedFile.setHistoryStorageKey("key-history");
|
||||||
|
Set<FileShare> shares = new HashSet<>();
|
||||||
|
shares.add(share);
|
||||||
|
ownedFile.setShares(shares);
|
||||||
|
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setOwner(user);
|
||||||
|
|
||||||
|
FileShare inboundShare = new FileShare();
|
||||||
|
when(userRepository.findByUsernameIgnoreCase("target")).thenReturn(Optional.of(user));
|
||||||
|
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user))
|
||||||
|
.thenReturn(List.of(session));
|
||||||
|
when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of(ownedFile));
|
||||||
|
when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of(inboundShare));
|
||||||
|
|
||||||
|
userService.deleteUser("target");
|
||||||
|
|
||||||
|
verify(userServerCertificateService).deleteUserCertificate(1L);
|
||||||
|
verify(fileShareAccessRepository).deleteByUser(user);
|
||||||
|
// Inbound share (file shared with this user by others) cleaned up
|
||||||
|
verify(fileShareAccessRepository).deleteByFileShare(inboundShare);
|
||||||
|
verify(fileShareRepository).deleteAll(List.of(inboundShare));
|
||||||
|
// Participant records in others' sessions de-linked (not deleted) to preserve audit trail
|
||||||
|
verify(workflowParticipantRepository).clearUserReferences(user);
|
||||||
|
verify(storedFileRepository).clearWorkflowSessionReferencesByOwner(user);
|
||||||
|
verify(workflowSessionRepository).deleteAll(List.of(session));
|
||||||
|
verify(fileShareAccessRepository).deleteByFileShare(share);
|
||||||
|
verify(storedFileRepository).deleteAll(List.of(ownedFile));
|
||||||
|
verify(userRepository).delete(user);
|
||||||
|
// Persistent login (remember-me) tokens revoked
|
||||||
|
verify(persistentLoginRepository).deleteByUsername("target");
|
||||||
|
// Storage blobs scheduled for physical deletion
|
||||||
|
verify(storageCleanupEntryRepository, times(2)).save(any());
|
||||||
|
verify(userService).invalidateUserSessions("target");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUser_withNoRelatedData_deletesUserSuccessfully() {
|
||||||
|
User user = new User();
|
||||||
|
user.setId(2L);
|
||||||
|
user.setUsername("clean");
|
||||||
|
|
||||||
|
when(userRepository.findByUsernameIgnoreCase("clean")).thenReturn(Optional.of(user));
|
||||||
|
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(user)).thenReturn(List.of());
|
||||||
|
when(storedFileRepository.findAllByOwner(user)).thenReturn(List.of());
|
||||||
|
when(fileShareRepository.findBySharedWithUser(user)).thenReturn(List.of());
|
||||||
|
|
||||||
|
userService.deleteUser("clean");
|
||||||
|
|
||||||
|
verify(userRepository).delete(user);
|
||||||
|
verify(fileShareAccessRepository, never()).deleteByFileShare(any());
|
||||||
|
verify(workflowSessionRepository).deleteAll(List.of());
|
||||||
|
verify(storedFileRepository).deleteAll(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteUser_internalApiUser_isNotDeleted() {
|
||||||
|
Authority internalAuth = new Authority();
|
||||||
|
internalAuth.setAuthority(Role.INTERNAL_API_USER.getRoleId());
|
||||||
|
User user = new User();
|
||||||
|
user.setId(3L);
|
||||||
|
user.setUsername("internal");
|
||||||
|
user.getAuthorities().add(internalAuth);
|
||||||
|
|
||||||
|
when(userRepository.findByUsernameIgnoreCase("internal")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
userService.deleteUser("internal");
|
||||||
|
|
||||||
|
verify(userRepository, never()).delete(any());
|
||||||
|
verify(workflowSessionRepository, never()).findByOwnerOrderByCreatedAtDesc(any());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+97
@@ -0,0 +1,97 @@
|
|||||||
|
package stirling.software.proprietary.storage.converter;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class JsonMapConverterTest {
|
||||||
|
|
||||||
|
private final JsonMapConverter converter = new JsonMapConverter();
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// convertToDatabaseColumn
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToDatabaseColumn_nullMap_returnsNull() {
|
||||||
|
assertThat(converter.convertToDatabaseColumn(null)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToDatabaseColumn_emptyMap_returnsNull() {
|
||||||
|
assertThat(converter.convertToDatabaseColumn(Map.of())).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToDatabaseColumn_singleEntry_producesValidJson() {
|
||||||
|
String json = converter.convertToDatabaseColumn(Map.of("key", "value"));
|
||||||
|
assertThat(json).contains("\"key\"").contains("\"value\"");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToDatabaseColumn_mapWithMixedTypes_roundTrips() {
|
||||||
|
Map<String, Object> input = Map.of("str", "hello", "num", 42);
|
||||||
|
String json = converter.convertToDatabaseColumn(input);
|
||||||
|
Map<String, Object> result = converter.convertToEntityAttribute(json);
|
||||||
|
assertThat(result.get("str")).isEqualTo("hello");
|
||||||
|
assertThat(result.get("num")).isEqualTo(42);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// convertToEntityAttribute — normal paths
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToEntityAttribute_nullInput_returnsEmptyMap() {
|
||||||
|
assertThat(converter.convertToEntityAttribute(null)).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToEntityAttribute_blankInput_returnsEmptyMap() {
|
||||||
|
assertThat(converter.convertToEntityAttribute(" ")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToEntityAttribute_validJson_restoresMap() {
|
||||||
|
Map<String, Object> result = converter.convertToEntityAttribute("{\"foo\":\"bar\"}");
|
||||||
|
assertThat(result).containsEntry("foo", "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToEntityAttribute_nestedObject_preservesStructure() {
|
||||||
|
String json = "{\"outer\":{\"inner\":\"value\"}}";
|
||||||
|
Map<String, Object> result = converter.convertToEntityAttribute(json);
|
||||||
|
assertThat(result).containsKey("outer");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// convertToEntityAttribute — legacy double-encoded fallback
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToEntityAttribute_doubleEncodedJson_fallbackRecovery() {
|
||||||
|
// A JSON string node whose text content is itself valid JSON
|
||||||
|
String doubleEncoded = "\"{\\\"foo\\\":\\\"bar\\\"}\"";
|
||||||
|
Map<String, Object> result = converter.convertToEntityAttribute(doubleEncoded);
|
||||||
|
assertThat(result).containsEntry("foo", "bar");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// convertToEntityAttribute — malformed input
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToEntityAttribute_completelyMalformed_returnsEmptyMap() {
|
||||||
|
Map<String, Object> result = converter.convertToEntityAttribute("not-json-at-all");
|
||||||
|
assertThat(result).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void convertToEntityAttribute_malformedJson_doesNotThrow() {
|
||||||
|
assertThatCode(() -> converter.convertToEntityAttribute("{broken"))
|
||||||
|
.doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
}
|
||||||
+573
@@ -0,0 +1,573 @@
|
|||||||
|
package stirling.software.proprietary.storage.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.junit.jupiter.MockitoSettings;
|
||||||
|
import org.mockito.quality.Strictness;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.storage.model.FileShare;
|
||||||
|
import stirling.software.proprietary.storage.model.ShareAccessRole;
|
||||||
|
import stirling.software.proprietary.storage.model.StoredFile;
|
||||||
|
import stirling.software.proprietary.storage.provider.StorageProvider;
|
||||||
|
import stirling.software.proprietary.storage.provider.StoredObject;
|
||||||
|
import stirling.software.proprietary.storage.repository.FileShareAccessRepository;
|
||||||
|
import stirling.software.proprietary.storage.repository.FileShareRepository;
|
||||||
|
import stirling.software.proprietary.storage.repository.StorageCleanupEntryRepository;
|
||||||
|
import stirling.software.proprietary.storage.repository.StoredFileRepository;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
|
class FileStorageServiceTest {
|
||||||
|
|
||||||
|
@Mock private StoredFileRepository storedFileRepository;
|
||||||
|
@Mock private FileShareRepository fileShareRepository;
|
||||||
|
@Mock private FileShareAccessRepository fileShareAccessRepository;
|
||||||
|
@Mock private UserRepository userRepository;
|
||||||
|
@Mock private ApplicationProperties applicationProperties;
|
||||||
|
@Mock private StorageProvider storageProvider;
|
||||||
|
@Mock private StorageCleanupEntryRepository storageCleanupEntryRepository;
|
||||||
|
|
||||||
|
@Mock private ApplicationProperties.Security securityProperties;
|
||||||
|
@Mock private ApplicationProperties.System systemProperties;
|
||||||
|
@Mock private ApplicationProperties.Storage storageProperties;
|
||||||
|
@Mock private ApplicationProperties.Storage.Sharing sharingProperties;
|
||||||
|
@Mock private ApplicationProperties.Storage.Quotas quotasProperties;
|
||||||
|
|
||||||
|
private FileStorageService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service =
|
||||||
|
new FileStorageService(
|
||||||
|
storedFileRepository,
|
||||||
|
fileShareRepository,
|
||||||
|
fileShareAccessRepository,
|
||||||
|
userRepository,
|
||||||
|
applicationProperties,
|
||||||
|
storageProvider,
|
||||||
|
Optional.empty(),
|
||||||
|
storageCleanupEntryRepository);
|
||||||
|
|
||||||
|
// Default: storage and sharing fully enabled, share links enabled, no expiry
|
||||||
|
when(applicationProperties.getSecurity()).thenReturn(securityProperties);
|
||||||
|
when(securityProperties.isEnableLogin()).thenReturn(true);
|
||||||
|
when(applicationProperties.getStorage()).thenReturn(storageProperties);
|
||||||
|
when(storageProperties.isEnabled()).thenReturn(true);
|
||||||
|
when(storageProperties.getSharing()).thenReturn(sharingProperties);
|
||||||
|
when(sharingProperties.isEnabled()).thenReturn(true);
|
||||||
|
when(sharingProperties.isLinkEnabled()).thenReturn(true);
|
||||||
|
when(sharingProperties.getLinkExpirationDays()).thenReturn(0);
|
||||||
|
when(applicationProperties.getSystem()).thenReturn(systemProperties);
|
||||||
|
when(systemProperties.getFrontendUrl()).thenReturn("http://localhost:8080");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private User user(long id) {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(id);
|
||||||
|
u.setUsername("user" + id);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private StoredFile ownedFile(User owner) {
|
||||||
|
StoredFile f = new StoredFile();
|
||||||
|
f.setId(100L);
|
||||||
|
f.setOwner(owner);
|
||||||
|
f.setOriginalFilename("test.pdf");
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileShare shareFor(StoredFile file, User user, ShareAccessRole role) {
|
||||||
|
FileShare s = new FileShare();
|
||||||
|
s.setFile(file);
|
||||||
|
s.setSharedWithUser(user);
|
||||||
|
s.setAccessRole(role);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// getAccessibleFile
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAccessibleFile_owner_returnsFile() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f));
|
||||||
|
|
||||||
|
assertThat(service.getAccessibleFile(owner, 100L)).isSameAs(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAccessibleFile_sharedUser_returnsFile() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
f.getShares().add(shareFor(f, requester, ShareAccessRole.VIEWER));
|
||||||
|
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f));
|
||||||
|
|
||||||
|
assertThat(service.getAccessibleFile(requester, 100L)).isSameAs(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAccessibleFile_noAccess_throwsForbidden() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
when(storedFileRepository.findByIdWithShares(100L)).thenReturn(Optional.of(f));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.getAccessibleFile(requester, 100L))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getAccessibleFile_fileNotFound_throwsNotFound() {
|
||||||
|
User owner = user(1L);
|
||||||
|
when(storedFileRepository.findByIdWithShares(999L)).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.getAccessibleFile(owner, 999L))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// requireEditorAccess
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requireEditorAccess_owner_passes() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
service.requireEditorAccess(owner, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requireEditorAccess_editorShare_passes() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
FileShare share = shareFor(f, requester, ShareAccessRole.EDITOR);
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
|
||||||
|
.thenReturn(Optional.of(share));
|
||||||
|
|
||||||
|
service.requireEditorAccess(requester, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requireEditorAccess_viewerShare_throwsForbidden() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
FileShare share = shareFor(f, requester, ShareAccessRole.VIEWER);
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
|
||||||
|
.thenReturn(Optional.of(share));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.requireEditorAccess(requester, f))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requireEditorAccess_noShare_throwsForbidden() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.requireEditorAccess(requester, f))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// requireReadAccess
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requireReadAccess_owner_passes() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
service.requireReadAccess(owner, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requireReadAccess_viewerShare_passes() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
FileShare share = shareFor(f, requester, ShareAccessRole.VIEWER);
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(f, requester))
|
||||||
|
.thenReturn(Optional.of(share));
|
||||||
|
|
||||||
|
service.requireReadAccess(requester, f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// shareWithUser
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shareWithUser_newShare_created() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User target = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
FileShare result = service.shareWithUser(owner, f, "user2", ShareAccessRole.VIEWER);
|
||||||
|
|
||||||
|
assertThat(result.getSharedWithUser()).isEqualTo(target);
|
||||||
|
assertThat(result.getAccessRole()).isEqualTo(ShareAccessRole.VIEWER);
|
||||||
|
verify(fileShareRepository).save(any(FileShare.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shareWithUser_existingShare_updatesRole() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User target = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
FileShare existing = shareFor(f, target, ShareAccessRole.VIEWER);
|
||||||
|
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
|
||||||
|
.thenReturn(Optional.of(existing));
|
||||||
|
when(fileShareRepository.save(existing)).thenReturn(existing);
|
||||||
|
|
||||||
|
service.shareWithUser(owner, f, "user2", ShareAccessRole.EDITOR);
|
||||||
|
|
||||||
|
assertThat(existing.getAccessRole()).isEqualTo(ShareAccessRole.EDITOR);
|
||||||
|
verify(fileShareRepository).save(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shareWithUser_selfShare_throwsBadRequest() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
when(userRepository.findByUsernameIgnoreCase("user1")).thenReturn(Optional.of(owner));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.shareWithUser(owner, f, "user1", ShareAccessRole.VIEWER))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shareWithUser_nonOwner_throwsForbidden() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User nonOwner = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
|
||||||
|
assertThatThrownBy(
|
||||||
|
() -> service.shareWithUser(nonOwner, f, "user1", ShareAccessRole.VIEWER))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// revokeUserShare
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeUserShare_owner_removesShare() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User target = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
FileShare share = shareFor(f, target, ShareAccessRole.VIEWER);
|
||||||
|
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
|
||||||
|
.thenReturn(Optional.of(share));
|
||||||
|
|
||||||
|
service.revokeUserShare(owner, f, "user2");
|
||||||
|
|
||||||
|
verify(fileShareRepository).delete(share);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeUserShare_shareNotFound_silentSuccess() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User target = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
when(userRepository.findByUsernameIgnoreCase("user2")).thenReturn(Optional.of(target));
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(f, target))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
service.revokeUserShare(owner, f, "user2");
|
||||||
|
|
||||||
|
verify(fileShareRepository, never()).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeUserShare_nonOwner_throwsForbidden() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User nonOwner = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.revokeUserShare(nonOwner, f, "user2"))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// createShareLink
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createShareLink_owner_tokenGenerated() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
when(fileShareRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
FileShare result = service.createShareLink(owner, f, ShareAccessRole.VIEWER);
|
||||||
|
|
||||||
|
assertThat(result.getShareToken()).isNotNull();
|
||||||
|
assertThat(result.getAccessRole()).isEqualTo(ShareAccessRole.VIEWER);
|
||||||
|
verify(fileShareRepository).save(any(FileShare.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createShareLink_nonOwner_throwsForbidden() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User nonOwner = user(2L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.createShareLink(nonOwner, f, ShareAccessRole.VIEWER))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// revokeShareLink
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeShareLink_owner_validToken_deletesShareAndAccessRecords() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
FileShare share = shareFor(f, null, ShareAccessRole.VIEWER);
|
||||||
|
share.setShareToken("test-token");
|
||||||
|
when(fileShareRepository.findByShareToken("test-token")).thenReturn(Optional.of(share));
|
||||||
|
|
||||||
|
service.revokeShareLink(owner, f, "test-token");
|
||||||
|
|
||||||
|
verify(fileShareAccessRepository).deleteByFileShare(share);
|
||||||
|
verify(fileShareRepository).delete(share);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeShareLink_tokenBelongsToOtherFile_throwsForbidden() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
f.setId(1L);
|
||||||
|
StoredFile otherFile = ownedFile(owner);
|
||||||
|
otherFile.setId(2L);
|
||||||
|
FileShare share = shareFor(otherFile, null, ShareAccessRole.VIEWER);
|
||||||
|
share.setShareToken("token");
|
||||||
|
when(fileShareRepository.findByShareToken("token")).thenReturn(Optional.of(share));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.revokeShareLink(owner, f, "token"))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(403);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void revokeShareLink_tokenNotFound_throwsNotFound() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
when(fileShareRepository.findByShareToken("unknown")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.revokeShareLink(owner, f, "unknown"))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Storage quota enforcement (via storeFile / replaceFile public API)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeFile_nullQuotas_passes() throws IOException {
|
||||||
|
when(storageProperties.getQuotas()).thenReturn(null);
|
||||||
|
User owner = user(1L);
|
||||||
|
MockMultipartFile file =
|
||||||
|
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
|
||||||
|
when(storageProvider.store(any(), any()))
|
||||||
|
.thenReturn(
|
||||||
|
StoredObject.builder()
|
||||||
|
.storageKey("k")
|
||||||
|
.originalFilename("test.pdf")
|
||||||
|
.contentType("application/pdf")
|
||||||
|
.sizeBytes(1L)
|
||||||
|
.build());
|
||||||
|
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
service.storeFile(owner, file);
|
||||||
|
|
||||||
|
verify(storageProvider).store(owner, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeFile_fileTooLarge_throwsPayloadTooLarge() {
|
||||||
|
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
|
||||||
|
when(quotasProperties.getMaxFileMb()).thenReturn(1L); // 1 MB limit
|
||||||
|
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(-1L);
|
||||||
|
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(-1L);
|
||||||
|
User owner = user(1L);
|
||||||
|
// 2 MB file exceeds the 1 MB limit
|
||||||
|
MockMultipartFile file =
|
||||||
|
new MockMultipartFile(
|
||||||
|
"file", "big.pdf", "application/pdf", new byte[2 * 1024 * 1024]);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.storeFile(owner, file))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(413);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeFile_perUserQuotaExceeded_throwsPayloadTooLarge() {
|
||||||
|
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
|
||||||
|
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
|
||||||
|
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(10L); // 10 MB per-user cap
|
||||||
|
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(-1L);
|
||||||
|
User owner = user(1L);
|
||||||
|
// user already has 9 MB stored; a 2 MB upload pushes to 11 MB > 10 MB cap
|
||||||
|
when(storedFileRepository.sumStorageBytesByOwner(owner)).thenReturn(9L * 1024 * 1024);
|
||||||
|
MockMultipartFile file =
|
||||||
|
new MockMultipartFile(
|
||||||
|
"file", "f.pdf", "application/pdf", new byte[2 * 1024 * 1024]);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.storeFile(owner, file))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(413);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void storeFile_globalQuotaExceeded_throwsPayloadTooLarge() {
|
||||||
|
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
|
||||||
|
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
|
||||||
|
when(quotasProperties.getMaxStorageMbPerUser()).thenReturn(-1L);
|
||||||
|
when(quotasProperties.getMaxStorageMbTotal()).thenReturn(100L); // 100 MB global cap
|
||||||
|
User owner = user(1L);
|
||||||
|
// system already has 99 MB; a 2 MB upload pushes to 101 MB > 100 MB cap
|
||||||
|
when(storedFileRepository.sumStorageBytesTotal()).thenReturn(99L * 1024 * 1024);
|
||||||
|
MockMultipartFile file =
|
||||||
|
new MockMultipartFile(
|
||||||
|
"file", "f.pdf", "application/pdf", new byte[2 * 1024 * 1024]);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.storeFile(owner, file))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(413);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void replaceFile_replacementShrinks_skipsPerUserAndGlobalCheck() throws IOException {
|
||||||
|
when(storageProperties.getQuotas()).thenReturn(quotasProperties);
|
||||||
|
when(quotasProperties.getMaxFileMb()).thenReturn(-1L);
|
||||||
|
User owner = user(1L);
|
||||||
|
// existing file is 5 MB; replacement is 1 MB → delta ≤ 0 → quota repos never queried
|
||||||
|
StoredFile existing = ownedFile(owner);
|
||||||
|
existing.setSizeBytes(5L * 1024 * 1024);
|
||||||
|
existing.setStorageKey("old-key");
|
||||||
|
MockMultipartFile newFile =
|
||||||
|
new MockMultipartFile(
|
||||||
|
"file", "small.pdf", "application/pdf", new byte[1 * 1024 * 1024]);
|
||||||
|
when(storageProvider.store(any(), any()))
|
||||||
|
.thenReturn(
|
||||||
|
StoredObject.builder()
|
||||||
|
.storageKey("new-key")
|
||||||
|
.originalFilename("small.pdf")
|
||||||
|
.contentType("application/pdf")
|
||||||
|
.sizeBytes(1L * 1024 * 1024)
|
||||||
|
.build());
|
||||||
|
when(storedFileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
service.replaceFile(owner, existing, newFile);
|
||||||
|
|
||||||
|
verify(storedFileRepository, never()).sumStorageBytesByOwner(any());
|
||||||
|
verify(storedFileRepository, never()).sumStorageBytesTotal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// deleteFile — workflow guard
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteFile_fileInActiveWorkflow_throwsBadRequest() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
WorkflowSession session = mock(WorkflowSession.class);
|
||||||
|
when(session.isActive()).thenReturn(true);
|
||||||
|
f.setWorkflowSession(session);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.deleteFile(owner, f))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode().value())
|
||||||
|
.isEqualTo(400);
|
||||||
|
|
||||||
|
verify(storedFileRepository, never()).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteFile_fileNotInAnyWorkflow_deletesSuccessfully() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
// no workflow session set
|
||||||
|
when(fileShareRepository.findShareLinks(f)).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.deleteFile(owner, f);
|
||||||
|
|
||||||
|
verify(storedFileRepository).delete(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteFile_fileInCompletedWorkflow_deletesSuccessfully() {
|
||||||
|
User owner = user(1L);
|
||||||
|
StoredFile f = ownedFile(owner);
|
||||||
|
WorkflowSession session = mock(WorkflowSession.class);
|
||||||
|
when(session.isActive()).thenReturn(false);
|
||||||
|
f.setWorkflowSession(session);
|
||||||
|
when(fileShareRepository.findShareLinks(f)).thenReturn(List.of());
|
||||||
|
|
||||||
|
service.deleteFile(owner, f);
|
||||||
|
|
||||||
|
verify(storedFileRepository).delete(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -0,0 +1,115 @@
|
|||||||
|
package stirling.software.proprietary.workflow.dto;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class WetSignatureMetadataTest {
|
||||||
|
|
||||||
|
private static WetSignatureMetadata canvas(double x, double y, double w, double h) {
|
||||||
|
return new WetSignatureMetadata("canvas", "data:image/png;base64,abc==", 0, x, y, w, h);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// validate() — image data prefix
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_canvas_withDataImagePrefix_passes() {
|
||||||
|
assertThatCode(() -> canvas(0.0, 0.0, 0.5, 0.5).validate()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_image_withDataImagePrefix_passes() {
|
||||||
|
WetSignatureMetadata sig =
|
||||||
|
new WetSignatureMetadata(
|
||||||
|
"image", "data:image/jpeg;base64,xyz==", 0, 0.1, 0.1, 0.3, 0.3);
|
||||||
|
assertThatCode(sig::validate).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_canvas_withoutDataImagePrefix_throws() {
|
||||||
|
WetSignatureMetadata sig =
|
||||||
|
new WetSignatureMetadata(
|
||||||
|
"canvas", "raw-base64-without-prefix", 0, 0.0, 0.0, 0.5, 0.5);
|
||||||
|
assertThatThrownBy(sig::validate)
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("data:image/");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_text_doesNotRequireDataImagePrefix() {
|
||||||
|
WetSignatureMetadata sig =
|
||||||
|
new WetSignatureMetadata("text", "John Doe", 0, 0.1, 0.1, 0.3, 0.2);
|
||||||
|
assertThatCode(sig::validate).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// validate() — cross-field boundary checks (x + width, y + height)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_xPlusWidthExactlyOne_passes() {
|
||||||
|
assertThatCode(() -> canvas(0.5, 0.0, 0.5, 0.5).validate()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_xPlusWidthExceedsOne_throws() {
|
||||||
|
assertThatThrownBy(() -> canvas(0.6, 0.0, 0.5, 0.5).validate())
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("right edge");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_yPlusHeightExactlyOne_passes() {
|
||||||
|
assertThatCode(() -> canvas(0.0, 0.5, 0.5, 0.5).validate()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_yPlusHeightExceedsOne_throws() {
|
||||||
|
assertThatThrownBy(() -> canvas(0.0, 0.6, 0.5, 0.5).validate())
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessageContaining("bottom edge");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validate_originWithFullPageSize_passes() {
|
||||||
|
assertThatCode(() -> canvas(0.0, 0.0, 1.0, 1.0).validate()).doesNotThrowAnyException();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// MAX_SIGNATURES_PER_PARTICIPANT constant
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void maxSignaturesConstant_isPositive() {
|
||||||
|
assertThat(WetSignatureMetadata.MAX_SIGNATURES_PER_PARTICIPANT).isGreaterThan(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// extractBase64Data()
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractBase64Data_stripsDataUrlPrefix() {
|
||||||
|
WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5);
|
||||||
|
sig.setData("data:image/png;base64,iVBORw0KGgo=");
|
||||||
|
assertThat(sig.extractBase64Data()).isEqualTo("iVBORw0KGgo=");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractBase64Data_noComma_returnsDataUnchanged() {
|
||||||
|
WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5);
|
||||||
|
sig.setData("plainbase64withoutcomma");
|
||||||
|
assertThat(sig.extractBase64Data()).isEqualTo("plainbase64withoutcomma");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void extractBase64Data_null_returnsNull() {
|
||||||
|
WetSignatureMetadata sig = canvas(0.0, 0.0, 0.5, 0.5);
|
||||||
|
sig.setData(null);
|
||||||
|
assertThat(sig.extractBase64Data()).isNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -0,0 +1,123 @@
|
|||||||
|
package stirling.software.proprietary.workflow.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated;
|
||||||
|
|
||||||
|
class MetadataEncryptionServiceTest {
|
||||||
|
|
||||||
|
private MetadataEncryptionService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
service = serviceWithKey("test-encryption-key-for-unit-tests-only");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MetadataEncryptionService serviceWithKey(String key) {
|
||||||
|
AutomaticallyGenerated generated = new AutomaticallyGenerated();
|
||||||
|
generated.setKey(key);
|
||||||
|
ApplicationProperties props = new ApplicationProperties();
|
||||||
|
props.setAutomaticallyGenerated(generated);
|
||||||
|
return new MetadataEncryptionService(props);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Null / empty passthrough
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void encrypt_null_returnsNull() {
|
||||||
|
assertThat(service.encrypt(null)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decrypt_null_returnsNull() {
|
||||||
|
assertThat(service.decrypt(null)).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Legacy plaintext backwards-compatibility
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decrypt_plaintextWithoutPrefix_returnsUnchanged() {
|
||||||
|
assertThat(service.decrypt("plaintext-password")).isEqualTo("plaintext-password");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void decrypt_emptyStringWithoutPrefix_returnsUnchanged() {
|
||||||
|
assertThat(service.decrypt("")).isEqualTo("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Encrypt / decrypt round-trip
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void encrypt_producesEncPrefix() {
|
||||||
|
String encrypted = service.encrypt("secret");
|
||||||
|
assertThat(encrypted).startsWith(MetadataEncryptionService.ENC_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTrip_restoresOriginalValue() {
|
||||||
|
String original = "my-keystore-password";
|
||||||
|
String encrypted = service.encrypt(original);
|
||||||
|
assertThat(service.decrypt(encrypted)).isEqualTo(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTrip_emptyString() {
|
||||||
|
String encrypted = service.encrypt("");
|
||||||
|
assertThat(service.decrypt(encrypted)).isEqualTo("");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void roundTrip_specialCharactersAndUnicode() {
|
||||||
|
String original = "p@$$w0rd!£€#\u00e9";
|
||||||
|
assertThat(service.decrypt(service.encrypt(original))).isEqualTo(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// IV randomisation — each call must produce a distinct ciphertext
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void encrypt_sameInput_producesDifferentCiphertexts() {
|
||||||
|
String a = service.encrypt("same-value");
|
||||||
|
String b = service.encrypt("same-value");
|
||||||
|
assertThat(a).isNotEqualTo(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Key dependency — different keys must produce different ciphertexts
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void encrypt_differentKey_producesIncompatibleCiphertext() {
|
||||||
|
MetadataEncryptionService otherService = serviceWithKey("completely-different-key");
|
||||||
|
|
||||||
|
String encryptedByOther = otherService.encrypt("secret");
|
||||||
|
|
||||||
|
// The original service cannot decrypt what the other service encrypted
|
||||||
|
assertThatThrownBy(() -> service.decrypt(encryptedByOther))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Missing key guard
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void encrypt_missingKey_throwsIllegalState() {
|
||||||
|
MetadataEncryptionService noKeyService = serviceWithKey(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> noKeyService.encrypt("anything"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
}
|
||||||
|
}
|
||||||
+183
@@ -0,0 +1,183 @@
|
|||||||
|
package stirling.software.proprietary.workflow.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||||
|
import stirling.software.common.service.PdfSigningService;
|
||||||
|
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||||
|
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 tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class SigningFinalizationServiceTest {
|
||||||
|
|
||||||
|
@Mock private WorkflowParticipantRepository participantRepository;
|
||||||
|
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||||
|
@Mock private ObjectMapper objectMapper;
|
||||||
|
@Mock private PdfSigningService pdfSigningService;
|
||||||
|
@Mock private MetadataEncryptionService metadataEncryptionService;
|
||||||
|
@Mock private ServerCertificateServiceInterface serverCertificateService;
|
||||||
|
@Mock private UserServerCertificateService userServerCertificateService;
|
||||||
|
|
||||||
|
@InjectMocks private SigningFinalizationService service;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private WorkflowSession sessionWithParticipants(WorkflowParticipant... participants) {
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId("test-session");
|
||||||
|
List<WorkflowParticipant> list = new ArrayList<>();
|
||||||
|
for (WorkflowParticipant p : participants) {
|
||||||
|
list.add(p);
|
||||||
|
}
|
||||||
|
session.setParticipants(list);
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkflowParticipant participantWithMetadata(Map<String, Object> metadata) {
|
||||||
|
WorkflowParticipant p = new WorkflowParticipant();
|
||||||
|
p.setStatus(ParticipantStatus.SIGNED);
|
||||||
|
p.setEmail("[email protected]");
|
||||||
|
p.setParticipantMetadata(new HashMap<>(metadata));
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// clearSensitiveMetadata — individual key removal
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearSensitiveMetadata_removesWetSignaturesKey() {
|
||||||
|
WorkflowParticipant p =
|
||||||
|
participantWithMetadata(Map.of("wetSignatures", List.of("sig1"), "other", "keep"));
|
||||||
|
WorkflowSession session = sessionWithParticipants(p);
|
||||||
|
|
||||||
|
service.clearSensitiveMetadata(session);
|
||||||
|
|
||||||
|
assertThat(p.getParticipantMetadata()).doesNotContainKey("wetSignatures");
|
||||||
|
assertThat(p.getParticipantMetadata()).containsKey("other");
|
||||||
|
verify(participantRepository, times(1)).save(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearSensitiveMetadata_removesCertificateSubmissionKey() {
|
||||||
|
WorkflowParticipant p =
|
||||||
|
participantWithMetadata(
|
||||||
|
Map.of("certificateSubmission", Map.of("certType", "SERVER")));
|
||||||
|
WorkflowSession session = sessionWithParticipants(p);
|
||||||
|
|
||||||
|
service.clearSensitiveMetadata(session);
|
||||||
|
|
||||||
|
assertThat(p.getParticipantMetadata()).doesNotContainKey("certificateSubmission");
|
||||||
|
verify(participantRepository, times(1)).save(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearSensitiveMetadata_removesBothKeys_savesOnce() {
|
||||||
|
Map<String, Object> metadata = new HashMap<>();
|
||||||
|
metadata.put("wetSignatures", List.of("sig1"));
|
||||||
|
metadata.put("certificateSubmission", Map.of("certType", "SERVER"));
|
||||||
|
metadata.put("showLogo", true);
|
||||||
|
WorkflowParticipant p = participantWithMetadata(metadata);
|
||||||
|
WorkflowSession session = sessionWithParticipants(p);
|
||||||
|
|
||||||
|
service.clearSensitiveMetadata(session);
|
||||||
|
|
||||||
|
assertThat(p.getParticipantMetadata()).doesNotContainKey("wetSignatures");
|
||||||
|
assertThat(p.getParticipantMetadata()).doesNotContainKey("certificateSubmission");
|
||||||
|
assertThat(p.getParticipantMetadata()).containsKey("showLogo");
|
||||||
|
verify(participantRepository, times(1)).save(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// clearSensitiveMetadata — no-op cases (save must NOT be called)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearSensitiveMetadata_noSensitiveKeys_doesNotSave() {
|
||||||
|
WorkflowParticipant p = participantWithMetadata(Map.of("showLogo", true, "pageNumber", 1));
|
||||||
|
WorkflowSession session = sessionWithParticipants(p);
|
||||||
|
|
||||||
|
service.clearSensitiveMetadata(session);
|
||||||
|
|
||||||
|
verify(participantRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearSensitiveMetadata_nullMetadata_doesNotSave() {
|
||||||
|
WorkflowParticipant p = new WorkflowParticipant();
|
||||||
|
p.setStatus(ParticipantStatus.SIGNED);
|
||||||
|
p.setParticipantMetadata(null);
|
||||||
|
WorkflowSession session = sessionWithParticipants(p);
|
||||||
|
|
||||||
|
service.clearSensitiveMetadata(session);
|
||||||
|
|
||||||
|
verify(participantRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearSensitiveMetadata_emptyMetadata_doesNotSave() {
|
||||||
|
WorkflowParticipant p = participantWithMetadata(Map.of());
|
||||||
|
WorkflowSession session = sessionWithParticipants(p);
|
||||||
|
|
||||||
|
service.clearSensitiveMetadata(session);
|
||||||
|
|
||||||
|
verify(participantRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// clearSensitiveMetadata — multiple participants
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearSensitiveMetadata_multipleParticipants_allWithSensitiveData_allCleared() {
|
||||||
|
WorkflowParticipant p1 = participantWithMetadata(Map.of("wetSignatures", List.of("s1")));
|
||||||
|
WorkflowParticipant p2 =
|
||||||
|
participantWithMetadata(Map.of("certificateSubmission", Map.of("k", "v")));
|
||||||
|
WorkflowParticipant p3 =
|
||||||
|
participantWithMetadata(Map.of("wetSignatures", List.of("s3"), "extra", "keep"));
|
||||||
|
WorkflowSession session = sessionWithParticipants(p1, p2, p3);
|
||||||
|
|
||||||
|
service.clearSensitiveMetadata(session);
|
||||||
|
|
||||||
|
assertThat(p1.getParticipantMetadata()).doesNotContainKey("wetSignatures");
|
||||||
|
assertThat(p2.getParticipantMetadata()).doesNotContainKey("certificateSubmission");
|
||||||
|
assertThat(p3.getParticipantMetadata()).doesNotContainKey("wetSignatures");
|
||||||
|
assertThat(p3.getParticipantMetadata()).containsKey("extra");
|
||||||
|
verify(participantRepository, times(3)).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void clearSensitiveMetadata_mixedParticipants_onlySavesModified() {
|
||||||
|
WorkflowParticipant withSensitive =
|
||||||
|
participantWithMetadata(Map.of("wetSignatures", List.of("s1")));
|
||||||
|
WorkflowParticipant withoutSensitive = participantWithMetadata(Map.of("showLogo", false));
|
||||||
|
WorkflowSession session = sessionWithParticipants(withSensitive, withoutSensitive);
|
||||||
|
|
||||||
|
service.clearSensitiveMetadata(session);
|
||||||
|
|
||||||
|
verify(participantRepository, times(1)).save(withSensitive);
|
||||||
|
verify(participantRepository, never()).save(withoutSensitive);
|
||||||
|
}
|
||||||
|
}
|
||||||
+337
@@ -0,0 +1,337 @@
|
|||||||
|
package stirling.software.proprietary.workflow.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.storage.model.FileShare;
|
||||||
|
import stirling.software.proprietary.storage.model.ShareAccessRole;
|
||||||
|
import stirling.software.proprietary.storage.model.StoredFile;
|
||||||
|
import stirling.software.proprietary.storage.repository.FileShareRepository;
|
||||||
|
import stirling.software.proprietary.workflow.model.ParticipantStatus;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowStatus;
|
||||||
|
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||||
|
import stirling.software.proprietary.workflow.service.UnifiedAccessControlService.AccessValidationResult;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UnifiedAccessControlServiceTest {
|
||||||
|
|
||||||
|
@Mock private FileShareRepository fileShareRepository;
|
||||||
|
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
|
||||||
|
|
||||||
|
@InjectMocks private UnifiedAccessControlService service;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private User user(long id) {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(id);
|
||||||
|
u.setUsername("user" + id);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
private FileShare share(StoredFile file, User sharedWith, LocalDateTime expiresAt) {
|
||||||
|
FileShare s = new FileShare();
|
||||||
|
s.setFile(file);
|
||||||
|
s.setShareToken("tok-" + System.nanoTime());
|
||||||
|
s.setAccessRole(ShareAccessRole.EDITOR);
|
||||||
|
s.setSharedWithUser(sharedWith);
|
||||||
|
s.setExpiresAt(expiresAt);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkflowParticipant participant(
|
||||||
|
User user, ParticipantStatus status, boolean sessionActive, LocalDateTime expiresAt) {
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setStatus(sessionActive ? WorkflowStatus.IN_PROGRESS : WorkflowStatus.COMPLETED);
|
||||||
|
session.setFinalized(false);
|
||||||
|
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
session.setOriginalFile(file);
|
||||||
|
|
||||||
|
WorkflowParticipant p = new WorkflowParticipant();
|
||||||
|
p.setUser(user);
|
||||||
|
p.setStatus(status);
|
||||||
|
p.setAccessRole(ShareAccessRole.EDITOR);
|
||||||
|
p.setExpiresAt(expiresAt);
|
||||||
|
p.setWorkflowSession(session);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// validateToken — file share branch
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateToken_genericShare_noExpiry_noUserRestriction_allowed() {
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
FileShare s = share(file, null, null);
|
||||||
|
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
|
||||||
|
|
||||||
|
AccessValidationResult result = service.validateToken("tok", null);
|
||||||
|
|
||||||
|
assertThat(result.isAllowed()).isTrue();
|
||||||
|
assertThat(result.isWorkflowAccess()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateToken_genericShare_expired_denied() {
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
FileShare s = share(file, null, LocalDateTime.now().minusSeconds(1));
|
||||||
|
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
|
||||||
|
|
||||||
|
AccessValidationResult result = service.validateToken("tok", null);
|
||||||
|
|
||||||
|
assertThat(result.isAllowed()).isFalse();
|
||||||
|
assertThat(result.getDenialReason()).containsIgnoringCase("expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateToken_userSpecificShare_wrongUser_denied() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
FileShare s = share(file, owner, null);
|
||||||
|
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
|
||||||
|
|
||||||
|
AccessValidationResult result = service.validateToken("tok", requester);
|
||||||
|
|
||||||
|
assertThat(result.isAllowed()).isFalse();
|
||||||
|
assertThat(result.getDenialReason()).containsIgnoringCase("denied");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateToken_userSpecificShare_correctUser_allowed() {
|
||||||
|
User u = user(1L);
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
FileShare s = share(file, u, null);
|
||||||
|
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.of(s));
|
||||||
|
|
||||||
|
AccessValidationResult result = service.validateToken("tok", u);
|
||||||
|
|
||||||
|
assertThat(result.isAllowed()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// validateToken — workflow participant branch
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateToken_participantToken_notFileShare_activeParticipant_allowed() {
|
||||||
|
User u = user(1L);
|
||||||
|
WorkflowParticipant p = participant(u, ParticipantStatus.PENDING, true, null);
|
||||||
|
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
|
||||||
|
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
|
||||||
|
|
||||||
|
AccessValidationResult result = service.validateToken("tok", u);
|
||||||
|
|
||||||
|
assertThat(result.isAllowed()).isTrue();
|
||||||
|
assertThat(result.isWorkflowAccess()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateToken_participantToken_expired_denied() {
|
||||||
|
User u = user(1L);
|
||||||
|
WorkflowParticipant p =
|
||||||
|
participant(
|
||||||
|
u, ParticipantStatus.PENDING, true, LocalDateTime.now().minusSeconds(1));
|
||||||
|
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
|
||||||
|
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
|
||||||
|
|
||||||
|
AccessValidationResult result = service.validateToken("tok", u);
|
||||||
|
|
||||||
|
assertThat(result.isAllowed()).isFalse();
|
||||||
|
assertThat(result.getDenialReason()).containsIgnoringCase("expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateToken_participantToken_sessionInactive_denied() {
|
||||||
|
User u = user(1L);
|
||||||
|
WorkflowParticipant p = participant(u, ParticipantStatus.PENDING, false, null);
|
||||||
|
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
|
||||||
|
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
|
||||||
|
|
||||||
|
AccessValidationResult result = service.validateToken("tok", u);
|
||||||
|
|
||||||
|
assertThat(result.isAllowed()).isFalse();
|
||||||
|
assertThat(result.getDenialReason()).containsIgnoringCase("no longer active");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateToken_participantToken_wrongUser_denied() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
WorkflowParticipant p = participant(owner, ParticipantStatus.PENDING, true, null);
|
||||||
|
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
|
||||||
|
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.of(p));
|
||||||
|
|
||||||
|
AccessValidationResult result = service.validateToken("tok", requester);
|
||||||
|
|
||||||
|
assertThat(result.isAllowed()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validateToken_unknownToken_denied() {
|
||||||
|
when(fileShareRepository.findByShareTokenWithFile("tok")).thenReturn(Optional.empty());
|
||||||
|
when(workflowParticipantRepository.findByShareToken("tok")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
AccessValidationResult result = service.validateToken("tok", null);
|
||||||
|
|
||||||
|
assertThat(result.isAllowed()).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// getEffectiveRole
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEffectiveRole_signed_returnsViewer() {
|
||||||
|
WorkflowParticipant p = participant(null, ParticipantStatus.SIGNED, true, null);
|
||||||
|
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEffectiveRole_declined_returnsViewer() {
|
||||||
|
WorkflowParticipant p = participant(null, ParticipantStatus.DECLINED, true, null);
|
||||||
|
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEffectiveRole_pending_returnsAssignedRole() {
|
||||||
|
WorkflowParticipant p = participant(null, ParticipantStatus.PENDING, true, null);
|
||||||
|
p.setAccessRole(ShareAccessRole.EDITOR);
|
||||||
|
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.EDITOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEffectiveRole_notified_returnsAssignedRole() {
|
||||||
|
WorkflowParticipant p = participant(null, ParticipantStatus.NOTIFIED, true, null);
|
||||||
|
p.setAccessRole(ShareAccessRole.VIEWER);
|
||||||
|
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.VIEWER);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getEffectiveRole_viewed_returnsAssignedRole() {
|
||||||
|
WorkflowParticipant p = participant(null, ParticipantStatus.VIEWED, true, null);
|
||||||
|
p.setAccessRole(ShareAccessRole.COMMENTER);
|
||||||
|
assertThat(service.getEffectiveRole(p)).isEqualTo(ShareAccessRole.COMMENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// canAccessFile
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canAccessFile_owner_returnsTrue() {
|
||||||
|
User u = user(1L);
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
file.setOwner(u);
|
||||||
|
|
||||||
|
assertThat(service.canAccessFile(u, file)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canAccessFile_nonOwner_withValidShare_returnsTrue() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
file.setOwner(owner);
|
||||||
|
|
||||||
|
FileShare s = share(file, requester, null);
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
|
||||||
|
.thenReturn(Optional.of(s));
|
||||||
|
|
||||||
|
assertThat(service.canAccessFile(requester, file)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canAccessFile_nonOwner_withExpiredShare_returnsFalse() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
file.setOwner(owner);
|
||||||
|
|
||||||
|
FileShare s = share(file, requester, LocalDateTime.now().minusSeconds(1));
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
|
||||||
|
.thenReturn(Optional.of(s));
|
||||||
|
|
||||||
|
assertThat(service.canAccessFile(requester, file)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canAccessFile_nonOwner_noShare_returnsFalse() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
file.setOwner(owner);
|
||||||
|
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThat(service.canAccessFile(requester, file)).isFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canAccessFile_workflowParticipant_activeSession_returnsTrue() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setStatus(WorkflowStatus.IN_PROGRESS);
|
||||||
|
session.setFinalized(false);
|
||||||
|
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
file.setOwner(owner);
|
||||||
|
file.setWorkflowSession(session);
|
||||||
|
|
||||||
|
WorkflowParticipant p = participant(requester, ParticipantStatus.PENDING, true, null);
|
||||||
|
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
when(workflowParticipantRepository.findByWorkflowSessionAndUser(session, requester))
|
||||||
|
.thenReturn(Optional.of(p));
|
||||||
|
|
||||||
|
assertThat(service.canAccessFile(requester, file)).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void canAccessFile_workflowParticipant_expiredParticipant_returnsFalse() {
|
||||||
|
User owner = user(1L);
|
||||||
|
User requester = user(2L);
|
||||||
|
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setStatus(WorkflowStatus.IN_PROGRESS);
|
||||||
|
session.setFinalized(false);
|
||||||
|
|
||||||
|
StoredFile file = new StoredFile();
|
||||||
|
file.setOwner(owner);
|
||||||
|
file.setWorkflowSession(session);
|
||||||
|
|
||||||
|
WorkflowParticipant p =
|
||||||
|
participant(
|
||||||
|
requester,
|
||||||
|
ParticipantStatus.PENDING,
|
||||||
|
true,
|
||||||
|
LocalDateTime.now().minusSeconds(1));
|
||||||
|
|
||||||
|
when(fileShareRepository.findByFileAndSharedWithUser(file, requester))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
when(workflowParticipantRepository.findByWorkflowSessionAndUser(session, requester))
|
||||||
|
.thenReturn(Optional.of(p));
|
||||||
|
|
||||||
|
assertThat(service.canAccessFile(requester, file)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
package stirling.software.proprietary.workflow.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.security.KeyPair;
|
||||||
|
import java.security.KeyPairGenerator;
|
||||||
|
import java.security.KeyStore;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.security.cert.Certificate;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||||
|
import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder;
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.bouncycastle.operator.ContentSigner;
|
||||||
|
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.model.ApplicationProperties.AutomaticallyGenerated;
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.workflow.model.UserServerCertificateEntity;
|
||||||
|
import stirling.software.proprietary.workflow.repository.UserServerCertificateRepository;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserServerCertificateServiceTest {
|
||||||
|
|
||||||
|
@Mock private UserServerCertificateRepository certificateRepository;
|
||||||
|
@Mock private UserRepository userRepository;
|
||||||
|
|
||||||
|
private MetadataEncryptionService encryptionService;
|
||||||
|
private UserServerCertificateService service;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
AutomaticallyGenerated generated = new AutomaticallyGenerated();
|
||||||
|
generated.setKey("test-key-for-unit-tests-only");
|
||||||
|
ApplicationProperties props = new ApplicationProperties();
|
||||||
|
props.setAutomaticallyGenerated(generated);
|
||||||
|
|
||||||
|
encryptionService = new MetadataEncryptionService(props);
|
||||||
|
service =
|
||||||
|
new UserServerCertificateService(
|
||||||
|
certificateRepository, userRepository, encryptionService);
|
||||||
|
}
|
||||||
|
|
||||||
|
private User user(long id) {
|
||||||
|
User u = new User();
|
||||||
|
u.setId(id);
|
||||||
|
u.setUsername("user" + id);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// generateUserCertificate — password must be stored encrypted
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateUserCertificate_keystorePasswordStoredEncrypted() throws Exception {
|
||||||
|
User user = user(1L);
|
||||||
|
when(certificateRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
service.generateUserCertificate(user);
|
||||||
|
|
||||||
|
ArgumentCaptor<UserServerCertificateEntity> captor =
|
||||||
|
ArgumentCaptor.forClass(UserServerCertificateEntity.class);
|
||||||
|
verify(certificateRepository).save(captor.capture());
|
||||||
|
|
||||||
|
String stored = captor.getValue().getKeystorePassword();
|
||||||
|
assertThat(stored).startsWith(MetadataEncryptionService.ENC_PREFIX);
|
||||||
|
// The raw predictable prefix must not appear in the stored value
|
||||||
|
assertThat(stored).doesNotContain("stirling-user-cert-");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// uploadUserCertificate — password must be stored encrypted
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void uploadUserCertificate_keystorePasswordStoredEncrypted() throws Exception {
|
||||||
|
User user = user(2L);
|
||||||
|
String uploadPassword = "my-upload-password";
|
||||||
|
byte[] p12Bytes = buildP12(uploadPassword);
|
||||||
|
|
||||||
|
when(certificateRepository.findByUserId(2L)).thenReturn(Optional.empty());
|
||||||
|
when(certificateRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
service.uploadUserCertificate(user, new ByteArrayInputStream(p12Bytes), uploadPassword);
|
||||||
|
|
||||||
|
ArgumentCaptor<UserServerCertificateEntity> captor =
|
||||||
|
ArgumentCaptor.forClass(UserServerCertificateEntity.class);
|
||||||
|
verify(certificateRepository).save(captor.capture());
|
||||||
|
|
||||||
|
String stored = captor.getValue().getKeystorePassword();
|
||||||
|
assertThat(stored).startsWith(MetadataEncryptionService.ENC_PREFIX);
|
||||||
|
assertThat(stored).doesNotContain(uploadPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// getUserKeystorePassword — must decrypt before returning
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUserKeystorePassword_returnsDecryptedPlaintext() {
|
||||||
|
String original = "plain-password";
|
||||||
|
String encrypted = encryptionService.encrypt(original);
|
||||||
|
|
||||||
|
UserServerCertificateEntity entity = new UserServerCertificateEntity();
|
||||||
|
entity.setKeystorePassword(encrypted);
|
||||||
|
when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity));
|
||||||
|
|
||||||
|
assertThat(service.getUserKeystorePassword(1L)).isEqualTo(original);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUserKeystorePassword_legacyPlaintext_returnedUnchanged() {
|
||||||
|
// Backwards-compatibility: values without enc: prefix pass through unchanged
|
||||||
|
UserServerCertificateEntity entity = new UserServerCertificateEntity();
|
||||||
|
entity.setKeystorePassword("legacy-plain");
|
||||||
|
when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity));
|
||||||
|
|
||||||
|
assertThat(service.getUserKeystorePassword(1L)).isEqualTo("legacy-plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// getUserKeyStore — decrypted password must successfully open the keystore
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getUserKeyStore_decryptsPasswordToLoadKeystore() throws Exception {
|
||||||
|
String keystorePassword = "keystore-pass";
|
||||||
|
byte[] p12Bytes = buildP12(keystorePassword);
|
||||||
|
String encryptedPassword = encryptionService.encrypt(keystorePassword);
|
||||||
|
|
||||||
|
UserServerCertificateEntity entity = new UserServerCertificateEntity();
|
||||||
|
entity.setKeystoreData(p12Bytes);
|
||||||
|
entity.setKeystorePassword(encryptedPassword);
|
||||||
|
when(certificateRepository.findByUserId(1L)).thenReturn(Optional.of(entity));
|
||||||
|
|
||||||
|
KeyStore ks = service.getUserKeyStore(1L);
|
||||||
|
|
||||||
|
assertThat(ks).isNotNull();
|
||||||
|
assertThat(ks.aliases().hasMoreElements()).isTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a minimal PKCS12 keystore containing a self-signed RSA certificate, using the same
|
||||||
|
* BouncyCastle provider that is already on the classpath.
|
||||||
|
*/
|
||||||
|
private static byte[] buildP12(String password) throws Exception {
|
||||||
|
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA", "BC");
|
||||||
|
kpg.initialize(2048, new SecureRandom());
|
||||||
|
KeyPair kp = kpg.generateKeyPair();
|
||||||
|
|
||||||
|
org.bouncycastle.asn1.x500.X500Name subject =
|
||||||
|
new org.bouncycastle.asn1.x500.X500Name("CN=test");
|
||||||
|
BigInteger serial = BigInteger.valueOf(System.currentTimeMillis());
|
||||||
|
Date notBefore = new Date();
|
||||||
|
Date notAfter = new Date(notBefore.getTime() + 365L * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
JcaX509v3CertificateBuilder builder =
|
||||||
|
new JcaX509v3CertificateBuilder(
|
||||||
|
subject, serial, notBefore, notAfter, subject, kp.getPublic());
|
||||||
|
|
||||||
|
ContentSigner signer =
|
||||||
|
new JcaContentSignerBuilder("SHA256WithRSA")
|
||||||
|
.setProvider("BC")
|
||||||
|
.build(kp.getPrivate());
|
||||||
|
|
||||||
|
X509Certificate cert =
|
||||||
|
new JcaX509CertificateConverter()
|
||||||
|
.setProvider(new BouncyCastleProvider())
|
||||||
|
.getCertificate(builder.build(signer));
|
||||||
|
|
||||||
|
KeyStore ks = KeyStore.getInstance("PKCS12");
|
||||||
|
ks.load(null, null);
|
||||||
|
ks.setKeyEntry("alias", kp.getPrivate(), password.toCharArray(), new Certificate[] {cert});
|
||||||
|
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
ks.store(baos, password.toCharArray());
|
||||||
|
return baos.toByteArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
+572
@@ -0,0 +1,572 @@
|
|||||||
|
package stirling.software.proprietary.workflow.service;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InOrder;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.model.ApplicationProperties.Storage;
|
||||||
|
import stirling.software.common.model.ApplicationProperties.Storage.Signing;
|
||||||
|
import stirling.software.proprietary.security.database.repository.UserRepository;
|
||||||
|
import stirling.software.proprietary.security.model.User;
|
||||||
|
import stirling.software.proprietary.storage.model.StoredFile;
|
||||||
|
import stirling.software.proprietary.storage.provider.StorageProvider;
|
||||||
|
import stirling.software.proprietary.storage.provider.StoredObject;
|
||||||
|
import stirling.software.proprietary.storage.repository.StoredFileRepository;
|
||||||
|
import stirling.software.proprietary.workflow.dto.SignDocumentRequest;
|
||||||
|
import stirling.software.proprietary.workflow.dto.WorkflowCreationRequest;
|
||||||
|
import stirling.software.proprietary.workflow.model.ParticipantStatus;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowParticipant;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowSession;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowStatus;
|
||||||
|
import stirling.software.proprietary.workflow.model.WorkflowType;
|
||||||
|
import stirling.software.proprietary.workflow.repository.WorkflowParticipantRepository;
|
||||||
|
import stirling.software.proprietary.workflow.repository.WorkflowSessionRepository;
|
||||||
|
|
||||||
|
import tools.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class WorkflowSessionServiceTest {
|
||||||
|
|
||||||
|
@Mock private WorkflowSessionRepository workflowSessionRepository;
|
||||||
|
@Mock private WorkflowParticipantRepository workflowParticipantRepository;
|
||||||
|
@Mock private StoredFileRepository storedFileRepository;
|
||||||
|
@Mock private UserRepository userRepository;
|
||||||
|
@Mock private StorageProvider storageProvider;
|
||||||
|
@Mock private ObjectMapper objectMapper;
|
||||||
|
@Mock private ApplicationProperties applicationProperties;
|
||||||
|
@Mock private MetadataEncryptionService metadataEncryptionService;
|
||||||
|
|
||||||
|
@InjectMocks private WorkflowSessionService service;
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private WorkflowSession sessionWithParticipant(
|
||||||
|
String sessionId, WorkflowParticipant participant) {
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId(sessionId);
|
||||||
|
List<WorkflowParticipant> participants = new ArrayList<>();
|
||||||
|
participants.add(participant);
|
||||||
|
session.setParticipants(participants);
|
||||||
|
when(workflowSessionRepository.findBySessionId(sessionId)).thenReturn(Optional.of(session));
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
private WorkflowParticipant pendingParticipant(User user) {
|
||||||
|
WorkflowParticipant p = new WorkflowParticipant();
|
||||||
|
p.setUser(user);
|
||||||
|
p.setStatus(ParticipantStatus.PENDING);
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
private User user(String username) {
|
||||||
|
User u = new User();
|
||||||
|
u.setUsername(username);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// signDocument — status transition
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signDocument_transitionsParticipantToSigned() {
|
||||||
|
User user = user("alice");
|
||||||
|
WorkflowParticipant participant = pendingParticipant(user);
|
||||||
|
sessionWithParticipant("s1", participant);
|
||||||
|
|
||||||
|
when(metadataEncryptionService.encrypt(any())).thenReturn("enc:pw");
|
||||||
|
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
SignDocumentRequest req = new SignDocumentRequest();
|
||||||
|
req.setCertType("SERVER");
|
||||||
|
|
||||||
|
service.signDocument("s1", user, req);
|
||||||
|
|
||||||
|
ArgumentCaptor<WorkflowParticipant> captor =
|
||||||
|
ArgumentCaptor.forClass(WorkflowParticipant.class);
|
||||||
|
verify(workflowParticipantRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getStatus()).isEqualTo(ParticipantStatus.SIGNED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// signDocument — certificate metadata
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signDocument_storesCertTypeAndEncryptedPasswordInMetadata() {
|
||||||
|
User user = user("bob");
|
||||||
|
WorkflowParticipant participant = pendingParticipant(user);
|
||||||
|
sessionWithParticipant("s2", participant);
|
||||||
|
|
||||||
|
when(metadataEncryptionService.encrypt("secret")).thenReturn("enc:secret");
|
||||||
|
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
SignDocumentRequest req = new SignDocumentRequest();
|
||||||
|
req.setCertType("USER_CERT");
|
||||||
|
req.setPassword("secret");
|
||||||
|
|
||||||
|
service.signDocument("s2", user, req);
|
||||||
|
|
||||||
|
ArgumentCaptor<WorkflowParticipant> captor =
|
||||||
|
ArgumentCaptor.forClass(WorkflowParticipant.class);
|
||||||
|
verify(workflowParticipantRepository).save(captor.capture());
|
||||||
|
|
||||||
|
Map<String, Object> meta = captor.getValue().getParticipantMetadata();
|
||||||
|
assertThat(meta).containsKey("certificateSubmission");
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> cert = (Map<String, Object>) meta.get("certificateSubmission");
|
||||||
|
assertThat(cert.get("certType")).isEqualTo("USER_CERT");
|
||||||
|
// Raw password must not be stored — only the encrypted form
|
||||||
|
assertThat(cert.get("password")).isEqualTo("enc:secret");
|
||||||
|
assertThat(cert.get("password")).isNotEqualTo("secret");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signDocument_preservesExistingParticipantMetadata() {
|
||||||
|
User user = user("carol");
|
||||||
|
WorkflowParticipant participant = pendingParticipant(user);
|
||||||
|
Map<String, Object> existing = new HashMap<>();
|
||||||
|
existing.put("showLogo", true);
|
||||||
|
existing.put("pageNumber", 1);
|
||||||
|
participant.setParticipantMetadata(existing);
|
||||||
|
sessionWithParticipant("s3", participant);
|
||||||
|
|
||||||
|
when(metadataEncryptionService.encrypt(any())).thenReturn("enc:pw");
|
||||||
|
when(workflowParticipantRepository.save(any())).thenAnswer(i -> i.getArgument(0));
|
||||||
|
|
||||||
|
SignDocumentRequest req = new SignDocumentRequest();
|
||||||
|
req.setCertType("SERVER");
|
||||||
|
|
||||||
|
service.signDocument("s3", user, req);
|
||||||
|
|
||||||
|
ArgumentCaptor<WorkflowParticipant> captor =
|
||||||
|
ArgumentCaptor.forClass(WorkflowParticipant.class);
|
||||||
|
verify(workflowParticipantRepository).save(captor.capture());
|
||||||
|
|
||||||
|
Map<String, Object> meta = captor.getValue().getParticipantMetadata();
|
||||||
|
// Owner-configured appearance settings must survive the sign operation
|
||||||
|
assertThat(meta.get("showLogo")).isEqualTo(true);
|
||||||
|
assertThat(meta.get("pageNumber")).isEqualTo(1);
|
||||||
|
assertThat(meta).containsKey("certificateSubmission");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// signDocument — guard conditions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signDocument_throwsBadRequest_whenAlreadySigned() {
|
||||||
|
User user = user("dave");
|
||||||
|
WorkflowParticipant participant = pendingParticipant(user);
|
||||||
|
participant.setStatus(ParticipantStatus.SIGNED);
|
||||||
|
sessionWithParticipant("s4", participant);
|
||||||
|
|
||||||
|
SignDocumentRequest req = new SignDocumentRequest();
|
||||||
|
req.setCertType("SERVER");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.signDocument("s4", user, req))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
|
||||||
|
verify(workflowParticipantRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signDocument_throwsBadRequest_whenAlreadyDeclined() {
|
||||||
|
User user = user("eve");
|
||||||
|
WorkflowParticipant participant = pendingParticipant(user);
|
||||||
|
participant.setStatus(ParticipantStatus.DECLINED);
|
||||||
|
sessionWithParticipant("s5", participant);
|
||||||
|
|
||||||
|
SignDocumentRequest req = new SignDocumentRequest();
|
||||||
|
req.setCertType("SERVER");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.signDocument("s5", user, req))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
|
||||||
|
verify(workflowParticipantRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void signDocument_throwsForbidden_whenUserIsNotParticipant() {
|
||||||
|
User owner = user("frank");
|
||||||
|
owner.setId(1L);
|
||||||
|
User intruder = user("intruder");
|
||||||
|
intruder.setId(2L);
|
||||||
|
WorkflowParticipant participant = pendingParticipant(owner);
|
||||||
|
sessionWithParticipant("s6", participant);
|
||||||
|
|
||||||
|
SignDocumentRequest req = new SignDocumentRequest();
|
||||||
|
req.setCertType("SERVER");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.signDocument("s6", intruder, req))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
|
||||||
|
verify(workflowParticipantRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// createSession — validation guards
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private void stubSigningEnabled() {
|
||||||
|
Storage storage = mock(Storage.class);
|
||||||
|
Signing signing = mock(Signing.class);
|
||||||
|
when(applicationProperties.getStorage()).thenReturn(storage);
|
||||||
|
when(storage.isEnabled()).thenReturn(true);
|
||||||
|
when(storage.getSigning()).thenReturn(signing);
|
||||||
|
when(signing.isEnabled()).thenReturn(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSession_nullFile_throwsBadRequest() {
|
||||||
|
WorkflowCreationRequest request = new WorkflowCreationRequest();
|
||||||
|
request.setWorkflowType(WorkflowType.SIGNING);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.createSession(user("owner"), null, request))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSession_emptyFile_throwsBadRequest() {
|
||||||
|
MockMultipartFile empty =
|
||||||
|
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[0]);
|
||||||
|
WorkflowCreationRequest request = new WorkflowCreationRequest();
|
||||||
|
request.setWorkflowType(WorkflowType.SIGNING);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.createSession(user("owner"), empty, request))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSession_nullWorkflowType_throwsBadRequest() {
|
||||||
|
MockMultipartFile file =
|
||||||
|
new MockMultipartFile("file", "test.pdf", "application/pdf", new byte[] {1});
|
||||||
|
WorkflowCreationRequest request = new WorkflowCreationRequest();
|
||||||
|
request.setWorkflowType(null);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.createSession(user("owner"), file, request))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSession_validRequest_sessionSavedWithOwnerAndInProgressStatus() throws IOException {
|
||||||
|
User owner = user("alice");
|
||||||
|
MockMultipartFile file =
|
||||||
|
new MockMultipartFile("file", "doc.pdf", "application/pdf", new byte[] {1, 2});
|
||||||
|
WorkflowCreationRequest request = new WorkflowCreationRequest();
|
||||||
|
request.setWorkflowType(WorkflowType.SIGNING);
|
||||||
|
request.setDocumentName("My Doc");
|
||||||
|
|
||||||
|
StoredObject storedObject =
|
||||||
|
StoredObject.builder()
|
||||||
|
.storageKey("key-1")
|
||||||
|
.originalFilename("doc.pdf")
|
||||||
|
.contentType("application/pdf")
|
||||||
|
.sizeBytes(2L)
|
||||||
|
.build();
|
||||||
|
when(storageProvider.store(any(), any())).thenReturn(storedObject);
|
||||||
|
|
||||||
|
StoredFile savedFile = new StoredFile();
|
||||||
|
when(storedFileRepository.save(any())).thenReturn(savedFile);
|
||||||
|
|
||||||
|
WorkflowSession savedSession = new WorkflowSession();
|
||||||
|
savedSession.setSessionId("s-abc");
|
||||||
|
savedSession.setParticipants(new ArrayList<>());
|
||||||
|
when(workflowSessionRepository.save(any())).thenReturn(savedSession);
|
||||||
|
|
||||||
|
WorkflowSession result = service.createSession(owner, file, request);
|
||||||
|
|
||||||
|
ArgumentCaptor<WorkflowSession> captor = ArgumentCaptor.forClass(WorkflowSession.class);
|
||||||
|
verify(workflowSessionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getOwner()).isEqualTo(owner);
|
||||||
|
assertThat(captor.getValue().getStatus()).isEqualTo(WorkflowStatus.IN_PROGRESS);
|
||||||
|
assertThat(result).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSession_documentNameFromRequest() throws IOException {
|
||||||
|
User owner = user("alice");
|
||||||
|
MockMultipartFile file =
|
||||||
|
new MockMultipartFile("file", "original.pdf", "application/pdf", new byte[] {1});
|
||||||
|
WorkflowCreationRequest request = new WorkflowCreationRequest();
|
||||||
|
request.setWorkflowType(WorkflowType.SIGNING);
|
||||||
|
request.setDocumentName("Custom Name");
|
||||||
|
|
||||||
|
when(storageProvider.store(any(), any()))
|
||||||
|
.thenReturn(
|
||||||
|
StoredObject.builder()
|
||||||
|
.storageKey("k")
|
||||||
|
.originalFilename("original.pdf")
|
||||||
|
.contentType("application/pdf")
|
||||||
|
.sizeBytes(1L)
|
||||||
|
.build());
|
||||||
|
when(storedFileRepository.save(any())).thenReturn(new StoredFile());
|
||||||
|
|
||||||
|
WorkflowSession savedSession = new WorkflowSession();
|
||||||
|
savedSession.setSessionId("s-1");
|
||||||
|
savedSession.setParticipants(new ArrayList<>());
|
||||||
|
when(workflowSessionRepository.save(any())).thenReturn(savedSession);
|
||||||
|
|
||||||
|
service.createSession(owner, file, request);
|
||||||
|
|
||||||
|
ArgumentCaptor<WorkflowSession> captor = ArgumentCaptor.forClass(WorkflowSession.class);
|
||||||
|
verify(workflowSessionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getDocumentName()).isEqualTo("Custom Name");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createSession_documentNameFallsBackToOriginalFilename() throws IOException {
|
||||||
|
User owner = user("alice");
|
||||||
|
MockMultipartFile file =
|
||||||
|
new MockMultipartFile("file", "uploaded.pdf", "application/pdf", new byte[] {1});
|
||||||
|
WorkflowCreationRequest request = new WorkflowCreationRequest();
|
||||||
|
request.setWorkflowType(WorkflowType.SIGNING);
|
||||||
|
request.setDocumentName(null);
|
||||||
|
|
||||||
|
when(storageProvider.store(any(), any()))
|
||||||
|
.thenReturn(
|
||||||
|
StoredObject.builder()
|
||||||
|
.storageKey("k")
|
||||||
|
.originalFilename("uploaded.pdf")
|
||||||
|
.contentType("application/pdf")
|
||||||
|
.sizeBytes(1L)
|
||||||
|
.build());
|
||||||
|
when(storedFileRepository.save(any())).thenReturn(new StoredFile());
|
||||||
|
|
||||||
|
WorkflowSession savedSession = new WorkflowSession();
|
||||||
|
savedSession.setSessionId("s-2");
|
||||||
|
savedSession.setParticipants(new ArrayList<>());
|
||||||
|
when(workflowSessionRepository.save(any())).thenReturn(savedSession);
|
||||||
|
|
||||||
|
service.createSession(owner, file, request);
|
||||||
|
|
||||||
|
ArgumentCaptor<WorkflowSession> captor = ArgumentCaptor.forClass(WorkflowSession.class);
|
||||||
|
verify(workflowSessionRepository).save(captor.capture());
|
||||||
|
assertThat(captor.getValue().getDocumentName()).isEqualTo("uploaded.pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// getSessionForOwner
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSessionForOwner_ownerMatch_returnsSession() {
|
||||||
|
User owner = user("alice");
|
||||||
|
owner.setId(1L);
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId("s1");
|
||||||
|
session.setOwner(owner);
|
||||||
|
when(workflowSessionRepository.findBySessionId("s1")).thenReturn(Optional.of(session));
|
||||||
|
|
||||||
|
WorkflowSession result = service.getSessionForOwner("s1", owner);
|
||||||
|
|
||||||
|
assertThat(result).isSameAs(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSessionForOwner_sessionNotFound_throwsNotFound() {
|
||||||
|
when(workflowSessionRepository.findBySessionId("missing")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.getSessionForOwner("missing", user("alice")))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getSessionForOwner_wrongOwner_throwsForbidden() {
|
||||||
|
User owner = user("alice");
|
||||||
|
owner.setId(1L);
|
||||||
|
User intruder = user("bob");
|
||||||
|
intruder.setId(2L);
|
||||||
|
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId("s2");
|
||||||
|
session.setOwner(owner);
|
||||||
|
when(workflowSessionRepository.findBySessionId("s2")).thenReturn(Optional.of(session));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.getSessionForOwner("s2", intruder))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// deleteSession
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteSession_ownerAuthorized_deletesSession() {
|
||||||
|
User owner = user("alice");
|
||||||
|
owner.setId(1L);
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId("s3");
|
||||||
|
session.setOwner(owner);
|
||||||
|
when(workflowSessionRepository.findBySessionId("s3")).thenReturn(Optional.of(session));
|
||||||
|
|
||||||
|
service.deleteSession("s3", owner);
|
||||||
|
|
||||||
|
verify(workflowSessionRepository).delete(session);
|
||||||
|
// session.save() must NOT be called — that would UPDATE original_file_id to NULL,
|
||||||
|
// violating the NOT NULL constraint; the row is simply deleted instead
|
||||||
|
verify(workflowSessionRepository, never()).save(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteSession_withBothFiles_nullsBackRefsAndDeletesInOrder() {
|
||||||
|
User owner = user("alice");
|
||||||
|
owner.setId(1L);
|
||||||
|
|
||||||
|
StoredFile originalFile = new StoredFile();
|
||||||
|
originalFile.setStorageKey("key-orig");
|
||||||
|
StoredFile processedFile = new StoredFile();
|
||||||
|
processedFile.setStorageKey("key-proc");
|
||||||
|
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId("s3b");
|
||||||
|
session.setOwner(owner);
|
||||||
|
session.setOriginalFile(originalFile);
|
||||||
|
session.setProcessedFile(processedFile);
|
||||||
|
when(workflowSessionRepository.findBySessionId("s3b")).thenReturn(Optional.of(session));
|
||||||
|
|
||||||
|
service.deleteSession("s3b", owner);
|
||||||
|
|
||||||
|
// Only the back-reference (StoredFile → session) must be nulled; session.originalFile
|
||||||
|
// is NOT nulled because that would emit UPDATE original_file_id=NULL (NOT NULL violation)
|
||||||
|
assertThat(originalFile.getWorkflowSession()).isNull();
|
||||||
|
assertThat(processedFile.getWorkflowSession()).isNull();
|
||||||
|
|
||||||
|
// Order: save StoredFiles (clear back-refs) → delete session → delete StoredFile rows
|
||||||
|
InOrder inOrder = inOrder(workflowSessionRepository, storedFileRepository);
|
||||||
|
inOrder.verify(storedFileRepository).save(originalFile);
|
||||||
|
inOrder.verify(storedFileRepository).save(processedFile);
|
||||||
|
inOrder.verify(workflowSessionRepository).delete(session);
|
||||||
|
inOrder.verify(storedFileRepository).delete(originalFile);
|
||||||
|
inOrder.verify(storedFileRepository).delete(processedFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteSession_finalizedSession_throwsBadRequest() {
|
||||||
|
User owner = user("alice");
|
||||||
|
owner.setId(1L);
|
||||||
|
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId("s3c");
|
||||||
|
session.setOwner(owner);
|
||||||
|
session.setFinalized(true);
|
||||||
|
when(workflowSessionRepository.findBySessionId("s3c")).thenReturn(Optional.of(session));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.deleteSession("s3c", owner))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.BAD_REQUEST);
|
||||||
|
|
||||||
|
verify(workflowSessionRepository, never()).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteSession_storageErrorOnOriginalFile_stillDeletesSession() throws Exception {
|
||||||
|
User owner = user("alice");
|
||||||
|
owner.setId(1L);
|
||||||
|
|
||||||
|
StoredFile originalFile = new StoredFile();
|
||||||
|
originalFile.setStorageKey("key-orig");
|
||||||
|
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId("s4");
|
||||||
|
session.setOwner(owner);
|
||||||
|
session.setOriginalFile(originalFile);
|
||||||
|
when(workflowSessionRepository.findBySessionId("s4")).thenReturn(Optional.of(session));
|
||||||
|
doThrow(new RuntimeException("storage unavailable"))
|
||||||
|
.when(storageProvider)
|
||||||
|
.delete("key-orig");
|
||||||
|
|
||||||
|
service.deleteSession("s4", owner);
|
||||||
|
|
||||||
|
// Storage failure is non-fatal — back-ref is still cleared and DB records still deleted
|
||||||
|
verify(storedFileRepository).save(originalFile); // back-ref cleared
|
||||||
|
verify(workflowSessionRepository).delete(session);
|
||||||
|
verify(storedFileRepository).delete(originalFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deleteSession_notOwner_throwsForbidden() {
|
||||||
|
User owner = user("alice");
|
||||||
|
owner.setId(1L);
|
||||||
|
User other = user("bob");
|
||||||
|
other.setId(2L);
|
||||||
|
|
||||||
|
WorkflowSession session = new WorkflowSession();
|
||||||
|
session.setSessionId("s5");
|
||||||
|
session.setOwner(owner);
|
||||||
|
when(workflowSessionRepository.findBySessionId("s5")).thenReturn(Optional.of(session));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.deleteSession("s5", other))
|
||||||
|
.isInstanceOf(ResponseStatusException.class)
|
||||||
|
.extracting(e -> ((ResponseStatusException) e).getStatusCode())
|
||||||
|
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||||
|
|
||||||
|
verify(workflowSessionRepository, never()).delete(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// listUserSessions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listUserSessions_returnsAllSessionsForOwner() {
|
||||||
|
User owner = user("alice");
|
||||||
|
WorkflowSession s1 = new WorkflowSession();
|
||||||
|
WorkflowSession s2 = new WorkflowSession();
|
||||||
|
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner))
|
||||||
|
.thenReturn(List.of(s1, s2));
|
||||||
|
|
||||||
|
List<WorkflowSession> result = service.listUserSessions(owner);
|
||||||
|
|
||||||
|
assertThat(result).containsExactly(s1, s2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listUserSessions_noSessions_returnsEmptyList() {
|
||||||
|
User owner = user("alice");
|
||||||
|
when(workflowSessionRepository.findByOwnerOrderByCreatedAtDesc(owner))
|
||||||
|
.thenReturn(List.of());
|
||||||
|
|
||||||
|
assertThat(service.listUserSessions(owner)).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ services:
|
|||||||
stirling-pdf:
|
stirling-pdf:
|
||||||
container_name: Stirling-PDF-Fat-Disable-Endpoints
|
container_name: Stirling-PDF-Fat-Disable-Endpoints
|
||||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:fat
|
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:fat
|
||||||
|
build:
|
||||||
|
context: ../../../
|
||||||
|
dockerfile: docker/embedded/Dockerfile.fat
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ services:
|
|||||||
stirling-pdf:
|
stirling-pdf:
|
||||||
container_name: Stirling-PDF-Security-Fat
|
container_name: Stirling-PDF-Security-Fat
|
||||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:fat
|
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:fat
|
||||||
|
build:
|
||||||
|
context: ../../../
|
||||||
|
dockerfile: docker/embedded/Dockerfile
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
@@ -33,4 +36,5 @@ services:
|
|||||||
METRICS_ENABLED: "true"
|
METRICS_ENABLED: "true"
|
||||||
SYSTEM_GOOGLEVISIBILITY: "true"
|
SYSTEM_GOOGLEVISIBILITY: "true"
|
||||||
SHOW_SURVEY: "true"
|
SHOW_SURVEY: "true"
|
||||||
|
STORAGE_LOCAL_BASEPATH: /configs/storage
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ services:
|
|||||||
stirling-pdf:
|
stirling-pdf:
|
||||||
container_name: Stirling-PDF-Ultra-Lite
|
container_name: Stirling-PDF-Ultra-Lite
|
||||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:ultra-lite
|
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:ultra-lite
|
||||||
|
build:
|
||||||
|
context: ../../../
|
||||||
|
dockerfile: docker/embedded/Dockerfile.ultra-lite
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ services:
|
|||||||
stirling-pdf:
|
stirling-pdf:
|
||||||
container_name: Stirling-PDF-Security-Fat-with-login
|
container_name: Stirling-PDF-Security-Fat-with-login
|
||||||
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:fat
|
image: docker.stirlingpdf.com/stirlingtools/stirling-pdf:fat
|
||||||
|
build:
|
||||||
|
context: ../../../
|
||||||
|
dockerfile: docker/embedded/Dockerfile.fat
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ black = "Black"
|
|||||||
blue = "Blue"
|
blue = "Blue"
|
||||||
bored = "Bored Waiting?"
|
bored = "Bored Waiting?"
|
||||||
cancel = "Cancel"
|
cancel = "Cancel"
|
||||||
|
confirm = "Confirm"
|
||||||
changedCredsMessage = "Credentials changed!"
|
changedCredsMessage = "Credentials changed!"
|
||||||
chooseFile = "Choose File"
|
chooseFile = "Choose File"
|
||||||
close = "Close"
|
close = "Close"
|
||||||
@@ -147,6 +148,8 @@ loadingCredits = "Checking credits..."
|
|||||||
loadingProStatus = "Checking subscription status..."
|
loadingProStatus = "Checking subscription status..."
|
||||||
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
|
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
|
||||||
|
|
||||||
|
accessInvite = "Invite"
|
||||||
|
|
||||||
[account]
|
[account]
|
||||||
accountSettings = "Account Settings"
|
accountSettings = "Account Settings"
|
||||||
adminSettings = "Admin Settings - View and Add Users"
|
adminSettings = "Admin Settings - View and Add Users"
|
||||||
@@ -1415,6 +1418,34 @@ title = "Processing"
|
|||||||
description = "Maximum time to wait for a processing job before reporting an error."
|
description = "Maximum time to wait for a processing job before reporting an error."
|
||||||
label = "Processing Timeout (seconds)"
|
label = "Processing Timeout (seconds)"
|
||||||
|
|
||||||
|
[admin.settings.storage]
|
||||||
|
description = "Control server storage and sharing options."
|
||||||
|
title = "File Storage & Sharing"
|
||||||
|
|
||||||
|
[admin.settings.storage.enabled]
|
||||||
|
description = "Allow users to store files on the server."
|
||||||
|
label = "Enable Server File Storage"
|
||||||
|
|
||||||
|
[admin.settings.storage.sharing.email]
|
||||||
|
description = "Allow sharing with email addresses."
|
||||||
|
label = "Enable Email Sharing"
|
||||||
|
mailLink = "Configure Mail Settings"
|
||||||
|
mailNote = "Requires mail configuration. "
|
||||||
|
|
||||||
|
[admin.settings.storage.sharing.enabled]
|
||||||
|
description = "Allow users to share stored files."
|
||||||
|
label = "Enable Sharing"
|
||||||
|
|
||||||
|
[admin.settings.storage.sharing.links]
|
||||||
|
description = "Allow sharing via signed-in links."
|
||||||
|
frontendUrlLink = "Configure in System Settings"
|
||||||
|
frontendUrlNote = "Requires a Frontend URL. "
|
||||||
|
label = "Enable Share Links"
|
||||||
|
|
||||||
|
[admin.settings.storage.signing.enabled]
|
||||||
|
description = "Allow users to create multi-participant document signing sessions. Requires server file storage to be enabled."
|
||||||
|
label = "Enable Group Signing (Alpha)"
|
||||||
|
|
||||||
[admin.settings.unsavedChanges]
|
[admin.settings.unsavedChanges]
|
||||||
cancel = "Keep Editing"
|
cancel = "Keep Editing"
|
||||||
discard = "Discard Changes"
|
discard = "Discard Changes"
|
||||||
@@ -2047,7 +2078,19 @@ numbers = "Numbers/ranges: 5, 10-20"
|
|||||||
progressions = "Progressions: 3n, 4n+1"
|
progressions = "Progressions: 3n, 4n+1"
|
||||||
|
|
||||||
[certSign]
|
[certSign]
|
||||||
|
allSigned = "All participants have signed. Ready to finalize."
|
||||||
|
awaitingSignatures = "Awaiting signatures"
|
||||||
|
signatureProgress = "{{signedCount}}/{{totalCount}} signatures"
|
||||||
chooseCertificate = "Choose Certificate File"
|
chooseCertificate = "Choose Certificate File"
|
||||||
|
declined = "Declined"
|
||||||
|
fetchFailed = "Failed to load signing data"
|
||||||
|
finalized = "Finalized"
|
||||||
|
notified = "Pending"
|
||||||
|
partialNote = "You can finalize early with current signatures. Unsigned participants will be excluded."
|
||||||
|
pending = "Pending"
|
||||||
|
readyToFinalize = "Ready to finalize"
|
||||||
|
signed = "Signed"
|
||||||
|
viewed = "Viewed"
|
||||||
chooseJksFile = "Choose JKS File"
|
chooseJksFile = "Choose JKS File"
|
||||||
chooseP12File = "Choose PKCS12 File"
|
chooseP12File = "Choose PKCS12 File"
|
||||||
choosePfxFile = "Choose PFX File"
|
choosePfxFile = "Choose PFX File"
|
||||||
@@ -2070,6 +2113,7 @@ title = "Certificate Signing"
|
|||||||
invisible = "Invisible"
|
invisible = "Invisible"
|
||||||
stepTitle = "Signature Appearance"
|
stepTitle = "Signature Appearance"
|
||||||
visible = "Visible"
|
visible = "Visible"
|
||||||
|
visibility = "Visibility"
|
||||||
|
|
||||||
[certSign.appearance.options]
|
[certSign.appearance.options]
|
||||||
title = "Signature Details"
|
title = "Signature Details"
|
||||||
@@ -2176,6 +2220,239 @@ bullet4 = "Can use custom certificates for verification"
|
|||||||
text = "When you check signatures, the tool tells you if they're valid, who signed the document, when it was signed, and whether the document has been changed since signing."
|
text = "When you check signatures, the tool tells you if they're valid, who signed the document, when it was signed, and whether the document has been changed since signing."
|
||||||
title = "Checking Signatures"
|
title = "Checking Signatures"
|
||||||
|
|
||||||
|
[certSign.collab.finalize]
|
||||||
|
button = "Finalize and Load Signed PDF"
|
||||||
|
early = "Finalize with Current Signatures"
|
||||||
|
|
||||||
|
[certSign.collab.sessionDetail]
|
||||||
|
addButton = "Add Participants"
|
||||||
|
addParticipants = "Add Participants"
|
||||||
|
addParticipantsError = "Failed to add participants"
|
||||||
|
backToList = "Back to Sessions"
|
||||||
|
deleteConfirm = "Are you sure? This cannot be undone."
|
||||||
|
deleteError = "Failed to delete session"
|
||||||
|
deleted = "Session deleted"
|
||||||
|
deleteSession = "Delete Session"
|
||||||
|
dueDate = "Due Date"
|
||||||
|
finalizeError = "Failed to finalize session"
|
||||||
|
loadPdfError = "Failed to load signed PDF"
|
||||||
|
loadSignedPdf = "Load Signed PDF into Active Files"
|
||||||
|
messageLabel = "Message"
|
||||||
|
noAdditionalInfo = "No additional information"
|
||||||
|
owner = "Owner"
|
||||||
|
participantRemoved = "Participant removed"
|
||||||
|
participants = "Participants"
|
||||||
|
participantsAdded = "Participants added successfully"
|
||||||
|
removeParticipant = "Remove"
|
||||||
|
removeParticipantError = "Failed to remove participant"
|
||||||
|
selectUsers = "Select users..."
|
||||||
|
sessionInfo = "Session Info"
|
||||||
|
workbenchTitle = "Session Management"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest]
|
||||||
|
addedToFiles = "Document added to active files"
|
||||||
|
addSignature = "Add Your Signature"
|
||||||
|
addToFiles = "Add to Active Files"
|
||||||
|
advancedSettings = "Advanced Settings"
|
||||||
|
backToList = "Back to Sign Requests"
|
||||||
|
certificateChoice = "Select a certificate to sign with"
|
||||||
|
changeSignature = "Change signature"
|
||||||
|
clearSignature = "Clear Signature"
|
||||||
|
completeAndSign = "Complete & Sign"
|
||||||
|
createNewSignature = "Create New Signature"
|
||||||
|
declineButton = "Decline"
|
||||||
|
decline = "Decline Request"
|
||||||
|
deleteSelected = "Delete selected signature"
|
||||||
|
drawSignature = "Draw your signature below"
|
||||||
|
dueDate = "Due Date"
|
||||||
|
fileTooLarge = "File size must be less than 5MB"
|
||||||
|
fontFamily = "Font Family"
|
||||||
|
fontSize = "Font Size: {{size}}px"
|
||||||
|
fontSizePlaceholder = "Size"
|
||||||
|
from = "From"
|
||||||
|
invalidCertFile = "Please select a P12 or PFX certificate file"
|
||||||
|
invalidFileType = "Please select an image file"
|
||||||
|
location = "Location (Optional)"
|
||||||
|
locationPlaceholder = "Where are you signing from?"
|
||||||
|
message = "Message"
|
||||||
|
noCertificate = "Please select a certificate file"
|
||||||
|
noSignatures = "Please place at least one signature on the PDF"
|
||||||
|
p12File = "P12/PFX Certificate File"
|
||||||
|
password = "Certificate Password"
|
||||||
|
passwordPlaceholder = "Enter password..."
|
||||||
|
penColor = "Pen Color"
|
||||||
|
penSize = "Pen Size: {{size}}px"
|
||||||
|
placementActive = "Click PDF to place"
|
||||||
|
placeSignatureButton = "Place Signature on PDF"
|
||||||
|
reason = "Reason (Optional)"
|
||||||
|
reasonPlaceholder = "Why are you signing?"
|
||||||
|
removeImage = "Remove Image"
|
||||||
|
removeCertFile = "Remove File"
|
||||||
|
savedSignatures = "Saved Signatures"
|
||||||
|
selectFile = "Select Image File"
|
||||||
|
selectSignatureTitle = "Select or Create Signature"
|
||||||
|
signButton = "Sign Document"
|
||||||
|
signatureInfo = "These settings are configured by the document owner"
|
||||||
|
signaturePlaced = "Signature placed on page"
|
||||||
|
signatureSettings = "Signature Settings"
|
||||||
|
signatureText = "Signature Text"
|
||||||
|
signatureTextPlaceholder = "Enter your name..."
|
||||||
|
signatureTypeLabel = "Signature Type"
|
||||||
|
signingTitle = "Signing"
|
||||||
|
textColor = "Text Color"
|
||||||
|
typeSignature = "Type your name to create a signature"
|
||||||
|
uploadCert = "Custom Certificate"
|
||||||
|
uploadCertDesc = "Use your own P12/PFX certificate"
|
||||||
|
uploadSignature = "Upload your signature image"
|
||||||
|
usePersonalCert = "Personal Certificate"
|
||||||
|
usePersonalCertDesc = "Auto-generated for your account"
|
||||||
|
useServerCert = "Organization Certificate"
|
||||||
|
useServerCertDesc = "Shared organization certificate"
|
||||||
|
workbenchTitle = "Sign Request"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.canvas]
|
||||||
|
colorPickerTitle = "Choose stroke colour"
|
||||||
|
continue = "Continue"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.certModal]
|
||||||
|
description = "You have placed {{count}} signature(s). Choose your certificate to complete signing."
|
||||||
|
sign = "Sign Document"
|
||||||
|
title = "Configure Certificate"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.image]
|
||||||
|
hint = "Upload a PNG or JPG image of your signature"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.mode]
|
||||||
|
move = "Move Signature"
|
||||||
|
place = "Place Signature"
|
||||||
|
title = "Sign or move mode"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.modeTabs]
|
||||||
|
draw = "Draw"
|
||||||
|
image = "Upload"
|
||||||
|
text = "Type"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.placeSignature]
|
||||||
|
message = "Click on the PDF to place your signature"
|
||||||
|
title = "Place Signature"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.preview]
|
||||||
|
imageAlt = "Selected signature"
|
||||||
|
missing = "No preview"
|
||||||
|
textFallback = "Signature"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.saved]
|
||||||
|
defaultCanvasLabel = "Drawing signature"
|
||||||
|
defaultImageLabel = "Uploaded signature"
|
||||||
|
defaultLabel = "Signature"
|
||||||
|
defaultTextLabel = "Typed signature"
|
||||||
|
delete = "Delete signature"
|
||||||
|
none = "No saved signatures"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.signatureType]
|
||||||
|
draw = "Draw"
|
||||||
|
type = "Type"
|
||||||
|
upload = "Upload"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.steps]
|
||||||
|
back = "Back"
|
||||||
|
cancelPlacement = "Cancel Placement"
|
||||||
|
certificate = "Certificate"
|
||||||
|
clickMultipleTimes = "Click on the PDF multiple times to place signatures. Drag any signature to move or resize it."
|
||||||
|
clickToPlace = "Click on the PDF where you would like your signature to appear."
|
||||||
|
continue = "Continue to Certificate Selection"
|
||||||
|
continueToPlacement = "Continue to Placement"
|
||||||
|
continueToReview = "Continue to Review"
|
||||||
|
createSignature = "Create Signature"
|
||||||
|
invisible = "Invisible"
|
||||||
|
location = "Location:"
|
||||||
|
multipleSignatures = "{{count}} signatures will be applied to the PDF"
|
||||||
|
oneSignature = "1 signature will be applied to the PDF"
|
||||||
|
placeOnPdf = "Place on PDF"
|
||||||
|
reason = "Reason:"
|
||||||
|
reviewTitle = "Review Before Signing"
|
||||||
|
signaturePlaced = "Signature placed on page {{page}}. You can adjust the position by clicking again or continue to review."
|
||||||
|
visible = "Visible"
|
||||||
|
visibility = "Visibility:"
|
||||||
|
yourSignatures = "Your Signatures ({{count}})"
|
||||||
|
|
||||||
|
[certSign.collab.signRequest.text]
|
||||||
|
colorLabel = "Color"
|
||||||
|
fontLabel = "Font"
|
||||||
|
fontSizeLabel = "Size"
|
||||||
|
fontSizePlaceholder = "16"
|
||||||
|
label = "Signature Text"
|
||||||
|
modalHint = "Enter your name, then click Continue to place it on the PDF."
|
||||||
|
placeholder = "Enter your name..."
|
||||||
|
|
||||||
|
[certSign.collab.addParticipants]
|
||||||
|
add = "Add {{count}} Participant(s)"
|
||||||
|
back = "Back"
|
||||||
|
configureSignatures = "Configure Signature Settings"
|
||||||
|
continue = "Continue to Signature Settings"
|
||||||
|
reasonHelp = "Pre-set a signing reason for these participants (optional, they can override when signing)"
|
||||||
|
reasonPlaceholder = "e.g. Approval, Review..."
|
||||||
|
selectUsers = "Select Users"
|
||||||
|
|
||||||
|
[certSign.collab.sessionCreation]
|
||||||
|
includeSummaryPage = "Include Signature Summary Page"
|
||||||
|
includeSummaryPageHelp = "A summary page will be added at the end with all signature metadata. The digital certificate signature boxes on individual pages will be suppressed (wet signatures are unaffected)."
|
||||||
|
|
||||||
|
[certSign.collab.sessionList]
|
||||||
|
active = "Active"
|
||||||
|
finalized = "Finalized"
|
||||||
|
|
||||||
|
[certSign.collab.signatureSettings]
|
||||||
|
description = "Configure how signatures will appear for all participants"
|
||||||
|
title = "Signature Appearance"
|
||||||
|
|
||||||
|
[certSign.collab.userSelector]
|
||||||
|
inviteUsers = "Add Users"
|
||||||
|
loadError = "Failed to load users"
|
||||||
|
noTeam = "No Team"
|
||||||
|
noUsers = "No other users found."
|
||||||
|
placeholder = "Select users..."
|
||||||
|
|
||||||
|
[certSign.mobile]
|
||||||
|
panelActions = "Actions"
|
||||||
|
panelDocument = "Document"
|
||||||
|
panelPeople = "People"
|
||||||
|
|
||||||
|
[certSign.sessions]
|
||||||
|
deleted = "Session deleted"
|
||||||
|
fetchFailed = "Failed to load session details"
|
||||||
|
finalized = "Session finalized"
|
||||||
|
loaded = "Signed PDF loaded"
|
||||||
|
pdfNotReady = "PDF Not Ready"
|
||||||
|
pdfNotReadyDesc = "The signed PDF is being generated. Please try again in a moment."
|
||||||
|
|
||||||
|
[certificateChoice.tooltip]
|
||||||
|
header = "Certificate Types"
|
||||||
|
|
||||||
|
[certificateChoice.tooltip.organization]
|
||||||
|
bullet1 = "Managed by system administrators"
|
||||||
|
bullet2 = "Shared across authorized users"
|
||||||
|
bullet3 = "Represents company identity, not individual"
|
||||||
|
bullet4 = "Best for: Official documents, team signatures"
|
||||||
|
description = "A shared certificate provided by your organization. Used for company-wide signing authority."
|
||||||
|
title = "Organization Certificate"
|
||||||
|
|
||||||
|
[certificateChoice.tooltip.personal]
|
||||||
|
bullet1 = "Generated automatically on first use"
|
||||||
|
bullet2 = "Tied to your user account"
|
||||||
|
bullet3 = "Cannot be shared with other users"
|
||||||
|
bullet4 = "Best for: Personal documents, individual accountability"
|
||||||
|
description = "An auto-generated certificate unique to your user account. Suitable for individual signatures."
|
||||||
|
title = "Personal Certificate"
|
||||||
|
|
||||||
|
[certificateChoice.tooltip.upload]
|
||||||
|
bullet1 = "Requires P12/PFX file and password"
|
||||||
|
bullet2 = "Can be issued by external Certificate Authorities"
|
||||||
|
bullet3 = "Higher trust level for legal documents"
|
||||||
|
bullet4 = "Best for: Legally binding contracts, external validation"
|
||||||
|
description = "Use your own PKCS#12 certificate file. Provides full control over certificate properties."
|
||||||
|
title = "Upload Custom P12"
|
||||||
|
|
||||||
[changeCreds]
|
[changeCreds]
|
||||||
changePassword = "You are using default login credentials. Please enter a new password"
|
changePassword = "You are using default login credentials. Please enter a new password"
|
||||||
changeUsername = "Update your username. You will be logged out after updating."
|
changeUsername = "Update your username. You will be logged out after updating."
|
||||||
@@ -3230,6 +3507,46 @@ totalSelected = "Total Selected"
|
|||||||
unsupported = "Unsupported"
|
unsupported = "Unsupported"
|
||||||
unzip = "Unzip"
|
unzip = "Unzip"
|
||||||
uploadError = "Failed to upload some files."
|
uploadError = "Failed to upload some files."
|
||||||
|
copyCreated = "Copy saved to this device."
|
||||||
|
copyFailed = "Could not create a copy."
|
||||||
|
leaveShare = "Remove from my list"
|
||||||
|
leaveShareFailed = "Could not remove the shared file."
|
||||||
|
leaveShareSuccess = "Removed from your shared list."
|
||||||
|
removeBoth = "Remove from both"
|
||||||
|
removeFilePrompt = "This file is saved on this device and on your server. Where would you like to remove it from?"
|
||||||
|
removeFileTitle = "Remove file"
|
||||||
|
removeLocalOnly = "This device only"
|
||||||
|
removeServerFailed = "Could not remove the file from the server."
|
||||||
|
removeServerOnly = "Server only"
|
||||||
|
removeServerOnlyPrompt = "This file is stored only on your server. Would you like to remove it from the server?"
|
||||||
|
removeServerSuccess = "Removed from server."
|
||||||
|
removeSharedPrompt = "This file is shared with you. You can remove it from this device or your shared list."
|
||||||
|
removeSharedServerOnlyBlockedPrompt = "This file is shared with you and stored only on the server."
|
||||||
|
removeSharedServerOnlyPrompt = "This file is shared with you and stored only on the server. Remove it from your list?"
|
||||||
|
changesNotUploaded = "Changes not uploaded"
|
||||||
|
cloudFile = "Cloud file"
|
||||||
|
filterAll = "All"
|
||||||
|
filterLocal = "Local"
|
||||||
|
filterSharedByMe = "Shared by me"
|
||||||
|
filterSharedWithMe = "Shared with me"
|
||||||
|
lastSynced = "Last synced"
|
||||||
|
localOnly = "Local only"
|
||||||
|
makeCopy = "Make a copy"
|
||||||
|
owner = "Owner"
|
||||||
|
ownerUnknown = "Unknown"
|
||||||
|
share = "Share"
|
||||||
|
shareSelected = "Share Selected"
|
||||||
|
sharedByYou = "Shared by you"
|
||||||
|
sharedEditNoticeBody = "You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy."
|
||||||
|
sharedEditNoticeConfirm = "Got it"
|
||||||
|
sharedEditNoticeTitle = "Read-only server copy"
|
||||||
|
sharedWithYou = "Shared with you"
|
||||||
|
sharing = "Sharing"
|
||||||
|
storageState = "Storage"
|
||||||
|
synced = "Synced"
|
||||||
|
updateOnServer = "Update on Server"
|
||||||
|
uploadSelected = "Upload Selected"
|
||||||
|
uploadToServer = "Upload to Server"
|
||||||
|
|
||||||
[files]
|
[files]
|
||||||
addFiles = "Add files"
|
addFiles = "Add files"
|
||||||
@@ -3355,6 +3672,77 @@ title = "About Flattening PDFs"
|
|||||||
discord = "Discord"
|
discord = "Discord"
|
||||||
issues = "GitHub"
|
issues = "GitHub"
|
||||||
|
|
||||||
|
[groupSigning.tooltip]
|
||||||
|
header = "About Group Signing"
|
||||||
|
|
||||||
|
[groupSigning.tooltip.finalization]
|
||||||
|
bullet1 = "All signatures are applied in the participant order you specified"
|
||||||
|
bullet2 = "You can finalize with partial signatures if needed"
|
||||||
|
bullet3 = "Once finalized, the session cannot be modified"
|
||||||
|
description = "Once all participants have signed (or you choose to finalize early), you can generate the final signed PDF."
|
||||||
|
title = "Finalization Process"
|
||||||
|
|
||||||
|
[groupSigning.tooltip.roles]
|
||||||
|
bullet1 = "Owner (you): Creates session, configures signature defaults, finalizes document"
|
||||||
|
bullet2 = "Participants: Create their signature, choose certificate, place on PDF"
|
||||||
|
bullet3 = "Participants cannot modify signature visibility, reason, or location settings"
|
||||||
|
description = "You control the signature appearance settings for all participants."
|
||||||
|
title = "Participant Roles"
|
||||||
|
|
||||||
|
[groupSigning.tooltip.sequential]
|
||||||
|
bullet1 = "First participant must sign before the second can access the document"
|
||||||
|
bullet2 = "Ensures proper signing order for legal compliance"
|
||||||
|
bullet3 = "You can reorder participants by dragging them in the list"
|
||||||
|
description = "Participants sign documents in the order you specify. Each signer receives a notification when it is their turn."
|
||||||
|
title = "Sequential Signing"
|
||||||
|
|
||||||
|
[groupSigning.steps]
|
||||||
|
back = "Back"
|
||||||
|
completed = "Completed"
|
||||||
|
current = "Current"
|
||||||
|
stepLabel = "Step {{number}}"
|
||||||
|
|
||||||
|
[groupSigning.steps.configureDefaults]
|
||||||
|
continue = "Continue to Review"
|
||||||
|
invisible = "Signatures will be invisible (metadata only)"
|
||||||
|
locationLabel = "Location:"
|
||||||
|
preview = "Preview"
|
||||||
|
reasonLabel = "Reason:"
|
||||||
|
title = "Configure Signature Settings"
|
||||||
|
visible = "Signatures will be visible on page {{page}}"
|
||||||
|
|
||||||
|
[groupSigning.steps.review]
|
||||||
|
document = "Document"
|
||||||
|
dueDate = "Due Date (Optional)"
|
||||||
|
dueDatePlaceholder = "Select due date..."
|
||||||
|
invisible = "Invisible (metadata only)"
|
||||||
|
location = "Location:"
|
||||||
|
logo = "Logo:"
|
||||||
|
logoHidden = "No logo"
|
||||||
|
logoShown = "Stirling PDF logo shown"
|
||||||
|
participants = "Participants"
|
||||||
|
reason = "Reason:"
|
||||||
|
send = "Send Signing Requests"
|
||||||
|
signatureSettings = "Signature Settings"
|
||||||
|
title = "Review Session Details"
|
||||||
|
titleShort = "Review & Send"
|
||||||
|
visibility = "Visibility:"
|
||||||
|
visible = "Visible on page {{page}}"
|
||||||
|
participantCount = "{{count}} participant(s) will sign in order"
|
||||||
|
|
||||||
|
[groupSigning.steps.selectDocument]
|
||||||
|
continue = "Continue to Participant Selection"
|
||||||
|
noFile = "Please select a single PDF file from your active files to create a signing session."
|
||||||
|
selectedFile = "Selected document"
|
||||||
|
title = "Select Document"
|
||||||
|
|
||||||
|
[groupSigning.steps.selectParticipants]
|
||||||
|
continue = "Continue to Signature Settings"
|
||||||
|
count = "{{count}} participant(s) selected"
|
||||||
|
label = "Select participants"
|
||||||
|
placeholder = "Choose participants to sign..."
|
||||||
|
title = "Choose Participants"
|
||||||
|
|
||||||
[getPdfInfo]
|
[getPdfInfo]
|
||||||
downloadJson = "Download JSON"
|
downloadJson = "Download JSON"
|
||||||
downloads = "Downloads"
|
downloads = "Downloads"
|
||||||
@@ -4458,6 +4846,7 @@ singlePageView = "Single Page View"
|
|||||||
unknownFile = "Unknown file"
|
unknownFile = "Unknown file"
|
||||||
zoomIn = "Zoom In"
|
zoomIn = "Zoom In"
|
||||||
zoomOut = "Zoom Out"
|
zoomOut = "Zoom Out"
|
||||||
|
resetZoom = "Reset zoom"
|
||||||
|
|
||||||
[rightRail]
|
[rightRail]
|
||||||
closeSelected = "Close Selected Files"
|
closeSelected = "Close Selected Files"
|
||||||
@@ -5353,20 +5742,72 @@ title = "Print File"
|
|||||||
2 = "Enter Printer Name"
|
2 = "Enter Printer Name"
|
||||||
|
|
||||||
[quickAccess]
|
[quickAccess]
|
||||||
|
access = "Access"
|
||||||
|
accessAddPerson = "Add another person"
|
||||||
|
accessBack = "Back"
|
||||||
|
accessCopyLink = "Copy link"
|
||||||
|
accessEmail = "Email Address"
|
||||||
|
accessEmailPlaceholder = "[email protected]"
|
||||||
|
accessFileLabel = "File"
|
||||||
|
accessGeneral = "General Access"
|
||||||
|
accessInviteTitle = "Invite People"
|
||||||
|
accessOwner = "Owner"
|
||||||
|
accessPanel = "Document access"
|
||||||
|
accessPeople = "People with access"
|
||||||
|
accessRemove = "Remove"
|
||||||
|
accessRestricted = "Restricted"
|
||||||
|
accessRestrictedHint = "Only people with access can open"
|
||||||
|
accessRole = "Role"
|
||||||
|
accessRoleCommenter = "Commenter"
|
||||||
|
accessRoleEditor = "Editor"
|
||||||
|
accessRoleViewer = "Viewer"
|
||||||
|
accessSelectedFile = "Selected file"
|
||||||
|
accessSendInvite = "Send Invite"
|
||||||
|
accessTitle = "Document Access"
|
||||||
|
accessYou = "You"
|
||||||
account = "Account"
|
account = "Account"
|
||||||
|
activeSessions = "Active Sessions"
|
||||||
|
activeTab = "Active"
|
||||||
activity = "Activity"
|
activity = "Activity"
|
||||||
adminSettings = "Admin Settings"
|
adminSettings = "Admin Settings"
|
||||||
|
allSessions = "All Sessions"
|
||||||
allTools = "Tools"
|
allTools = "Tools"
|
||||||
automate = "Automate"
|
automate = "Automate"
|
||||||
|
back = "Back"
|
||||||
|
certSign = "Certificate Sign"
|
||||||
|
completedSessions = "Completed Sessions"
|
||||||
|
completedTab = "Completed"
|
||||||
config = "Config"
|
config = "Config"
|
||||||
|
createNew = "Create New Request"
|
||||||
|
createSession = "Create Signing Request"
|
||||||
|
dueDate = "Due date (optional)"
|
||||||
files = "Files"
|
files = "Files"
|
||||||
help = "Help"
|
help = "Help"
|
||||||
|
noActiveSessions = "No pending sign requests or active sessions"
|
||||||
|
noCompletedSessions = "No completed sessions"
|
||||||
|
noFile = "No file selected"
|
||||||
read = "Read"
|
read = "Read"
|
||||||
reader = "Reader"
|
reader = "Reader"
|
||||||
|
refresh = "Refresh"
|
||||||
|
requestSignatures = "Request Signatures"
|
||||||
|
selectSingleFileToRequest = "Select a single PDF file to request signatures"
|
||||||
|
selectedFile = "Selected file"
|
||||||
|
selectUsers = "Select users to sign"
|
||||||
|
selectUsersPlaceholder = "Choose participants..."
|
||||||
|
sendingRequest = "Sending..."
|
||||||
settings = "Settings"
|
settings = "Settings"
|
||||||
showMeAround = "Show me around"
|
showMeAround = "Show me around"
|
||||||
sign = "Sign"
|
sign = "Sign"
|
||||||
|
signatureRequests = "Signature Requests"
|
||||||
|
signYourself = "Sign Yourself"
|
||||||
|
newRequest = "New Request"
|
||||||
tours = "Tours"
|
tours = "Tours"
|
||||||
|
wetSign = "Add Signature"
|
||||||
|
filterMine = "Mine"
|
||||||
|
filterOverdue = "Overdue"
|
||||||
|
filterSigned = "Signed"
|
||||||
|
filterDeclined = "Declined"
|
||||||
|
searchDocuments = "Search documents…"
|
||||||
|
|
||||||
[quickAccess.helpMenu]
|
[quickAccess.helpMenu]
|
||||||
adminTour = "Admin Tour"
|
adminTour = "Admin Tour"
|
||||||
@@ -5998,11 +6439,75 @@ toolNotAvailableLocally = "Your Stirling-PDF server is offline and \"{{endpoint}
|
|||||||
expired = "Your session has expired. Please refresh the page and try again."
|
expired = "Your session has expired. Please refresh the page and try again."
|
||||||
refreshPage = "Refresh Page"
|
refreshPage = "Refresh Page"
|
||||||
|
|
||||||
|
[sessionManagement.tooltip]
|
||||||
|
header = "Managing Signing Sessions"
|
||||||
|
|
||||||
|
[sessionManagement.tooltip.addParticipants]
|
||||||
|
bullet1 = "New participants added to the end of signing order"
|
||||||
|
bullet2 = "Cannot add participants after session is finalized"
|
||||||
|
bullet3 = "Each participant receives a notification when it's their turn"
|
||||||
|
description = "You can add more participants to an active session at any time before finalization."
|
||||||
|
title = "Adding Participants"
|
||||||
|
|
||||||
|
[sessionManagement.tooltip.finalization]
|
||||||
|
bullet1 = "<b>Full finalization</b>: All participants have signed"
|
||||||
|
bullet2 = "<b>Partial finalization</b>: Some participants haven't signed yet"
|
||||||
|
bullet3 = "Unsigned participants will be excluded from the final document"
|
||||||
|
bullet4 = "Once finalized, you can load the signed PDF into active files"
|
||||||
|
description = "Finalization combines all signatures into a single signed PDF. This action cannot be undone."
|
||||||
|
title = "Session Finalization"
|
||||||
|
|
||||||
|
[sessionManagement.tooltip.participantRemoval]
|
||||||
|
bullet1 = "Cannot remove participants who have already signed"
|
||||||
|
bullet2 = "Removed participants no longer receive notifications"
|
||||||
|
bullet3 = "Signing order adjusts automatically"
|
||||||
|
description = "Participants can be removed from sessions before they sign."
|
||||||
|
title = "Removing Participants"
|
||||||
|
|
||||||
|
[sessionManagement.tooltip.signatureOrder]
|
||||||
|
bullet1 = "Each signature is applied sequentially to the PDF"
|
||||||
|
bullet2 = "Later signers can see earlier signatures"
|
||||||
|
bullet3 = "Critical for approval workflows and legal chains of custody"
|
||||||
|
description = "The order you specify when creating the session determines who signs first."
|
||||||
|
title = "Signature Order"
|
||||||
|
|
||||||
|
[signatureSettings.tooltip]
|
||||||
|
header = "Signature Appearance Settings"
|
||||||
|
|
||||||
|
[signatureSettings.tooltip.location]
|
||||||
|
bullet1 = "Examples: \"New York, USA\", \"London Office\", \"Remote\""
|
||||||
|
bullet2 = "Not the same as page position"
|
||||||
|
bullet3 = "May be required for certain legal jurisdictions"
|
||||||
|
description = "Optional geographic location where the signature was applied. Stored in certificate metadata."
|
||||||
|
title = "Signature Location"
|
||||||
|
|
||||||
|
[signatureSettings.tooltip.logo]
|
||||||
|
bullet1 = "Displayed alongside signature and text"
|
||||||
|
bullet2 = "Supports PNG, JPG formats"
|
||||||
|
bullet3 = "Enhances professional appearance"
|
||||||
|
description = "Add a company logo to visible signatures for branding and authenticity."
|
||||||
|
title = "Company Logo"
|
||||||
|
|
||||||
|
[signatureSettings.tooltip.reason]
|
||||||
|
bullet1 = "Examples: \"Approval\", \"Contract Agreement\", \"Review Complete\""
|
||||||
|
bullet2 = "Visible in PDF signature properties"
|
||||||
|
bullet3 = "Useful for audit trails and compliance"
|
||||||
|
description = "Optional text explaining why the document is being signed. Stored in certificate metadata."
|
||||||
|
title = "Signature Reason"
|
||||||
|
|
||||||
|
[signatureSettings.tooltip.visibility]
|
||||||
|
bullet1 = "<b>Visible</b>: Signature appears on PDF with custom appearance"
|
||||||
|
bullet2 = "<b>Invisible</b>: Certificate embedded without visual mark"
|
||||||
|
bullet3 = "Invisible signatures still provide cryptographic validation"
|
||||||
|
description = "Controls whether the signature is visible on the document or embedded invisibly."
|
||||||
|
title = "Signature Visibility"
|
||||||
|
|
||||||
[settings.configuration]
|
[settings.configuration]
|
||||||
advanced = "Advanced"
|
advanced = "Advanced"
|
||||||
database = "Database"
|
database = "Database"
|
||||||
endpoints = "Endpoints"
|
endpoints = "Endpoints"
|
||||||
features = "Features"
|
features = "Features"
|
||||||
|
storageSharing = "File Storage & Sharing"
|
||||||
systemSettings = "System Settings"
|
systemSettings = "System Settings"
|
||||||
title = "Configuration"
|
title = "Configuration"
|
||||||
|
|
||||||
@@ -6477,6 +6982,15 @@ saved = "Saved"
|
|||||||
text = "Text"
|
text = "Text"
|
||||||
title = "Signature Type"
|
title = "Signature Type"
|
||||||
|
|
||||||
|
[signRequest]
|
||||||
|
declined = "Sign request declined"
|
||||||
|
fetchFailed = "Failed to load sign request"
|
||||||
|
signed = "Document signed successfully"
|
||||||
|
|
||||||
|
[signSession]
|
||||||
|
createFailed = "Failed to create signing request"
|
||||||
|
created = "Signing request sent"
|
||||||
|
|
||||||
[signup]
|
[signup]
|
||||||
accountCreatedSuccessfully = "Account created successfully! You can now sign in."
|
accountCreatedSuccessfully = "Account created successfully! You can now sign in."
|
||||||
alreadyHaveAccount = "Already have an account? Sign in"
|
alreadyHaveAccount = "Already have an account? Sign in"
|
||||||
@@ -6755,6 +7269,106 @@ title = "Split PDF by Chapters"
|
|||||||
[splitPdfByChapters]
|
[splitPdfByChapters]
|
||||||
tags = "split,chapters,bookmarks,organize"
|
tags = "split,chapters,bookmarks,organize"
|
||||||
|
|
||||||
|
[storageShare]
|
||||||
|
accessed = "Accessed"
|
||||||
|
accessDenied = "You do not have access to this shared file. Ask the owner to share it with you."
|
||||||
|
accessFailed = "Unable to load activity."
|
||||||
|
accessDeniedBody = "You do not have access to this file. Ask the owner to share it with you."
|
||||||
|
accessDeniedTitle = "No access"
|
||||||
|
accessLimitedCommenter = "Comment access is coming soon. Ask the owner for editor access if you need to download."
|
||||||
|
accessLimitedTitle = "Limited access"
|
||||||
|
accessLimitedViewer = "This link is view-only. Ask the owner for editor access if you need to download."
|
||||||
|
createdAt = "Created"
|
||||||
|
download = "Download"
|
||||||
|
downloadFailed = "Unable to download this file."
|
||||||
|
expiredBody = "This share link is invalid or has expired."
|
||||||
|
expiredTitle = "Link expired"
|
||||||
|
goToLogin = "Go to login"
|
||||||
|
loadFailed = "Unable to open shared file."
|
||||||
|
loading = "Loading share link..."
|
||||||
|
loginPrompt = "Sign in to access this shared file."
|
||||||
|
loginRequired = "Login required"
|
||||||
|
openInApp = "Open in Stirling PDF"
|
||||||
|
ownerLabel = "Owner"
|
||||||
|
ownerUnknown = "Unknown"
|
||||||
|
requiresLogin = "This shared file requires login."
|
||||||
|
roleCommenter = "Commenter"
|
||||||
|
roleEditor = "Editor"
|
||||||
|
roleViewer = "Viewer"
|
||||||
|
shareHeading = "Shared file"
|
||||||
|
titleDefault = "Shared file"
|
||||||
|
tryAgain = "Please try again later."
|
||||||
|
addUser = "Add"
|
||||||
|
commenterHint = "Commenting is coming soon."
|
||||||
|
copied = "Link copied to clipboard"
|
||||||
|
copy = "Copy"
|
||||||
|
copyFailed = "Copy failed"
|
||||||
|
description = "Create a share link for this file. Signed-in users with the link can access it."
|
||||||
|
downloadsCount = "Downloads: {{count}}"
|
||||||
|
emailWarningBody = "This looks like an email address. If this person is not already a Stirling PDF user, they will not be able to access the file."
|
||||||
|
emailWarningConfirm = "Share anyway"
|
||||||
|
emailWarningTitle = "Email address"
|
||||||
|
errorTitle = "Share failed"
|
||||||
|
failure = "Unable to generate a share link. Please try again."
|
||||||
|
fileLabel = "File"
|
||||||
|
generate = "Generate Link"
|
||||||
|
generated = "Share link generated"
|
||||||
|
hideActivity = "Hide activity"
|
||||||
|
invalidUsername = "Enter a valid username or email address."
|
||||||
|
lastAccessed = "Last accessed"
|
||||||
|
linkAccessTitle = "Share link access"
|
||||||
|
linkLabel = "Share link"
|
||||||
|
linksDisabled = "Share links are disabled."
|
||||||
|
linksDisabledBody = "Share links are disabled by your server settings."
|
||||||
|
manage = "Manage sharing"
|
||||||
|
manageDescription = "Create and manage links to share this file."
|
||||||
|
manageLoadFailed = "Unable to load share links."
|
||||||
|
manageTitle = "Manage Sharing"
|
||||||
|
noActivity = "No activity yet."
|
||||||
|
noLinks = "No active share links yet."
|
||||||
|
noSharedUsers = "No users have access yet."
|
||||||
|
removeLink = "Remove link"
|
||||||
|
removeUser = "Remove"
|
||||||
|
revokeFailed = "Unable to remove the share link."
|
||||||
|
revoked = "Share link removed"
|
||||||
|
roleLabel = "Role"
|
||||||
|
sharingDisabled = "Sharing is disabled."
|
||||||
|
sharingDisabledBody = "Sharing has been disabled by your server settings."
|
||||||
|
sharedUsersTitle = "Shared users"
|
||||||
|
title = "Share File"
|
||||||
|
unknownUser = "Unknown user"
|
||||||
|
userAddFailed = "Unable to share with that user."
|
||||||
|
userAdded = "User added to shared list."
|
||||||
|
usernameLabel = "Username or email"
|
||||||
|
usernamePlaceholder = "Enter a username or email"
|
||||||
|
userRemoveFailed = "Unable to remove that user."
|
||||||
|
userRemoved = "User removed from shared list."
|
||||||
|
viewActivity = "View activity"
|
||||||
|
viewed = "Viewed"
|
||||||
|
viewsCount = "Views: {{count}}"
|
||||||
|
downloaded = "Downloaded"
|
||||||
|
bulkDescription = "Create one link to share all selected files with signed-in users."
|
||||||
|
bulkTitle = "Share selected files"
|
||||||
|
copyLink = "Copy share link"
|
||||||
|
fileCount = "{{count}} files selected"
|
||||||
|
ownerOnly = "Only the owner can manage sharing."
|
||||||
|
selectSingleFile = "Select a single file to manage sharing."
|
||||||
|
|
||||||
|
[storageUpload]
|
||||||
|
description = "This uploads the current file to server storage for your own access."
|
||||||
|
errorTitle = "Upload failed"
|
||||||
|
failure = "Upload failed. Please check your login and storage settings."
|
||||||
|
fileLabel = "File"
|
||||||
|
hint = "Public links and access modes are controlled by your server settings."
|
||||||
|
success = "Uploaded to server"
|
||||||
|
title = "Upload to Server"
|
||||||
|
updateButton = "Update on Server"
|
||||||
|
uploadButton = "Upload to Server"
|
||||||
|
bulkDescription = "This uploads the selected files to your server storage."
|
||||||
|
bulkTitle = "Upload selected files"
|
||||||
|
fileCount = "{{count}} files selected"
|
||||||
|
more = " +{{count}} more"
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
approximateSize = "Approximate size"
|
approximateSize = "Approximate size"
|
||||||
fileTooLarge = "File too large. Maximum size per file is"
|
fileTooLarge = "File too large. Maximum size per file is"
|
||||||
@@ -7144,6 +7758,30 @@ title = "View/Edit PDF"
|
|||||||
[warning]
|
[warning]
|
||||||
tooltipTitle = "Warning"
|
tooltipTitle = "Warning"
|
||||||
|
|
||||||
|
[wetSignature.tooltip]
|
||||||
|
header = "Signature Creation Methods"
|
||||||
|
|
||||||
|
[wetSignature.tooltip.draw]
|
||||||
|
bullet1 = "Customize pen color and thickness"
|
||||||
|
bullet2 = "Clear and redraw until satisfied"
|
||||||
|
bullet3 = "Works on touch devices (tablets, phones)"
|
||||||
|
description = "Create a handwritten signature using your mouse or touchscreen. Best for personal, authentic signatures."
|
||||||
|
title = "Draw Signature"
|
||||||
|
|
||||||
|
[wetSignature.tooltip.type]
|
||||||
|
bullet1 = "Choose from multiple fonts"
|
||||||
|
bullet2 = "Customize text size and color"
|
||||||
|
bullet3 = "Perfect for standardized signatures"
|
||||||
|
description = "Generate a signature from typed text. Fast and consistent, suitable for business documents."
|
||||||
|
title = "Type Signature"
|
||||||
|
|
||||||
|
[wetSignature.tooltip.upload]
|
||||||
|
bullet1 = "Supports PNG, JPG, and other image formats"
|
||||||
|
bullet2 = "Transparent backgrounds recommended for best results"
|
||||||
|
bullet3 = "Image will be resized to fit signature area"
|
||||||
|
description = "Upload a pre-created signature image. Ideal if you have a scanned signature or company logo."
|
||||||
|
title = "Upload Signature Image"
|
||||||
|
|
||||||
[watermark]
|
[watermark]
|
||||||
completed = "Watermark added"
|
completed = "Watermark added"
|
||||||
desc = "Add text or image watermarks to PDF files"
|
desc = "Add text or image watermarks to PDF files"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface AuthContextType {
|
export interface AuthContextType {
|
||||||
session: null;
|
session: null;
|
||||||
user: null;
|
user: { id?: string; email?: string; [key: string]: unknown } | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: Error | null;
|
error: Error | null;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ interface DrawingCanvasProps {
|
|||||||
onPenSizeInputChange: (input: string) => void;
|
onPenSizeInputChange: (input: string) => void;
|
||||||
onSignatureDataChange: (data: string | null) => void;
|
onSignatureDataChange: (data: string | null) => void;
|
||||||
onDrawingComplete?: () => void;
|
onDrawingComplete?: () => void;
|
||||||
|
onModalClose?: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
autoOpen?: boolean;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
modalWidth?: number;
|
modalWidth?: number;
|
||||||
@@ -33,7 +35,9 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
onPenSizeInputChange,
|
onPenSizeInputChange,
|
||||||
onSignatureDataChange,
|
onSignatureDataChange,
|
||||||
onDrawingComplete,
|
onDrawingComplete,
|
||||||
|
onModalClose,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
autoOpen = false,
|
||||||
width = 400,
|
width = 400,
|
||||||
height = 150,
|
height = 150,
|
||||||
initialSignatureData,
|
initialSignatureData,
|
||||||
@@ -83,6 +87,10 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoOpen) openModal();
|
||||||
|
}, [autoOpen]);
|
||||||
|
|
||||||
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
const trimCanvas = (canvas: HTMLCanvasElement): string => {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (!ctx) return canvas.toDataURL('image/png');
|
if (!ctx) return canvas.toDataURL('image/png');
|
||||||
@@ -160,6 +168,7 @@ export const DrawingCanvas: React.FC<DrawingCanvasProps> = ({
|
|||||||
padRef.current = null;
|
padRef.current = null;
|
||||||
}
|
}
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
|
onModalClose?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
|||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||||
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||||
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
import LockOpenIcon from '@mui/icons-material/LockOpen';
|
||||||
@@ -26,6 +28,9 @@ import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverAction
|
|||||||
import { downloadFile } from '@app/services/downloadService';
|
import { downloadFile } from '@app/services/downloadService';
|
||||||
import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName';
|
import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName';
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
|
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
|
||||||
|
import ShareFileModal from '@app/components/shared/ShareFileModal';
|
||||||
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -60,6 +65,7 @@ const FileEditorThumbnail = ({
|
|||||||
isSupported = true,
|
isSupported = true,
|
||||||
}: FileEditorThumbnailProps) => {
|
}: FileEditorThumbnailProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { config } = useAppConfig();
|
||||||
const terminology = useFileActionTerminology();
|
const terminology = useFileActionTerminology();
|
||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
const DownloadOutlinedIcon = icons.download;
|
const DownloadOutlinedIcon = icons.download;
|
||||||
@@ -80,6 +86,10 @@ const FileEditorThumbnail = ({
|
|||||||
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
const [showHoverMenu, setShowHoverMenu] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [showCloseModal, setShowCloseModal] = useState(false);
|
const [showCloseModal, setShowCloseModal] = useState(false);
|
||||||
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
|
const [showSharedEditNotice, setShowSharedEditNotice] = useState(false);
|
||||||
|
const sharedEditNoticeShownRef = useRef(false);
|
||||||
|
|
||||||
// Resolve the actual File object for pin/unpin operations
|
// Resolve the actual File object for pin/unpin operations
|
||||||
const actualFile = useMemo(() => {
|
const actualFile = useMemo(() => {
|
||||||
@@ -115,6 +125,17 @@ const FileEditorThumbnail = ({
|
|||||||
|
|
||||||
const isCBZ = extLower === 'cbz';
|
const isCBZ = extLower === 'cbz';
|
||||||
const isCBR = extLower === 'cbr';
|
const isCBR = extLower === 'cbr';
|
||||||
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
|
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
|
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||||
|
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
||||||
|
const isSharedFile = file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink;
|
||||||
|
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||||
|
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
||||||
|
const isUploaded = Boolean(file.remoteStorageId);
|
||||||
|
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||||
|
const canUpload = uploadEnabled && isOwnedOrLocal && file.isLeaf && (!isUploaded || !isUpToDate);
|
||||||
|
const canShare = shareLinksEnabled && isOwnedOrLocal && file.isLeaf;
|
||||||
|
|
||||||
const pageLabel = useMemo(
|
const pageLabel = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -248,6 +269,30 @@ const FileEditorThumbnail = ({
|
|||||||
onDownloadFile(file.id);
|
onDownloadFile(file.id);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
...(canUpload || canShare
|
||||||
|
? [
|
||||||
|
...(canUpload ? [{
|
||||||
|
id: 'upload',
|
||||||
|
icon: <CloudUploadIcon style={{ fontSize: 20 }} />,
|
||||||
|
label: isUploaded
|
||||||
|
? t('fileManager.updateOnServer', 'Update on Server')
|
||||||
|
: t('fileManager.uploadToServer', 'Upload to Server'),
|
||||||
|
onClick: (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowUploadModal(true);
|
||||||
|
},
|
||||||
|
}] : []),
|
||||||
|
...(canShare ? [{
|
||||||
|
id: 'share',
|
||||||
|
icon: <LinkIcon style={{ fontSize: 20 }} />,
|
||||||
|
label: t('fileManager.share', 'Share'),
|
||||||
|
onClick: (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowShareModal(true);
|
||||||
|
},
|
||||||
|
}] : []),
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
id: 'unzip',
|
id: 'unzip',
|
||||||
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
icon: <UnarchiveIcon style={{ fontSize: 20 }} />,
|
||||||
@@ -271,7 +316,22 @@ const FileEditorThumbnail = ({
|
|||||||
},
|
},
|
||||||
color: 'red',
|
color: 'red',
|
||||||
}
|
}
|
||||||
], [t, file.id, file.name, isZipFile, isCBR, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]);
|
], [
|
||||||
|
t,
|
||||||
|
file.id,
|
||||||
|
file.name,
|
||||||
|
isZipFile,
|
||||||
|
isCBZ,
|
||||||
|
isCBR,
|
||||||
|
terminology,
|
||||||
|
onViewFile,
|
||||||
|
onDownloadFile,
|
||||||
|
onUnzipFile,
|
||||||
|
handleCloseWithConfirmation,
|
||||||
|
canUpload,
|
||||||
|
canShare,
|
||||||
|
isUploaded
|
||||||
|
]);
|
||||||
|
|
||||||
// ---- Card interactions ----
|
// ---- Card interactions ----
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
@@ -280,6 +340,10 @@ const FileEditorThumbnail = ({
|
|||||||
if (hasError) {
|
if (hasError) {
|
||||||
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
try { fileActions.clearFileError(file.id); } catch (_e) { void _e; }
|
||||||
}
|
}
|
||||||
|
if (isSharedFile && !sharedEditNoticeShownRef.current) {
|
||||||
|
sharedEditNoticeShownRef.current = true;
|
||||||
|
setShowSharedEditNotice(true);
|
||||||
|
}
|
||||||
onToggleFile(file.id);
|
onToggleFile(file.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -537,6 +601,42 @@ const FileEditorThumbnail = ({
|
|||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
opened={showSharedEditNotice}
|
||||||
|
onClose={() => setShowSharedEditNotice(false)}
|
||||||
|
title={t('fileManager.sharedEditNoticeTitle', 'Read-only server copy')}
|
||||||
|
centered
|
||||||
|
size="auto"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
'fileManager.sharedEditNoticeBody',
|
||||||
|
'You do not have edit rights to the server version of this file. Any edits you make will be saved as a local copy.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button onClick={() => setShowSharedEditNotice(false)}>
|
||||||
|
{t('fileManager.sharedEditNoticeConfirm', 'Got it')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{canUpload && (
|
||||||
|
<UploadToServerModal
|
||||||
|
opened={showUploadModal}
|
||||||
|
onClose={() => setShowUploadModal(false)}
|
||||||
|
file={file}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ShareFileModal
|
||||||
|
opened={showShareModal}
|
||||||
|
onClose={() => setShowShareModal(false)}
|
||||||
|
file={file}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const hasSelection = selectedFiles.length > 0;
|
const hasSelection = selectedFiles.length > 0;
|
||||||
const hasMultipleFiles = numberOfFiles > 1;
|
const hasMultipleFiles = numberOfFiles > 1;
|
||||||
|
const showOwner = Boolean(
|
||||||
|
currentFile &&
|
||||||
|
(currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink)
|
||||||
|
);
|
||||||
|
const ownerLabel = currentFile
|
||||||
|
? currentFile.remoteOwnerUsername || t('fileManager.ownerUnknown', 'Unknown')
|
||||||
|
: '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs" style={{ height: '100%' }}>
|
<Stack gap="xs" style={{ height: '100%' }}>
|
||||||
@@ -88,6 +95,11 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
|||||||
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')}
|
{currentFile.toolHistory.map((tool) => t(`home.${tool.toolId}.title`, tool.toolId)).join(' → ')}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{currentFile && showOwner && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('fileManager.owner', 'Owner')}: {ownerLabel}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Navigation arrows for multiple files */}
|
{/* Navigation arrows for multiple files */}
|
||||||
|
|||||||
@@ -1,25 +1,68 @@
|
|||||||
import React from "react";
|
import React, { useEffect } from "react";
|
||||||
import { Group, Text, ActionIcon, Tooltip } from "@mantine/core";
|
import { Group, Text, ActionIcon, Tooltip, SegmentedControl } from "@mantine/core";
|
||||||
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
import SelectAllIcon from "@mui/icons-material/SelectAll";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
|
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
|
||||||
|
import LinkIcon from "@mui/icons-material/Link";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
import { useFileManagerContext } from "@app/contexts/FileManagerContext";
|
||||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||||
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
|
||||||
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
|
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
|
||||||
|
import BulkShareModal from "@app/components/shared/BulkShareModal";
|
||||||
|
|
||||||
const FileActions: React.FC = () => {
|
const FileActions: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const terminology = useFileActionTerminology();
|
const terminology = useFileActionTerminology();
|
||||||
const icons = useFileActionIcons();
|
const icons = useFileActionIcons();
|
||||||
const DownloadIcon = icons.download;
|
const DownloadIcon = icons.download;
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const [showBulkUploadModal, setShowBulkUploadModal] = React.useState(false);
|
||||||
|
const [showBulkShareModal, setShowBulkShareModal] = React.useState(false);
|
||||||
const {
|
const {
|
||||||
recentFiles,
|
recentFiles,
|
||||||
selectedFileIds,
|
selectedFileIds,
|
||||||
|
selectedFiles,
|
||||||
filteredFiles,
|
filteredFiles,
|
||||||
onSelectAll,
|
onSelectAll,
|
||||||
onDeleteSelected,
|
onDeleteSelected,
|
||||||
onDownloadSelected
|
onDownloadSelected,
|
||||||
} = useFileManagerContext();
|
refreshRecentFiles,
|
||||||
|
storageFilter,
|
||||||
|
onStorageFilterChange
|
||||||
|
} =
|
||||||
|
useFileManagerContext();
|
||||||
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
|
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
|
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||||
|
const showStorageFilter = uploadEnabled;
|
||||||
|
const storageFilterOptions = sharingEnabled
|
||||||
|
? [
|
||||||
|
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||||
|
{ value: "local", label: t("fileManager.filterLocal", "Local") },
|
||||||
|
{ value: "sharedWithMe", label: t("fileManager.filterSharedWithMe", "Shared with me") },
|
||||||
|
{ value: "sharedByMe", label: t("fileManager.filterSharedByMe", "Shared by me") }
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{ value: "all", label: t("fileManager.filterAll", "All") },
|
||||||
|
{ value: "local", label: t("fileManager.filterLocal", "Local") }
|
||||||
|
];
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sharingEnabled && (storageFilter === "sharedWithMe" || storageFilter === "sharedByMe")) {
|
||||||
|
onStorageFilterChange("all");
|
||||||
|
}
|
||||||
|
}, [sharingEnabled, storageFilter, onStorageFilterChange]);
|
||||||
|
const hasSelection = selectedFileIds.length > 0;
|
||||||
|
const hasOnlyOwnedSelection = selectedFiles.every((file) => file.remoteOwnedByCurrentUser !== false);
|
||||||
|
const hasDownloadAccess = selectedFiles.every((file) => {
|
||||||
|
const role = (file.remoteOwnedByCurrentUser !== false
|
||||||
|
? 'editor'
|
||||||
|
: (file.remoteAccessRole ?? 'viewer')).toLowerCase();
|
||||||
|
return role === 'editor' || role === 'commenter' || role === 'viewer';
|
||||||
|
});
|
||||||
|
const canBulkUpload = uploadEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||||
|
const canBulkShare = shareLinksEnabled && hasSelection && hasOnlyOwnedSelection;
|
||||||
|
|
||||||
const handleSelectAll = () => {
|
const handleSelectAll = () => {
|
||||||
onSelectAll();
|
onSelectAll();
|
||||||
@@ -44,7 +87,6 @@ const FileActions: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
const allFilesSelected = filteredFiles.length > 0 && selectedFileIds.length === filteredFiles.length;
|
||||||
const hasSelection = selectedFileIds.length > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -58,8 +100,8 @@ const FileActions: React.FC = () => {
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left: Select All */}
|
{/* Left: Select All + Filter */}
|
||||||
<div>
|
<Group gap="xs">
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
|
label={allFilesSelected ? t("fileManager.deselectAll", "Deselect All") : t("fileManager.selectAll", "Select All")}
|
||||||
>
|
>
|
||||||
@@ -74,7 +116,17 @@ const FileActions: React.FC = () => {
|
|||||||
<SelectAllIcon style={{ fontSize: "1rem" }} />
|
<SelectAllIcon style={{ fontSize: "1rem" }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
{showStorageFilter && (
|
||||||
|
<SegmentedControl
|
||||||
|
size="xs"
|
||||||
|
value={storageFilter}
|
||||||
|
onChange={(value) =>
|
||||||
|
onStorageFilterChange(value as "all" | "local" | "sharedWithMe" | "sharedByMe")
|
||||||
|
}
|
||||||
|
data={storageFilterOptions}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
|
||||||
{/* Center: Selected count */}
|
{/* Center: Selected count */}
|
||||||
<div
|
<div
|
||||||
@@ -93,6 +145,34 @@ const FileActions: React.FC = () => {
|
|||||||
|
|
||||||
{/* Right: Delete and Download */}
|
{/* Right: Delete and Download */}
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
|
{uploadEnabled && (
|
||||||
|
<Tooltip label={t("fileManager.uploadSelected", "Upload Selected")}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
color="dimmed"
|
||||||
|
onClick={() => setShowBulkUploadModal(true)}
|
||||||
|
disabled={!canBulkUpload}
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
<CloudUploadIcon style={{ fontSize: "1rem" }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{shareLinksEnabled && (
|
||||||
|
<Tooltip label={t("fileManager.shareSelected", "Share Selected")}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="light"
|
||||||
|
size="sm"
|
||||||
|
color="dimmed"
|
||||||
|
onClick={() => setShowBulkShareModal(true)}
|
||||||
|
disabled={!canBulkShare}
|
||||||
|
radius="sm"
|
||||||
|
>
|
||||||
|
<LinkIcon style={{ fontSize: "1rem" }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
|
<Tooltip label={t("fileManager.deleteSelected", "Delete Selected")}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -112,13 +192,30 @@ const FileActions: React.FC = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
color="dimmed"
|
color="dimmed"
|
||||||
onClick={handleDownloadSelected}
|
onClick={handleDownloadSelected}
|
||||||
disabled={!hasSelection}
|
disabled={!hasSelection || !hasDownloadAccess}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
>
|
>
|
||||||
<DownloadIcon style={{ fontSize: "1rem" }} />
|
<DownloadIcon style={{ fontSize: "1rem" }} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{uploadEnabled && (
|
||||||
|
<BulkUploadToServerModal
|
||||||
|
opened={showBulkUploadModal}
|
||||||
|
onClose={() => setShowBulkUploadModal(false)}
|
||||||
|
files={selectedFiles}
|
||||||
|
onUploaded={refreshRecentFiles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{shareLinksEnabled && (
|
||||||
|
<BulkShareModal
|
||||||
|
opened={showBulkShareModal}
|
||||||
|
onClose={() => setShowBulkShareModal(false)}
|
||||||
|
files={selectedFiles}
|
||||||
|
onShared={refreshRecentFiles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
|
import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea, Button } from '@mantine/core';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { detectFileExtension, getFileSize } from '@app/utils/fileUtils';
|
import { detectFileExtension, getFileSize } from '@app/utils/fileUtils';
|
||||||
import { StirlingFileStub } from '@app/types/fileContext';
|
import { StirlingFileStub } from '@app/types/fileContext';
|
||||||
import ToolChain from '@app/components/shared/ToolChain';
|
import ToolChain from '@app/components/shared/ToolChain';
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
|
import { useFileManagerContext } from '@app/contexts/FileManagerContext';
|
||||||
|
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
|
||||||
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
|
||||||
interface FileInfoCardProps {
|
interface FileInfoCardProps {
|
||||||
currentFile: StirlingFileStub | null;
|
currentFile: StirlingFileStub | null;
|
||||||
@@ -16,6 +19,40 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
modalHeight
|
modalHeight
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const { onMakeCopy } = useFileManagerContext();
|
||||||
|
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
||||||
|
const isSharedWithYou = useMemo(() => {
|
||||||
|
if (!currentFile) return false;
|
||||||
|
return currentFile.remoteOwnedByCurrentUser === false || currentFile.remoteSharedViaLink;
|
||||||
|
}, [currentFile]);
|
||||||
|
const isOwnedRemote = useMemo(() => {
|
||||||
|
if (!currentFile) return false;
|
||||||
|
return Boolean(currentFile.remoteStorageId) && currentFile.remoteOwnedByCurrentUser !== false;
|
||||||
|
}, [currentFile]);
|
||||||
|
const localUpdatedAt = currentFile?.createdAt ?? currentFile?.lastModified ?? 0;
|
||||||
|
const remoteUpdatedAt = currentFile?.remoteStorageUpdatedAt ?? 0;
|
||||||
|
const isUploaded = Boolean(currentFile?.remoteStorageId);
|
||||||
|
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||||
|
const isOutOfSync = isUploaded && !isUpToDate && isOwnedRemote;
|
||||||
|
const isLocalOnly = !currentFile?.remoteStorageId && !currentFile?.remoteSharedViaLink;
|
||||||
|
const isSharedByYou = useMemo(() => {
|
||||||
|
if (!currentFile) return false;
|
||||||
|
return isOwnedRemote && Boolean(currentFile.remoteHasShareLinks);
|
||||||
|
}, [currentFile, isOwnedRemote]);
|
||||||
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
|
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
|
const ownerLabel = useMemo(() => {
|
||||||
|
if (!currentFile) return '';
|
||||||
|
if (currentFile.remoteOwnerUsername) {
|
||||||
|
return currentFile.remoteOwnerUsername;
|
||||||
|
}
|
||||||
|
return t('fileManager.ownerUnknown', 'Unknown');
|
||||||
|
}, [currentFile, t]);
|
||||||
|
const lastSyncedLabel = useMemo(() => {
|
||||||
|
if (!currentFile?.remoteStorageUpdatedAt) return '';
|
||||||
|
return new Date(currentFile.remoteStorageUpdatedAt).toLocaleString();
|
||||||
|
}, [currentFile?.remoteStorageUpdatedAt]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card withBorder p={0} mah={`calc(${modalHeight} * 0.45)`} style={{ overflow: 'hidden', flexShrink: 1, display: 'flex', flexDirection: 'column' }}>
|
<Card withBorder p={0} mah={`calc(${modalHeight} * 0.45)`} style={{ overflow: 'hidden', flexShrink: 1, display: 'flex', flexDirection: 'column' }}>
|
||||||
@@ -57,7 +94,7 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
<Divider />
|
<Divider />
|
||||||
|
|
||||||
<Group justify="space-between" py="xs">
|
<Group justify="space-between" py="xs">
|
||||||
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last Modified')}</Text>
|
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last modified')}</Text>
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''}
|
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -72,6 +109,20 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
</Badge>}
|
</Badge>}
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
|
{sharingEnabled && isSharedWithYou && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.owner', 'Owner')}</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Text size="sm" fw={500}>{ownerLabel}</Text>
|
||||||
|
<Badge size="xs" variant="light" color="grape">
|
||||||
|
{t('fileManager.sharedWithYou', 'Shared with you')}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tool Chain Display */}
|
{/* Tool Chain Display */}
|
||||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||||
@@ -87,8 +138,83 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
|||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{currentFile && isSharedWithYou && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => onMakeCopy(currentFile)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('fileManager.makeCopy', 'Make a copy')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentFile && isOwnedRemote && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.cloudFile', 'Cloud file')}</Text>
|
||||||
|
{uploadEnabled && isOutOfSync ? (
|
||||||
|
<Badge size="xs" variant="light" color="yellow">
|
||||||
|
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
|
||||||
|
</Badge>
|
||||||
|
) : uploadEnabled ? (
|
||||||
|
<Badge size="xs" variant="light" color="teal">
|
||||||
|
{t('fileManager.synced', 'Synced')}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
{lastSyncedLabel && (
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.lastSynced', 'Last synced')}</Text>
|
||||||
|
<Text size="sm" fw={500}>{lastSyncedLabel}</Text>
|
||||||
|
</Group>
|
||||||
|
)}
|
||||||
|
{isSharedByYou && sharingEnabled && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.sharing', 'Sharing')}</Text>
|
||||||
|
<Badge size="xs" variant="light" color="blue">
|
||||||
|
{t('fileManager.sharedByYou', 'Shared by you')}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => setShowShareManageModal(true)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{t('storageShare.manage', 'Manage sharing')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{currentFile && isLocalOnly && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<Group justify="space-between" py="xs">
|
||||||
|
<Text size="sm" c="dimmed">{t('fileManager.storageState', 'Storage')}</Text>
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{t('fileManager.localOnly', 'Local only')}
|
||||||
|
</Badge>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
{currentFile && isOwnedRemote && isSharedByYou && sharingEnabled && (
|
||||||
|
<ShareManagementModal
|
||||||
|
opened={showShareManageModal}
|
||||||
|
onClose={() => setShowShareManageModal(false)}
|
||||||
|
file={currentFile}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
|
import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@mantine/core';
|
||||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
@@ -7,6 +7,9 @@ import HistoryIcon from '@mui/icons-material/History';
|
|||||||
import RestoreIcon from '@mui/icons-material/Restore';
|
import RestoreIcon from '@mui/icons-material/Restore';
|
||||||
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
import UnarchiveIcon from '@mui/icons-material/Unarchive';
|
||||||
import CloseIcon from '@mui/icons-material/Close';
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
|
||||||
|
import CloudDoneIcon from '@mui/icons-material/CloudDone';
|
||||||
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getFileSize, getFileDate } from '@app/utils/fileUtils';
|
import { getFileSize, getFileDate } from '@app/utils/fileUtils';
|
||||||
import { FileId, StirlingFileStub } from '@app/types/fileContext';
|
import { FileId, StirlingFileStub } from '@app/types/fileContext';
|
||||||
@@ -16,6 +19,13 @@ import ToolChain from '@app/components/shared/ToolChain';
|
|||||||
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||||
import { useFileManagement } from '@app/contexts/FileContext';
|
import { useFileManagement } from '@app/contexts/FileContext';
|
||||||
|
import UploadToServerModal from '@app/components/shared/UploadToServerModal';
|
||||||
|
import ShareFileModal from '@app/components/shared/ShareFileModal';
|
||||||
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
import ShareManagementModal from '@app/components/shared/ShareManagementModal';
|
||||||
|
import apiClient from '@app/services/apiClient';
|
||||||
|
import { absoluteWithBasePath } from '@app/constants/app';
|
||||||
|
import { alert } from '@app/components/toast';
|
||||||
|
|
||||||
interface FileListItemProps {
|
interface FileListItemProps {
|
||||||
file: StirlingFileStub;
|
file: StirlingFileStub;
|
||||||
@@ -45,8 +55,12 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||||
|
const [showShareModal, setShowShareModal] = useState(false);
|
||||||
|
const [showShareManageModal, setShowShareManageModal] = useState(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
const { config } = useAppConfig();
|
||||||
|
const {expandedFileIds, onToggleExpansion, onUnzipFile, refreshRecentFiles } = useFileManagerContext();
|
||||||
const { removeFiles } = useFileManagement();
|
const { removeFiles } = useFileManagement();
|
||||||
|
|
||||||
// Check if this is a ZIP file
|
// Check if this is a ZIP file
|
||||||
@@ -65,6 +79,73 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
||||||
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
||||||
const isExpanded = expandedFileIds.has(leafFileId);
|
const isExpanded = expandedFileIds.has(leafFileId);
|
||||||
|
const uploadEnabled = config?.storageEnabled === true;
|
||||||
|
const sharingEnabled = uploadEnabled && config?.storageSharingEnabled === true;
|
||||||
|
const shareLinksEnabled = sharingEnabled && config?.storageShareLinksEnabled === true;
|
||||||
|
const isOwnedOrLocal = file.remoteOwnedByCurrentUser !== false;
|
||||||
|
const isSharedWithYou =
|
||||||
|
sharingEnabled && (file.remoteOwnedByCurrentUser === false || file.remoteSharedViaLink);
|
||||||
|
const localUpdatedAt = file.createdAt ?? file.lastModified ?? 0;
|
||||||
|
const remoteUpdatedAt = file.remoteStorageUpdatedAt ?? 0;
|
||||||
|
const isUploaded = Boolean(file.remoteStorageId);
|
||||||
|
const isUpToDate = isUploaded && remoteUpdatedAt >= localUpdatedAt;
|
||||||
|
const isOutOfSync = isUploaded && !isUpToDate && isOwnedOrLocal;
|
||||||
|
const isLocalOnly = !file.remoteStorageId && !file.remoteSharedViaLink;
|
||||||
|
const accessRole = (isOwnedOrLocal ? 'editor' : (file.remoteAccessRole ?? 'viewer')).toLowerCase();
|
||||||
|
const hasReadAccess = isOwnedOrLocal || accessRole === 'editor' || accessRole === 'commenter' || accessRole === 'viewer';
|
||||||
|
const canUpload = uploadEnabled && isOwnedOrLocal && isLatestVersion && (!isUploaded || !isUpToDate);
|
||||||
|
const canShare = shareLinksEnabled && isOwnedOrLocal && isLatestVersion;
|
||||||
|
const canManageShare = sharingEnabled && isOwnedOrLocal && Boolean(file.remoteStorageId);
|
||||||
|
const canCopyShareLink =
|
||||||
|
shareLinksEnabled && Boolean(file.remoteHasShareLinks) && Boolean(file.remoteStorageId);
|
||||||
|
const canDownloadFile = Boolean(onDownload) && hasReadAccess;
|
||||||
|
|
||||||
|
const shareBaseUrl = useMemo(() => {
|
||||||
|
const frontendUrl = (config?.frontendUrl || '').trim();
|
||||||
|
if (frontendUrl) {
|
||||||
|
const normalized = frontendUrl.endsWith('/')
|
||||||
|
? frontendUrl.slice(0, -1)
|
||||||
|
: frontendUrl;
|
||||||
|
return `${normalized}/share/`;
|
||||||
|
}
|
||||||
|
return absoluteWithBasePath('/share/');
|
||||||
|
}, [config?.frontendUrl]);
|
||||||
|
|
||||||
|
const handleCopyShareLink = useCallback(async () => {
|
||||||
|
if (!file.remoteStorageId) return;
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get<{ shareLinks?: Array<{ token?: string }> }>(
|
||||||
|
`/api/v1/storage/files/${file.remoteStorageId}`,
|
||||||
|
{ suppressErrorToast: true } as any
|
||||||
|
);
|
||||||
|
const links = response.data?.shareLinks ?? [];
|
||||||
|
const token = links[links.length - 1]?.token;
|
||||||
|
if (!token) {
|
||||||
|
alert({
|
||||||
|
alertType: 'warning',
|
||||||
|
title: t('storageShare.noLinks', 'No active share links yet.'),
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 2500,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(`${shareBaseUrl}${token}`);
|
||||||
|
alert({
|
||||||
|
alertType: 'success',
|
||||||
|
title: t('storageShare.copied', 'Link copied to clipboard'),
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 2000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy share link:', error);
|
||||||
|
alert({
|
||||||
|
alertType: 'warning',
|
||||||
|
title: t('storageShare.copyFailed', 'Copy failed'),
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 2500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [file.remoteStorageId, shareBaseUrl, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -133,6 +214,45 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
<Badge size="xs" variant="light" color={"blue"}>
|
<Badge size="xs" variant="light" color={"blue"}>
|
||||||
v{currentVersion}
|
v{currentVersion}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{sharingEnabled && isSharedWithYou ? (
|
||||||
|
<Badge size="xs" variant="light" color="grape">
|
||||||
|
{t('fileManager.sharedWithYou', 'Shared with you')}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{sharingEnabled && isSharedWithYou && accessRole && accessRole !== 'editor' ? (
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{accessRole === 'commenter'
|
||||||
|
? t('storageShare.roleCommenter', 'Commenter')
|
||||||
|
: t('storageShare.roleViewer', 'Viewer')}
|
||||||
|
</Badge>
|
||||||
|
) : isLocalOnly ? (
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
{t('fileManager.localOnly', 'Local only')}
|
||||||
|
</Badge>
|
||||||
|
) : uploadEnabled && isOutOfSync ? (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
leftSection={<CloudUploadIcon style={{ fontSize: 12 }} />}
|
||||||
|
>
|
||||||
|
{t('fileManager.changesNotUploaded', 'Changes not uploaded')}
|
||||||
|
</Badge>
|
||||||
|
) : uploadEnabled && isUploaded ? (
|
||||||
|
<Badge
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="teal"
|
||||||
|
leftSection={<CloudDoneIcon style={{ fontSize: 12 }} />}
|
||||||
|
>
|
||||||
|
{t('fileManager.synced', 'Synced')}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{sharingEnabled && file.remoteOwnedByCurrentUser !== false && file.remoteHasShareLinks && (
|
||||||
|
<Badge size="xs" variant="light" color="blue">
|
||||||
|
{t('fileManager.sharedByYou', 'Shared by you')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="xs" align="center">
|
<Group gap="xs" align="center">
|
||||||
@@ -194,18 +314,68 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{onDownload && (
|
{canDownloadFile && (
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
leftSection={<DownloadIcon style={{ fontSize: 16 }} />}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDownload();
|
onDownload?.();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('fileManager.download', 'Download')}
|
{t('fileManager.download', 'Download')}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{canUpload && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<CloudUploadIcon style={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowUploadModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isUploaded
|
||||||
|
? t('fileManager.updateOnServer', 'Update on Server')
|
||||||
|
: t('fileManager.uploadToServer', 'Upload to Server')}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canShare && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowShareModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('fileManager.share', 'Share')}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canCopyShareLink && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void handleCopyShareLink();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storageShare.copyLink', 'Copy share link')}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManageShare && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<LinkIcon style={{ fontSize: 16 }} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowShareManageModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('storageShare.manage', 'Manage sharing')}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Show/Hide History option for latest version files */}
|
{/* Show/Hide History option for latest version files */}
|
||||||
{isLatestVersion && hasVersionHistory && (
|
{isLatestVersion && hasVersionHistory && (
|
||||||
<>
|
<>
|
||||||
@@ -275,6 +445,29 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
|||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
{ <Divider color="var(--mantine-color-gray-3)" />}
|
{ <Divider color="var(--mantine-color-gray-3)" />}
|
||||||
|
{canUpload && (
|
||||||
|
<UploadToServerModal
|
||||||
|
opened={showUploadModal}
|
||||||
|
onClose={() => setShowUploadModal(false)}
|
||||||
|
file={file}
|
||||||
|
onUploaded={refreshRecentFiles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canShare && (
|
||||||
|
<ShareFileModal
|
||||||
|
opened={showShareModal}
|
||||||
|
onClose={() => setShowShareModal(false)}
|
||||||
|
file={file}
|
||||||
|
onUploaded={refreshRecentFiles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{canManageShare && (
|
||||||
|
<ShareManagementModal
|
||||||
|
opened={showShareManageModal}
|
||||||
|
onClose={() => setShowShareManageModal(false)}
|
||||||
|
file={file}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -93,20 +93,16 @@ export default function Workbench() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderMainContent = () => {
|
const renderMainContent = () => {
|
||||||
// Check for custom workbench views first
|
// Check if we're showing a custom workbench first
|
||||||
|
// Custom workbenches may not require files in FileContext (e.g., sign request workbench)
|
||||||
if (!isBaseWorkbench(currentView)) {
|
if (!isBaseWorkbench(currentView)) {
|
||||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||||
if (customView) {
|
if (customView) {
|
||||||
// PDF text editor handles its own empty state (shows dropzone when no document)
|
const CustomComponent = customView.component;
|
||||||
const handlesOwnEmptyState = currentView === 'custom:pdfTextEditor';
|
return <CustomComponent data={customView.data} />;
|
||||||
if (handlesOwnEmptyState || activeFiles.length > 0) {
|
|
||||||
const CustomComponent = customView.component;
|
|
||||||
return <CustomComponent data={customView.data} />;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For base workbenches (or custom views that don't handle empty state), show landing page when no files
|
|
||||||
if (activeFiles.length === 0) {
|
if (activeFiles.length === 0) {
|
||||||
return (
|
return (
|
||||||
<LandingPage
|
<LandingPage
|
||||||
@@ -196,7 +192,7 @@ export default function Workbench() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Top Controls */}
|
{/* Top Controls */}
|
||||||
{activeFiles.length > 0 && (
|
{activeFiles.length > 0 && !customWorkbenchViews.find(v => v.workbenchId === currentView)?.hideTopControls && (
|
||||||
<TopControls
|
<TopControls
|
||||||
currentView={currentView}
|
currentView={currentView}
|
||||||
setCurrentView={setCurrentView}
|
setCurrentView={setCurrentView}
|
||||||
|
|||||||
@@ -0,0 +1,264 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Modal, Stack, Text, Button, Group, Alert, TextInput, Paper, Select } from '@mantine/core';
|
||||||
|
import LinkIcon from '@mui/icons-material/Link';
|
||||||
|
import ContentCopyRoundedIcon from '@mui/icons-material/ContentCopyRounded';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import apiClient from '@app/services/apiClient';
|
||||||
|
import { absoluteWithBasePath } from '@app/constants/app';
|
||||||
|
import { alert } from '@app/components/toast';
|
||||||
|
import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '@app/styles/zIndex';
|
||||||
|
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||||
|
import type { StirlingFileStub } from '@app/types/fileContext';
|
||||||
|
import { uploadHistoryChains } from '@app/services/serverStorageUpload';
|
||||||
|
import { fileStorage } from '@app/services/fileStorage';
|
||||||
|
import { useFileActions } from '@app/contexts/FileContext';
|
||||||
|
import type { FileId } from '@app/types/file';
|
||||||
|
|
||||||
|
interface BulkShareModalProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
files: StirlingFileStub[];
|
||||||
|
onShared?: () => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BulkShareModal: React.FC<BulkShareModalProps> = ({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
files,
|
||||||
|
onShared,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const { actions } = useFileActions();
|
||||||
|
const shareLinksEnabled = config?.storageShareLinksEnabled === true;
|
||||||
|
const [isWorking, setIsWorking] = useState(false);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
const [shareToken, setShareToken] = useState<string | null>(null);
|
||||||
|
const [shareRole, setShareRole] = useState<'editor' | 'commenter' | 'viewer'>('editor');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!opened) {
|
||||||
|
setIsWorking(false);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setShareToken(null);
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (opened) {
|
||||||
|
setShareRole('editor');
|
||||||
|
}
|
||||||
|
}, [opened]);
|
||||||
|
|
||||||
|
const shareUrl = useMemo(() => {
|
||||||
|
if (!shareToken) return '';
|
||||||
|
const frontendUrl = (config?.frontendUrl || '').trim();
|
||||||
|
if (frontendUrl) {
|
||||||
|
const normalized = frontendUrl.endsWith('/')
|
||||||
|
? frontendUrl.slice(0, -1)
|
||||||
|
: frontendUrl;
|
||||||
|
return `${normalized}/share/${shareToken}`;
|
||||||
|
}
|
||||||
|
return absoluteWithBasePath(`/share/${shareToken}`);
|
||||||
|
}, [config?.frontendUrl, shareToken]);
|
||||||
|
|
||||||
|
const createShareLink = useCallback(async (storedFileId: number) => {
|
||||||
|
const response = await apiClient.post(`/api/v1/storage/files/${storedFileId}/shares/links`, {
|
||||||
|
accessRole: shareRole,
|
||||||
|
});
|
||||||
|
return response.data as { token?: string };
|
||||||
|
}, [shareRole]);
|
||||||
|
|
||||||
|
const handleGenerateLink = useCallback(async () => {
|
||||||
|
if (!shareLinksEnabled) {
|
||||||
|
alert({
|
||||||
|
alertType: 'warning',
|
||||||
|
title: t('storageShare.linksDisabled', 'Share links are disabled.'),
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 2500,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsWorking(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
setShareToken(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rootIds = Array.from(
|
||||||
|
new Set(files.map((file) => (file.originalFileId || file.id) as FileId))
|
||||||
|
);
|
||||||
|
const remoteIds = Array.from(
|
||||||
|
new Set(files.map((file) => file.remoteStorageId).filter(Boolean) as number[])
|
||||||
|
);
|
||||||
|
const existingRemoteId =
|
||||||
|
rootIds.length === 1 && remoteIds.length === 1 ? remoteIds[0] : undefined;
|
||||||
|
|
||||||
|
const { remoteId: storedId, updatedAt, chain } = await uploadHistoryChains(
|
||||||
|
rootIds,
|
||||||
|
existingRemoteId
|
||||||
|
);
|
||||||
|
|
||||||
|
const shareResponse = await createShareLink(storedId);
|
||||||
|
setShareToken(shareResponse.token ?? null);
|
||||||
|
|
||||||
|
for (const stub of chain) {
|
||||||
|
actions.updateStirlingFileStub(stub.id, {
|
||||||
|
remoteStorageId: storedId,
|
||||||
|
remoteStorageUpdatedAt: updatedAt,
|
||||||
|
remoteOwnedByCurrentUser: true,
|
||||||
|
remoteSharedViaLink: false,
|
||||||
|
remoteHasShareLinks: true,
|
||||||
|
});
|
||||||
|
await fileStorage.updateFileMetadata(stub.id, {
|
||||||
|
remoteStorageId: storedId,
|
||||||
|
remoteStorageUpdatedAt: updatedAt,
|
||||||
|
remoteOwnedByCurrentUser: true,
|
||||||
|
remoteSharedViaLink: false,
|
||||||
|
remoteHasShareLinks: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
alert({
|
||||||
|
alertType: 'success',
|
||||||
|
title: t('storageShare.generated', 'Share link generated'),
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 3000,
|
||||||
|
});
|
||||||
|
if (onShared) {
|
||||||
|
await onShared();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to generate share link:', error);
|
||||||
|
setErrorMessage(
|
||||||
|
t('storageShare.failure', 'Unable to generate a share link. Please try again.')
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setIsWorking(false);
|
||||||
|
}
|
||||||
|
}, [actions, createShareLink, files, onShared, shareLinksEnabled, t]);
|
||||||
|
|
||||||
|
const handleCopyLink = useCallback(async () => {
|
||||||
|
if (!shareUrl) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
|
alert({
|
||||||
|
alertType: 'success',
|
||||||
|
title: t('storageShare.copied', 'Link copied to clipboard'),
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 2000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy share link:', error);
|
||||||
|
alert({
|
||||||
|
alertType: 'warning',
|
||||||
|
title: t('storageShare.copyFailed', 'Copy failed'),
|
||||||
|
expandable: false,
|
||||||
|
durationMs: 2500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [shareUrl, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
centered
|
||||||
|
title={t('storageShare.bulkTitle', 'Share selected files')}
|
||||||
|
zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL}
|
||||||
|
size="lg"
|
||||||
|
overlayProps={{ blur: 6 }}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
'storageShare.bulkDescription',
|
||||||
|
'Create one link to share all selected files with signed-in users.'
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t('storageShare.fileCount', '{{count}} files selected', {
|
||||||
|
count: files.length,
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
{errorMessage && (
|
||||||
|
<Alert color="red" title={t('storageShare.errorTitle', 'Share failed')}>
|
||||||
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
{!shareLinksEnabled && (
|
||||||
|
<Alert color="yellow" title={t('storageShare.linksDisabled', 'Share links are disabled.')}>
|
||||||
|
{t('storageShare.linksDisabledBody', 'Share links are disabled by your server settings.')}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{shareUrl && (
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<TextInput
|
||||||
|
readOnly
|
||||||
|
value={shareUrl}
|
||||||
|
label={t('storageShare.linkLabel', 'Share link')}
|
||||||
|
rightSection={
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<ContentCopyRoundedIcon style={{ fontSize: 16 }} />}
|
||||||
|
onClick={handleCopyLink}
|
||||||
|
>
|
||||||
|
{t('storageShare.copy', 'Copy')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Paper withBorder radius="md" p="md">
|
||||||
|
<Stack gap="xs">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t('storageShare.linkAccessTitle', 'Share link access')}
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
label={t('storageShare.roleLabel', 'Role')}
|
||||||
|
value={shareRole}
|
||||||
|
onChange={(value) => setShareRole((value as typeof shareRole) || 'editor')}
|
||||||
|
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_FILE_MANAGER_MODAL + 10 }}
|
||||||
|
data={[
|
||||||
|
{ value: 'editor', label: t('storageShare.roleEditor', 'Editor') },
|
||||||
|
{ value: 'commenter', label: t('storageShare.roleCommenter', 'Commenter') },
|
||||||
|
{ value: 'viewer', label: t('storageShare.roleViewer', 'Viewer') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{shareRole === 'commenter' && (
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t('storageShare.commenterHint', 'Commenting is coming soon.')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button variant="default" onClick={onClose} disabled={isWorking}>
|
||||||
|
{t('cancel', 'Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
leftSection={<LinkIcon style={{ fontSize: 18 }} />}
|
||||||
|
onClick={handleGenerateLink}
|
||||||
|
loading={isWorking}
|
||||||
|
disabled={!shareLinksEnabled}
|
||||||
|
>
|
||||||
|
{t('storageShare.generate', 'Generate Link')}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BulkShareModal;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user