QUESTPIE

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.

View markdown

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.

src/questpie/server/routes/sign-upload.post.ts
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.

FieldTypeNotes
expiresInnumber, requiredSeconds the URL stays usable.
contentTypestringAdapters that cannot bind it throw, not warn.
maxSizenumberBytes. Switches S3 and R2 to a presigned POST.
minSizenumber, default 1Pass 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.

  • Storage, adapters, configuration and the Files handle.
  • Upload collections, the columns and the lifecycle a row goes through.
  • Routes, the builder used above.
  • Client SDK, the server-mediated upload with progress.

On this page