Direct-to-storage uploads
Mint a presigned upload in a route, hand it to the browser, and let the bytes travel straight to S3 or R2 while your server handles only the metadata.
The ordinary path is server-mediated. The browser posts to
POST {basePath}/:collection/upload, QUESTPIE validates and streams the bytes
into the backend, then writes the row. Every byte crosses your server. For a
video or a large archive that is the cost you want to remove.
Mint the upload
There is no built-in presign route, because the key and the size cap are
decisions only your application can make. Write a route
that calls signedUploadUrl on the storage handle.
import { route } from "questpie/services";
import { z } from "zod";
export default route()
.post()
.schema(z.object({ contentType: z.string() }))
.access(({ session }) => !!session?.user) // routes are public by default
.handler(async ({ input, storage }) => {
const key = crypto.randomUUID(); // never sign a key the caller sent
return {
key,
upload: await storage.signedUploadUrl(key, {
expiresIn: 600, // required, seconds
contentType: input.contentType, // bound into the signature
maxSize: 200_000_000, // enforced by the provider, see below
}),
};
});That .access() line is load-bearing. A route with no rule is reachable by
anyone, the inverse of a collection, and an open presign endpoint hands the
internet write access to your bucket.
The key matters as much. signedUploadUrl passes it to the provider verbatim,
so a route that signs whatever key it was handed lets any signed-in caller name
someone else's key and overwrite the bytes under their row. Generate it.
The browser then sends the file to the upload that comes back.
| Field | Type | Notes |
|---|---|---|
expiresIn | number, required | Seconds the URL stays usable. |
contentType | string | Adapters that cannot bind it throw, not warn. |
maxSize | number | Bytes. Switches S3 and R2 to a presigned POST. |
minSize | number, default 1 | Pass 0 to allow empty objects. |
The result is a SignedUpload, one of two shapes. { method: "PUT", url, headers? } is a plain PUT of the file body. { method: "POST", url, fields }
is a multipart form: send every entry of fields first, then the file.
Always pass `maxSize`
Without it, a supporting adapter falls back to a presigned PUT carrying no
size limit at all, so anyone holding the URL can push an object of any size
until expiresIn elapses. With it, S3 and R2 sign a POST policy whose
content-length-range the provider enforces.
Then write the row
A presigned upload puts bytes in the bucket and nothing in your database. The row is still yours to create, with the key you signed.
await app.collections.media.create(
{
key,
filename: "clip.mp4",
mimeType: "video/mp4",
size,
visibility: "private",
},
ctx,
);key, filename, mimeType, size and visibility are the columns
.upload() added, and the first four are nullable, so a row may exist before
its bytes. The write path checks that the object is really there. afterChange
calls storage.exists(key) on create, or whenever the key changes, and rejects
the write when the object is missing. So finalize after the client reports
success, not before.
`create()` does not apply the collection's visibility
collection.upload() writes visibility from .upload({visibility}).
create() does not, and the column defaults to "public", so a private
collection finalized this way hands out unsigned URLs unless you pass
visibility yourself.
This path skips the collection's own validation
maxSize and allowedTypes on .upload() are enforced by
collection.upload(), along with the blocked-extension list. A presigned
upload never runs that code. The signed policy is your only server-side gate,
which is why maxSize and contentType belong in it.
The local adapter cannot really sign
signedUploadUrl is part of the Files SDK Adapter contract, so every adapter
answers it, but fs() has no signing primitive. It returns a URL carrying an
expires query string that nothing enforces. Treat direct uploads as an
object-storage feature and keep development on the server-mediated route.
Serving untrusted bytes back
QUESTPIE's own file route already sets X-Content-Type-Options: nosniff, sends
Content-Security-Policy: sandbox and forces Content-Disposition: attachment
for HTML, XHTML and SVG. A provider URL has none of that. When you hand out
app.storage.url(key) for user-uploaded content, pass
responseContentDisposition: "attachment" so the browser downloads the file
instead of executing it on your origin.
Related
- Storage, adapters, configuration and the
Fileshandle. - Upload collections, the columns and the lifecycle a row goes through.
- Routes, the builder used above.
- Client SDK, the server-mediated upload with progress.