FILE_SHARING.md
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:
system.frontendUrl)mail.enabled)stored_files
workflow_session_id — nullable link to a WorkflowSession (signing feature)file_purpose — enum classifying the file's role: GENERIC, SIGNING_ORIGINAL, SIGNING_SIGNED, SIGNING_HISTORYfile_shares
shared_with_user_id is set, share_token is nullshare_token is set (UUID), shared_with_user_id is nullaccess_role — EDITOR, COMMENTER, or VIEWERexpires_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
VIEW or DOWNLOAD), timestampstorage_cleanup_entries
| 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)
StorageCleanupService
storage_cleanup_entriesfile_sharesLocalStorageProvider
storage.local.basePath (default: ./storage)DatabaseStorageProvider
stored_file_blobs tableProvider is selected at startup via storage.provider: local | database.
FileStorageController (/api/v1/storage)
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 trail
Response:
{
"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 # Optional
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.
GET /api/v1/storage/files
Response is sorted by createdAt descending.
GET /api/v1/storage/files/{fileId}/download?inline=false
inline=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:
FileShare with sharedWithUser setusername 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)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/self
Creates 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
FileShareAccess entry on successToken-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}/metadata
Returns 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/accessed
GET /api/v1/storage/files/{fileId}/shares/links/{token}/accesses
Returns 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 expire
Prerequisites:
storage.enabled requires security.enableLogin = truesharing.linkEnabled requires system.frontendUrl to be set (used to build share link URLs)sharing.emailEnabled requires mail.enabled = truerequireReadAccess / requireEditorAccess checked on every downloadStorageCleanupService runs two scheduled jobs daily:
Orphaned storage cleanup — processes up to 50 StorageCleanupEntry records, deletes the physical storage object, then removes the entry. Failed attempts increment attemptCount for retry.
Expired share link cleanup — deletes all FileShare records where expiresAt is in the past and shareToken is set.
"Storage is disabled":
storage.enabled: true in settingssecurity.enableLogin: true"Share links are disabled":
sharing.linkEnabled: truesystem.frontendUrl is set and non-empty"Email sharing is disabled":
sharing.emailEnabled: truemail.enabled: true and mail configurationSigning-session PDF appearing in the general file list:
file_purpose (SIGNING_ORIGINAL, SIGNING_SIGNED) in the UI to distinguish themShare link returns 410:
expires_at in file_shares table-- 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:
WorkflowParticipant.shareToken)