File storage
Files uploaded through file-upload questions never live in MongoDB — they go
to a pluggable storage backend behind one interface, and a Mongo
uploaded_files collection is the index. Two backends ship:
STORAGE_MODE=filesystem | s3 # default: filesystem (bare runs); the compose stack sets s3 + MinIOAny other value fails startup with an explicit error — there is no silent fallback.
The startup canary
Section titled “The startup canary”Whichever mode you pick, the backend writes a small canary object at startup, reads it back, compares the bytes, and deletes it. If any part of that round-trip fails, the backend refuses to start and the error names the settings to check. Storage misconfiguration surfaces at deploy time, not at your first respondent’s upload.
Filesystem mode
Section titled “Filesystem mode”STORAGE_MODE=filesystemFS_STORAGE_PATH=./data/uploads # created if absent; startup fails loudly if unwritableSimplest possible setup: files land in a directory. The path is created if missing; writes are atomic (temp file + rename). Two things to know:
- In the Docker image the relative default resolves to
/app/data/uploadsinside the container — mount a volume there (or setFS_STORAGE_PATHto a mounted path) or files disappear when the container is replaced. - Storage keys are always generated server-side
(
questionnaireId/responseId/fileId) — no user-supplied string ever becomes part of a path.
S3-compatible mode
Section titled “S3-compatible mode”STORAGE_MODE=s3S3_ENDPOINT=http://minio:9000 # omit/empty for real AWS (SDK derives from region)S3_BUCKET=forms-engine-uploads # must already exist — the backend never creates itS3_ACCESS_KEY=… # empty = AWS SDK default credential chainS3_SECRET_KEY=…S3_REGION=us-east-1S3_PATH_STYLE=true # ⚠️ true for MinIO and most S3-compatibles; false/omit for AWSSmoke-tested: MinIO (the compose stack and the test suite) and AWS S3. Expected-compatible per protocol: R2, B2, DigitalOcean Spaces, GCS interoperability mode.
The bundled compose stack uses MinIO with a one-shot minio-init container
that creates the bucket, so docker compose up remains the whole setup. If
you bring your own bucket, create it yourself first — the canary will catch
a missing one at startup.
What’s accepted
Section titled “What’s accepted”The editor offers upload categories; the server owns the extension ↔
content mapping and verifies file content (magic-byte signatures), never
trusting the filename or the client’s Content-Type:
| Category | Extensions |
|---|---|
| Documents | pdf, doc, docx |
| Images | jpg, jpeg, png, gif, webp |
| Spreadsheets | xls, xlsx, csv |
| Text | txt, md |
| Archives | zip |
Executables, scripts, HTML, and SVG are not offerable in any category, ever
(SVG is scriptable, and therefore an attack format rather than an image, for
upload purposes). A .pdf that is actually a zip, or an “image” that is
actually HTML, is rejected with a specific error message. Downloads exist
only on the management API, always served as
Content-Disposition: attachment with X-Content-Type-Options: nosniff —
never inline, never from a public endpoint.
Caps and lifecycle
Section titled “Caps and lifecycle”All env-tunable — see Environment variables:
- Per-file size comes from the question’s configuration, bounded by
MAX_FILE_SIZE_MB(default 50). - Per-response:
MAX_FILES_PER_RESPONSE(20 files) andMAX_BYTES_PER_RESPONSE_MB(200 MB). - Upload rate limit: ≈30 uploads / 10 minutes / IP.
An hourly cleanup job (FILE_CLEANUP_CRON) deletes active files older than
FILE_ORPHAN_GRACE_HOURS (24) that no response’s answers reference —
covering abandoned sessions and failed deletes. Files referenced by a
completed response are permanent. Deleted files leave a tombstone row in the
index (status DELETED) rather than vanishing from it.
Malware scanning hook
Section titled “Malware scanning hook”A no-op FileScanner interface is invoked after content verification and
before storage
(backend/src/main/java/io/formsengine/service/FileScanner.java):
void scan(String fileName, String contentType, InputStream content);Return normally to accept; throw to veto the upload. contentType is the
verified type, never the client’s claim. If you need virus scanning,
implement it against ClamAV (e.g. clamd’s
INSTREAM protocol) and register your bean — nothing else changes.
One operational caveat: a storage outage during an upload surfaces to the client as a generic 500, not the API’s usual structured error body. The startup canary makes this rare, but it’s worth knowing when reading logs.