# 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`)