Attachments & File Settings API
The central attachment system accepts files for 12 entity types — with virus scanning (ClamAV), file settings per entity type, retention periods and automatic cleanup of orphaned files. All entities use the same system — incl. eLibrary, custom reports and email signatures.
Supported Entity Types
| Entity-Type | Description | Example |
|---|---|---|
TICKET | Screenshots, logs | POST /api/attachments/TICKET/:ticketId |
INCIDENT | PIR reports, screenshots | POST /api/attachments/INCIDENT/:incidentId |
PROBLEM | Root cause analyses | POST /api/attachments/PROBLEM/:problemId |
CHANGE | Implementation plans, rollback procedures | POST /api/attachments/CHANGE/:changeId |
ASSET | Purchase orders, warranty docs | POST /api/attachments/ASSET/:assetId |
CONTRACT | Signed contracts (PDF) | POST /api/attachments/CONTRACT/:contractId |
LICENSE | License certificates | POST /api/attachments/LICENSE/:licenseId |
KB_ARTICLE | Screenshots, diagrams | POST /api/attachments/KB_ARTICLE/:articleId |
WORKFLOW | Workflow approvals | POST /api/attachments/WORKFLOW/:workflowId |
CUSTOM_REPORT | Generated reports (CSV/PDF) | POST /api/attachments/CUSTOM_REPORT/:reportId |
ELIBRARY_DOCUMENT | eLibrary documents (unified attachment) | POST /api/attachments/ELIBRARY_DOCUMENT/:docId |
EMAIL_SIGNATURE | Inline images for email signatures | POST /api/attachments/EMAIL_SIGNATURE/:signatureId |
Authentication & Permissions
Attachments inherit the rights of the record they belong to — with exactly the same rules as there (incl. ownership, substitute, mailbox/group scoping, approvers, asset type lock):
- List / metadata / download: requires view access to the record.
- Upload / delete: requires edit access to the record.
- No access to the parent entity → always 404, never 403: about a record the caller may not see, the API does not even reveal that it exists.
- There is NO permission that allows deleting or downloading independently of the record — whoever may edit the record may delete.
| Entity-Type | View / edit as | Special case |
|---|---|---|
TICKET | view / edit ticket | mailbox + group + substitute + participant |
INCIDENT | view / edit incident | + substitute; assigned approvers may also view |
PROBLEM | view / edit problem | + substitute + assigned group |
CHANGE | view / edit change | requester, assignee, approvers + substitute |
ASSET | view / modify asset | asset type lock — rights per asset type |
CONTRACT | view / edit contract | editAll or editOwn as owner |
LICENSE | view / edit license | editing requires licenses.update |
KB_ARTICLE | view / edit article | visibility, status, grants; edit with editAll or editOwn as author |
WORKFLOW | view workflow instance | initiator, step assignment (incl. substitute) or workflows.viewAllInstances |
CUSTOM_REPORT | view report and customReports.export / changes only by report owner or deleteAll | archived reports: no access |
ELIBRARY_DOCUMENT | eLibrary visibility | archived documents only with elibrary.viewArchived (else 404) |
EMAIL_SIGNATURE | signature/settings permission | inline images (CID) |
Special case CUSTOM_REPORT: Here, view access to the report is not enough. The attachment endpoints apply the same rights as the report endpoints: downloading additionally requires customReports.export, deleting/replacing is limited to the report owner or deleteAll. Otherwise /api/attachments/:id/download and the delete endpoint would bypass exactly what the report routes protect — a mere viewer of a shared report could pull or delete other people's export files.
This way every attachment is always governed by the same rights as its record. For the rights models see Permissions & RBAC.
Endpoints Overview
Attachment Operations
| Method | Endpoint | Description |
|---|---|---|
POST | /api/attachments/:entityType/:entityId | Upload file |
GET | /api/attachments/:entityType/:entityId | List all attachments of an entity |
GET | /api/attachments/:id | Get attachment metadata |
GET | /api/attachments/:id/download | Download file |
GET | /api/attachments/:id/thumbnail | Get image thumbnail (WebP) |
DELETE | /api/attachments/:id | Delete attachment (soft-delete) |
GET | /api/attachments/settings/:entityType | File settings for entity type |
File Settings (Admin)
| Method | Endpoint | Description |
|---|---|---|
GET | /api/settings/file-settings | Get all entity settings |
GET | /api/settings/file-settings/global/settings | Get global settings |
PUT | /api/settings/file-settings/global/settings | Update global settings |
GET | /api/settings/file-settings/:entityType | Get entity settings |
PUT | /api/settings/file-settings/:entityType | Update entity settings |
POST | /api/settings/file-settings/:entityType/reset | Reset settings (defaults) |
Mount / Permissions / UI: All file-settings routes live under /api/settings/file-settings. Reading (GET) requires settings.viewGeneral, writing (PUT/POST reset) requires settings.editGeneral. In the UI: Admin Center → System → File Settings (/admin/file-settings) — with a "Global" tab (global defaults, /admin/file-settings?tab=global) and one tab per entity type (ticket, incident, problem, change, asset, …). Per-type settings override the global defaults (see settings hierarchy below).
API Examples
Upload File (to Ticket)
POST /api/attachments/TICKET/:ticketId
Content-Type: multipart/form-data
// JavaScript
const formData = new FormData();
formData.append('file', fileBlob, 'error-screenshot.png');
const response = await fetch(`/api/attachments/TICKET/${ticketId}`, {
method: 'POST',
body: formData,
credentials: 'include'
});
const attachment = await response.json();
Response (201 Created)
{
"id": "clx...",
"entityType": "TICKET",
"entityId": "clx-ticket-123",
"originalFileName": "error-screenshot.png",
"mimeType": "image/png",
"fileSize": 125340,
"scanStatus": "PENDING",
"thumbnailPath": null,
"downloadAvailable": false,
"uploadedById": "clx-user",
"uploadedBy": { "id": "clx-user", "name": "John Doe" },
"uploadedApiKeyId": null,
"uploadedApiKey": null,
"uploadedActorName": null,
"createdAt": "2026-01-28T11:00:00Z",
"updatedAt": "2026-01-28T11:00:00Z"
}
The response is the row itself, without a wrapper. The uploader comes as a triple: either uploadedById + uploadedBy (user) or uploadedApiKeyId + uploadedApiKey (API key); uploadedActorName is the name snapshot when neither applies (system uploads or a deleted originator).
Check Scan Status
GET /api/attachments/:id
The scan status is part of an attachment's metadata and of the list. The following examples show only the relevant fields.
Response (Excerpt, During Scan)
{
"id": "clx...",
"scanStatus": "SCANNING",
"downloadAvailable": false,
"thumbnailPath": null,
"updatedAt": "2026-01-28T11:00:05Z"
}
Response (Excerpt, After Scan - CLEAN)
{
"id": "clx...",
"scanStatus": "CLEAN",
"downloadAvailable": true,
"thumbnailPath": "thumbnails/ticket/clx-ticket-123/2026/01/2f...c9.webp",
"updatedAt": "2026-01-28T11:00:12Z"
}
Response (Excerpt, INFECTED)
{
"id": "clx...",
"scanStatus": "INFECTED",
"downloadAvailable": false,
"thumbnailPath": null,
"updatedAt": "2026-01-28T11:00:15Z"
}
Note: Infected files are moved to quarantine and cannot be downloaded. The uploading user is notified.
Download File
GET /api/attachments/:id/download
Response
# Response-Headers:
Content-Type: image/png
Content-Disposition: attachment; filename="error-screenshot.png"
X-Content-Type-Options: nosniff
Content-Security-Policy: sandbox
# Response-Body: Binary File-Data
Security: Files can only be downloaded when the scan status is CLEAN or SKIPPED. On PENDING/SCANNING the download answers 423 SCAN_PENDING, on SCAN_ERROR 423 SCAN_ERROR and on INFECTED 451 INFECTED. The block cannot be lifted for anyone.
Get Thumbnail (Image Preview)
GET /api/attachments/:id/thumbnail
# Response-Headers:
Content-Type: image/webp
Cache-Control: private, max-age=3600
X-Content-Type-Options: nosniff
# Response-Body: WebP thumbnail (max. 320px, fit inside)
Note: Thumbnails are generated automatically for raster images (JPEG/PNG/GIF/WebP — no SVG) after a successful scan (CLEAN/SKIPPED), provided generateThumbnails is enabled for the entity type. The same rights as for download apply (view access to the record), and the thumbnail is only served if the attachment is downloadable. No image, no thumbnail or no access → 404. Non-images keep the generic file icon in the UI.
List All Attachments of Entity
GET /api/attachments/TICKET/:ticketId
Response
{
"data": [
{
"id": "clx-1",
"entityType": "TICKET",
"entityId": "clx-ticket-123",
"originalFileName": "error-screenshot.png",
"mimeType": "image/png",
"fileSize": 125340,
"scanStatus": "CLEAN",
"thumbnailPath": "thumbnails/ticket/clx-ticket-123/2026/01/2f...c9.webp",
"downloadAvailable": true,
"uploadedById": "clx-user",
"uploadedBy": { "id": "clx-user", "name": "John Doe" },
"uploadedApiKeyId": null,
"uploadedApiKey": null,
"uploadedActorName": null,
"createdAt": "2026-01-28T11:00:00Z",
"updatedAt": "2026-01-28T11:00:12Z"
},
{
"id": "clx-2",
"entityType": "TICKET",
"entityId": "clx-ticket-123",
"originalFileName": "windows-event-log.txt",
"mimeType": "text/plain",
"fileSize": 45600,
"scanStatus": "CLEAN",
"thumbnailPath": null,
"downloadAvailable": true,
"uploadedById": "clx-user",
"uploadedBy": { "id": "clx-user", "name": "John Doe" },
"uploadedApiKeyId": null,
"uploadedApiKey": null,
"uploadedActorName": null,
"createdAt": "2026-01-28T11:05:00Z",
"updatedAt": "2026-01-28T11:05:09Z"
}
]
}
The list is unpaginated — its upper bound is maxFilesPerEntity from the file settings. Deleted attachments are not included.
Delete Attachment
DELETE /api/attachments/:id
Response (204 No Content)
Automatically:
- The attachment is flagged as deleted (soft-delete) and disappears from the list
- The file remains in place for the retention period
- The cleanup job then removes file and row for good
Attachments follow their record
- Into the trash: When a ticket, incident, problem, change, asset, contract or license is deleted, its attachments follow — including on a bulk delete.
- And back: Restoring brings back the attachments that fell with the record. Attachments deleted individually beforehand stay deleted, and whatever retention has meanwhile purged for good does not return.
- Exception, custom report: A report is hard-deleted — so its export files fall immediately and permanently with it, including quarantine copies and thumbnails.
Virus Scan Flow
- After the upload the attachment has the scan status PENDING (downloadAvailable: false).
- ClamAV scans the file; meanwhile the status is SCANNING.
- Result CLEAN: the file can be downloaded; for images the thumbnail is created.
- Result INFECTED: the file is moved to quarantine and cannot be downloaded; the uploading user is notified.
- If the scan fails, the status is SCAN_ERROR; stuck scans are re-queued by the cleanup job (see below).
How scanner, worker and storage are isolated from each other is described on the pages Security and Container Architecture.
File Settings (Entity-Level)
Get Settings for TICKET
GET /api/settings/file-settings/TICKET
Response
{
"entityType": "TICKET",
"enabled": true,
"maxFileSize": 52428800,
"maxFilesPerEntity": 10,
"allowedExtensions": [".pdf", ".jpg", ".jpeg", ".png", ".gif", ".doc", ".docx", ".xls", ".xlsx", ".txt", ".csv", ".zip"],
"allowedMimeTypes": [
"application/pdf",
"image/jpeg",
"image/png",
"image/gif",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/plain",
"text/csv",
"application/zip"
],
"blockedExtensions": [".exe", ".bat", ".sh", ".cmd", ".msi", ".dll", ".js", ".vbs", ".ps1"],
"generateThumbnails": true,
"retentionDays": 0,
"allowUnknownMimes": false
}
Note: The generateThumbnails field controls automatic generation of image thumbnails (WebP, max. 320px) for raster images (JPEG/PNG/GIF/WebP; no SVG). Thumbnails are created after a successful scan (CLEAN/SKIPPED) and served via GET /api/attachments/:id/thumbnail. Can be disabled per entity type.
Update Settings (Admin)
PUT /api/settings/file-settings/TICKET
{
"maxFileSize": 104857600,
"maxFilesPerEntity": 20,
"retentionDays": 365,
"allowedExtensions": [".pdf", ".jpg", ".png", ".docx", ".xlsx", ".log"]
}
File Settings (Global-Level)
Get Global Settings
GET /api/settings/file-settings/global/settings
Response
{
"id": "global",
"schemaVersion": 1,
"virusScanEnabled": true,
"virusScanOnUpload": true,
"clamavRequestTimeoutMs": 30000,
"scanStuckTimeoutMinutes": 10,
"globalBlockedExtensions": [".exe", ".bat", ".sh", ".cmd", ".msi", ".dll", ".scr", ".pif", ".vbs", ".js", ".jar", ".ps1"],
"defaultStorageProvider": "DISK",
"uploadDirectory": "/app/uploads",
"orphanCleanupEnabled": true,
"orphanRetentionHours": 24,
"globalMaxFileSize": 104857600
}
Note: The quarantine path is set at installation via the QUARANTINE_DIR environment variable (default /app/quarantine, a dedicated Docker volume separate from the uploads volume) and is deliberately not editable in the UI, so it cannot accidentally point to an unsuitable location.
Update Global Settings (Admin)
PUT /api/settings/file-settings/global/settings
{
"virusScanEnabled": true,
"globalMaxFileSize": 157286400,
"scanStuckTimeoutMinutes": 15,
"orphanRetentionHours": 48
}
Virus Scan Status
| Status | Description | Download? |
|---|---|---|
PENDING | Waiting for scan (in queue) | ❌ |
SCANNING | Currently being scanned | ❌ |
CLEAN | No virus found | ✅ |
INFECTED | Virus found (in quarantine) | ❌ |
SCAN_ERROR | Scan failed | ❌ |
SKIPPED | Scan disabled (config) | ✅ |
Settings Hierarchy
Effective Settings Calculation: 1. Global Settings (Base): └─ globalMaxFileSize: 100MB └─ globalBlockedExtensions: [.exe, .bat, ...] └─ virusScanEnabled: true 2. Entity Settings (Override): └─ TICKET.maxFileSize: 50MB (smaller than global) └─ TICKET.maxFilesPerEntity: 10 └─ TICKET.allowedExtensions: [.pdf, .jpg, ...] 3. Effective Settings (Merged): └─ maxFileSize: min(global, entity) = 50MB └─ blockedExtensions: global blacklist + entity blacklist └─ allowedExtensions: entity (if set) └─ virusScanEnabled: global (cannot be disabled per entity) Example: Global: 100MB TICKET: 50MB CONTRACT: 150MB → Effective: 100MB (global limit) Global blocked: [.exe, .bat] TICKET blocked: [.zip] Effective: [.exe, .bat, .zip]
Error Handling
| errorCode | HTTP | Description |
|---|---|---|
NOT_FOUND | 404 | The attachment or the parent entity does not exist — OR the caller may not see or edit it. Both cases answer alike: the API does not even reveal that a foreign record exists. |
FORBIDDEN | 403 | An API key called one of the six user routes — uploading, reading, downloading and deleting are bound to a signed-in user. Exception: GET /settings/:entityType answers API keys as well. |
UPLOADS_DISABLED | 403 | Uploads are switched off for this entity type — both on upload and when reading the settings |
NO_FILE | 400 | No multipart field file in the request |
VALIDATION_ERROR | 400 | Schema violation with a field path — for instance a lowercase entity type: the path parameter is strictly uppercase (TICKET, not ticket) |
FILE_TOO_LARGE | 413 | File larger than the effective limit (the stricter of the global and entity setting) |
EXTENSION_BLOCKED | 415 | Extension is in blockedExtensions |
EXTENSION_NOT_ALLOWED | 415 | Extension is not in allowedExtensions |
MIME_TYPE_NOT_ALLOWED | 415 | MIME type is not in allowedMimeTypes |
ARCHIVE_REQUIRES_VIRUS_SCAN | 415 | An archive is not accepted while the virus scan is off |
UNKNOWN_FILE_TYPE | 415 | The content matches no known type and allowUnknownMimes is off |
BINARY_FILE_AS_TEXT | 415 | Declared as text while the content is binary |
TEXT_TYPE_NOT_ALLOWED | 415 | The detected text type is not permitted |
EXTENSION_CONTENT_MISMATCH | 415 | The extension does not match the detected CONTENT — for instance text as .pdf or an image as .txt. The check runs against the actual content, not the MIME type the browser reports; for extensions without a known content family, allowedMimeTypes and allowUnknownMimes still decide. |
MAX_FILES_EXCEEDED | 409 | The entity already carries maxFilesPerEntity attachments |
DUPLICATE_FILE | 409 | The same record already carries a file with identical CONTENT (hash, not name). details.existingId names the existing row. |
SCAN_PENDING | 423 | Download locked: the virus scan is still running |
SCAN_ERROR | 423 | Download locked: the file could not be scanned |
INFECTED | 451 | Download blocked: the file is quarantined |
FILE_GONE | 410 | The row exists, the file is missing from storage |
FILE_UPLOAD_RATE_LIMIT_EXCEEDED | 429 | Too many uploads in a short time |
Downloading unscanned files is not available to anyone — there is no parameter and no permission that lifts the block.
Use Cases
Use Case 1: Ticket with Screenshot
// 1. Create ticket
const ticket = await fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
title: 'Error on login page',
description: 'See attached screenshot'
})
}).then(r => r.json());
// 2. Upload screenshot
const formData = new FormData();
formData.append('file', screenshotBlob, 'login-error.png');
const upload = await fetch(`/api/attachments/TICKET/${ticket.id}`, {
method: 'POST',
body: formData,
credentials: 'include'
}).then(r => r.json());
// 3. Poll scan status (every 2s)
const pollStatus = async () => {
const status = await fetch(`/api/attachments/${upload.id}`, {
credentials: 'include'
}).then(r => r.json());
if (status.scanStatus === 'CLEAN') {
console.log('File is safe, download available!');
return true;
} else if (status.scanStatus === 'INFECTED') {
alert('File is infected! Contact IT.');
return true;
}
return false; // Keep polling
};
Use Case 2: Upload Contract PDFs
// Check settings (what is allowed?)
const settings = await fetch('/api/attachments/settings/CONTRACT', {
credentials: 'include'
}).then(r => r.json());
console.log('Max File Size:', settings.maxFileSize / 1024 / 1024, 'MB');
console.log('Allowed:', settings.allowedExtensions);
// Upload PDF
const formData = new FormData();
formData.append('file', pdfBlob, 'signed-contract-2026.pdf');
await fetch(`/api/attachments/CONTRACT/${contractId}`, {
method: 'POST',
body: formData,
credentials: 'include'
});
Use Case 3: Configure Global Settings
# Admin: disable virus scan (development)
PUT /api/settings/file-settings/global/settings
{
"virusScanEnabled": false
}
# Admin: increase max file size (for large reports)
PUT /api/settings/file-settings/global/settings
{
"globalMaxFileSize": 209715200
}
# Admin: extend orphan-cleanup window
PUT /api/settings/file-settings/global/settings
{
"orphanRetentionHours": 72
}
Best Practices
💡 Tips
1. Upload Validation
- • Load settings BEFORE upload (GET /attachments/settings/:entityType)
- • Client-side validation (maxFileSize, allowedExtensions)
- • Server validates again (defense-in-depth)
- • The server checks the actual file content
2. Virus Scan
- • Poll every 2s for scan status (not too frequently)
- • Timeout after 2min (if scan hangs)
- • User feedback on SCANNING ("Please wait...")
- • On INFECTED: user notification + alert to IT
3. Retention
- • Set retentionDays per entity type (tickets: 365 days, contracts: 0 = unlimited)
- • CronJob: attachment_cleanup runs daily
- • Deleted attachments are kept for retentionDays days, after which the cleanup job removes them permanently (0 = never)
- • Orphan cleanup: uploads without DB entry deleted after 24h
4. Performance
- • Thumbnail generation (generateThumbnails) creates WebP previews for image attachments — can be disabled per entity type
- • Increase ClamAV timeout for large files (clamavRequestTimeoutMs)
- • Set max files limit (keeps the database lean)
- • Keep orphan cleanup active (prevents full disks)
Integration with Entities
Usage with Different Entities: Tickets: POST /api/attachments/TICKET/:ticketId • Screenshots of error messages • Log files • User uploads (evidence) Incidents: POST /api/attachments/INCIDENT/:incidentId • Post-Incident-Review (PIR) reports • Screenshots from monitoring • Network diagrams Problems: POST /api/attachments/PROBLEM/:problemId • Root-cause-analysis reports • Vendor analysis reports • Interim-solution documentation Changes: POST /api/attachments/CHANGE/:changeId • Implementation-Plans • Rollback-Procedures • Approval-Documents Assets: POST /api/attachments/ASSET/:assetId • Purchase-Orders • Warranty-Certificates • Invoices Contracts: POST /api/attachments/CONTRACT/:contractId • Signed Contract-PDFs • Amendments • Renewal-Notices Licenses: POST /api/attachments/LICENSE/:licenseId • License-Certificates • Activation-Instructions KB-Articles: POST /api/attachments/KB_ARTICLE/:articleId • Screenshots for how-to guides • Diagrams • PDFs Workflows: POST /api/attachments/WORKFLOW/:workflowId • Approval-Documents • Supporting-Documents Custom Reports: POST /api/attachments/CUSTOM_REPORT/:reportId • Generated CSV/PDF reports eLibrary: POST /api/attachments/ELIBRARY_DOCUMENT/:docId • eLibrary documents (unified attachment) E-Mail-Signaturen: POST /api/attachments/EMAIL_SIGNATURE/:signatureId • Inline images (CID references)
Cleanup & Maintenance
Automatic Cleanup Jobs
A job of the attachment_cleanup action runs six operations in a set order; which of them run is set by the operations parameter (without it: all six).
| operation | Description |
|---|---|
stuck_scans | A scan stuck on SCANNING for too long is set to SCAN_ERROR and re-queued — but only while its file still exists. Rows without a file are not re-queued but reported. |
file_gone | Live rows whose file is missing and that are older than the grace period are deleted by the system (a note on the record plus an audit entry); the retention step of the same run clears them for good. |
retention | Permanently remove deleted attachments once their retention has elapsed — row, file and thumbnail. |
orphans | Delete files without a matching row after the grace period. |
infected | Remove quarantined files after their own retention period. |
signature_drafts | Clean up image uploads from the signature editor that were never saved. |
Note: Matching image thumbnails are included. The periods themselves live in the file settings, not on the job.
CronJob Configuration
{
"name": "Attachment Cleanup - Daily",
"category": "MAINTENANCE",
"trigger": {
"type": "cron",
"schedule": {
"cronExpression": "0 3 * * *"
}
},
"actions": [
{
"type": "attachment_cleanup",
"parameters": {
"operations": ["stuck_scans", "file_gone", "retention", "orphans", "infected", "signature_drafts"],
"fileGoneDryRun": false
}
}
]
}
Note: The attachment system is the same for all twelve entity types: one API, the rights of the respective record and a shared virus scan.