File Upload Pipeline
File Upload Pipeline User-uploaded files flow through an isolated microservice on a separate VM, with AES-256-GCM encryption, ClamAV malware scanning, SightE...
File Upload Pipeline
User-uploaded files flow through an isolated
app-uploadsmicroservice on a separate VM, with AES-256-GCM encryption, ClamAV malware scanning, SightEngine content safety, and S3 storage.
Why This Exists
File uploads need malware scanning, content moderation, encryption, and S3 storage. Running this on a separate VM limits the blast radius: a compromised upload server cannot access user data, the main Vault, or Directus.
How It Works
graph TB
A["Client POST /upload/file"] --> B["Auth: validate session<br/>via /internal/validate-token"]
B --> C["Validate: 100 MB max,<br/>MIME whitelist"]
C --> D{Duplicate hash?}
D -->|Yes| E["Return existing record"]
D -->|No| F["ClamAV malware scan"]
F --> G["SightEngine content safety<br/>nudity / violence / gore / AI-gen"]
G --> H["Generate WEBP preview<br/>600×600 max"]
H --> I["AES-256-GCM encrypt<br/>random per-file key"]
I --> J["Vault Transit wrap<br/>AES key"]
J --> K["S3 upload<br/>encrypted original + preview"]
K --> L["Record in Directus<br/>upload_files"]
L --> M["Return AES key + metadata<br/>→ client builds embed"]
Upload Flow (Phase 1: Images)
Client sends multipart POST to /api/uploads/v1/upload/file. Processing steps:
- Auth – session cookie validated via core API
/internal/validate-token - Validation – 100 MB max, MIME whitelist
- Dedup – per-user SHA-256 hash check via core API proxy (instant return on match)
- Malware scan – ClamAV TCP socket; 422 on threat
- Content safety – SightEngine combined scan (nudity/violence/gore/AI-gen); fail-closed on API error (503 to user)
- Preview – Pillow WEBP at max 600x600px
- Encryption – AES-256-GCM with random per-file key
- Key wrapping – AES key wrapped by core API via Vault Transit (
/internal/uploads/wrap-key) - S3 upload – encrypted original + preview to
chatfilesbucket - Record – written to Directus
upload_filesvia core API proxy
Client receives the plaintext AES key + S3 metadata, builds an embed TOON, client-encrypts it before storage (zero-knowledge at rest).
Security Architecture
UPLOADS VM MAIN SERVER
app-uploads -> local Vault (dev mode) core API -> main Vault (Transit only)
| S3 creds -> Directus
| SightEngine creds
+-> core API /internal/uploads/*
(INTERNAL_API_SHARED_TOKEN)
Compromise blast radius: Attacker gets S3 write creds + SightEngine keys only. Cannot decrypt existing files, access user data, or reach main Vault.
Local Vault: Dev mode (in-memory, auto-unsealed) Docker sidecar. vault-setup init container migrates SECRET__* env vars into KV v2. Only two KV paths: kv/data/providers/hetzner (S3) and kv/data/providers/sightengine.
Encryption Model
- Files encrypted before S3 upload – plaintext never leaves the upload server.
- Plaintext
aes_keyreturned to client for browser rendering; stored inside client-encrypted embed content at rest. vault_wrapped_aes_keyenables backend skills to decrypt on demand (e.g.,images.viewskill).- Key wrapping uses only Vault Transit
encrypt– upload VM has no decrypt capability.
Internal API Proxy Endpoints
All require INTERNAL_API_SHARED_TOKEN in X-Internal-Token header.
| Endpoint | Purpose |
|---|---|
POST /internal/uploads/check-duplicate |
Query upload_files for (user_id, hash) |
POST /internal/uploads/wrap-key |
Vault Transit encrypt on user’s key ID |
POST /internal/uploads/store-record |
Create upload_files Directus record |
Content Safety Scanning
Single combined SightEngine call (nudity-2.0,offensive,gore,genai). Blocking thresholds: sexual_activity/display > 0.3, erotica > 0.4, sextoy > 0.3, suggestive > 0.6, weapon > 0.5, gore > 0.3, blood > 0.4. AI-detection score is metadata only (non-blocking).
Fail-closed policy: SightEngine HTTP error/timeout -> upload rejected with HTTP 503. If credentials not configured (dev/self-hosted), scanning is skipped entirely.
PDF screenshots: app-pdf-worker scans each rendered page. Service unavailable -> Celery retry. Violation on any page -> entire PDF rejected.
Storage Billing
Weekly Celery Beat task (charge-storage-fees-weekly, Sunday 03:00 UTC):
- Aggregates
upload_filesby user for real total bytes. - 1 GB free tier; above: 3 credits/GB/week (ceil).
- Reconciles
storage_used_bytescounter drift on every run. - Failure escalation: warning (1st), second notice (2nd), final warning (3rd), file deletion (4th).
Auto-Deletion of Chats
Daily task (auto-delete-old-chats-daily, 02:30 UTC): users with auto_delete_chats_after_days configured have stale chats deleted (max 100/user/day). Deletion pipeline removes messages, embeds (with shared-embed safety check), upload_files records, and decrements storage_used_bytes.
File Type Routing
| Type | Route | Status |
|---|---|---|
| Images (JPEG/PNG/etc) | app-uploads microservice | Implemented |
| app-uploads microservice | Phase 2 planned | |
| DOCX, XLSX | app-uploads microservice | Phase 3 planned |
| Code/Audio/Video/EPUB | Client-only embeds | Already works |
Edge Cases
- Deduplication is per-user only. Cross-user dedup intentionally not implemented to maintain per-user encryption model.
- Content safety fallback providers (Azure, AWS Rekognition, Hive) researched but not yet implemented. See
sightengine_service.pyfor planned cascade. storage_used_bytescounter can drift from failed decrements; weekly billing run self-heals.
Data Structures
Key Directus fields on users: storage_used_bytes, storage_last_billed_at, storage_billing_failures, auto_delete_chats_after_days.
Related Docs
- Message Processing – how AI skills consume uploaded file embed data
- Encryption Architecture – encryption model