Files
A file field is a text column that holds the file’s URI. The bytes live with a provider, a named place for files: a folder on disk or an S3-compatible bucket. Protobase adds no table, trigger or foreign key to your database: a file field needs a text column and nothing else.
import { f, resource } from '@protobase/schema'import { aspectRatio, fileSize, imageSize, processor } from '@protobase/server'
// A processor of your own reads the whole file when it needs to: here a SHA-256, to spot the same manual uploaded twice.const sha256 = processor({ run: async (file) => { const digest = await crypto.subtle.digest('SHA-256', await new Response(await file.read()).arrayBuffer()) return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('') },})
// A bike shop's bikes for sale: a photo with its size in two more columns, a manual, and a public picture for the// shop's website, stored at most 2048 pixels wide as WebP, with a card-sized copy and a placeholder.export const bikes = resource('bikes') .table('shop.bikes') .fields({ id: f.integer().readOnly().dbDefault(), name: f.text(), photo: f.file().accept(['image/*']).maxSize('10 MB').optional().derive({ photo_width: imageSize('width'), photo_ratio: aspectRatio() }), photo_width: f.integer().readOnly().optional(), photo_ratio: f.decimal({ precision: 8, scale: 4 }).readOnly().optional(), manual: f.file().accept(['application/pdf']).optional().derive({ manual_bytes: fileSize(), manual_sha256: sha256 }), manual_bytes: f.integer().readOnly().optional(), manual_sha256: f.text().readOnly().optional(), listing: f .file() .accept(['image/png', 'image/jpeg', 'image/webp']) .maxSize('2 MB') .public() .optional() .placeholder('listing_placeholder') .variants({ card: { width: 640, height: 480, fit: 'cover', format: 'webp', quality: 75 } }, { original: { width: 2048, format: 'webp' } }), listing_placeholder: f.text().readOnly().optional(), }) .primaryKey((r) => r.id)| Option | |
|---|---|
.accept(['image/*', 'application/pdf']) |
Allowed types: exact types and type/* wildcards (*/* must be written out). They are checked against the type detected from the content (see How the type is decided). Without it any type is taken. |
.maxSize('10 MB') |
The largest upload, as bytes or text (512 KiB, 1.5 GB); default 50 MB. |
.public() |
Stored with the public provider, whose files anyone with the link may open. |
.storage('archive') |
Stored with another provider; default private. |
.derive({ field: processor }) |
Values computed from each upload and written to other fields of the row, see Derived fields. |
.placeholder('photo_placeholder') |
Each image’s ThumbHash in that text field, drawn while the image loads, see Image variants and placeholders. |
.variants({ card: { … } }, { original? }) |
Resized copies of each image stored beside it, see Image variants and placeholders. |
.externalUrls() |
Also takes an https URL of a file elsewhere instead of an upload, see External URLs. |
A file field cannot be filterable, sortable, aliased, searched, sensitive, or the key, tenant, owner or soft delete field. protobase doctor checks that its column is a text type.
What the column holds
Section titled “What the column holds”private:1/2992db9c-776e-4d4e-9e0f-59d485fefaf9.jpg?name=bike+red.png&size=48211- The scheme is the provider’s name, so a provider can move from local disk to a bucket without touching any row. Name the role (
archive), not the vendor: the name is part of every stored value. - The path of an upload is
<tenant>/<random id>.<extension>, with_for a resource without a tenant. Nothing in it comes from the row or from what the user typed, so renames and key changes need nothing. - The query holds the uploader’s file name and the size in bytes, and for an image with variants their names and extensions:
&variants=card.webp,thumb.webp. A plain{provider}:{path}, such aspublic:images/test.jpgwritten by an import, is valid too; its name is its last path segment. - The type is not stored. It is the path’s extension, looked up in one fixed table, which is also what downloads are served as.
- A field with
.externalUrls()may hold an https URL instead, such ashttps://images.example.com/listings/42.jpg, see External URLs.
Responses carry each file field as an object, and the stored value stays the URI:
{ "uri": "private:1/2992db9c-….jpg?name=bike+red.png&size=48211", "name": "bike red.png", "type": "image/jpeg", "size": 48211, "url": "/api/files/private/1/2992db9c-….jpg?r=bikes&k=42&f=photo&exp=1791673200&sig=…" }An image with variants also has variants, each { url, type, width, height } by name, and one with a placeholder its placeholder. An external URL comes as { uri, name, type, url } with the URL as both uri and url. A value that does not parse, or names a provider the config lacks, comes back as { uri, missing: true }. The URL is made for each response and never changes the record’s ETag.
Providers
Section titled “Providers”import sharp from 'sharp'import { defineConfig, localFiles } from '@protobase/server'import * as config from './config'
export default defineConfig({ config, files: { providers: { private: localFiles({ dir: './data/files/private' }), public: localFiles({ dir: './data/files/public', public: true }), archive: localFiles({ dir: '/mnt/archive', retention: '30 days' }), }, retention: '1 day', // Makes the image variants and placeholders sharp, },})localFiles({ dir, public?, publicUrl?, retention? })keeps files in a folder on local disk. Writes go to a temporary file first and are renamed into place, so nobody reads half a file. A full disk or quota answers507.s3Files({ bucket, prefix?, endpoint?, region?, public?, publicUrl?, retention? })keeps them in an S3-compatible bucket, see S3-compatible buckets.- Without config,
privateandpublicare the foldersdata/files/privateanddata/files/public, created when needed. Any other provider a field names must be configured. PROTOBASE_FILES_<NAME>configures a provider from the environment and wins over the config for that name:PROTOBASE_FILES_PRIVATE=file:///data/files/private, orfile:///data/files/public?public=1&public_url=https://cdn.example.com/app&retention=0, or ans3://URL.PROTOBASE_FILES_RETENTIONoverridesfiles.retention.- Uploads and download links are signed with
files.secret, elsePROTOBASE_FILES_SECRET, elseBETTER_AUTH_SECRET. The server does not start with file fields and none of them set.
Limits
Section titled “Limits”maxBytes ('1 GB', as for .maxSize()) and maxFiles cap what one provider holds, for either kind; in a URL they are max_bytes=1GB&max_files=20000. An upload that would pass either is refused with 507, and one that passes maxBytes while it streams stops there and leaves nothing. Scheduled deletes (.cleanup/) do not count, so a full provider can still be emptied.
A bucket has no quota of its own, so the provider counts what it holds with list on the first upload, and again once that count is five minutes old; in between it adds its own uploads. Several instances, or files added by hand, can therefore pass a limit by what they wrote since the last count.
Writing files from a seed
Section titled “Writing files from a seed”A seed, an import or another script that writes files outside a request uses storeFile from @protobase/server, so the same script fills the folder locally and the bucket where the app has one. It finds the provider as the server does, from env and the config’s files, when that declares providers:
import { storeFile } from '@protobase/server'
// A row then holds public:images/test.pngawait storeFile({ provider: 'public', path: 'images/test.png', body: bytes, env: process.env })body is bytes or a stream. A relative folder, such as the default data/files/public, is taken from the working directory, so run the script where the server runs. A script that makes variants stores each at variantPath(path, name, extension) the same way.
S3-compatible buckets
Section titled “S3-compatible buckets”s3Files stores files in any bucket with the S3 API, such as Cloudflare R2, Scaleway Object Storage or AWS S3, under prefix when given, so one bucket can hold several providers or apps:
// Cloudflare R2, in the EU jurisdictionprivate: s3Files({ bucket: 'acme-files', prefix: 'private', endpoint: 'https://<account id>.eu.r2.cloudflarestorage.com', region: 'auto' }),public: s3Files({ bucket: 'acme-files', prefix: 'public', endpoint: 'https://<account id>.eu.r2.cloudflarestorage.com', region: 'auto', publicUrl: 'https://files.acme.com' }),// Scaleway, in Parisarchive: s3Files({ bucket: 'acme-archive', endpoint: 'https://s3.fr-par.scw.cloud', region: 'fr-par', retention: '30 days' }),Or from the environment, the same providers:
PROTOBASE_FILES_PRIVATE=s3://acme-files/private?endpoint=https://<account id>.eu.r2.cloudflarestorage.com®ion=autoPROTOBASE_FILES_PUBLIC=s3://acme-files/public?endpoint=https://<account id>.eu.r2.cloudflarestorage.com®ion=auto&public_url=https://files.acme.comPROTOBASE_FILES_ARCHIVE=s3://acme-archive?endpoint=https://s3.fr-par.scw.cloud®ion=fr-par&retention=30d- Credentials never go in the URL. A provider set from the environment reads
PROTOBASE_FILES_<NAME>_ACCESS_KEY_IDandPROTOBASE_FILES_<NAME>_SECRET_ACCESS_KEY, elseS3_ACCESS_KEY_IDandS3_SECRET_ACCESS_KEY, elseAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY, and the server does not start without a pair. In the config, passaccessKeyIdandsecretAccessKey, or leave them out for theS3_orAWS_pair. The key needs to read, write, list and delete objects in the bucket. - Uploads still go through Protobase’s upload route, so types are checked and processors run; the body streams on to the bucket in parts, and an upload that fails stores nothing. Each object is stored with the type and disposition its path is served as. A bucket keeps the parts of an upload that never finished until they are aborted: R2 aborts them after 7 days, on Scaleway and AWS add a lifecycle rule that aborts incomplete multipart uploads after a day.
- Private files keep their signed
url. A download checks the link and the row as above, then redirects with302to a presigned link to the bucket that lasts as long as the app’s link, served with the same type and disposition. The bytes never pass through the app. - Public files are linked at
publicUrlfollowed by the prefix and the path, such ashttps://files.acme.com/public/_/2992db9c-….png: the bucket’s own public address, an R2 custom domain or a Scaleway public bucket. WithoutpublicUrl,/api/files/<provider>/<path>redirects to a presigned link instead. - The bucket serves its files itself, so the
nosniffand sandbox headers of the app’s own downloads do not apply. The type always comes from the path’s extension, never HTML, and the bucket is on another domain than the app. A page thatfetches file URLs, rather than showing them in<img>or linking them, needs CORS on the bucket. - It runs on Bun, whose built-in S3 client it uses:
protobase-serve.js, theprotobaseimage, or the CLI started with Bun. On Node the first file operation fails.
Uploading
Section titled “Uploading”Uploads go through Protobase’s own route, so the type is checked and processors run on the bytes:
POST /api/v1/{resource}:upload?field=photo&name=bike.pngwith the file as the body. Amultipart/form-databody with exactly one file part works too, using that part’s name and type; it is read whole, so the raw body is better for large files. The caller needscreateorupdateon the resource and write access to the field.- The server reads the first bytes, decides the type and refuses a type the field does not allow with
415, before anything is stored. AContent-LengthovermaxSizegets413before a byte is read, and a body that turns out larger stops there with413. - The rest streams to the provider while it is counted. The answer is
201 { value, file: { name, type, size }, derived, corrected? }. - Send
valueas the field’s value in a create, update or batch write within 24 hours. It is a ticket, signed for that user, tenant, resource and field: the write stores the clean URI and the derived values in the same transaction.
A write takes only a ticket, null to clear the field, or the stored uri unchanged, and in a field with .externalUrls() an https URL. Anything else is refused with 400, so a row can only point at a file its writer uploaded for that field, or at a URL elsewhere where the field allows one. From the client, client.upload(resource, field, file, { onProgress }) does step 1; see Client.
How the type is decided
Section titled “How the type is decided”An upload carries three hints: what the content is (C, from its magic bytes), what the name’s extension says (E), and what the client declared (H, the request’s or the file part’s Content-Type; application/octet-stream counts as none).
| Content | Type used | When the hints disagree |
|---|---|---|
| Recognized: images, PDF, archives, Office files, audio, video, executables, … | C | C wins and the answer reports corrected: { from, to }: a JPEG sent as photo.png is image/jpeg, and an executable named invoice.pdf is refused by accept: ['application/pdf']. |
| Not recognized, and text (UTF-8 without NUL bytes) | E when it is a text type (csv, tsv, txt, md, json); H when there is no extension; else text/plain |
E wins over H: HTML named .csv is stored as text/csv. HTML and SVG are never taken as images. |
| Not recognized, and binary | E, only when accept lists that type exactly and H does not disagree; else application/octet-stream |
Any disagreement gives application/octet-stream, which passes only an accept that names it. |
The decided type picks the stored extension, and downloads are served by that extension, so nothing posing as a PDF is ever served as something else, and nothing is served as HTML.
Downloads
Section titled “Downloads”GET /api/files/<provider>/<path> on the app’s own host:
- Private files need the signed
urlfrom a response, made only for a caller who may read that row and field. It needs no token, so it works in an<img src>, and it is valid one to two hours (the same URL within the hour, so browsers cache it). Each download checks that the row still holds the file and that the file sits under that row’s tenant: a replaced or cleared file stops downloading at once, and a URI copied into another tenant’s row by SQL is never served. - Public files are served to anyone at a permanent address,
/api/files/public/<path>, cached for a year.publicUrllinks them from elsewhere instead, such as a CDN in front of that route. - Every download is
nosniffwithContent-Security-Policy: sandbox; default-src 'none'. Raster images, PDF, audio and video areinline; everything else, SVG included, is an attachment. A private file is named as its uploader named it, with the extension matched to the type (photo.pngholding a JPEG downloads asphoto.jpg); a public file by its stored path.Rangerequests work. - A provider in a bucket answers with a redirect to the bucket instead, after the same checks, see S3-compatible buckets.
Derived fields
Section titled “Derived fields”.derive({ field: processor }) computes values from each upload into other fields of the same resource. Those fields must be readOnly(), so only the upload sets them.
| Processor | Gives |
|---|---|
imageSize('width'), imageSize('height') |
an image’s size in pixels as shown (EXIF rotation applied), for an integer field |
aspectRatio({ decimals? }) |
width divided by height as decimal text, 4 places by default, for a decimal field |
fileSize() |
the size in bytes, for an integer field to sort or filter on |
fileType() |
the detected type, for a text field |
processor({ accepts?, run }) |
your own: run(file) gets { name, type, size, head, read() }, where head is the first 256 KB and read() streams the whole file |
Processors run once, on the upload route, after the file is stored. A processor whose accepts does not match the type leaves its field null, so a field that takes images and PDFs gets a width only for images. The values travel signed inside the ticket and are written with the file in the same transaction; replacing the file recomputes them and clearing it sets them to null. A value the field’s own schema refuses is an error in the processor. Files stored before a processor was added keep their fields as they are.
Image variants and placeholders
Section titled “Image variants and placeholders”.placeholder() and .variants() process each image as it is uploaded, with sharp:
photo: f.file().accept(['image/*']).public() .placeholder('photo_placeholder') .variants({ card: { width: 800, height: 600, fit: 'cover', format: 'webp', quality: 75 }, thumb: { width: 96, height: 96, fit: 'cover', format: 'webp' }, }),photo_placeholder: f.text().readOnly().optional(),.placeholder('photo_placeholder')computes the image’s ThumbHash, about 30 characters of base64 that hold its shape and colours, into that read-only text field, written with the file like a derived field. The admin app draws it blurred wherever it shows the image, until the image has loaded..variants({ name: variant })stores resized copies beside the file, each at mostwidthbyheightpixels (either may be left out) and never enlarged.fit: 'inside', the default, keeps the whole image in that box;'cover'fills the box and crops the rest, and needs both sides.formatiswebp,avif,jpegorpng, andquality1 to 100, the format’s default when left out. Names are snake_case. A variant is stored at the file’s path followed by its name and extension (…/2992db9c-….jpg.card.webp) with the same provider, served and deleted with the file. The admin app shows tables, forms and cards with the smallest variant large enough for them.{ original: variant }, as.variants()’ second argument, stores that image instead of the upload, so the upload is not kept:{ original: { width: 2560, format: 'webp' } }stores at most 2560 pixels wide as WebP.
Variants and placeholders turn the image upright first, following its EXIF orientation, and carry no metadata. JPEG, PNG, WebP, AVIF, GIF and TIFF images are processed; any other file, HEIC and SVG included, is stored as it is, without variants, and its placeholder is null. Files uploaded before a field declared them keep none.
sharp is a native package, so the config passes it in, and a built bundle carries it with its add-on for linux:
import sharp from 'sharp'
export default defineConfig({ config, files: { sharp } })The server does not start with a field that declares variants or a placeholder and no files.sharp. In code, fileUrl(file, 'card') from @protobase/client links a variant by name, or the file itself when it has none.
External URLs
Section titled “External URLs”.externalUrls() lets a file field hold the https URL of a file another site serves, instead of an upload: an import that links a supplier’s product photos, say, rather than copying them into the app’s storage.
photo: f.file().accept(['image/*']).public().externalUrls() .placeholder('photo_placeholder') .variants({ card: { width: 800, height: 600, fit: 'cover', format: 'webp' } }),curl -X PATCH https://app.example.com/api/v1/products/42 -H "authorization: Bearer $TOKEN" -H 'if-match: *' \ -H 'content-type: application/json' \ -d '{ "photo": "https://images.example.com/products/42/front.jpg" }'- What is taken: an absolute
https://URL with a host, without a user name or password, written without spaces or other characters a URL escapes.http:,data:and anything else is refused with400, as in a field without the option. - The type is the URL path’s extension, from the same table as stored files, and must pass
.accept(): an image field takes…/front.jpgand refuses…/frontand…/scan.pdf. The name is the path’s last segment. - Stored as given: the column holds the URL itself, and Protobase never fetches, uploads or copies it. It has no size, and responses link it as it is, as
url, for whoever may read the row. - No variants: every place an image is drawn, cards, tables, thumbnails, forms and the record page, uses the URL itself, and
fileUrl(file, 'card')gives it too. Size the image at its source when it should be small. - No ThumbHash: a write through the API sets the field’s derived fields and placeholder to
null, since there are no bytes to compute them from; the image frame stays empty until the image loads. An import that writes the rows itself may write a placeholder beside the URL, and the admin app draws that one. - Nothing to delete: replacing an upload with a URL schedules the upload’s delete as any replacement does; a URL that is replaced or cleared schedules nothing.
- The other site sees each viewer’s IP address when it serves the image, and decides when it changes or disappears. The admin app draws it without a referrer, see Image referrers.
Image referrers
Section titled “Image referrers”The admin app draws every image of a file field with referrerPolicy="no-referrer", so another site never learns which page showed its image, unless the image comes from a host it trusts:
- the page’s own origin, which serves the app’s downloads and the public files without
publicUrl; - the host of each provider’s
publicUrl, such as a bucket’s custom domain on another domain than the app; - the hosts of the app’s sources;
- the hosts
files.trustedHostsadds, as lowercase host names without a scheme, port or path:
export default defineConfig({ config, files: { trustedHosts: ['images.example.com'] },})A trusted host is matched exactly: images.example.com does not trust cdn.images.example.com. /meta names the trusted hosts besides the page’s own as files.trustedHosts. Download links are opened with rel="noreferrer" wherever they point.
Deleting files
Section titled “Deleting files”Files are deleted by schedule, never in the write itself, so a restore of the database to an earlier moment still finds its files:
- A write that replaces or clears a file, or deletes a row (not a soft delete), schedules the delete of each file it took out of its row, with its variants. It is due after the provider’s retention:
files.retention, 1 day by default, or 0 for a public provider. - Every upload schedules one too, due an hour after its ticket expires, so a file nobody saved, or whose write rolled back, goes as well.
- Each scheduled delete is a small JSON object in the provider’s own storage, in a
.cleanup/folder, named after its due time and saying what to delete:.cleanup/2026-10-12T03-00-00Z-<uuid>.jsonholding{ "provider": "private", "path": "1/2992db9c-….jpg", "due": "…" }. - A cleanup run handles the entries that are due. Right before deleting, it checks every file column of every resource for the file: one that a row holds again (a rolled-back write, a restore, a copied value) is kept, and its entry dropped. Two runs at once do no harm.
A run happens:
- in
protobase serve, every 60 minutes (PROTOBASE_FILES_CLEANUP_MINUTES, 0 turns it off); - with
protobase files cleanup, from cron or a timer; - with
POST /api/files:cleanupand an admin’s token, for a hosting platform. It answers{ deleted, kept }.
The scheduled deletes sit behind a small interface (CleanupSchedule: add, due, done), so a table in a schema of its own, added by a migration at update time, can replace the folder later.
What this does not cover: a write made with SQL outside the API schedules nothing, so a file whose row SQL changed or deleted stays in storage. The check before deleting looks only at the file fields the config declares; a column holding file URIs that no resource declares is not protected.