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
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 aWorkflowSession(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_idis set,share_tokenis null - Link share:
share_tokenis set (UUID),shared_with_user_idis null
- User share:
access_role—EDITOR,COMMENTER, orVIEWERexpires_at— nullable expiration for link sharesworkflow_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 (
VIEWorDOWNLOAD), 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
| Role | Can Read | Can Write |
|---|---|---|
EDITOR |
✅ | ✅ |
COMMENTER |
✅ | ❌ |
VIEWER |
✅ | ❌ |
Default role when none is specified: EDITOR.
Owners always have full access regardless of role.
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.
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
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_blobstable - No filesystem dependency
Provider is selected at startup via storage.provider: local | database.
FileStorageController (/api/v1/storage)
- All endpoints require authentication
- File CRUD and sharing operations
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
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 trailResponse:
{
"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": []
}Replaces the file content. Only the owner can update.
PUT /api/v1/storage/files/{fileId}
Content-Type: multipart/form-data
file: document_v2.pdf
historyBundle: history.json # Optional
auditLog: audit.json # OptionalReturns all files owned by or shared with the current user. Workflow-shared files (signing participants) are excluded — those are accessible via signing endpoints only.
GET /api/v1/storage/filesResponse is sorted by createdAt descending.
GET /api/v1/storage/files/{fileId}/download?inline=falseinline=false(default) —Content-Disposition: attachmentinline=true—Content-Disposition: inline(for browser preview)
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.
DELETE /api/v1/storage/files/{fileId}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
FileSharewithsharedWithUserset - If
usernameis an email address and the user doesn't exist: creates a share link and sends a notification email (requiressharing.emailEnabledandsharing.linkEnabled) - If the target user is the owner: returns 400
- If sharing is disabled: returns 403
Only the owner can revoke.
DELETE /api/v1/storage/files/{fileId}/shares/users/{username}The recipient removes themselves from a shared file.
DELETE /api/v1/storage/files/{fileId}/shares/selfCreates a token-based link for anonymous/authenticated access. Requires sharing.linkEnabled and system.frontendUrl to be configured.
POST /api/v1/storage/files/{fileId}/shares/links
Content-Type: application/json
{
"accessRole": "viewer" # Optional (default: "editor")
}Response:
{
"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).
DELETE /api/v1/storage/files/{fileId}/shares/links/{token}Also deletes all access records for that token.
Authentication is required (even for share links). Anonymous access is not permitted.
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
FileShareAccessentry 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 /api/v1/storage/share-links/{token}/metadataReturns file name, owner, access role, creation/expiry timestamps, and whether the current user owns the file.
Returns the most recent access for each non-expired share link the current user has accessed.
GET /api/v1/storage/share-links/accessedGET /api/v1/storage/files/{fileId}/shares/links/{token}/accessesReturns per-user access history (username, VIEW/DOWNLOAD, timestamp), sorted descending by time.
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.
| 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) |
All storage settings live under the storage: key in settings.yml:
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 expirePrerequisites:
storage.enabledrequiressecurity.enableLogin = truesharing.linkEnabledrequiressystem.frontendUrlto be set (used to build share link URLs)sharing.emailEnabledrequiresmail.enabled = true
- All endpoints require authentication — there is no anonymous access
- Owner-only operations enforced in service layer (not just controller)
requireReadAccess/requireEditorAccesschecked on every download
- Tokens are UUIDs (random, not guessable)
- Expiration enforced on every access
- Expired links return HTTP 410 Gone
- Revoked links delete all access records
- 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
StorageCleanupService runs two scheduled jobs daily:
-
Orphaned storage cleanup — processes up to 50
StorageCleanupEntryrecords, deletes the physical storage object, then removes the entry. Failed attempts incrementattemptCountfor retry. -
Expired share link cleanup — deletes all
FileSharerecords whereexpiresAtis in the past andshareTokenis set.
"Storage is disabled":
- Check
storage.enabled: truein settings - Verify
security.enableLogin: true
"Share links are disabled":
- Check
sharing.linkEnabled: true - Verify
system.frontendUrlis set and non-empty
"Email sharing is disabled":
- Check
sharing.emailEnabled: true - Verify
mail.enabled: trueand 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_atinfile_sharestable - Owner must create a new link
-- 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;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)