-
Notifications
You must be signed in to change notification settings - Fork 35
Document presigned storage upload flow and endpoints #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -13,46 +13,173 @@ urBackend handles file and image uploads for you. Upload a file and receive a pu | |||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ## Upload a file | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Send a `POST` request with a `multipart/form-data` body containing the file. | ||||||||||||||||||||||||||||||||||||
| Uploads use a presigned URL three-step flow so the binary is sent directly to storage. | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| **Endpoint:** `POST /api/storage/upload` | ||||||||||||||||||||||||||||||||||||
| ### Step 1 — Request an upload URL | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ```javascript | ||||||||||||||||||||||||||||||||||||
| const formData = new FormData(); | ||||||||||||||||||||||||||||||||||||
| formData.append('file', fileInput.files[0]); | ||||||||||||||||||||||||||||||||||||
| **Endpoint:** `POST /api/storage/upload-request` | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const res = await fetch('https://api.ub.bitbros.in/api/storage/upload', { | ||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||
| **Request body (size in bytes):** | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ```json | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| "filename": "avatar.png", | ||||||||||||||||||||||||||||||||||||
| "contentType": "image/png", | ||||||||||||||||||||||||||||||||||||
| "size": 123456 | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| **Response:** | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ```json | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| "signedUrl": "https://storage.example.com/...", | ||||||||||||||||||||||||||||||||||||
| "token": "optional-provider-token", | ||||||||||||||||||||||||||||||||||||
| "filePath": "PROJECT_ID/550e8400-e29b-41d4-a716-446655440000_avatar.png" | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| | Field | Description | | ||||||||||||||||||||||||||||||||||||
| | --- | --- | | ||||||||||||||||||||||||||||||||||||
| | `signedUrl` | Presigned URL to upload the binary directly to storage | | ||||||||||||||||||||||||||||||||||||
| | `token` | Provider-specific token (only returned for some external providers) | | ||||||||||||||||||||||||||||||||||||
| | `filePath` | Opaque storage path (format: `projectId/uuid_sanitizedFilename`) — save this to confirm or delete the file later | | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| <Note> | ||||||||||||||||||||||||||||||||||||
| This endpoint enforces the 10 MB max file size and checks available headroom against your plan's project storage quota before the upload is confirmed. On the free tier, the default project storage limit is 20 MB. | ||||||||||||||||||||||||||||||||||||
| </Note> | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ### Step 2 — Upload the binary to storage | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| Send a `PUT` request to `signedUrl` with the raw file contents and the correct `Content-Type` header. | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ```javascript | ||||||||||||||||||||||||||||||||||||
| const uploadRes = await fetch(signedUrl, { | ||||||||||||||||||||||||||||||||||||
| method: 'PUT', | ||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||
| 'x-api-key': 'YOUR_KEY' | ||||||||||||||||||||||||||||||||||||
| 'Content-Type': file.type | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| body: formData | ||||||||||||||||||||||||||||||||||||
| body: file | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const { url, path, provider } = await res.json(); | ||||||||||||||||||||||||||||||||||||
| // Save `path` if you need to delete the file later | ||||||||||||||||||||||||||||||||||||
| if (!uploadRes.ok) { | ||||||||||||||||||||||||||||||||||||
| throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| <Note> | ||||||||||||||||||||||||||||||||||||
| Do **not** set the `Content-Type` header manually. When you pass a `FormData` object, the browser sets the correct `multipart/form-data` boundary automatically. Setting it yourself will break the upload. | ||||||||||||||||||||||||||||||||||||
| </Note> | ||||||||||||||||||||||||||||||||||||
| ### Step 3 — Confirm the upload | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| **Endpoint:** `POST /api/storage/upload-confirm` | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| **Request body:** | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ```json | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| "filePath": "PROJECT_ID/550e8400-e29b-41d4-a716-446655440000_avatar.png", | ||||||||||||||||||||||||||||||||||||
| "size": 123456 | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| **Response:** | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ```json | ||||||||||||||||||||||||||||||||||||
| { | ||||||||||||||||||||||||||||||||||||
| "message": "File uploaded successfully", | ||||||||||||||||||||||||||||||||||||
| "url": "https://xyz.supabase.co/storage/v1/object/public/dev-files/PROJECT_ID/file.png", | ||||||||||||||||||||||||||||||||||||
| "path": "PROJECT_ID/file.png", | ||||||||||||||||||||||||||||||||||||
| "provider": "internal" | ||||||||||||||||||||||||||||||||||||
| "message": "Upload confirmed", | ||||||||||||||||||||||||||||||||||||
| "path": "PROJECT_ID/550e8400-e29b-41d4-a716-446655440000_avatar.png", | ||||||||||||||||||||||||||||||||||||
| "provider": "internal", | ||||||||||||||||||||||||||||||||||||
| "url": "https://xyz.supabase.co/storage/v1/object/public/dev-files/PROJECT_ID/550e8400-e29b-41d4-a716-446655440000_avatar.png" | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
| ``` | ||||||||||||||||||||||||||||||||||||
|
Comment on lines
85
to
92
|
||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| | Field | Description | | ||||||||||||||||||||||||||||||||||||
| | --- | --- | | ||||||||||||||||||||||||||||||||||||
| | `url` | Publicly accessible CDN URL for the file | | ||||||||||||||||||||||||||||||||||||
| | `path` | Storage path — save this to delete the file later | | ||||||||||||||||||||||||||||||||||||
| | `provider` | The underlying storage provider used | | ||||||||||||||||||||||||||||||||||||
| | `message` | Confirmation message | | ||||||||||||||||||||||||||||||||||||
| | `path` | Normalized storage path — use this to delete the file later | | ||||||||||||||||||||||||||||||||||||
| | `provider` | Storage backend used (`internal` or `external`) | | ||||||||||||||||||||||||||||||||||||
| | `url` | Public URL for the file. If unavailable, this is `null`. | | ||||||||||||||||||||||||||||||||||||
| | `warning` | Optional warning message when a public URL is unavailable | | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| If the storage provider does not expose a public URL (for example, some external S3/R2 setups), the response returns `url: null` and includes a `warning` field explaining that a public URL is not available. | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| <Note> | ||||||||||||||||||||||||||||||||||||
| The confirm step verifies the object exists, checks the size matches, and then charges quota atomically. | ||||||||||||||||||||||||||||||||||||
| </Note> | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ### Full browser example | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| ```javascript | ||||||||||||||||||||||||||||||||||||
| if (!fileInput?.files?.length) { | ||||||||||||||||||||||||||||||||||||
| throw new Error('No file selected'); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const file = fileInput.files[0]; | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const requestRes = await fetch('https://api.ub.bitbros.in/api/storage/upload-request', { | ||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||||||||||||||||||||
| 'x-api-key': 'YOUR_KEY' | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| body: JSON.stringify({ | ||||||||||||||||||||||||||||||||||||
| filename: file.name, | ||||||||||||||||||||||||||||||||||||
| contentType: file.type, | ||||||||||||||||||||||||||||||||||||
| size: file.size | ||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (!requestRes.ok) { | ||||||||||||||||||||||||||||||||||||
| const errorBody = await requestRes.json().catch(() => null); | ||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||
| `Upload request failed: ${requestRes.status} ${requestRes.statusText}${ | ||||||||||||||||||||||||||||||||||||
| errorBody?.error ? ` - ${errorBody.error}` : errorBody?.message ? ` - ${errorBody.message}` : '' | ||||||||||||||||||||||||||||||||||||
| }` | ||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const { signedUrl, filePath } = await requestRes.json(); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
Comment on lines
+128
to
+140
|
||||||||||||||||||||||||||||||||||||
| const uploadRes = await fetch(signedUrl, { | ||||||||||||||||||||||||||||||||||||
| method: 'PUT', | ||||||||||||||||||||||||||||||||||||
| headers: { 'Content-Type': file.type }, | ||||||||||||||||||||||||||||||||||||
| body: file | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| if (!uploadRes.ok) { | ||||||||||||||||||||||||||||||||||||
| throw new Error(`Upload failed: ${uploadRes.status} ${uploadRes.statusText}`); | ||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
| const confirmRes = await fetch('https://api.ub.bitbros.in/api/storage/upload-confirm', { | ||||||||||||||||||||||||||||||||||||
| method: 'POST', | ||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||||||||||||||||||||
| 'x-api-key': 'YOUR_KEY' | ||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||
| body: JSON.stringify({ filePath, size: file.size }) | ||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||
| if (!confirmRes.ok) { | |
| if (confirmRes.status === 409) { | |
| const retryableError = await confirmRes.json(); | |
| throw new Error( | |
| `Upload not ready yet: ${retryableError.code || 'UPLOAD_NOT_READY'}. Retry the confirm request shortly.` | |
| ); | |
| } | |
| const errorBody = await confirmRes.json().catch(() => null); | |
| throw new Error( | |
| `Upload confirm failed: ${confirmRes.status} ${confirmRes.statusText}${ | |
| errorBody?.message ? ` - ${errorBody.message}` : '' | |
| }` | |
| ); | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The example
filePathvalue is misleading. The API generates a unique path of the form${projectId}/${uuid}_${sanitizedFilename}(spaces replaced with_), soPROJECT_ID/avatar.pngis not representative. Consider documentingfilePathas an opaque identifier and updating the example to match the actual format.