Store Files
await ctx.files.put(`avatars/${ctx.user.id}.png`, upload, { contentType: 'image/png' });
const avatar = await ctx.files.get(`avatars/${ctx.user.id}.png`); // a Blob with .metadata, or undefined
const link = await ctx.files.url(`avatars/${ctx.user.id}.png`, { expiresIn: 3600 });
await ctx.files.delete(`avatars/${ctx.user.id}.png`);- A key is a relative path of plain segments:
avatars/42.png, never../xor/x..webstir/is reserved: Webstir keeps local files' media types there. puttakes a string, bytes, a Blob, or a file from a form's file field.urlmakes a link that works forexpiresInseconds (an hour by default), for an<img>or a download.- Files come from your users, so a local file's link never runs as your app: images, audio, video, PDFs and plain text show in the browser, anything else (HTML and SVG included) downloads, and all of them are served under a sandbox policy.
- The media type given to
put(or the Blob's own) is kept, sogetand the link report it. - Outside a handler:
import { files } from '@webstir-io/webstir-backend/files'.
Private documents
Files that must never be replaced or half-read, such as signed versions of a document, have three more options:
await ctx.files.put(`proposals/${id}/v3.json`, json, {
ifAbsent: true, // write once: a file already at the key throws FileExistsError
metadata: { 'uploaded-by': ctx.user.id }, // short text kept with the file
});
const version = await ctx.files.get(`proposals/${id}/v3.json`, { maxBytes: 1_000_000 });
version?.metadata['uploaded-by'];ifAbsentwrites only when no file has the key, in one step, so two writers can't both win.metadatacomes back ongetasfile.metadata.maxBytesrefuses a larger file withFileTooLargeErrorinstead of loading it.- On S3, every write carries a SHA-256 checksum that S3 checks, and reads are checked against it. Each request gives up after 10 seconds, and the ones that may be retried are, three times.
Where they go
STORAGE_URL picks it:
STORAGE_URL |
Storage |
|---|---|
unset, or file:./data/files |
a folder beside the app; links are served by the app, signed with SESSION_SECRET |
s3://bucket or s3://bucket/prefix |
S3, Cloudflare R2, MinIO or any S3 service, through the AWS SDK; links are presigned |
For S3, set S3_REGION (or AWS_REGION), and S3_ENDPOINT for a service other than AWS. Credentials are S3_ACCESS_KEY_ID and S3_SECRET_ACCESS_KEY when set; otherwise the SDK finds them its usual way: the AWS_* variables, a profile named by S3_PROFILE or AWS_PROFILE (with AWS_SHARED_CREDENTIALS_FILE for a file elsewhere), or the instance's role. S3_PROFILE gives storage its own profile when the app's other AWS calls, such as sending email, use another. Each request fails after S3_TIMEOUT_MS (ten seconds) and, where safe, is retried, up to three attempts.
Your own storage client
When neither fits, set a store in src/backend/index.ts, before the server starts. ctx.files then uses it, with the same key rule:
import { setFileStore } from '@webstir-io/webstir-backend/files';
setFileStore({
async put(key, data, options) { /* write it, with options?.contentType, ifAbsent and metadata */ },
async get(key, options) { /* a Blob with metadata, or undefined; options?.maxBytes */ },
async url(key, { expiresIn }) { /* a link that works for expiresIn seconds */ },
async delete(key) { /* remove it */ },
});Files from a form
A form with enctype="multipart/form-data" gives its file fields as File objects in ctx.body:
const photo = (ctx.body as Record<string, unknown>).photo;
if (photo instanceof File && photo.size > 0) {
await ctx.files.put(`photos/${crypto.randomUUID()}`, photo);
}REQUEST_BODY_MAX_BYTES (1 MB by default) limits a request; raise it for large uploads.