Uploads
A browser sends a file to the project's storage, never through your machine, in one of two ways, and your backend decides in both. With a signed upload URL, your backend asks storage for a grant of one object (its path, a size ceiling, the content types the bucket takes, the user it will belong to) and the browser PUTs the bytes to it. With an upload route of your backend, the app's call sends the file and storage asks the machine first (An upload route). A publishable key alone cannot write, list or delete objects: the grant, or the route's yes, always comes from your backend.
Quick example
The backend half, a route that grants the signed-in user one upload into the avatars bucket:
// server/src/uploads/uploads.controller.ts
import { Body, Controller, Palbase, Post, User, z, type UserT } from "palbase/backend";
const UploadRequest = z.object({ name: z.string().min(1).max(200) });
type UploadRequest = z.infer<typeof UploadRequest>;
@Controller("/uploads")
export class UploadsController {
@Post("/avatar")
async avatar(@Body(UploadRequest) body: UploadRequest, @User() user: UserT): Promise<{ url: string; path: string }> {
const path = `${user.id}/${crypto.randomUUID()}-${body.name}`;
const grant = await Palbase.storage.createUploadUrl({
path: { bucket: "avatars" },
// Ten minutes, 5 MB at most, and the object is the user's: it goes when they are deleted.
body: { path, expires_in: 600, max_bytes: 5_000_000, owner_id: user.id },
});
return { url: grant.data.url, path };
}
}
// server/src/app.module.ts
import { Module } from "palbase/backend";
import { UploadsController } from "./uploads/uploads.controller";
@Module({ controllers: [UploadsController] })
export class AppModule {}
The browser half:
// src/lib/palbase.ts
import { createClient } from "palbase/web";
export const pb = createClient({ url: "https://k3xq81w4m.palbase.studio", publishableKey: "pb_k3xq81w4m_cZ8yX7wV6uT5" });
// src/upload.ts
import { pb } from "./lib/palbase";
const BACKEND = "https://k3xq81w4m.app.palbase.studio";
interface StoredObject {
bucket: string;
path: string;
size: number;
content_type: string;
}
export async function uploadAvatar(file: File): Promise<StoredObject> {
const token = (await pb.auth.currentSession())?.accessToken ?? "";
const asked = await fetch(`${BACKEND}/uploads/avatar`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ name: file.name }),
});
if (!asked.ok) throw new Error(`the backend refused the upload: ${asked.status}`);
const { url } = (await asked.json()) as { url: string };
// The URL is at the project's address: send the bytes to it as it is.
const res = await fetch(url, {
method: "PUT",
headers: { "content-type": file.type || "application/octet-stream" },
body: file,
});
if (!res.ok) throw new Error(`storage refused the upload: ${res.status}`);
return (await res.json()) as StoredObject;
}
An upload route
A route your palbase/backend app declares with @Upload, naming its bucket and a path template ({userId}/{uploadId}-{filename}), takes the file itself. The app calls it as any route, the file after its input, and the call goes to the project's storage in one request (POST /storage/upload/<machine>/<route>), with the user's token when someone is signed in. Storage asks the machine whether this caller may upload, writes the file where the template says (held to the bucket's size and types), then runs the route's handler with the stored object. What the handler answers is the call's answer; a refusal deletes the object. The app asks for no grant first, and the path is never the browser's to choose. The backend's side, its options and storage's steps are in Upload routes.
The backend half:
// server/src/profile/profile.controller.ts
import { Controller, Upload, UploadedObject, User, type UserT } from "palbase/backend";
@Controller("/profile")
export class ProfileController {
// Into the avatars bucket, under a folder of the user's.
@Upload("/avatar", { bucket: "avatars", pathTemplate: "{userId}/{uploadId}-{filename}" })
avatar(@UploadedObject() file: UploadedObject, @User() user: UserT): { path: string; size: number; contentType: string } {
console.log(`${user.id} uploaded ${file.size} bytes`);
return { path: file.path, size: file.size, contentType: file.contentType };
}
}
The browser half, with what palbase apps:sync writes for that route:
// palbase/shop-web.ts
// Code generated by palbase apps:sync — DO NOT EDIT. (Cut to this page's route.)
import { createClient, type BackendBase, type PalbaseClient, type UploadOptions } from "palbase/web";
import { __registerBackend } from "palbase/web/palbe";
export interface ProfileAvatarResponse {
path: string;
size: number;
contentType: string;
}
__registerBackend("https://k3xq81w4m.palbase.studio", "backend", "backend", "https://k3xq81w4m.app.palbase.studio", {
profile: {
avatar: { method: "POST", path: "/profile/avatar", input: "none", upload: true },
},
});
export interface Backend extends BackendBase {
profile: {
avatar(file: Blob, options?: UploadOptions): Promise<ProfileAvatarResponse>;
};
}
export const pb = createClient({ url: "https://k3xq81w4m.palbase.studio", publishableKey: "pb_k3xq81w4m_cZ8yX7wV6uT5" }) as PalbaseClient & { backend: Backend };
// src/avatar.ts
import { isBackendError } from "palbase/web";
import { pb } from "../palbase/shop-web";
export async function changeAvatar(file: File, bar: HTMLProgressElement): Promise<string> {
try {
// Told as the bytes go out, and once more when the answer is in.
const stored = await pb.backend.profile.avatar(file, { onProgress: ({ loaded, total }) => { bar.value = loaded / total; } });
return `saved as ${stored.path}`;
} catch (e) {
if (isBackendError(e)) return `not saved: ${e.code}`;
throw e;
}
}
| Argument | What it is |
|---|---|
| path parameters, input | As the route takes them (Calling Your Backend). The input goes as JSON beside the file. |
file | A Blob; a File is one, and its name is the template's {filename}. |
onProgress | Told { loaded, total } as the bytes go out, through XMLHttpRequest where the page has one. |
signal | An AbortSignal that cancels the upload. |
An upload is never sent again on its own: a lost answer can follow a file that was kept and a handler that ran. Its refusals reject with a BackendError, the route's own and storage's alike (413 past the bucket's size, 415 a type it does not take).
The grant
POST /storage/buckets/{bucket}/upload-urls takes the secret key, so it is made by your backend (Palbase.storage.createUploadUrl on a machine, createServer(…).storage.createUploadUrl anywhere else):
| Field | What it pins |
|---|---|
path | The object's path in the bucket. Your code chooses it: put the user's id in it, never a name the browser chose alone. |
expires_in | Seconds the URL works, 1 to 604800 (7 days). |
max_bytes | A ceiling below the bucket's own. |
owner_id | The user the object belongs to. It is deleted with them. |
The content types are the bucket's. A bucket declared to take image/* refuses a PDF whatever the grant says. Declare buckets in Studio's Storage screens, or with Palbase.storage.putBucket from a server. See Storage.
What comes back is { url, expires_at }. The URL is https://<ref>.palbase.studio/storage/signed/{bucket}/{path}?…, and every parameter in its query is covered by its signature: change one and storage answers 403. The object's path is one percent-encoded segment (a/b.jpg is a%2Fb.jpg), so pass the URL on as it is.
Sending the bytes
PUT /storage/signed/{bucket}/{path} needs no key: the signature is the credential. A PUT with a file's content type is preflighted by the browser, and the project's address answers the preflight from a page on any site, so the browser sends the bytes to the URL as it is. See Overview.
| Header | Effect |
|---|---|
content-type | What the bytes are. Storage also sniffs the type from the bytes and keeps that one. |
if-none-match: * | Create only: an object already at the path is refused with 412. |
x-checksum-sha256 | The bytes' SHA-256 in hex: bytes that do not match are refused and nothing is kept. |
The answer is the stored object: 201 when it was created, 200 when it replaced one. It is { bucket, path, size, content_type, checksum_sha256, owner_id?, width?, height?, thumbhash?, variants, created_at, updated_at }, with a picture's size, its placeholder (ThumbHash) and the renditions the bucket made of it. An upload emits file.uploaded to the project's webhook, so your backend can react to it.
Progress
fetch reports no upload progress. XMLHttpRequest does:
// src/progress.ts
export function putWithProgress(url: string, file: File, onProgress: (fraction: number) => void): Promise<unknown> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("content-type", file.type || "application/octet-stream");
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) onProgress(e.loaded / e.total);
};
xhr.onload = () => (xhr.status === 200 || xhr.status === 201 ? resolve(JSON.parse(xhr.responseText)) : reject(new Error(`storage answered ${xhr.status}`)));
xhr.onerror = () => reject(new Error("the upload did not reach storage"));
xhr.send(file);
});
}
Cancel with xhr.abort(), or with an AbortController's signal on fetch. Nothing is kept of an upload that did not finish.
What can go wrong
| Status | Why |
|---|---|
| 403 | The signature does not hold: expired, edited, another project's, or a download grant used for an upload. Ask the backend for a new one. |
| 412 | if-none-match: * and an object is already at the path. |
| 413 | Larger than the grant's max_bytes or the bucket's ceiling. |
| 415 | A content type the bucket does not take. |
400 checksum_mismatch | The bytes did not match x-checksum-sha256. Nothing was kept. |
422 unreadable_picture | A picture storage cannot decode: truncated, or a format it does not read. |
| 429 | The project's storage quota or request rate. Honour Retry-After. |
Check the size and type in the browser before you ask for a grant, so the user is told at once, but the grant and the bucket are what storage enforces.
Showing what was uploaded
Reading needs no key either:
- A public bucket:
GET /storage/public/{bucket}/{path}, with?variant=<name>for a rendition. Put it in an<img src>straight from the project's address: an image load is not preflighted. - A private bucket: your backend signs a download URL,
Palbase.storage.createSignedUrl({ path: { bucket }, body: { path, expires_in } }), which answers atGET /storage/signed/{bucket}/{path}. The same applies: an<img src>loads it, and afetchfrom the page reads its bytes.
Related
- Storage — buckets, objects, signed URLs and renditions from the server
- Direct Uploads — the grant and upload routes, in full
- Calling Your Backend — the generated module, and the call that asks for the grant
- Overview — what the project's address answers a browser