Files
Upload, validation, multi-file fields, on-the-fly thumbnails, and protected URLs.
Files attach to records via fields of type file. By default they live on
the local filesystem at <dataDir>/uploads/; switch to S3 / Cloudflare R2
from Settings → File storage — see Storage.
Schema
Section titled “Schema”{ "name": "avatar", "type": "file", "options": { "maxSize": 5242880, // 5 MB "mimeTypes": ["image/*"], // patterns; "image/*" supported "multiple": false, "protected": false} }The record stores the filename (or a JSON array of filenames if multiple).
A value written to a file field must be a stored file name as uploads return it
(<uuid>.<ext>); anything else, such as a path, is rejected with 422.
For everyone but admins, a file name added to a field must be a file uploaded to
that same record and field (422 otherwise), so one record can’t show another’s
uploads. Names the field already holds stay valid, so sending a record back
unchanged works. A new record has no uploads yet: create it, then upload to it
and set the field.
The actual file lives at <dataDir>/uploads/<id>.<ext>.
Upload
Section titled “Upload”POST /api/v1/files/<collection>/<recordId>/<field>Content-Type: multipart/form-datafile: <binary>The record must already exist (404 otherwise). Uploads need a signed-in
caller (401 otherwise, even when the update rule is public), and non-admin
callers must pass the collection’s update rule for it. Uploading doesn’t set
the field: update the record with the returned filename to attach it. Server validates maxSize and
mimeTypes before writing anything. Single-file fields reject more than one
upload; multi-file fields accept any count.
Response:
{ "data": { "id": "...", "filename": "...uuid....png", "originalName": "avatar.png", "size": 12345, "mimeType": "image/png" }}For multi-file uploads, data is an array.
GET /api/v1/files/<filename>Governed by the collection’s view_rule (and the field’s viewRule). Safe raster
images are served inline with their MIME type; everything else downloads as
application/octet-stream with Content-Disposition: attachment, and every file
response is sandboxed by CSP. See the files API for the headers.
Thumbnails
Section titled “Thumbnails”Add ?thumb=WIDTHxHEIGHT to the GET — Vaultbase generates a thumbnail,
caches it on disk, and serves the cache on subsequent hits.
GET /api/v1/files/<filename>?thumb=200x200GET /api/v1/files/<filename>?thumb=64x64&fit=coverGET /api/v1/files/<filename>?thumb=400x300_crop ← shorthandFit modes
Section titled “Fit modes”| Mode | Behavior |
|---|---|
contain (default) | Fit-within: preserve aspect ratio, the longest axis matches W or H. Output may be smaller than the requested box. |
cover | Center-crop the source to the target aspect ratio, then resize to exactly WxH. No letterboxing, no distortion. |
crop | Alias for cover. |
Two ways to pick a mode:
GET /api/v1/files/<filename>?thumb=400x300&fit=coverGET /api/v1/files/<filename>?thumb=400x300_cover ← shorthand suffixThe cache key includes the mode, so contain and cover thumbnails for
the same file co-exist without colliding.
- Supported source formats: PNG, JPEG, GIF (animated → animated thumb),
WebP. AVIF uploads are stored and served, but
?thumb=returns the original. JPEG EXIF orientation is applied, so phone photos come out upright. - Output: same format as input unless
formatis set. JPEG → JPEG (q85), PNG → PNG, GIF → GIF (frames preserved with original delays and loop count; single-frame GIFs downgraded to PNG), WebP → WebP.Content-Typematches the output format. - Cache:
<dataDir>/uploads/.thumbs/<filename>__<W>x<H>[_cover][.<format>][_q<N>], invalidated on file delete.containhas no suffix andcropshares thecoverentry; a re-encode withoutthumbuses0x0, and PNG output has no_q. - Non-image files: served unchanged (the
?thumb=is silently ignored).
Range bounds: 1 to 4096 on each side. One side may be 0 to keep the aspect
ratio: ?thumb=800x0 is 800 wide, ?thumb=0x300 is 300 tall.
Format and quality
Section titled “Format and quality”| Param | Values | Notes |
|---|---|---|
format | webp · jpeg · png · auto | Re-encode the image. auto returns WebP when the request’s Accept header allows it, otherwise the source format, and sets Vary: Accept. |
quality | 1–100 | JPEG and WebP output. Defaults: JPEG 85, WebP 80. |
Both work with or without thumb. Without it, the image keeps its size:
GET /api/v1/files/<filename>?thumb=400x0&format=auto&quality=75GET /api/v1/files/<filename>?format=webpAVIF output isn’t offered: encoding it needs an OS AV1 encoder that Linux
servers don’t have. An animated GIF with format set becomes a still image
of its first frame.
Limiting sizes
Section titled “Limiting sizes”Each new size, format or quality is a fresh decode and a new cache file, so
an open ?thumb= lets anyone burn CPU and disk by asking for endless sizes.
Two guards:
- Allowed sizes per field. Set the file field’s Thumbnail sizes
(option
thumbs, e.g.["100x100", "400x300_cover", "800x0"]). Non-admin requests for any other size get the original file withX-Vaultbase-Thumb: not-allowed. Format and quality stay free to choose. - Variant cap. A file accumulates at most 32 cached variants from
non-admin requests. Past that, new variants get the original with
X-Vaultbase-Thumb: limit; cached ones keep working.
Admin requests skip both.
# Hero image, exact 1200x400, center-croppedcurl -o hero.jpg "https://api.example.com/api/v1/files/$FN?thumb=1200x400&fit=cover"
# Avatar, 64x64 fit-within (default)curl -o avatar.png "https://api.example.com/api/v1/files/$FN?thumb=64x64"Protected files
Section titled “Protected files”Set protected: true on the field’s options. GET /api/v1/files/<filename>
then requires ?token=<jwt> for everyone but admins — even when the record’s
rules would let the caller view it. Without a valid token the answer is
401. Issue tokens via:
POST /api/v1/files/<col>/<recordId>/<field>/<filename>/tokenAuthorization: Bearer <admin-or-user-jwt>Response:
{ "data": { "token": "<jwt>", "expires_at": 1730003600, "one_time": false } }Then:
GET /api/v1/files/<filename>?token=<jwt>The token’s filename claim is checked against the requested path — a
token for a.png can’t unlock b.png. Tokens are 1-hour JWTs with
audience: "file". one_time says whether the token is single-use (the
field’s oneTimeToken option); a client shouldn’t cache a one-time token.
Who can issue a token
Section titled “Who can issue a token”Admins always pass. Other callers pass iff the collection’s view_rule
(and the field’s viewRule) would let them read the parent record:
view_rule | Behavior |
|---|---|
null | Public — anyone, including unauthenticated callers, can mint a token |
"" | Admin only — non-admins get 403 |
Expression (e.g. @request.auth.id = owner) | Evaluated against the record + caller. Pass → token; fail → 403. |
On a collection with a public view_rule, protected: true therefore keeps
files off plain URLs but doesn’t limit who can download them; add a field
viewRule or requireAuth for that.
So the same rules that gate reading the record gate minting a file token — no new policy surface to maintain.
Admin UI auto-tokens
Section titled “Admin UI auto-tokens”The bundled admin records page handles protected file previews automatically — it mints tokens behind the scenes when rendering thumbnails and download links, so you don’t need to plumb tokens through manually when working in the UI.
Rule-based file protection
Section titled “Rule-based file protection”view_rule on the parent collection is one knob; for fine-grained control
each file field can carry its own access policy. Rules are
AND-combined — both the collection’s view rule and the field rule
must pass.
{ "name": "tax_doc", "type": "file", "options": { "protected": true, "viewRule": "@request.auth.id = owner", "requireAuth": true, "oneTimeToken": true, "bindTokenIp": true, "auditDownloads": true} }| Option | Behavior |
|---|---|
viewRule | Per-field rule, evaluated AND with view_rule. null/missing inherits the collection rule. "" forces admin-only. A rule that doesn’t parse is refused at save with 422. |
requireAuth | Anonymous fetches are rejected even on a public collection. |
oneTimeToken | Tokens minted from /token are single-use; second fetch returns 410 Gone. Only takes effect with protected: true; otherwise the file is still served by the rules without a token. |
bindTokenIp | Token’s JWT carries the requesting client’s IP; re-use from a different IP returns 403. Incompatible with mobile NAT — opt-in. Only takes effect with protected: true. |
auditDownloads | Each successful fetch appends a files.download row to the audit log (actor, target, ip, via: rule/token). |
Extra rule context
Section titled “Extra rule context”Per-field rules see the same operands as collection rules, plus a few
file-specific headers under @request.headers.*:
| Operand | Source |
|---|---|
@request.headers.x_vb_ip | Client IP (respects trusted proxies) |
@request.headers.x_vb_file_field | The field name being downloaded |
@request.headers.x_vb_file_mime | MIME type of the stored file |
@request.headers.x_vb_file_size | Size in bytes (string) |
@request.headers.x_vb_collection | Parent collection name |
So you can write:
@request.auth.id = owner && @request.headers.x_vb_file_size < '5000000'Examples
Section titled “Examples”// Avatar visible on a public profile collection, but only the owner can// fetch the original file.{ "viewRule": "@request.auth.id = id" }
// Internal-only document — collection is public-list, file is admin-only.{ "viewRule": "" }
// Verified accounts only.{ "viewRule": "@request.auth.verified = true", "requireAuth": true }
// Sensitive download — single-use token, IP-bound, audited.{ "protected": true, "viewRule": "@request.auth.id = assignee", "oneTimeToken": true, "bindTokenIp": true, "auditDownloads": true }Failure modes
Section titled “Failure modes”| Status | Reason |
|---|---|
401 | protected: true field without a valid ?token= (for non-admins) |
403 | Collection or field rule denied; or token IP claim mismatch |
410 | One-time token replayed |
Multi-file fields
Section titled “Multi-file fields”Set multiple: true to store an array. The record’s field is JSON-encoded:
{ "attachments": ["uuid-a.pdf", "uuid-b.pdf"] }Delete a single file from a multi-file field:
DELETE /api/v1/files/<col>/<recordId>/<field>/<filename>Delete all files for a record’s field:
DELETE /api/v1/files/<col>/<recordId>/<field>Cleanup
Section titled “Cleanup”An hourly sweep deletes stored files nothing points at any more, along with their thumbnails:
- the record was deleted, or its collection was;
- the file field was removed from the schema;
- the record’s field no longer names the file (it was replaced, or uploaded and never attached).
Files younger than 24 hours are left alone, since clients often upload
before creating or updating the record. Collections with
record history are skipped, so restoring an old
version still finds its files. Set files.orphan_cleanup to 0 to turn the
sweep off.
The DELETE endpoints above remove a file immediately. They don’t change the record: update its field too, or it keeps naming the deleted file.
Storage backends
Section titled “Storage backends”Two drivers ship: local (default, <dataDir>/uploads/) and s3
(AWS S3, Cloudflare R2, MinIO, B2 — anything S3-compatible, no SDK
required). Switch in the admin at Settings → File storage.